From 749808c8e02542a123561dd3762ff38b5f3e6309 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Wed, 18 Mar 2026 23:57:36 +0100 Subject: [PATCH 01/20] Add completion --- README.md | 48 ++++- examples/completion.toit | 110 ++++++++++ src/cli.toit | 246 +++++++++++++++++++--- src/completion-scripts_.toit | 139 +++++++++++++ src/completion_.toit | 284 ++++++++++++++++++++++++++ src/help-generator_.toit | 7 + tests/completion_test.toit | 383 +++++++++++++++++++++++++++++++++++ tests/health/readme10.toit | 19 ++ tests/health/readme9.toit | 2 +- tests/help_test.toit | 44 ++-- 10 files changed, 1237 insertions(+), 45 deletions(-) create mode 100644 examples/completion.toit create mode 100644 src/completion-scripts_.toit create mode 100644 src/completion_.toit create mode 100644 tests/completion_test.toit create mode 100644 tests/health/readme10.toit 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..618c136 --- /dev/null +++ b/src/completion-scripts_.toit @@ -0,0 +1,139 @@ +// 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 + +/** +Returns a bash completion script for the given $program-path. +*/ +bash-completion-script_ --program-path/string -> string: + program-name := basename_ program-path + // Sanitize the program name for use as a bash function name. + func-name := program-name.replace --all "-" "_" + 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" | head -n -1) + + 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 := program-name.replace --all "-" "_" + 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" | head -n -1)}") + + 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 := program-name.replace --all "-" "_" + 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 + 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..f8d5ad3 --- /dev/null +++ b/src/completion_.toit @@ -0,0 +1,284 @@ +// 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 + + // 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. Nothing to track. + 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 "-": + // Short option. For simplicity, just mark it as seen. + // Short options can be packed (-abc), so we'd need complex parsing. + // Just skip for tracking purposes. + 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 + add-options-for-command_ current-command all-named-options all-short-options + + // 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 := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-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 + + // 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 := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-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 + +/** +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 "--help".starts-with current-word: + candidates.add (CompletionCandidate_ "--help" --description="Show help for this command.") + if "-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 "--help".starts-with current-word: + candidates.add (CompletionCandidate_ "--help" --description="Show help for this command.") + if "-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 -> CompletionResult_: + // If there are rest options with completion callbacks, use them. + command.rest_.do: | option/Option | + 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_ + +/** +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_test.toit b/tests/completion_test.toit new file mode 100644 index 0000000..d2898c7 --- /dev/null +++ b/tests/completion_test.toit @@ -0,0 +1,383 @@ +// 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-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")) 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]) From 34a646d3907c1afe986e39ea3db81b9c5146fa6e Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Wed, 18 Mar 2026 23:57:46 +0100 Subject: [PATCH 02/20] Add support for powershell. --- src/cli.toit | 9 +++++-- src/completion-scripts_.toit | 48 ++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/cli.toit b/src/cli.toit index cf77193..2a0944e 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -364,9 +364,12 @@ class Command: $program-path completion zsh > \$fpath[1]/_$prog-name Fish: - $program-path completion fish > ~/.config/fish/completions/$(prog-name).fish""" + $program-path completion fish > ~/.config/fish/completions/$(prog-name).fish + + PowerShell: + $program-path completion powershell >> \$PROFILE""" --rest=[ - OptionEnum "shell" ["bash", "zsh", "fish"] + OptionEnum "shell" ["bash", "zsh", "fish", "powershell"] --help="The shell to generate completions for." --required, ] @@ -379,6 +382,8 @@ class Command: 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 diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 618c136..463d1aa 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -2,8 +2,6 @@ // 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. */ @@ -137,3 +135,49 @@ fish-completion-script_ --program-path/string -> string: end complete -c $program-name -f -a '(__$(func-name)_completions)'""" + +/** +Returns a PowerShell completion script for the given $program-path. +*/ +powershell-completion-script_ --program-path/string -> string: + program-name := basename_ program-path + return """ + Register-ArgumentCompleter -Native -CommandName '$program-name' -ScriptBlock { + param(\$wordToComplete, \$commandAst, \$cursorPosition) + + \$tokens = \$commandAst.ToString() -split '\\s+' + \$args = \$tokens[1..(\$tokens.Length - 1)] + + \$output = & $program-path __complete -- @args 2>\$null + if (\$LASTEXITCODE -ne 0) { return } + + \$lines = \$output -split '\\n' + \$directive = (\$lines[-1] -replace '^:', '') + \$lines = \$lines[0..(\$lines.Length - 2)] + + foreach (\$line in \$lines) { + if (-not \$line) { continue } + if (\$line -match '^([^\\t]+)\\t(.+)\$') { + \$value = \$Matches[1] + \$desc = \$Matches[2] + } else { + \$value = \$line + \$desc = \$line + } + [System.Management.Automation.CompletionResult]::new( + \$value, + \$value, + 'ParameterValue', + \$desc + ) + } + + if (\$directive -eq '4') { + [System.Management.Automation.CompletionResult]::new( + '', + '', + 'ProviderContainer', + 'File completion' + ) + } + }""" From 8c1deba773bd42286cb07f14956f9a26edaa7e71 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:17:40 +0100 Subject: [PATCH 03/20] Introduce `OptionPath`. --- src/cli.toit | 70 +++++++++++++++++++++++++++++++++--- src/completion-scripts_.toit | 31 ++++++++++++---- src/completion_.toit | 21 ++++++++--- tests/completion_test.toit | 32 +++++++++++++++++ tests/options_test.toit | 26 ++++++++++++++ 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src/cli.toit b/src/cli.toit index 2a0944e..58276cd 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -762,6 +762,16 @@ abstract class Option: */ abstract options-for-completion -> List + /** + Returns the completion directive for this option, or null. + + If non-null, the completion engine uses this directive instead of computing + one from the candidates. Subclasses like $OptionPath override this to + request file or directory completion from the shell. + */ + completion-directive -> int?: + return null + /** Returns completion candidates for this option's value. @@ -1021,11 +1031,63 @@ 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 path option. + +When completing, the shell will suggest file or directory paths depending + on the $is-directory flag. +*/ +class OptionPath extends Option: + default/string? + type/string + + /** + Whether this option completes only directories. + + If true, the shell only suggests directories. If false, it suggests + all files and directories. + */ + is-directory/bool + + /** + Creates a new path option. + + The $default value is null. + The $type defaults to "path" for file paths or "directory" for directory paths. + + If $directory is true, the shell only completes directories. + + See $Option.constructor for the other parameters. + */ + constructor name/string + --.default=null + --directory/bool=false + --.type=(directory ? "directory" : "path") + --short-name/string?=null + --help/string?=null + --required/bool=false + --hidden/bool=false + --multi/bool=false + --split-commas/bool=false + --completion/Lambda?=null: + is-directory = directory + 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 --completion=completion + + is-flag: return false + + options-for-completion -> List: return [] + completion-directive -> int?: + if is-directory: return DIRECTIVE-DIRECTORY-COMPLETION_ + return DIRECTIVE-FILE-COMPLETION_ + + parse str/string --for-help-example/bool=false -> string: + return str + +/** A Uuid option. */ class OptionUuid extends Option: diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 463d1aa..a323c2d 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -50,6 +50,10 @@ bash-completion-script_ --program-path/string -> string: if [[ \${#COMPREPLY[@]} -eq 0 ]]; then compopt -o default 2>/dev/null fi + elif [[ \$directive -eq 8 ]]; then + if [[ \${#COMPREPLY[@]} -eq 0 ]]; then + compopt -o dirnames 2>/dev/null + fi fi } complete -o default -F _$(func-name)_completions $program-name""" @@ -98,6 +102,8 @@ zsh-completion-script_ --program-path/string -> string: if [[ \$directive -eq 4 ]]; then _files + elif [[ \$directive -eq 8 ]]; then + _directories fi } @@ -132,6 +138,12 @@ fish-completion-script_ --program-path/string -> string: end end end + + if test "\$directive" = "4" + __fish_complete_path + else if test "\$directive" = "8" + __fish_complete_directories + end end complete -c $program-name -f -a '(__$(func-name)_completions)'""" @@ -172,12 +184,17 @@ powershell-completion-script_ --program-path/string -> string: ) } - if (\$directive -eq '4') { - [System.Management.Automation.CompletionResult]::new( - '', - '', - 'ProviderContainer', - 'File completion' - ) + if (\$directive -eq '4' -or \$directive -eq '8') { + \$completionType = if (\$directive -eq '8') { 'ProviderContainer' } else { 'ProviderItem' } + Get-ChildItem -Path "\$wordToComplete*" -ErrorAction SilentlyContinue | + Where-Object { \$directive -ne '8' -or \$_.PSIsContainer } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + \$_.FullName, + \$_.Name, + \$completionType, + \$_.FullName + ) + } } }""" diff --git a/src/completion_.toit b/src/completion_.toit index f8d5ad3..19c5e8b 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -20,6 +20,11 @@ The directive indicating that the shell should fall back to file completion. */ DIRECTIVE-FILE-COMPLETION_ ::= 4 +/** +The directive indicating that the shell should fall back to directory-only completion. +*/ +DIRECTIVE-DIRECTORY-COMPLETION_ ::= 8 + /** A completion candidate with a value and an optional description. */ @@ -134,7 +139,9 @@ complete_ root/Command arguments/List -> CompletionResult_: --seen-options=seen-options --prefix=current-word completions := pending-option.complete context - directive := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive := pending-option.completion-directive + if not directive: + directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ return CompletionResult_ completions.map: to-candidate_ it --directive=directive @@ -162,7 +169,9 @@ complete_ root/Command arguments/List -> CompletionResult_: option-prefix := current-word[..split + 1] candidates := completions.map: | c/CompletionCandidate | CompletionCandidate_ "$option-prefix$c.value" --description=c.description - directive := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive := option.completion-directive + if not directive: + directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ return CompletionResult_ candidates --directive=directive return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_ @@ -263,6 +272,7 @@ Completes rest arguments. Returns file completion directive since rest arguments are often file paths. */ complete-rest_ command/Command seen-options/Map current-word/string -> CompletionResult_: + // TODO(florian): rest arguments should only suggest paths if the option is an OptionPath. // If there are rest options with completion callbacks, use them. command.rest_.do: | option/Option | context := CompletionContext.private_ @@ -271,9 +281,12 @@ complete-rest_ command/Command seen-options/Map current-word/string -> Completio --seen-options=seen-options --prefix=current-word completions := option.complete context - if not completions.is-empty: + directive := option.completion-directive + if not directive: + directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + if not completions.is-empty or directive != DIRECTIVE-DEFAULT_: candidates := completions.map: to-candidate_ it - return CompletionResult_ candidates --directive=DIRECTIVE-NO-FILE-COMPLETION_ + return CompletionResult_ candidates --directive=directive return CompletionResult_ [] --directive=DIRECTIVE-FILE-COMPLETION_ diff --git a/tests/completion_test.toit b/tests/completion_test.toit index d2898c7..ca721f2 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -27,6 +27,7 @@ main: test-completion-with-descriptions test-help-only-at-root test-flags-hidden-without-dash-prefix + test-option-path test-empty-input: root := cli.Command "app" @@ -381,3 +382,34 @@ test-flags-hidden-without-dash-prefix: expect (values.contains "-h") // Subcommands should not appear when prefix starts with "-". expect (not (values.contains "serve")) + +test-option-path: + // OptionPath for files should use file-completion directive. + root := cli.Command "app" + --options=[ + cli.OptionPath "config" --help="Config file.", + ] + --run=:: null + result := complete_ root ["--config", ""] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + + // OptionPath for directories should use directory-completion directive. + root = cli.Command "app" + --options=[ + cli.OptionPath "output-dir" --directory --help="Output directory.", + ] + --run=:: null + result = complete_ root ["--output-dir", ""] + expect-equals 0 result.candidates.size + expect-equals DIRECTIVE-DIRECTORY-COMPLETION_ result.directive + + // OptionPath with --option=prefix should also use the correct directive. + result = complete_ root ["--output-dir=foo"] + expect-equals DIRECTIVE-DIRECTORY-COMPLETION_ result.directive + + // OptionPath type should reflect the directory flag. + file-opt := cli.OptionPath "file" --help="A file." + expect-equals "path" file-opt.type + dir-opt := cli.OptionPath "dir" --directory --help="A dir." + expect-equals "directory" dir-opt.type diff --git a/tests/options_test.toit b/tests/options_test.toit index d737f8e..f24731b 100644 --- a/tests/options_test.toit +++ b/tests/options_test.toit @@ -13,6 +13,7 @@ main: test-int test-uuid test-flag + test-path test-bad-combos test-string: @@ -190,3 +191,28 @@ test-bad-combos: expect-throw "Multi option can't have default value.": cli.Flag "foo" --default=false --multi + +test-path: + option := cli.OptionPath "config" --help="Config file." + expect-equals "config" option.name + expect-null option.default + expect-equals "path" option.type + expect-not option.is-flag + expect-not option.is-directory + + option = cli.OptionPath "output" --directory --help="Output dir." + expect-equals "directory" option.type + expect option.is-directory + + option = cli.OptionPath "input" --default="/tmp/foo" --help="Input." + expect-equals "/tmp/foo" option.default + + value := option.parse "/some/path" + expect-equals "/some/path" value + + // OptionPath supports the same combos as other options. + option = cli.OptionPath "files" --multi --help="Files." + expect option.is-multi + + expect-throw "Multi option can't have default value.": + cli.OptionPath "foo" --default="bar" --multi From 4bc9ddf76bb2d24faf82fdfd319aa3d598601aff Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:31:27 +0100 Subject: [PATCH 04/20] Feedback. --- src/completion_.toit | 52 ++++++++++++++--- tests/completion_test.toit | 113 +++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/src/completion_.toit b/src/completion_.toit index f8d5ad3..a7fe455 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -70,6 +70,8 @@ complete_ root/Command arguments/List -> CompletionResult_: 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] @@ -78,7 +80,8 @@ complete_ root/Command arguments/List -> CompletionResult_: arg/string := args-to-process[index] if past-dashdash: - // After --, everything is a rest argument. Nothing to track. + // After --, everything is a rest argument. Track positional index. + positional-index++ continue.repeat if pending-option: @@ -110,9 +113,33 @@ complete_ root/Command arguments/List -> CompletionResult_: continue.repeat if arg.starts-with "-": - // Short option. For simplicity, just mark it as seen. - // Short options can be packed (-abc), so we'd need complex parsing. - // Just skip for tracking purposes. + // 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. @@ -121,7 +148,11 @@ complete_ root/Command arguments/List -> CompletionResult_: 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 @@ -141,7 +172,7 @@ complete_ root/Command arguments/List -> CompletionResult_: // After --, only rest arguments (no option completions). if past-dashdash: - return complete-rest_ current-command seen-options current-word + 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: @@ -174,7 +205,7 @@ complete_ root/Command arguments/List -> CompletionResult_: 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 + return complete-rest_ current-command seen-options current-word --positional-index=positional-index /** Adds the options of the given $command to the option maps. @@ -262,9 +293,16 @@ Completes rest arguments. Returns file completion directive since rest arguments are often file paths. */ -complete-rest_ command/Command seen-options/Map current-word/string -> CompletionResult_: +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 diff --git a/tests/completion_test.toit b/tests/completion_test.toit index d2898c7..19a0e27 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -27,6 +27,12 @@ main: 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-empty-input: root := cli.Command "app" @@ -381,3 +387,110 @@ test-flags-hidden-without-dash-prefix: 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") From 7e878ca32a1272b05d781130ff7ea98a3660098c Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:34:30 +0100 Subject: [PATCH 05/20] Feedback. --- src/completion_.toit | 20 ++++++++++++++++++-- tests/completion_test.toit | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/completion_.toit b/src/completion_.toit index a7fe455..bbf3dcc 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -165,7 +165,9 @@ complete_ root/Command arguments/List -> CompletionResult_: --seen-options=seen-options --prefix=current-word completions := pending-option.complete context - directive := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive := has-completer_ pending-option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ return CompletionResult_ completions.map: to-candidate_ it --directive=directive @@ -193,7 +195,9 @@ complete_ root/Command arguments/List -> CompletionResult_: option-prefix := current-word[..split + 1] candidates := completions.map: | c/CompletionCandidate | CompletionCandidate_ "$option-prefix$c.value" --description=c.description - directive := completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive := has-completer_ option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ return CompletionResult_ candidates --directive=directive return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_ @@ -315,6 +319,18 @@ complete-rest_ command/Command seen-options/Map current-word/string --positional 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_. */ diff --git a/tests/completion_test.toit b/tests/completion_test.toit index 19a0e27..2e0030c 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -33,6 +33,7 @@ main: test-short-option-marks-seen test-short-option-pending-value test-packed-short-options + test-custom-completer-no-file-fallback test-empty-input: root := cli.Command "app" @@ -494,3 +495,32 @@ test-packed-short-options: 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 From b6d700b04f2df12d1ff8b98247783236c5e62e1c Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:35:51 +0100 Subject: [PATCH 06/20] Feedback. --- src/completion_.toit | 16 ++++++++++------ tests/completion_test.toit | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/completion_.toit b/src/completion_.toit index bbf3dcc..64388d1 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -245,10 +245,12 @@ complete-option-names_ command/Command all-named-options/Map seen-options/Map cu if short.starts-with current-word: candidates.add (CompletionCandidate_ short --description=option.help) - // Also suggest --help / -h. - if "--help".starts-with current-word: + // 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 "-h".starts-with current-word: + 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_ @@ -284,10 +286,12 @@ complete-subcommands_ command/Command all-named-options/Map seen-options/Map cur if long-name.starts-with current-word: candidates.add (CompletionCandidate_ long-name --description=option.help) - // Also suggest --help / -h. - if "--help".starts-with current-word: + // 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 "-h".starts-with current-word: + 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_ diff --git a/tests/completion_test.toit b/tests/completion_test.toit index 2e0030c..a25b485 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -34,6 +34,7 @@ main: 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" @@ -524,3 +525,31 @@ test-custom-completer-no-file-fallback: 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") From 4f2285acab4da51bc1408eaf8af9a50ae116c741 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:36:47 +0100 Subject: [PATCH 07/20] Feedback. --- src/completion-scripts_.toit | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 618c136..969c5ad 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -31,7 +31,7 @@ bash-completion-script_ --program-path/string -> string: local directive directive=\$(echo "\$completions" | tail -n 1) - completions=\$(echo "\$completions" | head -n -1) + completions=\$(echo "\$completions" | sed '\$d') directive="\${directive#:}" @@ -79,7 +79,7 @@ zsh-completion-script_ --program-path/string -> string: directive="\${directive#:}" local -a lines - lines=("\${(@f)\$(echo "\$output" | head -n -1)}") + lines=("\${(@f)\$(echo "\$output" | sed '\$d')}") local -a candidates for line in "\${lines[@]}"; do From 1f3e28b25a8a51b8bb83f6d42885a14d582ff7ae Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:40:02 +0100 Subject: [PATCH 08/20] Feedback. --- src/completion-scripts_.toit | 37 ++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 969c5ad..cdc8e79 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -12,19 +12,36 @@ basename_ path/string -> string: 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 - // Sanitize the program name for use as a bash function name. - func-name := program-name.replace --all "-" "_" + 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}") + completions=\$("$program-path" __complete -- "\${COMP_WORDS[@]:1:\$COMP_CWORD}") if [ \$? -ne 0 ]; then return fi @@ -54,14 +71,14 @@ 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 "$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 := program-name.replace --all "-" "_" + func-name := sanitize-func-name_ program-name return """ #compdef $program-name @@ -70,7 +87,7 @@ zsh-completion-script_ --program-path/string -> string: local directive local output - output=\$($program-path __complete -- "\${words[@]:1:\$((CURRENT-1))}" 2>/dev/null) + output=\$("$program-path" __complete -- "\${words[@]:1:\$((CURRENT-1))}" 2>/dev/null) if [ \$? -ne 0 ]; then return fi @@ -103,20 +120,20 @@ zsh-completion-script_ --program-path/string -> string: fi } - compdef _$(func-name) $program-name""" + 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 := program-name.replace --all "-" "_" + 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) + set -l output ("$program-path" __complete -- \$tokens[2..] \$current 2>/dev/null) if test \$status -ne 0 return end @@ -136,4 +153,4 @@ fish-completion-script_ --program-path/string -> string: end end - complete -c $program-name -f -a '(__$(func-name)_completions)'""" + complete -c "$program-name" -f -a '(__$(func-name)_completions)'""" From a3cf1432f305c0c3a91fa992131ad2a95c238049 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 00:56:25 +0100 Subject: [PATCH 09/20] More tests. --- .github/workflows/ci.yml | 11 ++ src/completion-scripts_.toit | 4 + tests/completion_shell_test.toit | 246 +++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 tests/completion_shell_test.toit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb5bca4..002b001 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,17 @@ jobs: with: toit-version: ${{ matrix.toit-version }} + - name: Install tmux and fish (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y tmux fish + + - name: Install tmux and fish (macOS) + if: runner.os == 'macOS' + run: | + brew install tmux fish + - name: Test run: | make test diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index cdc8e79..d3513e5 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -151,6 +151,10 @@ fish-completion-script_ --program-path/string -> string: 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/tests/completion_shell_test.toit b/tests/completion_shell_test.toit new file mode 100644 index 0000000..e12df82 --- /dev/null +++ b/tests/completion_shell_test.toit @@ -0,0 +1,246 @@ +// 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 expect show * +import host.pipe +import host.directory + +/** +A tmux session wrapper for testing interactive shell completions. +*/ +class Tmux: + session-name/string + + constructor .session-name --shell/string --width/int=200 --height/int=50: + pipe.run-program [ + "tmux", "new-session", + "-d", // Detached. + "-s", session-name, + "-x", "$width", + "-y", "$height", + shell, + ] + // Wait for the shell to initialize. + sleep --ms=500 + + /** + 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", "send-keys", "-t", session-name] + keys + + /** Sends a Tab keystroke. */ + send-tab -> none: + send-keys ["Tab"] + + /** Sends text followed by Enter. */ + send-line text/string -> none: + send-keys [text, "Enter"] + + /** Sends Ctrl-C to cancel the current line. */ + cancel -> none: + send-keys ["C-c"] + + /** Captures the current pane content as a string. */ + capture -> string: + return pipe.backticks ["tmux", "capture-pane", "-t", session-name, "-p"] + + /** + Waits until the pane contains the given $expected string, or times out. + Returns true if found, false on timeout. + */ + wait-for expected/string --timeout-ms/int=5000 -> bool: + deadline := Time.monotonic-us + timeout-ms * 1000 + while Time.monotonic-us < deadline: + content := capture + if content.contains expected: return true + sleep --ms=200 + return false + + /** Kills the tmux session. */ + close -> none: + catch: pipe.run-program ["tmux", "kill-session", "-t", session-name] + +// 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 + + with-tmp-dir: | tmpdir | + binary_ = "$tmpdir/fleet" + print "Compiling example binary..." + pipe.run-program ["toit", "compile", "-o", binary_, "examples/completion.toit"] + print "Binary compiled: $binary_" + + test-bash + test-zsh + test-fish + + print "" + print "All shell completion tests passed!" + +test-bash: + print "" + print "=== Testing bash completion ===" + tmux := Tmux (next-session-name_) --shell="bash --norc --noprofile" + try: + // Source the completion script. + tmux.send-line "source <($binary_ completion bash)" + sleep --ms=500 + + // Test 1: Subcommand completion (double Tab for ambiguous matches). + tmux.send-keys ["$binary_ ", "Tab", "Tab"] + expect (tmux.wait-for "deploy") + --message="bash: subcommand 'deploy' should appear" + content := tmux.capture + expect (content.contains "status") + --message="bash: subcommand 'status' should appear" + expect (content.contains "help") + --message="bash: subcommand 'help' should appear" + expect (content.contains "completion") + --message="bash: subcommand 'completion' should appear" + tmux.cancel + sleep --ms=300 + + // Test 2: Unique prefix auto-completes inline. + tmux.send-keys ["$binary_ dep", "Tab"] + sleep --ms=1000 + content = tmux.capture + // With a single match, bash auto-completes inline. + expect (content.contains "deploy") + --message="bash: 'dep' should auto-complete to 'deploy'" + tmux.cancel + sleep --ms=300 + + // Test 3: Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab", "Tab"] + expect (tmux.wait-for "stable") + --message="bash: enum value 'stable' should appear" + content = tmux.capture + expect (content.contains "beta") + --message="bash: enum value 'beta' should appear" + expect (content.contains "dev") + --message="bash: enum value 'dev' should appear" + tmux.cancel + sleep --ms=300 + + // Test 4: Short option -d triggers device completion. + tmux.send-keys ["$binary_ deploy -d ", "Tab", "Tab"] + expect (tmux.wait-for "d3b07384") + --message="bash: short option -d should show device UUID" + tmux.cancel + sleep --ms=300 + + print " All bash tests passed." + finally: + tmux.close + +test-zsh: + print "" + print "=== Testing zsh completion ===" + tmux := Tmux (next-session-name_) --shell="zsh -f" + try: + // Initialize zsh completion system. + tmux.send-line "autoload -U compinit && compinit -u" + sleep --ms=500 + tmux.send-line "source <($binary_ completion zsh)" + sleep --ms=500 + + // Test 1: Subcommand completion. + tmux.send-keys ["$binary_ ", "Tab"] + expect (tmux.wait-for "deploy") + --message="zsh: subcommand 'deploy' should appear" + content := tmux.capture + expect (content.contains "status") + --message="zsh: subcommand 'status' should appear" + tmux.cancel + sleep --ms=300 + + // Test 2: Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab"] + expect (tmux.wait-for "stable") + --message="zsh: enum value 'stable' should appear" + content = tmux.capture + expect (content.contains "beta") + --message="zsh: enum value 'beta' should appear" + tmux.cancel + sleep --ms=300 + + // Test 3: Device completion with descriptions. + tmux.send-keys ["$binary_ deploy --device ", "Tab"] + expect (tmux.wait-for "Living Room Sensor") + --message="zsh: device completion should show description" + tmux.cancel + sleep --ms=300 + + print " All zsh tests passed." + finally: + tmux.close + +test-fish: + // Check if fish is available. + 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="fish --no-config" + try: + // Source the completion script. + tmux.send-line "$binary_ completion fish | source" + sleep --ms=500 + + // Test 1: Subcommand completion. + tmux.send-keys ["$binary_ ", "Tab"] + expect (tmux.wait-for "deploy") + --message="fish: subcommand 'deploy' should appear" + content := tmux.capture + expect (content.contains "status") + --message="fish: subcommand 'status' should appear" + tmux.cancel + sleep --ms=300 + + // Test 2: Enum value completion. + tmux.send-keys ["$binary_ deploy --channel ", "Tab"] + expect (tmux.wait-for "stable") + --message="fish: enum value 'stable' should appear" + content = tmux.capture + expect (content.contains "beta") + --message="fish: enum value 'beta' should appear" + tmux.cancel + sleep --ms=300 + + // Test 3: Device completion with descriptions. + tmux.send-keys ["$binary_ deploy --device ", "Tab"] + expect (tmux.wait-for "Living Room Sensor") + --message="fish: device completion should show description" + tmux.cancel + sleep --ms=300 + + print " All fish tests passed." + finally: + tmux.close From 074a00cbb053e7ba3e9d9c438ad3393ee78ae046 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:03:45 +0100 Subject: [PATCH 10/20] Fix. --- tests/completion_shell_test.toit | 9 +++-- tests/completion_shell_test_app.toit | 51 ++++++++++++++++++++++++++++ tests/package.lock | 1 + tests/package.yaml | 3 ++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/completion_shell_test_app.toit diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index e12df82..b664f1d 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -3,8 +3,10 @@ // be found in the tests/LICENSE file. import expect show * +import fs import host.pipe import host.directory +import system /** A tmux session wrapper for testing interactive shell completions. @@ -88,10 +90,13 @@ main: 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 example binary..." - pipe.run-program ["toit", "compile", "-o", binary_, "examples/completion.toit"] + print "Compiling test app..." + pipe.run-program ["toit", "compile", "-o", binary_, app-source] print "Binary compiled: $binary_" test-bash 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/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 From 8502aabb13953e832db18f8641a43788c5566215 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:06:55 +0100 Subject: [PATCH 11/20] Speed up test. --- tests/completion_shell_test.toit | 45 +++++++++++++++----------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index b664f1d..aa025ba 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -23,8 +23,9 @@ class Tmux: "-y", "$height", shell, ] - // Wait for the shell to initialize. - sleep --ms=500 + // Wait for the shell to initialize by echoing a marker. + send-line "echo tmux-ready" + wait-for "tmux-ready" /** Sends keystrokes to the tmux session. @@ -41,9 +42,13 @@ class Tmux: send-line text/string -> none: send-keys [text, "Enter"] - /** Sends Ctrl-C to cancel the current line. */ + /** 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: @@ -112,8 +117,9 @@ test-bash: tmux := Tmux (next-session-name_) --shell="bash --norc --noprofile" try: // Source the completion script. - tmux.send-line "source <($binary_ completion bash)" - sleep --ms=500 + tmux.send-line "source <($binary_ completion bash); echo sourced" + expect (tmux.wait-for "sourced") + --message="bash: sourcing completion script" // Test 1: Subcommand completion (double Tab for ambiguous matches). tmux.send-keys ["$binary_ ", "Tab", "Tab"] @@ -127,17 +133,13 @@ test-bash: expect (content.contains "completion") --message="bash: subcommand 'completion' should appear" tmux.cancel - sleep --ms=300 // Test 2: Unique prefix auto-completes inline. tmux.send-keys ["$binary_ dep", "Tab"] - sleep --ms=1000 - content = tmux.capture // With a single match, bash auto-completes inline. - expect (content.contains "deploy") + expect (tmux.wait-for "deploy") --message="bash: 'dep' should auto-complete to 'deploy'" tmux.cancel - sleep --ms=300 // Test 3: Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab", "Tab"] @@ -149,14 +151,12 @@ test-bash: expect (content.contains "dev") --message="bash: enum value 'dev' should appear" tmux.cancel - sleep --ms=300 // Test 4: Short option -d triggers device completion. tmux.send-keys ["$binary_ deploy -d ", "Tab", "Tab"] expect (tmux.wait-for "d3b07384") --message="bash: short option -d should show device UUID" tmux.cancel - sleep --ms=300 print " All bash tests passed." finally: @@ -168,10 +168,12 @@ test-zsh: tmux := Tmux (next-session-name_) --shell="zsh -f" try: // Initialize zsh completion system. - tmux.send-line "autoload -U compinit && compinit -u" - sleep --ms=500 - tmux.send-line "source <($binary_ completion zsh)" - sleep --ms=500 + tmux.send-line "autoload -U compinit && compinit -u && echo ready" + expect (tmux.wait-for "ready") + --message="zsh: compinit" + tmux.send-line "source <($binary_ completion zsh) && echo sourced" + expect (tmux.wait-for "sourced") + --message="zsh: sourcing completion script" // Test 1: Subcommand completion. tmux.send-keys ["$binary_ ", "Tab"] @@ -181,7 +183,6 @@ test-zsh: expect (content.contains "status") --message="zsh: subcommand 'status' should appear" tmux.cancel - sleep --ms=300 // Test 2: Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab"] @@ -191,14 +192,12 @@ test-zsh: expect (content.contains "beta") --message="zsh: enum value 'beta' should appear" tmux.cancel - sleep --ms=300 // Test 3: Device completion with descriptions. tmux.send-keys ["$binary_ deploy --device ", "Tab"] expect (tmux.wait-for "Living Room Sensor") --message="zsh: device completion should show description" tmux.cancel - sleep --ms=300 print " All zsh tests passed." finally: @@ -216,8 +215,9 @@ test-fish: tmux := Tmux (next-session-name_) --shell="fish --no-config" try: // Source the completion script. - tmux.send-line "$binary_ completion fish | source" - sleep --ms=500 + tmux.send-line "$binary_ completion fish | source; echo sourced" + expect (tmux.wait-for "sourced") + --message="fish: sourcing completion script" // Test 1: Subcommand completion. tmux.send-keys ["$binary_ ", "Tab"] @@ -227,7 +227,6 @@ test-fish: expect (content.contains "status") --message="fish: subcommand 'status' should appear" tmux.cancel - sleep --ms=300 // Test 2: Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab"] @@ -237,14 +236,12 @@ test-fish: expect (content.contains "beta") --message="fish: enum value 'beta' should appear" tmux.cancel - sleep --ms=300 // Test 3: Device completion with descriptions. tmux.send-keys ["$binary_ deploy --device ", "Tab"] expect (tmux.wait-for "Living Room Sensor") --message="fish: device completion should show description" tmux.cancel - sleep --ms=300 print " All fish tests passed." finally: From c615c3461be45b0bf790dcc958450b367200206d Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:10:55 +0100 Subject: [PATCH 12/20] Speed up more and simplify. --- tests/completion_shell_test.toit | 115 ++++++++++++------------------- 1 file changed, 43 insertions(+), 72 deletions(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index aa025ba..c8b98e4 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -2,7 +2,6 @@ // Use of this source code is governed by a Zero-Clause BSD license that can // be found in the tests/LICENSE file. -import expect show * import fs import host.pipe import host.directory @@ -34,10 +33,6 @@ class Tmux: send-keys keys/List -> none: pipe.run-program ["tmux", "send-keys", "-t", session-name] + keys - /** Sends a Tab keystroke. */ - send-tab -> none: - send-keys ["Tab"] - /** Sends text followed by Enter. */ send-line text/string -> none: send-keys [text, "Enter"] @@ -55,16 +50,17 @@ class Tmux: return pipe.backticks ["tmux", "capture-pane", "-t", session-name, "-p"] /** - Waits until the pane contains the given $expected string, or times out. - Returns true if found, false on timeout. + Waits until the pane contains the given $expected string, or throws on timeout. */ - wait-for expected/string --timeout-ms/int=5000 -> bool: + 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 true - sleep --ms=200 - return false + if content.contains expected: return + sleep --ms=delay-ms + delay-ms = min 500 (delay-ms * 2) + throw "Timed out waiting for '$expected' in tmux pane" /** Kills the tmux session. */ close -> none: @@ -116,46 +112,34 @@ test-bash: print "=== Testing bash completion ===" tmux := Tmux (next-session-name_) --shell="bash --norc --noprofile" try: - // Source the completion script. tmux.send-line "source <($binary_ completion bash); echo sourced" - expect (tmux.wait-for "sourced") - --message="bash: sourcing completion script" + tmux.wait-for "sourced" - // Test 1: Subcommand completion (double Tab for ambiguous matches). + // Subcommand completion (double Tab for ambiguous matches). tmux.send-keys ["$binary_ ", "Tab", "Tab"] - expect (tmux.wait-for "deploy") - --message="bash: subcommand 'deploy' should appear" + tmux.wait-for "deploy" content := tmux.capture - expect (content.contains "status") - --message="bash: subcommand 'status' should appear" - expect (content.contains "help") - --message="bash: subcommand 'help' should appear" - expect (content.contains "completion") - --message="bash: subcommand 'completion' should appear" + assert_ (content.contains "status") + assert_ (content.contains "help") + assert_ (content.contains "completion") tmux.cancel - // Test 2: Unique prefix auto-completes inline. + // Unique prefix auto-completes inline. tmux.send-keys ["$binary_ dep", "Tab"] - // With a single match, bash auto-completes inline. - expect (tmux.wait-for "deploy") - --message="bash: 'dep' should auto-complete to 'deploy'" + tmux.wait-for "deploy" tmux.cancel - // Test 3: Enum value completion. + // Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab", "Tab"] - expect (tmux.wait-for "stable") - --message="bash: enum value 'stable' should appear" + tmux.wait-for "stable" content = tmux.capture - expect (content.contains "beta") - --message="bash: enum value 'beta' should appear" - expect (content.contains "dev") - --message="bash: enum value 'dev' should appear" + assert_ (content.contains "beta") + assert_ (content.contains "dev") tmux.cancel - // Test 4: Short option -d triggers device completion. + // Short option -d triggers device completion. tmux.send-keys ["$binary_ deploy -d ", "Tab", "Tab"] - expect (tmux.wait-for "d3b07384") - --message="bash: short option -d should show device UUID" + tmux.wait-for "d3b07384" tmux.cancel print " All bash tests passed." @@ -167,36 +151,28 @@ test-zsh: print "=== Testing zsh completion ===" tmux := Tmux (next-session-name_) --shell="zsh -f" try: - // Initialize zsh completion system. tmux.send-line "autoload -U compinit && compinit -u && echo ready" - expect (tmux.wait-for "ready") - --message="zsh: compinit" + tmux.wait-for "ready" tmux.send-line "source <($binary_ completion zsh) && echo sourced" - expect (tmux.wait-for "sourced") - --message="zsh: sourcing completion script" + tmux.wait-for "sourced" - // Test 1: Subcommand completion. + // Subcommand completion. tmux.send-keys ["$binary_ ", "Tab"] - expect (tmux.wait-for "deploy") - --message="zsh: subcommand 'deploy' should appear" + tmux.wait-for "deploy" content := tmux.capture - expect (content.contains "status") - --message="zsh: subcommand 'status' should appear" + assert_ (content.contains "status") tmux.cancel - // Test 2: Enum value completion. + // Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab"] - expect (tmux.wait-for "stable") - --message="zsh: enum value 'stable' should appear" + tmux.wait-for "stable" content = tmux.capture - expect (content.contains "beta") - --message="zsh: enum value 'beta' should appear" + assert_ (content.contains "beta") tmux.cancel - // Test 3: Device completion with descriptions. + // Device completion with descriptions. tmux.send-keys ["$binary_ deploy --device ", "Tab"] - expect (tmux.wait-for "Living Room Sensor") - --message="zsh: device completion should show description" + tmux.wait-for "Living Room Sensor" tmux.cancel print " All zsh tests passed." @@ -204,7 +180,6 @@ test-zsh: tmux.close test-fish: - // Check if fish is available. if not has-command_ "fish": print "" print "=== Skipping fish tests (fish not installed) ===" @@ -214,35 +189,31 @@ test-fish: print "=== Testing fish completion ===" tmux := Tmux (next-session-name_) --shell="fish --no-config" try: - // Source the completion script. tmux.send-line "$binary_ completion fish | source; echo sourced" - expect (tmux.wait-for "sourced") - --message="fish: sourcing completion script" + tmux.wait-for "sourced" - // Test 1: Subcommand completion. + // Subcommand completion. tmux.send-keys ["$binary_ ", "Tab"] - expect (tmux.wait-for "deploy") - --message="fish: subcommand 'deploy' should appear" + tmux.wait-for "deploy" content := tmux.capture - expect (content.contains "status") - --message="fish: subcommand 'status' should appear" + assert_ (content.contains "status") tmux.cancel - // Test 2: Enum value completion. + // Enum value completion. tmux.send-keys ["$binary_ deploy --channel ", "Tab"] - expect (tmux.wait-for "stable") - --message="fish: enum value 'stable' should appear" + tmux.wait-for "stable" content = tmux.capture - expect (content.contains "beta") - --message="fish: enum value 'beta' should appear" + assert_ (content.contains "beta") tmux.cancel - // Test 3: Device completion with descriptions. + // Device completion with descriptions. tmux.send-keys ["$binary_ deploy --device ", "Tab"] - expect (tmux.wait-for "Living Room Sensor") - --message="fish: device completion should show description" + 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" From a5bb971bb25530ddd367eeb75c860ec5ff20b1ac Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:16:28 +0100 Subject: [PATCH 13/20] Test fixes. --- tests/completion_shell_test.toit | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index c8b98e4..0b90310 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -22,9 +22,19 @@ class Tmux: "-y", "$height", shell, ] - // Wait for the shell to initialize by echoing a marker. - send-line "echo tmux-ready" - wait-for "tmux-ready" + // Wait for the shell to initialize. Retry sending the marker in case + // the tmux server isn't fully ready yet (e.g. after a previous session + // was killed and the server is restarting). + deadline := Time.monotonic-us + 5_000_000 + delay-ms := 10 + while true: + send-keys ["echo tmux-ready", "Enter"] + sleep --ms=delay-ms + delay-ms = min 500 (delay-ms * 2) + content := capture + if content.contains "tmux-ready": break + if Time.monotonic-us >= deadline: + throw "Timed out waiting for tmux session to start" /** Sends keystrokes to the tmux session. @@ -45,9 +55,14 @@ class Tmux: send-line "echo $marker" wait-for marker - /** Captures the current pane content as a string. */ + /** + Captures the current pane content as a string. + Returns empty string if tmux is temporarily unavailable (e.g. server restarting). + */ capture -> string: - return pipe.backticks ["tmux", "capture-pane", "-t", session-name, "-p"] + catch: + return pipe.backticks ["tmux", "capture-pane", "-t", session-name, "-p"] + return "" /** Waits until the pane contains the given $expected string, or throws on timeout. From b4d4ca61c3e9da97309e739d1bfa98c578503a28 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:20:20 +0100 Subject: [PATCH 14/20] Use a socket. --- .github/workflows/ci.yml | 6 ++--- tests/completion_shell_test.toit | 42 ++++++++++++-------------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002b001..5dcdc7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,13 @@ jobs: with: toit-version: ${{ matrix.toit-version }} - - name: Install tmux and fish (Linux) + - name: Install shell completion test dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y tmux fish + sudo apt-get install -y fish - - name: Install tmux and fish (macOS) + - name: Install shell completion test dependencies (macOS) if: runner.os == 'macOS' run: | brew install tmux fish diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index 0b90310..e473f17 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -11,37 +11,30 @@ import system A tmux session wrapper for testing interactive shell completions. */ class Tmux: - session-name/string + /** The socket name, unique per session to avoid server conflicts. */ + socket-name/string - constructor .session-name --shell/string --width/int=200 --height/int=50: + constructor .socket-name --shell/string --width/int=200 --height/int=50: pipe.run-program [ - "tmux", "new-session", + "tmux", + "-L", socket-name, // Use a dedicated server socket. + "new-session", "-d", // Detached. - "-s", session-name, + "-s", socket-name, "-x", "$width", "-y", "$height", shell, ] - // Wait for the shell to initialize. Retry sending the marker in case - // the tmux server isn't fully ready yet (e.g. after a previous session - // was killed and the server is restarting). - deadline := Time.monotonic-us + 5_000_000 - delay-ms := 10 - while true: - send-keys ["echo tmux-ready", "Enter"] - sleep --ms=delay-ms - delay-ms = min 500 (delay-ms * 2) - content := capture - if content.contains "tmux-ready": break - if Time.monotonic-us >= deadline: - throw "Timed out waiting for tmux session to start" + // 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", "send-keys", "-t", session-name] + keys + pipe.run-program ["tmux", "-L", socket-name, "send-keys", "-t", socket-name] + keys /** Sends text followed by Enter. */ send-line text/string -> none: @@ -55,14 +48,9 @@ class Tmux: send-line "echo $marker" wait-for marker - /** - Captures the current pane content as a string. - Returns empty string if tmux is temporarily unavailable (e.g. server restarting). - */ + /** Captures the current pane content as a string. */ capture -> string: - catch: - return pipe.backticks ["tmux", "capture-pane", "-t", session-name, "-p"] - return "" + 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. @@ -77,9 +65,9 @@ class Tmux: delay-ms = min 500 (delay-ms * 2) throw "Timed out waiting for '$expected' in tmux pane" - /** Kills the tmux session. */ + /** Kills the tmux server (and its session). */ close -> none: - catch: pipe.run-program ["tmux", "kill-session", "-t", session-name] + catch: pipe.run-program ["tmux", "-L", socket-name, "kill-server"] // Unique session prefix for this test run. session-id_ := 0 From ebe5943c2714bb0c150f3d65d62230f216d3a89a Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:24:59 +0100 Subject: [PATCH 15/20] Fixes. --- tests/completion_shell_test.toit | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index e473f17..75aa789 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -14,8 +14,8 @@ class Tmux: /** The socket name, unique per session to avoid server conflicts. */ socket-name/string - constructor .socket-name --shell/string --width/int=200 --height/int=50: - pipe.run-program [ + constructor .socket-name --shell-cmd/List --width/int=200 --height/int=50: + args := [ "tmux", "-L", socket-name, // Use a dedicated server socket. "new-session", @@ -23,8 +23,10 @@ class Tmux: "-s", socket-name, "-x", "$width", "-y", "$height", - shell, - ] + ] + 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" @@ -48,9 +50,14 @@ class Tmux: send-line "echo $marker" wait-for marker - /** Captures the current pane content as a string. */ + /** + Captures the current pane content as a string. + Returns empty string if the session is unavailable. + */ capture -> string: - return pipe.backticks ["tmux", "-L", socket-name, "capture-pane", "-t", socket-name, "-p"] + catch: + return pipe.backticks ["tmux", "-L", socket-name, "capture-pane", "-t", socket-name, "-p"] + return "" /** Waits until the pane contains the given $expected string, or throws on timeout. @@ -113,7 +120,7 @@ main: test-bash: print "" print "=== Testing bash completion ===" - tmux := Tmux (next-session-name_) --shell="bash --norc --noprofile" + tmux := Tmux (next-session-name_) --shell-cmd=["bash", "--norc", "--noprofile"] try: tmux.send-line "source <($binary_ completion bash); echo sourced" tmux.wait-for "sourced" @@ -150,9 +157,14 @@ test-bash: 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="zsh -f" + tmux := Tmux (next-session-name_) --shell-cmd=["zsh", "-f"] try: tmux.send-line "autoload -U compinit && compinit -u && echo ready" tmux.wait-for "ready" @@ -190,7 +202,7 @@ test-fish: print "" print "=== Testing fish completion ===" - tmux := Tmux (next-session-name_) --shell="fish --no-config" + tmux := Tmux (next-session-name_) --shell-cmd=["fish", "--no-config"] try: tmux.send-line "$binary_ completion fish | source; echo sourced" tmux.wait-for "sourced" From 1b4c08d4179281587f2b112a6243823ee4c539e0 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:27:45 +0100 Subject: [PATCH 16/20] Skip if too old. --- tests/completion_shell_test.toit | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index 75aa789..098c812 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -70,7 +70,8 @@ class Tmux: if content.contains expected: return sleep --ms=delay-ms delay-ms = min 500 (delay-ms * 2) - throw "Timed out waiting for '$expected' in tmux pane" + content := capture + throw "Timed out waiting for '$expected' in tmux pane. Content:\n$content" /** Kills the tmux server (and its session). */ close -> none: @@ -118,6 +119,14 @@ main: 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"] From 528b0116a005146ed79480e61c8efdb8480d7758 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 01:29:22 +0100 Subject: [PATCH 17/20] Don't hide errors. --- tests/completion_shell_test.toit | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index 098c812..d8ab025 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -50,14 +50,9 @@ class Tmux: send-line "echo $marker" wait-for marker - /** - Captures the current pane content as a string. - Returns empty string if the session is unavailable. - */ + /** Captures the current pane content as a string. */ capture -> string: - catch: - return pipe.backticks ["tmux", "-L", socket-name, "capture-pane", "-t", socket-name, "-p"] - return "" + 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. From 6ba435a7f4586625b4fda07d72df1f3cc5fbc8bd Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 17:00:54 +0100 Subject: [PATCH 18/20] Add test. --- src/completion-scripts_.toit | 2 +- tests/completion_shell_test.toit | 52 ++++++++++++++++++++++++---- tests/completion_shell_test_app.toit | 2 ++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 2450cd5..9d02331 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -159,7 +159,7 @@ fish-completion-script_ --program-path/string -> string: if test "\$directive" = "4" __fish_complete_path (commandline -ct) else if test "\$directive" = "8" - __fish_complete_directories + __fish_complete_directories (commandline -ct) end end diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index d8ab025..b7ad5db 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -106,14 +106,18 @@ main: pipe.run-program ["toit", "compile", "-o", binary_, app-source] print "Binary compiled: $binary_" - test-bash - test-zsh - test-fish + // Create artifacts for OptionPath completion testing. + pipe.run-program ["touch", "$tmpdir/xfirmware.bin"] + pipe.run-program ["mkdir", "$tmpdir/xreleases"] + + test-bash tmpdir + test-zsh tmpdir + test-fish tmpdir print "" print "All shell completion tests passed!" -test-bash: +test-bash tmpdir/string: // 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 @@ -156,11 +160,23 @@ test-bash: tmux.wait-for "d3b07384" tmux.cancel + // OptionPath: file option falls back to file completion. + tmux.send-line "cd $tmpdir && echo cd-done" + tmux.wait-for "cd-done" + tmux.send-keys ["$binary_ deploy --firmware xfirm", "Tab"] + tmux.wait-for "xfirmware.bin" + tmux.cancel + + // OptionPath --directory: falls back to directory-only completion. + tmux.send-keys ["$binary_ deploy --output-dir xrel", "Tab"] + tmux.wait-for "xreleases" + tmux.cancel + print " All bash tests passed." finally: tmux.close -test-zsh: +test-zsh tmpdir/string: if not has-command_ "zsh": print "" print "=== Skipping zsh tests (zsh not installed) ===" @@ -194,11 +210,23 @@ test-zsh: tmux.wait-for "Living Room Sensor" tmux.cancel + // OptionPath: file option falls back to file completion. + tmux.send-line "cd $tmpdir && echo cd-done" + tmux.wait-for "cd-done" + tmux.send-keys ["$binary_ deploy --firmware xfirm", "Tab"] + tmux.wait-for "xfirmware.bin" + tmux.cancel + + // OptionPath --directory: falls back to directory-only completion. + tmux.send-keys ["$binary_ deploy --output-dir xrel", "Tab"] + tmux.wait-for "xreleases" + tmux.cancel + print " All zsh tests passed." finally: tmux.close -test-fish: +test-fish tmpdir/string: if not has-command_ "fish": print "" print "=== Skipping fish tests (fish not installed) ===" @@ -230,6 +258,18 @@ test-fish: tmux.wait-for "Living Room Sensor" tmux.cancel + // OptionPath: file option falls back to file completion. + tmux.send-line "cd $tmpdir; and echo cd-done" + tmux.wait-for "cd-done" + tmux.send-keys ["$binary_ deploy --firmware xfirm", "Tab"] + tmux.wait-for "xfirmware.bin" + tmux.cancel + + // OptionPath --directory: falls back to directory-only completion. + tmux.send-keys ["$binary_ deploy --output-dir xrel", "Tab"] + tmux.wait-for "xreleases" + tmux.cancel + print " All fish tests passed." finally: tmux.close diff --git a/tests/completion_shell_test_app.toit b/tests/completion_shell_test_app.toit index 5d4e574..f236064 100644 --- a/tests/completion_shell_test_app.toit +++ b/tests/completion_shell_test_app.toit @@ -30,6 +30,8 @@ main arguments: result, OptionEnum "channel" ["stable", "beta", "dev"] --help="Release channel.", + OptionPath "firmware" --help="Firmware file to deploy.", + OptionPath "output-dir" --directory --help="Output directory.", ] --run=:: null From 9fead8f60630d457c42e91d9ed808ed3c2c66723 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 17:09:35 +0100 Subject: [PATCH 19/20] Fix bad merge. --- src/completion_.toit | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/completion_.toit b/src/completion_.toit index 59c0e9f..024010d 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -172,7 +172,9 @@ complete_ root/Command arguments/List -> CompletionResult_: completions := pending-option.complete context directive := pending-option.completion-directive if not directive: - directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive = has-completer_ pending-option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ return CompletionResult_ completions.map: to-candidate_ it --directive=directive @@ -202,7 +204,9 @@ complete_ root/Command arguments/List -> CompletionResult_: CompletionCandidate_ "$option-prefix$c.value" --description=c.description directive := option.completion-directive if not directive: - directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive = has-completer_ option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ return CompletionResult_ candidates --directive=directive return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_ @@ -324,7 +328,9 @@ complete-rest_ command/Command seen-options/Map current-word/string --positional completions := option.complete context directive := option.completion-directive if not directive: - directive = completions.is-empty ? DIRECTIVE-FILE-COMPLETION_ : DIRECTIVE-NO-FILE-COMPLETION_ + directive = has-completer_ option + ? DIRECTIVE-NO-FILE-COMPLETION_ + : DIRECTIVE-FILE-COMPLETION_ if not completions.is-empty or directive != DIRECTIVE-DEFAULT_: candidates := completions.map: to-candidate_ it return CompletionResult_ candidates --directive=directive From eb0e557d5a35369dc1507247eb937abeaf489580 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 19 Mar 2026 22:53:53 +0100 Subject: [PATCH 20/20] Increase timeout. --- tests/completion_shell_test.toit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index b7ad5db..61fe57a 100644 --- a/tests/completion_shell_test.toit +++ b/tests/completion_shell_test.toit @@ -57,7 +57,7 @@ class Tmux: /** Waits until the pane contains the given $expected string, or throws on timeout. */ - wait-for expected/string --timeout-ms/int=5000 -> none: + wait-for expected/string --timeout-ms/int=10_000 -> none: deadline := Time.monotonic-us + timeout-ms * 1000 delay-ms := 10 while Time.monotonic-us < deadline: