Skip to content

Commit e52a240

Browse files
committed
Add --generate-completion flag for single-command apps and fix zsh completion.
For commands with a run callback (and thus no subcommands), a "completion" subcommand cannot be auto-added. Fall back to a "--generate-completion <shell>" option on the root command instead, gated on the existing --add-completion flag. Fix zsh extension-filtered completion: replace parenthesized glob patterns like "(*.toml|*.yaml)" with one "_files -g" call per extension, since grouped patterns break _path_files directory-prefix navigation. Register completion bindings for the basename, the program-path as given, and a "./" prefixed variant for relative paths, so that completion works regardless of how the binary is invoked. Add examples/comp.toit demonstrating OptionPath with --extensions.
1 parent 774fa49 commit e52a240

8 files changed

Lines changed: 346 additions & 32 deletions

examples/comp.toit

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (C) 2026 Toit contributors.
2+
// Use of this source code is governed by an MIT-style license that can be
3+
// found in the package's LICENSE file.
4+
5+
import cli show *
6+
7+
/**
8+
Demonstrates $OptionPath completion with extension filtering.
9+
10+
A tiny imaginary "compiler" that takes an input Toit file and an output
11+
path. The input rest argument uses $OptionPath with `--extensions=[".toit"]`,
12+
so shell completion only suggests `.toit` files (and directories for
13+
navigation).
14+
15+
Since this command has a `--run` callback (and therefore no subcommands),
16+
the library automatically adds a `--generate-completion` option instead
17+
of a `completion` subcommand.
18+
19+
To try it out:
20+
21+
```
22+
# Compile:
23+
toit compile -o /tmp/comp examples/comp.toit
24+
25+
# Enable completions (bash):
26+
source <(/tmp/comp --generate-completion bash)
27+
28+
# Invoke:
29+
/tmp/comp -o /tmp/foo in.toit
30+
```
31+
32+
Then type `/tmp/comp -o /tmp/foo ` and press Tab — only `.toit` files
33+
and directories are suggested.
34+
*/
35+
36+
main arguments:
37+
cmd := Command "comp"
38+
--help="An imaginary compiler that compiles a Toit file."
39+
--options=[
40+
OptionPath "output" --short-name="o"
41+
--help="The output path."
42+
--required,
43+
]
44+
--rest=[
45+
OptionPath "input"
46+
--extensions=[".toit"]
47+
--help="The input Toit file."
48+
--required,
49+
]
50+
--run=:: run-comp it
51+
52+
cmd.run arguments
53+
54+
run-comp invocation/Invocation:
55+
input := invocation["input"]
56+
output := invocation["output"]
57+
print "Compiling '$input' to '$output'."

src/cli.toit

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ export Ui
2525
export Cache FileStore DirectoryStore
2626
export Config
2727

