Skip to content

Commit 3ccc322

Browse files
authored
feat: add manual completions for pnpm options that take values and add npm handler (#48)
* init * update * update * feat: add npm handler (#49) * add npm handler * big update * clean npm handler * clean shared * clean pnpm handler * clean bun handler * update * update * ci * update * rm debug log
1 parent 0a1cc2f commit 3ccc322

File tree

4 files changed

+551
-168
lines changed

4 files changed

+551
-168
lines changed

bin/handlers/npm-handler.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,158 @@
11
import type { PackageManagerCompletion } from '../package-manager-completion.js';
2+
import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js';
3+
import {
4+
LazyCommand,
5+
OptionHandlers,
6+
commonOptionHandlers,
7+
setupLazyOptionLoading,
8+
setupCommandArguments,
9+
safeExec,
10+
safeExecSync,
11+
createLogLevelHandler,
12+
} from '../utils/shared.js';
13+
14+
const ALL_COMMANDS_RE = /^All commands:\s*$/i;
15+
const OPTIONS_SECTION_RE = /^Options:\s*$/i;
16+
const SECTION_END_RE = /^(aliases|run|more)/i; // marks end of Options: block
17+
const COMMAND_VALIDATION_RE = /^[a-z][a-z0-9-]*$/;
18+
const NPM_OPTION_RE =
19+
/(?:\[)?(?:-([a-z])\|)?--([a-z][a-z0-9-]+)(?:\s+<[^>]+>)?(?:\])?/gi;
20+
const ANGLE_VALUE_RE = /<[^>]+>/;
21+
const INDENTED_LINE_RE = /^\s/;
22+
23+
function toLines(helpText: string): string[] {
24+
return stripAnsiEscapes(helpText).split(/\r?\n/);
25+
}
26+
27+
function readIndentedBlockAfter(lines: string[], headerRe: RegExp): string {
28+
const start = lines.findIndex((l) => headerRe.test(l.trim()));
29+
if (start === -1) return '';
30+
31+
let buf = '';
32+
for (let i = start + 1; i < lines.length; i++) {
33+
const line = lines[i];
34+
if (!INDENTED_LINE_RE.test(line) && line.trim() && !line.includes(','))
35+
break;
36+
if (INDENTED_LINE_RE.test(line)) buf += ' ' + line.trim();
37+
}
38+
return buf;
39+
}
40+
41+
const listHandler =
42+
(values: string[], describe: (v: string) => string = () => ' ') =>
43+
(complete: (value: string, description: string) => void) =>
44+
values.forEach((v) => complete(v, describe(v)));
45+
46+
const npmOptionHandlers: OptionHandlers = {
47+
...commonOptionHandlers,
48+
49+
loglevel: createLogLevelHandler([
50+
'silent',
51+
'error',
52+
'warn',
53+
'notice',
54+
'http',
55+
'info',
56+
'verbose',
57+
'silly',
58+
]),
59+
60+
'install-strategy': listHandler(
61+
['hoisted', 'nested', 'shallow', 'linked'],
62+
() => ' '
63+
),
64+
65+
omit: listHandler(['dev', 'optional', 'peer'], () => ' '),
66+
67+
include: listHandler(['prod', 'dev', 'optional', 'peer'], () => ' '),
68+
};
69+
70+
export function parseNpmHelp(helpText: string): Record<string, string> {
71+
const lines = toLines(helpText);
72+
const commandsBlob = readIndentedBlockAfter(lines, ALL_COMMANDS_RE);
73+
if (!commandsBlob) return {};
74+
75+
const commands: Record<string, string> = {};
76+
77+
commandsBlob
78+
.split(',')
79+
.map((c) => c.trim())
80+
.filter((c) => c && COMMAND_VALIDATION_RE.test(c))
81+
.forEach((cmd) => {
82+
// npm main help has no per-command descriptions
83+
commands[cmd] = ' ';
84+
});
85+
86+
// this is the most common used aliase that isn't in the main list
87+
commands['run'] = ' ';
88+
89+
return commands;
90+
}
91+
92+
// Get npm commands from the main help output
93+
export async function getNpmCommandsFromMainHelp(): Promise<
94+
Record<string, string>
95+
> {
96+
const output = await safeExec('npm --help');
97+
return output ? parseNpmHelp(output) : {};
98+
}
99+
100+
export function parseNpmOptions(
101+
helpText: string,
102+
{ flagsOnly = true }: { flagsOnly?: boolean } = {}
103+
): ParsedOption[] {
104+
const lines = toLines(helpText);
105+
106+
const start = lines.findIndex((l) => OPTIONS_SECTION_RE.test(l.trim()));
107+
if (start === -1) return [];
108+
109+
const out: ParsedOption[] = [];
110+
111+
for (const line of lines.slice(start + 1)) {
112+
const trimmed = line.trim();
113+
if (SECTION_END_RE.test(trimmed)) break;
114+
115+
const matches = line.matchAll(NPM_OPTION_RE);
116+
for (const m of matches) {
117+
const short = m[1] || undefined;
118+
const long = m[2];
119+
const takesValue = ANGLE_VALUE_RE.test(m[0]);
120+
if (flagsOnly && takesValue) continue;
121+
122+
out.push({ short, long, desc: ' ' });
123+
}
124+
}
125+
126+
return out;
127+
}
128+
129+
function loadNpmOptionsSync(cmd: LazyCommand, command: string): void {
130+
const output = safeExecSync(`npm ${command} --help`);
131+
if (!output) return;
132+
133+
const allOptions = parseNpmOptions(output, { flagsOnly: false });
134+
135+
for (const { long, short, desc } of allOptions) {
136+
const exists = cmd.optionsRaw?.get?.(long);
137+
if (exists) continue;
138+
139+
const handler = npmOptionHandlers[long];
140+
if (handler) cmd.option(long, desc, handler, short);
141+
else cmd.option(long, desc, short);
142+
}
143+
}
2144

3145
export async function setupNpmCompletions(
4146
completion: PackageManagerCompletion
5-
): Promise<void> {}
147+
): Promise<void> {
148+
try {
149+
const commands = await getNpmCommandsFromMainHelp();
150+
for (const [command, description] of Object.entries(commands)) {
151+
const c = completion.command(command, description);
152+
153+
setupCommandArguments(c, command, 'npm');
154+
155+
setupLazyOptionLoading(c, command, 'npm', loadNpmOptionsSync);
156+
}
157+
} catch {}
158+
}

0 commit comments

Comments
 (0)