From 92ef3f1a45417123c417faf5a1feb79c1f56c839 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 13:50:13 +0330 Subject: [PATCH 1/6] init --- src/zsh.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/zsh.ts b/src/zsh.ts index 81503fd..7d78b83 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -21,12 +21,24 @@ _${name}() { local shellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs} local shellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder} - local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder isNewWord local -a completions __${name}_debug "\\n========= starting completion logic ==========" __${name}_debug "CURRENT: \${CURRENT}, words[*]: \${words[*]}" + # Track whether the cursor is positioned on a new (empty) word so we can + # pass an empty argument to the CLI when the user has already completed + # the previous token (e.g. after typing a space). + isNewWord=0 + if [ \${#words[@]} -ge \${CURRENT} ] && [ -z "\${words[\${CURRENT}]}" ]; then + isNewWord=1 + __${name}_debug "Detected empty current word via words[\${CURRENT}]" + elif [ -n "\${BUFFER}" ] && [ "\${BUFFER[-1]}" = " " ]; then + isNewWord=1 + __${name}_debug "Detected trailing space in buffer" + fi + # The user could have moved the cursor backwards on the command-line. # We need to trigger completion from the $CURRENT location, so we need # to truncate the command-line ($words) up to the $CURRENT location. @@ -48,7 +60,7 @@ _${name}() { # Prepare the command to obtain completions, ensuring arguments are quoted for eval local -a args_to_quote=("\${(@)words[2,-1]}") - if [ "\${lastChar}" = "" ]; then + if [ "\${lastChar}" = "" ] || [ $isNewWord -eq 1 ]; then # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go completion code. __${name}_debug "Adding extra empty parameter" From 8986c835b9e688544359d41264558577ce48cd30 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 15:05:56 +0330 Subject: [PATCH 2/6] fallback --- src/t.ts | 14 ++++++++++++++ src/zsh.ts | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/t.ts b/src/t.ts index 7c18537..e47065f 100644 --- a/src/t.ts +++ b/src/t.ts @@ -217,6 +217,20 @@ export class RootCommand extends Command { } } + // Fallback: if nothing matched yet, try to match any single-token command + // later in the args (helps when the executable name or runner is included, + // e.g., `pnpm nuxt dev` where `nuxt` is not a registered command). + if (!matchedCommand) { + for (let i = 0; i < args.length; i++) { + const potential = this.commands.get(args[i]); + if (potential) { + matchedCommand = potential; + remaining = args.slice(i + 1); + break; + } + } + } + // If no command was matched, use the root command (this) return [matchedCommand || this, remaining]; } diff --git a/src/zsh.ts b/src/zsh.ts index 7d78b83..61110b4 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -39,6 +39,14 @@ _${name}() { __${name}_debug "Detected trailing space in buffer" fi + # When completing a brand-new (empty) word, CURRENT can point past the end + # of the words array. Pad with an empty element so downstream logic keeps + # the last typed argument (avoids replaying the prior completion). + if [ $isNewWord -eq 1 ] && [ \${#words[@]} -lt \${CURRENT} ]; then + __${name}_debug "Padding words to CURRENT with empty element" + words+=("") + fi + # The user could have moved the cursor backwards on the command-line. # We need to trigger completion from the $CURRENT location, so we need # to truncate the command-line ($words) up to the $CURRENT location. From 1be9924bbdedcb892194ce971901f2433a27f073 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 17:36:47 +0330 Subject: [PATCH 3/6] update --- src/t.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/t.ts b/src/t.ts index e47065f..bfa181c 100644 --- a/src/t.ts +++ b/src/t.ts @@ -273,9 +273,12 @@ export class RootCommand extends Command { // Determine if we should complete commands private shouldCompleteCommands( toComplete: string, - endsWithSpace: boolean + endsWithSpace: boolean, + isRootCommandContext: boolean ): boolean { - return !toComplete.startsWith('-'); + // Only suggest commands when we're still completing at the root context. + // Once a command is matched, we should move on to its arguments/flags. + return isRootCommandContext && !toComplete.startsWith('-'); } // Handle flag completion (names and values) @@ -471,6 +474,7 @@ export class RootCommand extends Command { } const [matchedCommand] = this.matchCommand(previousArgs); + const isRootContext = matchedCommand === this; const lastPrevArg = previousArgs[previousArgs.length - 1]; // 1. Handle flag/option completion @@ -504,7 +508,9 @@ export class RootCommand extends Command { } // 2. Handle command/subcommand completion - if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { + if ( + this.shouldCompleteCommands(toComplete, endsWithSpace, isRootContext) + ) { this.handleCommandCompletion(previousArgs, toComplete); } // 3. Handle positional arguments - always check for root command arguments From 70cd43bc81200a89a6a837b8b5bea22b9359811b Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 18:02:50 +0330 Subject: [PATCH 4/6] revert --- src/t.ts | 26 +++----------------------- src/zsh.ts | 24 ++---------------------- 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/src/t.ts b/src/t.ts index bfa181c..7c18537 100644 --- a/src/t.ts +++ b/src/t.ts @@ -217,20 +217,6 @@ export class RootCommand extends Command { } } - // Fallback: if nothing matched yet, try to match any single-token command - // later in the args (helps when the executable name or runner is included, - // e.g., `pnpm nuxt dev` where `nuxt` is not a registered command). - if (!matchedCommand) { - for (let i = 0; i < args.length; i++) { - const potential = this.commands.get(args[i]); - if (potential) { - matchedCommand = potential; - remaining = args.slice(i + 1); - break; - } - } - } - // If no command was matched, use the root command (this) return [matchedCommand || this, remaining]; } @@ -273,12 +259,9 @@ export class RootCommand extends Command { // Determine if we should complete commands private shouldCompleteCommands( toComplete: string, - endsWithSpace: boolean, - isRootCommandContext: boolean + endsWithSpace: boolean ): boolean { - // Only suggest commands when we're still completing at the root context. - // Once a command is matched, we should move on to its arguments/flags. - return isRootCommandContext && !toComplete.startsWith('-'); + return !toComplete.startsWith('-'); } // Handle flag completion (names and values) @@ -474,7 +457,6 @@ export class RootCommand extends Command { } const [matchedCommand] = this.matchCommand(previousArgs); - const isRootContext = matchedCommand === this; const lastPrevArg = previousArgs[previousArgs.length - 1]; // 1. Handle flag/option completion @@ -508,9 +490,7 @@ export class RootCommand extends Command { } // 2. Handle command/subcommand completion - if ( - this.shouldCompleteCommands(toComplete, endsWithSpace, isRootContext) - ) { + if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { this.handleCommandCompletion(previousArgs, toComplete); } // 3. Handle positional arguments - always check for root command arguments diff --git a/src/zsh.ts b/src/zsh.ts index 61110b4..81503fd 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -21,32 +21,12 @@ _${name}() { local shellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs} local shellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder} - local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder isNewWord + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder local -a completions __${name}_debug "\\n========= starting completion logic ==========" __${name}_debug "CURRENT: \${CURRENT}, words[*]: \${words[*]}" - # Track whether the cursor is positioned on a new (empty) word so we can - # pass an empty argument to the CLI when the user has already completed - # the previous token (e.g. after typing a space). - isNewWord=0 - if [ \${#words[@]} -ge \${CURRENT} ] && [ -z "\${words[\${CURRENT}]}" ]; then - isNewWord=1 - __${name}_debug "Detected empty current word via words[\${CURRENT}]" - elif [ -n "\${BUFFER}" ] && [ "\${BUFFER[-1]}" = " " ]; then - isNewWord=1 - __${name}_debug "Detected trailing space in buffer" - fi - - # When completing a brand-new (empty) word, CURRENT can point past the end - # of the words array. Pad with an empty element so downstream logic keeps - # the last typed argument (avoids replaying the prior completion). - if [ $isNewWord -eq 1 ] && [ \${#words[@]} -lt \${CURRENT} ]; then - __${name}_debug "Padding words to CURRENT with empty element" - words+=("") - fi - # The user could have moved the cursor backwards on the command-line. # We need to trigger completion from the $CURRENT location, so we need # to truncate the command-line ($words) up to the $CURRENT location. @@ -68,7 +48,7 @@ _${name}() { # Prepare the command to obtain completions, ensuring arguments are quoted for eval local -a args_to_quote=("\${(@)words[2,-1]}") - if [ "\${lastChar}" = "" ] || [ $isNewWord -eq 1 ]; then + if [ "\${lastChar}" = "" ]; then # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go completion code. __${name}_debug "Adding extra empty parameter" From d16810676d6b83737aa55d52d3f049b891c2ea62 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 18:17:33 +0330 Subject: [PATCH 5/6] test --- bin/package-manager-completion.ts | 81 +++++++++++++++++-------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/bin/package-manager-completion.ts b/bin/package-manager-completion.ts index 91e683c..2923e4b 100644 --- a/bin/package-manager-completion.ts +++ b/bin/package-manager-completion.ts @@ -1,32 +1,57 @@ -import { execSync } from 'child_process'; +import { + spawnSync, + type SpawnSyncOptionsWithStringEncoding, +} from 'child_process'; import { RootCommand } from '../src/t.js'; -function debugLog(...args: any[]) { +function debugLog(...args: unknown[]) { if (process.env.DEBUG) { console.error('[DEBUG]', ...args); } } +const completionSpawnOptions: SpawnSyncOptionsWithStringEncoding = { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, +}; + +function runCompletionCommand( + command: string, + leadingArgs: string[], + completionArgs: string[] +): string { + const result = spawnSync( + command, + [...leadingArgs, 'complete', '--', ...completionArgs], + completionSpawnOptions + ); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error( + `Completion command "${command}" exited with code ${result.status}` + ); + } + + return (result.stdout ?? '').trim(); +} + async function checkCliHasCompletions( cliName: string, packageManager: string ): Promise { try { - const result = execSync(`${cliName} complete --`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - timeout: 1000, - }); - if (result.trim()) return true; + const result = runCompletionCommand(cliName, [], []); + if (result) return true; } catch {} try { - const result = execSync(`${packageManager} ${cliName} complete --`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - timeout: 1000, - }); - return !!result.trim(); + const result = runCompletionCommand(packageManager, [cliName], []); + return !!result; } catch { return false; } @@ -37,34 +62,16 @@ async function getCliCompletions( packageManager: string, args: string[] ): Promise { - const completeArgs = args.map((arg) => - arg.includes(' ') ? `"${arg}"` : arg - ); - try { - const result = execSync( - `${cliName} complete -- ${completeArgs.join(' ')}`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - timeout: 1000, - } - ); - if (result.trim()) { - return result.trim().split('\n').filter(Boolean); + const result = runCompletionCommand(cliName, [], args); + if (result) { + return result.split('\n').filter(Boolean); } } catch {} try { - const result = execSync( - `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - timeout: 1000, - } - ); - return result.trim().split('\n').filter(Boolean); + const result = runCompletionCommand(packageManager, [cliName], args); + return result.split('\n').filter(Boolean); } catch { return []; } From 970a537fc525183e08e09ddb59554a228c1464a0 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 10 Dec 2025 18:37:15 +0330 Subject: [PATCH 6/6] add changeset --- .changeset/stupid-kiwis-fold.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stupid-kiwis-fold.md diff --git a/.changeset/stupid-kiwis-fold.md b/.changeset/stupid-kiwis-fold.md new file mode 100644 index 0000000..ffd3cbb --- /dev/null +++ b/.changeset/stupid-kiwis-fold.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +switching command execution from execSync (string-based, shell-parsed) to spawnSync with an argv array. this ensures trailing "" arguments are not dropped during shell re-parsing