Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions examples/comp.toit
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (C) 2026 Toit contributors.
// Use of this source code is governed by an MIT-style license that can be
// found in the package's LICENSE file.

import cli show *

/**
Demonstrates $OptionPath completion with extension filtering.

A tiny imaginary "compiler" that takes an input Toit file and an output
path. The input rest argument uses $OptionPath with `--extensions=[".toit"]`,
so shell completion only suggests `.toit` files (and directories for
navigation).

Since this command has a `--run` callback (and therefore no subcommands),
the library automatically adds a `--generate-completion` option instead
of a `completion` subcommand.

To try it out:

```
# Compile:
toit compile -o /tmp/comp examples/comp.toit

# Enable completions (bash):
source <(/tmp/comp --generate-completion bash)

# Invoke:
/tmp/comp -o /tmp/foo in.toit
```

Then type `/tmp/comp -o /tmp/foo ` and press Tab — only `.toit` files
and directories are suggested.
*/

main arguments:
cmd := Command "comp"
--help="An imaginary compiler that compiles a Toit file."
--options=[
OptionPath "output" --short-name="o"
--help="The output path."
--required,
]
--rest=[
OptionPath "input"
--extensions=[".toit"]
--help="The input Toit file."
--required,
]
--run=:: run-comp it

cmd.run arguments

run-comp invocation/Invocation:
input := invocation["input"]
output := invocation["output"]
print "Compiling '$input' to '$output'."
102 changes: 83 additions & 19 deletions src/cli.toit
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,46 @@ export Ui
export Cache FileStore DirectoryStore
export Config

/**
Shells for which $Command can generate completion scripts.
*/
COMPLETION-SHELLS_ ::= ["bash", "zsh", "fish", "powershell"]

/**
Returns the completion script for the given $shell.

The $program-path is baked into the script and used to re-invoke the
binary at completion time.
*/
completion-script-for-shell_ --shell/string --program-path/string -> string:
if shell == "bash": return bash-completion-script_ --program-path=program-path
if shell == "zsh": return zsh-completion-script_ --program-path=program-path
if shell == "fish": return fish-completion-script_ --program-path=program-path
if shell == "powershell": return powershell-completion-script_ --program-path=program-path
unreachable

/**
Scans $arguments for a "--generate-completion <shell>" or
"--generate-completion=<shell>" occurrence with a known shell value.

Returns the shell name if found, null otherwise.

Unknown or missing values are ignored, so that the normal parser can
report them with its standard error machinery.
*/
find-generate-completion-arg_ arguments/List -> string?:
prefix := "--generate-completion="
for i := 0; i < arguments.size; i++:
arg/string := arguments[i]
value/string? := null
if arg == "--generate-completion":
if i + 1 < arguments.size: value = arguments[i + 1]
else if arg.starts-with prefix:
value = arg[prefix.size..]
if value and (COMPLETION-SHELLS_.contains value):
return value
return null

/**
An object giving access to common operations for CLI programs.

Expand Down Expand Up @@ -310,8 +350,10 @@ class Command:
--cli/Cli?=null
--add-ui-help/bool=(not cli)
--add-completion/bool=true:
added-completion-flag := false
if add-completion:
add-completion-command_ --program-path=invoked-command
added-completion-flag = add-completion-bootstrap_
--program-path=invoked-command

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

// Handle --generate-completion before any other processing, so that
// required rest arguments and run callbacks are skipped.
if added-completion-flag:
shell := find-generate-completion-arg_ arguments
if shell:
print (completion-script-for-shell_ --shell=shell --program-path=invoked-command)
return

if not cli:
ui := create-ui-from-args_ arguments
log.set-default (ui.logger --name=name)
Expand All @@ -339,13 +389,38 @@ class Command:
invocation := Invocation.private_ cli path.commands parameters
invocation.command.run-callback_.call invocation

add-completion-command_ --program-path/string:
/**
Adds a bootstrap mechanism for shell completions.

If the command has no run callback, a "completion" subcommand is added.
Otherwise, a "--generate-completion" option is added to the root command.

Returns true if the "--generate-completion" flag was added.
*/
add-completion-bootstrap_ --program-path/string -> bool:
// Don't add if the user already has a "completion" subcommand.
if find-subcommand_ "completion": return
// Can't add subcommands to a command with rest args or a run callback
// that already has subcommands handled.
if run-callback_: return
if find-subcommand_ "completion": return false
// Don't add if the user already has a "--generate-completion" option.
options_.do: | opt/Option |
if opt.name == "generate-completion": return false

