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
2 changes: 1 addition & 1 deletion bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async function main() {
if (dashIndex !== -1) {
// Use the new PackageManagerCompletion wrapper
const completion = new PackageManagerCompletion(packageManager);
setupCompletionForPackageManager(packageManager, completion);
await setupCompletionForPackageManager(packageManager, completion);
const toComplete = process.argv.slice(dashIndex + 1);
await completion.parse(toComplete);
process.exit(0);
Expand Down
482 changes: 29 additions & 453 deletions bin/completion-handlers.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions bin/completions/completion-producers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Complete } from '../../src/t.js';
import {
getPackageJsonScripts,
getPackageJsonDependencies,
} from '../utils/package-json-utils.js';

// provides completions for npm scripts from package.json.. like: start,dev,build
export const packageJsonScriptCompletion = async (
complete: Complete
): Promise<void> => {
getPackageJsonScripts().forEach((script) =>
complete(script, `Run ${script} script`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the description here, it's not needed!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe we can use the content of the script as the description of it, up to you!

);
};

// provides completions for package dependencies from package.json.. for commands like remove `pnpm remove <dependency>`
export const packageJsonDependencyCompletion = async (
complete: Complete
): Promise<void> => {
getPackageJsonDependencies().forEach((dep) => complete(dep, ''));
};
5 changes: 5 additions & 0 deletions bin/handlers/bun-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { PackageManagerCompletion } from '../package-manager-completion.js';

export async function setupBunCompletions(
completion: PackageManagerCompletion
): Promise<void> {}
5 changes: 5 additions & 0 deletions bin/handlers/npm-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { PackageManagerCompletion } from '../package-manager-completion.js';

export async function setupNpmCompletions(
completion: PackageManagerCompletion
): Promise<void> {}
233 changes: 233 additions & 0 deletions bin/handlers/pnpm-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { promisify } from 'node:util';
import child_process from 'node:child_process';

const exec = promisify(child_process.exec);
const { execSync } = child_process;
import type { PackageManagerCompletion } from '../package-manager-completion.js';
import { Command, Option } from '../../src/t.js';

interface LazyCommand extends Command {
_lazyCommand?: string;
_optionsLoaded?: boolean;
optionsRaw?: Map<string, Option>;
}

import {
packageJsonScriptCompletion,
packageJsonDependencyCompletion,
} from '../completions/completion-producers.js';
import {
stripAnsiEscapes,
measureIndent,
parseAliasList,
COMMAND_ROW_RE,
OPTION_ROW_RE,
OPTION_HEAD_RE,
type ParsedOption,
} from '../utils/text-utils.js';

// regex to detect options section in help text
const OPTIONS_SECTION_RE = /^\s*Options:/i;

// we parse the pnpm help text to extract commands and their descriptions!
export function parsePnpmHelp(helpText: string): Record<string, string> {
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);

// we find the earliest description column across command rows.
let descColumnIndex = Number.POSITIVE_INFINITY;
for (const line of helpLines) {
const rowMatch = line.match(COMMAND_ROW_RE);
if (!rowMatch) continue;
const descColumnIndexOnThisLine = line.indexOf(rowMatch[2]);
if (
descColumnIndexOnThisLine >= 0 &&
descColumnIndexOnThisLine < descColumnIndex
) {
descColumnIndex = descColumnIndexOnThisLine;
}
}
if (!Number.isFinite(descColumnIndex)) return {};

// we fold rows, and join continuation lines aligned to descColumnIndex or deeper.
type PendingRow = { names: string[]; desc: string } | null;
let pendingRow: PendingRow = null;

const commandMap = new Map<string, string>();
const flushPendingRow = () => {
if (!pendingRow) return;
const desc = pendingRow.desc.trim();
for (const name of pendingRow.names) commandMap.set(name, desc);
pendingRow = null;
};

for (const line of helpLines) {
if (OPTIONS_SECTION_RE.test(line)) break; // we stop at options

// we match the command row
const rowMatch = line.match(COMMAND_ROW_RE);
if (rowMatch) {
flushPendingRow();
pendingRow = {
names: parseAliasList(rowMatch[1]),
desc: rowMatch[2].trim(),
};
continue;
}

// we join continuation lines aligned to descColumnIndex or deeper
if (pendingRow) {
const indentWidth = measureIndent(line);
if (indentWidth >= descColumnIndex && line.trim()) {
pendingRow.desc += ' ' + line.trim();
}
}
}
// we flush the pending row and return the command map
flushPendingRow();

return Object.fromEntries(commandMap);
}

// now we get the pnpm commands from the main help output
export async function getPnpmCommandsFromMainHelp(): Promise<
Record<string, string>
> {
try {
const { stdout } = await exec('pnpm --help', {
encoding: 'utf8',
timeout: 500,
maxBuffer: 4 * 1024 * 1024,
});
return parsePnpmHelp(stdout);
} catch {
return {};
}
}

// here we parse the pnpm options from the help text
export function parsePnpmOptions(
helpText: string,
{ flagsOnly = true }: { flagsOnly?: boolean } = {}
): ParsedOption[] {
// we strip the ANSI escapes from the help text
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);

// we find the earliest description column among option rows we care about
let descColumnIndex = Number.POSITIVE_INFINITY;
for (const line of helpLines) {
const optionMatch = line.match(OPTION_ROW_RE);
if (!optionMatch) continue;
if (flagsOnly && optionMatch.groups?.val) continue; // skip value-taking options, we will add them manually with their value
const descColumnIndexOnThisLine = line.indexOf(optionMatch.groups!.desc);
if (
descColumnIndexOnThisLine >= 0 &&
descColumnIndexOnThisLine < descColumnIndex
) {
descColumnIndex = descColumnIndexOnThisLine;
}
}
if (!Number.isFinite(descColumnIndex)) return [];

// we fold the option rows and join the continuations
const optionsOut: ParsedOption[] = [];
let pendingOption: ParsedOption | null = null;

const flushPendingOption = () => {
if (!pendingOption) return;
pendingOption.desc = pendingOption.desc.trim();
optionsOut.push(pendingOption);
pendingOption = null;
};

// we match the option row
for (const line of helpLines) {
const optionMatch = line.match(OPTION_ROW_RE);
if (optionMatch) {
if (flagsOnly && optionMatch.groups?.val) continue;
flushPendingOption();
pendingOption = {
short: optionMatch.groups?.short || undefined,
long: optionMatch.groups!.long,
desc: optionMatch.groups!.desc.trim(),
};
continue;
}

// we join the continuations
if (pendingOption) {
const indentWidth = measureIndent(line);
const startsNewOption = OPTION_HEAD_RE.test(line);
if (indentWidth >= descColumnIndex && line.trim() && !startsNewOption) {
pendingOption.desc += ' ' + line.trim();
}
}
}
// we flush the pending option
flushPendingOption();

return optionsOut;
}

