r2ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. data/lib/r2ui/terminal.rb +0 -53
@@ -0,0 +1,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ # c24-completion: shell completion scripts, generated from the command tree.
4
+ #
5
+ # R2UI.cli "deployer" do
6
+ # completion # adds `deployer completion <bash|zsh|fish>`
7
+ # command :deploy do
8
+ # aliases :d
9
+ # option :env, short: "e", in: %w[staging production]
10
+ # end
11
+ # end
12
+ #
13
+ # `deployer completion bash` (or zsh, fish) prints a static script that completes, at every level
14
+ # of the tree, the commands and their aliases, the options a command accepts (its own and its
15
+ # groups', `--no-x` for default-true flags, `--help`) and the `in:` choices of an option's value
16
+ # (`--env <TAB>`, `-e <TAB>`, `--env=<TAB>`); `:path` options complete files. Hidden commands and
17
+ # options are left out. zsh and fish show each command's summary and each option's `desc:`.
18
+ #
19
+ # eval "$(deployer completion bash)" # in ~/.bashrc
20
+ # eval "$(deployer completion zsh)" # in ~/.zshrc, after compinit
21
+ # deployer completion fish | source # in ~/.config/fish/config.fish
22
+ #
23
+ # `deployer completion --help` shows those lines. The script is plain text on a terminal and off
24
+ # it alike (it's data); on a terminal a hint on stderr says how to load it. An unknown shell is a
25
+ # usage error with a "did you mean".
26
+ module R2UI
27
+ module CLI
28
+ module Ext
29
+ module Completion
30
+ SHELLS = { "bash" => "Bourne Again SHell", "zsh" => "Z shell", "fish" => "Friendly interactive shell" }.freeze
31
+ SETUP = {
32
+ "bash" => ['eval "$(%s completion bash)"', "in ~/.bashrc"],
33
+ "zsh" => ['eval "$(%s completion zsh)"', "in ~/.zshrc, after compinit"],
34
+ "fish" => ["%s completion fish | source", "in ~/.config/fish/config.fish"]
35
+ }.freeze
36
+ HELP = { long: "--help", short: "-h", negation: nil, desc: "Show help", value: false, choices: nil, path: false }.freeze
37
+
38
+ # One command the completion knows: its number in the script, the words that may follow it
39
+ # (subcommands and aliases, or the completion command's shells) and the options it takes.
40
+ Node = Struct.new(:id, :command, :words, :options)
41
+
42
+ # The visible tree as numbered nodes and links ("from word" → to).
43
+ class Tree
44
+ attr_reader :nodes, :links
45
+
46
+ def initialize(program)
47
+ @nodes = []
48
+ @links = []
49
+ add(program)
50
+ end
51
+
52
+ private
53
+
54
+ def add(command)
55
+ node = Node.new(@nodes.size, command, [], options(command))
56
+ @nodes << node
57
+ if command.declared(:completion_command).any?
58
+ node.words = SHELLS.to_a
59
+ return node
60
+ end
61
+
62
+ visible = command.commands.reject(&:hidden)
63
+ visible.each do |sub|
64
+ child = add(sub)
65
+ [sub.name, *sub.aliases].each do |word|
66
+ node.words << [word, sub.summary]
67
+ @links << [node.id, word, child.id]
68
+ end
69
+ end
70
+ # `tool help deploy`: offered at the root only (like cobra), understood in every group.
71
+ if visible.any? && !command.find("help")
72
+ node.words << ["help", "Show help for a command"] if command.root?
73
+ @links << [node.id, "help", node.id]
74
+ end
75
+ node
76
+ end
77
+
78
+ def options(command)
79
+ command.all_options.reject(&:hidden).map do |o|
80
+ negation = o.boolean? && o.default == true ? "--no-#{o.long.delete_prefix("--")}" : nil
81
+ { long: o.long, short: o.short && "-#{o.short}", negation:, desc: o.desc, value: !o.boolean?,
82
+ choices: o.choices&.map(&:to_s), path: o.type == :path }
83
+ end + [HELP]
84
+ end
85
+ end
86
+
87
+ module_function
88
+
89
+ def script(program, kind)
90
+ Script.new(program).public_send(kind)
91
+ end
92
+
93
+ def unknown(kind)
94
+ message = "unknown shell '#{kind}' (expected #{SHELLS.keys[0..-2].join(", ")} or #{SHELLS.keys.last})"
95
+ guess = DidYouMean::SpellChecker.new(dictionary: SHELLS.keys).correct(kind).first if defined?(DidYouMean)
96
+ guess ? "#{message}. Did you mean '#{guess}'?" : message
97
+ end
98
+
99
+ def setup_lines(shell, name)
100
+ rows = SETUP.map { |kind, (line, where)| [kind, format(line, name), where] }
101
+ kind_width = rows.map { |r| r[0].size }.max
102
+ line_width = rows.map { |r| r[1].size }.max
103
+ rows.map do |kind, line, where|
104
+ "#{kind.ljust(kind_width)} #{shell.paint(line, :accent)}#{" " * (line_width - line.size)} #{shell.paint(where, :muted)}"
105
+ end
106
+ end
107
+
108
+ # The three scripts. Every table is a `case` on node numbers, so names never become
109
+ # code; words are single-quoted, and choices also backslash-escaped for the shells that
110
+ # expand them.
111
+ class Script
112
+ def initialize(program)
113
+ @name = program.name
114
+ @id = program.name.gsub(/[^A-Za-z0-9_]/, "_")
115
+ @tree = Tree.new(program)
116
+ end
117
+
118
+ def bash
119
+ <<~BASH
120
+ # bash completion for #{@name}
121
+ # Generated by `#{@name} completion bash`; regenerate it when the tool changes.
122
+ # Load it in this shell: eval "$(#{@name} completion bash)"
123
+ # For every new shell, add that line to ~/.bashrc.
124
+
125
+ #{next_function}
126
+
127
+ #{takes_value_function}
128
+
129
+ __#{@id}_complete_choices() {
130
+ case "$1 $2" in
131
+ #{indent(choice_cases { |words| "REPLY=#{sq(words.map { |w| escape(w) }.join(" "))}" }, 4)}
132
+ *) return 1 ;;
133
+ esac
134
+ }
135
+
136
+ __#{@id}_complete_options() {
137
+ case "$1" in
138
+ #{indent(@tree.nodes.map { |n| "#{n.id}) REPLY=#{sq(option_words(n).join(" "))} ;; # #{n.command.full_name}" }, 4)}
139
+ *) return 1 ;;
140
+ esac
141
+ }
142
+
143
+ __#{@id}_complete_words() {
144
+ case "$1" in
145
+ #{indent(word_nodes.map { |n| "#{n.id}) REPLY=#{sq(n.words.map { |w, _| escape(w) }.join(" "))} ;; # #{n.command.full_name}" }, 4)}
146
+ *) return 1 ;;
147
+ esac
148
+ }
149
+
150
+ _#{@id}() {
151
+ local line=${COMP_LINE:0:COMP_POINT} node=0 word cur skip= positional= REPLY i
152
+ local -a words
153
+ read -ra words <<< "$line"
154
+ [[ -z $line || $line == *[[:space:]] ]] && words+=("")
155
+ cur=${words[${#words[@]}-1]}
156
+ COMPREPLY=()
157
+ for ((i = 1; i < ${#words[@]} - 1; i++)); do
158
+ word=${words[i]}
159
+ if [[ -n $skip ]]; then skip=; continue; fi
160
+ case $word in
161
+ --) return 0 ;;
162
+ --*=*) ;;
163
+ --*) __#{@id}_complete_takes_value "$node" "$word" && skip=$word ;;
164
+ -?*) __#{@id}_complete_takes_value "$node" "-${word: -1}" && skip="-${word: -1}" ;;
165
+ *)
166
+ if [[ -z $positional ]] && __#{@id}_complete_next "$node $word"; then node=$REPLY; else positional=1; fi ;;
167
+ esac
168
+ done
169
+ if [[ -n $skip ]]; then
170
+ __#{@id}_complete_choices "$node" "$skip" && COMPREPLY=($(compgen -W "$REPLY" -- "$cur"))
171
+ return 0
172
+ fi
173
+ if [[ $cur == --*=* ]]; then
174
+ local prefix=
175
+ [[ $COMP_WORDBREAKS == *=* ]] || prefix="${cur%%=*}="
176
+ __#{@id}_complete_choices "$node" "${cur%%=*}" && COMPREPLY=($(compgen -P "$prefix" -W "$REPLY" -- "${cur#*=}"))
177
+ return 0
178
+ fi
179
+ if [[ $cur == -* ]]; then
180
+ __#{@id}_complete_options "$node" && COMPREPLY=($(compgen -W "$REPLY" -- "$cur"))
181
+ return 0
182
+ fi
183
+ [[ -z $positional ]] && __#{@id}_complete_words "$node" && COMPREPLY=($(compgen -W "$REPLY" -- "$cur"))
184
+ return 0
185
+ }
186
+
187
+ complete -o default -F _#{@id} #{sq(@name)}
188
+ BASH
189
+ end
190
+
191
+ def zsh
192
+ <<~ZSH
193
+ #compdef #{@name}
194
+ # zsh completion for #{@name}
195
+ # Generated by `#{@name} completion zsh`; regenerate it when the tool changes.
196
+ # Load it in this shell (after compinit): eval "$(#{@name} completion zsh)"
197
+ # Or save it on your $fpath: #{@name} completion zsh > "${fpath[1]}/_#{@id}"
198
+
199
+ #{next_function}
200
+
201
+ #{takes_value_function}
202
+
203
+ # Status 0 with the choices in $candidates, 2 for a path, 1 for any other value.
204
+ __#{@id}_complete_choices() {
205
+ case "$1 $2" in
206
+ #{indent(choice_cases { |words| "candidates=(#{words.map { |w| sq(w) }.join(" ")})" }, 4)}
207
+ #{indent(path_cases, 4)}
208
+ *) return 1 ;;
209
+ esac
210
+ }
211
+
212
+ __#{@id}_complete_options() {
213
+ case "$1" in
214
+ #{indent(@tree.nodes.map { |n| "#{n.id}) candidates=(#{described_options(n)}) ;; # #{n.command.full_name}" }, 4)}
215
+ *) return 1 ;;
216
+ esac
217
+ }
218
+
219
+ __#{@id}_complete_words() {
220
+ case "$1" in
221
+ #{indent(word_nodes.map { |n| "#{n.id}) REPLY=#{word_kind(n)} candidates=(#{n.words.map { |w, d| described(w, d) }.join(" ")}) ;; # #{n.command.full_name}" }, 4)}
222
+ *) return 1 ;;
223
+ esac
224
+ }
225
+
226
+ _#{@id}() {
227
+ local node=0 word cur=${words[CURRENT]} skip= positional= REPLY i rc
228
+ local -a candidates
229
+ for (( i = 2; i < CURRENT; i++ )); do
230
+ word=${words[i]}
231
+ if [[ -n $skip ]]; then skip=; continue; fi
232
+ case $word in
233
+ --) _files; return ;;
234
+ --*=*) ;;
235
+ --*) __#{@id}_complete_takes_value $node "$word" && skip=$word ;;
236
+ -?*) __#{@id}_complete_takes_value $node "-${word[-1]}" && skip="-${word[-1]}" ;;
237
+ *)
238
+ if [[ -z $positional ]] && __#{@id}_complete_next "$node $word"; then node=$REPLY; else positional=1; fi ;;
239
+ esac
240
+ done
241
+ if [[ -z $skip && $cur == --*=* ]]; then
242
+ skip=${cur%%=*}
243
+ compset -P '*='
244
+ fi
245
+ if [[ -n $skip ]]; then
246
+ __#{@id}_complete_choices $node "$skip"
247
+ rc=$?
248
+ if (( rc == 0 )); then compadd -a candidates; elif (( rc == 2 )); then _files; fi
249
+ return
250
+ fi
251
+ if [[ $cur == -* ]]; then
252
+ __#{@id}_complete_options $node && _describe -t options option candidates
253
+ return
254
+ fi
255
+ if [[ -z $positional ]] && __#{@id}_complete_words $node; then
256
+ _describe -t ${REPLY}s $REPLY candidates
257
+ else
258
+ _files
259
+ fi
260
+ }
261
+
262
+ if [ "$funcstack[1]" = "_#{@id}" ]; then
263
+ _#{@id} "$@"
264
+ else
265
+ compdef _#{@id} #{sq(@name)}
266
+ fi
267
+ ZSH
268
+ end
269
+
270
+ def fish
271
+ cmd = fq(@name)
272
+ <<~FISH
273
+ # fish completion for #{@name}
274
+ # Generated by `#{@name} completion fish`; regenerate it when the tool changes.
275
+ # Load it in this shell: #{@name} completion fish | source
276
+ # Or save it: #{@name} completion fish > ~/.config/fish/completions/#{@name}.fish
277
+
278
+ function __#{@id}_complete_next
279
+ switch $argv[1]
280
+ #{indent(fish_next_cases, 8)}
281
+ end
282
+ return 1
283
+ end
284
+
285
+ function __#{@id}_complete_takes_value
286
+ switch "$argv[1] $argv[2]"
287
+ #{indent(fish_value_cases, 8)}
288
+ end
289
+ return 1
290
+ end
291
+
292
+ # The node the command line is at; "N+" once a positional argument was typed.
293
+ function __#{@id}_complete_node
294
+ set -l node 0
295
+ set -l skip 0
296
+ set -l positional 0
297
+ set -l tokens (commandline -opc)
298
+ set -e tokens[1]
299
+ for word in $tokens
300
+ if test $skip = 1
301
+ set skip 0
302
+ continue
303
+ end
304
+ if test "$word" = --
305
+ echo $node+
306
+ return
307
+ else if string match -q -- '--*=*' $word
308
+ continue
309
+ else if string match -q -- '-?*' $word
310
+ set -l opt $word
311
+ string match -q -- '--*' $word; or set opt -(string sub -s -1 -- $word)
312
+ __#{@id}_complete_takes_value $node $opt; and set skip 1
313
+ else if test $positional = 0; and __#{@id}_complete_next "$node $word" >/dev/null
314
+ set node (__#{@id}_complete_next "$node $word")
315
+ else
316
+ set positional 1
317
+ end
318
+ end
319
+ if test $positional = 1
320
+ echo $node+
321
+ else
322
+ echo $node
323
+ end
324
+ end
325
+
326
+ # Before any positional argument: where command names complete.
327
+ function __#{@id}_complete_at
328
+ test (__#{@id}_complete_node) = $argv[1]
329
+ end
330
+
331
+ # Anywhere in the command: where its options complete.
332
+ function __#{@id}_complete_in
333
+ set -l node (__#{@id}_complete_node)
334
+ test "$node" = $argv[1]; or test "$node" = "$argv[1]+"
335
+ end
336
+
337
+ #{fish_entries(cmd).join("\n")}
338
+ FISH
339
+ end
340
+
341
+ private
342
+
343
+ def word_nodes = @tree.nodes.select { |n| n.words.any? }
344
+
345
+ def word_kind(node) = node.command.declared(:completion_command).any? ? "shell" : "command"
346
+
347
+ def option_words(node)
348
+ node.options.flat_map { |o| [o[:long], o[:short], o[:negation]] }.compact
349
+ end
350
+
351
+ def value_options(node) = node.options.select { |o| o[:value] }
352
+
353
+ def next_function
354
+ cases = @tree.links.group_by(&:last).map do |to, links|
355
+ "#{links.map { |from, word, _| sq("#{from} #{word}") }.join("|")}) REPLY=#{to} ;;"
356
+ end
357
+ <<~SH.chomp
358
+ __#{@id}_complete_next() {
359
+ case "$1" in
360
+ #{indent(cases, 4)}
361
+ *) return 1 ;;
362
+ esac
363
+ }
364
+ SH
365
+ end
366
+
367
+ def takes_value_function
368
+ cases = @tree.nodes.filter_map do |n|
369
+ names = value_options(n).flat_map { |o| [o[:long], o[:short]] }.compact
370
+ "#{names.map { |name| sq("#{n.id} #{name}") }.join("|")}) return 0 ;;" if names.any?
371
+ end
372
+ <<~SH.chomp
373
+ __#{@id}_complete_takes_value() {
374
+ case "$1 $2" in
375
+ #{indent(cases, 4)}
376
+ esac
377
+ return 1
378
+ }
379
+ SH
380
+ end
381
+
382
+ def choice_cases
383
+ @tree.nodes.flat_map do |n|
384
+ value_options(n).select { |o| o[:choices] }.map do |o|
385
+ "#{option_keys(n, o)}) #{yield o[:choices]} ;;"
386
+ end
387
+ end
388
+ end
389
+
390
+ def path_cases
391
+ @tree.nodes.flat_map do |n|
392
+ value_options(n).select { |o| o[:path] && !o[:choices] }.map { |o| "#{option_keys(n, o)}) return 2 ;;" }
393
+ end
394
+ end
395
+
396
+ def option_keys(node, option)
397
+ [option[:long], option[:short]].compact.map { |name| sq("#{node.id} #{name}") }.join("|")
398
+ end
399
+
400
+ def described_options(node)
401
+ node.options.flat_map do |o|
402
+ [o[:long], o[:short], o[:negation]].compact.map { |name| described(name, o[:desc]) }
403
+ end.join(" ")
404
+ end
405
+
406
+ def described(word, desc)
407
+ word = word.gsub(":", "\\:")
408
+ sq(desc.to_s.empty? ? word : "#{word}:#{desc.to_s.lines.first.chomp}")
409
+ end
410
+
411
+ def fish_next_cases
412
+ @tree.links.group_by(&:last).map do |to, links|
413
+ "case #{links.map { |from, word, _| fq("#{from} #{word}") }.join(" ")}\n echo #{to}\n return 0"
414
+ end
415
+ end
416
+
417
+ def fish_value_cases
418
+ @tree.nodes.filter_map do |n|
419
+ names = value_options(n).flat_map { |o| [o[:long], o[:short]] }.compact
420
+ "case #{names.map { |name| fq("#{n.id} #{name}") }.join(" ")}\n return 0" if names.any?
421
+ end
422
+ end
423
+
424
+ def fish_entries(cmd)
425
+ lines = []
426
+ word_nodes.each do |n|
427
+ lines << "# #{n.command.full_name}"
428
+ n.words.each do |word, desc|
429
+ lines << "complete -c #{cmd} -f -n '__#{@id}_complete_at #{n.id}' -a #{fq(escape(word))}#{fish_desc(desc)}"
430
+ end
431
+ end
432
+ @tree.nodes.each do |n|
433
+ lines << "# #{n.command.full_name} options"
434
+ n.options.each do |o|
435
+ next if o.equal?(HELP)
436
+
437
+ lines << "complete -c #{cmd} -n '__#{@id}_complete_in #{n.id}'#{fish_option(o)}#{fish_desc(o[:desc])}"
438
+ if o[:negation]
439
+ lines << "complete -c #{cmd} -n '__#{@id}_complete_in #{n.id}' -l #{fq(o[:negation].delete_prefix("--"))}#{fish_desc(o[:desc])}"
440
+ end
441
+ end
442
+ end
443
+ lines << "complete -c #{cmd} -s h -l help -d 'Show help'"
444
+ end
445
+
446
+ def fish_option(option)
447
+ parts = []
448
+ parts << "-s #{fq(option[:short].delete_prefix("-"))}" if option[:short]
449
+ parts << "-l #{fq(option[:long].delete_prefix("--"))}"
450
+ if option[:choices]
451
+ parts << "-x -a #{fq(option[:choices].map { |c| escape(c) }.join(" "))}"
452
+ elsif option[:path]
453
+ parts << "-r -F"
454
+ elsif option[:value]
455
+ parts << "-x"
456
+ end
457
+ " #{parts.join(" ")}"
458
+ end
459
+
460
+ def fish_desc(desc) = desc.to_s.empty? ? "" : " -d #{fq(desc.to_s.lines.first.chomp)}"
461
+
462
+ def indent(lines, spaces) = lines.flat_map { |l| l.split("\n") }.map { |l| (" " * spaces) + l }.join("\n")
463
+
464
+ SAFE = %r{\A[\w.,:@%+=/-]+\z}
465
+
466
+ # A word for bash and zsh: bare when safe, else single-quoted.
467
+ def sq(text)
468
+ text = text.to_s
469
+ text.match?(SAFE) ? text : "'#{text.gsub("'") { "'\\''" }}'"
470
+ end
471
+
472
+ # A word for fish: bare when safe, else single-quoted (where \\ and \' are the only escapes).
473
+ def fq(text)
474
+ text = text.to_s
475
+ text.match?(SAFE) ? text : "'#{text.gsub(/[\\']/) { "\\#{::Regexp.last_match(0)}" }}'"
476
+ end
477
+
478
+ # A word as it must appear in a list the shell expands again (compgen -W, complete -a).
479
+ def escape(word) = word.to_s.gsub(%r{[^\w.,:@%+=/-]}) { "\\#{::Regexp.last_match(0)}" }
480
+ end
481
+ end
482
+ end
483
+
484
+ extension :completion do
485
+ dsl(:command) do
486
+ # On the root: adds `completion <bash|zsh|fish>`.
487
+ def completion
488
+ raise ArgumentError, "completion goes on the root command, not #{definition.full_name}" unless definition.root?
489
+
490
+ declare(:completion, true)
491
+ end
492
+ end
493
+
494
+ setup do
495
+ if declared(:completion).any?
496
+ tool = definition.name
497
+ command :completion, "Print a shell completion script" do
498
+ description "Print a shell completion script for #{tool}: it completes commands, aliases, options and their choices."
499
+ declare(:completion_command, true)
500
+ argument :shell, desc: "bash, zsh or fish"
501
+ run do
502
+ kind = args[:shell]
503
+ usage_error!(Ext::Completion.unknown(kind)) unless Ext::Completion::SHELLS.key?(kind)
504
+
505
+ shell.print(Ext::Completion.script(program, kind))
506
+ if shell.live?
507
+ line = format(Ext::Completion::SETUP.fetch(kind).first, program.name)
508
+ shell.err_puts("#{shell.symbol(:info)} Load it with #{shell.paint(line, :accent)} #{shell.paint("(#{program.name} completion --help)", :muted)}")
509
+ end
510
+ end
511
+ end
512
+ end
513
+ end
514
+
515
+ help_section do |command, shell|
516
+ ["Setup", Ext::Completion.setup_lines(shell, command.root.name)] if command.declared(:completion_command).any?
517
+ end
518
+ end
519
+ end
520
+ end