Skip to content

Commit a9d4f04

Browse files
murrayjuAmirSa12
andauthored
fix: positional argument completion broken after flags (#111)
* fix: positional argument completion broken after flags Three interacting bugs prevented positional argument completion when flags preceded the argument (e.g., 'mycli logs -f <TAB>'): 1. handlePositionalCompletion received raw args including flags, so its positional index calculation was inflated. Fix: call stripOptions() on previousArgs before counting positions. 2. parse() had an early return after detecting a boolean flag that skipped both handleCommandCompletion and handlePositionalCompletion. Fix: remove the early return to let control fall through. 3. The commander adapter marked ALL options as isBoolean=true, even value-taking ones like '--output <format>'. This caused stripOptions to not skip the value argument, further corrupting the positional index. Fix: detect '<' or '[' in flag syntax and register with a no-op handler to force isBoolean=false. Fixes #110 * style: revert cosmetic linter changes, keep only meaningful fix Remove import reordering, trailing comma additions, arrow function conversion, and test.each reformatting that were introduced by a linter not used by the project. Retains only the actual bug fix for positional completion after flags. * pnpm changeset --------- Co-authored-by: AmirSa12 <amirhosseinpr184@gmail.com>
1 parent e05d809 commit a9d4f04

5 files changed

Lines changed: 102 additions & 56 deletions

File tree

.changeset/chatty-wings-melt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@bomb.sh/tab': patch
3+
---
4+
5+
fix positional argument completion broken after flags

src/commander.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,55 @@ export default function tab(instance: CommanderCommand): RootCommand {
101101
return t;
102102
}
103103

104+
/**
105+
* Detect whether a commander option flag expects a value argument.
106+
* Options with `<value>` or `[value]` in their flags are value-taking.
107+
*/
108+
function optionTakesValue(flags: string): boolean {
109+
return flags.includes('<') || flags.includes('[');
110+
}
111+
112+
/**
113+
* Register a commander option with the tab library, correctly setting
114+
* isBoolean based on whether the option takes a value.
115+
*
116+
* The tab Command.option() method infers isBoolean from the argument types:
117+
* - string arg → alias, isBoolean=true
118+
* - function arg → handler, isBoolean=false
119+
* So for value-taking options with an alias, we pass a no-op handler
120+
* and the alias separately to get isBoolean=false.
121+
*/
122+
function registerOption(
123+
tabCommand: {
124+
option: (
125+
value: string,
126+
description: string,
127+
handlerOrAlias?: ((...args: unknown[]) => void) | string,
128+
alias?: string
129+
) => unknown;
130+
},
131+
flags: string,
132+
longFlag: string,
133+
description: string,
134+
shortFlag?: string
135+
): void {
136+
const takesValue = optionTakesValue(flags);
137+
if (shortFlag) {
138+
if (takesValue) {
139+
// Pass a no-op handler to force isBoolean=false, with alias as 4th arg
140+
tabCommand.option(longFlag, description, () => {}, shortFlag);
141+
} else {
142+
tabCommand.option(longFlag, description, shortFlag);
143+
}
144+
} else {
145+
if (takesValue) {
146+
tabCommand.option(longFlag, description, () => {});
147+
} else {
148+
tabCommand.option(longFlag, description);
149+
}
150+
}
151+
}
152+
104153
function processRootCommand(command: CommanderCommand): void {
105154
// Add root command options to the root t instance
106155
for (const option of command.options) {
@@ -110,11 +159,7 @@ function processRootCommand(command: CommanderCommand): void {
110159
const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1];
111160

112161
if (longFlag) {
113-
if (shortFlag) {
114-
t.option(longFlag, option.description || '', shortFlag);
115-
} else {
116-
t.option(longFlag, option.description || '');
117-
}
162+
registerOption(t, flags, longFlag, option.description || '', shortFlag);
118163
}
119164
}
120165
}
@@ -141,11 +186,13 @@ function processSubcommands(rootCommand: CommanderCommand): void {
141186
const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1];
142187

143188
if (longFlag) {
144-
if (shortFlag) {
145-
command.option(longFlag, option.description || '', shortFlag);
146-
} else {
147-
command.option(longFlag, option.description || '');
148-
}
189+
registerOption(
190+
command,
191+
flags,
192+
longFlag,
193+
option.description || '',
194+
shortFlag
195+
);
149196
}
150197
}
151198
}