if not run-callback_:
add-completion-subcommand_ --program-path=program-path
return false

// The root has a run callback, so we can't add a subcommand. Fall back
// to a "--generate-completion" flag.
add-completion-flag_
return true

add-completion-flag_:
options_ = options_.copy
options_.add
OptionEnum "generate-completion" COMPLETION-SHELLS_
--type="shell"
--help="Print a shell completion script (bash, zsh, fish, powershell) to stdout and exit."

add-completion-subcommand_ --program-path/string:
prog-name := basename_ program-path
completion-command := Command "completion"
--help="""
Expand Down Expand Up @@ -378,24 +453,13 @@ class Command:
PowerShell:
$program-path completion powershell >> \$PROFILE"""
--rest=[
OptionEnum "shell" ["bash", "zsh", "fish", "powershell"]
OptionEnum "shell" COMPLETION-SHELLS_
--help="The shell to generate completions for."
--required,
]
--run=:: | invocation/Invocation |
shell := invocation["shell"]
script/string := ?
if shell == "bash":
script = bash-completion-script_ --program-path=program-path
else if shell == "zsh":
script = zsh-completion-script_ --program-path=program-path
else if shell == "fish":
script = fish-completion-script_ --program-path=program-path
else if shell == "powershell":
script = powershell-completion-script_ --program-path=program-path
else:
unreachable
print script
print (completion-script-for-shell_ --shell=shell --program-path=program-path)
subcommands_.add completion-command

add-ui-options_:
Expand Down
57 changes: 46 additions & 11 deletions src/completion-scripts_.toit
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ basename_ path/string -> string:
if name.ends-with ".exe": name = name[..name.size - 4]
return name

/**
Returns the list of command names to which a generated completion script
should bind.

A naive `complete`/`compdef` registration only matches the exact basename
of the program — i.e. only when the binary is on \$PATH. Users who source
the script with a path (for example `source <(examples/comp ...)`) would
then invoke the binary as `examples/comp` or `./examples/comp`, which
shells look up verbatim and fail to match.

We therefore register every plausible invocation form:
- the basename (for \$PATH-installed binaries),
- the program-path as given (for relative or absolute paths),
- a "./" prefixed variant for relative paths (the common "./bin" case).
*/
completion-bind-names_ program-path/string -> List:
name := basename_ program-path
names := [name]
if program-path != name: names.add program-path
is-absolute := program-path.starts-with "/"
or (program-path.size >= 2 and program-path[1] == ':') // Windows drive.
already-dotted := program-path.starts-with "./" or program-path.starts-with ".\\"
if not is-absolute and not already-dotted and program-path != name:
names.add "./$program-path"
return names

/**
Sanitizes the given $name for use as a shell function name.

Expand All @@ -39,6 +65,8 @@ Returns a bash completion script for the given $program-path.
bash-completion-script_ --program-path/string -> string:
program-name := basename_ program-path
func-name := sanitize-func-name_ program-name
bind-names := (completion-bind-names_ program-path).map: "\"$it\""
bind-names-str := bind-names.join " "
return """
_$(func-name)_completions() {
local IFS=\$'\\n'
Expand Down Expand Up @@ -104,14 +132,16 @@ bash-completion-script_ --program-path/string -> string:
fi
fi
}
complete -o default -F _$(func-name)_completions "$program-name\""""
complete -o default -F _$(func-name)_completions $bind-names-str"""

/**
Returns a zsh completion script for the given $program-path.
*/
zsh-completion-script_ --program-path/string -> string:
program-name := basename_ program-path
func-name := sanitize-func-name_ program-name
bind-names := (completion-bind-names_ program-path).map: "\"$it\""
bind-names-str := bind-names.join " "
return """
#compdef $program-name

Expand Down Expand Up @@ -157,17 +187,15 @@ zsh-completion-script_ --program-path/string -> string:

if [[ \$directive -eq 4 ]]; then
if [[ -n "\$extensions" ]]; then
# Issue one _files -g call per extension. Alternation
# patterns like "(*.toml|*.yaml)" or "*.(toml|yaml)"
# break _path_files directory-prefix navigation in zsh,
# so we avoid them entirely.
local -a ext_array
ext_array=(\${(s:,:)extensions})
local glob_parts=""
for ext in "\${ext_array[@]}"; do
if [[ -n "\$glob_parts" ]]; then
glob_parts="\$glob_parts|*\$ext"
else
glob_parts="*\$ext"
fi
_files -g "*\$ext"
done
_files -g "(\$glob_parts)"
else
_files
fi
Expand All @@ -176,14 +204,19 @@ zsh-completion-script_ --program-path/string -> string:
fi
}

compdef _$(func-name) "$program-name\""""
compdef _$(func-name) $bind-names-str"""

/**
Returns a fish completion script for the given $program-path.
*/
fish-completion-script_ --program-path/string -> string:
program-name := basename_ program-path
func-name := sanitize-func-name_ program-name
// Fish's `complete -c` takes one command name per invocation, so emit
// one `complete` line per bind name.
bind-lines := (completion-bind-names_ program-path).map: | n/string |
"complete -c \"$n\" -f -a '(__$(func-name)_completions)'"
complete-block := bind-lines.join "\n "
return """
function __$(func-name)_completions
set -l tokens (commandline -opc)
Expand Down Expand Up @@ -239,15 +272,17 @@ fish-completion-script_ --program-path/string -> string:
end
end

