Skip to content

Commit 0a1cc2f

Browse files
authored
fix: use pnpm'n main help - pnpm's official descriptions for cmds and opts (#46)
* update * trigger ci * machanism change - parse --help * types * fix/descriptions * big update * update * update
1 parent 44f9091 commit 0a1cc2f

File tree

10 files changed

+360
-455
lines changed

10 files changed

+360
-455
lines changed

bin/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async function main() {
3030
if (dashIndex !== -1) {
3131
// Use the new PackageManagerCompletion wrapper
3232
const completion = new PackageManagerCompletion(packageManager);
33-
setupCompletionForPackageManager(packageManager, completion);
33+
await setupCompletionForPackageManager(packageManager, completion);
3434
const toComplete = process.argv.slice(dashIndex + 1);
3535
await completion.parse(toComplete);
3636
process.exit(0);

bin/completion-handlers.ts

Lines changed: 29 additions & 453 deletions
Large diffs are not rendered by default.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Complete } from '../../src/t.js';
2+
import {
3+
getPackageJsonScripts,
4+
getPackageJsonDependencies,
5+
} from '../utils/package-json-utils.js';
6+
7+
// provides completions for npm scripts from package.json.. like: start,dev,build
8+
export const packageJsonScriptCompletion = async (
9+
complete: Complete
10+
): Promise<void> => {
11+
getPackageJsonScripts().forEach((script) =>
12+
complete(script, `Run ${script} script`)
13+
);
14+
};
15+
16+
// provides completions for package dependencies from package.json.. for commands like remove `pnpm remove <dependency>`
17+
export const packageJsonDependencyCompletion = async (
18+
complete: Complete
19+
): Promise<void> => {
20+
getPackageJsonDependencies().forEach((dep) => complete(dep, ''));
21+
};

bin/handlers/bun-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { PackageManagerCompletion } from '../package-manager-completion.js';
2+
3+
export async function setupBunCompletions(
4+
completion: PackageManagerCompletion
5+
): Promise<void> {}

bin/handlers/npm-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { PackageManagerCompletion } from '../package-manager-completion.js';
2+
3+
export async function setupNpmCompletions(
4+
completion: PackageManagerCompletion
5+
): Promise<void> {}

bin/handlers/pnpm-handler.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { promisify } from 'node:util';
2+
import child_process from 'node:child_process';
3+
4+
const exec = promisify(child_process.exec);
5+
const { execSync } = child_process;
6+
import type { PackageManagerCompletion } from '../package-manager-completion.js';
7+
import { Command, Option } from '../../src/t.js';
8+
9+
interface LazyCommand extends Command {
10+
_lazyCommand?: string;
11+
_optionsLoaded?: boolean;
12+
optionsRaw?: Map<string, Option>;
13+
}
14+
15+
import {
16+
packageJsonScriptCompletion,
17+
packageJsonDependencyCompletion,
18+
} from '../completions/completion-producers.js';
19+
import {
20+
stripAnsiEscapes,
21+
measureIndent,
22+
parseAliasList,
23+
COMMAND_ROW_RE,
24+
OPTION_ROW_RE,
25+
OPTION_HEAD_RE,
26+
type ParsedOption,
27+
} from '../utils/text-utils.js';
28+
29+
// regex to detect options section in help text
30+
const OPTIONS_SECTION_RE = /^\s*Options:/i;
31+
32+
// we parse the pnpm help text to extract commands and their descriptions!
33+
export function parsePnpmHelp(helpText: string): Record<string, string> {
34+
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);
35+
36+
// we find the earliest description column across command rows.
37+
let descColumnIndex = Number.POSITIVE_INFINITY;
38+
for (const line of helpLines) {
39+
const rowMatch = line.match(COMMAND_ROW_RE);
40+
if (!rowMatch) continue;
41+
const descColumnIndexOnThisLine = line.indexOf(rowMatch[2]);
42+
if (
43+
descColumnIndexOnThisLine >= 0 &&
44+
descColumnIndexOnThisLine < descColumnIndex
45+
) {
46+
descColumnIndex = descColumnIndexOnThisLine;
47+
}
48+
}
49+
if (!Number.isFinite(descColumnIndex)) return {};
50+
51+
// we fold rows, and join continuation lines aligned to descColumnIndex or deeper.
52+
type PendingRow = { names: string[]; desc: string } | null;
53+
let pendingRow: PendingRow = null;
54+
55+
const commandMap = new Map<string, string>();
56+
const flushPendingRow = () => {
57+
if (!pendingRow) return;
58+
const desc = pendingRow.desc.trim();
59+
for (const name of pendingRow.names) commandMap.set(name, desc);
60+
pendingRow = null;
61+
};
62+
63+
for (const line of helpLines) {
64+
if (OPTIONS_SECTION_RE.test(line)) break; // we stop at options
65+
66+
// we match the command row
67+
const rowMatch = line.match(COMMAND_ROW_RE);
68+
if (rowMatch) {
69+
flushPendingRow();
70+
pendingRow = {
71+
names: parseAliasList(rowMatch[1]),
72+
desc: rowMatch[2].trim(),
73+
};
74+
continue;
75+
}
76+
77+
// we join continuation lines aligned to descColumnIndex or deeper
78+
if (pendingRow) {
79+
const indentWidth = measureIndent(line);
80+
if (indentWidth >= descColumnIndex && line.trim()) {
81+
pendingRow.desc += ' ' + line.trim();
82+
}
83+
}
84+
}
85+
// we flush the pending row and return the command map
86+
flushPendingRow();
87+
88+
return Object.fromEntries(commandMap);
89+
}
90+
91+
// now we get the pnpm commands from the main help output
92+
export async function getPnpmCommandsFromMainHelp(): Promise<
93+
Record<string, string>
94+
> {
95+
try {
96+
const { stdout } = await exec('pnpm --help', {
97+
encoding: 'utf8',
98+
timeout: 500,
99+
maxBuffer: 4 * 1024 * 1024,
100+
});
101+
return parsePnpmHelp(stdout);
102+
} catch {
103+
return {};
104+
}
105+
}
106+
107+
// here we parse the pnpm options from the help text
108+
export function parsePnpmOptions(
109+
helpText: string,
110+
{ flagsOnly = true }: { flagsOnly?: boolean } = {}
111+
): ParsedOption[] {
112+
// we strip the ANSI escapes from the help text
113+
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);
114+
115+
// we find the earliest description column among option rows we care about
116+
let descColumnIndex = Number.POSITIVE_INFINITY;
117+
for (const line of helpLines) {
118+
const optionMatch = line.match(OPTION_ROW_RE);
119+
if (!optionMatch) continue;
120+
if (flagsOnly && optionMatch.groups?.val) continue; // skip value-taking options, we will add them manually with their value
121+
const descColumnIndexOnThisLine = line.indexOf(optionMatch.groups!.desc);
122+
if (
123+
descColumnIndexOnThisLine >= 0 &&
124+
descColumnIndexOnThisLine < descColumnIndex
125+
) {
126+
descColumnIndex = descColumnIndexOnThisLine;
127+
}
128+
}
129+
if (!Number.isFinite(descColumnIndex)) return [];
130+
131+
// we fold the option rows and join the continuations
132+
const optionsOut: ParsedOption[] = [];
133+
let pendingOption: ParsedOption | null = null;
134+
135+
const flushPendingOption = () => {
136+
if (!pendingOption) return;
137+
pendingOption.desc = pendingOption.desc.trim();
138+
optionsOut.push(pendingOption);
139+
pendingOption = null;
140+
};
141+
142+
// we match the option row
143+
for (const line of helpLines) {
144+
const optionMatch = line.match(OPTION_ROW_RE);
145+
if (optionMatch) {
146+
if (flagsOnly && optionMatch.groups?.val) continue;
147+
flushPendingOption();
148+
pendingOption = {
149+
short: optionMatch.groups?.short || undefined,
150+
long: optionMatch.groups!.long,
151+
desc: optionMatch.groups!.desc.trim(),
152+
};
153+
continue;
154+
}
155+
156+
// we join the continuations
157+
if (pendingOption) {
158+
const indentWidth = measureIndent(line);
159+
const startsNewOption = OPTION_HEAD_RE.test(line);
160+
if (indentWidth >= descColumnIndex && line.trim() && !startsNewOption) {
161+
pendingOption.desc += ' ' + line.trim();
162+
}
163+
}
164+
}
165+
// we flush the pending option
166+
flushPendingOption();
167+
168+
return optionsOut;
169+
}
170+
171+
// we load the dynamic options synchronously when requested ( separated from the command loading )
172+
export function loadDynamicOptionsSync(
173+
cmd: LazyCommand,
174+
command: string
175+
): void {
176+
try {
177+
const stdout = execSync(`pnpm ${command} --help`, {
178+
encoding: 'utf8',
179+
timeout: 500,
180+
});
181+
182+
const parsedOptions = parsePnpmOptions(stdout, { flagsOnly: true });
183+
184+
for (const { long, short, desc } of parsedOptions) {
185+
const alreadyDefined = cmd.optionsRaw?.get?.(long);
186+
if (!alreadyDefined) cmd.option(long, desc, short);
187+
}
188+
} catch (_err) {}
189+
}
190+
191+
// we setup the lazy option loading for a command
192+
193+
function setupLazyOptionLoading(cmd: LazyCommand, command: string): void {
194+
cmd._lazyCommand = command;
195+
cmd._optionsLoaded = false;
196+
197+
const optionsStore = cmd.options;
198+
cmd.optionsRaw = optionsStore;
199+
200+
Object.defineProperty(cmd, 'options', {
201+
get() {
202+
if (!this._optionsLoaded) {
203+
this._optionsLoaded = true;
204+
loadDynamicOptionsSync(this, this._lazyCommand); // block until filled
205+
}
206+
return optionsStore;
207+
},
208+
configurable: true,
209+
});
210+
}
211+
212+
export async function setupPnpmCompletions(
213+
completion: PackageManagerCompletion
214+
): Promise<void> {
215+
try {
216+
const commandsWithDescriptions = await getPnpmCommandsFromMainHelp();
217+
218+
for (const [command, description] of Object.entries(
219+
commandsWithDescriptions
220+
)) {
221+
const cmd = completion.command(command, description);
222+
223+
if (['remove', 'rm', 'update', 'up'].includes(command)) {
224+
cmd.argument('package', packageJsonDependencyCompletion);
225+
}
226+
if (command === 'run') {
227+
cmd.argument('script', packageJsonScriptCompletion, true);
228+
}
229+
230+
setupLazyOptionLoading(cmd, command);
231+
}
232+
} catch (_err) {}
233+
}