src/t.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,11 @@ export class RootCommand extends Command {
349349

350350
// positional argument completion
351351
private handlePositionalCompletion(command: Command, previousArgs: string[]) {
352+
// Strip options so flags don't inflate the positional index
353+
const strippedArgs = this.stripOptions(previousArgs);
352354
// current argument position (subtract command name)
353355
const commandParts = command.value.split(' ').length;
354-
const currentArgIndex = Math.max(0, previousArgs.length - commandParts);
356+
const currentArgIndex = Math.max(0, strippedArgs.length - commandParts);
355357
const argumentEntries = Array.from(command.arguments.entries());
356358

357359
if (argumentEntries.length > 0) {
@@ -438,20 +440,10 @@ export class RootCommand extends Command {
438440
lastPrevArg
439441
);
440442
} else {
441-
if (lastPrevArg?.startsWith('-') && toComplete === '' && endsWithSpace) {
442-
let option = this.findOption(this, lastPrevArg);
443-
if (!option) {
444-
for (const [, command] of this.commands) {
445-
option = this.findOption(command, lastPrevArg);
446-
if (option) break;
447-
}
448-
}
449-
450-
if (option && option.isBoolean) {
451-
this.complete(toComplete);
452-
return;
453-
}
454-
}
443+
// Note: we intentionally do NOT early-return after detecting a boolean
444+
// flag. The previous code called this.complete(toComplete) and returned
445+
// here, which skipped positional argument completion. After a boolean
446+
// flag like -f, the user may still be completing a positional argument.
455447

456448
if (this.shouldCompleteCommands(toComplete)) {
457449
this.handleCommandCompletion(previousArgs, toComplete);

tests/__snapshots__/cli.test.ts.snap

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -506,12 +506,18 @@ exports[`cli completion tests for citty > root command argument tests > should c
506506
"dev Start dev server
507507
copy Copy files
508508
lint Lint project
509+
my-app My application
510+
my-lib My library
511+
my-tool My tool
509512
:4
510513
"
511514
`;
512515

513516
exports[`cli completion tests for citty > root command argument tests > should complete root command project argument with options and partial input 1`] = `
514-
":4
517+
"my-app My application
518+
my-lib My library
519+
my-tool My tool
520+
:4
515521
"
516522
`;
517523

@@ -620,23 +626,6 @@ my-tool My tool
620626
"
621627
`;
622628

623-
exports[`cli completion tests for commander > cli option value handling > should handle unknown options with no completions 1`] = `":4"`;
624-
625-
exports[`cli completion tests for commander > short flag handling > should handle global short flags 1`] = `
626-
"-c specify config file
627-
:4
628-
"
629-
`;
630-
631-
exports[`cli completion tests for commander > should complete cli options 1`] = `
632-
"serve Start the server
633-
build Build the project
634-
deploy Deploy the application
635-
lint Lint source files
636-
:4
637-
"
638-
`;
639-
640629
exports[`cli completion tests for t > --config option tests > should complete --config option values 1`] = `
641630
"vite.config.ts Vite config file
642631
vite.config.js Vite config file
@@ -846,12 +835,18 @@ exports[`cli completion tests for t > root command argument tests > should compl
846835
serve Start the server
847836
copy Copy files
848837
lint Lint project
838+
my-app My application
839+
my-lib My library
840+
my-tool My tool
849841
:4
850842
"
851843
`;
852844

853845
exports[`cli completion tests for t > root command argument tests > should complete root command project argument with options and partial input 1`] = `
854-
":4
846+
"my-app My application
847+
my-lib My library
848+
my-tool My tool
849+
:4
855850
"
856851
`;
857852

tests/cli.test.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,22 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => {
9090
});
9191

9292
describe.runIf(!shouldSkipTest)('boolean option handling', () => {
93-
it('should not provide value completions for boolean options', async () => {
93+
it('should complete subcommands and arguments after boolean options', async () => {
9494
const command = `${commandPrefix} dev --verbose ""`;
9595
const output = await runCommand(command);
96-
// Boolean options should return just the directive, no completions
97-
expect(output.trim()).toBe(':4');
96+
// After a boolean option, should show subcommands/arguments (not flag values)
97+
expect(output).toContain('build');
98+
expect(output).toContain('start');
99+
expect(output).not.toContain('verbose');
98100
});
99101

100-
it('should not provide value completions for short boolean options', async () => {
102+
it('should complete subcommands and arguments after short boolean options', async () => {
101103
const command = `${commandPrefix} dev -v ""`;
102104
const output = await runCommand(command);
103-
// Boolean options should return just the directive, no completions
104-
expect(output.trim()).toBe(':4');
105+
// After a short boolean option, should show subcommands/arguments (not flag values)
106+
expect(output).toContain('build');
107+
expect(output).toContain('start');
108+
expect(output).not.toContain('verbose');
105109
});
106110

107111
it('should not interfere with command completion after boolean options', async () => {
@@ -124,24 +128,27 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => {
124128
// This tests the case: option('quiet', 'Suppress output')
125129
const command = `${commandPrefix} dev --quiet ""`;
126130
const output = await runCommand(command);
127-
// Should be treated as boolean flag (no value completion)
128-
expect(output.trim()).toBe(':4');
131+
// Should be treated as boolean flag — shows subcommands, not flag values
132+
expect(output).toContain('build');
133+
expect(output).not.toContain('quiet');
129134
});
130135

131136
it('should handle option with alias only as boolean flag', async () => {
132137
// This tests the case: option('verbose', 'Enable verbose', 'v')
133138
const command = `${commandPrefix} dev --verbose ""`;
134139
const output = await runCommand(command);
135-
// Should be treated as boolean flag (no value completion)
136-
expect(output.trim()).toBe(':4');
140+
// Should be treated as boolean flag — shows subcommands, not flag values
141+
expect(output).toContain('build');
142+
expect(output).not.toContain('verbose');
137143
});
138144

139145
it('should handle option with alias only (short flag) as boolean flag', async () => {
140146
// This tests the short flag version: -v instead of --verbose
141147
const command = `${commandPrefix} dev -v ""`;
142148
const output = await runCommand(command);
143-
// Should be treated as boolean flag (no value completion)
144-
expect(output.trim()).toBe(':4');
149+
// Should be treated as boolean flag — shows subcommands, not flag values
150+
expect(output).toContain('build');
151+
expect(output).not.toContain('verbose');
145152
});
146153

147154
it('should handle option with handler only as value option', async () => {

0 commit comments

Comments
 (0)