|
| 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 | +} |
0 commit comments