diff --git a/.changeset/witty-shoes-brake.md b/.changeset/witty-shoes-brake.md new file mode 100644 index 0000000..d6cf547 --- /dev/null +++ b/.changeset/witty-shoes-brake.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +Update fish shell completion script to match latest Cobra output. (#99) diff --git a/src/fish.ts b/src/fish.ts index e510bf0..28e016b 100644 --- a/src/fish.ts +++ b/src/fish.ts @@ -19,14 +19,6 @@ export function generate(name: string, exec: string): string { return `# fish completion for ${name} -*- shell-script -*- -# Define shell completion directives -set -l ShellCompDirectiveError ${ShellCompDirectiveError} -set -l ShellCompDirectiveNoSpace ${ShellCompDirectiveNoSpace} -set -l ShellCompDirectiveNoFileComp ${ShellCompDirectiveNoFileComp} -set -l ShellCompDirectiveFilterFileExt ${ShellCompDirectiveFilterFileExt} -set -l ShellCompDirectiveFilterDirs ${ShellCompDirectiveFilterDirs} -set -l ShellCompDirectiveKeepOrder ${ShellCompDirectiveKeepOrder} - function __${nameForVar}_debug set -l file "$BASH_COMP_DEBUG_FILE" if test -n "$file" @@ -37,134 +29,227 @@ end function __${nameForVar}_perform_completion __${nameForVar}_debug "Starting __${nameForVar}_perform_completion" - # Extract all args except the completion flag - set -l args (string match -v -- "--completion=" (commandline -opc)) - - # Extract the current token being completed - set -l current_token (commandline -ct) - - # Check if current token starts with a dash - set -l flag_prefix "" - if string match -q -- "-*" $current_token - set flag_prefix "--flag=" - end - - __${nameForVar}_debug "Current token: $current_token" - __${nameForVar}_debug "All args: $args" + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space or wildcard + set -l lastArg (string escape -- (commandline -ct)) + + __${nameForVar}_debug "args: $args" + __${nameForVar}_debug "last arg: $lastArg" + + # Build the completion request command + set -l requestComp "${exec} complete -- (string join ' ' -- (string escape -- $args[2..-1])) $lastArg" - # Call the completion program and get the results - set -l requestComp "${exec} complete -- $args" __${nameForVar}_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) - + # Some programs may output extra empty lines after the directive. # Let's ignore them or else it will break completion. # Ref: https://github.com/spf13/cobra/issues/1279 for line in $results[-1..1] - if test (string sub -s 1 -l 1 -- $line) = ":" - # The directive - set -l directive (string sub -s 2 -- $line) - set -l directive_num (math $directive) + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output break end end - - # No directive specified, use default - if not set -q directive_num - set directive_num 0 + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __${nameForVar}_debug "Comps: $comps" + __${nameForVar}_debug "DirectiveLine: $directiveLine" + __${nameForVar}_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\\n" "$flagPrefix" "$comp" end - - __${nameForVar}_debug "Directive: $directive_num" - # Process completions based on directive - if test $directive_num -eq $ShellCompDirectiveError - # Error code. No completion. - __${nameForVar}_debug "Received error directive: aborting." + printf "%s\\n" "$directiveLine" +end + +# This function limits calls to __${nameForVar}_perform_completion, by caching the result +function __${nameForVar}_perform_completion_once + __${nameForVar}_debug "Starting __${nameForVar}_perform_completion_once" + + if test -n "$__${nameForVar}_perform_completion_once_result" + __${nameForVar}_debug "Seems like a valid result already exists, skipping __${nameForVar}_perform_completion" + return 0 + end + + set --global __${nameForVar}_perform_completion_once_result (__${nameForVar}_perform_completion) + if test -z "$__${nameForVar}_perform_completion_once_result" + __${nameForVar}_debug "No completions, probably due to a failure" return 1 end - # Filter out the directive (last line) - if test (count $results) -gt 0 -a (string sub -s 1 -l 1 -- $results[-1]) = ":" - set results $results[1..-2] + __${nameForVar}_debug "Performed completions and set __${nameForVar}_perform_completion_once_result" + return 0 +end + +# This function is used to clear the cached result after completions are run +function __${nameForVar}_clear_perform_completion_once_result + __${nameForVar}_debug "" + __${nameForVar}_debug "========= clearing previously set __${nameForVar}_perform_completion_once_result variable ==========" + set --erase __${nameForVar}_perform_completion_once_result + __${nameForVar}_debug "Successfully erased the variable __${nameForVar}_perform_completion_once_result" +end + +function __${nameForVar}_requires_order_preservation + __${nameForVar}_debug "" + __${nameForVar}_debug "========= checking if order preservation is required ==========" + + __${nameForVar}_perform_completion_once + if test -z "$__${nameForVar}_perform_completion_once_result" + __${nameForVar}_debug "Error determining if order preservation is required" + return 1 end - # No completions, let fish handle file completions unless forbidden - if test (count $results) -eq 0 - if test $directive_num -ne $ShellCompDirectiveNoFileComp - __${nameForVar}_debug "No completions, performing file completion" - return 1 - end - __${nameForVar}_debug "No completions, but file completion forbidden" + set -l directive (string sub --start 2 $__${nameForVar}_perform_completion_once_result[-1]) + __${nameForVar}_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder ${ShellCompDirectiveKeepOrder} + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __${nameForVar}_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __${nameForVar}_debug "This does require order preservation" return 0 end - # Filter file extensions - if test $directive_num -eq $ShellCompDirectiveFilterFileExt - __${nameForVar}_debug "File extension filtering" - set -l file_extensions - for item in $results - if test -n "$item" -a (string sub -s 1 -l 1 -- $item) != "-" - set -a file_extensions "*$item" - end - end - __${nameForVar}_debug "File extensions: $file_extensions" - - # Use the file extensions as completions - set -l completions - for ext in $file_extensions - # Get all files matching the extension - set -a completions (string replace -r '^.*/' '' -- $ext) - end - - for item in $completions - echo -e "$item\t" - end - return 0 + __${nameForVar}_debug "This doesn't require order preservation" + return 1 +end + +# This function does two things: +# - Obtain the completions and store them in the global __${nameForVar}_comp_results +# - Return false if file completion should be performed +function __${nameForVar}_prepare_completions + __${nameForVar}_debug "" + __${nameForVar}_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __${nameForVar}_comp_results + + __${nameForVar}_perform_completion_once + __${nameForVar}_debug "Completion results: $__${nameForVar}_perform_completion_once_result" + + if test -z "$__${nameForVar}_perform_completion_once_result" + __${nameForVar}_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 end - # Filter directories - if test $directive_num -eq $ShellCompDirectiveFilterDirs - __${nameForVar}_debug "Directory filtering" - set -l dirs - for item in $results - if test -d "$item" - set -a dirs "$item/" - end - end - - for item in $dirs - echo -e "$item\t" - end - return 0 + set -l directive (string sub --start 2 $__${nameForVar}_perform_completion_once_result[-1]) + set --global __${nameForVar}_comp_results $__${nameForVar}_perform_completion_once_result[1..-2] + + __${nameForVar}_debug "Completions are: $__${nameForVar}_comp_results" + __${nameForVar}_debug "Directive is: $directive" + + set -l shellCompDirectiveError ${ShellCompDirectiveError} + set -l shellCompDirectiveNoSpace ${ShellCompDirectiveNoSpace} + set -l shellCompDirectiveNoFileComp ${ShellCompDirectiveNoFileComp} + set -l shellCompDirectiveFilterFileExt ${ShellCompDirectiveFilterFileExt} + set -l shellCompDirectiveFilterDirs ${ShellCompDirectiveFilterDirs} + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __${nameForVar}_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 end - # Process remaining completions - for item in $results - if test -n "$item" - # Check if the item has a description - if string match -q "*\t*" -- "$item" - set -l completion_parts (string split \t -- "$item") - set -l comp $completion_parts[1] - set -l desc $completion_parts[2] - - # Add the completion and description - echo -e "$comp\t$desc" - else - # Add just the completion - echo -e "$item\t" + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __${nameForVar}_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __${nameForVar}_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __${nameForVar}_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__${nameForVar}_comp_results) + set --global __${nameForVar}_comp_results $completions + __${nameForVar}_debug "Filtered completions are: $__${nameForVar}_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__${nameForVar}_comp_results) + __${nameForVar}_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \\t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 "\\t" $__${nameForVar}_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __${nameForVar}_debug "Adding second completion to perform nospace directive" + set --global __${nameForVar}_comp_results $split[1] $split[1]. + __${nameForVar}_debug "Completions are now: $__${nameForVar}_comp_results" end end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __${nameForVar}_debug "Requesting file completion" + return 1 + end end - - # If directive contains NoSpace, tell fish not to add a space after completion - if test (math "$directive_num & $ShellCompDirectiveNoSpace") -ne 0 - return 2 - end - + return 0 end -# Set up the completion for the ${name} command -complete -c ${name} -f -a "(eval __${nameForVar}_perform_completion)" +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "${name}" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "${name} " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c ${name} -e + +# This will get called after the two calls below and clear the cached result +complete -c ${name} -n '__${nameForVar}_clear_perform_completion_once_result' +# The call to __${nameForVar}_prepare_completions will setup __${nameForVar}_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c ${name} -n 'not __${nameForVar}_requires_order_preservation && __${nameForVar}_prepare_completions' -f -a '$__${nameForVar}_comp_results' +# Otherwise we use the -k flag +complete -k -c ${name} -n '__${nameForVar}_requires_order_preservation && __${nameForVar}_prepare_completions' -f -a '$__${nameForVar}_comp_results' `; } diff --git a/tests/__snapshots__/shell.test.ts.snap b/tests/__snapshots__/shell.test.ts.snap index 3f937ec..6a54925 100644 --- a/tests/__snapshots__/shell.test.ts.snap +++ b/tests/__snapshots__/shell.test.ts.snap @@ -3,14 +3,6 @@ exports[`shell completion generators > fish shell completion > should generate a valid fish completion script 1`] = ` "# fish completion for testcli -*- shell-script -*- -# Define shell completion directives -set -l ShellCompDirectiveError 1 -set -l ShellCompDirectiveNoSpace 2 -set -l ShellCompDirectiveNoFileComp 4 -set -l ShellCompDirectiveFilterFileExt 8 -set -l ShellCompDirectiveFilterDirs 16 -set -l ShellCompDirectiveKeepOrder 32 - function __testcli_debug set -l file "$BASH_COMP_DEBUG_FILE" if test -n "$file" @@ -21,149 +13,234 @@ end function __testcli_perform_completion __testcli_debug "Starting __testcli_perform_completion" - # Extract all args except the completion flag - set -l args (string match -v -- "--completion=" (commandline -opc)) - - # Extract the current token being completed - set -l current_token (commandline -ct) - - # Check if current token starts with a dash - set -l flag_prefix "" - if string match -q -- "-*" $current_token - set flag_prefix "--flag=" - end - - __testcli_debug "Current token: $current_token" - __testcli_debug "All args: $args" + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space or wildcard + set -l lastArg (string escape -- (commandline -ct)) + + __testcli_debug "args: $args" + __testcli_debug "last arg: $lastArg" + + # Build the completion request command + set -l requestComp "/usr/bin/node /path/to/testcli complete -- (string join ' ' -- (string escape -- $args[2..-1])) $lastArg" - # Call the completion program and get the results - set -l requestComp "/usr/bin/node /path/to/testcli complete -- $args" __testcli_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) - + # Some programs may output extra empty lines after the directive. # Let's ignore them or else it will break completion. # Ref: https://github.com/spf13/cobra/issues/1279 for line in $results[-1..1] - if test (string sub -s 1 -l 1 -- $line) = ":" - # The directive - set -l directive (string sub -s 2 -- $line) - set -l directive_num (math $directive) + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output break end end - - # No directive specified, use default - if not set -q directive_num - set directive_num 0 + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __testcli_debug "Comps: $comps" + __testcli_debug "DirectiveLine: $directiveLine" + __testcli_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\\n" "$flagPrefix" "$comp" end - - __testcli_debug "Directive: $directive_num" - # Process completions based on directive - if test $directive_num -eq $ShellCompDirectiveError - # Error code. No completion. - __testcli_debug "Received error directive: aborting." + printf "%s\\n" "$directiveLine" +end + +# This function limits calls to __testcli_perform_completion, by caching the result +function __testcli_perform_completion_once + __testcli_debug "Starting __testcli_perform_completion_once" + + if test -n "$__testcli_perform_completion_once_result" + __testcli_debug "Seems like a valid result already exists, skipping __testcli_perform_completion" + return 0 + end + + set --global __testcli_perform_completion_once_result (__testcli_perform_completion) + if test -z "$__testcli_perform_completion_once_result" + __testcli_debug "No completions, probably due to a failure" return 1 end - # Filter out the directive (last line) - if test (count $results) -gt 0 -a (string sub -s 1 -l 1 -- $results[-1]) = ":" - set results $results[1..-2] + __testcli_debug "Performed completions and set __testcli_perform_completion_once_result" + return 0 +end + +# This function is used to clear the cached result after completions are run +function __testcli_clear_perform_completion_once_result + __testcli_debug "" + __testcli_debug "========= clearing previously set __testcli_perform_completion_once_result variable ==========" + set --erase __testcli_perform_completion_once_result + __testcli_debug "Successfully erased the variable __testcli_perform_completion_once_result" +end + +function __testcli_requires_order_preservation + __testcli_debug "" + __testcli_debug "========= checking if order preservation is required ==========" + + __testcli_perform_completion_once + if test -z "$__testcli_perform_completion_once_result" + __testcli_debug "Error determining if order preservation is required" + return 1 end - # No completions, let fish handle file completions unless forbidden - if test (count $results) -eq 0 - if test $directive_num -ne $ShellCompDirectiveNoFileComp - __testcli_debug "No completions, performing file completion" - return 1 - end - __testcli_debug "No completions, but file completion forbidden" + set -l directive (string sub --start 2 $__testcli_perform_completion_once_result[-1]) + __testcli_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __testcli_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __testcli_debug "This does require order preservation" return 0 end - # Filter file extensions - if test $directive_num -eq $ShellCompDirectiveFilterFileExt - __testcli_debug "File extension filtering" - set -l file_extensions - for item in $results - if test -n "$item" -a (string sub -s 1 -l 1 -- $item) != "-" - set -a file_extensions "*$item" - end - end - __testcli_debug "File extensions: $file_extensions" - - # Use the file extensions as completions - set -l completions - for ext in $file_extensions - # Get all files matching the extension - set -a completions (string replace -r '^.*/' '' -- $ext) - end - - for item in $completions - echo -e "$item " - end - return 0 + __testcli_debug "This doesn't require order preservation" + return 1 +end + +# This function does two things: +# - Obtain the completions and store them in the global __testcli_comp_results +# - Return false if file completion should be performed +function __testcli_prepare_completions + __testcli_debug "" + __testcli_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __testcli_comp_results + + __testcli_perform_completion_once + __testcli_debug "Completion results: $__testcli_perform_completion_once_result" + + if test -z "$__testcli_perform_completion_once_result" + __testcli_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 end - # Filter directories - if test $directive_num -eq $ShellCompDirectiveFilterDirs - __testcli_debug "Directory filtering" - set -l dirs - for item in $results - if test -d "$item" - set -a dirs "$item/" - end - end - - for item in $dirs - echo -e "$item " - end - return 0 + set -l directive (string sub --start 2 $__testcli_perform_completion_once_result[-1]) + set --global __testcli_comp_results $__testcli_perform_completion_once_result[1..-2] + + __testcli_debug "Completions are: $__testcli_comp_results" + __testcli_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __testcli_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 end - # Process remaining completions - for item in $results - if test -n "$item" - # Check if the item has a description - if string match -q "* *" -- "$item" - set -l completion_parts (string split -- "$item") - set -l comp $completion_parts[1] - set -l desc $completion_parts[2] - - # Add the completion and description - echo -e "$comp $desc" - else - # Add just the completion - echo -e "$item " + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __testcli_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __testcli_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __testcli_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__testcli_comp_results) + set --global __testcli_comp_results $completions + __testcli_debug "Filtered completions are: $__testcli_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__testcli_comp_results) + __testcli_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \\t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 "\\t" $__testcli_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __testcli_debug "Adding second completion to perform nospace directive" + set --global __testcli_comp_results $split[1] $split[1]. + __testcli_debug "Completions are now: $__testcli_comp_results" end end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __testcli_debug "Requesting file completion" + return 1 + end end - - # If directive contains NoSpace, tell fish not to add a space after completion - if test (math "$directive_num & $ShellCompDirectiveNoSpace") -ne 0 - return 2 - end - + return 0 end -# Set up the completion for the testcli command -complete -c testcli -f -a "(eval __testcli_perform_completion)" +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "testcli" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "testcli " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c testcli -e + +# This will get called after the two calls below and clear the cached result +complete -c testcli -n '__testcli_clear_perform_completion_once_result' +# The call to __testcli_prepare_completions will setup __testcli_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c testcli -n 'not __testcli_requires_order_preservation && __testcli_prepare_completions' -f -a '$__testcli_comp_results' +# Otherwise we use the -k flag +complete -k -c testcli -n '__testcli_requires_order_preservation && __testcli_prepare_completions' -f -a '$__testcli_comp_results' " `; exports[`shell completion generators > fish shell completion > should handle special characters in the name 1`] = ` "# fish completion for test-cli:app -*- shell-script -*- -# Define shell completion directives -set -l ShellCompDirectiveError 1 -set -l ShellCompDirectiveNoSpace 2 -set -l ShellCompDirectiveNoFileComp 4 -set -l ShellCompDirectiveFilterFileExt 8 -set -l ShellCompDirectiveFilterDirs 16 -set -l ShellCompDirectiveKeepOrder 32 - function __test_cli_app_debug set -l file "$BASH_COMP_DEBUG_FILE" if test -n "$file" @@ -174,134 +251,227 @@ end function __test_cli_app_perform_completion __test_cli_app_debug "Starting __test_cli_app_perform_completion" - # Extract all args except the completion flag - set -l args (string match -v -- "--completion=" (commandline -opc)) - - # Extract the current token being completed - set -l current_token (commandline -ct) - - # Check if current token starts with a dash - set -l flag_prefix "" - if string match -q -- "-*" $current_token - set flag_prefix "--flag=" - end - - __test_cli_app_debug "Current token: $current_token" - __test_cli_app_debug "All args: $args" + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space or wildcard + set -l lastArg (string escape -- (commandline -ct)) + + __test_cli_app_debug "args: $args" + __test_cli_app_debug "last arg: $lastArg" + + # Build the completion request command + set -l requestComp "/usr/bin/node /path/to/testcli complete -- (string join ' ' -- (string escape -- $args[2..-1])) $lastArg" - # Call the completion program and get the results - set -l requestComp "/usr/bin/node /path/to/testcli complete -- $args" __test_cli_app_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) - + # Some programs may output extra empty lines after the directive. # Let's ignore them or else it will break completion. # Ref: https://github.com/spf13/cobra/issues/1279 for line in $results[-1..1] - if test (string sub -s 1 -l 1 -- $line) = ":" - # The directive - set -l directive (string sub -s 2 -- $line) - set -l directive_num (math $directive) + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output break end end - - # No directive specified, use default - if not set -q directive_num - set directive_num 0 + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __test_cli_app_debug "Comps: $comps" + __test_cli_app_debug "DirectiveLine: $directiveLine" + __test_cli_app_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\\n" "$flagPrefix" "$comp" end - - __test_cli_app_debug "Directive: $directive_num" - # Process completions based on directive - if test $directive_num -eq $ShellCompDirectiveError - # Error code. No completion. - __test_cli_app_debug "Received error directive: aborting." + printf "%s\\n" "$directiveLine" +end + +# This function limits calls to __test_cli_app_perform_completion, by caching the result +function __test_cli_app_perform_completion_once + __test_cli_app_debug "Starting __test_cli_app_perform_completion_once" + + if test -n "$__test_cli_app_perform_completion_once_result" + __test_cli_app_debug "Seems like a valid result already exists, skipping __test_cli_app_perform_completion" + return 0 + end + + set --global __test_cli_app_perform_completion_once_result (__test_cli_app_perform_completion) + if test -z "$__test_cli_app_perform_completion_once_result" + __test_cli_app_debug "No completions, probably due to a failure" return 1 end - # Filter out the directive (last line) - if test (count $results) -gt 0 -a (string sub -s 1 -l 1 -- $results[-1]) = ":" - set results $results[1..-2] + __test_cli_app_debug "Performed completions and set __test_cli_app_perform_completion_once_result" + return 0 +end + +# This function is used to clear the cached result after completions are run +function __test_cli_app_clear_perform_completion_once_result + __test_cli_app_debug "" + __test_cli_app_debug "========= clearing previously set __test_cli_app_perform_completion_once_result variable ==========" + set --erase __test_cli_app_perform_completion_once_result + __test_cli_app_debug "Successfully erased the variable __test_cli_app_perform_completion_once_result" +end + +function __test_cli_app_requires_order_preservation + __test_cli_app_debug "" + __test_cli_app_debug "========= checking if order preservation is required ==========" + + __test_cli_app_perform_completion_once + if test -z "$__test_cli_app_perform_completion_once_result" + __test_cli_app_debug "Error determining if order preservation is required" + return 1 end - # No completions, let fish handle file completions unless forbidden - if test (count $results) -eq 0 - if test $directive_num -ne $ShellCompDirectiveNoFileComp - __test_cli_app_debug "No completions, performing file completion" - return 1 - end - __test_cli_app_debug "No completions, but file completion forbidden" + set -l directive (string sub --start 2 $__test_cli_app_perform_completion_once_result[-1]) + __test_cli_app_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __test_cli_app_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __test_cli_app_debug "This does require order preservation" return 0 end - # Filter file extensions - if test $directive_num -eq $ShellCompDirectiveFilterFileExt - __test_cli_app_debug "File extension filtering" - set -l file_extensions - for item in $results - if test -n "$item" -a (string sub -s 1 -l 1 -- $item) != "-" - set -a file_extensions "*$item" - end - end - __test_cli_app_debug "File extensions: $file_extensions" - - # Use the file extensions as completions - set -l completions - for ext in $file_extensions - # Get all files matching the extension - set -a completions (string replace -r '^.*/' '' -- $ext) - end - - for item in $completions - echo -e "$item " - end - return 0 + __test_cli_app_debug "This doesn't require order preservation" + return 1 +end + +# This function does two things: +# - Obtain the completions and store them in the global __test_cli_app_comp_results +# - Return false if file completion should be performed +function __test_cli_app_prepare_completions + __test_cli_app_debug "" + __test_cli_app_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __test_cli_app_comp_results + + __test_cli_app_perform_completion_once + __test_cli_app_debug "Completion results: $__test_cli_app_perform_completion_once_result" + + if test -z "$__test_cli_app_perform_completion_once_result" + __test_cli_app_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 end - # Filter directories - if test $directive_num -eq $ShellCompDirectiveFilterDirs - __test_cli_app_debug "Directory filtering" - set -l dirs - for item in $results - if test -d "$item" - set -a dirs "$item/" - end - end - - for item in $dirs - echo -e "$item " - end - return 0 + set -l directive (string sub --start 2 $__test_cli_app_perform_completion_once_result[-1]) + set --global __test_cli_app_comp_results $__test_cli_app_perform_completion_once_result[1..-2] + + __test_cli_app_debug "Completions are: $__test_cli_app_comp_results" + __test_cli_app_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __test_cli_app_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 end - # Process remaining completions - for item in $results - if test -n "$item" - # Check if the item has a description - if string match -q "* *" -- "$item" - set -l completion_parts (string split -- "$item") - set -l comp $completion_parts[1] - set -l desc $completion_parts[2] - - # Add the completion and description - echo -e "$comp $desc" - else - # Add just the completion - echo -e "$item " + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __test_cli_app_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __test_cli_app_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __test_cli_app_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__test_cli_app_comp_results) + set --global __test_cli_app_comp_results $completions + __test_cli_app_debug "Filtered completions are: $__test_cli_app_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__test_cli_app_comp_results) + __test_cli_app_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \\t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 "\\t" $__test_cli_app_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __test_cli_app_debug "Adding second completion to perform nospace directive" + set --global __test_cli_app_comp_results $split[1] $split[1]. + __test_cli_app_debug "Completions are now: $__test_cli_app_comp_results" end end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __test_cli_app_debug "Requesting file completion" + return 1 + end end - - # If directive contains NoSpace, tell fish not to add a space after completion - if test (math "$directive_num & $ShellCompDirectiveNoSpace") -ne 0 - return 2 - end - + return 0 end -# Set up the completion for the test-cli:app command -complete -c test-cli:app -f -a "(eval __test_cli_app_perform_completion)" +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "test-cli:app" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "test-cli:app " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c test-cli:app -e + +# This will get called after the two calls below and clear the cached result +complete -c test-cli:app -n '__test_cli_app_clear_perform_completion_once_result' +# The call to __test_cli_app_prepare_completions will setup __test_cli_app_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c test-cli:app -n 'not __test_cli_app_requires_order_preservation && __test_cli_app_prepare_completions' -f -a '$__test_cli_app_comp_results' +# Otherwise we use the -k flag +complete -k -c test-cli:app -n '__test_cli_app_requires_order_preservation && __test_cli_app_prepare_completions' -f -a '$__test_cli_app_comp_results' " `; diff --git a/tests/fish-integration.test.ts b/tests/fish-integration.test.ts new file mode 100644 index 0000000..b05c303 --- /dev/null +++ b/tests/fish-integration.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; + +// Check if fish is available +function isFishAvailable(): boolean { + try { + execSync('fish --version', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +// Run fish command and return output +function runFish(script: string): string { + const result = spawnSync('fish', ['-c', script], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error) { + throw result.error; + } + return result.stdout + result.stderr; +} + +// Simulate TAB completion in fish +function simulateTab(completionScript: string, commandLine: string): string[] { + const script = ` + source (echo '${completionScript.replace(/'/g, "\\'")}' | psub) + complete --do-complete "${commandLine}" + `; + const output = runFish(script); + return output + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => line.trim()); +} + +describe.skipIf(!isFishAvailable())( + 'fish shell completion integration tests', + () => { + const demoCliPath = path.join( + __dirname, + '../examples/demo-cli-cac/demo-cli-cac.js' + ); + let completionScript: string; + + beforeAll(() => { + // Generate the completion script from demo-cli-cac + const result = spawnSync('node', [demoCliPath, 'complete', 'fish'], { + encoding: 'utf-8', + cwd: path.dirname(demoCliPath), + }); + completionScript = result.stdout; + }); + + it('should complete subcommands when pressing TAB after command', () => { + const completions = simulateTab(completionScript, 'demo-cli-cac '); + + // Should contain subcommands + expect(completions.some((c) => c.startsWith('start'))).toBe(true); + expect(completions.some((c) => c.startsWith('build'))).toBe(true); + }); + + it('should complete flags when pressing TAB after --', () => { + const completions = simulateTab(completionScript, 'demo-cli-cac --'); + + // Should contain global flags + expect(completions.some((c) => c.includes('--config'))).toBe(true); + expect(completions.some((c) => c.includes('--debug'))).toBe(true); + expect(completions.some((c) => c.includes('--help'))).toBe(true); + expect(completions.some((c) => c.includes('--version'))).toBe(true); + }); + + it('should complete subcommand-specific flags', () => { + const completions = simulateTab( + completionScript, + 'demo-cli-cac start --' + ); + + // Should contain start-specific flag + expect(completions.some((c) => c.includes('--port'))).toBe(true); + // Should also contain global flags + expect(completions.some((c) => c.includes('--config'))).toBe(true); + }); + + it('should complete build command flags', () => { + const completions = simulateTab( + completionScript, + 'demo-cli-cac build --' + ); + + // Should contain build-specific flag + expect(completions.some((c) => c.includes('--mode'))).toBe(true); + // Should also contain global flags + expect(completions.some((c) => c.includes('--config'))).toBe(true); + }); + + it('should show descriptions with completions', () => { + const completions = simulateTab(completionScript, 'demo-cli-cac '); + + // Check that descriptions are included (tab-separated) + const startCompletion = completions.find((c) => c.startsWith('start')); + expect(startCompletion).toContain('Start the application'); + + const buildCompletion = completions.find((c) => c.startsWith('build')); + expect(buildCompletion).toContain('Build the application'); + }); + + it('should filter completions based on partial input', () => { + const completions = simulateTab(completionScript, 'demo-cli-cac st'); + + // Should only show completions starting with 'st' + expect(completions.some((c) => c.startsWith('start'))).toBe(true); + // 'build' should not appear + expect(completions.some((c) => c.startsWith('build'))).toBe(false); + }); + + it('should filter flag completions based on partial input', () => { + const completions = simulateTab(completionScript, 'demo-cli-cac --c'); + + // Should show --config + expect(completions.some((c) => c.includes('--config'))).toBe(true); + // Should not show --debug (doesn't start with --c) + expect(completions.some((c) => c.includes('--debug'))).toBe(false); + }); + } +);