bin/handlers/yarn-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { PackageManagerCompletion } from '../package-manager-completion.js';
2+
3+
export async function setupYarnCompletions(
4+
completion: PackageManagerCompletion
5+
): Promise<void> {}

bin/utils/package-json-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readFileSync } from 'fs';
2+
3+
export function getPackageJsonScripts(): string[] {
4+
try {
5+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
6+
return Object.keys(packageJson.scripts || {});
7+
} catch {
8+
return [];
9+
}
10+
}
11+
12+
export function getPackageJsonDependencies(): string[] {
13+
try {
14+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
15+
const deps = {
16+
...packageJson.dependencies,
17+
...packageJson.devDependencies,
18+
...packageJson.peerDependencies,
19+
...packageJson.optionalDependencies,
20+
};
21+
return Object.keys(deps);
22+
} catch {
23+
return [];
24+
}
25+
}

bin/utils/text-utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// regex for parsing help text
2+
export const ANSI_ESCAPE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
3+
4+
// Command row: <indent><names><>=2 spaces><desc>
5+
// e.g. " install, i Install all dependencies"
6+
export const COMMAND_ROW_RE = /^\s+([a-z][a-z\s,-]*?)\s{2,}(\S.*)$/i;
7+
8+
// Option row (optional value part captured in (?<val>)):
9+
// [indent][-x, ]--long[ <value>| [value]] <>=2 spaces> <desc>
10+
export const OPTION_ROW_RE =
11+
/^\s*(?:-(?<short>[A-Za-z]),\s*)?--(?<long>[a-z0-9-]+)(?<val>\s+(?:<[^>]+>|\[[^\]]+\]))?\s{2,}(?<desc>\S.*)$/i;
12+
13+
// we detect the start of a new option head (used to stop continuation)
14+
export const OPTION_HEAD_RE = /^\s*(?:-[A-Za-z],\s*)?--[a-z0-9-]+/i;
15+
16+
// we remove the ANSI escape sequences from a string
17+
export const stripAnsiEscapes = (s: string): string =>
18+
s.replace(ANSI_ESCAPE_RE, '');
19+
20+
// measure the indentation level of a string
21+
export const measureIndent = (s: string): number =>
22+
(s.match(/^\s*/) || [''])[0].length;
23+
24+
// parse a comma-separated list of aliases
25+
export const parseAliasList = (s: string): string[] =>
26+
s
27+
.split(',')
28+
.map((t) => t.trim())
29+
.filter(Boolean);
30+
31+
export type ParsedOption = {
32+
long: string;
33+
short?: string;
34+
desc: string;
35+
};

src/t.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const ShellCompDirective = {
1212

1313
export type OptionsMap = Map<string, Option>;
1414

15-
type Complete = (value: string, description: string) => void;
15+
export type Complete = (value: string, description: string) => void;
1616

1717
export type OptionHandler = (
1818
this: Option,

0 commit comments

Comments
 (0)