Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-onions-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bomb.sh/tab': patch
---

perf: make package-manager-delegated completions much faster
6 changes: 4 additions & 2 deletions bin/cli.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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 `<pm> --help` parse.
const completion = new PackageManagerCompletion(packageManager);
await setupCompletionForPackageManager(packageManager, completion);
await completion.parse(completionArgs);
process.exit(0);
}
Expand Down
44 changes: 40 additions & 4 deletions bin/completion-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<pm> --help` on every keystroke.
*/
export async function getPackageManagerCommands(
packageManager: string
): Promise<Record<string, string>> {
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,
Expand Down
7 changes: 5 additions & 2 deletions bin/handlers/bun-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -128,8 +129,10 @@ export function parseBunHelp(helpText: string): Record<string, string> {
export async function getBunCommandsFromMainHelp(): Promise<
Record<string, string>
> {
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(
Expand Down
7 changes: 5 additions & 2 deletions bin/handlers/npm-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -93,8 +94,10 @@ export function parseNpmHelp(helpText: string): Record<string, string> {
export async function getNpmCommandsFromMainHelp(): Promise<
Record<string, string>
> {
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(
Expand Down
7 changes: 5 additions & 2 deletions bin/handlers/pnpm-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -243,8 +244,10 @@ export function parsePnpmHelp(helpText: string): Record<string, string> {
export async function getPnpmCommandsFromMainHelp(): Promise<
Record<string, string>
> {
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(
Expand Down
7 changes: 5 additions & 2 deletions bin/handlers/yarn-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -91,8 +92,10 @@ export function parseYarnHelp(helpText: string): Record<string, string> {
export async function getYarnCommandsFromMainHelp(): Promise<
Record<string, string>
> {
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(
Expand Down
168 changes: 92 additions & 76 deletions bin/package-manager-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -18,69 +20,61 @@ const completionSpawnOptions: SpawnSyncOptionsWithStringEncoding = {
timeout: 1000,
};

function runCompletionCommand(
export interface DelegatedCompletions {
delegated: boolean;
lines: string[];
}

/**
* Run `<command> [leadingArgs...] complete -- <args>` 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
* `:<directive>` 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<boolean> {
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 (`<cli> 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
* (`<pm> <cli> complete -- ...`), which covers local-only installs that are
* reachable only via e.g. `pnpm <cli>`.
*
* 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<string[]> {
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);
}

/**
Expand All @@ -91,6 +85,7 @@ async function getCliCompletions(
*/
export class PackageManagerCompletion extends RootCommand {
private packageManager: string;
private treeReady = false;

constructor(packageManager: string) {
super();
Expand All @@ -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<void> {
if (this.treeReady) return;
this.treeReady = true;
await setupCompletionForPackageManager(this.packageManager, this);
}

private async getKnownCommandNames(): Promise<Set<string>> {
// 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
// `<pm> --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 `:<directive>`; 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);
}
}
Loading
Loading