28+
/**
29+
Shells for which $Command can generate completion scripts.
30+
*/
31+
COMPLETION-SHELLS_ ::= ["bash", "zsh", "fish", "powershell"]
32+
33+
/**
34+
Returns the completion script for the given $shell.
35+
36+
The $program-path is baked into the script and used to re-invoke the
37+
binary at completion time.
38+
*/
39+
completion-script-for-shell_ --shell/string --program-path/string -> string:
40+
if shell == "bash": return bash-completion-script_ --program-path=program-path
41+
if shell == "zsh": return zsh-completion-script_ --program-path=program-path
42+
if shell == "fish": return fish-completion-script_ --program-path=program-path
43+
if shell == "powershell": return powershell-completion-script_ --program-path=program-path
44+
unreachable
45+
46+
/**
47+
Scans $arguments for a "--generate-completion <shell>" or
48+
"--generate-completion=<shell>" occurrence with a known shell value.
49+
50+
Returns the shell name if found, null otherwise.
51+
52+
Unknown or missing values are ignored, so that the normal parser can
53+
report them with its standard error machinery.
54+
*/
55+
find-generate-completion-arg_ arguments/List -> string?:
56+
prefix := "--generate-completion="
57+
for i := 0; i < arguments.size; i++:
58+
arg/string := arguments[i]
59+
value/string? := null
60+
if arg == "--generate-completion":
61+
if i + 1 < arguments.size: value = arguments[i + 1]
62+
else if arg.starts-with prefix:
63+
value = arg[prefix.size..]
64+
if value and (COMPLETION-SHELLS_.contains value):
65+
return value
66+
return null
67+
2868
/**
2969
An object giving access to common operations for CLI programs.
3070
@@ -310,8 +350,10 @@ class Command:
310350
--cli/Cli?=null
311351
--add-ui-help/bool=(not cli)
312352
--add-completion/bool=true:
353+
added-completion-flag := false
313354
if add-completion:
314-
add-completion-command_ --program-path=invoked-command
355+
added-completion-flag = add-completion-bootstrap_
356+
--program-path=invoked-command
315357

316358
// Handle __complete requests before any other processing.
317359
if add-completion and not arguments.is-empty and arguments[0] == "__complete":
@@ -328,6 +370,14 @@ class Command:
328370
print ":$result.directive"
329371
return
330372

373+
// Handle --generate-completion before any other processing, so that
374+
// required rest arguments and run callbacks are skipped.
375+
if added-completion-flag:
376+
shell := find-generate-completion-arg_ arguments
377+
if shell:
378+
print (completion-script-for-shell_ --shell=shell --program-path=invoked-command)
379+
return
380+
331381
if not cli:
332382
ui := create-ui-from-args_ arguments
333383
log.set-default (ui.logger --name=name)
@@ -339,13 +389,38 @@ class Command:
339389
invocation := Invocation.private_ cli path.commands parameters
340390
invocation.command.run-callback_.call invocation
341391

342-
add-completion-command_ --program-path/string:
392+
/**
393+
Adds a bootstrap mechanism for shell completions.
394+
395+
If the command has no run callback, a "completion" subcommand is added.
396+
Otherwise, a "--generate-completion" option is added to the root command.
397+
398+
Returns true if the "--generate-completion" flag was added.
399+
*/
400+
add-completion-bootstrap_ --program-path/string -> bool:
343401
// Don't add if the user already has a "completion" subcommand.
344-
if find-subcommand_ "completion": return
345-
// Can't add subcommands to a command with rest args or a run callback
346-
// that already has subcommands handled.
347-
if run-callback_: return
402+
if find-subcommand_ "completion": return false
403+
// Don't add if the user already has a "--generate-completion" option.
404+
options_.do: | opt/Option |
405+
if opt.name == "generate-completion": return false
406+
407+
if not run-callback_:
408+
add-completion-subcommand_ --program-path=program-path
409+
return false
410+
411+
// The root has a run callback, so we can't add a subcommand. Fall back
412+
// to a "--generate-completion" flag.
413+
add-completion-flag_
414+
return true
415+
416+
add-completion-flag_:
417+
options_ = options_.copy
418+
options_.add
419+
OptionEnum "generate-completion" COMPLETION-SHELLS_
420+
--type="shell"
421+
--help="Print a shell completion script (bash, zsh, fish, powershell) to stdout and exit."
348422

423+
add-completion-subcommand_ --program-path/string:
349424
prog-name := basename_ program-path
350425
completion-command := Command "completion"
351426
--help="""
@@ -378,24 +453,13 @@ class Command:
378453
PowerShell:
379454
$program-path completion powershell >> \$PROFILE"""
380455
--rest=[
381-
OptionEnum "shell" ["bash", "zsh", "fish", "powershell"]
456+
OptionEnum "shell" COMPLETION-SHELLS_
382457
--help="The shell to generate completions for."
383458
--required,
384459
]
385460
--run=:: | invocation/Invocation |
386461
shell := invocation["shell"]
387-
script/string := ?
388-
if shell == "bash":
389-
script = bash-completion-script_ --program-path=program-path
390-
else if shell == "zsh":
391-
script = zsh-completion-script_ --program-path=program-path
392-
else if shell == "fish":
393-
script = fish-completion-script_ --program-path=program-path
394-
else if shell == "powershell":
395-
script = powershell-completion-script_ --program-path=program-path
396-
else:
397-
unreachable
398-
print script
462+
print (completion-script-for-shell_ --shell=shell --program-path=program-path)
399463
subcommands_.add completion-command
400464

