From e6c9b182e6d3684508696e11691607bdf28034af Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Sat, 4 Apr 2026 16:45:33 +0200 Subject: [PATCH] Add --extensions parameter to OptionPath, OptionInFile, and OptionOutFile. When specified, shell completion only suggests files matching the given extensions (plus directories for navigation). Supported in all four shells: bash, zsh, fish, and PowerShell. --- src/cli.toit | 69 ++++++++++- src/completion-scripts_.toit | 125 ++++++++++++++++---- src/completion_.toit | 8 +- tests/completion_shell.toit | 5 + tests/completion_shell_bash_test.toit | 8 ++ tests/completion_shell_fish_test.toit | 8 ++ tests/completion_shell_powershell_test.toit | 7 ++ tests/completion_shell_test_app.toit | 1 + tests/completion_shell_zsh_test.toit | 8 ++ tests/completion_test.toit | 66 +++++++++++ 10 files changed, 281 insertions(+), 24 deletions(-) diff --git a/src/cli.toit b/src/cli.toit index f6ef95f..266548d 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -322,7 +322,10 @@ class Command: result := complete_ this completion-args result.candidates.do: | candidate/CompletionCandidate_ | print candidate.to-string - print ":$result.directive" + if result.extensions and not result.extensions.is-empty: + print ":$result.directive:$(result.extensions.join ",")" + else: + print ":$result.directive" return if not cli: @@ -779,6 +782,16 @@ abstract class Option: completion-directive -> int?: return null + /** + Returns file extensions to filter completions by, or null. + + When non-null, the shell only shows files matching these extensions + (plus directories for navigation). Extensions include the leading + dot, e.g. [".txt", ".json"]. + */ + completion-extensions -> List?: + return null + /** Returns completion candidates for this option's value. @@ -1055,6 +1068,15 @@ class OptionPath extends Option: */ is-directory/bool + /** + File extensions to filter completions by, or null. + + When non-null, the shell only shows files matching these extensions + (plus directories for navigation). Extensions include the leading + dot, e.g. [".txt", ".json"]. + */ + extensions/List? + /** Creates a new path option. @@ -1063,11 +1085,17 @@ class OptionPath extends Option: If $directory is true, the shell only completes directories. + If $extensions is non-null, the shell only completes files matching + the given extensions (plus directories for navigation). Extensions + must include the leading dot, e.g. `[".txt", ".json"]`. Cannot be + combined with $directory. + See $Option.constructor for the other parameters. */ constructor name/string --.default=null --directory/bool=false + --.extensions/List?=null --.type=(directory ? "directory" : "path") --short-name/string?=null --help/string?=null @@ -1077,6 +1105,8 @@ class OptionPath extends Option: --split-commas/bool=false --completion/Lambda?=null: is-directory = directory + if directory and extensions and not extensions.is-empty: + throw "OptionPath can't have both --directory and --extensions." 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 \ @@ -1091,6 +1121,9 @@ class OptionPath extends Option: if is-directory: return DIRECTIVE-DIRECTORY-COMPLETION_ return DIRECTIVE-FILE-COMPLETION_ + completion-extensions -> List?: + return extensions + parse str/string [--if-error] --for-help-example/bool=false -> string: return str @@ -1119,6 +1152,15 @@ class OptionInFile extends Option: */ check-exists/bool + /** + File extensions to filter completions by, or null. + + When non-null, the shell only shows files matching these extensions + (plus directories for navigation). Extensions include the leading + dot, e.g. [".txt", ".json"]. + */ + extensions/List? + /** Creates a new input file option. @@ -1131,6 +1173,10 @@ class OptionInFile extends Option: If $check-exists is true (the default), the file must exist at parse time. This check is skipped for "-" (stdin) and for help examples. + If $extensions is non-null, the shell only completes files matching + the given extensions (plus directories for navigation). Extensions + must include the leading dot, e.g. `[".txt", ".json"]`. + See $Option.constructor for the other parameters. */ constructor name/string @@ -1138,6 +1184,7 @@ class OptionInFile extends Option: --.type="file" --.allow-dash=true --.check-exists=true + --.extensions/List?=null --short-name/string?=null --help/string?=null --required/bool=false @@ -1157,6 +1204,9 @@ class OptionInFile extends Option: completion-directive -> int?: return DIRECTIVE-FILE-COMPLETION_ + completion-extensions -> List?: + return extensions + parse str/string [--if-error] --for-help-example/bool=false -> any: if allow-dash and str == "-": return InFile.stdin_ --option-name=name @@ -1190,6 +1240,15 @@ class OptionOutFile extends Option: */ create-directories/bool + /** + File extensions to filter completions by, or null. + + When non-null, the shell only shows files matching these extensions + (plus directories for navigation). Extensions include the leading + dot, e.g. [".txt", ".json"]. + */ + extensions/List? + /** Creates a new output file option. @@ -1202,6 +1261,10 @@ class OptionOutFile extends Option: If $create-directories is true, parent directories are created automatically when opening the file for writing. Defaults to false. + If $extensions is non-null, the shell only completes files matching + the given extensions (plus directories for navigation). Extensions + must include the leading dot, e.g. `[".txt", ".json"]`. + See $Option.constructor for the other parameters. */ constructor name/string @@ -1209,6 +1272,7 @@ class OptionOutFile extends Option: --.type="file" --.allow-dash=true --.create-directories=false + --.extensions/List?=null --short-name/string?=null --help/string?=null --required/bool=false @@ -1228,6 +1292,9 @@ class OptionOutFile extends Option: completion-directive -> int?: return DIRECTIVE-FILE-COMPLETION_ + completion-extensions -> List?: + return extensions + parse str/string [--if-error] --for-help-example/bool=false -> any: if allow-dash and str == "-": return OutFile.stdout_ --create-directories=create-directories --option-name=name diff --git a/src/completion-scripts_.toit b/src/completion-scripts_.toit index 888ae7c..af9f55e 100644 --- a/src/completion-scripts_.toit +++ b/src/completion-scripts_.toit @@ -42,6 +42,7 @@ bash-completion-script_ --program-path/string -> string: return """ _$(func-name)_completions() { local IFS=\$'\\n' + shopt -s extglob 2>/dev/null local completions completions=\$("$program-path" __complete -- "\${COMP_WORDS[@]:1:\$COMP_CWORD}") @@ -49,11 +50,19 @@ bash-completion-script_ --program-path/string -> string: return fi - local directive - directive=\$(echo "\$completions" | tail -n 1) + local directive_line + directive_line=\$(echo "\$completions" | tail -n 1) completions=\$(echo "\$completions" | sed '\$d') - directive="\${directive#:}" + directive_line="\${directive_line#:}" + + local directive extensions="" + if [[ "\$directive_line" == *:* ]]; then + directive="\${directive_line%%:*}" + extensions="\${directive_line#*:}" + else + directive="\$directive_line" + fi local candidates=() while IFS='' read -r line; do @@ -70,7 +79,24 @@ bash-completion-script_ --program-path/string -> string: compopt +o default 2>/dev/null elif [[ \$directive -eq 4 ]]; then if [[ \${#COMPREPLY[@]} -eq 0 ]]; then - compopt -o default 2>/dev/null + if [[ -n "\$extensions" ]]; then + local ext_pattern="" + IFS=',' read -ra exts <<< "\$extensions" + for ext in "\${exts[@]}"; do + ext="\${ext#.}" + if [[ -n "\$ext_pattern" ]]; then + ext_pattern="\$ext_pattern|\$ext" + else + ext_pattern="\$ext" + fi + done + COMPREPLY=(\$(compgen -f -X "!*.@(\$ext_pattern)" -- "\$cur_word")) + COMPREPLY+=(\$(compgen -d -- "\$cur_word")) + # Remove duplicate directory entries. + COMPREPLY=(\$(printf '%s\\n' "\${COMPREPLY[@]}" | sort -u)) + else + compopt -o default 2>/dev/null + fi fi elif [[ \$directive -eq 8 ]]; then if [[ \${#COMPREPLY[@]} -eq 0 ]]; then @@ -91,7 +117,7 @@ zsh-completion-script_ --program-path/string -> string: _$(func-name)() { local -a completions - local directive + local directive_line directive extensions="" local output output=\$("$program-path" __complete -- "\${words[@]:1:\$((CURRENT-1))}" 2>/dev/null) @@ -99,8 +125,15 @@ zsh-completion-script_ --program-path/string -> string: return fi - directive=\$(echo "\$output" | tail -n 1) - directive="\${directive#:}" + directive_line=\$(echo "\$output" | tail -n 1) + directive_line="\${directive_line#:}" + + if [[ "\$directive_line" == *:* ]]; then + directive="\${directive_line%%:*}" + extensions="\${directive_line#*:}" + else + directive="\$directive_line" + fi local -a lines lines=("\${(@f)\$(echo "\$output" | sed '\$d')}") @@ -123,7 +156,21 @@ zsh-completion-script_ --program-path/string -> string: fi if [[ \$directive -eq 4 ]]; then - _files + if [[ -n "\$extensions" ]]; then + 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 + done + _files -g "(\$glob_parts)" + else + _files + fi elif [[ \$directive -eq 8 ]]; then _directories fi @@ -147,9 +194,16 @@ fish-completion-script_ --program-path/string -> string: return end - set -l directive (string replace -r '^:(.*)' '\$1' \$output[-1]) + set -l directive_line (string replace -r '^:(.*)' '\$1' \$output[-1]) set -e output[-1] + set -l directive \$directive_line + set -l extensions "" + if string match -q '*:*' \$directive_line + set directive (string split ':' \$directive_line)[1] + set extensions (string split ':' \$directive_line)[2] + end + for line in \$output set -l parts (string split \\t \$line) if test (count \$parts) -gt 1 @@ -162,7 +216,24 @@ fish-completion-script_ --program-path/string -> string: end if test "\$directive" = "4" - __fish_complete_path (commandline -ct) + if test -n "\$extensions" + set -l cur (commandline -ct) + set -l ext_list (string split ',' \$extensions) + for f in \$cur* + if test -d "\$f" + echo \$f + continue + end + for ext in \$ext_list + if string match -q "*\$ext" \$f + echo \$f + break + end + end + end + else + __fish_complete_path (commandline -ct) + end else if test "\$directive" = "8" __fish_complete_directories (commandline -ct) end @@ -193,9 +264,16 @@ powershell-completion-script_ --program-path/string -> string: if (\$LASTEXITCODE -ne 0 -or -not \$output) { return } \$lines = \$output -split '\\r?\\n' - \$directive = (\$lines[-1] -replace '^:', '') + \$directiveLine = (\$lines[-1] -replace '^:', '') \$lines = \$lines[0..(\$lines.Length - 2)] + \$directive = \$directiveLine + \$extensions = '' + if (\$directiveLine -match '^(\\d+):(.+)\$') { + \$directive = \$Matches[1] + \$extensions = \$Matches[2] + } + foreach (\$line in \$lines) { if (-not \$line) { continue } if (\$line -match '^([^\\t]+)\\t(.+)\$') { @@ -216,15 +294,22 @@ powershell-completion-script_ --program-path/string -> string: 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 - ) + \$items = Get-ChildItem -Path "\$wordToComplete*" -ErrorAction SilentlyContinue + if (\$directive -eq '8') { + \$items = \$items | Where-Object { \$_.PSIsContainer } + } elseif (\$extensions) { + \$extArray = \$extensions -split ',' + \$items = \$items | Where-Object { + \$_.PSIsContainer -or (\$extArray -contains \$_.Extension) } + } + \$items | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + \$_.FullName, + \$_.Name, + \$completionType, + \$_.FullName + ) + } } }""" diff --git a/src/completion_.toit b/src/completion_.toit index ba188b4..3e651b8 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -50,8 +50,9 @@ Contains a list of $candidates and a $directive that tells the shell class CompletionResult_: candidates/List directive/int + extensions/List? // List of extension strings like [".txt", ".json"], or null. - constructor .candidates --.directive=DIRECTIVE-DEFAULT_: + constructor .candidates --.directive=DIRECTIVE-DEFAULT_ --.extensions=null: /** Computes completion candidates for the given $arguments. @@ -205,6 +206,7 @@ complete_ root/Command arguments/List -> CompletionResult_: return CompletionResult_ completions.map: to-candidate_ it --directive=directive + --extensions=pending-option.completion-extensions // After --, only rest arguments (no option completions). if past-dashdash: @@ -234,7 +236,7 @@ complete_ root/Command arguments/List -> CompletionResult_: directive = has-completer_ option ? DIRECTIVE-NO-FILE-COMPLETION_ : DIRECTIVE-FILE-COMPLETION_ - return CompletionResult_ candidates --directive=directive + return CompletionResult_ candidates --directive=directive --extensions=option.completion-extensions return CompletionResult_ [] --directive=DIRECTIVE-DEFAULT_ // Completing an option name. @@ -360,7 +362,7 @@ complete-rest_ command/Command seen-options/Map current-word/string --positional : DIRECTIVE-FILE-COMPLETION_ if not completions.is-empty or directive != DIRECTIVE-DEFAULT_: candidates := completions.map: to-candidate_ it - return CompletionResult_ candidates --directive=directive + return CompletionResult_ candidates --directive=directive --extensions=option.completion-extensions return CompletionResult_ [] --directive=DIRECTIVE-FILE-COMPLETION_ diff --git a/tests/completion_shell.toit b/tests/completion_shell.toit index bb38696..4e90cf3 100644 --- a/tests/completion_shell.toit +++ b/tests/completion_shell.toit @@ -110,5 +110,10 @@ setup-test-binary_ tmpdir/string -> string: file.write-contents --path="$tmpdir/xfirmware.bin" "" directory.mkdir "$tmpdir/xreleases" + // Create artifacts for extension-filtered completion testing. + file.write-contents --path="$tmpdir/xconfig.toml" "" + file.write-contents --path="$tmpdir/xconfig.yaml" "" + file.write-contents --path="$tmpdir/xconfig.txt" "" + return binary diff --git a/tests/completion_shell_bash_test.toit b/tests/completion_shell_bash_test.toit index 856111d..f69d167 100644 --- a/tests/completion_shell_bash_test.toit +++ b/tests/completion_shell_bash_test.toit @@ -73,6 +73,14 @@ test-bash binary/string tmpdir/string: tmux.wait-for "xreleases" tmux.cancel + // OptionPath --extensions: only .toml and .yaml files should complete. + tmux.send-keys ["$binary deploy --config xconfig.", "Tab", "Tab"] + tmux.wait-for "xconfig.toml" + content = tmux.capture + expect (content.contains "xconfig.yaml") + expect (not content.contains "xconfig.txt") + tmux.cancel + print " All bash tests passed." finally: tmux.close diff --git a/tests/completion_shell_fish_test.toit b/tests/completion_shell_fish_test.toit index a551f5a..f56c050 100644 --- a/tests/completion_shell_fish_test.toit +++ b/tests/completion_shell_fish_test.toit @@ -61,6 +61,14 @@ test-fish binary/string tmpdir/string: tmux.wait-for "xreleases" tmux.cancel + // OptionPath --extensions: only .toml and .yaml files should complete. + tmux.send-keys ["$binary deploy --config xconfig.", "Tab"] + tmux.wait-for "xconfig.toml" + content = tmux.capture + expect (content.contains "xconfig.yaml") + expect (not content.contains "xconfig.txt") + tmux.cancel + print " All fish tests passed." finally: tmux.close diff --git a/tests/completion_shell_powershell_test.toit b/tests/completion_shell_powershell_test.toit index e8129f7..d0e6dd8 100644 --- a/tests/completion_shell_powershell_test.toit +++ b/tests/completion_shell_powershell_test.toit @@ -82,4 +82,11 @@ test-powershell binary/string tmpdir/string: expect (output.contains "xreleases") print " directory path completion: ok" + // OptionPath --extensions: only .toml and .yaml files should complete. + output = pwsh-complete_ binary tmpdir "fleet deploy --config xconfig." + expect (output.contains "xconfig.toml") + expect (output.contains "xconfig.yaml") + expect (not (output.contains "xconfig.txt")) + print " extension-filtered completion: ok" + print " All powershell tests passed." diff --git a/tests/completion_shell_test_app.toit b/tests/completion_shell_test_app.toit index f236064..ae9dab7 100644 --- a/tests/completion_shell_test_app.toit +++ b/tests/completion_shell_test_app.toit @@ -31,6 +31,7 @@ main arguments: OptionEnum "channel" ["stable", "beta", "dev"] --help="Release channel.", OptionPath "firmware" --help="Firmware file to deploy.", + OptionPath "config" --extensions=[".toml", ".yaml"] --help="Config file.", OptionPath "output-dir" --directory --help="Output directory.", ] --run=:: null diff --git a/tests/completion_shell_zsh_test.toit b/tests/completion_shell_zsh_test.toit index e618ad4..dde512c 100644 --- a/tests/completion_shell_zsh_test.toit +++ b/tests/completion_shell_zsh_test.toit @@ -63,6 +63,14 @@ test-zsh binary/string tmpdir/string: tmux.wait-for "xreleases" tmux.cancel + // OptionPath --extensions: only .toml and .yaml files should complete. + tmux.send-keys ["$binary deploy --config xconfig.", "Tab"] + tmux.wait-for "xconfig.toml" + content = tmux.capture + expect (content.contains "xconfig.yaml") + expect (not content.contains "xconfig.txt") + tmux.cancel + print " All zsh tests passed." finally: tmux.close diff --git a/tests/completion_test.toit b/tests/completion_test.toit index 146921e..653f429 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -35,6 +35,7 @@ main: test-short-option-pending-value test-packed-short-options test-custom-completer-no-file-fallback + test-option-extensions test-help-completion test-help-gated-on-availability @@ -559,6 +560,71 @@ test-custom-completer-no-file-fallback: expect-equals 0 result.candidates.size expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive +test-option-extensions: + // OptionPath with extensions should report them in the result. + root := cli.Command "app" + --options=[ + cli.OptionPath "config" --extensions=[".toml", ".yaml"] --help="Config file.", + ] + --run=:: null + result := complete_ root ["--config", ""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 2 result.extensions.size + expect (result.extensions.contains ".toml") + expect (result.extensions.contains ".yaml") + + // With --option=prefix form. + result = complete_ root ["--config=foo"] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 2 result.extensions.size + + // OptionInFile with extensions. + root = cli.Command "app" + --options=[ + cli.OptionInFile "input" --extensions=[".csv"] --help="Input file.", + ] + --run=:: null + result = complete_ root ["--input", ""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 1 result.extensions.size + expect (result.extensions.contains ".csv") + + // OptionOutFile with extensions. + root = cli.Command "app" + --options=[ + cli.OptionOutFile "output" --extensions=[".log"] --help="Output file.", + ] + --run=:: null + result = complete_ root ["--output", ""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 1 result.extensions.size + expect (result.extensions.contains ".log") + + // Without extensions, result.extensions should be null. + root = cli.Command "app" + --options=[ + cli.OptionPath "file" --help="Any file.", + ] + --run=:: null + result = complete_ root ["--file", ""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals null result.extensions + + // OptionPath with --directory and --extensions should throw. + expect-throw "OptionPath can't have both --directory and --extensions.": + cli.OptionPath "dir" --directory --extensions=[".txt"] --help="Bad." + + // Rest option with extensions. + root = cli.Command "app" + --rest=[ + cli.OptionPath "config" --extensions=[".toml"] --help="Config file.", + ] + --run=:: null + result = complete_ root [""] + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 1 result.extensions.size + expect (result.extensions.contains ".toml") + test-help-completion: root := cli.Command "app" --options=[