diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb5bca4..5dcdc7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,17 @@ jobs: with: toit-version: ${{ matrix.toit-version }} + - name: Install shell completion test dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y fish + + - name: Install shell completion test dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install tmux fish + - name: Test run: | make test diff --git a/README.md b/README.md index 719da77..c65e33e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ It provides: * Composable subcommands: `myapp subcommand` * Type options/flags that parse arguments: `myapp --int-flag=49 enum_rest_arg` * Automatic help generation +* Shell completion for bash, zsh, and fish * Command aliases * Functionality to cache data between runs * Functionality to store configurations @@ -316,7 +317,7 @@ main args: run invocation/Invocation: ui := invocation.cli.ui - ui.emit + ui.emit --result // Block that is invoked if structured data is needed. --structured=: { "result": "Computed result" @@ -338,6 +339,51 @@ The shorthands `ui.info`, `ui.debug`, also dispatch to these methods if they rec See the documentation of the `ui` library for more details. +## Shell Completion + +Programs built with this package automatically get a `completion` subcommand that +generates shell completion scripts for bash, zsh, and fish. Users enable completions +by sourcing the output: + +``` sh +# Bash (~/.bashrc): +source <(myapp completion bash) + +# Zsh (~/.zshrc): +source <(myapp completion zsh) + +# Fish (~/.config/fish/config.fish): +myapp completion fish | source +``` + +Enum options and subcommands are completed automatically. For custom completions +(e.g., completing device IDs from a database), pass a `--completion` callback when +creating an option: + +``` toit +import cli show * + +create-command -> Command: + return Command "deploy" + --options=[ + Option "device" + --help="The device to deploy to." + --completion=:: | context/CompletionContext | + [ + CompletionCandidate "device-001" --description="Living Room Sensor", + CompletionCandidate "device-002" --description="Garden Monitor", + ] + ] + --run=:: | invocation/Invocation | + print "Deploying to $(invocation["device"])" +``` + +The callback receives a `CompletionContext` with the current prefix, option, +command, and already-provided options. It returns a list of `CompletionCandidate` +objects, which can include descriptions shown by shells that support them (zsh, fish). + +See `examples/completion.toit` for a complete example. + ## Features and bugs Please file feature requests and bugs at the [issue tracker][tracker]. diff --git a/examples/completion.toit b/examples/completion.toit new file mode 100644 index 0000000..c4d7a96 --- /dev/null +++ b/examples/completion.toit @@ -0,0 +1,110 @@ +// 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 shell completion support. + +To try it out, compile to a binary and source the completion script: + +``` +# Compile: +toit compile -o /tmp/fleet examples/completion.toit + +# Enable completions (bash): +source <(/tmp/fleet completion bash) + +# Enable completions (zsh): +source <(/tmp/fleet completion zsh) + +# Enable completions (fish): +/tmp/fleet completion fish | source +``` + +Then type `/tmp/fleet ` and press Tab to see suggestions. +*/ + +main arguments: + root := Command "fleet" + --help=""" + An imaginary fleet manager for Toit devices. + + Manages a fleet of devices, allowing you to deploy firmware, + monitor status, and configure devices. + """ + + root.add create-deploy-command + root.add create-status-command + + root.run arguments + +KNOWN-DEVICES ::= { + "d3b07384-d113-4ec6-a7d2-8c6b2ab3e8f5": "Living Room Sensor", + "6f1ed002-ab5d-42e0-868f-9e0c30e5a295": "Garden Monitor", + "1f3870be-2748-4c9a-81e4-1b3b5e5a5c7f": "Front Door Lock", +} + +/** +Completion callback that returns device UUIDs with human-readable descriptions. +*/ +complete-device context/CompletionContext -> List: + result := [] + KNOWN-DEVICES.do: | uuid/string name/string | + if uuid.starts-with context.prefix: + result.add (CompletionCandidate uuid --description=name) + return result + +create-deploy-command -> Command: + return Command "deploy" + --help=""" + Deploys firmware to a device. + + Uploads and installs the specified firmware file on the target device. + """ + --options=[ + Option "device" --short-name="d" + --help="The device to deploy to." + --completion=:: complete-device it + --required, + OptionEnum "channel" ["stable", "beta", "dev"] + --help="The release channel." + --default="stable", + ] + --rest=[ + Option "firmware" + --type="file" + --help="Path to the firmware file." + --required, + ] + --run=:: run-deploy it + +create-status-command -> Command: + return Command "status" + --help="Shows live status of devices." + --options=[ + Option "device" --short-name="d" + --help="The device to show. Shows all if omitted." + --completion=:: complete-device it, + OptionEnum "format" ["table", "json", "sparkline"] + --help="Output format." + --default="table", + ] + --run=:: run-status it + +run-deploy invocation/Invocation: + device := invocation["device"] + channel := invocation["channel"] + firmware := invocation["firmware"] + name := KNOWN-DEVICES.get device --if-absent=: "unknown" + print "Deploying '$firmware' to '$name' on $channel channel." + +run-status invocation/Invocation: + device := invocation["device"] + format := invocation["format"] + if device: + name := KNOWN-DEVICES.get device --if-absent=: "unknown" + print "Status of '$name' (format: $format)." + else: + print "Status of all devices (format: $format)." diff --git a/src/cli.toit b/src/cli.toit index cc9a7d1..cf77193 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -7,6 +7,8 @@ import uuid show Uuid import system import .cache +import .completion_ +import .completion-scripts_ import .config import .help-generator_ import .parser_ @@ -300,7 +302,23 @@ class Command: run arguments/List -> none --invoked-command=system.program-name --cli/Cli?=null - --add-ui-help/bool=(not cli): + --add-ui-help/bool=(not cli) + --add-completion/bool=true: + if add-completion: + add-completion-command_ --program-path=invoked-command + + // Handle __complete requests before any other processing. + if add-completion and not arguments.is-empty and arguments[0] == "__complete": + if add-ui-help: add-ui-options_ + completion-args := arguments[1..] + if not completion-args.is-empty and completion-args[0] == "--": + completion-args = completion-args[1..] + result := complete_ this completion-args + result.candidates.do: | candidate/CompletionCandidate_ | + print candidate.to-string + print ":$result.directive" + return + if not cli: ui := create-ui-from-args_ arguments log.set-default (ui.logger --name=name) @@ -312,6 +330,60 @@ class Command: invocation := Invocation.private_ cli path.commands parameters invocation.command.run-callback_.call invocation + add-completion-command_ --program-path/string: + // 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 + + prog-name := basename_ program-path + completion-command := Command "completion" + --help=""" + Generate shell completion scripts. + + To enable completions, add the appropriate command to your shell + configuration: + + Bash (~/.bashrc): + source <($program-path completion bash) + + Zsh (~/.zshrc): + source <($program-path completion zsh) + + Fish (~/.config/fish/config.fish): + $program-path completion fish | source + + Alternatively, install the script to the system completion directory + so it loads automatically for all sessions: + + Bash: + $program-path completion bash > /etc/bash_completion.d/$prog-name + + Zsh: + $program-path completion zsh > \$fpath[1]/_$prog-name + + Fish: + $program-path completion fish > ~/.config/fish/completions/$(prog-name).fish""" + --rest=[ + OptionEnum "shell" ["bash", "zsh", "fish"] + --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: + unreachable + print script + subcommands_.add completion-command + add-ui-options_: has-output-format-option := false has-verbose-flag := false @@ -349,6 +421,22 @@ class Command: --default="info" options_.add option + /** + Returns a shell completion script for this command. + + The $shell must be one of "bash", "zsh", or "fish". + The $program-path is the path to the executable. The basename of this path + is used to register the completion with the shell. + */ + completion-script --shell/string --program-path/string=name -> 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 + throw "Unknown shell: $shell. Supported shells: bash, zsh, fish." + /** Checks this command and all subcommands for errors. @@ -447,6 +535,58 @@ class Command: return command return null +/** +A completion candidate returned by completion callbacks. + +Contains a $value and an optional $description. The $description is shown + alongside the value in shells that support it (zsh, fish). +*/ +class CompletionCandidate: + /** The completion value. */ + value/string + + /** + An optional description shown alongside the value. + For example, if the value is a UUID, the description could be the + human-readable name of the entity. + */ + description/string? + + /** + Creates a completion candidate with the given $value and optional $description. + */ + constructor .value --.description=null: + +/** +Context provided to completion callbacks. + +Contains the prefix being completed, the option being completed, the + current command, and the options that have already been seen. +*/ +class CompletionContext: + /** + The text the user has typed so far for the value being completed. + */ + prefix/string + + /** + The option whose value is being completed. + */ + option/Option + + /** + The command that is currently being completed. + */ + command/Command + + /** + A map from option name to a list of values that have been provided + for that option so far. + */ + seen-options/Map + + constructor.private_ --.prefix --.option --.command --.seen-options: + /** An option to a command. @@ -464,6 +604,7 @@ abstract class Option: is-hidden/bool is-multi/bool should-split-commas/bool + completion-callback_/Lambda? /** Deprecated. Use '--help' instead of '--short-help'. */ constructor name/string @@ -474,7 +615,8 @@ abstract class Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: return OptionString name --default=default --type=type @@ -484,6 +626,7 @@ abstract class Option: --hidden=hidden --multi=multi --split-commas=split-commas + --completion=completion /** An alias for $OptionString. */ constructor name/string @@ -494,7 +637,8 @@ abstract class Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: return OptionString name --default=default --type=type @@ -504,6 +648,7 @@ abstract class Option: --hidden=hidden --multi=multi --split-commas=split-commas + --completion=completion /** Deprecated. Use $help instead. */ short-help -> string?: return help @@ -536,13 +681,14 @@ abstract class Option: If $split-commas is true, then $multi must be true too. Values given to this option are then split on commas. For example, `--option a,b,c` will result in the list `["a", "b", "c"]`. */ - constructor.from-subclass .name --.short-name --help/string? --required --hidden --multi --split-commas: + constructor.from-subclass .name --.short-name --help/string? --required --hidden --multi --split-commas --completion/Lambda?=null: this.help = help name = to-kebab name is-required = required is-hidden = hidden is-multi = multi should-split-commas = split-commas + completion-callback_ = completion if name.contains "=" or name.starts-with "no-": throw "Invalid option name: $name" if short-name and not is-alpha-num-string_ short-name: throw "Invalid short option name: '$short-name'" @@ -552,13 +698,14 @@ abstract class Option: throw "Option can't be hidden and required." /** Deprecated. Use --help instead of '--short-help'. */ - constructor.from-subclass .name --.short-name --short-help/string --required --hidden --multi --split-commas: + constructor.from-subclass .name --.short-name --short-help/string --required --hidden --multi --split-commas --completion/Lambda?=null: help = short-help name = to-kebab name is-required = required is-hidden = hidden is-multi = multi should-split-commas = split-commas + completion-callback_ = completion if name.contains "=" or name.starts-with "no-": throw "Invalid option name: $name" if short-name and not is-alpha-num-string_ short-name: throw "Invalid short option name: '$short-name'" @@ -602,6 +749,26 @@ abstract class Option: */ abstract parse str/string --for-help-example/bool=false -> any + /** + Returns the default completion candidates for this option. + + Subclasses override this to provide type-specific completions. + For example, $OptionEnum returns its $OptionEnum.values list. + */ + abstract options-for-completion -> List + + /** + Returns completion candidates for this option's value. + + If a completion callback was provided via `--completion` in the constructor, it is + called with the given $context and must return a list of $CompletionCandidate + objects. Otherwise, the default completions from $options-for-completion are + wrapped as candidates without descriptions. + */ + complete context/CompletionContext -> List: + if completion-callback_: return completion-callback_.call context + return options-for-completion.map: CompletionCandidate it + /** A string option. @@ -628,12 +795,13 @@ class OptionString extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion /** Deprecated. Use '--help' instead of '--short-help'. */ constructor name/string @@ -644,15 +812,18 @@ class OptionString extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=short-help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion is-flag: return false + options-for-completion -> List: return [] + parse str/string --for-help-example/bool=false -> string: return str @@ -687,12 +858,13 @@ class OptionEnum extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion if default and not values.contains default: throw "Default value of '$name' is not a valid value: $default" @@ -705,17 +877,20 @@ class OptionEnum extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=short-help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion if default and not values.contains default: throw "Default value of '$name' is not a valid value: $default" is-flag: return false + options-for-completion -> List: return values + parse str/string --for-help-example/bool=false -> string: if not values.contains str: throw "Invalid value for option '$name': '$str'. Valid values are: $(values.join ", ")." @@ -746,12 +921,13 @@ class OptionInt extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion /** Deprecated. Use '--help' instead of '--short-help'. */ constructor name/string @@ -762,15 +938,18 @@ class OptionInt extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=short-help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion is-flag: return false + options-for-completion -> List: return [] + parse str/string --for-help-example/bool=false -> int: return int.parse str --if-error=: throw "Invalid integer value for option '$name': '$str'." @@ -797,18 +976,21 @@ class OptionPatterns extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion if default: parse_ default --if-error=: throw "Default value of '$name' is not a valid value: $default" is-flag -> bool: return false + options-for-completion -> List: return patterns + /** Returns the pattern that matches the given $str in a map with the pattern as key. */ @@ -834,6 +1016,11 @@ class OptionPatterns extends Option: } /** +// TODO(florian): Add OptionPath that can be configured for file or directory +// completion. Shells support completing only directories (bash: compopt -o +// dirnames, zsh: _directories, fish: __fish_complete_directories), so the +// completion engine could use a directory-only directive. + A Uuid option. */ class OptionUuid extends Option: @@ -855,15 +1042,18 @@ class OptionUuid extends Option: --required/bool=false --hidden/bool=false --multi/bool=false - --split-commas/bool=false: + --split-commas/bool=false + --completion/Lambda?=null: if multi and default: throw "Multi option can't have default value." if required and default: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ --required=required --hidden=hidden --multi=multi \ - --split-commas=split-commas + --split-commas=split-commas --completion=completion is-flag: return false + options-for-completion -> List: return [] + type -> string: return "uuid" parse str/string --for-help-example/bool=false -> Uuid: @@ -897,11 +1087,13 @@ class Flag extends Option: --help/string?=null --required/bool=false --hidden/bool=false - --multi/bool=false: + --multi/bool=false + --completion/Lambda?=null: if multi and default != null: throw "Multi option can't have default value." if required and default != null: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=help \ - --required=required --hidden=hidden --multi=multi --no-split-commas + --required=required --hidden=hidden --multi=multi --no-split-commas \ + --completion=completion /** Deprecated. Use '--help' instead of '--short-help'. */ constructor name/string @@ -910,17 +1102,21 @@ class Flag extends Option: --short-help/string? --required/bool=false --hidden/bool=false - --multi/bool=false: + --multi/bool=false + --completion/Lambda?=null: if multi and default != null: throw "Multi option can't have default value." if required and default != null: throw "Option can't have default value and be required." super.from-subclass name --short-name=short-name --help=short-help \ - --required=required --hidden=hidden --multi=multi --no-split-commas + --required=required --hidden=hidden --multi=multi --no-split-commas \ + --completion=completion type -> string: return "true|false" is-flag: return true + options-for-completion -> List: return ["true", "false"] + parse str/string --for-help-example/bool=false -> bool: if str == "true": return true if str == "false": return false diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit new file mode 100644 index 0000000..d3513e5 --- /dev/null +++ b/src/completion-scripts_.toit @@ -0,0 +1,160 @@ +// 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. + +// TODO(florian): Add support for PowerShell (Register-ArgumentCompleter). + +/** +Extracts the basename from the given $path, stripping any directory components. +*/ +basename_ path/string -> string: + slash := path.index-of --last "/" + if slash >= 0: return path[slash + 1..] + return path + +/** +Sanitizes the given $name for use as a shell function name. + +Replaces all non-alphanumeric characters with underscores and prefixes + with an underscore if the name starts with a digit. +*/ +sanitize-func-name_ name/string -> string: + buffer := [] + name.do --runes: | c/int | + if 'a' <= c <= 'z' or 'A' <= c <= 'Z' or '0' <= c <= '9': + buffer.add (string.from-rune c) + else: + buffer.add "_" + result := buffer.join "" + if result.size > 0 and '0' <= result[0] <= '9': + result = "_$result" + return result + +/** +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 + return """ + _$(func-name)_completions() { + local IFS=\$'\\n' + + local completions + completions=\$("$program-path" __complete -- "\${COMP_WORDS[@]:1:\$COMP_CWORD}") + if [ \$? -ne 0 ]; then + return + fi + + local directive + directive=\$(echo "\$completions" | tail -n 1) + completions=\$(echo "\$completions" | sed '\$d') + + directive="\${directive#:}" + + local candidates=() + while IFS='' read -r line; do + local candidate="\${line%%\$'\\t'*}" + if [ -n "\$candidate" ]; then + candidates+=("\$candidate") + fi + done <<< "\$completions" + + local cur_word="\${COMP_WORDS[\$COMP_CWORD]}" + COMPREPLY=(\$(compgen -W "\${candidates[*]}" -- "\$cur_word")) + + if [[ \$directive -eq 1 ]]; then + compopt +o default 2>/dev/null + elif [[ \$directive -eq 4 ]]; then + if [[ \${#COMPREPLY[@]} -eq 0 ]]; then + compopt -o default 2>/dev/null + fi + fi + } + complete -o default -F _$(func-name)_completions "$program-name\"""" + +/** +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 + return """ + #compdef $program-name + + _$(func-name)() { + local -a completions + local directive + + local output + output=\$("$program-path" __complete -- "\${words[@]:1:\$((CURRENT-1))}" 2>/dev/null) + if [ \$? -ne 0 ]; then + return + fi + + directive=\$(echo "\$output" | tail -n 1) + directive="\${directive#:}" + + local -a lines + lines=("\${(@f)\$(echo "\$output" | sed '\$d')}") + + local -a candidates + for line in "\${lines[@]}"; do + if [[ "\$line" == *\$'\\t'* ]]; then + local val="\${line%%\$'\\t'*}" + local desc="\${line#*\$'\\t'}" + candidates+=("\${val}:\$desc") + else + if [ -n "\$line" ]; then + candidates+=("\$line") + fi + fi + done + + if [[ \${#candidates[@]} -gt 0 ]]; then + _describe '' candidates + fi + + if [[ \$directive -eq 4 ]]; then + _files + fi + } + + compdef _$(func-name) "$program-name\"""" + +/** +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 + return """ + function __$(func-name)_completions + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l output ("$program-path" __complete -- \$tokens[2..] \$current 2>/dev/null) + if test \$status -ne 0 + return + end + + set -l directive (string replace -r '^:(.*)' '\$1' \$output[-1]) + set -e output[-1] + + for line in \$output + set -l parts (string split \\t \$line) + if test (count \$parts) -gt 1 + printf '%s\\t%s\\n' \$parts[1] \$parts[2] + else + if test -n "\$parts[1]" + echo \$parts[1] + end + end + end + + if test "\$directive" = "4" + __fish_complete_path (commandline -ct) + end + end + + complete -c "$program-name" -f -a '(__$(func-name)_completions)'""" diff --git a/src/completion_.toit b/src/completion_.toit new file mode 100644 index 0000000..64388d1 --- /dev/null +++ b/src/completion_.toit @@ -0,0 +1,342 @@ +// 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 +import .utils_ + +/** +The directive indicating default shell behavior. +*/ +DIRECTIVE-DEFAULT_ ::= 0 + +/** +The directive indicating that the shell should not fall back to file completion. +*/ +DIRECTIVE-NO-FILE-COMPLETION_ ::= 1 + +/** +The directive indicating that the shell should fall back to file completion. +*/ +DIRECTIVE-FILE-COMPLETION_ ::= 4 + +/** +A completion candidate with a value and an optional description. +*/ +class CompletionCandidate_: + value/string + description/string? + + constructor .value --.description=null: + + to-string -> string: + if description: return "$value\t$description" + return value + + stringify -> string: + return to-string + +/** +The result of a completion request. + +Contains a list of $candidates and a $directive that tells the shell + how to handle the results. +*/ +class CompletionResult_: + candidates/List + directive/int + + constructor .candidates --.directive=DIRECTIVE-DEFAULT_: + +/** +Computes completion candidates for the given $arguments. + +Walks the command tree starting from $root, determines the completion + context, and returns appropriate candidates. + +The $arguments is the list of words the user has typed so far (everything + before the cursor, split by the shell). +*/ +complete_ root/Command arguments/List -> CompletionResult_: + // Track the current command and accumulated options from all ancestor commands. + current-command := root + is-root := true + all-named-options := {:} // Map from kebab-name to Option. + all-short-options := {:} // Map from short-name to Option. + seen-options := {:} // Map from option name to list of values. + + add-options-for-command_ current-command all-named-options all-short-options + + past-dashdash := false + // The option that is expecting a value (the previous arg was a non-flag option). + pending-option/Option? := null + // How many positional (rest) arguments have been consumed so far. + positional-index := 0 + + // Process all arguments except the last one (which is the word being completed). + args-to-process := arguments.is-empty ? [] : arguments[..arguments.size - 1] + + args-to-process.size.repeat: | index/int | + arg/string := args-to-process[index] + + if past-dashdash: + // After --, everything is a rest argument. Track positional index. + positional-index++ + continue.repeat + + if pending-option: + // This argument is the value for the previous option. + (seen-options.get pending-option.name --init=:[]).add arg + pending-option = null + continue.repeat + + if arg == "--": + past-dashdash = true + continue.repeat + + if arg.starts-with "--": + split := arg.index-of "=" + name := (split < 0) ? arg[2..] : arg[2..split] + if name.starts-with "no-": name = name[3..] + kebab-name := to-kebab name + option := all-named-options.get kebab-name + if option: + if split >= 0: + // --option=value: option is fully provided. + value := arg[split + 1..] + (seen-options.get option.name --init=:[]).add value + else if option.is-flag: + (seen-options.get option.name --init=:[]).add "true" + else: + // Next argument is the value. + pending-option = option + continue.repeat + + if arg.starts-with "-": + // Parse short options. They can be packed (-abc) and short names + // can be multi-character, so we search for matching prefixes like + // the parser does. + for i := 1; i < arg.size; : + option-length := 1 + option/Option? := null + while i + option-length <= arg.size: + short-name := arg[i..i + option-length] + option = all-short-options.get short-name + if option: break + option-length++ + if not option: + // Unknown short option; stop parsing this arg. + break + i += option-length + if option.is-flag: + if not option.is-multi: + (seen-options.get option.name --init=:[]).add "true" + else: + if i < arg.size: + // Value is the rest of the argument (e.g., -oValue). + value := arg[i..] + (seen-options.get option.name --init=:[]).add value + else: + // Next argument is the value. + pending-option = option + break + continue.repeat + + // Not an option — try to descend into a subcommand. + if not current-command.run-callback_: + subcommand := current-command.find-subcommand_ arg + if subcommand: + current-command = subcommand + is-root = false + positional-index = 0 + add-options-for-command_ current-command all-named-options all-short-options + else: + // It's a positional/rest argument. + positional-index++ + + // Now determine what to complete for the last argument (the word being typed). + current-word := arguments.is-empty ? "" : arguments.last + + // If we were expecting a value for an option, complete that option's values. + if pending-option: + context := CompletionContext.private_ + --option=pending-option + --command=current-command + --seen-options=seen-options + --prefix=current-word + completions := pending-option.complete context + directive := has-completer_ pending-option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ + return CompletionResult_ + completions.map: to-candidate_ it + --directive=directive + + // After --, only rest arguments (no option completions). + if past-dashdash: + return complete-rest_ current-command seen-options current-word --positional-index=positional-index + + // Completing an option value with --option=prefix. + if current-word.starts-with "--" and (current-word.index-of "=") >= 0: + split := current-word.index-of "=" + option-name := current-word[2..split] + if option-name.starts-with "no-": option-name = option-name[3..] + prefix := current-word[split + 1..] + kebab-name := to-kebab option-name + option := all-named-options.get kebab-name + if option: + context := CompletionContext.private_ + --option=option + --command=current-command + --seen-options=seen-options + --prefix=prefix + completions := option.complete context + // Prepend the --option= part to each candidate. + option-prefix := current-word[..split + 1] + candidates := completions.map: | c/CompletionCandidate | + CompletionCandidate_ "$option-prefix$c.value" --description=c.description + directive := has-completer_ option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ + return CompletionResult_ candidates --directive=directive + return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_ + + // Completing an option name. + if current-word.starts-with "-": + return complete-option-names_ current-command all-named-options seen-options current-word + + // Completing a subcommand or rest argument. + if not current-command.run-callback_: + return complete-subcommands_ current-command all-named-options seen-options current-word --is-root=is-root + else: + return complete-rest_ current-command seen-options current-word --positional-index=positional-index + +/** +Adds the options of the given $command to the option maps. +*/ +add-options-for-command_ command/Command named-options/Map short-options/Map: + command.options_.do: | option/Option | + named-options[option.name] = option + if option.short-name: short-options[option.short-name] = option + +/** +Completes option names for the given $current-word. +*/ +complete-option-names_ command/Command all-named-options/Map seen-options/Map current-word/string -> CompletionResult_: + candidates := [] + + all-named-options.do: | name/string option/Option | + if option.is-hidden: continue.do + // Skip already-provided non-multi options. + if (seen-options.contains name) and not option.is-multi: continue.do + + long-name := "--$name" + if long-name.starts-with current-word: + candidates.add (CompletionCandidate_ long-name --description=option.help) + + // Suggest --no-name for flags. + if option.is-flag: + no-name := "--no-$name" + if no-name.starts-with current-word: + candidates.add (CompletionCandidate_ no-name --description=option.help) + + if option.short-name: + short := "-$option.short-name" + if short.starts-with current-word: + candidates.add (CompletionCandidate_ short --description=option.help) + + // Also suggest --help / -h if those names are not already in use. + has-help-option := all-named-options.contains "help" + has-h-short := all-named-options.any: | _ option/Option | option.short-name == "h" + if not has-help-option and "--help".starts-with current-word: + candidates.add (CompletionCandidate_ "--help" --description="Show help for this command.") + if not has-h-short and "-h".starts-with current-word: + candidates.add (CompletionCandidate_ "-h" --description="Show help for this command.") + + return CompletionResult_ candidates --directive=DIRECTIVE-NO-FILE-COMPLETION_ + +/** +Completes subcommand names for the given $current-word. + +Also includes option names since they can be interleaved with subcommands. +Only suggests "help" when $is-root is true, matching the parser behavior. +Only suggests option names when $current-word starts with "-". +*/ +complete-subcommands_ command/Command all-named-options/Map seen-options/Map current-word/string --is-root/bool -> CompletionResult_: + candidates := [] + + command.subcommands_.do: | sub/Command | + if sub.is-hidden_: continue.do + if sub.name.starts-with current-word: + candidates.add (CompletionCandidate_ sub.name --description=sub.short-help) + sub.aliases_.do: | alias/string | + if alias.starts-with current-word: + candidates.add (CompletionCandidate_ alias --description=sub.short-help) + + // Only suggest "help" at the root level, matching the parser. + if is-root and "help".starts-with current-word: + candidates.add (CompletionCandidate_ "help" --description="Show help for a command.") + + // Only include option names when the user is typing an option (starts with "-"). + if current-word.starts-with "-": + all-named-options.do: | name/string option/Option | + if option.is-hidden: continue.do + if (seen-options.contains name) and not option.is-multi: continue.do + long-name := "--$name" + if long-name.starts-with current-word: + candidates.add (CompletionCandidate_ long-name --description=option.help) + + // Also suggest --help / -h if those names are not already in use. + has-help-option := all-named-options.contains "help" + has-h-short := all-named-options.any: | _ option/Option | option.short-name == "h" + if not has-help-option and "--help".starts-with current-word: + candidates.add (CompletionCandidate_ "--help" --description="Show help for this command.") + if not has-h-short and "-h".starts-with current-word: + candidates.add (CompletionCandidate_ "-h" --description="Show help for this command.") + + return CompletionResult_ candidates --directive=DIRECTIVE-NO-FILE-COMPLETION_ + +/** +Completes rest arguments. + +Returns file completion directive since rest arguments are often file paths. +*/ +complete-rest_ command/Command seen-options/Map current-word/string --positional-index/int=0 -> CompletionResult_: + // If there are rest options with completion callbacks, use them. + // Skip rest options that have already been consumed by earlier positional args. + // Multi options absorb all remaining positionals, so we stop skipping once we + // reach a multi option. + skip := positional-index + command.rest_.do: | option/Option | + if skip > 0 and not option.is-multi: + skip-- + continue.do + context := CompletionContext.private_ + --option=option + --command=command + --seen-options=seen-options + --prefix=current-word + completions := option.complete context + if not completions.is-empty: + candidates := completions.map: to-candidate_ it + return CompletionResult_ candidates --directive=DIRECTIVE-NO-FILE-COMPLETION_ + + return CompletionResult_ [] --directive=DIRECTIVE-FILE-COMPLETION_ + +/** +Whether the given $option has meaningful completion support. + +An option has a completer if it has a custom completion callback or + if it provides built-in completion values (like enum values). +Options without either (like plain string/int options) don't have a + completer, and should fall back to file completion. +*/ +has-completer_ option/Option -> bool: + return option.completion-callback_ != null + or not option.options-for-completion.is-empty + +/** +Converts a public $CompletionCandidate to an internal $CompletionCandidate_. +*/ +to-candidate_ candidate/CompletionCandidate -> CompletionCandidate_: + return CompletionCandidate_ candidate.value --description=candidate.description diff --git a/src/help-generator_.toit b/src/help-generator_.toit index 5a2e0a0..6364b7f 100644 --- a/src/help-generator_.toit +++ b/src/help-generator_.toit @@ -244,6 +244,13 @@ class HelpGenerator: if not has-help-subcommand and is-root-command_: commands-and-help.add ["help", "Show help for a command."] + has-completion-subcommand := false + command_.subcommands_.do: | subcommand/Command | + if subcommand.name == "completion": has-completion-subcommand = true + subcommand.aliases_.do: if it == "completion": has-completion-subcommand = true + if not has-completion-subcommand and is-root-command_: + commands-and-help.add ["completion", "Generate shell completion scripts."] + sorted-commands := commands-and-help.sort: | a/List b/List | a[0].compare-to b[0] write-table_ sorted-commands --indentation=2 diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit new file mode 100644 index 0000000..d8ab025 --- /dev/null +++ b/tests/completion_shell_test.toit @@ -0,0 +1,238 @@ +// 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 fs +import host.pipe +import host.directory +import system + +/** +A tmux session wrapper for testing interactive shell completions. +*/ +class Tmux: + /** The socket name, unique per session to avoid server conflicts. */ + socket-name/string + + constructor .socket-name --shell-cmd/List --width/int=200 --height/int=50: + args := [ + "tmux", + "-L", socket-name, // Use a dedicated server socket. + "new-session", + "-d", // Detached. + "-s", socket-name, + "-x", "$width", + "-y", "$height", + ] + shell-cmd + exit-code := pipe.run-program args + if exit-code != 0: + throw "tmux new-session failed with exit code $exit-code for shell '$shell-cmd'" + // Wait for the shell to initialize. + send-line "echo tmux-ready" + wait-for "tmux-ready" + + /** + Sends keystrokes to the tmux session. + Each argument is a tmux key name (e.g. "Enter", "Tab", "C-c"). + */ + send-keys keys/List -> none: + pipe.run-program ["tmux", "-L", socket-name, "send-keys", "-t", socket-name] + keys + + /** Sends text followed by Enter. */ + send-line text/string -> none: + send-keys [text, "Enter"] + + /** Sends Ctrl-C to cancel the current line, then waits for the shell to be ready. */ + cancel -> none: + send-keys ["C-c"] + // Echo a marker and wait for it so we know the shell is ready. + marker := "ready-$Time.monotonic-us" + send-line "echo $marker" + wait-for marker + + /** Captures the current pane content as a string. */ + capture -> string: + return pipe.backticks ["tmux", "-L", socket-name, "capture-pane", "-t", socket-name, "-p"] + + /** + Waits until the pane contains the given $expected string, or throws on timeout. + */ + wait-for expected/string --timeout-ms/int=5000 -> none: + deadline := Time.monotonic-us + timeout-ms * 1000 + delay-ms := 10 + while Time.monotonic-us < deadline: + content := capture + if content.contains expected: return + sleep --ms=delay-ms + delay-ms = min 500 (delay-ms * 2) + content := capture + throw "Timed out waiting for '$expected' in tmux pane. Content:\n$content" + + /** Kills the tmux server (and its session). */ + close -> none: + catch: pipe.run-program ["tmux", "-L", socket-name, "kill-server"] + +// Unique session prefix for this test run. +session-id_ := 0 + +next-session-name_ -> string: + session-id_++ + return "completion-test-$Time.monotonic-us-$session-id_" + +with-tmp-dir [block]: + tmpdir := directory.mkdtemp "/tmp/completion-shell-test-" + try: + block.call tmpdir + finally: + catch: pipe.run-program ["rm", "-rf", tmpdir] + +binary_ := ? + +has-command_ name/string -> bool: + exit-code := pipe.run-program ["which", name] + return exit-code == 0 + +main: + if not has-command_ "tmux": + print "tmux not found, skipping shell completion tests." + return + + test-dir := fs.dirname system.program-path + app-source := "$test-dir/completion_shell_test_app.toit" + + with-tmp-dir: | tmpdir | + binary_ = "$tmpdir/fleet" + print "Compiling test app..." + pipe.run-program ["toit", "compile", "-o", binary_, app-source] + print "Binary compiled: $binary_" + + test-bash + test-zsh + test-fish + + print "" + print "All shell completion tests passed!" + +test-bash: + // Bash 3.x (macOS default) lacks compopt and has limited programmable + // completion support. Skip if bash is too old. + bash-version := (pipe.backticks ["bash", "-c", "echo \$BASH_VERSINFO"]).trim + if bash-version == "" or bash-version[0] < '4': + print "" + print "=== Skipping bash tests (bash $bash-version too old, need 4+) ===" + return + + print "" + print "=== Testing bash completion ===" + tmux := Tmux (next-session-name_) --shell-cmd=["bash", "--norc", "--noprofile"] + try: + tmux.send-line "source <($binary_ completion bash); echo sourced" + tmux.wait-for "sourced" + + // Subcommand completion (double Tab for ambiguous matches). + tmux.send-keys ["$binary_ ", "Tab", "Tab"] + tmux.wait-for "deploy" + content := tmux.capture + assert_ (content.contains "status") + assert_ (content.contains "help") + assert_ (content.contains "completion") + tmux.cancel + + // Unique prefix auto-completes inline. + tmux.send-keys ["$binary_ dep", "Tab"] + tmux.wait-for "deploy" + tmux.cancel + + // Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab", "Tab"] + tmux.wait-for "stable" + content = tmux.capture + assert_ (content.contains "beta") + assert_ (content.contains "dev") + tmux.cancel + + // Short option -d triggers device completion. + tmux.send-keys ["$binary_ deploy -d ", "Tab", "Tab"] + tmux.wait-for "d3b07384" + tmux.cancel + + print " All bash tests passed." + finally: + tmux.close + +test-zsh: + if not has-command_ "zsh": + print "" + print "=== Skipping zsh tests (zsh not installed) ===" + return + + print "" + print "=== Testing zsh completion ===" + tmux := Tmux (next-session-name_) --shell-cmd=["zsh", "-f"] + try: + tmux.send-line "autoload -U compinit && compinit -u && echo ready" + tmux.wait-for "ready" + tmux.send-line "source <($binary_ completion zsh) && echo sourced" + tmux.wait-for "sourced" + + // Subcommand completion. + tmux.send-keys ["$binary_ ", "Tab"] + tmux.wait-for "deploy" + content := tmux.capture + assert_ (content.contains "status") + tmux.cancel + + // Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab"] + tmux.wait-for "stable" + content = tmux.capture + assert_ (content.contains "beta") + tmux.cancel + + // Device completion with descriptions. + tmux.send-keys ["$binary_ deploy --device ", "Tab"] + tmux.wait-for "Living Room Sensor" + tmux.cancel + + print " All zsh tests passed." + finally: + tmux.close + +test-fish: + if not has-command_ "fish": + print "" + print "=== Skipping fish tests (fish not installed) ===" + return + + print "" + print "=== Testing fish completion ===" + tmux := Tmux (next-session-name_) --shell-cmd=["fish", "--no-config"] + try: + tmux.send-line "$binary_ completion fish | source; echo sourced" + tmux.wait-for "sourced" + + // Subcommand completion. + tmux.send-keys ["$binary_ ", "Tab"] + tmux.wait-for "deploy" + content := tmux.capture + assert_ (content.contains "status") + tmux.cancel + + // Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab"] + tmux.wait-for "stable" + content = tmux.capture + assert_ (content.contains "beta") + tmux.cancel + + // Device completion with descriptions. + tmux.send-keys ["$binary_ deploy --device ", "Tab"] + tmux.wait-for "Living Room Sensor" + tmux.cancel + + print " All fish tests passed." + finally: + tmux.close + +assert_ condition/bool: + if not condition: throw "ASSERTION_FAILED" diff --git a/tests/completion_shell_test_app.toit b/tests/completion_shell_test_app.toit new file mode 100644 index 0000000..5d4e574 --- /dev/null +++ b/tests/completion_shell_test_app.toit @@ -0,0 +1,51 @@ +// 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. + +// Minimal CLI app used by completion_shell_test.toit to exercise +// shell completion end-to-end via tmux. + +import cli show * + +KNOWN-DEVICES ::= { + "d3b07384-d113-4ec6-a7d2-8c6b2ab3e8f5": "Living Room Sensor", + "6f1ed002-ab5d-42e0-868f-9e0c30e5a295": "Garden Monitor", +} + +main arguments: + root := Command "fleet" + --help="Test fleet manager." + + root.add + Command "deploy" + --help="Deploy firmware." + --options=[ + Option "device" --short-name="d" + --help="Target device." + --completion=:: | ctx/CompletionContext | + result := [] + KNOWN-DEVICES.do: | uuid/string name/string | + if uuid.starts-with ctx.prefix: + result.add (CompletionCandidate uuid --description=name) + result, + OptionEnum "channel" ["stable", "beta", "dev"] + --help="Release channel.", + ] + --run=:: null + + root.add + Command "status" + --help="Show status." + --options=[ + Option "device" --short-name="d" + --help="Device to show." + --completion=:: | ctx/CompletionContext | + result := [] + KNOWN-DEVICES.do: | uuid/string name/string | + if uuid.starts-with ctx.prefix: + result.add (CompletionCandidate uuid --description=name) + result, + ] + --run=:: null + + root.run arguments diff --git a/tests/completion_test.toit b/tests/completion_test.toit new file mode 100644 index 0000000..a25b485 --- /dev/null +++ b/tests/completion_test.toit @@ -0,0 +1,555 @@ +// 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 expect show * + + +main: + test-empty-input + test-subcommand-completion + test-option-name-completion + test-option-value-completion + test-enum-completion + test-flag-completion + test-after-dashdash + test-hidden-excluded + test-alias-completion + test-already-provided-excluded + test-multi-still-suggested + test-custom-completion-callback + test-completion-context + test-nested-subcommands + test-rest-completion + test-option-equals-value + test-completion-with-descriptions + test-help-only-at-root + test-flags-hidden-without-dash-prefix + test-rest-positional-index + test-rest-positional-index-after-dashdash + test-rest-multi-not-skipped + test-short-option-marks-seen + test-short-option-pending-value + test-packed-short-options + test-custom-completer-no-file-fallback + test-help-gated-on-availability + +test-empty-input: + root := cli.Command "app" + --subcommands=[ + cli.Command "serve" --help="Start a server." --run=:: null, + cli.Command "build" --help="Build the project." --run=:: null, + ] + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "serve") + expect (values.contains "build") + expect (values.contains "help") + // Flags should NOT appear when prefix is empty (no "-" prefix). + expect (not (values.contains "--help")) + expect (not (values.contains "-h")) + +test-subcommand-completion: + root := cli.Command "app" + --subcommands=[ + cli.Command "serve" --help="Start a server." --run=:: null, + cli.Command "status" --help="Show status." --run=:: null, + cli.Command "build" --help="Build the project." --run=:: null, + ] + // Complete with prefix "s". + result := complete_ root ["s"] + values := result.candidates.map: it.value + expect (values.contains "serve") + expect (values.contains "status") + expect (not (values.contains "build")) + +test-option-name-completion: + root := cli.Command "app" + --options=[ + cli.Option "output" --short-name="o" --help="Output path.", + cli.Flag "verbose" --short-name="v" --help="Be verbose.", + ] + --run=:: null + // Complete "--". + result := complete_ root ["--"] + values := result.candidates.map: it.value + expect (values.contains "--output") + expect (values.contains "--verbose") + expect (values.contains "--no-verbose") + expect (values.contains "--help") + + // Complete "-". + result = complete_ root ["-"] + values = result.candidates.map: it.value + expect (values.contains "-o") + expect (values.contains "-v") + expect (values.contains "-h") + + // Complete "--ou". + result = complete_ root ["--ou"] + values = result.candidates.map: it.value + expect (values.contains "--output") + expect (not (values.contains "--verbose")) + +test-option-value-completion: + root := cli.Command "app" + --options=[ + cli.OptionEnum "format" ["json", "text", "csv"] --help="Output format.", + cli.Option "file" --help="Input file.", + ] + --run=:: null + + // After a non-flag option, complete its values. + result := complete_ root ["--format", ""] + values := result.candidates.map: it.value + expect-equals 3 values.size + expect (values.contains "json") + expect (values.contains "text") + expect (values.contains "csv") + expect-equals DIRECTIVE-NO-FILE-COMPLETION_ result.directive + + // After a string option with no completions, expect file completion. + result = complete_ root ["--file", ""] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + +test-enum-completion: + root := cli.Command "app" + --options=[ + cli.OptionEnum "color" ["red", "green", "blue"] --help="Pick a color.", + ] + --run=:: null + // Complete after --color. + result := complete_ root ["--color", "r"] + values := result.candidates.map: it.value + // All enum values are returned; shell filters by prefix. + expect (values.contains "red") + expect (values.contains "green") + expect (values.contains "blue") + +test-flag-completion: + root := cli.Command "app" + --options=[ + cli.Flag "verbose" --help="Be verbose.", + ] + --run=:: null + // Flags don't need value completion in normal flow since the parser + // doesn't consume the next arg. But --verbose= should complete. + result := complete_ root ["--verbose="] + values := result.candidates.map: it.value + expect (values.contains "--verbose=true") + expect (values.contains "--verbose=false") + +test-after-dashdash: + root := cli.Command "app" + --options=[ + cli.Option "output" --help="Output path.", + ] + --rest=[ + cli.Option "files" --multi --help="Input files.", + ] + --run=:: null + // After --, no option completion. + result := complete_ root ["--", ""] + values := result.candidates.map: it.value + expect (not (values.contains "--output")) + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + +test-hidden-excluded: + root := cli.Command "app" + --options=[ + cli.Option "secret" --hidden --help="Secret option.", + cli.Option "visible" --help="Visible option.", + ] + --subcommands=[ + cli.Command "hidden-cmd" --hidden --run=:: null, + cli.Command "visible-cmd" --help="Visible command." --run=:: null, + ] + // Hidden options and commands should not appear. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (not (values.contains "--secret")) + expect (not (values.contains "hidden-cmd")) + expect (values.contains "visible-cmd") + + result = complete_ root ["--"] + values = result.candidates.map: it.value + expect (not (values.contains "--secret")) + expect (values.contains "--visible") + +test-alias-completion: + root := cli.Command "app" + --subcommands=[ + cli.Command "device" --aliases=["dev", "d"] --help="Device commands." --run=:: null, + ] + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "device") + expect (values.contains "dev") + expect (values.contains "d") + + result = complete_ root ["de"] + values = result.candidates.map: it.value + expect (values.contains "device") + expect (values.contains "dev") + +test-already-provided-excluded: + root := cli.Command "app" + --options=[ + cli.Option "output" --help="Output path.", + cli.Option "input" --help="Input path.", + ] + --run=:: null + // After providing --output, it should not be suggested again. + result := complete_ root ["--output", "foo", "--"] + values := result.candidates.map: it.value + expect (not (values.contains "--output")) + expect (values.contains "--input") + +test-multi-still-suggested: + root := cli.Command "app" + --options=[ + cli.Option "tag" --multi --help="Tags.", + ] + --run=:: null + // Multi options should still be suggested after being provided. + result := complete_ root ["--tag", "v1", "--"] + values := result.candidates.map: it.value + expect (values.contains "--tag") + +test-custom-completion-callback: + root := cli.Command "app" + --options=[ + cli.Option "host" --help="Target host." + --completion=:: | context/cli.CompletionContext | + hosts := ["localhost", "staging.example.com", "prod.example.com"] + (hosts.filter: it.starts-with context.prefix).map: cli.CompletionCandidate it, + ] + --run=:: null + // Custom callback is called with the context. + result := complete_ root ["--host", "local"] + values := result.candidates.map: it.value + expect-equals 1 values.size + expect (values.contains "localhost") + + result = complete_ root ["--host", ""] + values = result.candidates.map: it.value + expect-equals 3 values.size + +test-completion-context: + // Verify that the completion context provides seen options. + seen/Map? := null + root := cli.Command "app" + --options=[ + cli.Option "output" --help="Output path.", + cli.Option "target" --help="Target." + --completion=:: | context/cli.CompletionContext | + seen = context.seen-options + ["a", "b"].map: cli.CompletionCandidate it, + ] + --run=:: null + result := complete_ root ["--output", "foo", "--target", ""] + expect-not-null seen + expect (seen.contains "output") + expect-equals ["foo"] seen["output"] + +test-completion-with-descriptions: + root := cli.Command "app" + --options=[ + cli.Option "device" --help="Device to use." + --completion=:: | context/cli.CompletionContext | + [ + cli.CompletionCandidate "abc-123" --description="My Phone", + cli.CompletionCandidate "def-456" --description="My Laptop", + ], + ] + --run=:: null + result := complete_ root ["--device", ""] + expect-equals 2 result.candidates.size + // Check that descriptions are preserved. + candidate := result.candidates.first + expect-equals "abc-123" candidate.value + expect-equals "My Phone" candidate.description + + // Also verify --device=prefix preserves descriptions. + result = complete_ root ["--device=a"] + expect-equals 2 result.candidates.size + candidate = result.candidates.first + expect-equals "--device=abc-123" candidate.value + expect-equals "My Phone" candidate.description + +test-nested-subcommands: + root := cli.Command "app" + --options=[ + cli.Flag "verbose" --help="Be verbose.", + ] + --subcommands=[ + cli.Command "device" + --help="Device commands." + --options=[ + cli.Option "name" --help="Device name.", + ] + --subcommands=[ + cli.Command "list" --help="List devices." --run=:: null, + cli.Command "show" --help="Show device." --run=:: null, + ], + ] + // After "device", complete its subcommands. + result := complete_ root ["device", ""] + values := result.candidates.map: it.value + expect (values.contains "list") + expect (values.contains "show") + // Flags should NOT appear without "-" prefix. + expect (not (values.contains "--verbose")) + expect (not (values.contains "--name")) + + // But with "-" prefix, options should appear. + result = complete_ root ["device", "-"] + values = result.candidates.map: it.value + expect (values.contains "--verbose") + expect (values.contains "--name") + expect (values.contains "--help") + expect (values.contains "-h") + + // Complete "device l". + result = complete_ root ["device", "l"] + values = result.candidates.map: it.value + expect (values.contains "list") + expect (not (values.contains "show")) + +test-rest-completion: + root := cli.Command "app" + --rest=[ + cli.OptionEnum "action" ["start", "stop", "restart"] + --help="Action to perform.", + ] + --run=:: null + // Rest arguments with completion should work. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "start") + expect (values.contains "stop") + expect (values.contains "restart") + +test-option-equals-value: + root := cli.Command "app" + --options=[ + cli.OptionEnum "format" ["json", "text"] --help="Output format.", + ] + --run=:: null + // Complete --format=j. + result := complete_ root ["--format=j"] + values := result.candidates.map: it.value + expect (values.contains "--format=json") + expect (values.contains "--format=text") + +test-help-only-at-root: + root := cli.Command "app" + --subcommands=[ + cli.Command "device" + --help="Device commands." + --subcommands=[ + cli.Command "list" --help="List devices." --run=:: null, + ], + ] + // "help" should appear at root level. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "help") + + // "help" should NOT appear after descending into a subcommand. + result = complete_ root ["device", ""] + values = result.candidates.map: it.value + expect (not (values.contains "help")) + expect (values.contains "list") + +test-flags-hidden-without-dash-prefix: + root := cli.Command "app" + --options=[ + cli.Option "output" --help="Output path.", + ] + --subcommands=[ + cli.Command "serve" --help="Start a server." --run=:: null, + ] + // With empty prefix, flags should not appear. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "serve") + expect (not (values.contains "--output")) + expect (not (values.contains "--help")) + expect (not (values.contains "-h")) + + // With "-" prefix, flags should appear. + result = complete_ root ["-"] + values = result.candidates.map: it.value + expect (values.contains "--output") + expect (values.contains "--help") + expect (values.contains "-h") + // Subcommands should not appear when prefix starts with "-". + expect (not (values.contains "serve")) + +test-rest-positional-index: + root := cli.Command "app" + --rest=[ + cli.OptionEnum "action" ["start", "stop", "restart"] + --help="Action to perform.", + cli.OptionEnum "target" ["dev", "staging", "prod"] + --help="Target environment.", + ] + --run=:: null + // With no prior positional args, should complete the first rest option. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "start") + expect (not (values.contains "dev")) + + // After providing the first positional, should complete the second rest option. + result = complete_ root ["start", ""] + values = result.candidates.map: it.value + expect (not (values.contains "start")) + expect (values.contains "dev") + expect (values.contains "staging") + expect (values.contains "prod") + +test-rest-positional-index-after-dashdash: + root := cli.Command "app" + --rest=[ + cli.OptionEnum "action" ["start", "stop"] + --help="Action to perform.", + cli.OptionEnum "target" ["dev", "prod"] + --help="Target environment.", + ] + --run=:: null + // After -- and one positional, should complete the second rest option. + result := complete_ root ["--", "start", ""] + values := result.candidates.map: it.value + expect (not (values.contains "start")) + expect (values.contains "dev") + expect (values.contains "prod") + +test-rest-multi-not-skipped: + root := cli.Command "app" + --rest=[ + cli.OptionEnum "files" ["a.txt", "b.txt"] --multi + --help="Input files.", + ] + --run=:: null + // Multi rest options should still complete even after prior positionals. + result := complete_ root ["a.txt", ""] + values := result.candidates.map: it.value + expect (values.contains "a.txt") + expect (values.contains "b.txt") + +test-short-option-marks-seen: + root := cli.Command "app" + --options=[ + cli.Option "output" --short-name="o" --help="Output path.", + cli.Option "input" --short-name="i" --help="Input path.", + ] + --run=:: null + // After providing -o with a value, --output should not be suggested again. + result := complete_ root ["-o", "foo", "-"] + values := result.candidates.map: it.value + expect (not (values.contains "--output")) + expect (not (values.contains "-o")) + expect (values.contains "--input") + expect (values.contains "-i") + +test-short-option-pending-value: + root := cli.Command "app" + --options=[ + cli.OptionEnum "format" ["json", "text"] --short-name="f" --help="Format.", + ] + --run=:: null + // After -f, the next word should complete the option's values. + result := complete_ root ["-f", ""] + values := result.candidates.map: it.value + expect (values.contains "json") + expect (values.contains "text") + expect-equals DIRECTIVE-NO-FILE-COMPLETION_ result.directive + +test-packed-short-options: + root := cli.Command "app" + --options=[ + cli.Flag "verbose" --short-name="v" --help="Be verbose.", + cli.Option "output" --short-name="o" --help="Output path.", + cli.Flag "force" --short-name="F" --help="Force.", + ] + --run=:: null + // Packed flags: -vF should mark both as seen. + result := complete_ root ["-vF", "--"] + values := result.candidates.map: it.value + expect (not (values.contains "--verbose")) + expect (not (values.contains "--force")) + expect (values.contains "--output") + + // Packed flag + value option: -vo should set pending for output. + result = complete_ root ["-vo", "out.txt", "--"] + values = result.candidates.map: it.value + expect (not (values.contains "--verbose")) + expect (not (values.contains "--output")) + + // Packed with inline value: -ofile.txt should consume the value. + result = complete_ root ["-ofile.txt", "--"] + values = result.candidates.map: it.value + expect (not (values.contains "--output")) + expect (values.contains "--verbose") + +test-custom-completer-no-file-fallback: + root := cli.Command "app" + --options=[ + cli.Option "host" --help="Target host." + --completion=:: | context/cli.CompletionContext | + hosts := ["localhost", "staging.example.com"] + (hosts.filter: it.starts-with context.prefix).map: cli.CompletionCandidate it, + ] + --run=:: null + // When a custom completer returns no matches, should NOT fall back to file completion. + result := complete_ root ["--host", "xyz"] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-NO-FILE-COMPLETION_ result.directive + + // Same with --option=prefix form. + result = complete_ root ["--host=xyz"] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-NO-FILE-COMPLETION_ result.directive + + // A plain string option with no completer SHOULD fall back to file completion. + root2 := cli.Command "app" + --options=[ + cli.Option "file" --help="Input file.", + ] + --run=:: null + result = complete_ root2 ["--file", ""] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + +test-help-gated-on-availability: + // When a command defines its own "help" option, --help should not be suggested. + root := cli.Command "app" + --options=[ + cli.Option "help" --help="Custom help option.", + ] + --run=:: null + result := complete_ root ["--"] + values := result.candidates.map: it.value + // The user's own --help is in all-named-options and will appear. + expect (values.contains "--help") + // But it should appear only once (from the user's option, not the synthetic one). + expect-equals 1 (values.filter: it == "--help").size + + // When a command uses short-name "h", -h should not be suggested as help. + root2 := cli.Command "app" + --options=[ + cli.Flag "hack" --short-name="h" --help="Hack mode.", + ] + --run=:: null + result = complete_ root2 ["-"] + values = result.candidates.map: it.value + // -h should appear (for --hack), but only once. + expect (values.contains "-h") + expect-equals 1 (values.filter: it == "-h").size + // --help should still appear since "help" as a name is not taken. + expect (values.contains "--help") diff --git a/tests/health/readme10.toit b/tests/health/readme10.toit new file mode 100644 index 0000000..637fd7b --- /dev/null +++ b/tests/health/readme10.toit @@ -0,0 +1,19 @@ +// 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 show * + +create-command -> Command: + return Command "deploy" + --options=[ + Option "device" + --help="The device to deploy to." + --completion=:: | context/CompletionContext | + [ + CompletionCandidate "device-001" --description="Living Room Sensor", + CompletionCandidate "device-002" --description="Garden Monitor", + ] + ] + --run=:: | invocation/Invocation | + print "Deploying to $(invocation["device"])" diff --git a/tests/health/readme9.toit b/tests/health/readme9.toit index c4339bb..93421e8 100644 --- a/tests/health/readme9.toit +++ b/tests/health/readme9.toit @@ -11,7 +11,7 @@ main args: run invocation/Invocation: ui := invocation.cli.ui - ui.emit + ui.emit --result // Block that is invoked if structured data is needed. --structured=: { "result": "Computed result" diff --git a/tests/help_test.toit b/tests/help_test.toit index b0fba81..c27172c 100644 --- a/tests/help_test.toit +++ b/tests/help_test.toit @@ -106,8 +106,9 @@ test-combination: bin/app [] Commands: - help Show help for a command. - sub Long sub. + completion Generate shell completion scripts. + help Show help for a command. + sub Long sub. Options: -h, --help Show help for this command. @@ -338,7 +339,8 @@ test-commands: expected := """ Commands: - help Show help for a command. + completion Generate shell completion scripts. + help Show help for a command. sub """ expect-equals expected (build-commands.call [cmd]) @@ -351,8 +353,9 @@ test-commands: expected = """ Commands: - help Show help for a command. - sub Subcommand. + completion Generate shell completion scripts. + help Show help for a command. + sub Subcommand. """ expect-equals expected (build-commands.call [cmd]) @@ -368,10 +371,11 @@ test-commands: // Commands are sorted. expected = """ Commands: - asub3 Subcommand 3. - help Show help for a command. - sub Subcommand. - sub2 Subcommand 2. + asub3 Subcommand 3. + completion Generate shell completion scripts. + help Show help for a command. + sub Subcommand. + sub2 Subcommand 2. """ expect-equals expected (build-commands.call [cmd]) @@ -390,9 +394,10 @@ test-commands: // If a command has only a long help text, show the first paragraph. expected = """ Commands: - help Show help for a command. - sub First - paragraph. + completion Generate shell completion scripts. + help Show help for a command. + sub First + paragraph. """ expect-equals expected (build-commands.call [cmd]) @@ -414,11 +419,12 @@ test-commands: expected = """ Commands: - help Show help for a command. + completion Generate shell completion scripts. + help Show help for a command. sub - sub2 Long - shorthelp. - sub3 Short help3. + sub2 Long + shorthelp. + sub3 Short help3. """ expect-equals expected (build-commands.call [cmd]) @@ -431,7 +437,8 @@ test-commands: // The automatically added help command is not added. expected = """ Commands: - help My own help. + completion Generate shell completion scripts. + help My own help. """ expect-equals expected (build-commands.call [cmd]) @@ -445,7 +452,8 @@ test-commands: // The automatically added help command is not added. expected = """ Commands: - sub Sub with 'help' alias. + completion Generate shell completion scripts. + sub Sub with 'help' alias. """ expect-equals expected (build-commands.call [cmd]) diff --git a/tests/package.lock b/tests/package.lock index 3009758..35c344c 100644 --- a/tests/package.lock +++ b/tests/package.lock @@ -1,6 +1,7 @@ sdk: ^2.0.0-alpha.188.1 prefixes: cli: .. + fs: pkg-fs host: pkg-host packages: ..: diff --git a/tests/package.yaml b/tests/package.yaml index e6bbdfa..d704d4c 100644 --- a/tests/package.yaml +++ b/tests/package.yaml @@ -1,6 +1,9 @@ dependencies: cli: path: .. + fs: + url: github.com/toitlang/pkg-fs + version: ^2.3.1 host: url: github.com/toitlang/pkg-host version: ^1.17.0