// we load the dynamic options synchronously when requested ( separated from the command loading )
export function loadDynamicOptionsSync(
cmd: LazyCommand,
command: string
): void {
try {
const stdout = execSync(`pnpm ${command} --help`, {
encoding: 'utf8',
timeout: 500,
});

const parsedOptions = parsePnpmOptions(stdout, { flagsOnly: true });

for (const { long, short, desc } of parsedOptions) {
const alreadyDefined = cmd.optionsRaw?.get?.(long);
if (!alreadyDefined) cmd.option(long, desc, short);
}
} catch (_err) {}
}

// we setup the lazy option loading for a command

function setupLazyOptionLoading(cmd: LazyCommand, command: string): void {
cmd._lazyCommand = command;
cmd._optionsLoaded = false;

const optionsStore = cmd.options;
cmd.optionsRaw = optionsStore;

Object.defineProperty(cmd, 'options', {
get() {
if (!this._optionsLoaded) {
this._optionsLoaded = true;
loadDynamicOptionsSync(this, this._lazyCommand); // block until filled
}
return optionsStore;
},
configurable: true,
});
}

export async function setupPnpmCompletions(
completion: PackageManagerCompletion
): Promise<void> {
try {
const commandsWithDescriptions = await getPnpmCommandsFromMainHelp();

for (const [command, description] of Object.entries(
commandsWithDescriptions
)) {
const cmd = completion.command(command, description);

if (['remove', 'rm', 'update', 'up'].includes(command)) {
cmd.argument('package', packageJsonDependencyCompletion);
}
if (command === 'run') {
cmd.argument('script', packageJsonScriptCompletion, true);
}

setupLazyOptionLoading(cmd, command);
}
} catch (_err) {}
}
5 changes: 5 additions & 0 deletions bin/handlers/yarn-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { PackageManagerCompletion } from '../package-manager-completion.js';

export async function setupYarnCompletions(
completion: PackageManagerCompletion
): Promise<void> {}
25 changes: 25 additions & 0 deletions bin/utils/package-json-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFileSync } from 'fs';

export function getPackageJsonScripts(): string[] {
try {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
return Object.keys(packageJson.scripts || {});
} catch {
return [];
}
}

export function getPackageJsonDependencies(): string[] {
try {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
...packageJson.optionalDependencies,
};
return Object.keys(deps);
} catch {
return [];
}
}
35 changes: 35 additions & 0 deletions bin/utils/text-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// regex for parsing help text
export const ANSI_ESCAPE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;

// Command row: <indent><names><>=2 spaces><desc>
// e.g. " install, i Install all dependencies"
export const COMMAND_ROW_RE = /^\s+([a-z][a-z\s,-]*?)\s{2,}(\S.*)$/i;

// Option row (optional value part captured in (?<val>)):
// [indent][-x, ]--long[ <value>| [value]] <>=2 spaces> <desc>
export const OPTION_ROW_RE =
/^\s*(?:-(?<short>[A-Za-z]),\s*)?--(?<long>[a-z0-9-]+)(?<val>\s+(?:<[^>]+>|\[[^\]]+\]))?\s{2,}(?<desc>\S.*)$/i;

// we detect the start of a new option head (used to stop continuation)
export const OPTION_HEAD_RE = /^\s*(?:-[A-Za-z],\s*)?--[a-z0-9-]+/i;

// we remove the ANSI escape sequences from a string
export const stripAnsiEscapes = (s: string): string =>
s.replace(ANSI_ESCAPE_RE, '');

// measure the indentation level of a string
export const measureIndent = (s: string): number =>
(s.match(/^\s*/) || [''])[0].length;

// parse a comma-separated list of aliases
export const parseAliasList = (s: string): string[] =>
s
.split(',')
.map((t) => t.trim())
.filter(Boolean);

export type ParsedOption = {
long: string;
short?: string;
desc: string;
};
2 changes: 1 addition & 1 deletion src/t.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const ShellCompDirective = {

export type OptionsMap = Map<string, Option>;

type Complete = (value: string, description: string) => void;
export type Complete = (value: string, description: string) => void;

export type OptionHandler = (
this: Option,
Expand Down
Loading