diff --git a/src/cli.toit b/src/cli.toit index f61fb2c..19753fb 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -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. @@ -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: diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 243ed6a..9d02331 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -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\"""" @@ -115,6 +119,8 @@ zsh-completion-script_ --program-path/string -> string: if [[ \$directive -eq 4 ]]; then _files + elif [[ \$directive -eq 8 ]]; then + _directories fi } @@ -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 @@ -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 + ) + } } }""" diff --git a/src/completion_.toit b/src/completion_.toit index 64388d1..024010d 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. */ @@ -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 @@ -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_ @@ -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_ diff --git a/tests/completion_shell_test.toit b/tests/completion_shell_test.toit index d8ab025..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: @@ -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 diff --git a/tests/completion_test.toit b/tests/completion_test.toit index a25b485..dba52c5 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-rest-positional-index test-rest-positional-index-after-dashdash test-rest-multi-not-skipped @@ -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=[ 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