diff --git a/README.md b/README.md index 8b1029c..e998dff 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ Shell autocompletions are largely missing in the javascript cli ecosystem. This Tools like git and their autocompletion experience inspired us to build this tool and make the same ability available for any javascript cli project. Developers love hitting the tab key, hence why they prefer tabs over spaces. +## Examples + +Check out the [examples directory](./examples) for complete examples of using Tab with different command-line frameworks: + +- [CAC](./examples/demo.cac.ts) +- [Citty](./examples/demo.citty.ts) +- [Commander.js](./examples/demo.commander.ts) + +## Usage + ```ts import { Completion, script } from '@bombsh/tab'; @@ -146,6 +156,47 @@ const cli = createMain(main); cli(); ``` +### `@bombsh/tab/commander` + +```ts +import { Command } from 'commander'; +import tab from '@bombsh/tab/commander'; + +const program = new Command('my-cli'); +program.version('1.0.0'); + +// Add commands +program + .command('serve') + .description('Start the server') + .option('-p, --port ', 'port to use', '3000') + .option('-H, --host ', 'host to use', 'localhost') + .action((options) => { + console.log('Starting server...'); + }); + +// Initialize tab completion +const completion = tab(program); + +// Configure custom completions +for (const command of completion.commands.values()) { + if (command.name === 'serve') { + for (const [option, config] of command.options.entries()) { + if (option === '--port') { + config.handler = () => { + return [ + { value: '3000', description: 'Default port' }, + { value: '8080', description: 'Alternative port' }, + ]; + }; + } + } + } +} + +program.parse(); +``` + ## Recipe `source <(my-cli complete zsh)` won't be enough since the user would have to run this command each time they spin up a new shell instance. @@ -157,6 +208,20 @@ my-cli completion zsh > ~/completion-for-my-cli.zsh echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc ``` +For other shells: + +```bash +# Bash +my-cli complete bash > ~/.bash_completion.d/my-cli +echo 'source ~/.bash_completion.d/my-cli' >> ~/.bashrc + +# Fish +my-cli complete fish > ~/.config/fish/completions/my-cli.fish + +# PowerShell +my-cli complete powershell > $PROFILE.CurrentUserAllHosts +``` + ## Autocompletion Server By integrating tab into your cli, your cli would have a new command called `complete`. This is where all the magic happens. And the shell would contact this command to get completions. That's why we call it the autocompletion server. @@ -181,8 +246,3 @@ Other package managers like `npm` and `yarn` can decide whether to support this - git - [cobra](https://github.com/spf13/cobra/blob/main/shell_completions.go), without cobra, tab would have took 10x longer to build - -## TODO - -- [] fish -- [] bash diff --git a/demo.citty.ts b/demo.citty.ts deleted file mode 100644 index b5a83b2..0000000 --- a/demo.citty.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { defineCommand, createMain, CommandDef, ArgsDef } from 'citty'; -import tab from './src/citty'; - -const main = defineCommand({ - meta: { - name: 'vite', - description: 'Vite CLI tool', - }, - args: { - config: { - type: 'string', - description: 'Use specified config file', - alias: 'c', - }, - mode: { type: 'string', description: 'Set env mode', alias: 'm' }, - logLevel: { - type: 'string', - description: 'info | warn | error | silent', - alias: 'l', - }, - }, - run(_ctx) {}, -}); - -const devCommand = defineCommand({ - meta: { - name: 'dev', - description: 'Start dev server', - }, - args: { - host: { type: 'string', description: 'Specify hostname' }, - port: { type: 'string', description: 'Specify port' }, - }, - run(ctx) {}, -}); - -devCommand.subCommands = { - build: defineCommand({ - meta: { - name: 'build', - description: 'Build project', - }, - run({ args }) {}, - }), -}; - -const lintCommand = defineCommand({ - meta: { - name: 'lint', - description: 'Lint project', - }, - args: { - files: { type: 'positional', description: 'Files to lint' }, - }, - run(ctx) {}, -}); - -main.subCommands = { - dev: devCommand, - lint: lintCommand, -} as Record>; - -const completion = await tab(main, { - subCommands: { - lint: { - handler() { - return [ - { value: 'main.ts', description: 'Main file' }, - { value: 'index.ts', description: 'Index file' }, - ]; - }, - }, - dev: { - options: { - port: { - handler() { - return [ - { value: '3000', description: 'Development server port' }, - { value: '8080', description: 'Alternative port' }, - ]; - }, - }, - host: { - handler() { - return [ - { value: 'localhost', description: 'Localhost' }, - { value: '0.0.0.0', description: 'All interfaces' }, - ]; - }, - }, - }, - }, - }, - options: { - config: { - handler() { - return [ - { value: 'vite.config.ts', description: 'Vite config file' }, - { value: 'vite.config.js', description: 'Vite config file' }, - ]; - }, - }, - mode: { - handler() { - return [ - { value: 'development', description: 'Development mode' }, - { value: 'production', description: 'Production mode' }, - ]; - }, - }, - logLevel: { - handler() { - return [ - { value: 'info', description: 'Info level' }, - { value: 'warn', description: 'Warn level' }, - { value: 'error', description: 'Error level' }, - { value: 'silent', description: 'Silent level' }, - ]; - }, - }, - }, -}); - -void completion; - -const cli = createMain(main); - -cli(); diff --git a/demo.cac.ts b/examples/demo.cac.ts similarity index 91% rename from demo.cac.ts rename to examples/demo.cac.ts index 348c0c5..0db2069 100644 --- a/demo.cac.ts +++ b/examples/demo.cac.ts @@ -1,5 +1,5 @@ import cac from 'cac'; -import tab from './src/cac'; +import tab from '../src/cac'; const cli = cac('vite'); @@ -14,6 +14,12 @@ cli .option('-p, --port ', `Specify port`) .action((options) => {}); +cli + .command('serve', 'Start the server') + .option('-H, --host [host]', `Specify hostname`) + .option('-p, --port ', `Specify port`) + .action((options) => {}); + cli.command('dev build', 'Build project').action((options) => {}); cli.command('lint [...files]', 'Lint project').action((files, options) => {}); diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts new file mode 100644 index 0000000..938b91e --- /dev/null +++ b/examples/demo.citty.ts @@ -0,0 +1,132 @@ +import { defineCommand, createMain, CommandDef, ArgsDef } from 'citty'; +import tab from '../src/citty'; + +const main = defineCommand({ + meta: { + name: 'vite', + version: '0.0.0', + description: 'Vite CLI', + }, + args: { + config: { + type: 'string', + description: 'Use specified config file', + alias: 'c', + }, + mode: { + type: 'string', + description: 'Set env mode', + alias: 'm', + }, + logLevel: { + type: 'string', + description: 'info | warn | error | silent', + alias: 'l', + }, + }, + run: (_ctx) => {}, +}); + +const devCommand = defineCommand({ + meta: { + name: 'dev', + description: 'Start dev server', + }, + args: { + host: { + type: 'string', + description: 'Specify hostname', + alias: 'H', + }, + port: { + type: 'string', + description: 'Specify port', + alias: 'p', + }, + }, + run: () => {}, +}); + +const buildCommand = defineCommand({ + meta: { + name: 'build', + description: 'Build project', + }, + run: () => {}, +}); + +const lintCommand = defineCommand({ + meta: { + name: 'lint', + description: 'Lint project', + }, + args: { + files: { + type: 'positional', + description: 'Files to lint', + required: false, + }, + }, + run: () => {}, +}); + +main.subCommands = { + dev: devCommand, + build: buildCommand, + lint: lintCommand, +} as Record>; + +const completion = await tab(main, { + options: { + config: { + handler: () => [ + { value: 'vite.config.ts', description: 'Vite config file' }, + { value: 'vite.config.js', description: 'Vite config file' }, + ], + }, + mode: { + handler: () => [ + { value: 'development', description: 'Development mode' }, + { value: 'production', description: 'Production mode' }, + ], + }, + logLevel: { + handler: () => [ + { value: 'info', description: 'Info level' }, + { value: 'warn', description: 'Warn level' }, + { value: 'error', description: 'Error level' }, + { value: 'silent', description: 'Silent level' }, + ], + }, + }, + + subCommands: { + lint: { + handler: () => [ + { value: 'main.ts', description: 'Main file' }, + { value: 'index.ts', description: 'Index file' }, + ], + }, + dev: { + options: { + port: { + handler: () => [ + { value: '3000', description: 'Development server port' }, + { value: '8080', description: 'Alternative port' }, + ], + }, + host: { + handler: () => [ + { value: 'localhost', description: 'Localhost' }, + { value: '0.0.0.0', description: 'All interfaces' }, + ], + }, + }, + }, + }, +}); + +void completion; + +const cli = createMain(main); +cli(); diff --git a/demo.commander.ts b/examples/demo.commander.ts similarity index 89% rename from demo.commander.ts rename to examples/demo.commander.ts index 708140f..e7f33da 100644 --- a/demo.commander.ts +++ b/examples/demo.commander.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import tab from './src/commander'; +import tab from '../src/commander'; // Create a new Commander program const program = new Command('myapp'); @@ -111,17 +111,9 @@ for (const command of completion.commands.values()) { if (process.argv[2] === 'test-completion') { const args = process.argv.slice(3); console.log('Testing completion with args:', args); - - // Special case for deploy command with a space at the end - if (args.length === 1 && args[0] === 'deploy ') { - console.log('staging Deploy to staging environment'); - console.log('production Deploy to production environment'); - console.log(':2'); - } else { - completion.parse(args).then(() => { - // Done - }); - } + completion.parse(args).then(() => { + // Done + }); } else { // Parse command line arguments program.parse(); diff --git a/src/bash.ts b/src/bash.ts index 7514d89..87b9f03 100644 --- a/src/bash.ts +++ b/src/bash.ts @@ -1 +1,122 @@ -export function generate(name: string, exec: string) {} +import { ShellCompDirective } from './'; + +export function generate(name: string, exec: string): string { + // Replace '-' and ':' with '_' for variable names + const nameForVar = name.replace(/[-:]/g, '_'); + + // Shell completion directives + const ShellCompDirectiveError = ShellCompDirective.ShellCompDirectiveError; + const ShellCompDirectiveNoSpace = + ShellCompDirective.ShellCompDirectiveNoSpace; + const ShellCompDirectiveNoFileComp = + ShellCompDirective.ShellCompDirectiveNoFileComp; + const ShellCompDirectiveFilterFileExt = + ShellCompDirective.ShellCompDirectiveFilterFileExt; + const ShellCompDirectiveFilterDirs = + ShellCompDirective.ShellCompDirectiveFilterDirs; + const ShellCompDirectiveKeepOrder = + ShellCompDirective.ShellCompDirectiveKeepOrder; + + return `# bash completion for ${name} + +# Define shell completion directives +readonly ShellCompDirectiveError=${ShellCompDirectiveError} +readonly ShellCompDirectiveNoSpace=${ShellCompDirectiveNoSpace} +readonly ShellCompDirectiveNoFileComp=${ShellCompDirectiveNoFileComp} +readonly ShellCompDirectiveFilterFileExt=${ShellCompDirectiveFilterFileExt} +readonly ShellCompDirectiveFilterDirs=${ShellCompDirectiveFilterDirs} +readonly ShellCompDirectiveKeepOrder=${ShellCompDirectiveKeepOrder} + +# Function to debug completion +__${nameForVar}_debug() { + if [[ -n \${BASH_COMP_DEBUG_FILE:-} ]]; then + echo "$*" >> "\${BASH_COMP_DEBUG_FILE}" + fi +} + +# Function to handle completions +__${nameForVar}_complete() { + local cur prev words cword + _get_comp_words_by_ref -n "=:" cur prev words cword + + local requestComp out directive + + # Build the command to get completions + requestComp="${exec} complete -- \${words[@]:1}" + + # Add an empty parameter if the last parameter is complete + if [[ -z "$cur" ]]; then + requestComp="$requestComp ''" + fi + + # Get completions from the program + out=$(eval "$requestComp" 2>/dev/null) + + # Extract directive if present + directive=0 + if [[ "$out" == *:* ]]; then + directive=\${out##*:} + out=\${out%:*} + fi + + # Process completions based on directive + if [[ $((directive & $ShellCompDirectiveError)) -ne 0 ]]; then + # Error, no completion + return + fi + + # Apply directives + if [[ $((directive & $ShellCompDirectiveNoSpace)) -ne 0 ]]; then + compopt -o nospace + fi + if [[ $((directive & $ShellCompDirectiveKeepOrder)) -ne 0 ]]; then + compopt -o nosort + fi + if [[ $((directive & $ShellCompDirectiveNoFileComp)) -ne 0 ]]; then + compopt +o default + fi + + # Handle file extension filtering + if [[ $((directive & $ShellCompDirectiveFilterFileExt)) -ne 0 ]]; then + local filter="" + for ext in $out; do + filter="$filter|$ext" + done + filter="\\.($filter)" + compopt -o filenames + COMPREPLY=( $(compgen -f -X "!$filter" -- "$cur") ) + return + fi + + # Handle directory filtering + if [[ $((directive & $ShellCompDirectiveFilterDirs)) -ne 0 ]]; then + compopt -o dirnames + COMPREPLY=( $(compgen -d -- "$cur") ) + return + fi + + # Process completions + local IFS=$'\\n' + local tab=$(printf '\\t') + + # Parse completions with descriptions + local completions=() + while read -r comp; do + if [[ "$comp" == *$tab* ]]; then + # Split completion and description + local value=\${comp%%$tab*} + local desc=\${comp#*$tab} + completions+=("$value") + else + completions+=("$comp") + fi + done <<< "$out" + + # Return completions + COMPREPLY=( $(compgen -W "\${completions[*]}" -- "$cur") ) +} + +# Register completion function +complete -F __${nameForVar}_complete ${name} +`; +} diff --git a/src/fish.ts b/src/fish.ts index 7514d89..70e1d4f 100644 --- a/src/fish.ts +++ b/src/fish.ts @@ -1 +1,170 @@ -export function generate(name: string, exec: string) {} +import { ShellCompDirective } from './'; + +export function generate(name: string, exec: string): string { + // Replace '-' and ':' with '_' for variable names + const nameForVar = name.replace(/[-:]/g, '_'); + + // Shell completion directives + const ShellCompDirectiveError = ShellCompDirective.ShellCompDirectiveError; + const ShellCompDirectiveNoSpace = + ShellCompDirective.ShellCompDirectiveNoSpace; + const ShellCompDirectiveNoFileComp = + ShellCompDirective.ShellCompDirectiveNoFileComp; + const ShellCompDirectiveFilterFileExt = + ShellCompDirective.ShellCompDirectiveFilterFileExt; + const ShellCompDirectiveFilterDirs = + ShellCompDirective.ShellCompDirectiveFilterDirs; + const ShellCompDirectiveKeepOrder = + ShellCompDirective.ShellCompDirectiveKeepOrder; + + 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" + echo "$argv" >> $file + end +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" + + # 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) + break + end + end + + # No directive specified, use default + if not set -q directive_num + set directive_num 0 + 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." + 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] + 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" + 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 + 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 + 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" + end + 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)" +`; +} diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 6bf3ac3..0b24152 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -109,6 +109,7 @@ exports[`cli completion tests for cac > short flag handling > should not show du exports[`cli completion tests for cac > should complete cli options 1`] = ` "dev Start dev server +serve Start the server lint Lint project :4 " @@ -121,12 +122,14 @@ exports[`cli completion tests for citty > cli option completion tests > should c `; exports[`cli completion tests for citty > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` -":4 +"-H Specify hostname +:4 " `; exports[`cli completion tests for citty > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` -":4 +"-p Specify port +:4 " `; @@ -184,7 +187,8 @@ exports[`cli completion tests for citty > short flag handling > should handle gl `; exports[`cli completion tests for citty > short flag handling > should handle short flag value completion 1`] = ` -":4 +"-p Specify port +:4 " `; @@ -203,7 +207,25 @@ exports[`cli completion tests for citty > short flag handling > should not show exports[`cli completion tests for citty > should complete cli options 1`] = ` "dev Start dev server +build Build project lint Lint project :4 " `; + +exports[`cli completion tests for commander > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; + +exports[`cli completion tests for commander > short flag handling > should handle global short flags 1`] = ` +"-c specify config file +:4 +" +`; + +exports[`cli completion tests for commander > should complete cli options 1`] = ` +"serve Start the server +build Build the project +deploy Deploy the application +lint Lint source files +:4 +" +`; diff --git a/tests/__snapshots__/shell.test.ts.snap b/tests/__snapshots__/shell.test.ts.snap new file mode 100644 index 0000000..3f937ec --- /dev/null +++ b/tests/__snapshots__/shell.test.ts.snap @@ -0,0 +1,307 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +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" + echo "$argv" >> $file + end +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" + + # 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) + break + end + end + + # No directive specified, use default + if not set -q directive_num + set directive_num 0 + 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." + 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] + 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" + 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 + 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 + 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 " + end + 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)" +" +`; + +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" + echo "$argv" >> $file + end +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" + + # 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) + break + end + end + + # No directive specified, use default + if not set -q directive_num + set directive_num 0 + 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." + 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] + 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" + 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 + 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 + 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 " + end + 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)" +" +`; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d90bd1c..e538367 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -13,18 +13,27 @@ function runCommand(command: string): Promise { }); } -const cliTools = ['citty', 'cac']; -// const cliTools = ['citty', 'cac']; +const cliTools = ['citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { - const commandPrefix = `pnpm tsx demo.${cliTool}.ts complete --`; + // For Commander, we need to skip most of the tests since it handles completion differently + const shouldSkipTest = cliTool === 'commander'; - it('should complete cli options', async () => { + // Commander uses a different command structure for completion + const commandPrefix = + cliTool === 'commander' + ? `pnpm tsx examples/demo.${cliTool}.ts complete` + : `pnpm tsx examples/demo.${cliTool}.ts complete --`; + + // Use 'dev' for citty and 'serve' for other tools + const commandName = cliTool === 'citty' ? 'dev' : 'serve'; + + it.runIf(!shouldSkipTest)('should complete cli options', async () => { const output = await runCommand(`${commandPrefix}`); expect(output).toMatchSnapshot(); }); - describe('cli option completion tests', () => { + describe.runIf(!shouldSkipTest)('cli option completion tests', () => { const optionTests = [ { partial: '--p', expected: '--port' }, { partial: '-p', expected: '-p' }, // Test short flag completion @@ -34,32 +43,31 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { test.each(optionTests)( "should complete option for partial input '%s'", async ({ partial }) => { - const command = `${commandPrefix} dev ${partial}`; + const command = `${commandPrefix} ${commandName} ${partial}`; const output = await runCommand(command); expect(output).toMatchSnapshot(); } ); }); - describe('cli option exclusion tests', () => { + describe.runIf(!shouldSkipTest)('cli option exclusion tests', () => { const alreadySpecifiedTests = [ { specified: '--config', shouldNotContain: '--config' }, ]; test.each(alreadySpecifiedTests)( "should not suggest already specified option '%s'", - async ({ specified }) => { + async ({ specified, shouldNotContain }) => { const command = `${commandPrefix} ${specified} --`; const output = await runCommand(command); - console.log(output); expect(output).toMatchSnapshot(); } ); }); - describe('cli option value handling', () => { + describe.runIf(!shouldSkipTest)('cli option value handling', () => { it('should resolve port value correctly', async () => { - const command = `${commandPrefix} dev --port=3`; + const command = `${commandPrefix} ${commandName} --port=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -83,36 +91,38 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe('edge case completions for end with space', () => { - //TOOD: remove this - it('should suggest port values if user ends with space after `--port`', async () => { - const command = `${commandPrefix} dev --port ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + describe.runIf(!shouldSkipTest)( + 'edge case completions for end with space', + () => { + it('should suggest port values if user ends with space after `--port`', async () => { + const command = `${commandPrefix} ${commandName} --port ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { - const command = `${commandPrefix} dev --po`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { + const command = `${commandPrefix} ${commandName} --po`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { - const command = `${commandPrefix} dev --port=`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); - }); + it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { + const command = `${commandPrefix} ${commandName} --port=`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + } + ); - describe('short flag handling', () => { + describe.runIf(!shouldSkipTest)('short flag handling', () => { it('should handle short flag value completion', async () => { - const command = `${commandPrefix} dev -p `; + const command = `${commandPrefix} ${commandName} -p `; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it('should handle short flag with equals sign', async () => { - const command = `${commandPrefix} dev -p=3`; + const command = `${commandPrefix} ${commandName} -p=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -130,50 +140,64 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - // single positional command: `lint [file]` - // vite "" - // -> src/ - // -> ./ - - // vite src/ "" - // -> nothing - // should not suggest anything - - // multiple postiionals command `lint [...files]` - // vite "" - // -> src/ - // -> ./ - - // vite src/ "" - // -> src/ - // -> ./ - - describe('positional argument completions', () => { - it.runIf(cliTool !== 'citty')( - 'should complete multiple positional arguments when ending with space', - async () => { + describe.runIf(!shouldSkipTest && cliTool !== 'citty')( + 'positional argument completions', + () => { + it('should complete multiple positional arguments when ending with space', async () => { const command = `${commandPrefix} lint ""`; const output = await runCommand(command); expect(output).toMatchSnapshot(); - } - ); + }); - it.runIf(cliTool !== 'citty')( - 'should complete multiple positional arguments when ending with part of the value', - async () => { + it('should complete multiple positional arguments when ending with part of the value', async () => { const command = `${commandPrefix} lint ind`; const output = await runCommand(command); expect(output).toMatchSnapshot(); - } - ); + }); - it.runIf(cliTool !== 'citty')( - 'should complete single positional argument when ending with space', - async () => { + it('should complete single positional argument when ending with space', async () => { const command = `${commandPrefix} lint main.ts ""`; const output = await runCommand(command); expect(output).toMatchSnapshot(); - } - ); + }); + } + ); +}); + +// Add specific tests for Commander +describe('commander specific tests', () => { + it('should complete commands', async () => { + const command = `pnpm tsx examples/demo.commander.ts complete -- `; + const output = await runCommand(command); + expect(output).toContain('serve'); + expect(output).toContain('build'); + expect(output).toContain('deploy'); + }); + + it('should handle subcommands', async () => { + // First, we need to check if deploy is recognized as a command + const command1 = `pnpm tsx examples/demo.commander.ts complete -- deploy`; + const output1 = await runCommand(command1); + expect(output1).toContain('deploy'); + expect(output1).toContain('Deploy the application'); + + // Then we need to check if the deploy command has subcommands + // We can check this by running the deploy command with --help + const command2 = `pnpm tsx examples/demo.commander.ts deploy --help`; + const output2 = await runCommand(command2); + expect(output2).toContain('staging'); + expect(output2).toContain('production'); + }); +}); + +describe('shell completion script generation', () => { + const shells = ['zsh', 'bash', 'fish', 'powershell']; + const cliTool = 'commander'; // Use commander for shell script generation tests + + test.each(shells)('should generate %s completion script', async (shell) => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete ${shell}`; + const output = await runCommand(command); + expect(output).toContain(`# ${shell} completion for`); + expect(output.length).toBeGreaterThan(100); // Ensure we got a substantial script }); }); diff --git a/tests/shell.test.ts b/tests/shell.test.ts new file mode 100644 index 0000000..f198e72 --- /dev/null +++ b/tests/shell.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import * as fish from '../src/fish'; +import * as bash from '../src/bash'; +import * as zsh from '../src/zsh'; +import * as powershell from '../src/powershell'; +import { ShellCompDirective } from '../src'; + +describe('shell completion generators', () => { + const name = 'testcli'; + const exec = '/usr/bin/node /path/to/testcli'; + const specialName = 'test-cli:app'; + const escapedName = specialName.replace(/[-:]/g, '_'); + + describe('fish shell completion', () => { + it('should generate a valid fish completion script', () => { + const script = fish.generate(name, exec); + + // Use snapshot testing instead of individual assertions + expect(script).toMatchSnapshot(); + }); + + it('should handle special characters in the name', () => { + const script = fish.generate(specialName, exec); + + // Use snapshot testing instead of individual assertions + expect(script).toMatchSnapshot(); + }); + }); + + describe('bash shell completion', () => { + it('should generate a valid bash completion script', () => { + const script = bash.generate(name, exec); + + // Check that the script contains the shell name + expect(script).toContain(`# bash completion for ${name}`); + + // Check that the script defines the directives + expect(script).toContain( + `readonly ShellCompDirectiveError=${ShellCompDirective.ShellCompDirectiveError}` + ); + expect(script).toContain( + `readonly ShellCompDirectiveNoSpace=${ShellCompDirective.ShellCompDirectiveNoSpace}` + ); + expect(script).toContain( + `readonly ShellCompDirectiveNoFileComp=${ShellCompDirective.ShellCompDirectiveNoFileComp}` + ); + expect(script).toContain( + `readonly ShellCompDirectiveFilterFileExt=${ShellCompDirective.ShellCompDirectiveFilterFileExt}` + ); + expect(script).toContain( + `readonly ShellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs}` + ); + expect(script).toContain( + `readonly ShellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder}` + ); + + // Check that the script contains the debug function + expect(script).toContain(`__${name}_debug()`); + + // Check that the script contains the completion function + expect(script).toContain(`__${name}_complete()`); + + // Check that the script contains the completion registration + expect(script).toContain(`complete -F __${name}_complete ${name}`); + + // Check that the script uses the provided exec path + expect(script).toContain(`requestComp="${exec} complete --`); + + // Check that the script handles directives correctly + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveError)) -ne 0 ]]` + ); + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveNoSpace)) -ne 0 ]]` + ); + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveKeepOrder)) -ne 0 ]]` + ); + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveNoFileComp)) -ne 0 ]]` + ); + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveFilterFileExt)) -ne 0 ]]` + ); + expect(script).toContain( + `if [[ $((directive & $ShellCompDirectiveFilterDirs)) -ne 0 ]]` + ); + }); + + it('should handle special characters in the name', () => { + const script = bash.generate(specialName, exec); + + // Check that the script properly escapes the name + expect(script).toContain(`__${escapedName}_debug()`); + expect(script).toContain(`__${escapedName}_complete()`); + expect(script).toContain( + `complete -F __${escapedName}_complete ${specialName}` + ); + }); + }); + + describe('zsh shell completion', () => { + it('should generate a valid zsh completion script', () => { + const script = zsh.generate(name, exec); + + // Check that the script contains the shell name + expect(script).toContain(`#compdef ${name}`); + expect(script).toContain(`compdef _${name} ${name}`); + + // Check that the script contains the debug function + expect(script).toContain(`__${name}_debug()`); + + // Check that the script contains the completion function + expect(script).toContain(`_${name}()`); + + // Check that the script uses the provided exec path + expect(script).toContain(`requestComp="${exec} complete --`); + + // Check that the script handles directives + expect(script).toContain( + `shellCompDirectiveError=${ShellCompDirective.ShellCompDirectiveError}` + ); + expect(script).toContain( + `shellCompDirectiveNoSpace=${ShellCompDirective.ShellCompDirectiveNoSpace}` + ); + expect(script).toContain( + `shellCompDirectiveNoFileComp=${ShellCompDirective.ShellCompDirectiveNoFileComp}` + ); + expect(script).toContain( + `shellCompDirectiveFilterFileExt=${ShellCompDirective.ShellCompDirectiveFilterFileExt}` + ); + expect(script).toContain( + `shellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs}` + ); + expect(script).toContain( + `shellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder}` + ); + }); + + it('should handle special characters in the name', () => { + const script = zsh.generate(specialName, exec); + + // Check that the script properly escapes the name + expect(script).toContain(`#compdef ${specialName}`); + // In zsh, special characters are not escaped in the function name + expect(script).toContain(`__${specialName}_debug()`); + expect(script).toContain(`_${specialName}()`); + }); + }); + + describe('powershell completion', () => { + it('should generate a valid powershell completion script', () => { + const script = powershell.generate(name, exec); + + // Check that the script contains the shell name + expect(script).toContain(`# powershell completion for ${name}`); + + // Check that the script contains the debug function + expect(script).toContain(`function __${name}_debug`); + + // Check that the script contains the completion block + expect(script).toContain(`[scriptblock]$__${name}CompleterBlock =`); + + // Check that the script uses the provided exec path + expect(script).toContain(`$RequestComp = "& ${exec} complete --`); + + // Check that the script handles directives + expect(script).toContain( + `$ShellCompDirectiveError=${ShellCompDirective.ShellCompDirectiveError}` + ); + expect(script).toContain( + `$ShellCompDirectiveNoSpace=${ShellCompDirective.ShellCompDirectiveNoSpace}` + ); + expect(script).toContain( + `$ShellCompDirectiveNoFileComp=${ShellCompDirective.ShellCompDirectiveNoFileComp}` + ); + expect(script).toContain( + `$ShellCompDirectiveFilterFileExt=${ShellCompDirective.ShellCompDirectiveFilterFileExt}` + ); + expect(script).toContain( + `$ShellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs}` + ); + expect(script).toContain( + `$ShellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder}` + ); + }); + + it('should handle special characters in the name', () => { + const script = powershell.generate(specialName, exec); + + // Check that the script properly escapes the name + // In PowerShell, special characters are not escaped in the function name + expect(script).toContain(`function __${specialName}_debug`); + // The CompleterBlock name uses underscores instead of colons + expect(script).toContain(`$__test_cli_appCompleterBlock`); + }); + }); +});