diff --git a/examples/comp.toit b/examples/comp.toit new file mode 100644 index 0000000..6596932 --- /dev/null +++ b/examples/comp.toit @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Toit contributors. +// Use of this source code is governed by an MIT-style license that can be +// found in the package's LICENSE file. + +import cli show * + +/** +Demonstrates $OptionPath completion with extension filtering. + +A tiny imaginary "compiler" that takes an input Toit file and an output + path. The input rest argument uses $OptionPath with `--extensions=[".toit"]`, + so shell completion only suggests `.toit` files (and directories for + navigation). + +Since this command has a `--run` callback (and therefore no subcommands), + the library automatically adds a `--generate-completion` option instead + of a `completion` subcommand. + +To try it out: + +``` +# Compile: +toit compile -o /tmp/comp examples/comp.toit + +# Enable completions (bash): +source <(/tmp/comp --generate-completion bash) + +# Invoke: +/tmp/comp -o /tmp/foo in.toit +``` + +Then type `/tmp/comp -o /tmp/foo ` and press Tab — only `.toit` files + and directories are suggested. +*/ + +main arguments: + cmd := Command "comp" + --help="An imaginary compiler that compiles a Toit file." + --options=[ + OptionPath "output" --short-name="o" + --help="The output path." + --required, + ] + --rest=[ + OptionPath "input" + --extensions=[".toit"] + --help="The input Toit file." + --required, + ] + --run=:: run-comp it + + cmd.run arguments + +run-comp invocation/Invocation: + input := invocation["input"] + output := invocation["output"] + print "Compiling '$input' to '$output'." diff --git a/src/cli.toit b/src/cli.toit index 5b08654..eedc9f4 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -25,6 +25,46 @@ export Ui export Cache FileStore DirectoryStore export Config +/** +Shells for which $Command can generate completion scripts. +*/ +COMPLETION-SHELLS_ ::= ["bash", "zsh", "fish", "powershell"] + +/** +Returns the completion script for the given $shell. + +The $program-path is baked into the script and used to re-invoke the + binary at completion time. +*/ +completion-script-for-shell_ --shell/string --program-path/string -> string: + if shell == "bash": return bash-completion-script_ --program-path=program-path + if shell == "zsh": return zsh-completion-script_ --program-path=program-path + if shell == "fish": return fish-completion-script_ --program-path=program-path + if shell == "powershell": return powershell-completion-script_ --program-path=program-path + unreachable + +/** +Scans $arguments for a "--generate-completion " or + "--generate-completion=" occurrence with a known shell value. + +Returns the shell name if found, null otherwise. + +Unknown or missing values are ignored, so that the normal parser can + report them with its standard error machinery. +*/ +find-generate-completion-arg_ arguments/List -> string?: + prefix := "--generate-completion=" + for i := 0; i < arguments.size; i++: + arg/string := arguments[i] + value/string? := null + if arg == "--generate-completion": + if i + 1 < arguments.size: value = arguments[i + 1] + else if arg.starts-with prefix: + value = arg[prefix.size..] + if value and (COMPLETION-SHELLS_.contains value): + return value + return null + /** An object giving access to common operations for CLI programs. @@ -310,8 +350,10 @@ class Command: --cli/Cli?=null --add-ui-help/bool=(not cli) --add-completion/bool=true: + added-completion-flag := false if add-completion: - add-completion-command_ --program-path=invoked-command + added-completion-flag = add-completion-bootstrap_ + --program-path=invoked-command // Handle __complete requests before any other processing. if add-completion and not arguments.is-empty and arguments[0] == "__complete": @@ -328,6 +370,14 @@ class Command: print ":$result.directive" return + // Handle --generate-completion before any other processing, so that + // required rest arguments and run callbacks are skipped. + if added-completion-flag: + shell := find-generate-completion-arg_ arguments + if shell: + print (completion-script-for-shell_ --shell=shell --program-path=invoked-command) + return + if not cli: ui := create-ui-from-args_ arguments log.set-default (ui.logger --name=name) @@ -339,13 +389,38 @@ class Command: invocation := Invocation.private_ cli path.commands parameters invocation.command.run-callback_.call invocation - add-completion-command_ --program-path/string: + /** + Adds a bootstrap mechanism for shell completions. + + If the command has no run callback, a "completion" subcommand is added. + Otherwise, a "--generate-completion" option is added to the root command. + + Returns true if the "--generate-completion" flag was added. + */ + add-completion-bootstrap_ --program-path/string -> bool: // Don't add if the user already has a "completion" subcommand. - if find-subcommand_ "completion": return - // Can't add subcommands to a command with rest args or a run callback - // that already has subcommands handled. - if run-callback_: return + if find-subcommand_ "completion": return false + // Don't add if the user already has a "--generate-completion" option. + options_.do: | opt/Option | + if opt.name == "generate-completion": return false + + if not run-callback_: + add-completion-subcommand_ --program-path=program-path + return false + + // The root has a run callback, so we can't add a subcommand. Fall back + // to a "--generate-completion" flag. + add-completion-flag_ + return true + + add-completion-flag_: + options_ = options_.copy + options_.add + OptionEnum "generate-completion" COMPLETION-SHELLS_ + --type="shell" + --help="Print a shell completion script (bash, zsh, fish, powershell) to stdout and exit." + add-completion-subcommand_ --program-path/string: prog-name := basename_ program-path completion-command := Command "completion" --help=""" @@ -378,24 +453,13 @@ class Command: PowerShell: $program-path completion powershell >> \$PROFILE""" --rest=[ - OptionEnum "shell" ["bash", "zsh", "fish", "powershell"] + OptionEnum "shell" COMPLETION-SHELLS_ --help="The shell to generate completions for." --required, ] --run=:: | invocation/Invocation | shell := invocation["shell"] - script/string := ? - if shell == "bash": - script = bash-completion-script_ --program-path=program-path - else if shell == "zsh": - script = zsh-completion-script_ --program-path=program-path - else if shell == "fish": - script = fish-completion-script_ --program-path=program-path - else if shell == "powershell": - script = powershell-completion-script_ --program-path=program-path - else: - unreachable - print script + print (completion-script-for-shell_ --shell=shell --program-path=program-path) subcommands_.add completion-command add-ui-options_: diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index af9f55e..ca3c6b7 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -15,6 +15,32 @@ basename_ path/string -> string: if name.ends-with ".exe": name = name[..name.size - 4] return name +/** +Returns the list of command names to which a generated completion script + should bind. + +A naive `complete`/`compdef` registration only matches the exact basename + of the program — i.e. only when the binary is on \$PATH. Users who source + the script with a path (for example `source <(examples/comp ...)`) would + then invoke the binary as `examples/comp` or `./examples/comp`, which + shells look up verbatim and fail to match. + +We therefore register every plausible invocation form: +- the basename (for \$PATH-installed binaries), +- the program-path as given (for relative or absolute paths), +- a "./" prefixed variant for relative paths (the common "./bin" case). +*/ +completion-bind-names_ program-path/string -> List: + name := basename_ program-path + names := [name] + if program-path != name: names.add program-path + is-absolute := program-path.starts-with "/" + or (program-path.size >= 2 and program-path[1] == ':') // Windows drive. + already-dotted := program-path.starts-with "./" or program-path.starts-with ".\\" + if not is-absolute and not already-dotted and program-path != name: + names.add "./$program-path" + return names + /** Sanitizes the given $name for use as a shell function name. @@ -39,6 +65,8 @@ Returns a bash completion script for the given $program-path. bash-completion-script_ --program-path/string -> string: program-name := basename_ program-path func-name := sanitize-func-name_ program-name + bind-names := (completion-bind-names_ program-path).map: "\"$it\"" + bind-names-str := bind-names.join " " return """ _$(func-name)_completions() { local IFS=\$'\\n' @@ -104,7 +132,7 @@ bash-completion-script_ --program-path/string -> string: fi fi } - complete -o default -F _$(func-name)_completions "$program-name\"""" + complete -o default -F _$(func-name)_completions $bind-names-str""" /** Returns a zsh completion script for the given $program-path. @@ -112,6 +140,8 @@ Returns a zsh completion script for the given $program-path. zsh-completion-script_ --program-path/string -> string: program-name := basename_ program-path func-name := sanitize-func-name_ program-name + bind-names := (completion-bind-names_ program-path).map: "\"$it\"" + bind-names-str := bind-names.join " " return """ #compdef $program-name @@ -157,17 +187,15 @@ zsh-completion-script_ --program-path/string -> string: if [[ \$directive -eq 4 ]]; then if [[ -n "\$extensions" ]]; then + # Issue one _files -g call per extension. Alternation + # patterns like "(*.toml|*.yaml)" or "*.(toml|yaml)" + # break _path_files directory-prefix navigation in zsh, + # so we avoid them entirely. local -a ext_array ext_array=(\${(s:,:)extensions}) - local glob_parts="" for ext in "\${ext_array[@]}"; do - if [[ -n "\$glob_parts" ]]; then - glob_parts="\$glob_parts|*\$ext" - else - glob_parts="*\$ext" - fi + _files -g "*\$ext" done - _files -g "(\$glob_parts)" else _files fi @@ -176,7 +204,7 @@ zsh-completion-script_ --program-path/string -> string: fi } - compdef _$(func-name) "$program-name\"""" + compdef _$(func-name) $bind-names-str""" /** Returns a fish completion script for the given $program-path. @@ -184,6 +212,11 @@ Returns a fish completion script for the given $program-path. fish-completion-script_ --program-path/string -> string: program-name := basename_ program-path func-name := sanitize-func-name_ program-name + // Fish's `complete -c` takes one command name per invocation, so emit + // one `complete` line per bind name. + bind-lines := (completion-bind-names_ program-path).map: | n/string | + "complete -c \"$n\" -f -a '(__$(func-name)_completions)'" + complete-block := bind-lines.join "\n " return """ function __$(func-name)_completions set -l tokens (commandline -opc) @@ -239,15 +272,17 @@ fish-completion-script_ --program-path/string -> string: end end - complete -c "$program-name" -f -a '(__$(func-name)_completions)'""" + $complete-block""" /** Returns a PowerShell completion script for the given $program-path. */ powershell-completion-script_ --program-path/string -> string: program-name := basename_ program-path + bind-names := (completion-bind-names_ program-path).map: "'$it'" + bind-names-str := bind-names.join "," return """ - Register-ArgumentCompleter -Native -CommandName '$program-name' -ScriptBlock { + Register-ArgumentCompleter -Native -CommandName @($bind-names-str) -ScriptBlock { param(\$wordToComplete, \$commandAst, \$cursorPosition) \$tokens = \$commandAst.ToString() -split '\\s+' diff --git a/tests/completion_shell.toit b/tests/completion_shell.toit index 4e90cf3..725ad87 100644 --- a/tests/completion_shell.toit +++ b/tests/completion_shell.toit @@ -115,5 +115,10 @@ setup-test-binary_ tmpdir/string -> string: file.write-contents --path="$tmpdir/xconfig.yaml" "" file.write-contents --path="$tmpdir/xconfig.txt" "" + // Directory used to verify that extension-filtered completion still lets + // the user navigate into directories. + directory.mkdir "$tmpdir/xsubdir" + file.write-contents --path="$tmpdir/xsubdir/nested.toml" "" + return binary diff --git a/tests/completion_shell_bash_test.toit b/tests/completion_shell_bash_test.toit index f69d167..49ddceb 100644 --- a/tests/completion_shell_bash_test.toit +++ b/tests/completion_shell_bash_test.toit @@ -81,6 +81,24 @@ test-bash binary/string tmpdir/string: expect (not content.contains "xconfig.txt") tmux.cancel + // OptionPath --extensions: directories must still be suggested so the + // user can navigate into them. + tmux.send-keys ["$binary deploy --config xsub", "Tab"] + tmux.wait-for "xsubdir" + tmux.cancel + + // Completion must also work when the binary is invoked via a relative + // path (e.g. ./fleet) rather than the absolute path baked into the + // completion script. Re-source with a relative path so that + // `complete` registers "./fleet" as a bind name. + tmux.send-line "source <(./fleet completion bash) && echo re-sourced" + tmux.wait-for "re-sourced" + tmux.send-keys ["./fleet deploy --channel ", "Tab", "Tab"] + tmux.wait-for "stable" + content = tmux.capture + expect (content.contains "beta") + tmux.cancel + print " All bash tests passed." finally: tmux.close diff --git a/tests/completion_shell_zsh_test.toit b/tests/completion_shell_zsh_test.toit index dde512c..816b633 100644 --- a/tests/completion_shell_zsh_test.toit +++ b/tests/completion_shell_zsh_test.toit @@ -71,6 +71,24 @@ test-zsh binary/string tmpdir/string: expect (not content.contains "xconfig.txt") tmux.cancel + // OptionPath --extensions: directories must still be suggested so the + // user can navigate into them. + tmux.send-keys ["$binary deploy --config xsub", "Tab"] + tmux.wait-for "xsubdir" + tmux.cancel + + // Completion must also work when the binary is invoked via a relative + // path (e.g. ./fleet) rather than the absolute path baked into the + // completion script. Re-source with a relative path so that + // compdef registers "./fleet" as a bind name. + tmux.send-line "source <(./fleet completion zsh) && echo re-sourced" + tmux.wait-for "re-sourced" + tmux.send-keys ["./fleet deploy --channel ", "Tab"] + tmux.wait-for "stable" + content = tmux.capture + expect (content.contains "beta") + tmux.cancel + print " All zsh tests passed." finally: tmux.close diff --git a/tests/generate_completion_test.toit b/tests/generate_completion_test.toit new file mode 100644 index 0000000..84b0931 --- /dev/null +++ b/tests/generate_completion_test.toit @@ -0,0 +1,115 @@ +// Copyright (C) 2026 Toit contributors. +// Use of this source code is governed by a Zero-Clause BSD license that can +// be found in the tests/LICENSE file. + +import cli +import cli.completion_ show * +import cli.test show * +import expect show * + +/** +Tests that a simple single-command app — one with a run callback and a + required rest argument, like the examples/comp.toit example — gets a + "--generate-completion" flag as a fallback to the "completion" subcommand, + and that runtime ($complete_) completion still works. +*/ +main: + test-flag-added-for-single-command-app + test-subcommand-used-when-no-run-callback + test-flag-skips-run-callback + test-equals-form-also-skips-run-callback + test-invalid-shell-falls-through + test-user-defined-option-not-overridden + test-normal-invocation-still-works + test-runtime-completion-with-extensions + +// Builds a "comp"-style single-command app: run callback plus a required +// input rest arg with extension filtering. +build-comp-app --on-run/Lambda -> cli.Command: + return cli.Command "comp" + --help="An imaginary compiler." + --options=[ + cli.OptionPath "output" --short-name="o" --required, + ] + --rest=[ + cli.OptionPath "input" --extensions=[".toit"] --required, + ] + --run=on-run + +test-flag-added-for-single-command-app: + cmd := build-comp-app --on-run=:: null + // Invoke normally so that run() wires up the completion bootstrap. + cmd.run ["-o", "/tmp/x", "in.toit"] + help := cmd.help --invoked-command="comp" + expect (help.contains "--generate-completion shell") + // And no "completion" subcommand exists (there are no subcommands at all). + expect (not help.contains "completion Generate shell completion scripts.") + +test-subcommand-used-when-no-run-callback: + cmd := cli.Command "multi" + --subcommands=[ + cli.Command "build" --help="Build it." --run=:: null, + ] + cmd.run ["build"] + help := cmd.help --invoked-command="multi" + // The subcommand path is used; no fallback flag. + expect (help.contains "completion") + expect (not help.contains "--generate-completion") + +test-flag-skips-run-callback: + called := false + cmd := build-comp-app --on-run=:: called = true + cmd.run ["--generate-completion", "bash"] + // The run callback must not have been invoked, even though the + // required rest argument is missing. + expect (not called) + +test-equals-form-also-skips-run-callback: + called := false + cmd := build-comp-app --on-run=:: called = true + cmd.run ["--generate-completion=zsh"] + expect (not called) + +test-invalid-shell-falls-through: + // An unknown shell value should NOT be intercepted. The parser then reports + // a standard enum validation error, which calls ui.abort. + called := false + cmd := build-comp-app --on-run=:: called = true + test-cli := TestCli + exception := catch: + cmd.run ["--generate-completion", "tcsh", "-o", "/tmp/x", "in.toit"] --cli=test-cli + expect (not called) + expect exception is TestAbort + all-output := test-cli.ui.stdout + test-cli.ui.stderr + expect (all-output.contains "Invalid value for option 'generate-completion'") + +test-user-defined-option-not-overridden: + // If the user already has a --generate-completion option, the bootstrap + // must leave it alone. + user-called := false + cmd := cli.Command "comp" + --options=[ + cli.Option "generate-completion" --help="User's own option.", + ] + --rest=[ + cli.OptionPath "input" --required, + ] + --run=:: user-called = true + cmd.run ["--generate-completion", "mine", "in.toit"] + expect user-called + +test-normal-invocation-still-works: + called := false + cmd := build-comp-app --on-run=:: called = true + cmd.run ["-o", "/tmp/x", "in.toit"] + expect called + +test-runtime-completion-with-extensions: + // This exercises the path the shell completion script calls back into: + // a __complete request at the rest-arg position should surface the + // ".toit" extension filter. + cmd := build-comp-app --on-run=:: null + result := complete_ cmd ["-o", "/tmp/x", ""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-not-null result.extensions + expect (result.extensions.contains ".toit") diff --git a/tests/help_test.toit b/tests/help_test.toit index 4b6ea49..c56714b 100644 --- a/tests/help_test.toit +++ b/tests/help_test.toit @@ -63,8 +63,9 @@ test-combination: bin/app [] [--] Options: - -h, --help Show help for this command. - --option1 string Option 1. + --generate-completion shell Print a shell completion script (bash, zsh, fish, powershell) to stdout and exit. + -h, --help Show help for this command. + --option1 string Option 1. Rest: rest1 rest_type Rest 1 (required) @@ -650,6 +651,7 @@ test-options: cmd.run --add-ui-help [] expected = """ Global options: + --generate-completion shell Print a shell completion script (bash, zsh, fish, powershell) to stdout and exit. --output-format human|plain|json Specify the format used when printing to the console. (default: human) --verbose Enable verbose output. Shorthand for --verbosity-level=verbose. --verbosity-level debug|info|verbose|quiet|silent Specify the verbosity level. (default: info)