Skip to content

Commit 5e52540

Browse files
committed
boolean opts
1 parent 571f12e commit 5e52540

File tree

8 files changed

+166
-23
lines changed

8 files changed

+166
-23
lines changed

examples/demo.cac.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ cli
1313
.command('dev', 'Start dev server')
1414
.option('-H, --host [host]', `Specify hostname`)
1515
.option('-p, --port <port>', `Specify port`)
16+
.option('-v, --verbose', `Enable verbose logging`)
1617
.action((options) => {});
1718

1819
cli
@@ -23,6 +24,8 @@ cli
2324

2425
cli.command('dev build', 'Build project').action((options) => {});
2526

27+
cli.command('dev start', 'Start development server').action((options) => {});
28+
2629
cli
2730
.command('copy <source> <destination>', 'Copy files')
2831
.action((source, destination, options) => {});

examples/demo.citty.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ const devCommand = defineCommand({
5353
description: 'Specify port',
5454
alias: 'p',
5555
},
56+
verbose: {
57+
type: 'boolean',
58+
description: 'Enable verbose logging',
59+
alias: 'v',
60+
},
5661
},
5762
run: () => {},
5863
});
@@ -65,6 +70,14 @@ const buildCommand = defineCommand({
6570
run: () => {},
6671
});
6772