401465
add-ui-options_:

src/completion-scripts_.toit

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,32 @@ basename_ path/string -> string:
1515
if name.ends-with ".exe": name = name[..name.size - 4]
1616
return name
1717

18+
/**
19+
Returns the list of command names to which a generated completion script
20+
should bind.
21+
22+
A naive `complete`/`compdef` registration only matches the exact basename
23+
of the program — i.e. only when the binary is on \$PATH. Users who source
24+
the script with a path (for example `source <(examples/comp ...)`) would
25+
then invoke the binary as `examples/comp` or `./examples/comp`, which
26+
shells look up verbatim and fail to match.
27+
28+
We therefore register every plausible invocation form:
29+
- the basename (for \$PATH-installed binaries),
30+
- the program-path as given (for relative or absolute paths),
31+
- a "./" prefixed variant for relative paths (the common "./bin" case).
32+
*/
33+
completion-bind-names_ program-path/string -> List:
34+
name := basename_ program-path
35+
names := [name]
36+
if program-path != name: names.add program-path
37+
is-absolute := program-path.starts-with "/"
38+
or (program-path.size >= 2 and program-path[1] == ':') // Windows drive.
39+
already-dotted := program-path.starts-with "./" or program-path.starts-with ".\\"
40+
if not is-absolute and not already-dotted and program-path != name:
41+
names.add "./$program-path"
42+
return names
43+
1844
/**
1945
Sanitizes the given $name for use as a shell function name.
2046
@@ -39,6 +65,8 @@ Returns a bash completion script for the given $program-path.
3965
bash-completion-script_ --program-path/string -> string:
4066
program-name := basename_ program-path
4167
func-name := sanitize-func-name_ program-name
68+
bind-names := (completion-bind-names_ program-path).map: "\"$it\""
69+
bind-names-str := bind-names.join " "
4270
return """
4371
_$(func-name)_completions() {
4472
local IFS=\$'\\n'
@@ -104,14 +132,16 @@ bash-completion-script_ --program-path/string -> string:
104132
fi
105133
fi
106134
}
107-
complete -o default -F _$(func-name)_completions "$program-name\""""
135+
complete -o default -F _$(func-name)_completions $bind-names-str"""
108136

109137
/**
110138
Returns a zsh completion script for the given $program-path.
111139
*/
112140
zsh-completion-script_ --program-path/string -> string:
113141
program-name := basename_ program-path
114142
func-name := sanitize-func-name_ program-name
143+
bind-names := (completion-bind-names_ program-path).map: "\"$it\""
144+
bind-names-str := bind-names.join " "
115145
return """
116146
#compdef $program-name
117147
@@ -157,17 +187,15 @@ zsh-completion-script_ --program-path/string -> string:
157187
158188
if [[ \$directive -eq 4 ]]; then
159189
if [[ -n "\$extensions" ]]; then
190+
# Issue one _files -g call per extension. Alternation
191+
# patterns like "(*.toml|*.yaml)" or "*.(toml|yaml)"
192+
# break _path_files directory-prefix navigation in zsh,
193+
# so we avoid them entirely.
160194
local -a ext_array
161195
ext_array=(\${(s:,:)extensions})
162-
local glob_parts=""
163196
for ext in "\${ext_array[@]}"; do
164-
if [[ -n "\$glob_parts" ]]; then
165-
glob_parts="\$glob_parts|*\$ext"
166-
else
167-
glob_parts="*\$ext"
168-
fi
197+
_files -g "*\$ext"
169198
done
170-
_files -g "(\$glob_parts)"
171199
else
172200
_files
173201
fi
@@ -176,14 +204,19 @@ zsh-completion-script_ --program-path/string -> string:
176204
fi
177205
}
178206
179-
compdef _$(func-name) "$program-name\""""
207+
compdef _$(func-name) $bind-names-str"""
180208

181209
/**
182210
Returns a fish completion script for the given $program-path.
183211
*/
184212
fish-completion-script_ --program-path/string -> string:
185213
program-name := basename_ program-path
186214
func-name := sanitize-func-name_ program-name
215+
// Fish's `complete -c` takes one command name per invocation, so emit
216+
// one `complete` line per bind name.
217+
bind-lines := (completion-bind-names_ program-path).map: | n/string |
218+
"complete -c \"$n\" -f -a '(__$(func-name)_completions)'"
219+
complete-block := bind-lines.join "\n "
187220
return """
188221
function __$(func-name)_completions
189222
set -l tokens (commandline -opc)
@@ -239,15 +272,17 @@ fish-completion-script_ --program-path/string -> string:
239272
end
240273
end
241274
242-
complete -c "$program-name" -f -a '(__$(func-name)_completions)'"""
275+
$complete-block"""
243276

