From 6c5ccba3ee979b202ca8dbdbd511fc7a44b8aa2f Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 15 Oct 2026 15:46:09 +0330 Subject: [PATCH 1/2] fix(commander): match commander options scope behavior in different modes --- src/commander.ts | 15 ++- src/option-scope.ts | 18 +++ src/t.ts | 163 +++++++++++++++++++---- tests/commander-option-scope.test.ts | 104 +++++++++++++++ tests/fixtures/commander-option-scope.ts | 55 ++++++++ 5 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 src/option-scope.ts create mode 100644 tests/commander-option-scope.test.ts create mode 100644 tests/fixtures/commander-option-scope.ts diff --git a/src/commander.ts b/src/commander.ts index 45f95a7..60ef89a 100644 --- a/src/commander.ts +++ b/src/commander.ts @@ -1,16 +1,29 @@ import type { Command as CommanderCommand } from 'commander'; -import t, { Command as TabCommand, type RootCommand, OptionHandler } from './t'; +import t, { + type Command as TabCommand, + type RootCommand, + type OptionHandler, +} from './t'; +import { setOptionLookupMode } from './option-scope'; // rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types. interface CommandWithRawArgs extends CommanderCommand { rawArgs: string[]; } +// Commander does not expose a getter for this setting. +interface CommandWithPositionalOptions extends CommanderCommand { + _enablePositionalOptions: boolean; +} + export default function tab( instance: CommanderCommand, completionConfig?: { completionCommandName?: string } ): RootCommand { const programName = instance.name(); + const positionalOptions = (instance as CommandWithPositionalOptions) + ._enablePositionalOptions; + setOptionLookupMode(t, positionalOptions ? 'local' : 'parent-first'); // Make a `completion` command with a required command-argument. const completionCommandName = diff --git a/src/option-scope.ts b/src/option-scope.ts new file mode 100644 index 0000000..977e629 --- /dev/null +++ b/src/option-scope.ts @@ -0,0 +1,18 @@ +import type { RootCommand } from './t'; + +export type OptionLookupMode = 'parent-first' | 'local'; + +const optionLookupModes = new WeakMap(); + +export function setOptionLookupMode( + command: RootCommand, + mode: OptionLookupMode +): void { + optionLookupModes.set(command, mode); +} + +export function getOptionLookupMode( + command: RootCommand +): OptionLookupMode | undefined { + return optionLookupModes.get(command); +} diff --git a/src/t.ts b/src/t.ts index b22cd9c..6ce1b22 100644 --- a/src/t.ts +++ b/src/t.ts @@ -135,6 +135,7 @@ import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; import assert from 'node:assert'; +import { getOptionLookupMode } from './option-scope'; export class RootCommand extends Command { commands = new Map(); @@ -147,11 +148,68 @@ export class RootCommand extends Command { command(value: string, description: string) { const c = new Command(value, description); + const parentPath = value.split(' ').slice(0, -1).join(' '); + c.parent = parentPath ? this.commands.get(parentPath) : this; this.commands.set(value, c); return c; } + private get optionLookupMode() { + return getOptionLookupMode(this); + } + + private scanArguments(args: string[]): { + command: Command; + parts: string[]; + } { + const parts: string[] = []; + const commandParts: string[] = []; + let matchedCommand: Command | undefined; + let canMatchCommand = true; + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith('-')) { + i++; + + const option = this.findOptionInScope(matchedCommand ?? this, arg); + const isBoolean = option?.isBoolean ?? false; + + // skip the next argument if this is not a boolean option and the next arg doesn't start with - + if (!isBoolean && i < args.length && !args[i].startsWith('-')) { + i++; + } + } else { + parts.push(arg); + + if (canMatchCommand) { + commandParts.push(arg); + const commandPath = commandParts.join(' '); + const command = this.commands.get(commandPath); + + if (command) { + matchedCommand = command; + } + + canMatchCommand = Array.from(this.commands.keys()).some( + (name) => name === commandPath || name.startsWith(`${commandPath} `) + ); + } + + i++; + } + } + + return { command: matchedCommand ?? this, parts }; + } + private stripOptions(args: string[]): string[] { + if (this.optionLookupMode) { + return this.scanArguments(args).parts; + } + const parts: string[] = []; let i = 0; @@ -191,6 +249,13 @@ export class RootCommand extends Command { } private matchCommand(args: string[]): [Command, string[]] { + if (this.optionLookupMode) { + const { command, parts } = this.scanArguments(args); + const commandPartCount = + command === this ? 0 : command.value.split(' ').length; + return [command, parts.slice(commandPartCount)]; + } + args = this.stripOptions(args); const parts: string[] = []; let remaining: string[] = []; @@ -213,6 +278,7 @@ export class RootCommand extends Command { } private shouldCompleteFlags( + command: Command, lastPrevArg: string | undefined, toComplete: string ): boolean { @@ -222,12 +288,18 @@ export class RootCommand extends Command { // previous argument was an option, check if it expects a value if (lastPrevArg?.startsWith('-')) { - let option = this.findOption(this, lastPrevArg); - if (!option) { - // subcommand options - for (const [, command] of this.commands) { - option = this.findOption(command, lastPrevArg); - if (option) break; + let option: Option | undefined; + + if (this.optionLookupMode) { + option = this.findOptionInScope(command, lastPrevArg); + } else { + option = this.findOption(this, lastPrevArg); + if (!option) { + // subcommand options + for (const [, candidate] of this.commands) { + option = this.findOption(candidate, lastPrevArg); + if (option) break; + } } } @@ -261,21 +333,21 @@ export class RootCommand extends Command { const [flag] = toComplete.split('='); optionName = flag; } else if (lastPrevArg?.startsWith('-')) { - const option = this.findOption(command, lastPrevArg); + const option = this.findOptionForCompletion(command, lastPrevArg); if (option && !option.isBoolean) { optionName = lastPrevArg; } } if (optionName) { - const option = this.findOption(command, optionName); + const option = this.findOptionForCompletion(command, optionName); if (option?.handler) { const suggestions: Completion[] = []; option.handler.call( option, (value: string, description: string) => suggestions.push({ value, description }), - command.options + option.command.options ); this.completions = suggestions; @@ -288,26 +360,67 @@ export class RootCommand extends Command { toComplete.startsWith('-') && !toComplete.startsWith('--'); const cleanToComplete = toComplete.replace(/^-+/, ''); - for (const [name, option] of command.options) { - if ( - isShortFlag && - option.alias && - `-${option.alias}`.startsWith(toComplete) - ) { - this.completions.push({ - value: `-${option.alias}`, - description: option.description, - }); - } else if (!isShortFlag && name.startsWith(cleanToComplete)) { - this.completions.push({ - value: `--${name}`, - description: option.description, - }); + for (const optionCommand of this.optionCommandsFor(command)) { + for (const [name, option] of optionCommand.options) { + if ( + isShortFlag && + option.alias && + `-${option.alias}`.startsWith(toComplete) + ) { + this.completions.push({ + value: `-${option.alias}`, + description: option.description, + }); + } else if (!isShortFlag && name.startsWith(cleanToComplete)) { + this.completions.push({ + value: `--${name}`, + description: option.description, + }); + } } } } } + private optionCommandsFor(command: Command): Command[] { + if (!this.optionLookupMode) { + return [command]; + } + + if (this.optionLookupMode === 'local') { + return [command]; + } + + const commands: Command[] = []; + let candidate: Command | undefined = command; + while (candidate) { + commands.unshift(candidate); + candidate = candidate.parent; + } + return commands; + } + + private findOptionInScope( + command: Command, + optionName: string + ): Option | undefined { + for (const candidate of this.optionCommandsFor(command)) { + const option = this.findOption(candidate, optionName); + if (option) return option; + } + return undefined; + } + + private findOptionForCompletion( + command: Command, + optionName: string + ): Option | undefined { + if (this.optionLookupMode) { + return this.findOptionInScope(command, optionName); + } + return this.findOption(command, optionName); + } + // find option by name or alias private findOption(command: Command, optionName: string): Option | undefined { let option = command.options.get(optionName); @@ -432,7 +545,7 @@ export class RootCommand extends Command { const [matchedCommand] = this.matchCommand(previousArgs); const lastPrevArg = previousArgs[previousArgs.length - 1]; - if (this.shouldCompleteFlags(lastPrevArg, toComplete)) { + if (this.shouldCompleteFlags(matchedCommand, lastPrevArg, toComplete)) { this.handleFlagCompletion( matchedCommand, previousArgs, diff --git a/tests/commander-option-scope.test.ts b/tests/commander-option-scope.test.ts new file mode 100644 index 0000000..b56e123 --- /dev/null +++ b/tests/commander-option-scope.test.ts @@ -0,0 +1,104 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const fixture = 'tests/fixtures/commander-option-scope.ts'; + +async function complete( + args: string[], + positionalOptions: boolean +): Promise { + const { stdout } = await execFileAsync('pnpm', ['tsx', fixture, ...args], { + cwd: process.cwd(), + env: { + ...process.env, + COMMANDER_POSITIONAL: positionalOptions ? '1' : '0', + }, + }); + + return stdout + .split('\n') + .filter((line) => line && !line.startsWith(':')) + .map((line) => line.split('\t')[0]); +} + +describe('Commander option scope', () => { + describe('default options', () => { + it('uses the root option when a subcommand reuses its short flag', async () => { + await expect(complete(['serve', '-c', ''], false)).resolves.toEqual([ + 'top', + 'global', + ]); + }); + + it('keeps a non-conflicting leaf long option available', async () => { + await expect( + complete(['serve', '--leaf-choice', ''], false) + ).resolves.toEqual(['leaf', 'local']); + }); + + it('keeps root option values available after a subcommand', async () => { + await expect( + complete(['serve', '--root-choice', ''], false) + ).resolves.toEqual(['top', 'global']); + }); + + it('suggests root option names after a subcommand', async () => { + await expect(complete(['serve', '--root-c'], false)).resolves.toEqual([ + '--root-choice', + ]); + }); + + it('uses root option arity for a colliding short flag', async () => { + const values = await complete(['serve', '-b', ''], false); + + expect(values).toEqual(expect.arrayContaining(['prod', 'staging'])); + expect(values).not.toEqual(expect.arrayContaining(['blue', 'green'])); + }); + + it('uses an ancestor option before a nested command option', async () => { + await expect( + complete(['serve', 'nested', '-a', ''], false) + ).resolves.toEqual(['parent', 'ancestor']); + }); + }); + + describe('positional options', () => { + it('uses the subcommand option when it reuses a root short flag', async () => { + await expect(complete(['serve', '-c', ''], true)).resolves.toEqual([ + 'leaf', + 'local', + ]); + }); + + it('does not complete root option values after a subcommand', async () => { + await expect( + complete(['serve', '--root-choice', ''], true) + ).resolves.toEqual([]); + }); + + it('does not suggest root option names after a subcommand', async () => { + await expect(complete(['serve', '--root-c'], true)).resolves.toEqual([]); + }); + + it('still accepts root options before the subcommand', async () => { + await expect( + complete(['--root-choice', 'top', 'serve', '-c', ''], true) + ).resolves.toEqual(['leaf', 'local']); + }); + + it('uses leaf option arity for a colliding short flag', async () => { + await expect(complete(['serve', '-b', ''], true)).resolves.toEqual([ + 'blue', + 'green', + ]); + }); + + it('limits nested lookup to the matched command', async () => { + await expect( + complete(['serve', 'nested', '-a', ''], true) + ).resolves.toEqual(['deep', 'nested']); + }); + }); +}); diff --git a/tests/fixtures/commander-option-scope.ts b/tests/fixtures/commander-option-scope.ts new file mode 100644 index 0000000..3f4b322 --- /dev/null +++ b/tests/fixtures/commander-option-scope.ts @@ -0,0 +1,55 @@ +import { Argument, Command, Option } from 'commander'; +import tab from '../../src/commander'; + +const program = new Command('commander-option-scope'); + +if (process.env.COMMANDER_POSITIONAL === '1') { + program.enablePositionalOptions(); +} + +program + .addOption( + new Option('-c, --root-choice ', 'Root choice').choices([ + 'top', + 'global', + ]) + ) + .addOption(new Option('-b, --root-boolean', 'Root boolean')); + +const serve = program + .command('serve') + .description('Serve the app') + .addOption( + new Option('-c, --leaf-choice ', 'Leaf choice').choices([ + 'leaf', + 'local', + ]) + ) + .addOption( + new Option('-b, --leaf-value ', 'Leaf value').choices([ + 'blue', + 'green', + ]) + ) + .addOption( + new Option('-a, --ancestor-choice ', 'Ancestor choice').choices([ + 'parent', + 'ancestor', + ]) + ) + .addArgument( + new Argument('[environment]', 'Environment').choices(['prod', 'staging']) + ); + +serve + .command('nested') + .description('Nested command') + .addOption( + new Option('-a, --nested-choice ', 'Nested choice').choices([ + 'deep', + 'nested', + ]) + ); + +const completion = tab(program); +completion.parse(process.argv.slice(2)); From 4564f995a79aaf745e35a97f9070f84e22254316 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 15 Oct 2026 15:57:41 +0330 Subject: [PATCH 2/2] fix win tests --- tests/commander-option-scope.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/commander-option-scope.test.ts b/tests/commander-option-scope.test.ts index b56e123..2d1c641 100644 --- a/tests/commander-option-scope.test.ts +++ b/tests/commander-option-scope.test.ts @@ -1,21 +1,28 @@ import { execFile } from 'node:child_process'; +import { createRequire } from 'node:module'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const tsxCli = require.resolve('tsx/cli'); const fixture = 'tests/fixtures/commander-option-scope.ts'; async function complete( args: string[], positionalOptions: boolean ): Promise { - const { stdout } = await execFileAsync('pnpm', ['tsx', fixture, ...args], { - cwd: process.cwd(), - env: { - ...process.env, - COMMANDER_POSITIONAL: positionalOptions ? '1' : '0', - }, - }); + const { stdout } = await execFileAsync( + process.execPath, + [tsxCli, fixture, ...args], + { + cwd: process.cwd(), + env: { + ...process.env, + COMMANDER_POSITIONAL: positionalOptions ? '1' : '0', + }, + } + ); return stdout .split('\n')