diff --git a/bin/handlers/npm-handler.ts b/bin/handlers/npm-handler.ts index e4528e9..2c1f6ab 100644 --- a/bin/handlers/npm-handler.ts +++ b/bin/handlers/npm-handler.ts @@ -1,5 +1,158 @@ import type { PackageManagerCompletion } from '../package-manager-completion.js'; +import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js'; +import { + LazyCommand, + OptionHandlers, + commonOptionHandlers, + setupLazyOptionLoading, + setupCommandArguments, + safeExec, + safeExecSync, + createLogLevelHandler, +} from '../utils/shared.js'; + +const ALL_COMMANDS_RE = /^All commands:\s*$/i; +const OPTIONS_SECTION_RE = /^Options:\s*$/i; +const SECTION_END_RE = /^(aliases|run|more)/i; // marks end of Options: block +const COMMAND_VALIDATION_RE = /^[a-z][a-z0-9-]*$/; +const NPM_OPTION_RE = + /(?:\[)?(?:-([a-z])\|)?--([a-z][a-z0-9-]+)(?:\s+<[^>]+>)?(?:\])?/gi; +const ANGLE_VALUE_RE = /<[^>]+>/; +const INDENTED_LINE_RE = /^\s/; + +function toLines(helpText: string): string[] { + return stripAnsiEscapes(helpText).split(/\r?\n/); +} + +function readIndentedBlockAfter(lines: string[], headerRe: RegExp): string { + const start = lines.findIndex((l) => headerRe.test(l.trim())); + if (start === -1) return ''; + + let buf = ''; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!INDENTED_LINE_RE.test(line) && line.trim() && !line.includes(',')) + break; + if (INDENTED_LINE_RE.test(line)) buf += ' ' + line.trim(); + } + return buf; +} + +const listHandler = + (values: string[], describe: (v: string) => string = () => ' ') => + (complete: (value: string, description: string) => void) => + values.forEach((v) => complete(v, describe(v))); + +const npmOptionHandlers: OptionHandlers = { + ...commonOptionHandlers, + + loglevel: createLogLevelHandler([ + 'silent', + 'error', + 'warn', + 'notice', + 'http', + 'info', + 'verbose', + 'silly', + ]), + + 'install-strategy': listHandler( + ['hoisted', 'nested', 'shallow', 'linked'], + () => ' ' + ), + + omit: listHandler(['dev', 'optional', 'peer'], () => ' '), + + include: listHandler(['prod', 'dev', 'optional', 'peer'], () => ' '), +}; + +export function parseNpmHelp(helpText: string): Record { + const lines = toLines(helpText); + const commandsBlob = readIndentedBlockAfter(lines, ALL_COMMANDS_RE); + if (!commandsBlob) return {}; + + const commands: Record = {}; + + commandsBlob + .split(',') + .map((c) => c.trim()) + .filter((c) => c && COMMAND_VALIDATION_RE.test(c)) + .forEach((cmd) => { + // npm main help has no per-command descriptions + commands[cmd] = ' '; + }); + + // this is the most common used aliase that isn't in the main list + commands['run'] = ' '; + + return commands; +} + +// Get npm commands from the main help output +export async function getNpmCommandsFromMainHelp(): Promise< + Record +> { + const output = await safeExec('npm --help'); + return output ? parseNpmHelp(output) : {}; +} + +export function parseNpmOptions( + helpText: string, + { flagsOnly = true }: { flagsOnly?: boolean } = {} +): ParsedOption[] { + const lines = toLines(helpText); + + const start = lines.findIndex((l) => OPTIONS_SECTION_RE.test(l.trim())); + if (start === -1) return []; + + const out: ParsedOption[] = []; + + for (const line of lines.slice(start + 1)) { + const trimmed = line.trim(); + if (SECTION_END_RE.test(trimmed)) break; + + const matches = line.matchAll(NPM_OPTION_RE); + for (const m of matches) { + const short = m[1] || undefined; + const long = m[2]; + const takesValue = ANGLE_VALUE_RE.test(m[0]); + if (flagsOnly && takesValue) continue; + + out.push({ short, long, desc: ' ' }); + } + } + + return out; +} + +function loadNpmOptionsSync(cmd: LazyCommand, command: string): void { + const output = safeExecSync(`npm ${command} --help`); + if (!output) return; + + const allOptions = parseNpmOptions(output, { flagsOnly: false }); + + for (const { long, short, desc } of allOptions) { + const exists = cmd.optionsRaw?.get?.(long); + if (exists) continue; + + const handler = npmOptionHandlers[long]; + if (handler) cmd.option(long, desc, handler, short); + else cmd.option(long, desc, short); + } +} export async function setupNpmCompletions( completion: PackageManagerCompletion -): Promise {} +): Promise { + try { + const commands = await getNpmCommandsFromMainHelp(); + for (const [command, description] of Object.entries(commands)) { + const c = completion.command(command, description); + + setupCommandArguments(c, command, 'npm'); + + setupLazyOptionLoading(c, command, 'npm', loadNpmOptionsSync); + } + } catch {} +} diff --git a/bin/handlers/pnpm-handler.ts b/bin/handlers/pnpm-handler.ts index 6e9ad11..b3502bf 100644 --- a/bin/handlers/pnpm-handler.ts +++ b/bin/handlers/pnpm-handler.ts @@ -1,21 +1,15 @@ -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; -} - +import { getWorkspacePatterns } from '../utils/filesystem-utils.js'; import { - packageJsonScriptCompletion, - packageJsonDependencyCompletion, -} from '../completions/completion-producers.js'; + LazyCommand, + OptionHandlers, + commonOptionHandlers, + setupLazyOptionLoading, + setupCommandArguments, + safeExec, + safeExecSync, + createLogLevelHandler, +} from '../utils/shared.js'; import { stripAnsiEscapes, measureIndent, @@ -26,208 +20,314 @@ import { type ParsedOption, } from '../utils/text-utils.js'; -// regex to detect options section in help text const OPTIONS_SECTION_RE = /^\s*Options:/i; +const LEVEL_MATCH_RE = /(?:levels?|options?|values?)[^:]*:\s*([^.]+)/i; +const LINE_SPLIT_RE = /\r?\n/; +const COMMA_SPACE_SPLIT_RE = /[,\s]+/; +const OPTION_WITH_VALUE_RE = + /^\s*(?:-\w,?\s*)?--(\w+(?:-\w+)*)\s+(\w+(?:-\w+)*)\s+(.+)$/; +const OPTION_ALIAS_RE = + /^\s*-\w,?\s*--\w+(?:,\s*--(\w+(?:-\w+)*)\s+(\w+(?:-\w+)*))?\s+(.+)$/; +const CONTINUATION_LINE_RE = /^\s{20,}/; +const SECTION_HEADER_RE = /^\s*[A-Z][^:]*:\s*$/; -// we parse the pnpm help text to extract commands and their descriptions! -export function parsePnpmHelp(helpText: string): Record { - 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; +function toLines(text: string): string[] { + return stripAnsiEscapes(text).split(LINE_SPLIT_RE); +} + +function findCommandDescColumn(lines: string[]): number { + let col = Number.POSITIVE_INFINITY; + for (const line of lines) { + const m = line.match(COMMAND_ROW_RE); + if (!m) continue; + const idx = line.indexOf(m[2]); + if (idx >= 0 && idx < col) col = idx; + } + return col; +} + +function findOptionDescColumn(lines: string[], flagsOnly: boolean): number { + let col = Number.POSITIVE_INFINITY; + for (const line of lines) { + const m = line.match(OPTION_ROW_RE); + if (!m) continue; + if (flagsOnly && m.groups?.val) continue; // skip value-taking options in flagsOnly mode + const idx = line.indexOf(m.groups!.desc); + if (idx >= 0 && idx < col) col = idx; + } + return col; +} + +function extractValidValuesFromHelp( + helpText: string, + optionName: string +): Array<{ value: string; desc: string }> { + const lines = toLines(helpText); + const results: Array<{ value: string; desc: string }> = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Look for options with values in any section + const optionMatch = line.match(OPTION_WITH_VALUE_RE); + if (optionMatch) { + const [, option, value, initialDesc] = optionMatch; + if (option === optionName) { + // capture continuation lines for complete description + let fullDesc = initialDesc.trim(); + let j = i + 1; + + // Look ahead for continuation lines (indented lines that don't start new options) + while (j < lines.length) { + const nextLine = lines[j]; + const isIndented = CONTINUATION_LINE_RE.test(nextLine); + const isNewOption = + OPTION_WITH_VALUE_RE.test(nextLine) || + OPTION_ALIAS_RE.test(nextLine); + const isEmptyOrSection = + !nextLine.trim() || SECTION_HEADER_RE.test(nextLine); + + if (isIndented && !isNewOption && !isEmptyOrSection) { + fullDesc += ' ' + nextLine.trim(); + j++; + } else { + break; + } + } + + results.push({ value, desc: fullDesc }); + } + } + + const aliasMatch = line.match(OPTION_ALIAS_RE); + if (aliasMatch) { + const [, option, value, initialDesc] = aliasMatch; + if (option === optionName && value) { + // capture continuation lines for alias descriptions too + let fullDesc = initialDesc.trim(); + let j = i + 1; + + while (j < lines.length) { + const nextLine = lines[j]; + const isIndented = CONTINUATION_LINE_RE.test(nextLine); + const isNewOption = + OPTION_WITH_VALUE_RE.test(nextLine) || + OPTION_ALIAS_RE.test(nextLine); + const isEmptyOrSection = + !nextLine.trim() || SECTION_HEADER_RE.test(nextLine); + + if (isIndented && !isNewOption && !isEmptyOrSection) { + fullDesc += ' ' + nextLine.trim(); + j++; + } else { + break; + } + } + + results.push({ value, desc: fullDesc }); + } + } + } + + if (results.length) return results; + + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (ln.includes(`--${optionName}`) || ln.includes(`${optionName}:`)) { + for (let j = i; j < Math.min(i + 3, lines.length); j++) { + const probe = lines[j]; + const m = probe.match(LEVEL_MATCH_RE); + if (m) { + return m[1] + .split(COMMA_SPACE_SPLIT_RE) + .map((v) => v.trim()) + .filter((v) => v && !v.includes('(') && !v.includes(')')) + .map((value) => ({ value, desc: `Log level: ${value}` })); + } + } } } - 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(); - const flushPendingRow = () => { - if (!pendingRow) return; - const desc = pendingRow.desc.trim(); - for (const name of pendingRow.names) commandMap.set(name, desc); - pendingRow = null; + return []; +} + +const pnpmOptionHandlers: OptionHandlers = { + ...commonOptionHandlers, + + loglevel(complete) { + const fromHelp = extractValidValuesFromHelp( + safeExecSync('pnpm install --help'), + 'loglevel' + ); + if (fromHelp.length) { + fromHelp.forEach(({ value, desc }) => complete(value, desc)); + } else { + createLogLevelHandler(['debug', 'info', 'warn', 'error', 'silent'])( + complete + ); + } + }, + + reporter(complete) { + const out = extractValidValuesFromHelp( + safeExecSync('pnpm install --help'), + 'reporter' + ); + if (out.length) { + out.forEach(({ value, desc }) => complete(value, desc)); + } else { + createLogLevelHandler(['default', 'append-only', 'ndjson', 'silent'])( + complete + ); + } + }, + + filter(complete) { + complete('.', 'Current working directory'); + complete('!', 'Exclude packages matching selector'); + + const patterns = getWorkspacePatterns(); + patterns.forEach((p) => { + complete(p, `Workspace pattern: ${p}`); + complete(`${p}...`, `Include dependencies of ${p}`); + }); + + complete('@*/*', 'All scoped packages'); + complete('...', 'Include dependencies of pattern'); + complete('...', 'Include dependents of pattern'); + }, +}; + +export function parsePnpmHelp(helpText: string): Record { + const lines = toLines(helpText); + + const descCol = findCommandDescColumn(lines); + if (!Number.isFinite(descCol)) return {}; + + type Pending = { names: string[]; desc: string } | null; + let pending: Pending = null; + + const out = new Map(); + + const flush = () => { + if (!pending) return; + const desc = pending.desc.trim(); + for (const n of pending.names) out.set(n, desc); + pending = null; }; - for (const line of helpLines) { - if (OPTIONS_SECTION_RE.test(line)) break; // we stop at options + for (const line of lines) { + if (OPTIONS_SECTION_RE.test(line)) break; // end of commands section - // we match the command row - const rowMatch = line.match(COMMAND_ROW_RE); - if (rowMatch) { - flushPendingRow(); - pendingRow = { - names: parseAliasList(rowMatch[1]), - desc: rowMatch[2].trim(), + const row = line.match(COMMAND_ROW_RE); + if (row) { + flush(); + pending = { + names: parseAliasList(row[1]), + desc: row[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(); + if (pending) { + const indent = measureIndent(line); + if (indent >= descCol && line.trim()) { + pending.desc += ' ' + line.trim(); } } } - // we flush the pending row and return the command map - flushPendingRow(); + flush(); - return Object.fromEntries(commandMap); + return Object.fromEntries(out); } -// now we get the pnpm commands from the main help output export async function getPnpmCommandsFromMainHelp(): Promise< Record > { - try { - const { stdout } = await exec('pnpm --help', { - encoding: 'utf8', - timeout: 500, - maxBuffer: 4 * 1024 * 1024, - }); - return parsePnpmHelp(stdout); - } catch { - return {}; - } + const output = await safeExec('pnpm --help'); + return output ? parsePnpmHelp(output) : {}; } -// 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 []; + const lines = toLines(helpText); + + const descCol = findOptionDescColumn(lines, flagsOnly); + if (!Number.isFinite(descCol)) return []; - // we fold the option rows and join the continuations - const optionsOut: ParsedOption[] = []; - let pendingOption: ParsedOption | null = null; + const out: ParsedOption[] = []; + let pending: ParsedOption | null = null; - const flushPendingOption = () => { - if (!pendingOption) return; - pendingOption.desc = pendingOption.desc.trim(); - optionsOut.push(pendingOption); - pendingOption = null; + const flush = () => { + if (!pending) return; + pending.desc = pending.desc.trim(); + out.push(pending); + pending = 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(), + for (const line of lines) { + const m = line.match(OPTION_ROW_RE); + if (m) { + if (flagsOnly && m.groups?.val) continue; + flush(); + pending = { + short: m.groups?.short || undefined, + long: m.groups!.long, + desc: m.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(); + if (pending) { + const indent = measureIndent(line); + const startsNew = OPTION_HEAD_RE.test(line); + if (indent >= descCol && line.trim() && !startsNew) { + pending.desc += ' ' + line.trim(); } } } - // we flush the pending option - flushPendingOption(); + flush(); - return optionsOut; + return out; } -// 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 }); +function loadPnpmOptionsSync(cmd: LazyCommand, command: string): void { + const output = safeExecSync(`pnpm ${command} --help`); + if (!output) return; - for (const { long, short, desc } of parsedOptions) { - const alreadyDefined = cmd.optionsRaw?.get?.(long); - if (!alreadyDefined) cmd.option(long, desc, short); - } - } catch (_err) {} -} + const options = parsePnpmOptions(output, { flagsOnly: false }); -// we setup the lazy option loading for a command + for (const { long, short, desc } of options) { + const exists = cmd.optionsRaw?.get?.(long); + if (exists) continue; -function setupLazyOptionLoading(cmd: LazyCommand, command: string): void { - cmd._lazyCommand = command; - cmd._optionsLoaded = false; - - const optionsStore = cmd.options; - cmd.optionsRaw = optionsStore; + const handler = pnpmOptionHandlers[long]; + if (handler) cmd.option(long, desc, handler, short); + else cmd.option(long, desc, short); + } - Object.defineProperty(cmd, 'options', { - get() { - if (!this._optionsLoaded) { - this._optionsLoaded = true; - loadDynamicOptionsSync(this, this._lazyCommand); // block until filled + // Register options found by general algorithm but not in standard parsing + for (const [optionName, handler] of Object.entries(pnpmOptionHandlers)) { + if (!cmd.optionsRaw?.get?.(optionName)) { + const values = extractValidValuesFromHelp(output, optionName); + if (values.length > 0) { + cmd.option(optionName, ' ', handler); } - return optionsStore; - }, - configurable: true, - }); + } + } } export async function setupPnpmCompletions( completion: PackageManagerCompletion ): Promise { 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); - } + const commands = await getPnpmCommandsFromMainHelp(); - setupLazyOptionLoading(cmd, command); + for (const [command, description] of Object.entries(commands)) { + const c = completion.command(command, description); + setupCommandArguments(c, command, 'pnpm'); + setupLazyOptionLoading(c, command, 'pnpm', loadPnpmOptionsSync); } - } catch (_err) {} + } catch {} } diff --git a/bin/utils/filesystem-utils.ts b/bin/utils/filesystem-utils.ts new file mode 100644 index 0000000..3f4df47 --- /dev/null +++ b/bin/utils/filesystem-utils.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; + +export function getWorkspacePatterns(): string[] { + try { + let content: string; + try { + content = readFileSync('pnpm-workspace.yaml', 'utf8'); + } catch { + content = readFileSync('pnpm-workspace.yml', 'utf8'); + } + + const packagesMatch = content.match(/packages:\s*\n((?:\s*-\s*.+\n?)*)/); + if (!packagesMatch) return []; + + const patterns = packagesMatch[1] + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('-')) + .map((line) => line.substring(1).trim()) + .filter((pattern) => pattern && !pattern.startsWith('#')); + + return patterns; + } catch { + return []; + } +} diff --git a/bin/utils/shared.ts b/bin/utils/shared.ts new file mode 100644 index 0000000..30f749a --- /dev/null +++ b/bin/utils/shared.ts @@ -0,0 +1,104 @@ +import { promisify } from 'node:util'; +import child_process from 'node:child_process'; + +export const exec = promisify(child_process.exec); +export const { execSync } = child_process; + +import { Command, Option } from '../../src/t.js'; +import { + packageJsonScriptCompletion, + packageJsonDependencyCompletion, +} from '../completions/completion-producers.js'; +import { getWorkspacePatterns } from './filesystem-utils.js'; + +export interface LazyCommand extends Command { + _lazyCommand?: string; + _optionsLoaded?: boolean; + optionsRaw?: Map; +} + +export type CompletionHandler = ( + complete: (value: string, description: string) => void +) => void; +export type OptionHandlers = Record; + +export const commonOptionHandlers: OptionHandlers = { + workspace(complete) { + const patterns = getWorkspacePatterns(); + patterns.forEach((p) => complete(p, `Workspace pattern: ${p}`)); + }, +}; + +export function setupLazyOptionLoading( + cmd: LazyCommand, + command: string, + _packageManager: string, + loadOptionsSync: (cmd: LazyCommand, command: string) => void +): void { + cmd._lazyCommand = command; + cmd._optionsLoaded = false; + + const store = cmd.options; + cmd.optionsRaw = store; + + Object.defineProperty(cmd, 'options', { + get() { + if (!this._optionsLoaded) { + this._optionsLoaded = true; + loadOptionsSync(this, this._lazyCommand); + } + return store; + }, + configurable: true, + }); +} + +export function setupCommandArguments( + cmd: LazyCommand, + command: string, + _packageManager: string +): void { + if (['remove', 'rm', 'uninstall', 'un', 'update', 'up'].includes(command)) { + cmd.argument('package', packageJsonDependencyCompletion); + } + + if (['run', 'run-script'].includes(command)) { + cmd.argument('script', packageJsonScriptCompletion, true); + } +} + +export async function safeExec( + command: string, + options: any = {} +): Promise { + try { + const { stdout } = await exec(command, { + encoding: 'utf8' as const, + timeout: 500, + maxBuffer: 4 * 1024 * 1024, + ...options, + }); + return stdout as unknown as string; + } catch (error) { + if (error instanceof Error && 'stdout' in error) { + return (error as any).stdout as string; + } + return ''; + } +} + +export function safeExecSync(command: string, options: any = {}): string { + try { + return execSync(command, { + encoding: 'utf8' as const, + timeout: 500, + ...options, + }) as unknown as string; + } catch (error: any) { + return error?.stdout ? (error.stdout as string) : ''; + } +} + +export function createLogLevelHandler(levels: string[]): CompletionHandler { + return (complete) => levels.forEach((lvl) => complete(lvl, ' ')); +}