Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
749808c
Add completion
floitsch Mar 18, 2026
34a646d
Add support for powershell.
floitsch Mar 18, 2026
8c1deba
Introduce `OptionPath`.
floitsch Mar 18, 2026
4bc9ddf
Feedback.
floitsch Mar 18, 2026
7e878ca
Feedback.
floitsch Mar 18, 2026
b6d700b
Feedback.
floitsch Mar 18, 2026
4f2285a
Feedback.
floitsch Mar 18, 2026
1f3e28b
Feedback.
floitsch Mar 18, 2026
a3cf143
More tests.
floitsch Mar 18, 2026
074a00c
Fix.
floitsch Mar 19, 2026
8502aab
Speed up test.
floitsch Mar 19, 2026
c615c34
Speed up more and simplify.
floitsch Mar 19, 2026
a5bb971
Test fixes.
floitsch Mar 19, 2026
b4d4ca6
Use a socket.
floitsch Mar 19, 2026
ebe5943
Fixes.
floitsch Mar 19, 2026
1b4c08d
Skip if too old.
floitsch Mar 19, 2026
528b011
Don't hide errors.
floitsch Mar 19, 2026
94b2eea
Merge branch 'floitsch/completion' into floitsch/completion.pwsh
floitsch Mar 19, 2026
4369886
Merge floitsch/completion.pwsh into floitsch/completion.pwsh.OptionPath
floitsch Mar 19, 2026
6ba435a
Add test.
floitsch Mar 19, 2026
9fead8f
Fix bad merge.
floitsch Mar 19, 2026
df62c75
Merge branch 'main' into floitsch/completion
floitsch Mar 19, 2026
25bbba2
Merge branch 'floitsch/completion' into floitsch/completion.pwsh
floitsch Mar 19, 2026
c51a238
Merge branch 'floitsch/completion.pwsh' into floitsch/completion.pwsh…
floitsch Mar 19, 2026
7753ebc
Merge branch 'main' into floitsch/completion.pwsh
floitsch Mar 19, 2026
eced3f3
Merge branch 'floitsch/completion.pwsh' into floitsch/completion.pwsh…
floitsch Mar 19, 2026
eb0e557
Increase timeout.
floitsch Mar 19, 2026
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
70 changes: 66 additions & 4 deletions src/cli.toit
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,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.

Expand Down Expand Up @@ -1020,11 +1030,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:
Expand Down
27 changes: 20 additions & 7 deletions src/completion-scripts_.toit
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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\""""
Expand Down Expand Up @@ -115,6 +119,8 @@ zsh-completion-script_ --program-path/string -> string:

if [[ \$directive -eq 4 ]]; then
_files
elif [[ \$directive -eq 8 ]]; then
_directories
fi
}

Expand Down Expand Up @@ -152,6 +158,8 @@ fish-completion-script_ --program-path/string -> string:

if test "\$directive" = "4"
__fish_complete_path (commandline -ct)
else if test "\$directive" = "8"
__fish_complete_directories (commandline -ct)
end
end

Expand Down Expand Up @@ -193,12 +201,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
)
}
}
}"""
30 changes: 22 additions & 8 deletions src/completion_.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -165,9 +170,11 @@ complete_ root/Command arguments/List -> CompletionResult_:
--seen-options=seen-options
--prefix=current-word
completions := pending-option.complete context
directive := has-completer_ pending-option
? DIRECTIVE-NO-FILE-COMPLETION_
: DIRECTIVE-FILE-COMPLETION_
directive := pending-option.completion-directive
if not directive:
directive = has-completer_ pending-option
? DIRECTIVE-NO-FILE-COMPLETION_
: DIRECTIVE-FILE-COMPLETION_
return CompletionResult_
completions.map: to-candidate_ it
--directive=directive
Expand Down Expand Up @@ -195,9 +202,11 @@ 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 := has-completer_ option
? DIRECTIVE-NO-FILE-COMPLETION_
: DIRECTIVE-FILE-COMPLETION_
directive := option.completion-directive
if not directive:
directive = has-completer_ option
? DIRECTIVE-NO-FILE-COMPLETION_
: DIRECTIVE-FILE-COMPLETION_
return CompletionResult_ candidates --directive=directive
return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_

Expand Down Expand Up @@ -317,9 +326,14 @@ complete-rest_ command/Command seen-options/Map current-word/string --positional
--seen-options=seen-options
--prefix=current-word
completions := option.complete context
if not completions.is-empty:
directive := option.completion-directive
if not directive:
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-NO-FILE-COMPLETION_
return CompletionResult_ candidates --directive=directive

return CompletionResult_ [] --directive=DIRECTIVE-FILE-COMPLETION_

Expand Down
54 changes: 47 additions & 7 deletions tests/completion_shell_test.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) ==="
Expand Down Expand Up @@ -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) ==="
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/completion_shell_test_app.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions tests/completion_test.toit
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ main:
test-completion-with-descriptions
test-help-only-at-root
test-flags-hidden-without-dash-prefix
test-option-path
test-rest-positional-index
test-rest-positional-index-after-dashdash
test-rest-multi-not-skipped
Expand Down Expand Up @@ -390,6 +391,37 @@ test-flags-hidden-without-dash-prefix:
// 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

test-rest-positional-index:
root := cli.Command "app"
--rest=[
Expand Down
26 changes: 26 additions & 0 deletions tests/options_test.toit
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ main:
test-int
test-uuid
test-flag
test-path
test-bad-combos

test-string:
Expand Down Expand Up @@ -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