diff --git a/.changeset/cold-eggs-wonder.md b/.changeset/cold-eggs-wonder.md new file mode 100644 index 0000000..4bb2c50 --- /dev/null +++ b/.changeset/cold-eggs-wonder.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +feat(commander): support completions for positional arguments diff --git a/examples/demo.commander.ts b/examples/demo.commander.ts index 909e8af..1d30d1f 100644 --- a/examples/demo.commander.ts +++ b/examples/demo.commander.ts @@ -81,6 +81,14 @@ program console.log('Linting files...'); }); +// Command with multiple required positional arguments +program + .command('copy ') + .description('Copy files') + .action((source, destination) => { + console.log(`Copying ${source} to ${destination}...`); + }); + // Initialize tab completion const completion = tab(program); @@ -129,5 +137,38 @@ if (devCommandInstance) { } } +// Positional arguments on lint command +const lintCommandInstance = completion.commands.get('lint'); +if (lintCommandInstance) { + const filesArg = lintCommandInstance.arguments.get('files'); + if (filesArg) { + filesArg.handler = (complete) => { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }; + } +} + +// Positional arguments on copy command +const copyCommandInstance = completion.commands.get('copy'); +if (copyCommandInstance) { + const sourceArg = copyCommandInstance.arguments.get('source'); + if (sourceArg) { + sourceArg.handler = (complete) => { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }; + } + const destinationArg = copyCommandInstance.arguments.get('destination'); + if (destinationArg) { + destinationArg.handler = (complete) => { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }; + } +} + // Parse command line arguments program.parse(); diff --git a/src/commander.ts b/src/commander.ts index 71ad63d..7832bd0 100644 --- a/src/commander.ts +++ b/src/commander.ts @@ -1,5 +1,5 @@ import type { Command as CommanderCommand } from 'commander'; -import t, { type RootCommand } from './t'; +import t, { Command as TabCommand, type RootCommand } from './t'; // rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types. interface CommandWithRawArgs extends CommanderCommand { @@ -165,6 +165,25 @@ function processRootCommand(command: CommanderCommand): void { registerOption(t, flags, longFlag, option.description || '', shortFlag); } } + + processArguments(t, command); +} + +function processArguments(tabCommand: TabCommand, cmd: CommanderCommand): void { + for (const arg of cmd.registeredArguments) { + const choices = arg.argChoices; + if (choices?.length) { + tabCommand.argument( + arg.name(), + (complete) => { + for (const choice of choices) complete(choice, ''); + }, + arg.variadic + ); + } else { + tabCommand.argument(arg.name(), undefined, arg.variadic); + } + } } function processSubcommands(rootCommand: CommanderCommand): void { @@ -198,6 +217,8 @@ function processSubcommands(rootCommand: CommanderCommand): void { ); } } + + processArguments(command, cmd); } } diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 1b4eebf..2261316 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -717,6 +717,35 @@ exports[`cli completion tests for commander > cli option value handling > should " `; +exports[`cli completion tests for commander > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for commander > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for commander > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -737,6 +766,84 @@ exports[`cli completion tests for commander > edge case completions for end with " `; +exports[`cli completion tests for commander > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +build Build the project +deploy Deploy the application +lint Lint source files +copy Copy files +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument after options 1`] = ` +"dev Start dev server +serve Start the server +build Build the project +deploy Deploy the application +lint Lint source files +copy Copy files +:4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with partial input 1`] = ` +":4 +" +`; + exports[`cli completion tests for commander > root command option tests > should complete root command --logLevel option values 1`] = ` "info Info level warn Warn level @@ -829,6 +936,7 @@ serve Start the server build Build the project deploy Deploy the application lint Lint source files +copy Copy files :4 " `; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index c1244d8..742a509 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -16,9 +16,6 @@ function runCommand(command: string): Promise { const cliTools = ['t', 'citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { - // Commander does not have custom completions for arguments yet. - const shouldSkipTest = cliTool === 'commander'; - const commandPrefix = `pnpm tsx examples/demo.${cliTool}.ts complete --`; it('should complete cli options', async () => { @@ -230,7 +227,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('root command argument tests', () => { + describe('root command argument tests', () => { it('should complete root command project argument', async () => { const command = `${commandPrefix} ""`; const output = await runCommand(command); @@ -352,7 +349,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('positional argument completions', () => { + describe('positional argument completions', () => { it('should complete multiple positional arguments when ending with space', async () => { const command = `${commandPrefix} lint ""`; const output = await runCommand(command); @@ -372,7 +369,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('copy command argument handlers', () => { + describe('copy command argument handlers', () => { it('should complete source argument with directory suggestions', async () => { const command = `${commandPrefix} copy ""`; const output = await runCommand(command); @@ -398,7 +395,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest)('lint command argument handlers', () => { + describe('lint command argument handlers', () => { it('should complete files argument with file suggestions', async () => { const command = `${commandPrefix} lint ""`; const output = await runCommand(command);