complete -c "$program-name" -f -a '(__$(func-name)_completions)'"""
$complete-block"""

/**
Returns a PowerShell completion script for the given $program-path.
*/
powershell-completion-script_ --program-path/string -> string:
program-name := basename_ program-path
bind-names := (completion-bind-names_ program-path).map: "'$it'"
bind-names-str := bind-names.join ","
return """
Register-ArgumentCompleter -Native -CommandName '$program-name' -ScriptBlock {
Register-ArgumentCompleter -Native -CommandName @($bind-names-str) -ScriptBlock {
param(\$wordToComplete, \$commandAst, \$cursorPosition)

\$tokens = \$commandAst.ToString() -split '\\s+'
Expand Down
5 changes: 5 additions & 0 deletions tests/completion_shell.toit
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,10 @@ setup-test-binary_ tmpdir/string -> string:
file.write-contents --path="$tmpdir/xconfig.yaml" ""
file.write-contents --path="$tmpdir/xconfig.txt" ""

// Directory used to verify that extension-filtered completion still lets
// the user navigate into directories.
directory.mkdir "$tmpdir/xsubdir"
file.write-contents --path="$tmpdir/xsubdir/nested.toml" ""

return binary

18 changes: 18 additions & 0 deletions tests/completion_shell_bash_test.toit
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ test-bash binary/string tmpdir/string:
expect (not content.contains "xconfig.txt")
tmux.cancel

// OptionPath --extensions: directories must still be suggested so the
// user can navigate into them.
tmux.send-keys ["$binary deploy --config xsub", "Tab"]
tmux.wait-for "xsubdir"
tmux.cancel

// Completion must also work when the binary is invoked via a relative
// path (e.g. ./fleet) rather than the absolute path baked into the
// completion script. Re-source with a relative path so that
// `complete` registers "./fleet" as a bind name.
tmux.send-line "source <(./fleet completion bash) && echo re-sourced"
tmux.wait-for "re-sourced"
tmux.send-keys ["./fleet deploy --channel ", "Tab", "Tab"]
tmux.wait-for "stable"
content = tmux.capture
expect (content.contains "beta")
tmux.cancel

print " All bash tests passed."
finally:
tmux.close
18 changes: 18 additions & 0 deletions tests/completion_shell_zsh_test.toit
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ test-zsh binary/string tmpdir/string:
expect (not content.contains "xconfig.txt")
tmux.cancel

// OptionPath --extensions: directories must still be suggested so the
// user can navigate into them.
tmux.send-keys ["$binary deploy --config xsub", "Tab"]
tmux.wait-for "xsubdir"
tmux.cancel

// Completion must also work when the binary is invoked via a relative
// path (e.g. ./fleet) rather than the absolute path baked into the
// completion script. Re-source with a relative path so that
// compdef registers "./fleet" as a bind name.
tmux.send-line "source <(./fleet completion zsh) && echo re-sourced"
tmux.wait-for "re-sourced"
tmux.send-keys ["./fleet deploy --channel ", "Tab"]
tmux.wait-for "stable"
content = tmux.capture
expect (content.contains "beta")
tmux.cancel

print " All zsh tests passed."
finally:
tmux.close
Loading