From 8e5579b35326227a26ab05c8ac9aa6298ba52fe2 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sat, 18 Jul 2026 23:54:16 +0330 Subject: [PATCH 1/2] performance x speed --- bin/cli.ts | 6 +- bin/completion-handlers.ts | 44 +++++++- bin/handlers/bun-handler.ts | 7 +- bin/handlers/npm-handler.ts | 7 +- bin/handlers/pnpm-handler.ts | 7 +- bin/handlers/yarn-handler.ts | 7 +- bin/package-manager-completion.ts | 168 ++++++++++++++++-------------- bin/utils/help-cache.ts | 116 +++++++++++++++++++++ 8 files changed, 272 insertions(+), 90 deletions(-) create mode 100644 bin/utils/help-cache.ts diff --git a/bin/cli.ts b/bin/cli.ts index 18098e2..dbe6264 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { script } from '../src/t.js'; -import { setupCompletionForPackageManager } from './completion-handlers'; import { PackageManagerCompletion } from './package-manager-completion.js'; const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']; @@ -41,8 +40,11 @@ async function main() { process.exit(1); } + // Note: we intentionally do NOT build the package manager's command tree + // here. `parse` builds it lazily and only when it actually needs to + // complete the package manager itself, so the delegation path never pays + // for a ` --help` parse. const completion = new PackageManagerCompletion(packageManager); - await setupCompletionForPackageManager(packageManager, completion); await completion.parse(completionArgs); process.exit(0); } diff --git a/bin/completion-handlers.ts b/bin/completion-handlers.ts index 28b6c97..046ac49 100644 --- a/bin/completion-handlers.ts +++ b/bin/completion-handlers.ts @@ -4,10 +4,46 @@ */ import type { PackageManagerCompletion } from './package-manager-completion.js'; -import { setupPnpmCompletions } from './handlers/pnpm-handler.js'; -import { setupNpmCompletions } from './handlers/npm-handler.js'; -import { setupYarnCompletions } from './handlers/yarn-handler.js'; -import { setupBunCompletions } from './handlers/bun-handler.js'; +import { + setupPnpmCompletions, + getPnpmCommandsFromMainHelp, +} from './handlers/pnpm-handler.js'; +import { + setupNpmCompletions, + getNpmCommandsFromMainHelp, +} from './handlers/npm-handler.js'; +import { + setupYarnCompletions, + getYarnCommandsFromMainHelp, +} from './handlers/yarn-handler.js'; +import { + setupBunCompletions, + getBunCommandsFromMainHelp, +} from './handlers/bun-handler.js'; + +/** + * Return just the package manager's own command names (cached), without + * building the full command tree. Used on the delegation hot path to decide + * whether the first token is a package-manager command (complete it ourselves) + * or an unknown sub-CLI (delegate to it) — cheaply, without re-parsing + * ` --help` on every keystroke. + */ +export async function getPackageManagerCommands( + packageManager: string +): Promise> { + switch (packageManager) { + case 'pnpm': + return getPnpmCommandsFromMainHelp(); + case 'npm': + return getNpmCommandsFromMainHelp(); + case 'yarn': + return getYarnCommandsFromMainHelp(); + case 'bun': + return getBunCommandsFromMainHelp(); + default: + return {}; + } +} export async function setupCompletionForPackageManager( packageManager: string, diff --git a/bin/handlers/bun-handler.ts b/bin/handlers/bun-handler.ts index 24753ac..18ffab0 100644 --- a/bin/handlers/bun-handler.ts +++ b/bin/handlers/bun-handler.ts @@ -1,5 +1,6 @@ import type { PackageManagerCompletion } from '../package-manager-completion.js'; import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js'; +import { getCachedCommandMap } from '../utils/help-cache.js'; import { LazyCommand, OptionHandlers, @@ -128,8 +129,10 @@ export function parseBunHelp(helpText: string): Record { export async function getBunCommandsFromMainHelp(): Promise< Record > { - const output = await safeExec('bun --help'); - return output ? parseBunHelp(output) : {}; + return getCachedCommandMap('bun', async () => { + const output = await safeExec('bun --help'); + return output ? parseBunHelp(output) : {}; + }); } export function parseBunOptions( diff --git a/bin/handlers/npm-handler.ts b/bin/handlers/npm-handler.ts index 2c1f6ab..70b18e9 100644 --- a/bin/handlers/npm-handler.ts +++ b/bin/handlers/npm-handler.ts @@ -1,5 +1,6 @@ import type { PackageManagerCompletion } from '../package-manager-completion.js'; import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js'; +import { getCachedCommandMap } from '../utils/help-cache.js'; import { LazyCommand, OptionHandlers, @@ -93,8 +94,10 @@ export function parseNpmHelp(helpText: string): Record { export async function getNpmCommandsFromMainHelp(): Promise< Record > { - const output = await safeExec('npm --help'); - return output ? parseNpmHelp(output) : {}; + return getCachedCommandMap('npm', async () => { + const output = await safeExec('npm --help'); + return output ? parseNpmHelp(output) : {}; + }); } export function parseNpmOptions( diff --git a/bin/handlers/pnpm-handler.ts b/bin/handlers/pnpm-handler.ts index b3502bf..9a4dedb 100644 --- a/bin/handlers/pnpm-handler.ts +++ b/bin/handlers/pnpm-handler.ts @@ -1,5 +1,6 @@ import type { PackageManagerCompletion } from '../package-manager-completion.js'; import { getWorkspacePatterns } from '../utils/filesystem-utils.js'; +import { getCachedCommandMap } from '../utils/help-cache.js'; import { LazyCommand, OptionHandlers, @@ -243,8 +244,10 @@ export function parsePnpmHelp(helpText: string): Record { export async function getPnpmCommandsFromMainHelp(): Promise< Record > { - const output = await safeExec('pnpm --help'); - return output ? parsePnpmHelp(output) : {}; + return getCachedCommandMap('pnpm', async () => { + const output = await safeExec('pnpm --help'); + return output ? parsePnpmHelp(output) : {}; + }); } export function parsePnpmOptions( diff --git a/bin/handlers/yarn-handler.ts b/bin/handlers/yarn-handler.ts index b75bef4..fde58c5 100644 --- a/bin/handlers/yarn-handler.ts +++ b/bin/handlers/yarn-handler.ts @@ -1,5 +1,6 @@ import type { PackageManagerCompletion } from '../package-manager-completion.js'; import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js'; +import { getCachedCommandMap } from '../utils/help-cache.js'; import { LazyCommand, OptionHandlers, @@ -91,8 +92,10 @@ export function parseYarnHelp(helpText: string): Record { export async function getYarnCommandsFromMainHelp(): Promise< Record > { - const output = await safeExec('yarn --help'); - return output ? parseYarnHelp(output) : {}; + return getCachedCommandMap('yarn', async () => { + const output = await safeExec('yarn --help'); + return output ? parseYarnHelp(output) : {}; + }); } export function parseYarnOptions( diff --git a/bin/package-manager-completion.ts b/bin/package-manager-completion.ts index 7c12ff9..6d53538 100644 --- a/bin/package-manager-completion.ts +++ b/bin/package-manager-completion.ts @@ -3,8 +3,10 @@ import { type SpawnSyncOptionsWithStringEncoding, } from 'child_process'; import { RootCommand } from '../src/t.js'; - -const noop = () => {}; +import { + getPackageManagerCommands, + setupCompletionForPackageManager, +} from './completion-handlers.js'; function debugLog(...args: unknown[]) { if (process.env.DEBUG) { @@ -18,69 +20,61 @@ const completionSpawnOptions: SpawnSyncOptionsWithStringEncoding = { timeout: 1000, }; -function runCompletionCommand( +export interface DelegatedCompletions { + delegated: boolean; + lines: string[]; +} + +/** + * Run ` [leadingArgs...] complete -- ` once and decide, from a + * single spawn, whether the target behaved like a tab-enabled CLI. + * + * A spawn error (e.g. ENOENT when the command isn't on PATH), a non-zero exit, + * or empty stdout all mean "not a tab-enabled CLI here" — the caller should + * fall back. A tab-enabled CLI always prints at least the trailing + * `:` line, so non-empty stdout on a clean exit is a reliable signal. + */ +function runCompletion( command: string, leadingArgs: string[], completionArgs: string[] -): string { +): DelegatedCompletions { const result = spawnSync( command, [...leadingArgs, 'complete', '--', ...completionArgs], completionSpawnOptions ); - if (result.error) { - throw result.error; - } - + if (result.error) return { delegated: false, lines: [] }; if (typeof result.status === 'number' && result.status !== 0) { - throw new Error( - `Completion command "${command}" exited with code ${result.status}` - ); + return { delegated: false, lines: [] }; } - return (result.stdout ?? '').trim(); -} + const output = (result.stdout ?? '').trim(); + if (!output) return { delegated: false, lines: [] }; -async function checkCliHasCompletions( - cliName: string, - packageManager: string -): Promise { - try { - const result = runCompletionCommand(cliName, [], []); - if (result) return true; - } catch { - noop(); - } - - try { - const result = runCompletionCommand(packageManager, [cliName], []); - return !!result; - } catch { - return false; - } + return { delegated: true, lines: output.split('\n').filter(Boolean) }; } -async function getCliCompletions( +/** + * Combined detection + fetch for a delegated CLI, in a single pass: + * 1. Try the CLI directly (` complete -- ...`). This is the fast path and + * works whenever the CLI is resolvable on PATH. + * 2. If that fails, fall back to running it through the package manager + * (` complete -- ...`), which covers local-only installs that are + * reachable only via e.g. `pnpm `. + * + * The target CLI is launched at most twice, and only a second time when the + * direct launch produced nothing. + */ +export function fetchDelegatedCompletions( cliName: string, packageManager: string, args: string[] -): Promise { - try { - const result = runCompletionCommand(cliName, [], args); - if (result) { - return result.split('\n').filter(Boolean); - } - } catch { - noop(); - } - - try { - const result = runCompletionCommand(packageManager, [cliName], args); - return result.split('\n').filter(Boolean); - } catch { - return []; - } +): DelegatedCompletions { + const direct = runCompletion(cliName, [], args); + if (direct.delegated) return direct; + return runCompletion(packageManager, [cliName], args); } /** @@ -91,6 +85,7 @@ async function getCliCompletions( */ export class PackageManagerCompletion extends RootCommand { private packageManager: string; + private treeReady = false; constructor(packageManager: string) { super(); @@ -104,47 +99,68 @@ export class PackageManagerCompletion extends RootCommand { return args; } + // Build the package manager's own command tree lazily. This is only needed + // when we actually complete the package manager itself (super.parse), never + // on the delegation path. + private async ensurePackageManagerTree(): Promise { + if (this.treeReady) return; + this.treeReady = true; + await setupCompletionForPackageManager(this.packageManager, this); + } + + private async getKnownCommandNames(): Promise> { + // If the full tree is already built, use it; otherwise read just the + // (cached) command names so we can gate delegation without paying for a + // ` --help` parse on every keystroke. + if (this.treeReady) return new Set(this.commands.keys()); + const commands = await getPackageManagerCommands(this.packageManager); + return new Set(Object.keys(commands)); + } + + private printDelegated(lines: string[]): void { + debugLog(`Returning ${lines.length} delegated completion lines`); + for (const line of lines) { + // The delegated CLI prints its own trailing `:`; we emit our + // own below, so drop theirs. + if (line.startsWith(':')) continue; + if (line.includes('\t')) { + const [value, description] = line.split('\t'); + console.log(`${value}\t${description}`); + } else { + console.log(line); + } + } + console.log(':4'); + } + async parse(args: string[]) { const normalizedArgs = this.stripPackageManagerCommands(args); if (normalizedArgs.length >= 1 && normalizedArgs[0].trim() !== '') { const potentialCliName = normalizedArgs[0]; - const knownCommands = [...this.commands.keys()]; - - if (!knownCommands.includes(potentialCliName)) { - const hasCompletions = await checkCliHasCompletions( + const knownCommands = await this.getKnownCommandNames(); + + // Only a token that is NOT one of the package manager's own commands is a + // delegation candidate. This keeps package-manager commands (add, install, + // remove, ...) on the package-manager completion path and avoids ever + // running e.g. `pnpm add complete -- ...`. + if (!knownCommands.has(potentialCliName)) { + const cliArgs = normalizedArgs.slice(1); + const delegated = fetchDelegatedCompletions( potentialCliName, - this.packageManager + this.packageManager, + cliArgs ); - if (hasCompletions) { - const cliArgs = normalizedArgs.slice(1); - const suggestions = await getCliCompletions( - potentialCliName, - this.packageManager, - cliArgs - ); - - if (suggestions.length > 0) { - debugLog( - `Returning ${suggestions.length} completions for ${potentialCliName}` - ); - for (const suggestion of suggestions) { - if (suggestion.startsWith(':')) continue; - if (suggestion.includes('\t')) { - const [value, description] = suggestion.split('\t'); - console.log(`${value}\t${description}`); - } else { - console.log(suggestion); - } - } - console.log(':4'); - return; - } + if (delegated.delegated) { + this.printDelegated(delegated.lines); + return; } } } + // Fall back to completing the package manager itself. + await this.ensurePackageManagerTree(); return super.parse(args); } } diff --git a/bin/utils/help-cache.ts b/bin/utils/help-cache.ts new file mode 100644 index 0000000..8fe84a9 --- /dev/null +++ b/bin/utils/help-cache.ts @@ -0,0 +1,116 @@ +import { mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, delimiter } from 'node:path'; + +// Parsing ` --help` to discover a package manager's command tree is the +// single most expensive thing on the completion hot path (~0.4s). The command +// list only changes when the package manager itself is upgraded, so we cache +// the parsed result on disk. +// +// Cache validity is guarded two ways: +// - a short TTL, and +// - a cheap "fingerprint" of the resolved `` executable (its mtime), +// which acts as a version proxy without paying for a ` --version` +// spawn. If the binary changes, the fingerprint changes and we re-parse. + +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h + +interface CacheEntry { + timestamp: number; + fingerprint: string; + commands: Record; +} + +function cacheDir(): string { + return ( + process.env.TAB_COMPLETION_CACHE_DIR || + join(tmpdir(), 'tab-completion-cache') + ); +} + +function cacheFile(packageManager: string): string { + return join(cacheDir(), `${packageManager}.json`); +} + +function ttl(): number { + const override = Number(process.env.TAB_COMPLETION_CACHE_TTL_MS); + return Number.isFinite(override) && override > 0 ? override : DEFAULT_TTL_MS; +} + +function cachingDisabled(): boolean { + return process.env.TAB_DISABLE_COMPLETION_CACHE === '1'; +} + +// Resolve an executable on PATH without spawning anything, so we can use its +// mtime as a version fingerprint. Best-effort: returns '' when not resolvable. +function fingerprint(packageManager: string): string { + const pathValue = process.env.PATH || ''; + const exts = + process.platform === 'win32' + ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') + : ['']; + + for (const dir of pathValue.split(delimiter)) { + if (!dir) continue; + for (const ext of exts) { + const candidate = join(dir, packageManager + ext); + try { + const stat = statSync(candidate); + if (stat.isFile()) return String(stat.mtimeMs); + } catch { + // keep scanning + } + } + } + return ''; +} + +export function readCommandCache( + packageManager: string +): Record | null { + if (cachingDisabled()) return null; + try { + const raw = readFileSync(cacheFile(packageManager), 'utf8'); + const entry = JSON.parse(raw) as CacheEntry; + if (Date.now() - entry.timestamp > ttl()) return null; + if (entry.fingerprint !== fingerprint(packageManager)) return null; + return entry.commands; + } catch { + return null; + } +} + +export function writeCommandCache( + packageManager: string, + commands: Record +): void { + if (cachingDisabled()) return; + try { + mkdirSync(cacheDir(), { recursive: true }); + const entry: CacheEntry = { + timestamp: Date.now(), + fingerprint: fingerprint(packageManager), + commands, + }; + writeFileSync(cacheFile(packageManager), JSON.stringify(entry)); + } catch { + // caching is best-effort; ignore write failures + } +} + +// Return the cached command map, or produce + cache it on a miss. Empty results +// are never cached, so a transient failure to read ` --help` won't be +// remembered. +export async function getCachedCommandMap( + packageManager: string, + produce: () => Promise> +): Promise> { + const cached = readCommandCache(packageManager); + if (cached) return cached; + + const commands = await produce(); + if (Object.keys(commands).length > 0) { + writeCommandCache(packageManager, commands); + } + return commands; +} From ee0e5c6b495bde918c58ff5b1e8ba67050fd1759 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 20 Jul 2026 14:37:28 +0330 Subject: [PATCH 2/2] add changeset --- .changeset/hungry-onions-send.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hungry-onions-send.md diff --git a/.changeset/hungry-onions-send.md b/.changeset/hungry-onions-send.md new file mode 100644 index 0000000..3177888 --- /dev/null +++ b/.changeset/hungry-onions-send.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +perf: make package-manager-delegated completions much faster