244277
/**
245278
Returns a PowerShell completion script for the given $program-path.
246279
*/
247280
powershell-completion-script_ --program-path/string -> string:
248281
program-name := basename_ program-path
282+
bind-names := (completion-bind-names_ program-path).map: "'$it'"
283+
bind-names-str := bind-names.join ","
249284
return """
250-
Register-ArgumentCompleter -Native -CommandName '$program-name' -ScriptBlock {
285+
Register-ArgumentCompleter -Native -CommandName @($bind-names-str) -ScriptBlock {
251286
param(\$wordToComplete, \$commandAst, \$cursorPosition)
252287
253288
\$tokens = \$commandAst.ToString() -split '\\s+'

tests/completion_shell.toit

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,10 @@ setup-test-binary_ tmpdir/string -> string:
115115
file.write-contents --path="$tmpdir/xconfig.yaml" ""
116116
file.write-contents --path="$tmpdir/xconfig.txt" ""
117117

118+
// Directory used to verify that extension-filtered completion still lets
119+
// the user navigate into directories.
120+
directory.mkdir "$tmpdir/xsubdir"
121+
file.write-contents --path="$tmpdir/xsubdir/nested.toml" ""
122+
118123
return binary
119124

tests/completion_shell_bash_test.toit

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ test-bash binary/string tmpdir/string:
8181
expect (not content.contains "xconfig.txt")
8282
tmux.cancel
8383

84+
// OptionPath --extensions: directories must still be suggested so the
85+
// user can navigate into them.
86+
tmux.send-keys ["$binary deploy --config xsub", "Tab"]
87+
tmux.wait-for "xsubdir"
88+
tmux.cancel
89+
90+
// Completion must also work when the binary is invoked via a relative
91+
// path (e.g. ./fleet) rather than the absolute path baked into the
92+
// completion script. Re-source with a relative path so that
93+
// `complete` registers "./fleet" as a bind name.
94+
tmux.send-line "source <(./fleet completion bash) && echo re-sourced"
95+
tmux.wait-for "re-sourced"
96+
tmux.send-keys ["./fleet deploy --channel ", "Tab", "Tab"]
97+
tmux.wait-for "stable"
98+
content = tmux.capture
99+
expect (content.contains "beta")
100+
tmux.cancel
101+
84102
print " All bash tests passed."
85103
finally:
86104
tmux.close

tests/completion_shell_zsh_test.toit

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ test-zsh binary/string tmpdir/string:
7171
expect (not content.contains "xconfig.txt")
7272
tmux.cancel
7373

74+
// OptionPath --extensions: directories must still be suggested so the
75+
// user can navigate into them.
76+
tmux.send-keys ["$binary deploy --config xsub", "Tab"]
77+
tmux.wait-for "xsubdir"
78+
tmux.cancel
79+
80+
// Completion must also work when the binary is invoked via a relative
81+
// path (e.g. ./fleet) rather than the absolute path baked into the
82+
// completion script. Re-source with a relative path so that
83+
// compdef registers "./fleet" as a bind name.
84+
tmux.send-line "source <(./fleet completion zsh) && echo re-sourced"
85+
tmux.wait-for "re-sourced"
86+
tmux.send-keys ["./fleet deploy --channel ", "Tab"]
87+
tmux.wait-for "stable"
88+
content = tmux.capture
89+
expect (content.contains "beta")
90+
tmux.cancel
91+
7492
print " All zsh tests passed."
7593
finally:
7694
tmux.close

0 commit comments

Comments
 (0)