73+
const startCommand = defineCommand({
74+
meta: {
75+
name: 'start',
76+
description: 'Start development server',
77+
},
78+
run: () => {},
79+
});
80+
6881
const copyCommand = defineCommand({
6982
meta: {
7083
name: 'copy',
@@ -100,9 +113,13 @@ const lintCommand = defineCommand({
100113
run: () => {},
101114
});
102115

116+
devCommand.subCommands = {
117+
build: buildCommand,
118+
start: startCommand,
119+
};
120+
103121
main.subCommands = {
104122
dev: devCommand,
105-
build: buildCommand,
106123
copy: copyCommand,
107124
lint: lintCommand,
108125
} as Record<string, CommandDef<ArgsDef>>;

examples/demo.t.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ devCmd.option(
6262
'p'
6363
);
6464

65+
devCmd.option('verbose', 'Enable verbose logging', undefined, 'v', true);
66+
6567
// Serve command
6668
const serveCmd = t.command('serve', 'Start the server');
6769
serveCmd.option(
@@ -87,6 +89,9 @@ serveCmd.option(
8789
// Build command
8890
t.command('dev build', 'Build project');
8991

92+
// Start command
93+
t.command('dev start', 'Start development server');
94+
9095
// Copy command with multiple arguments
9196
const copyCmd = t
9297
.command('copy', 'Copy files')

src/cac.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,19 @@ export default async function tab(
7777
const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1];
7878
const argName = option.name.replace(/^-[a-zA-Z], --/, '');
7979

80+
// Detect if this is a boolean option by checking if it has <value> or [value] in the raw definition
81+
const isBoolean =
82+
!option.rawName.includes('<') && !option.rawName.includes('[');
83+
8084
// Add option using t.ts API
8185
const targetCommand = isRootCommand ? t : command;
8286
if (targetCommand) {
8387
targetCommand.option(
8488
argName, // Store just the option name without -- prefix
8589
option.description || '',
8690
commandCompletionConfig?.options?.[argName] ?? noopOptionHandler,
87-
shortFlag
91+
shortFlag,
92+
isBoolean
8893
);
8994
}
9095
}

src/citty.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ async function handleSubCommands(
105105

106106
// Add command using t.ts API
107107
const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd;
108-
const command = t.command(cmd, meta.description);
108+
const command = t.command(commandName, meta.description);
109109

110110
// Set args for the command if it has positional arguments
111111
if (isPositional && config.args) {
@@ -138,9 +138,6 @@ async function handleSubCommands(
138138
if (config.args) {
139139
for (const [argName, argConfig] of Object.entries(config.args)) {
140140
const conf = argConfig as ArgDef;
141-
if (conf.type === 'positional') {
142-
continue;
143-
}
144141
// Extract alias from the config if it exists
145142
const shortFlag =
146143
typeof conf === 'object' && 'alias' in conf
@@ -150,11 +147,13 @@ async function handleSubCommands(
150147
: undefined;
151148

152149
// Add option using t.ts API - store without -- prefix
150+
const isBoolean = conf.type === 'boolean';
153151
command.option(
154152
argName,
155153
conf.description ?? '',
156154
subCompletionConfig?.options?.[argName] ?? noopOptionHandler,
157-
shortFlag
155+
shortFlag,
156+
isBoolean
158157
);
159158
}
160159
}
@@ -206,12 +205,15 @@ export default async function tab<TArgs extends ArgsDef>(
206205

207206
if (instance.args) {
208207
for (const [argName, argConfig] of Object.entries(instance.args)) {
209-
const conf = argConfig as PositionalArgDef;
208+
const conf = argConfig as ArgDef;
210209
// Add option using t.ts API - store without -- prefix
210+
const isBoolean = conf.type === 'boolean';
211211
t.option(
212212
argName,
213213
conf.description ?? '',
214-
completionConfig?.options?.[argName] ?? noopOptionHandler
214+
completionConfig?.options?.[argName] ?? noopOptionHandler,
215+
undefined,
216+
isBoolean
215217
);
216218
}
217219
}

src/t.ts

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,22 @@ export class Option {
5757
command: Command;
5858
handler?: OptionHandler;
5959
alias?: string;
60-
// TODO: handle boolean options
60+
isBoolean?: boolean;
6161

6262
constructor(
6363
command: Command,
6464
value: string,
6565
description: string,
6666
handler?: OptionHandler,
67-
alias?: string
67+
alias?: string,
68+
isBoolean?: boolean
6869
) {
6970
this.command = command;
7071
this.value = value;
7172
this.description = description;
7273
this.handler = handler;
7374
this.alias = alias;
75+
this.isBoolean = isBoolean;
7476
}
7577
}
7678

@@ -90,9 +92,17 @@ export class Command {
9092
value: string,
9193
description: string,
9294
handler?: OptionHandler,
93-
alias?: string
95+
alias?: string,
96+
isBoolean?: boolean
9497
) {
95-
const option = new Option(this, value, description, handler, alias);
98+
const option = new Option(
99+
this,
100+
value,
101+
description,
102+
handler,
103+
alias,
104+
isBoolean
105+
);
96106
this.options.set(value, option);
97107
return this;
98108
}
@@ -135,7 +145,28 @@ export class RootCommand extends Command {
135145

136146
if (arg.startsWith('-')) {
137147
i++; // Skip the option
138-
if (i < args.length && !args[i].startsWith('-')) {
148+
149+
// Check if this option expects a value (not boolean)
150+
// We need to check across all commands since we don't know which command context we're in yet
151+
let isBoolean = false;
152+
153+
// Check root command options
154+
const rootOption = this.findOption(this, arg);
155+
if (rootOption) {
156+
isBoolean = rootOption.isBoolean ?? false;
157+
} else {
158+
// Check all subcommand options
159+
for (const [, command] of this.commands) {
160+
const option = this.findOption(command, arg);
161+
if (option) {
162+
isBoolean = option.isBoolean ?? false;
163+
break;
164+
}
165+
}
166+
}
167+
168+
// Only skip the next argument if this is not a boolean option and the next arg doesn't start with -
169+
if (!isBoolean && i < args.length && !args[i].startsWith('-')) {
139170
i++; // Skip the option value
140171
}
141172
} else {
@@ -176,7 +207,33 @@ export class RootCommand extends Command {
176207
toComplete: string,
177208
endsWithSpace: boolean
178209
): boolean {
179-
return lastPrevArg?.startsWith('-') || toComplete.startsWith('-');
210+
// Always complete if the current token starts with a dash
211+
if (toComplete.startsWith('-')) {
212+
return true;
213+
}
214+
215+
// If the previous argument was an option, check if it expects a value
216+
if (lastPrevArg?.startsWith('-')) {
217+
// Find the option to check if it's boolean
218+
let option = this.findOption(this, lastPrevArg);
219+
if (!option) {
220+
// Check all subcommand options
221+
for (const [, command] of this.commands) {
222+
option = this.findOption(command, lastPrevArg);
223+
if (option) break;
224+
}
225+
}
226+
227+
// If it's a boolean option, don't try to complete its value
228+
if (option && option.isBoolean) {
229+
return false;
230+
}
231+
232+
// Non-boolean options expect values
233+
return true;
234+
}
235+
236+
return false;
180237
}
181238

182239
// Determine if we should complete commands
@@ -276,7 +333,7 @@ export class RootCommand extends Command {
276333

277334
// Handle command completion
278335
private handleCommandCompletion(previousArgs: string[], toComplete: string) {
279-
const commandParts = previousArgs.filter(Boolean);
336+
const commandParts = this.stripOptions(previousArgs);
280337

281338
for (const [k, command] of this.commands) {
282339
if (k === '') continue;
@@ -373,7 +430,9 @@ export class RootCommand extends Command {
373430
const previousArgs = args.slice(0, -1);
374431

375432
if (endsWithSpace) {
376-
previousArgs.push(toComplete);
433+
if (toComplete !== '') {
434+
previousArgs.push(toComplete);
435+
}
377436
toComplete = '';
378437
}
379438

@@ -390,11 +449,31 @@ export class RootCommand extends Command {
390449
lastPrevArg
391450
);
392451
} else {
452+
// Check if we just finished a boolean option with no value expected
453+
// In this case, don't complete anything
454+
if (lastPrevArg?.startsWith('-') && toComplete === '' && endsWithSpace) {
455+
let option = this.findOption(this, lastPrevArg);
456+
if (!option) {
457+
// Check all subcommand options
458+
for (const [, command] of this.commands) {
459+
option = this.findOption(command, lastPrevArg);
460+
if (option) break;
461+
}
462+
}
463+
464+
// If it's a boolean option followed by empty space, don't complete anything
465+
if (option && option.isBoolean) {
466+
// Don't add any completions, just output the directive
467+
this.complete(toComplete);
468+
return;
469+
}
470+
}
471+
393472
// 2. Handle command/subcommand completion
394473
if (this.shouldCompleteCommands(toComplete, endsWithSpace)) {
395474
this.handleCommandCompletion(previousArgs, toComplete);
396475
}
397-
// 3. Handle positional arguments
476+
// 3. Handle positional arguments - always check for root command arguments
398477
if (matchedCommand && matchedCommand.arguments.size > 0) {
399478
this.handlePositionalCompletion(
400479
matchedCommand,

tests/__snapshots__/cli.test.ts.snap

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,11 @@ lint Lint project
188188
`;
189189

190190
exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = `
191-
":4
191+
"dev Start dev server
192+
serve Start the server
193+
copy Copy files
194+
lint Lint project
195+
:4
192196
"
193197
`;
194198

@@ -470,7 +474,6 @@ index.ts Index file
470474

471475
exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = `
472476
"dev Start dev server
473-
build Build project
474477
copy Copy files
475478
lint Lint project
476479
my-app My application
@@ -481,7 +484,10 @@ my-tool My tool
481484
`;
482485

483486
exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = `
484-
":4
487+
"dev Start dev server
488+
copy Copy files
489+
lint Lint project
490+
:4
485491
"
486492
`;
487493

@@ -579,7 +585,6 @@ exports[`cli completion tests for citty > short flag handling > should not show
579585

580586
exports[`cli completion tests for citty > should complete cli options 1`] = `
581587
"dev Start dev server
582-
build Build project
583588
copy Copy files
584589
lint Lint project
585590
my-app My application
@@ -811,7 +816,11 @@ my-tool My tool
811816
`;
812817

813818
exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = `
814-
":4
819+
"dev Start dev server
820+
serve Start the server
821+
copy Copy files
822+
lint Lint project
823+
:4
815824
"
816825
`;
817826

tests/cli.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => {
8989
});
9090
});
9191

92+
describe.runIf(!shouldSkipTest)('boolean option handling', () => {
93+
it('should not provide value completions for boolean options', async () => {
94+
const command = `${commandPrefix} dev --verbose ""`;
95+
const output = await runCommand(command);
96+
// Boolean options should return just the directive, no completions
97+
expect(output.trim()).toBe(':4');
98+
});
99+
100+
it('should not provide value completions for short boolean options', async () => {
101+
const command = `${commandPrefix} dev -v ""`;
102+
const output = await runCommand(command);
103+
// Boolean options should return just the directive, no completions
104+
expect(output.trim()).toBe(':4');
105+
});
106+
107+
it('should not interfere with command completion after boolean options', async () => {
108+
const command = `${commandPrefix} dev --verbose s`;
109+
const output = await runCommand(command);
110+
// Should complete subcommands that start with 's' even after a boolean option
111+
expect(output).toContain('start');
112+
});
113+
});
114+
92115
describe.runIf(!shouldSkipTest)('--config option tests', () => {
93116
it('should complete --config option values', async () => {
94117
const command = `${commandPrefix} --config ""`;

0 commit comments

Comments
 (0)