politty 0.4.16 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{arg-registry-MVWOAcvw.d.cts → arg-registry--NRaNFJM.d.cts} +123 -3
- package/dist/arg-registry--NRaNFJM.d.cts.map +1 -0
- package/dist/{arg-registry-Cd6xnjHa.d.ts → arg-registry-6E0WHOh_.d.ts} +123 -3
- package/dist/arg-registry-6E0WHOh_.d.ts.map +1 -0
- package/dist/augment.d.cts +1 -1
- package/dist/augment.d.ts +1 -1
- package/dist/completion/index.cjs +1 -1
- package/dist/completion/index.d.cts +3 -2
- package/dist/completion/index.d.ts +3 -2
- package/dist/completion/index.js +1 -1
- package/dist/completion-BA5JMvVG.js +4067 -0
- package/dist/completion-BA5JMvVG.js.map +1 -0
- package/dist/completion-Cqs1Ja7C.cjs +4169 -0
- package/dist/completion-Cqs1Ja7C.cjs.map +1 -0
- package/dist/docs/index.cjs +9 -9
- package/dist/docs/index.cjs.map +1 -1
- package/dist/docs/index.d.cts +1 -1
- package/dist/docs/index.d.ts +1 -1
- package/dist/docs/index.js +2 -2
- package/dist/{index-CPebddth.d.cts → index-DBMfKZ34.d.ts} +135 -15
- package/dist/index-DBMfKZ34.d.ts.map +1 -0
- package/dist/{index-DR9HLxIP.d.ts → index-DJp8k5Bq.d.cts} +135 -15
- package/dist/index-DJp8k5Bq.d.cts.map +1 -0
- package/dist/index.cjs +10 -10
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/prompt/clack/index.d.cts +1 -1
- package/dist/prompt/clack/index.d.ts +1 -1
- package/dist/prompt/index.d.cts +1 -1
- package/dist/prompt/index.d.ts +1 -1
- package/dist/prompt/inquirer/index.d.cts +1 -1
- package/dist/prompt/inquirer/index.d.ts +1 -1
- package/dist/{runner-BHeCMEa5.js → runner-BmSEiD9A.js} +12 -9
- package/dist/{runner-BHeCMEa5.js.map → runner-BmSEiD9A.js.map} +1 -1
- package/dist/{runner-BcyR6Z8r.cjs → runner-CRZ_7Y9i.cjs} +56 -53
- package/dist/{runner-BcyR6Z8r.cjs.map → runner-CRZ_7Y9i.cjs.map} +1 -1
- package/dist/{subcommand-router-XZBWe8HN.js → schema-extractor-C50R-1re.js} +135 -135
- package/dist/schema-extractor-C50R-1re.js.map +1 -0
- package/dist/{subcommand-router-DQy0KZU-.cjs → schema-extractor-SLPgBNgZ.cjs} +134 -134
- package/dist/schema-extractor-SLPgBNgZ.cjs.map +1 -0
- package/package.json +5 -5
- package/dist/arg-registry-Cd6xnjHa.d.ts.map +0 -1
- package/dist/arg-registry-MVWOAcvw.d.cts.map +0 -1
- package/dist/completion-B04iiki9.js +0 -2338
- package/dist/completion-B04iiki9.js.map +0 -1
- package/dist/completion-BlZxMSeU.cjs +0 -2440
- package/dist/completion-BlZxMSeU.cjs.map +0 -1
- package/dist/index-CPebddth.d.cts.map +0 -1
- package/dist/index-DR9HLxIP.d.ts.map +0 -1
- package/dist/subcommand-router-DQy0KZU-.cjs.map +0 -1
- package/dist/subcommand-router-XZBWe8HN.js.map +0 -1
|
@@ -0,0 +1,4067 @@
|
|
|
1
|
+
import { a as toCamelCase, h as arg, m as resolveSubCommandMeta, n as getAllAliases, t as extractFields, u as resolveSubCommandAlias } from "./schema-extractor-C50R-1re.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { execSync, spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
//#region src/core/command.ts
|
|
8
|
+
function defineCommand(config) {
|
|
9
|
+
return {
|
|
10
|
+
name: config.name,
|
|
11
|
+
description: config.description,
|
|
12
|
+
aliases: config.aliases,
|
|
13
|
+
args: config.args,
|
|
14
|
+
subCommands: config.subCommands,
|
|
15
|
+
setup: config.setup,
|
|
16
|
+
run: config.run,
|
|
17
|
+
cleanup: config.cleanup,
|
|
18
|
+
notes: config.notes,
|
|
19
|
+
examples: config.examples
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a typed defineCommand factory with pre-bound global args type.
|
|
24
|
+
* This is the recommended pattern for type-safe global options.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // global-args.ts
|
|
29
|
+
* type GlobalArgsType = { verbose: boolean; config?: string };
|
|
30
|
+
* export const defineAppCommand = createDefineCommand<GlobalArgsType>();
|
|
31
|
+
*
|
|
32
|
+
* // commands/build.ts
|
|
33
|
+
* export const buildCommand = defineAppCommand({
|
|
34
|
+
* name: "build",
|
|
35
|
+
* args: z.object({ output: arg(z.string().default("dist")) }),
|
|
36
|
+
* run: (args) => {
|
|
37
|
+
* args.verbose; // typed via GlobalArgsType
|
|
38
|
+
* args.output; // typed via local args
|
|
39
|
+
* },
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
function createDefineCommand() {
|
|
44
|
+
return defineCommand;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/completion/shell-shared.ts
|
|
49
|
+
/**
|
|
50
|
+
* Helpers shared across the bash/zsh/fish completion generators.
|
|
51
|
+
*
|
|
52
|
+
* The three generators are necessarily distinct (each shell has its own
|
|
53
|
+
* syntax) but they share a handful of building blocks: ANSI-C literal
|
|
54
|
+
* encoding, the `--alias`/`-a` token shape, and the resolved-dep records
|
|
55
|
+
* that drive expand lookups. Keeping these in one place avoids drift
|
|
56
|
+
* when one generator gets a fix that the others should mirror.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Build the shell-agnostic part of an expand location for an option. Wraps
|
|
60
|
+
* `resolveExpandDepGlobality` so the three generators don't each repeat the
|
|
61
|
+
* `(opt.valueCompletion, opt.isGlobal === true, options, positionals)`
|
|
62
|
+
* incantation.
|
|
63
|
+
*/
|
|
64
|
+
function optionExpandLocation(opt, frameOptions, framePositionals) {
|
|
65
|
+
return {
|
|
66
|
+
fieldName: opt.name,
|
|
67
|
+
isArrayOption: opt.valueType === "array",
|
|
68
|
+
isGlobal: opt.isGlobal === true,
|
|
69
|
+
resolvedDeps: resolveExpandDepGlobality(opt.valueCompletion, opt.isGlobal === true, frameOptions, framePositionals)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Shell-agnostic expand location for a positional. Positionals are never
|
|
74
|
+
* global (the runtime parser does not propagate positionals across frames)
|
|
75
|
+
* and never array-dedup hosts (`key=value` semantics don't apply), so those
|
|
76
|
+
* bits are hard-coded.
|
|
77
|
+
*/
|
|
78
|
+
function positionalExpandLocation(pos, frameOptions, framePositionals) {
|
|
79
|
+
return {
|
|
80
|
+
fieldName: pos.name,
|
|
81
|
+
isArrayOption: false,
|
|
82
|
+
isGlobal: false,
|
|
83
|
+
resolvedDeps: resolveExpandDepGlobality(pos.valueCompletion, false, frameOptions, framePositionals)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve each `dependsOn` entry to its globality at codegen time. A
|
|
88
|
+
* global host's deps all live in the global namespace. A local host
|
|
89
|
+
* may declare deps against a propagated global (its sibling index
|
|
90
|
+
* includes globals); those individual deps must read from the global
|
|
91
|
+
* bucket even when the host itself is local — the tracker only ever
|
|
92
|
+
* writes the global value into \`_global_arg_values_<name>\`, so a
|
|
93
|
+
* lookup against the local bucket would see the empty key.
|
|
94
|
+
*
|
|
95
|
+
* Local-precedence matches `buildSiblingIndex` in `expand-resolver.ts`:
|
|
96
|
+
* a dep name that exists on a local field (option OR positional) at the
|
|
97
|
+
* frame resolves to the local, even if a same-named global also exists.
|
|
98
|
+
* Marking such a dep as global would route the lookup at the wrong
|
|
99
|
+
* bucket and produce no candidates.
|
|
100
|
+
*/
|
|
101
|
+
function resolveExpandDepGlobality(vc, hostIsGlobal, frameOptions, framePositionals = []) {
|
|
102
|
+
if (vc?.type !== "expand") return [];
|
|
103
|
+
const globalOptionNames = /* @__PURE__ */ new Set();
|
|
104
|
+
const localFieldNames = /* @__PURE__ */ new Set();
|
|
105
|
+
for (const o of frameOptions) if (o.isGlobal === true) globalOptionNames.add(o.name);
|
|
106
|
+
else localFieldNames.add(o.name);
|
|
107
|
+
for (const p of framePositionals) localFieldNames.add(p.name);
|
|
108
|
+
return vc.dependsOn.map((name) => ({
|
|
109
|
+
name,
|
|
110
|
+
isGlobal: hostIsGlobal || globalOptionNames.has(name) && !localFieldNames.has(name)
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Encode a string as an ANSI-C shell literal (`$'…'`) with backslash
|
|
115
|
+
* escapes. Used by bash and zsh to embed expand-table values so newlines,
|
|
116
|
+
* the unit-separator key delimiter, and other control characters survive
|
|
117
|
+
* verbatim.
|
|
118
|
+
*/
|
|
119
|
+
function ansiC(s) {
|
|
120
|
+
let out = "$'";
|
|
121
|
+
for (const ch of s) {
|
|
122
|
+
const code = ch.codePointAt(0);
|
|
123
|
+
if (ch === "\\") out += "\\\\";
|
|
124
|
+
else if (ch === "'") out += "\\'";
|
|
125
|
+
else if (ch === "\n") out += "\\n";
|
|
126
|
+
else if (ch === "\r") out += "\\r";
|
|
127
|
+
else if (ch === " ") out += "\\t";
|
|
128
|
+
else if (code < 32 || code === 127) out += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
129
|
+
else out += ch;
|
|
130
|
+
}
|
|
131
|
+
out += "'";
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Render an alias as its CLI token form: single-char aliases become `-x`,
|
|
136
|
+
* multi-char aliases become `--long`. Mirrors the parser's accepted shapes
|
|
137
|
+
* and is the bare-token form (no quoting) used inside generated case
|
|
138
|
+
* patterns.
|
|
139
|
+
*/
|
|
140
|
+
function aliasToken(alias) {
|
|
141
|
+
return alias.length === 1 ? `-${alias}` : `--${alias}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Append every alternate spelling runtime's aliasMap accepts for an alias:
|
|
145
|
+
* `-a` / `--a` for single-char, `--to-be` / `--toBe` for hyphenated.
|
|
146
|
+
* Shared between {@link collectOptionTokens} and {@link localShadowingTokens}.
|
|
147
|
+
*/
|
|
148
|
+
function pushAliasTokens(tokens, a) {
|
|
149
|
+
pushUnique(tokens, aliasToken(a));
|
|
150
|
+
if (a.length === 1) pushUnique(tokens, `--${a}`);
|
|
151
|
+
else if (a.includes("-")) pushUnique(tokens, `--${toCamelCase(a)}`);
|
|
152
|
+
}
|
|
153
|
+
function pushUnique(tokens, t) {
|
|
154
|
+
if (!tokens.includes(t)) tokens.push(t);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every CLI spelling the runtime's aliasMap routes to this option:
|
|
158
|
+
* - `--cliName` (always),
|
|
159
|
+
* - `-x` when `cliName` is one character,
|
|
160
|
+
* - `--toCamelCase(cliName)` when `cliName` is hyphenated,
|
|
161
|
+
* - and the analogous forms for each alias.
|
|
162
|
+
*
|
|
163
|
+
* The order is stable so shell-side case patterns stay diff-friendly.
|
|
164
|
+
*/
|
|
165
|
+
function collectOptionTokens(cliName, aliases) {
|
|
166
|
+
const tokens = [`--${cliName}`];
|
|
167
|
+
if (cliName.length === 1) tokens.push(`-${cliName}`);
|
|
168
|
+
if (cliName.includes("-")) tokens.push(`--${toCamelCase(cliName)}`);
|
|
169
|
+
for (const a of aliases ?? []) pushAliasTokens(tokens, a);
|
|
170
|
+
return tokens;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Single-character tokens (`-x`) that global options at this frame own.
|
|
174
|
+
* Shared between availability-guard and value-completion token filtering:
|
|
175
|
+
* both paths must agree on which short forms `separateGlobalArgs` routes
|
|
176
|
+
* to a global rather than a same-letter local.
|
|
177
|
+
*/
|
|
178
|
+
function globalShortTokens(frameOptions) {
|
|
179
|
+
const out = /* @__PURE__ */ new Set();
|
|
180
|
+
for (const o of frameOptions) {
|
|
181
|
+
if (o.isGlobal !== true) continue;
|
|
182
|
+
if (o.cliName.length === 1) out.add(`-${o.cliName}`);
|
|
183
|
+
for (const a of o.alias ?? []) if (a.length === 1) out.add(`-${a}`);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Build the quoted-token list bash/zsh/fish pass to `__<fn>_not_used` to
|
|
189
|
+
* decide whether an option (and its negation form, if any) is still
|
|
190
|
+
* available. Quoting style (`"…"`) is identical across all three shells,
|
|
191
|
+
* so the helper lives here instead of being re-derived per generator.
|
|
192
|
+
*/
|
|
193
|
+
function quotedAvailabilityTokens(cliName, aliases, negation, options) {
|
|
194
|
+
const tokens = new Set(collectOptionTokens(cliName, aliases));
|
|
195
|
+
if (negation) {
|
|
196
|
+
tokens.add(`--${negation}`);
|
|
197
|
+
if (negation.includes("-")) tokens.add(`--${toCamelCase(negation)}`);
|
|
198
|
+
}
|
|
199
|
+
if (options?.frameOptions) if (options.isGlobal === true) for (const o of options.frameOptions) {
|
|
200
|
+
if (o.isGlobal === true) continue;
|
|
201
|
+
for (const t of localShadowingTokens(o.cliName, o.alias)) tokens.delete(t);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const globalShort = globalShortTokens(options.frameOptions);
|
|
205
|
+
if (globalShort.size > 0) {
|
|
206
|
+
const localExplicitShort = new Set((aliases ?? []).filter((a) => a.length === 1).map((a) => `-${a}`));
|
|
207
|
+
for (const g of globalShort) if (!localExplicitShort.has(g)) tokens.delete(g);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return [...tokens].map((t) => `"${t}"`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Tokens the runtime's `separateGlobalArgs` would consider locally
|
|
214
|
+
* owned at the leaf: long-form cliName plus every EXPLICIT alias
|
|
215
|
+
* spelling (in all forms the aliasMap accepts — `-a`/`--a` for short,
|
|
216
|
+
* `--to-be`/`--toBe` for hyphenated). Excludes the auto-derived `-x`
|
|
217
|
+
* for a 1-char cliName because that short form lives in the local
|
|
218
|
+
* aliasMap only when an explicit alias declares it.
|
|
219
|
+
*/
|
|
220
|
+
function localShadowingTokens(cliName, aliases) {
|
|
221
|
+
const tokens = [`--${cliName}`];
|
|
222
|
+
if (cliName.includes("-")) tokens.push(`--${toCamelCase(cliName)}`);
|
|
223
|
+
for (const a of aliases ?? []) pushAliasTokens(tokens, a);
|
|
224
|
+
return tokens;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/completion/value-completion-resolver.ts
|
|
229
|
+
/**
|
|
230
|
+
* Resolve value completion from field metadata.
|
|
231
|
+
*
|
|
232
|
+
* Priority (within `custom`): `expand` > `resolve` > `choices` > `shellCommand`.
|
|
233
|
+
* Specifying more than one of these on the same field throws so the
|
|
234
|
+
* misconfiguration surfaces at command-definition time rather than at
|
|
235
|
+
* completion time. The `expand` variant returns a sentinel — the extractor
|
|
236
|
+
* resolves it against sibling fields and replaces the sentinel with a
|
|
237
|
+
* `{ type: "expand", table, dependsOn }` entry.
|
|
238
|
+
*
|
|
239
|
+
* Outside `custom`: explicit `type` (file/directory/none) > auto-detected
|
|
240
|
+
* enum values from the schema.
|
|
241
|
+
*/
|
|
242
|
+
function resolveValueCompletion(field) {
|
|
243
|
+
const meta = field.completion;
|
|
244
|
+
if (meta?.custom) {
|
|
245
|
+
const c = meta.custom;
|
|
246
|
+
const definedKeys = [];
|
|
247
|
+
if (c.expand) definedKeys.push("expand");
|
|
248
|
+
if (c.resolve) definedKeys.push("resolve");
|
|
249
|
+
if (c.choices && c.choices.length > 0) definedKeys.push("choices");
|
|
250
|
+
if (c.shellCommand) definedKeys.push("shellCommand");
|
|
251
|
+
if (definedKeys.length > 1) throw new Error(`Field "${field.name ?? "<unknown>"}": completion.custom may only specify one of choices, shellCommand, resolve, expand (got ${definedKeys.join(", ")}).`);
|
|
252
|
+
if (c.expand) return {
|
|
253
|
+
type: "pending-expand",
|
|
254
|
+
spec: c.expand
|
|
255
|
+
};
|
|
256
|
+
if (c.resolve) return {
|
|
257
|
+
type: "dynamic",
|
|
258
|
+
resolve: c.resolve
|
|
259
|
+
};
|
|
260
|
+
if (c.choices && c.choices.length > 0) return {
|
|
261
|
+
type: "choices",
|
|
262
|
+
choices: c.choices
|
|
263
|
+
};
|
|
264
|
+
if (c.shellCommand) return {
|
|
265
|
+
type: "command",
|
|
266
|
+
shellCommand: c.shellCommand
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (meta?.type) {
|
|
270
|
+
if (meta.type === "file") {
|
|
271
|
+
if (meta.matcher) return {
|
|
272
|
+
type: "file",
|
|
273
|
+
matcher: meta.matcher
|
|
274
|
+
};
|
|
275
|
+
if (meta.extensions) return {
|
|
276
|
+
type: "file",
|
|
277
|
+
extensions: meta.extensions
|
|
278
|
+
};
|
|
279
|
+
return { type: "file" };
|
|
280
|
+
}
|
|
281
|
+
if (meta.type === "directory") return { type: "directory" };
|
|
282
|
+
if (meta.type === "none") return { type: "none" };
|
|
283
|
+
}
|
|
284
|
+
if (field.enumValues && field.enumValues.length > 0) return {
|
|
285
|
+
type: "choices",
|
|
286
|
+
choices: field.enumValues
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/completion/dynamic/context-parser.ts
|
|
292
|
+
/**
|
|
293
|
+
* Parse completion context from partial command line
|
|
294
|
+
*/
|
|
295
|
+
/**
|
|
296
|
+
* The dynamic completion path runs `__complete` at TAB time and never sees
|
|
297
|
+
* "expand" fields (those are handled inline by the static shell script).
|
|
298
|
+
* Strip the transient pending sentinel here so the rest of the runtime path
|
|
299
|
+
* can stay strict about handling only resolved `ValueCompletion` values.
|
|
300
|
+
*/
|
|
301
|
+
function stripPendingExpand(vc) {
|
|
302
|
+
return vc?.type === "pending-expand" ? void 0 : vc;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Extract options from a command
|
|
306
|
+
*/
|
|
307
|
+
function extractOptions(command) {
|
|
308
|
+
if (!command.args) return [];
|
|
309
|
+
return extractOptionsFromSchema(command.args);
|
|
310
|
+
}
|
|
311
|
+
function extractOptionsFromSchema(schema) {
|
|
312
|
+
return extractFields(schema).fields.filter((field) => !field.positional).map((field) => {
|
|
313
|
+
const aliases = getAllAliases(field);
|
|
314
|
+
return {
|
|
315
|
+
name: field.name,
|
|
316
|
+
cliName: field.cliName,
|
|
317
|
+
alias: aliases.length > 0 ? aliases : void 0,
|
|
318
|
+
negation: field.negationDisplay,
|
|
319
|
+
negationDescription: field.negationDescription,
|
|
320
|
+
description: field.description,
|
|
321
|
+
takesValue: field.type !== "boolean",
|
|
322
|
+
valueType: field.type,
|
|
323
|
+
required: field.required,
|
|
324
|
+
defaultNegationAccepted: field.type === "boolean" && (field.negation === void 0 || field.negation === true),
|
|
325
|
+
valueCompletion: stripPendingExpand(resolveValueCompletion(field))
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Build the CLI tokens an option is recognised by (`--cliName`,
|
|
331
|
+
* `--long-alias`, `-x`). Wraps `collectOptionTokens` so collision
|
|
332
|
+
* detection sees every spelling the runtime aliasMap accepts — including
|
|
333
|
+
* the camelCase form of hyphenated names, without which a parent-frame
|
|
334
|
+
* local that intercepts `--toBe` for a global `to-be` would silently
|
|
335
|
+
* skip the migration loop.
|
|
336
|
+
*/
|
|
337
|
+
function optionTokenSet(opt) {
|
|
338
|
+
return new Set(collectOptionTokens(opt.cliName, opt.alias));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Find a global option whose CLI tokens overlap with `local`'s tokens.
|
|
342
|
+
* Used at subcommand descent to migrate values the runtime's
|
|
343
|
+
* `scanForSubcommand` would have routed to a global field even though
|
|
344
|
+
* the completion parser stored them under a different-named local.
|
|
345
|
+
*/
|
|
346
|
+
function findGlobalByTokenCollision(globals, local) {
|
|
347
|
+
const localTokens = optionTokenSet(local);
|
|
348
|
+
for (const g of globals) for (const t of optionTokenSet(g)) if (localTokens.has(t)) return g;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Mirror runtime `scanForSubcommand`: walk a pre-subcommand token slice
|
|
352
|
+
* with the global schema only, populating `globalParsedArgs` with the
|
|
353
|
+
* values the runtime would have routed there before the next subcommand
|
|
354
|
+
* boundary. Runs at every subcommand descent — the runtime invokes
|
|
355
|
+
* `scanForSubcommand` recursively at each frame in `parseArgs`, so the
|
|
356
|
+
* pre-sub global pass applies per descent, not just from root.
|
|
357
|
+
*
|
|
358
|
+
* Returns the set of global option names captured during this pre-scan.
|
|
359
|
+
* Used at descent to skip the local→global token migration for entries
|
|
360
|
+
* whose true value is already known here — a value-taking global
|
|
361
|
+
* aliased the same as a parent-local boolean (e.g. global `--profile`
|
|
362
|
+
* /`-p` and local boolean `alias: "p"`) would otherwise have `true`
|
|
363
|
+
* written over the genuine `"prod"`.
|
|
364
|
+
*/
|
|
365
|
+
function parsePreSubGlobals(tokens, globalOptions, globalParsedArgs) {
|
|
366
|
+
const captured = /* @__PURE__ */ new Set();
|
|
367
|
+
if (globalOptions.length === 0) return captured;
|
|
368
|
+
const arrayWrittenInThisSlice = /* @__PURE__ */ new Set();
|
|
369
|
+
const writeGlobal = (opt, value) => {
|
|
370
|
+
writeOptionValue(globalParsedArgs, opt, value, arrayWrittenInThisSlice);
|
|
371
|
+
captured.add(opt.name);
|
|
372
|
+
};
|
|
373
|
+
let i = 0;
|
|
374
|
+
while (i < tokens.length) {
|
|
375
|
+
const word = tokens[i];
|
|
376
|
+
if (word === "--") break;
|
|
377
|
+
if (!word.startsWith("-")) break;
|
|
378
|
+
if (!word.startsWith("--") && word.length > 2) {
|
|
379
|
+
const eqIdx = word.indexOf("=");
|
|
380
|
+
if ((eqIdx >= 0 ? word.slice(1, eqIdx) : word.slice(1)).length > 1) break;
|
|
381
|
+
}
|
|
382
|
+
const parsed = parseOption(word);
|
|
383
|
+
const opt = findOption(globalOptions, parsed);
|
|
384
|
+
if (!opt) break;
|
|
385
|
+
if (opt.takesValue) if (hasInlineValue(word)) {
|
|
386
|
+
const eqIdx = word.indexOf("=");
|
|
387
|
+
writeGlobal(opt, word.slice(eqIdx + 1));
|
|
388
|
+
i++;
|
|
389
|
+
} else if (i + 1 < tokens.length) {
|
|
390
|
+
const next = tokens[i + 1];
|
|
391
|
+
if (next.startsWith("-")) {
|
|
392
|
+
i++;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
writeGlobal(opt, next);
|
|
396
|
+
i += 2;
|
|
397
|
+
} else break;
|
|
398
|
+
else {
|
|
399
|
+
globalParsedArgs[opt.name] = !isNegationOf(opt, parsed);
|
|
400
|
+
captured.add(opt.name);
|
|
401
|
+
i++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return captured;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Reshape a value pulled from local storage so it matches the global's
|
|
408
|
+
* declared shape before landing in `globalParsedArgs`. Without this,
|
|
409
|
+
* migrating a local scalar into an array global would expose the
|
|
410
|
+
* resolver to `parsedArgs.tags === "foo"` instead of `["foo"]` — a
|
|
411
|
+
* state the runtime parser never produces.
|
|
412
|
+
*/
|
|
413
|
+
function adaptValueForGlobal(value, global) {
|
|
414
|
+
if (global.valueType === "array") {
|
|
415
|
+
if (Array.isArray(value)) return value;
|
|
416
|
+
return [value];
|
|
417
|
+
}
|
|
418
|
+
if (Array.isArray(value)) return value.at(-1);
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Append globals to local, preserving local-shadowing by list ORDER rather
|
|
423
|
+
* than by exclusion. Keeping a global in the merged list — even when a
|
|
424
|
+
* local declares the same `cliName` — lets a global's non-colliding tokens
|
|
425
|
+
* (e.g. a `-e` alias the local does not redeclare) still resolve to the
|
|
426
|
+
* global. `findOption` walks the list and returns the first match, so a
|
|
427
|
+
* token the local actually owns still wins.
|
|
428
|
+
*/
|
|
429
|
+
function mergeGlobalOptions(local, globals) {
|
|
430
|
+
if (globals.length === 0) return local;
|
|
431
|
+
return [...local, ...globals];
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Clamp a positional index against `positionals` so a value past the last
|
|
435
|
+
* declared positional resolves to the trailing variadic slot (if any).
|
|
436
|
+
* Returns `undefined` only when `positionals` is empty — callers can then
|
|
437
|
+
* skip the variadic/previousValues path entirely.
|
|
438
|
+
*/
|
|
439
|
+
function clampToVariadic(positionalIndex, positionals) {
|
|
440
|
+
const lastIdx = positionals.length - 1;
|
|
441
|
+
if (lastIdx < 0) return void 0;
|
|
442
|
+
return positionalIndex > lastIdx ? lastIdx : positionalIndex;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Extract positionals from a command
|
|
446
|
+
*/
|
|
447
|
+
function extractPositionalsForContext(command) {
|
|
448
|
+
if (!command.args) return [];
|
|
449
|
+
return extractFields(command.args).fields.filter((field) => field.positional).map((field, index) => ({
|
|
450
|
+
name: field.name,
|
|
451
|
+
cliName: field.cliName,
|
|
452
|
+
position: index,
|
|
453
|
+
description: field.description,
|
|
454
|
+
required: field.required,
|
|
455
|
+
variadic: field.type === "array",
|
|
456
|
+
valueCompletion: stripPendingExpand(resolveValueCompletion(field))
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get subcommand names from a command (including aliases)
|
|
461
|
+
*/
|
|
462
|
+
function getSubcommandNames(command) {
|
|
463
|
+
if (!command.subCommands) return [];
|
|
464
|
+
const names = [];
|
|
465
|
+
for (const [name, subCmd] of Object.entries(command.subCommands)) {
|
|
466
|
+
if (name.startsWith("__")) continue;
|
|
467
|
+
names.push(name);
|
|
468
|
+
const meta = resolveSubCommandMeta(subCmd);
|
|
469
|
+
if (meta?.aliases) names.push(...meta.aliases);
|
|
470
|
+
}
|
|
471
|
+
return names;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Resolve subcommand by name (including alias lookup)
|
|
475
|
+
*/
|
|
476
|
+
function resolveSubcommand(command, name) {
|
|
477
|
+
if (!command.subCommands) return null;
|
|
478
|
+
const sub = command.subCommands[name];
|
|
479
|
+
if (sub) return resolveSubCommandMeta(sub);
|
|
480
|
+
const canonical = resolveSubCommandAlias(command, name);
|
|
481
|
+
if (canonical) return resolveSubCommandMeta(command.subCommands[canonical]);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Check if a word is an option (starts with - or --)
|
|
486
|
+
*/
|
|
487
|
+
function isOption(word) {
|
|
488
|
+
return word.startsWith("-");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Parse option name from word, retaining the form the user typed so the
|
|
492
|
+
* lookup can keep short-form (`-x`) and long-form (`--x`) matches in
|
|
493
|
+
* separate token spaces — runtime negation, cliName, and multi-char
|
|
494
|
+
* aliases only ever appear as long form.
|
|
495
|
+
*/
|
|
496
|
+
function parseOption(word) {
|
|
497
|
+
if (word.startsWith("--")) {
|
|
498
|
+
const withoutPrefix = word.slice(2);
|
|
499
|
+
const eqIndex = withoutPrefix.indexOf("=");
|
|
500
|
+
return {
|
|
501
|
+
name: eqIndex >= 0 ? withoutPrefix.slice(0, eqIndex) : withoutPrefix,
|
|
502
|
+
isLong: true
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (word.startsWith("-")) return {
|
|
506
|
+
name: word.slice(1, 2),
|
|
507
|
+
isLong: false
|
|
508
|
+
};
|
|
509
|
+
return {
|
|
510
|
+
name: word,
|
|
511
|
+
isLong: true
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Check if option has inline value (e.g., "--foo=bar")
|
|
516
|
+
*/
|
|
517
|
+
function hasInlineValue(word) {
|
|
518
|
+
return word.includes("=");
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* For boolean options, the runtime parser accepts the implicit
|
|
522
|
+
* `--no-<cliName>` (and camelCase `--noCliName`) form unless the user
|
|
523
|
+
* opted out via `negation: false` or supplied a custom-string negation
|
|
524
|
+
* (which suppresses the default form). Aliases participate too: a
|
|
525
|
+
* boolean with `alias: "c"` accepts `--no-c` / `--noC` because the
|
|
526
|
+
* runtime resolves the post-`no-` segment through `aliasMap`. Implicit
|
|
527
|
+
* negation is LONG-FORM only — `-no-c` is never an accepted negation —
|
|
528
|
+
* so callers must say so via `isLong` to prevent a short option from
|
|
529
|
+
* being read as a negation.
|
|
530
|
+
*/
|
|
531
|
+
function isImplicitBooleanNegation(opt, name, isLong) {
|
|
532
|
+
if (!isLong) return false;
|
|
533
|
+
if (opt.valueType !== "boolean") return false;
|
|
534
|
+
if (opt.defaultNegationAccepted === false) return false;
|
|
535
|
+
const candidates = [opt.cliName, ...opt.alias ?? []];
|
|
536
|
+
for (const c of candidates) {
|
|
537
|
+
const hyphenated = `no-${c}`;
|
|
538
|
+
if (name === hyphenated) return true;
|
|
539
|
+
if (name === toCamelCase(hyphenated)) return true;
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
/** True when `source` is hyphenated and its camelCase form equals `name`. */
|
|
544
|
+
function matchesCamelCase(source, name) {
|
|
545
|
+
return source !== void 0 && source.includes("-") && toCamelCase(source) === name;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Write `value` into `target[opt.name]` following the runtime's per-frame
|
|
549
|
+
* array semantics: the first write in a frame REPLACES any inherited value
|
|
550
|
+
* (mirroring the runtime's shallow merge of inherited globals), subsequent
|
|
551
|
+
* writes in the same frame APPEND. Scalars overwrite unconditionally.
|
|
552
|
+
* `arraysSeenInFrame` is the frame-scoped seen-set the caller maintains.
|
|
553
|
+
*/
|
|
554
|
+
function writeOptionValue(target, opt, value, arraysSeenInFrame) {
|
|
555
|
+
if (opt.valueType !== "array") {
|
|
556
|
+
target[opt.name] = value;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (arraysSeenInFrame.has(opt.name)) {
|
|
560
|
+
const existing = target[opt.name];
|
|
561
|
+
target[opt.name] = Array.isArray(existing) ? [...existing, value] : [value];
|
|
562
|
+
} else {
|
|
563
|
+
target[opt.name] = [value];
|
|
564
|
+
arraysSeenInFrame.add(opt.name);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* True when the typed token is the boolean option's negation form — either
|
|
569
|
+
* the explicit `negation` name (or its camelCase variant) or the implicit
|
|
570
|
+
* `--no-<name>` form. Long-form only; short tokens are never negations.
|
|
571
|
+
*/
|
|
572
|
+
function isNegationOf(opt, parsed) {
|
|
573
|
+
if (!parsed.isLong) return false;
|
|
574
|
+
if (opt.negation !== void 0 && (opt.negation === parsed.name || matchesCamelCase(opt.negation, parsed.name))) return true;
|
|
575
|
+
return isImplicitBooleanNegation(opt, parsed.name, parsed.isLong);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Match by cliName, alias, camelCase variants, or an explicit negation
|
|
579
|
+
* name. `isLong` separates the short (`-x`) and long (`--xxx`) token
|
|
580
|
+
* spaces: cliNames and explicit negations are only valid as long form,
|
|
581
|
+
* and aliases match their own length class (a 1-char alias only matches
|
|
582
|
+
* short form because its token is `-x`).
|
|
583
|
+
*/
|
|
584
|
+
function matchesExplicit(opt, name, isLong) {
|
|
585
|
+
if (opt.cliName === name && (isLong || opt.cliName.length === 1)) return true;
|
|
586
|
+
if (opt.alias) {
|
|
587
|
+
for (const a of opt.alias) if (a === name && (isLong || a.length === 1)) return true;
|
|
588
|
+
}
|
|
589
|
+
if (isLong && opt.negation === name) return true;
|
|
590
|
+
if (!isLong || name.length <= 1) return false;
|
|
591
|
+
if (matchesCamelCase(opt.cliName, name)) return true;
|
|
592
|
+
if (opt.alias?.some((a) => matchesCamelCase(a, name))) return true;
|
|
593
|
+
return matchesCamelCase(opt.negation, name);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Find option by name or alias. Tried in two passes so that a real field
|
|
597
|
+
* literally named `noFoo` always wins over `--no-foo` being interpreted as
|
|
598
|
+
* the implicit negation of a sibling `foo` field — the runtime parser
|
|
599
|
+
* resolves the explicit field first as well.
|
|
600
|
+
*
|
|
601
|
+
* Short-form precedence mirrors runtime's `separateGlobalArgs`: when a
|
|
602
|
+
* global owns the `-x` alias and the local does NOT explicitly declare
|
|
603
|
+
* `alias: "x"`, the global wins (a bare local `cliName: "x"` does not
|
|
604
|
+
* register `x` in the local aliasMap). Long form keeps the local-first
|
|
605
|
+
* order via the unshadowed merged list.
|
|
606
|
+
*/
|
|
607
|
+
function findOption(options, parsed) {
|
|
608
|
+
if (!parsed.isLong) {
|
|
609
|
+
const localWithExplicitAlias = options.find((opt) => opt.isGlobal !== true && opt.alias?.includes(parsed.name) === true);
|
|
610
|
+
if (localWithExplicitAlias) return localWithExplicitAlias;
|
|
611
|
+
const global = options.find((opt) => opt.isGlobal === true && matchesExplicit(opt, parsed.name, parsed.isLong));
|
|
612
|
+
if (global) return global;
|
|
613
|
+
}
|
|
614
|
+
const explicit = options.find((opt) => matchesExplicit(opt, parsed.name, parsed.isLong));
|
|
615
|
+
if (explicit) return explicit;
|
|
616
|
+
return options.find((opt) => isImplicitBooleanNegation(opt, parsed.name, parsed.isLong));
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Parse completion context from command line arguments
|
|
620
|
+
*
|
|
621
|
+
* @param argv - Arguments after the program name (e.g., ["build", "--fo"])
|
|
622
|
+
* @param rootCommand - The root command
|
|
623
|
+
* @param globalArgsSchema - Optional global args. When provided, options
|
|
624
|
+
* derived from this schema are merged into every command level so dynamic
|
|
625
|
+
* resolvers attached to global options can be reached from any subcommand.
|
|
626
|
+
* @returns Completion context
|
|
627
|
+
*/
|
|
628
|
+
function parseCompletionContext(argv, rootCommand, globalArgsSchema) {
|
|
629
|
+
let currentCommand = rootCommand;
|
|
630
|
+
const subcommandPath = [];
|
|
631
|
+
const globalOptions = globalArgsSchema ? extractOptionsFromSchema(globalArgsSchema).map((o) => ({
|
|
632
|
+
...o,
|
|
633
|
+
isGlobal: true
|
|
634
|
+
})) : [];
|
|
635
|
+
const usedOptions = /* @__PURE__ */ new Set();
|
|
636
|
+
let positionalCount = 0;
|
|
637
|
+
let parsedArgs = {};
|
|
638
|
+
let positionalValues = [];
|
|
639
|
+
const globalParsedArgs = {};
|
|
640
|
+
const globalsCapturedByPreSubScan = /* @__PURE__ */ new Set();
|
|
641
|
+
let frameStartIdx = 0;
|
|
642
|
+
let arraysSetInCurrentFrame = /* @__PURE__ */ new Set();
|
|
643
|
+
/**
|
|
644
|
+
* Mark the option's cliName plus every alias and (if present) negation
|
|
645
|
+
* form as consumed. The negation shares the field's "used" slot so
|
|
646
|
+
* typing either form filters both from subsequent suggestions.
|
|
647
|
+
*/
|
|
648
|
+
const markUsed = (opt) => {
|
|
649
|
+
usedOptions.add(opt.cliName);
|
|
650
|
+
for (const a of opt.alias ?? []) usedOptions.add(a);
|
|
651
|
+
if (opt.negation) usedOptions.add(opt.negation);
|
|
652
|
+
};
|
|
653
|
+
const recordOptionValue = (opt, value) => {
|
|
654
|
+
writeOptionValue(opt.isGlobal === true ? globalParsedArgs : parsedArgs, opt, value, arraysSetInCurrentFrame);
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Record a boolean flag the user typed. The positive form sets `true`;
|
|
658
|
+
* the negation form (`--no-foo` or a custom `negationDisplay`) sets
|
|
659
|
+
* `false`. Dynamic resolvers depend on these values to switch candidates
|
|
660
|
+
* based on flag state, so the absence of a writer here used to hide
|
|
661
|
+
* boolean siblings entirely.
|
|
662
|
+
*/
|
|
663
|
+
const recordBooleanFlag = (opt, parsed) => {
|
|
664
|
+
const target = opt.isGlobal === true ? globalParsedArgs : parsedArgs;
|
|
665
|
+
target[opt.name] = !isNegationOf(opt, parsed);
|
|
666
|
+
};
|
|
667
|
+
let i = 0;
|
|
668
|
+
let options = mergeGlobalOptions(extractOptions(currentCommand), globalOptions);
|
|
669
|
+
let afterDoubleDash = false;
|
|
670
|
+
while (i < argv.length - 1) {
|
|
671
|
+
const word = argv[i];
|
|
672
|
+
if (!afterDoubleDash && word === "--") {
|
|
673
|
+
afterDoubleDash = true;
|
|
674
|
+
i++;
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (!afterDoubleDash && word.startsWith("-") && !word.startsWith("--") && word.length > 2 && !word.includes("=")) {
|
|
678
|
+
const chars = Array.from(word.slice(1));
|
|
679
|
+
const localOptions = options.filter((o) => o.isGlobal !== true);
|
|
680
|
+
for (const c of chars) {
|
|
681
|
+
const o = findOption(localOptions, {
|
|
682
|
+
name: c,
|
|
683
|
+
isLong: false
|
|
684
|
+
});
|
|
685
|
+
if (!o || o.takesValue) continue;
|
|
686
|
+
markUsed(o);
|
|
687
|
+
recordBooleanFlag(o, {
|
|
688
|
+
name: c,
|
|
689
|
+
isLong: false
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
i++;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (!afterDoubleDash && isOption(word)) {
|
|
696
|
+
const parsed = parseOption(word);
|
|
697
|
+
const opt = findOption(options, parsed);
|
|
698
|
+
if (opt) {
|
|
699
|
+
markUsed(opt);
|
|
700
|
+
if (opt.takesValue) {
|
|
701
|
+
if (hasInlineValue(word)) {
|
|
702
|
+
const eqIdx = word.indexOf("=");
|
|
703
|
+
recordOptionValue(opt, word.slice(eqIdx + 1));
|
|
704
|
+
} else if (i + 1 < argv.length - 1) {
|
|
705
|
+
const next = argv[i + 1];
|
|
706
|
+
if (!isOption(next)) {
|
|
707
|
+
recordOptionValue(opt, next);
|
|
708
|
+
i++;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} else recordBooleanFlag(opt, parsed);
|
|
712
|
+
}
|
|
713
|
+
i++;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const subcommand = afterDoubleDash ? null : resolveSubcommand(currentCommand, word);
|
|
717
|
+
if (subcommand) {
|
|
718
|
+
subcommandPath.push(word);
|
|
719
|
+
const parentLocalOptions = extractOptions(currentCommand);
|
|
720
|
+
const preSubSlice = argv.slice(frameStartIdx, i);
|
|
721
|
+
for (const name of parsePreSubGlobals(preSubSlice, globalOptions, globalParsedArgs)) globalsCapturedByPreSubScan.add(name);
|
|
722
|
+
currentCommand = subcommand;
|
|
723
|
+
options = mergeGlobalOptions(extractOptions(currentCommand), globalOptions);
|
|
724
|
+
for (const key of Object.keys(parsedArgs)) {
|
|
725
|
+
const localOpt = parentLocalOptions.find((o) => o.name === key);
|
|
726
|
+
const tokenCollidingGlobal = globalOptions.find((g) => g.name === key) ?? (localOpt ? findGlobalByTokenCollision(globalOptions, localOpt) : void 0);
|
|
727
|
+
if (!tokenCollidingGlobal) continue;
|
|
728
|
+
if (globalsCapturedByPreSubScan.has(tokenCollidingGlobal.name)) continue;
|
|
729
|
+
globalParsedArgs[tokenCollidingGlobal.name] = adaptValueForGlobal(parsedArgs[key], tokenCollidingGlobal);
|
|
730
|
+
}
|
|
731
|
+
usedOptions.clear();
|
|
732
|
+
positionalCount = 0;
|
|
733
|
+
parsedArgs = {};
|
|
734
|
+
positionalValues = [];
|
|
735
|
+
arraysSetInCurrentFrame = /* @__PURE__ */ new Set();
|
|
736
|
+
frameStartIdx = i + 1;
|
|
737
|
+
i++;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
positionalValues.push(word);
|
|
741
|
+
positionalCount++;
|
|
742
|
+
i++;
|
|
743
|
+
}
|
|
744
|
+
const currentWord = argv[argv.length - 1] ?? "";
|
|
745
|
+
const previousWord = argv[argv.length - 2] ?? "";
|
|
746
|
+
const positionals = extractPositionalsForContext(currentCommand);
|
|
747
|
+
const subcommands = getSubcommandNames(currentCommand);
|
|
748
|
+
for (let p = 0; p < positionals.length; p++) {
|
|
749
|
+
const pos = positionals[p];
|
|
750
|
+
if (pos.variadic) {
|
|
751
|
+
parsedArgs[pos.name] = positionalValues.slice(p);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
if (p < positionalValues.length) parsedArgs[pos.name] = positionalValues[p];
|
|
755
|
+
}
|
|
756
|
+
let completionType;
|
|
757
|
+
let targetOption;
|
|
758
|
+
let positionalIndex;
|
|
759
|
+
if (!afterDoubleDash && previousWord && isOption(previousWord) && !hasInlineValue(previousWord)) {
|
|
760
|
+
const opt = findOption(options, parseOption(previousWord));
|
|
761
|
+
if (opt && opt.takesValue) {
|
|
762
|
+
completionType = "option-value";
|
|
763
|
+
targetOption = opt;
|
|
764
|
+
} else if (currentWord.startsWith("-")) completionType = "option-name";
|
|
765
|
+
else {
|
|
766
|
+
completionType = determineDefaultCompletionType(currentWord, subcommands, positionals, positionalCount);
|
|
767
|
+
if (completionType === "positional") positionalIndex = positionalCount;
|
|
768
|
+
}
|
|
769
|
+
} else if (!afterDoubleDash && currentWord.startsWith("-") && hasInlineValue(currentWord)) {
|
|
770
|
+
const opt = findOption(options, parseOption(currentWord));
|
|
771
|
+
if (opt && opt.takesValue) {
|
|
772
|
+
completionType = "option-value";
|
|
773
|
+
targetOption = opt;
|
|
774
|
+
} else completionType = "option-name";
|
|
775
|
+
} else if (!afterDoubleDash && currentWord.startsWith("-")) completionType = "option-name";
|
|
776
|
+
else {
|
|
777
|
+
completionType = determineDefaultCompletionType(currentWord, subcommands, positionals, positionalCount, afterDoubleDash);
|
|
778
|
+
if (completionType === "positional") positionalIndex = positionalCount;
|
|
779
|
+
}
|
|
780
|
+
let previousValues = [];
|
|
781
|
+
if (targetOption) {
|
|
782
|
+
if (targetOption.valueType === "array") {
|
|
783
|
+
const stored = (targetOption.isGlobal === true ? globalParsedArgs : parsedArgs)[targetOption.name];
|
|
784
|
+
previousValues = Array.isArray(stored) ? stored.filter((v) => typeof v === "string") : [];
|
|
785
|
+
}
|
|
786
|
+
} else if (completionType === "positional" && positionalIndex !== void 0) {
|
|
787
|
+
const clampedIdx = clampToVariadic(positionalIndex, positionals);
|
|
788
|
+
if (clampedIdx !== void 0 && positionals[clampedIdx]?.variadic) previousValues = positionalValues.slice(clampedIdx);
|
|
789
|
+
}
|
|
790
|
+
const mergedParsedArgs = {
|
|
791
|
+
...globalParsedArgs,
|
|
792
|
+
...parsedArgs
|
|
793
|
+
};
|
|
794
|
+
return {
|
|
795
|
+
subcommandPath,
|
|
796
|
+
currentCommand,
|
|
797
|
+
currentWord,
|
|
798
|
+
previousWord,
|
|
799
|
+
completionType,
|
|
800
|
+
targetOption,
|
|
801
|
+
positionalIndex,
|
|
802
|
+
options,
|
|
803
|
+
subcommands,
|
|
804
|
+
positionals,
|
|
805
|
+
usedOptions,
|
|
806
|
+
providedPositionalCount: positionalCount,
|
|
807
|
+
parsedArgs: mergedParsedArgs,
|
|
808
|
+
previousValues
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Determine default completion type when not completing an option
|
|
813
|
+
*/
|
|
814
|
+
function determineDefaultCompletionType(currentWord, subcommands, positionals, positionalCount, afterDoubleDash) {
|
|
815
|
+
if (afterDoubleDash) return "positional";
|
|
816
|
+
if (subcommands.length > 0) {
|
|
817
|
+
if (subcommands.filter((s) => s.startsWith(currentWord)).length > 0 || currentWord === "") return "subcommand";
|
|
818
|
+
}
|
|
819
|
+
if (positionalCount < positionals.length) return "positional";
|
|
820
|
+
if (positionals.length > 0 && positionals[positionals.length - 1].variadic) return "positional";
|
|
821
|
+
return "subcommand";
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/completion/dynamic/candidate-generator.ts
|
|
826
|
+
/**
|
|
827
|
+
* Generate completion candidates based on context
|
|
828
|
+
*/
|
|
829
|
+
/**
|
|
830
|
+
* Completion directive flags (bitwise)
|
|
831
|
+
*/
|
|
832
|
+
const CompletionDirective = {
|
|
833
|
+
/** Default completion behavior */
|
|
834
|
+
Default: 0,
|
|
835
|
+
/** Don't add space after completion */
|
|
836
|
+
NoSpace: 1,
|
|
837
|
+
/** Don't offer file completion (even if no other completions) */
|
|
838
|
+
NoFileCompletion: 2,
|
|
839
|
+
/** Filter completions using current word as prefix */
|
|
840
|
+
FilterPrefix: 4,
|
|
841
|
+
/** Keep the order of completions */
|
|
842
|
+
KeepOrder: 8,
|
|
843
|
+
/** Trigger file completion */
|
|
844
|
+
FileCompletion: 16,
|
|
845
|
+
/** Trigger directory completion */
|
|
846
|
+
DirectoryCompletion: 32,
|
|
847
|
+
/** Error occurred during completion */
|
|
848
|
+
Error: 64
|
|
849
|
+
};
|
|
850
|
+
/**
|
|
851
|
+
* Detect an inline `--opt=` prefix on an option-value `currentWord`.
|
|
852
|
+
* Mirrors what the shell scripts already strip via `_inline_prefix`, so
|
|
853
|
+
* resolvers see only the value portion (e.g. `foo` for `--field=foo`).
|
|
854
|
+
* Positional words are excluded: `cli -- --foo=bar` is a legitimate
|
|
855
|
+
* positional value, not an inline option assignment.
|
|
856
|
+
*/
|
|
857
|
+
function detectInlineOptionPrefix(currentWord) {
|
|
858
|
+
if (!currentWord.startsWith("-")) return void 0;
|
|
859
|
+
const eqIdx = currentWord.indexOf("=");
|
|
860
|
+
if (eqIdx <= 0) return void 0;
|
|
861
|
+
return currentWord.slice(0, eqIdx + 1);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Generate completion candidates based on context.
|
|
865
|
+
*
|
|
866
|
+
* Async because dynamic resolvers may return promises. Sync completion
|
|
867
|
+
* sources (choices/file/directory/command/none, subcommand, option name)
|
|
868
|
+
* still resolve synchronously and the await is a no-op for them.
|
|
869
|
+
*
|
|
870
|
+
* Inline option-value prefixes (`--field=foo`) on the option-value path
|
|
871
|
+
* are stripped here so resolvers and post-processing always see the
|
|
872
|
+
* value portion regardless of whether the caller pre-normalized.
|
|
873
|
+
*/
|
|
874
|
+
async function generateCandidates(context, options) {
|
|
875
|
+
switch (context.completionType) {
|
|
876
|
+
case "subcommand": return generateSubcommandCandidates(context);
|
|
877
|
+
case "option-name": return generateOptionNameCandidates(context);
|
|
878
|
+
case "option-value": {
|
|
879
|
+
const opt = context.targetOption;
|
|
880
|
+
const inlinePrefix = opt ? detectInlineOptionPrefix(context.currentWord) : void 0;
|
|
881
|
+
return generateValueCandidates(inlinePrefix ? {
|
|
882
|
+
...context,
|
|
883
|
+
currentWord: context.currentWord.slice(inlinePrefix.length)
|
|
884
|
+
} : context, options, opt?.name, opt?.valueCompletion);
|
|
885
|
+
}
|
|
886
|
+
case "positional": {
|
|
887
|
+
const positional = resolvePositionalTarget(context);
|
|
888
|
+
return generateValueCandidates(context, options, positional?.name, positional?.valueCompletion, positional?.description);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Pick the positional whose `valueCompletion` should drive the current
|
|
894
|
+
* cursor. Clamps to the trailing variadic positional so a value beyond
|
|
895
|
+
* the schema's positional count still resolves to the variadic slot —
|
|
896
|
+
* but only when that slot IS variadic; otherwise a non-variadic last
|
|
897
|
+
* positional must not greedily absorb the extra value.
|
|
898
|
+
*/
|
|
899
|
+
function resolvePositionalTarget(context) {
|
|
900
|
+
const requestedIdx = context.positionalIndex ?? 0;
|
|
901
|
+
const clampedIdx = clampToVariadic(requestedIdx, context.positionals);
|
|
902
|
+
if (clampedIdx === void 0) return void 0;
|
|
903
|
+
const candidate = context.positionals[clampedIdx];
|
|
904
|
+
if (!candidate) return void 0;
|
|
905
|
+
return clampedIdx === requestedIdx || candidate.variadic ? candidate : void 0;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Execute a shell command and return results as candidates
|
|
909
|
+
*/
|
|
910
|
+
function executeShellCommand(command) {
|
|
911
|
+
try {
|
|
912
|
+
return execSync(command, {
|
|
913
|
+
encoding: "utf-8",
|
|
914
|
+
timeout: 5e3
|
|
915
|
+
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line) => ({
|
|
916
|
+
value: line,
|
|
917
|
+
type: "value"
|
|
918
|
+
}));
|
|
919
|
+
} catch {
|
|
920
|
+
return [];
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Two-stage `key=value` post-processing. Returns the transformed candidate
|
|
925
|
+
* list plus whether it contains a bare `key=` entry so the caller can flip
|
|
926
|
+
* NoSpace and let the user keep typing past the first TAB.
|
|
927
|
+
*
|
|
928
|
+
* Key stage (`=` not yet typed): collapse every `key=value` candidate to a
|
|
929
|
+
* unique `key=` entry so the first TAB picks the key.
|
|
930
|
+
*
|
|
931
|
+
* Value stage (`=` typed): drop only the bare `<key>=` candidate that
|
|
932
|
+
* echoes the prefix the user already typed. A blanket `endsWith("=")`
|
|
933
|
+
* filter would also remove legitimate values such as base64 `key=YWJj=` or
|
|
934
|
+
* value-only `YWJj=` (padding), so match the candidate string exactly
|
|
935
|
+
* against the typed key prefix.
|
|
936
|
+
*/
|
|
937
|
+
function applyKeyValuePostProcessing(candidates, currentWord) {
|
|
938
|
+
const keyStage = !currentWord.includes("=");
|
|
939
|
+
const processed = keyStage ? collapseToKeys(candidates) : dropBareKeyEcho(candidates, currentWord);
|
|
940
|
+
return {
|
|
941
|
+
candidates: processed,
|
|
942
|
+
hasEqSuffix: keyStage && processed.some((c) => c.value.endsWith("="))
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function collapseToKeys(candidates) {
|
|
946
|
+
const seen = /* @__PURE__ */ new Set();
|
|
947
|
+
const out = [];
|
|
948
|
+
for (const c of candidates) {
|
|
949
|
+
const eqIdx = c.value.indexOf("=");
|
|
950
|
+
if (eqIdx <= 0) {
|
|
951
|
+
out.push(c);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const keyPart = c.value.slice(0, eqIdx + 1);
|
|
955
|
+
if (seen.has(keyPart)) continue;
|
|
956
|
+
seen.add(keyPart);
|
|
957
|
+
out.push({
|
|
958
|
+
...c,
|
|
959
|
+
value: keyPart
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return out;
|
|
963
|
+
}
|
|
964
|
+
function dropBareKeyEcho(candidates, currentWord) {
|
|
965
|
+
const keyPrefix = currentWord.slice(0, currentWord.indexOf("=") + 1);
|
|
966
|
+
return candidates.filter((c) => c.value !== keyPrefix);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Resolve value completion, executing shell commands and file lookups in JS
|
|
970
|
+
*/
|
|
971
|
+
async function resolveValueCandidates(vc, ctx, description) {
|
|
972
|
+
const candidates = [];
|
|
973
|
+
let directive = CompletionDirective.FilterPrefix;
|
|
974
|
+
let fileExtensions;
|
|
975
|
+
let fileMatchers;
|
|
976
|
+
switch (vc.type) {
|
|
977
|
+
case "choices":
|
|
978
|
+
if (vc.choices) for (const choice of vc.choices) candidates.push({
|
|
979
|
+
value: choice,
|
|
980
|
+
description,
|
|
981
|
+
type: "value"
|
|
982
|
+
});
|
|
983
|
+
directive |= CompletionDirective.NoFileCompletion;
|
|
984
|
+
break;
|
|
985
|
+
case "file":
|
|
986
|
+
if (vc.matcher && vc.matcher.length > 0) {
|
|
987
|
+
fileMatchers = vc.matcher.filter((m) => m.trim().length > 0);
|
|
988
|
+
if (fileMatchers.length === 0) {
|
|
989
|
+
fileMatchers = void 0;
|
|
990
|
+
directive |= CompletionDirective.FileCompletion;
|
|
991
|
+
}
|
|
992
|
+
} else if (vc.extensions && vc.extensions.length > 0) {
|
|
993
|
+
fileExtensions = Array.from(new Set(vc.extensions.map((ext) => ext.trim().replace(/^\./, "")).filter((ext) => ext.length > 0)));
|
|
994
|
+
if (fileExtensions.length === 0) {
|
|
995
|
+
fileExtensions = void 0;
|
|
996
|
+
directive |= CompletionDirective.FileCompletion;
|
|
997
|
+
}
|
|
998
|
+
} else directive |= CompletionDirective.FileCompletion;
|
|
999
|
+
break;
|
|
1000
|
+
case "directory":
|
|
1001
|
+
directive |= CompletionDirective.DirectoryCompletion;
|
|
1002
|
+
break;
|
|
1003
|
+
case "command":
|
|
1004
|
+
if (vc.shellCommand) candidates.push(...executeShellCommand(vc.shellCommand));
|
|
1005
|
+
directive |= CompletionDirective.NoFileCompletion;
|
|
1006
|
+
break;
|
|
1007
|
+
case "none":
|
|
1008
|
+
directive |= CompletionDirective.NoFileCompletion;
|
|
1009
|
+
break;
|
|
1010
|
+
case "dynamic":
|
|
1011
|
+
try {
|
|
1012
|
+
const result = await vc.resolve(ctx);
|
|
1013
|
+
for (const c of result.candidates) {
|
|
1014
|
+
const normalized = typeof c === "string" ? { value: c } : c;
|
|
1015
|
+
candidates.push({
|
|
1016
|
+
...normalized,
|
|
1017
|
+
type: "value"
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
directive = result.directive ?? CompletionDirective.FilterPrefix | CompletionDirective.NoFileCompletion;
|
|
1021
|
+
} catch {
|
|
1022
|
+
directive = CompletionDirective.NoFileCompletion | CompletionDirective.Error;
|
|
1023
|
+
}
|
|
1024
|
+
break;
|
|
1025
|
+
case "expand":
|
|
1026
|
+
directive |= CompletionDirective.NoFileCompletion;
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
if (vc.type === "dynamic" || vc.type === "expand") {
|
|
1030
|
+
const processed = applyKeyValuePostProcessing(candidates, ctx.currentWord);
|
|
1031
|
+
if (processed.hasEqSuffix) directive |= CompletionDirective.NoSpace;
|
|
1032
|
+
return {
|
|
1033
|
+
candidates: processed.candidates,
|
|
1034
|
+
directive,
|
|
1035
|
+
fileExtensions,
|
|
1036
|
+
fileMatchers
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
candidates,
|
|
1041
|
+
directive,
|
|
1042
|
+
fileExtensions,
|
|
1043
|
+
fileMatchers
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Generate subcommand candidates
|
|
1048
|
+
*/
|
|
1049
|
+
function generateSubcommandCandidates(context) {
|
|
1050
|
+
const candidates = [];
|
|
1051
|
+
for (const name of context.subcommands) {
|
|
1052
|
+
const subs = context.currentCommand.subCommands;
|
|
1053
|
+
const direct = subs?.[name];
|
|
1054
|
+
const aliasCanonical = direct ? void 0 : resolveSubCommandAlias(context.currentCommand, name);
|
|
1055
|
+
const resolved = direct ?? (aliasCanonical ? subs?.[aliasCanonical] : void 0);
|
|
1056
|
+
const description = resolved ? resolveSubCommandMeta(resolved)?.description : void 0;
|
|
1057
|
+
candidates.push({
|
|
1058
|
+
value: name,
|
|
1059
|
+
description,
|
|
1060
|
+
type: "subcommand"
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
if (candidates.length === 0 || context.currentWord.startsWith("-")) {
|
|
1064
|
+
const optionResult = generateOptionNameCandidates(context);
|
|
1065
|
+
candidates.push(...optionResult.candidates);
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
candidates,
|
|
1069
|
+
directive: CompletionDirective.FilterPrefix
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Generate option name candidates
|
|
1074
|
+
*/
|
|
1075
|
+
function generateOptionNameCandidates(context) {
|
|
1076
|
+
const candidates = [];
|
|
1077
|
+
const availableOptions = context.options.filter((opt) => {
|
|
1078
|
+
if (opt.valueType === "array") return true;
|
|
1079
|
+
if (context.usedOptions.has(opt.cliName)) return false;
|
|
1080
|
+
if (opt.alias && opt.alias.some((a) => context.usedOptions.has(a))) return false;
|
|
1081
|
+
if (opt.negation && context.usedOptions.has(opt.negation)) return false;
|
|
1082
|
+
return true;
|
|
1083
|
+
});
|
|
1084
|
+
for (const opt of availableOptions) {
|
|
1085
|
+
candidates.push({
|
|
1086
|
+
value: `--${opt.cliName}`,
|
|
1087
|
+
description: opt.description,
|
|
1088
|
+
type: "option"
|
|
1089
|
+
});
|
|
1090
|
+
if (opt.negation) candidates.push({
|
|
1091
|
+
value: `--${opt.negation}`,
|
|
1092
|
+
description: opt.negationDescription ?? opt.description,
|
|
1093
|
+
type: "option"
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (!context.usedOptions.has("help")) candidates.push({
|
|
1097
|
+
value: "--help",
|
|
1098
|
+
description: "Show help information",
|
|
1099
|
+
type: "option"
|
|
1100
|
+
});
|
|
1101
|
+
return {
|
|
1102
|
+
candidates,
|
|
1103
|
+
directive: CompletionDirective.FilterPrefix
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Build the resolver-invocation slice of CompletionContext.
|
|
1108
|
+
* `currentWord` reaches resolvers normalized by `generateCandidates` —
|
|
1109
|
+
* the option-value path strips any inline `--field=` prefix first, so
|
|
1110
|
+
* resolvers and the key=value post-processing only ever see the value
|
|
1111
|
+
* portion. The target field is dropped from `parsedArgs` so resolvers
|
|
1112
|
+
* can treat it as "other args": for repeatable options and variadic
|
|
1113
|
+
* positionals the parser already stages already-typed values under the
|
|
1114
|
+
* same key, and exposing them under both `parsedArgs` and `previousValues`
|
|
1115
|
+
* would let a resolver mistake the in-flight field for a fully-supplied
|
|
1116
|
+
* sibling.
|
|
1117
|
+
*/
|
|
1118
|
+
function resolverContext(context, options, targetFieldName) {
|
|
1119
|
+
return {
|
|
1120
|
+
currentWord: context.currentWord,
|
|
1121
|
+
shell: options.shell,
|
|
1122
|
+
parsedArgs: parsedArgsWithoutTarget(context.parsedArgs, targetFieldName),
|
|
1123
|
+
previousValues: context.previousValues,
|
|
1124
|
+
subcommandPath: context.subcommandPath
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function parsedArgsWithoutTarget(parsedArgs, key) {
|
|
1128
|
+
if (key === void 0 || !(key in parsedArgs)) return parsedArgs;
|
|
1129
|
+
const next = { ...parsedArgs };
|
|
1130
|
+
delete next[key];
|
|
1131
|
+
return next;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Generate value candidates for either an option or a positional. Both paths
|
|
1135
|
+
* resolve the same way once their target field is identified. `description`
|
|
1136
|
+
* is propagated to choices candidates (positional path supplies it; option
|
|
1137
|
+
* path does not, mirroring the prior split implementations).
|
|
1138
|
+
*/
|
|
1139
|
+
async function generateValueCandidates(context, options, targetFieldName, vc, description) {
|
|
1140
|
+
if (!vc) return {
|
|
1141
|
+
candidates: [],
|
|
1142
|
+
directive: CompletionDirective.FilterPrefix
|
|
1143
|
+
};
|
|
1144
|
+
return resolveValueCandidates(vc, resolverContext(context, options, targetFieldName), description);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/completion/expand-resolver.ts
|
|
1149
|
+
/**
|
|
1150
|
+
* Resolve every pending `expand` spec on a subcommand. The static extractor
|
|
1151
|
+
* collects pending targets while building options/positionals (it sees the
|
|
1152
|
+
* sentinel via `resolveValueCompletion`) and passes them here once siblings
|
|
1153
|
+
* are known.
|
|
1154
|
+
*/
|
|
1155
|
+
function resolveExpandTargets(sub, targets, globalOptions = []) {
|
|
1156
|
+
if (targets.length === 0) return;
|
|
1157
|
+
const siblingIndex = buildSiblingIndex(sub, globalOptions);
|
|
1158
|
+
for (const target of targets) target.set(resolveOne(target, siblingIndex));
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Build a name → static-values map for siblings, using each field's already
|
|
1162
|
+
* resolved `valueCompletion`. Only `choices`-typed completions count;
|
|
1163
|
+
* referencing anything else from `dependsOn` is reported as a clean error
|
|
1164
|
+
* in {@link resolveOne}. Global options with static choices are merged in
|
|
1165
|
+
* so a local expand can declare `dependsOn: ["env"]` against a global
|
|
1166
|
+
* \`env\` field — runtime propagates the global value to every frame, so
|
|
1167
|
+
* the resolved table must cover those combinations too.
|
|
1168
|
+
*/
|
|
1169
|
+
function buildSiblingIndex(sub, globalOptions) {
|
|
1170
|
+
const index = /* @__PURE__ */ new Map();
|
|
1171
|
+
const claimedByLocal = /* @__PURE__ */ new Set();
|
|
1172
|
+
for (const field of [...sub.options, ...sub.positionals]) claimedByLocal.add(field.name);
|
|
1173
|
+
const visit = (fields, fromGlobal) => {
|
|
1174
|
+
for (const field of fields) {
|
|
1175
|
+
if (index.has(field.name)) continue;
|
|
1176
|
+
if (fromGlobal && claimedByLocal.has(field.name)) continue;
|
|
1177
|
+
const vc = field.valueCompletion;
|
|
1178
|
+
if (vc?.type === "choices" && vc.choices && vc.choices.length > 0) index.set(field.name, vc.choices);
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
visit([...sub.options, ...sub.positionals], false);
|
|
1182
|
+
visit(globalOptions, true);
|
|
1183
|
+
return index;
|
|
1184
|
+
}
|
|
1185
|
+
function resolveOne(target, siblings) {
|
|
1186
|
+
const { spec } = target;
|
|
1187
|
+
const deps = spec.dependsOn;
|
|
1188
|
+
if (deps.length === 0) throw new Error(`Field "${target.describe}": completion.custom.expand.dependsOn must list at least one sibling arg.`);
|
|
1189
|
+
const valueLists = [];
|
|
1190
|
+
for (const dep of deps) {
|
|
1191
|
+
if (dep === target.name) throw new Error(`Field "${target.describe}": completion.custom.expand.dependsOn cannot reference the field itself ("${dep}").`);
|
|
1192
|
+
const values = siblings.get(dep);
|
|
1193
|
+
if (!values) throw new Error(`Field "${target.describe}": completion.custom.expand.dependsOn references "${dep}", which is not a sibling arg with a static \`choices\`/enum schema on the same command. Chaining expand specs is not supported.`);
|
|
1194
|
+
valueLists.push([...values]);
|
|
1195
|
+
}
|
|
1196
|
+
const table = [];
|
|
1197
|
+
for (const combo of cartesian(valueLists)) {
|
|
1198
|
+
const depsRecord = {};
|
|
1199
|
+
deps.forEach((name, idx) => {
|
|
1200
|
+
depsRecord[name] = combo[idx];
|
|
1201
|
+
});
|
|
1202
|
+
let raw;
|
|
1203
|
+
try {
|
|
1204
|
+
raw = spec.enumerate(depsRecord);
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
1207
|
+
throw new Error(`Field "${target.describe}": completion.custom.expand.enumerate threw for deps=${JSON.stringify(depsRecord)}: ${cause}`);
|
|
1208
|
+
}
|
|
1209
|
+
const candidates = normaliseCandidates(raw);
|
|
1210
|
+
if (candidates.length === 0) continue;
|
|
1211
|
+
table.push({
|
|
1212
|
+
key: combo,
|
|
1213
|
+
candidates
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
type: "expand",
|
|
1218
|
+
dependsOn: deps,
|
|
1219
|
+
table
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function normaliseCandidates(raw) {
|
|
1223
|
+
const out = [];
|
|
1224
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1225
|
+
for (const item of raw) {
|
|
1226
|
+
const value = typeof item === "string" ? item : item.value;
|
|
1227
|
+
if (seen.has(value)) continue;
|
|
1228
|
+
seen.add(value);
|
|
1229
|
+
const entry = { value };
|
|
1230
|
+
if (typeof item !== "string" && item.description !== void 0) entry.description = item.description;
|
|
1231
|
+
out.push(entry);
|
|
1232
|
+
}
|
|
1233
|
+
return out;
|
|
1234
|
+
}
|
|
1235
|
+
function* cartesian(lists) {
|
|
1236
|
+
if (lists.length === 0) {
|
|
1237
|
+
yield [];
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const indices = Array.from({ length: lists.length }).fill(0);
|
|
1241
|
+
while (true) {
|
|
1242
|
+
yield indices.map((i, dim) => lists[dim][i]);
|
|
1243
|
+
let dim = lists.length - 1;
|
|
1244
|
+
while (dim >= 0) {
|
|
1245
|
+
indices[dim]++;
|
|
1246
|
+
if (indices[dim] < lists[dim].length) break;
|
|
1247
|
+
indices[dim] = 0;
|
|
1248
|
+
dim--;
|
|
1249
|
+
}
|
|
1250
|
+
if (dim < 0) return;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/completion/extractor.ts
|
|
1256
|
+
/**
|
|
1257
|
+
* Extract completion data from commands
|
|
1258
|
+
*/
|
|
1259
|
+
/**
|
|
1260
|
+
* Resolve and assign value completion to a field. Pending expand sentinels
|
|
1261
|
+
* are stashed in `pending`; the eventual `resolveExpandTargets` pass replaces
|
|
1262
|
+
* the sentinel with a fully-resolved `{ type: "expand", ... }` via `set`.
|
|
1263
|
+
*/
|
|
1264
|
+
function assignValueCompletion(field, pending, describe, set) {
|
|
1265
|
+
const raw = resolveValueCompletion(field);
|
|
1266
|
+
if (raw?.type === "pending-expand") {
|
|
1267
|
+
pending.push({
|
|
1268
|
+
name: field.name,
|
|
1269
|
+
describe,
|
|
1270
|
+
set,
|
|
1271
|
+
spec: raw.spec
|
|
1272
|
+
});
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (raw !== void 0) set(raw);
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Sanitize a name for use as a shell function/variable identifier.
|
|
1279
|
+
* Replaces any character that is not alphanumeric or underscore with underscore.
|
|
1280
|
+
*
|
|
1281
|
+
* Note: This is not injective -- distinct names may produce the same output
|
|
1282
|
+
* (e.g., "foo-bar" and "foo_bar" both become "foo_bar"). When used for nested
|
|
1283
|
+
* path encoding (`path.map(sanitize).join("_")`), cross-level collisions are
|
|
1284
|
+
* theoretically possible (e.g., "foo-bar:baz" vs "foo:bar-baz") but extremely
|
|
1285
|
+
* unlikely in real CLI designs. If collision-safety is needed, sanitize must be
|
|
1286
|
+
* replaced with an injective encoding.
|
|
1287
|
+
*/
|
|
1288
|
+
function sanitize(name) {
|
|
1289
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Build the override env-var name shells inspect to pick a different
|
|
1293
|
+
* binary (`<NAME>_BIN`). Shell parameter names cannot begin with a
|
|
1294
|
+
* digit, so prepend an underscore when the upper-cased function name
|
|
1295
|
+
* starts with one — e.g. `2fa` ⇒ `_2FA_BIN`.
|
|
1296
|
+
*/
|
|
1297
|
+
function binEnvVarName(fn) {
|
|
1298
|
+
const upper = fn.toUpperCase();
|
|
1299
|
+
return /^[A-Z_]/.test(upper) ? `${upper}_BIN` : `_${upper}_BIN`;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Hoisted expand-table variable name for a (funcSuffix, fieldName) at frame
|
|
1303
|
+
* scope. Bash uses indirect expansion `${!var}` on per-entry scalars, zsh
|
|
1304
|
+
* uses associative-array subscripts on this same base — the identifier
|
|
1305
|
+
* shape is identical, so both generators share this helper.
|
|
1306
|
+
*/
|
|
1307
|
+
function expandTableVarName(fn, funcSuffix, fieldName) {
|
|
1308
|
+
return `__${fn}_expand_${funcSuffix}__${sanitize(fieldName)}`;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Body of the `__<fn>_invoke_complete` wrapper that bash and zsh emit when
|
|
1312
|
+
* any field uses an in-process JS resolver. Bytes are identical between the
|
|
1313
|
+
* two shells — both use `local`, `shift`, and the `<NAME>_BIN`-override
|
|
1314
|
+
* lookup — so the helper lives here. Fish has a different `function`
|
|
1315
|
+
* syntax (no `local`, `set -l` instead) and emits its own version inline.
|
|
1316
|
+
*/
|
|
1317
|
+
function dynamicInvokeCompleteLines(fn, programName) {
|
|
1318
|
+
return [
|
|
1319
|
+
`__${fn}_invoke_complete() {`,
|
|
1320
|
+
` local _shell="$1"; shift`,
|
|
1321
|
+
` "\${${binEnvVarName(fn)}:-${programName}}" __complete --shell "$_shell" -- "$@" 2>/dev/null`,
|
|
1322
|
+
`}`
|
|
1323
|
+
];
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Filter subcommands to only visible (non-internal) ones.
|
|
1327
|
+
* Internal subcommands start with "__" and are hidden from completion/help.
|
|
1328
|
+
*/
|
|
1329
|
+
function getVisibleSubs(subs) {
|
|
1330
|
+
return subs.filter((s) => !s.name.startsWith("__"));
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Get all completable subcommand names including aliases.
|
|
1334
|
+
* Returns an array of { name, description } for all visible subcommands
|
|
1335
|
+
* and their aliases.
|
|
1336
|
+
*/
|
|
1337
|
+
function getSubNamesWithAliases(subs) {
|
|
1338
|
+
const result = [];
|
|
1339
|
+
for (const sub of getVisibleSubs(subs)) {
|
|
1340
|
+
result.push({
|
|
1341
|
+
name: sub.name,
|
|
1342
|
+
description: sub.description
|
|
1343
|
+
});
|
|
1344
|
+
if (sub.aliases) for (const alias of sub.aliases) result.push({
|
|
1345
|
+
name: alias,
|
|
1346
|
+
description: sub.description
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
return result;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Convert a resolved field to a completable option. Pending expand specs
|
|
1353
|
+
* are stashed in `pending` and patched onto the returned object after
|
|
1354
|
+
* sibling choices are known.
|
|
1355
|
+
*/
|
|
1356
|
+
function fieldToOption(field, pending) {
|
|
1357
|
+
const defaultNegationAccepted = field.type === "boolean" && (field.negation === void 0 || field.negation === true);
|
|
1358
|
+
const opt = {
|
|
1359
|
+
name: field.name,
|
|
1360
|
+
cliName: field.cliName,
|
|
1361
|
+
alias: field.alias,
|
|
1362
|
+
negation: field.negationDisplay,
|
|
1363
|
+
negationDescription: field.negationDescription,
|
|
1364
|
+
description: field.description,
|
|
1365
|
+
takesValue: field.type !== "boolean",
|
|
1366
|
+
valueType: field.type,
|
|
1367
|
+
required: field.required,
|
|
1368
|
+
defaultNegationAccepted
|
|
1369
|
+
};
|
|
1370
|
+
assignValueCompletion(field, pending, `--${field.cliName}`, (next) => {
|
|
1371
|
+
opt.valueCompletion = next;
|
|
1372
|
+
});
|
|
1373
|
+
return opt;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Extract positional arguments from a command
|
|
1377
|
+
*/
|
|
1378
|
+
function extractPositionals(command) {
|
|
1379
|
+
if (!command.args) return [];
|
|
1380
|
+
return extractFields(command.args).fields.filter((field) => field.positional);
|
|
1381
|
+
}
|
|
1382
|
+
/** Convert pre-extracted fields to options. */
|
|
1383
|
+
function fieldsToOptions(fields, pending) {
|
|
1384
|
+
return fields.filter((field) => !field.positional).map((field) => fieldToOption(field, pending));
|
|
1385
|
+
}
|
|
1386
|
+
/** Convert pre-extracted fields to positionals. */
|
|
1387
|
+
function fieldsToPositionals(fields, pending) {
|
|
1388
|
+
return fields.filter((field) => field.positional).map((field, index) => {
|
|
1389
|
+
const pos = {
|
|
1390
|
+
name: field.name,
|
|
1391
|
+
cliName: field.cliName,
|
|
1392
|
+
position: index,
|
|
1393
|
+
description: field.description,
|
|
1394
|
+
required: field.required,
|
|
1395
|
+
variadic: field.type === "array"
|
|
1396
|
+
};
|
|
1397
|
+
assignValueCompletion(field, pending, `<${field.cliName}>`, (next) => {
|
|
1398
|
+
pos.valueCompletion = next;
|
|
1399
|
+
});
|
|
1400
|
+
return pos;
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Extract a completable subcommand from a command
|
|
1405
|
+
*/
|
|
1406
|
+
function extractSubcommand(name, command, globalOptions = []) {
|
|
1407
|
+
const subcommands = [];
|
|
1408
|
+
if (command.subCommands) for (const [subName, subCommand] of Object.entries(command.subCommands)) {
|
|
1409
|
+
const resolved = resolveSubCommandMeta(subCommand);
|
|
1410
|
+
if (resolved) subcommands.push(extractSubcommand(subName, resolved, globalOptions));
|
|
1411
|
+
else subcommands.push({
|
|
1412
|
+
name: subName,
|
|
1413
|
+
description: "(lazy loaded)",
|
|
1414
|
+
subcommands: [],
|
|
1415
|
+
options: [],
|
|
1416
|
+
positionals: []
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
const pending = [];
|
|
1420
|
+
const fields = command.args ? extractFields(command.args).fields : [];
|
|
1421
|
+
const node = {
|
|
1422
|
+
name,
|
|
1423
|
+
description: command.description,
|
|
1424
|
+
aliases: command.aliases,
|
|
1425
|
+
subcommands,
|
|
1426
|
+
options: fieldsToOptions(fields, pending),
|
|
1427
|
+
positionals: fieldsToPositionals(fields, pending)
|
|
1428
|
+
};
|
|
1429
|
+
resolveExpandTargets(node, pending, globalOptions);
|
|
1430
|
+
return node;
|
|
1431
|
+
}
|
|
1432
|
+
/** Join parent and child with a separator, omitting separator when parent is empty. */
|
|
1433
|
+
function joinPrefix(parent, child, sep) {
|
|
1434
|
+
return parent ? `${parent}${sep}${child}` : child;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Expand each parent pathStr by joining every child name (canonical plus
|
|
1438
|
+
* aliases) with `:`. Used to keep alias-expanded path variants in lockstep
|
|
1439
|
+
* across walkers that need to reach the same node from any path the
|
|
1440
|
+
* runtime scanner can produce.
|
|
1441
|
+
*/
|
|
1442
|
+
function expandChildPathStrs(pathStrs, child) {
|
|
1443
|
+
const childNames = [child.name, ...child.aliases ?? []];
|
|
1444
|
+
return pathStrs.flatMap((p) => childNames.map((n) => joinPrefix(p, n, ":")));
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Walk the subcommand tree and yield a row per value-taking option, with
|
|
1448
|
+
* the path-aware token set the runtime would actually route here. Used by
|
|
1449
|
+
* all three shell generators — bash/zsh format these as `|`-joined case
|
|
1450
|
+
* patterns, fish wraps each pattern in a quoted glob.
|
|
1451
|
+
*
|
|
1452
|
+
* At ANCESTOR frames (those with further subcommand children) the runtime's
|
|
1453
|
+
* `scanForSubcommand` consults globals only — local-precedence does NOT
|
|
1454
|
+
* apply, so a global value option keeps every alias even when a local at
|
|
1455
|
+
* the frame claims the same short token. At LEAF frames, the leaf parser's
|
|
1456
|
+
* `separateGlobalArgs` applies local-precedence so `effectiveOptionTokens`
|
|
1457
|
+
* is the correct filter.
|
|
1458
|
+
*/
|
|
1459
|
+
function walkOptTakesValueRows(sub, parentPath) {
|
|
1460
|
+
const rows = [];
|
|
1461
|
+
const isAncestor = getVisibleSubs(sub.subcommands).length > 0;
|
|
1462
|
+
for (const opt of sub.options) {
|
|
1463
|
+
if (!opt.takesValue) continue;
|
|
1464
|
+
const tokens = isAncestor && opt.isGlobal === true ? collectOptionTokens(opt.cliName, opt.alias) : effectiveOptionTokens(opt, sub.options);
|
|
1465
|
+
if (tokens.length === 0) continue;
|
|
1466
|
+
rows.push({
|
|
1467
|
+
parentPath,
|
|
1468
|
+
tokens
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
for (const child of getVisibleSubs(sub.subcommands)) for (const name of [child.name, ...child.aliases ?? []]) rows.push(...walkOptTakesValueRows(child, joinPrefix(parentPath, name, ":")));
|
|
1472
|
+
return rows;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Collect opt-takes-value case entries for a subcommand tree.
|
|
1476
|
+
* Used by bash and zsh generators (identical case syntax: `path:--opt) return 0 ;;`).
|
|
1477
|
+
* parentPath is a colon-delimited path (e.g., "" for root, "workspace:user" for nested).
|
|
1478
|
+
*/
|
|
1479
|
+
function optTakesValueEntries(sub, parentPath) {
|
|
1480
|
+
return walkOptTakesValueRows(sub, parentPath).map((row) => {
|
|
1481
|
+
return ` ${row.tokens.map((t) => `${row.parentPath}:${t}`).join("|")}) return 0 ;;`;
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Recursively collect all subcommand route entries.
|
|
1486
|
+
* Returns entries used by all shell generators for both dispatch routing
|
|
1487
|
+
* and subcommand lookup (is_subcmd) tables.
|
|
1488
|
+
* Aliases are mapped to the same handler as the canonical name.
|
|
1489
|
+
*/
|
|
1490
|
+
function collectRouteEntries(sub, parentPath = "", parentFunc = "") {
|
|
1491
|
+
const entries = [];
|
|
1492
|
+
for (const child of getVisibleSubs(sub.subcommands)) {
|
|
1493
|
+
const funcSuffix = joinPrefix(parentFunc, sanitize(child.name), "_");
|
|
1494
|
+
for (const name of [child.name, ...child.aliases ?? []]) {
|
|
1495
|
+
const pathStr = joinPrefix(parentPath, name, ":");
|
|
1496
|
+
entries.push(...collectRouteEntries(child, pathStr, funcSuffix));
|
|
1497
|
+
entries.push({
|
|
1498
|
+
pathStr,
|
|
1499
|
+
funcSuffix,
|
|
1500
|
+
lookupPattern: `${parentPath}:${name}`
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return entries;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Generate is_subcmd case/switch body lines (bash/zsh case syntax).
|
|
1508
|
+
* Returns lines for the case statement body only (caller wraps in function).
|
|
1509
|
+
*/
|
|
1510
|
+
function isSubcmdCaseLines(routeEntries) {
|
|
1511
|
+
return routeEntries.map((r) => ` ${r.lookupPattern}) return 0 ;;`);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Subcommand-dispatch case body lines for bash/zsh: each route forwards
|
|
1515
|
+
* `$_subcmd` to its handler function. Identical emission between shells.
|
|
1516
|
+
*/
|
|
1517
|
+
function subDispatchCaseLines(routeEntries, fn) {
|
|
1518
|
+
return routeEntries.map((r) => ` ${r.pathStr}) __${fn}_complete_${r.funcSuffix} ;;`);
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Per-shell `_arg_values` write expression. zsh uses an associative-array
|
|
1522
|
+
* subscript; bash uses prefix-scalar variables so the generated script
|
|
1523
|
+
* runs on bash 3.2 (macOS default `/bin/bash`), which lacks associative
|
|
1524
|
+
* arrays. The `isGlobal` flag picks the bucket (`_global_arg_values_*`
|
|
1525
|
+
* survives subcommand descent; `_arg_values_*` does not).
|
|
1526
|
+
*/
|
|
1527
|
+
function trackedFieldAssign(t, shell) {
|
|
1528
|
+
const prefix = t.isGlobal ? `_global_arg_values` : `_arg_values`;
|
|
1529
|
+
return shell === "bash" ? `${prefix}_${sanitize(t.fieldName)}="$3"` : `${prefix}[${t.fieldName}]="$3"`;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Case-statement body lines for `__track_opt` — capture option values into
|
|
1533
|
+
* the per-frame state. See {@link trackedFieldAssign} for the per-shell
|
|
1534
|
+
* assignment shape.
|
|
1535
|
+
*/
|
|
1536
|
+
function trackOptCaseLines(trackedFields, shell) {
|
|
1537
|
+
const lines = [];
|
|
1538
|
+
for (const t of trackedFields) {
|
|
1539
|
+
if (t.isPositional || !t.optionTokens || t.optionTokens.length === 0) continue;
|
|
1540
|
+
const joined = t.pathStrs.flatMap((p) => t.optionTokens.map((n) => `${p}:${n}`)).join("|");
|
|
1541
|
+
lines.push(` ${joined}) ${trackedFieldAssign(t, shell)} ;;`);
|
|
1542
|
+
}
|
|
1543
|
+
return lines;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Case-statement body lines for `__track_pos` — capture positional values
|
|
1547
|
+
* by `(subcmd, positional-index)`. See {@link trackedFieldAssign} for the
|
|
1548
|
+
* per-shell assignment shape.
|
|
1549
|
+
*/
|
|
1550
|
+
function trackPosCaseLines(trackedFields, shell) {
|
|
1551
|
+
const lines = [];
|
|
1552
|
+
for (const t of trackedFields) {
|
|
1553
|
+
if (!t.isPositional) continue;
|
|
1554
|
+
const joined = t.pathStrs.map((p) => `${p}:${t.position}`).join("|");
|
|
1555
|
+
lines.push(` ${joined}) ${trackedFieldAssign(t, shell)} ;;`);
|
|
1556
|
+
}
|
|
1557
|
+
return lines;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Case-statement body lines for `__track_array_expand` — record each `key=`
|
|
1561
|
+
* slot the user has typed so the candidate loop can skip already-consumed
|
|
1562
|
+
* entries. The first write to a global array in a frame replaces the
|
|
1563
|
+
* inherited bucket (mirroring the runtime's per-frame array merge);
|
|
1564
|
+
* subsequent writes append. zsh uses associative arrays; bash uses
|
|
1565
|
+
* prefix-scalar variables (see {@link trackOptCaseLines}).
|
|
1566
|
+
*/
|
|
1567
|
+
function trackArrayExpandCaseLines(arrayExpandSpecs, shell) {
|
|
1568
|
+
const lines = [];
|
|
1569
|
+
for (const spec of arrayExpandSpecs) {
|
|
1570
|
+
if (spec.optionTokens.length === 0) continue;
|
|
1571
|
+
const joined = spec.pathStrs.flatMap((p) => spec.optionTokens.map((tok) => `${p}:${tok}`)).join("|");
|
|
1572
|
+
const bucket = sanitize(spec.fieldName);
|
|
1573
|
+
const bucketPrefix = spec.isGlobal ? `_global_used_field_keys` : `_used_field_keys`;
|
|
1574
|
+
const bucketRef = shell === "bash" ? `${bucketPrefix}_${bucket}` : `${bucketPrefix}[${bucket}]`;
|
|
1575
|
+
const seenRef = shell === "bash" ? `_global_arr_seen_${bucket}` : `_global_arr_seen[${bucket}]`;
|
|
1576
|
+
const assignFirst = `${bucketRef}=" $_k "`;
|
|
1577
|
+
const assignAppend = `${bucketRef}+=" $_k "`;
|
|
1578
|
+
const seenSet = `${seenRef}=1`;
|
|
1579
|
+
lines.push(` ${joined})`);
|
|
1580
|
+
lines.push(` if [[ "$3" == *=* ]]; then`);
|
|
1581
|
+
lines.push(` local _k="\${3%%=*}"`);
|
|
1582
|
+
if (spec.isGlobal) {
|
|
1583
|
+
lines.push(` if [[ -n "$_k" ]]; then`);
|
|
1584
|
+
lines.push(` if [[ -z "\${${seenRef}:-}" ]]; then`);
|
|
1585
|
+
lines.push(` ${assignFirst}`);
|
|
1586
|
+
lines.push(` ${seenSet}`);
|
|
1587
|
+
lines.push(` else`);
|
|
1588
|
+
lines.push(` ${assignAppend}`);
|
|
1589
|
+
lines.push(` fi`);
|
|
1590
|
+
lines.push(` fi`);
|
|
1591
|
+
} else lines.push(` [[ -n "$_k" ]] && ${assignAppend}`);
|
|
1592
|
+
lines.push(` fi`);
|
|
1593
|
+
lines.push(` ;;`);
|
|
1594
|
+
}
|
|
1595
|
+
return lines;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Compute the option token set the runtime would actually route to
|
|
1599
|
+
* `opt` at the given frame. Globals shadow LOCAL short tokens of the
|
|
1600
|
+
* same letter (runtime's `separateGlobalArgs` harvests `-x` for the
|
|
1601
|
+
* global unless the local explicitly declares `alias: "x"`), so a
|
|
1602
|
+
* local cliName `x` with no alias must NOT emit `-x` in its
|
|
1603
|
+
* tracker / value-completion / takes-value cases when a global at the
|
|
1604
|
+
* frame owns that token. Long forms are unaffected — precedence is
|
|
1605
|
+
* scoped to short aliases.
|
|
1606
|
+
*/
|
|
1607
|
+
function effectiveOptionTokens(opt, frameOptions) {
|
|
1608
|
+
const all = collectOptionTokens(opt.cliName, opt.alias);
|
|
1609
|
+
if (opt.isGlobal === true) {
|
|
1610
|
+
const localClaimed = /* @__PURE__ */ new Set();
|
|
1611
|
+
for (const o of frameOptions) {
|
|
1612
|
+
if (o.isGlobal === true) continue;
|
|
1613
|
+
for (const t of localShadowingTokens(o.cliName, o.alias)) localClaimed.add(t);
|
|
1614
|
+
}
|
|
1615
|
+
return all.filter((t) => !localClaimed.has(t));
|
|
1616
|
+
}
|
|
1617
|
+
const globalShort = globalShortTokens(frameOptions);
|
|
1618
|
+
if (globalShort.size === 0) return all;
|
|
1619
|
+
return all.filter((t) => {
|
|
1620
|
+
if (!t.startsWith("-") || t.startsWith("--")) return true;
|
|
1621
|
+
if (!globalShort.has(t)) return true;
|
|
1622
|
+
return opt.alias?.includes(t.slice(1)) === true;
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Walk the subcommand tree and return every resolved expand spec along
|
|
1627
|
+
* with where it lives. The order is deterministic (DFS, root → leaves;
|
|
1628
|
+
* options before positionals within a node).
|
|
1629
|
+
*/
|
|
1630
|
+
function collectExpandSpecs(root) {
|
|
1631
|
+
const out = [];
|
|
1632
|
+
walk(root, [], [""], [], "root", out);
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
function walk(node, path, pathStrs, intermediatePathStrs, funcSuffix, out) {
|
|
1636
|
+
const pathStr = pathStrs[0];
|
|
1637
|
+
for (const opt of node.options) {
|
|
1638
|
+
const vc = opt.valueCompletion;
|
|
1639
|
+
if (vc?.type === "expand") {
|
|
1640
|
+
const isArrayOption = opt.valueType === "array";
|
|
1641
|
+
out.push({
|
|
1642
|
+
path,
|
|
1643
|
+
pathStr,
|
|
1644
|
+
pathStrs,
|
|
1645
|
+
intermediatePathStrs,
|
|
1646
|
+
funcSuffix,
|
|
1647
|
+
fieldName: opt.name,
|
|
1648
|
+
isGlobal: opt.isGlobal === true,
|
|
1649
|
+
isPositional: false,
|
|
1650
|
+
isArrayOption,
|
|
1651
|
+
optionTokens: isArrayOption ? effectiveOptionTokens(opt, node.options) : [],
|
|
1652
|
+
vc
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
for (const pos of node.positionals) {
|
|
1657
|
+
const vc = pos.valueCompletion;
|
|
1658
|
+
if (vc?.type === "expand") out.push({
|
|
1659
|
+
path,
|
|
1660
|
+
pathStr,
|
|
1661
|
+
pathStrs,
|
|
1662
|
+
intermediatePathStrs,
|
|
1663
|
+
funcSuffix,
|
|
1664
|
+
fieldName: pos.name,
|
|
1665
|
+
isGlobal: false,
|
|
1666
|
+
isPositional: true,
|
|
1667
|
+
isArrayOption: false,
|
|
1668
|
+
optionTokens: [],
|
|
1669
|
+
vc
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
for (const child of getVisibleSubs(node.subcommands)) walk(child, [...path, child.name], expandChildPathStrs(pathStrs, child), [...intermediatePathStrs, ...pathStrs], funcSuffix === "root" ? sanitize(child.name) : `${funcSuffix}_${sanitize(child.name)}`, out);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* For every expand spec, find each `dependsOn` sibling field at the same
|
|
1676
|
+
* path and return enough metadata for the shell generator to emit tracker
|
|
1677
|
+
* cases. Fields that cannot be resolved are silently skipped — the static
|
|
1678
|
+
* extractor already validated `dependsOn` upstream in
|
|
1679
|
+
* `resolveExpandTargets`, so unresolved siblings here would indicate a
|
|
1680
|
+
* programming error and would simply yield no tracker entries.
|
|
1681
|
+
*/
|
|
1682
|
+
function collectTrackedFields(root, specs, globalOptions = []) {
|
|
1683
|
+
const nodeByPath = indexNodesByPath(root);
|
|
1684
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1685
|
+
const addPath = (key, ref, paths) => {
|
|
1686
|
+
let bucket = buckets.get(key);
|
|
1687
|
+
if (!bucket) {
|
|
1688
|
+
bucket = {
|
|
1689
|
+
ref,
|
|
1690
|
+
pathStrs: [],
|
|
1691
|
+
seen: /* @__PURE__ */ new Set()
|
|
1692
|
+
};
|
|
1693
|
+
buckets.set(key, bucket);
|
|
1694
|
+
}
|
|
1695
|
+
for (const p of paths) {
|
|
1696
|
+
if (bucket.seen.has(p)) continue;
|
|
1697
|
+
bucket.seen.add(p);
|
|
1698
|
+
bucket.pathStrs.push(p);
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
/**
|
|
1702
|
+
* Group spec frames by the per-leaf surviving-token set for a global option.
|
|
1703
|
+
* The runtime scanner at a LEAF frame drops global tokens that a local
|
|
1704
|
+
* option already claims (via `localShadowingTokens`); at ANCESTOR frames it
|
|
1705
|
+
* sees only globals so every token survives. Grouping by token-set keeps the
|
|
1706
|
+
* generated tracker case body minimal — one branch per unique frame group.
|
|
1707
|
+
*/
|
|
1708
|
+
const groupGlobalFramesByTokenSet = (optTokens, leafPaths, ancestorPaths) => {
|
|
1709
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1710
|
+
const recordLeaf = (path, tokens) => {
|
|
1711
|
+
if (tokens.length === 0) return;
|
|
1712
|
+
const tokenKey = tokens.join(" ");
|
|
1713
|
+
let group = groups.get(tokenKey);
|
|
1714
|
+
if (!group) {
|
|
1715
|
+
group = {
|
|
1716
|
+
tokens,
|
|
1717
|
+
paths: []
|
|
1718
|
+
};
|
|
1719
|
+
groups.set(tokenKey, group);
|
|
1720
|
+
}
|
|
1721
|
+
group.paths.push(path);
|
|
1722
|
+
};
|
|
1723
|
+
for (const p of leafPaths) {
|
|
1724
|
+
const n = nodeByPath.get(p);
|
|
1725
|
+
if (!n) continue;
|
|
1726
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1727
|
+
for (const o of n.options) {
|
|
1728
|
+
if (o.isGlobal === true) continue;
|
|
1729
|
+
for (const t of localShadowingTokens(o.cliName, o.alias)) claimed.add(t);
|
|
1730
|
+
}
|
|
1731
|
+
recordLeaf(p, optTokens.filter((t) => !claimed.has(t)));
|
|
1732
|
+
}
|
|
1733
|
+
for (const p of ancestorPaths) recordLeaf(p, optTokens);
|
|
1734
|
+
return groups;
|
|
1735
|
+
};
|
|
1736
|
+
const addGlobalDepTracker = (dep, opt, spec) => {
|
|
1737
|
+
const groups = groupGlobalFramesByTokenSet(collectOptionTokens(opt.cliName, opt.alias), spec.pathStrs, spec.intermediatePathStrs);
|
|
1738
|
+
for (const [tokenKey, group] of groups) addPath(`g::${dep}::${tokenKey}`, {
|
|
1739
|
+
fieldName: dep,
|
|
1740
|
+
isGlobal: true,
|
|
1741
|
+
isPositional: false,
|
|
1742
|
+
optionTokens: group.tokens
|
|
1743
|
+
}, group.paths);
|
|
1744
|
+
};
|
|
1745
|
+
for (const spec of specs) {
|
|
1746
|
+
const node = nodeByPath.get(spec.pathStr);
|
|
1747
|
+
if (!node) continue;
|
|
1748
|
+
for (const dep of spec.vc.dependsOn) {
|
|
1749
|
+
if (spec.isGlobal) {
|
|
1750
|
+
const globalOpt = globalOptions.find((o) => o.name === dep);
|
|
1751
|
+
if (!globalOpt) continue;
|
|
1752
|
+
addGlobalDepTracker(dep, globalOpt, spec);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
const posIndex = node.positionals.findIndex((p) => p.name === dep);
|
|
1756
|
+
if (posIndex >= 0) {
|
|
1757
|
+
addPath(`lp::${spec.pathStr}::${dep}`, {
|
|
1758
|
+
fieldName: dep,
|
|
1759
|
+
isGlobal: false,
|
|
1760
|
+
isPositional: true,
|
|
1761
|
+
position: posIndex
|
|
1762
|
+
}, spec.pathStrs);
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
const opt = node.options.find((o) => o.name === dep);
|
|
1766
|
+
if (!opt) continue;
|
|
1767
|
+
if (opt.isGlobal === true) {
|
|
1768
|
+
addGlobalDepTracker(dep, opt, spec);
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
addPath(`lo::${spec.pathStr}::${dep}`, {
|
|
1772
|
+
fieldName: dep,
|
|
1773
|
+
isGlobal: false,
|
|
1774
|
+
isPositional: false,
|
|
1775
|
+
optionTokens: effectiveOptionTokens(opt, node.options)
|
|
1776
|
+
}, spec.pathStrs);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
const out = [];
|
|
1780
|
+
for (const bucket of buckets.values()) {
|
|
1781
|
+
if (bucket.pathStrs.length === 0) continue;
|
|
1782
|
+
out.push({
|
|
1783
|
+
...bucket.ref,
|
|
1784
|
+
pathStr: bucket.pathStrs[0],
|
|
1785
|
+
pathStrs: bucket.pathStrs
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
return out;
|
|
1789
|
+
}
|
|
1790
|
+
function indexNodesByPath(root) {
|
|
1791
|
+
const map = /* @__PURE__ */ new Map();
|
|
1792
|
+
const recurse = (node, pathStrs) => {
|
|
1793
|
+
for (const p of pathStrs) map.set(p, node);
|
|
1794
|
+
for (const child of getVisibleSubs(node.subcommands)) recurse(child, expandChildPathStrs(pathStrs, child));
|
|
1795
|
+
};
|
|
1796
|
+
recurse(root, [""]);
|
|
1797
|
+
return map;
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Walk a CompletableSubcommand tree and return true when any option or
|
|
1801
|
+
* positional uses an in-process dynamic resolver. Used by shell generators
|
|
1802
|
+
* to decide whether to emit `__<fn>_invoke_complete` delegate helpers.
|
|
1803
|
+
*/
|
|
1804
|
+
function hasDynamicCompletion(sub) {
|
|
1805
|
+
for (const opt of sub.options) if (opt.valueCompletion?.type === "dynamic") return true;
|
|
1806
|
+
for (const pos of sub.positionals) if (pos.valueCompletion?.type === "dynamic") return true;
|
|
1807
|
+
for (const child of sub.subcommands) if (hasDynamicCompletion(child)) return true;
|
|
1808
|
+
return false;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Recursively merge global options into a subcommand and all its descendants.
|
|
1812
|
+
* Avoids duplicates by checking existing option names.
|
|
1813
|
+
*/
|
|
1814
|
+
function propagateGlobalOptions(sub, globalOptions) {
|
|
1815
|
+
const existingNames = new Set(sub.options.map((o) => o.name));
|
|
1816
|
+
const newOpts = globalOptions.filter((o) => !existingNames.has(o.name));
|
|
1817
|
+
sub.options = [...sub.options, ...newOpts];
|
|
1818
|
+
for (const child of sub.subcommands) propagateGlobalOptions(child, globalOptions);
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Extract completion data from a command tree
|
|
1822
|
+
*
|
|
1823
|
+
* @param command - The root command
|
|
1824
|
+
* @param programName - Program name for completion scripts
|
|
1825
|
+
* @param globalArgsSchema - Optional global args schema. When provided, global options
|
|
1826
|
+
* are derived from this schema instead of the root command's options.
|
|
1827
|
+
*/
|
|
1828
|
+
function extractCompletionData(command, programName, globalArgsSchema) {
|
|
1829
|
+
let globalOptions = [];
|
|
1830
|
+
if (globalArgsSchema) {
|
|
1831
|
+
const globalPending = [];
|
|
1832
|
+
globalOptions = fieldsToOptions(extractFields(globalArgsSchema).fields, globalPending).map((opt) => {
|
|
1833
|
+
opt.isGlobal = true;
|
|
1834
|
+
return opt;
|
|
1835
|
+
});
|
|
1836
|
+
resolveExpandTargets({
|
|
1837
|
+
name: programName,
|
|
1838
|
+
subcommands: [],
|
|
1839
|
+
options: globalOptions,
|
|
1840
|
+
positionals: []
|
|
1841
|
+
}, globalPending);
|
|
1842
|
+
}
|
|
1843
|
+
const rootSubcommand = extractSubcommand(programName, command, globalOptions);
|
|
1844
|
+
if (globalArgsSchema) propagateGlobalOptions(rootSubcommand, globalOptions);
|
|
1845
|
+
else globalOptions = rootSubcommand.options;
|
|
1846
|
+
return {
|
|
1847
|
+
command: rootSubcommand,
|
|
1848
|
+
programName,
|
|
1849
|
+
globalOptions
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region src/completion/header.ts
|
|
1855
|
+
/**
|
|
1856
|
+
* Static-script header utilities.
|
|
1857
|
+
*
|
|
1858
|
+
* Every completion script generated by politty starts with a small
|
|
1859
|
+
* machine-readable header. The rc loader and the runMain background
|
|
1860
|
+
* refresh path use the `# politty-bin-sig:` line to detect when the
|
|
1861
|
+
* cached script is stale relative to the binary on disk.
|
|
1862
|
+
*/
|
|
1863
|
+
/** Schema version of the header itself. Bump when the header layout changes. */
|
|
1864
|
+
const COMPLETION_VERSION = 1;
|
|
1865
|
+
/**
|
|
1866
|
+
* Read the binary's mtime in whole seconds (matches POSIX `stat -c %Y` /
|
|
1867
|
+
* BSD `stat -f %m`). Returns `"0"` on failure so the header is always
|
|
1868
|
+
* well-formed.
|
|
1869
|
+
*/
|
|
1870
|
+
function computeBinSig(binPath) {
|
|
1871
|
+
try {
|
|
1872
|
+
return Math.floor(statSync(binPath).mtimeMs / 1e3).toString();
|
|
1873
|
+
} catch {
|
|
1874
|
+
return "0";
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Walk `$PATH` looking for an executable named `programName`. Returns
|
|
1879
|
+
* the first match's full path, or `null` when not found. We mirror the
|
|
1880
|
+
* shell's `command -v <prog>` here so the sig embedded in the header
|
|
1881
|
+
* (computed by Node) lines up with what the rc loader stat-checks at
|
|
1882
|
+
* runtime — including pnpm/npm bin shims that wrap the real entrypoint.
|
|
1883
|
+
* Without this alignment, shimmed installs would never match the
|
|
1884
|
+
* embedded sig and the cache would regenerate on every shell startup.
|
|
1885
|
+
*/
|
|
1886
|
+
function findOnPath(programName) {
|
|
1887
|
+
if (!programName || /[/\\\0]/.test(programName)) return null;
|
|
1888
|
+
const path = process.env.PATH ?? "";
|
|
1889
|
+
for (const dir of path.split(":")) {
|
|
1890
|
+
if (!dir) continue;
|
|
1891
|
+
const candidate = join(dir, programName);
|
|
1892
|
+
try {
|
|
1893
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
1894
|
+
} catch {}
|
|
1895
|
+
}
|
|
1896
|
+
return null;
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Resolve the binary path used for sig computation and stat checks.
|
|
1900
|
+
*
|
|
1901
|
+
* Order: explicit override → `$PATH` lookup of `programName` → `process.argv[1]`.
|
|
1902
|
+
* The `$PATH` lookup keeps Node-side and shell-side stats pointed at the
|
|
1903
|
+
* same shim file when the CLI is invoked through a package-manager bin shim.
|
|
1904
|
+
*/
|
|
1905
|
+
function resolveBinPath(programName, override) {
|
|
1906
|
+
if (override) return override;
|
|
1907
|
+
return findOnPath(programName) ?? process.argv[1] ?? "";
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Build the header lines (no trailing blank line). Returned without a
|
|
1911
|
+
* leading `#!` so each generator can prepend its own shebang/compdef
|
|
1912
|
+
* marker.
|
|
1913
|
+
*/
|
|
1914
|
+
function buildHeaderLines(opts) {
|
|
1915
|
+
const sig = computeBinSig(resolveBinPath(opts.programName, opts.binPath));
|
|
1916
|
+
const lines = [
|
|
1917
|
+
`# politty-completion-version: ${1}`,
|
|
1918
|
+
`# politty-bin-sig: ${sig}`,
|
|
1919
|
+
`# program: ${opts.programName}`
|
|
1920
|
+
];
|
|
1921
|
+
if (opts.programVersion) lines.push(`# program-version: ${opts.programVersion}`);
|
|
1922
|
+
lines.push(`# shell: ${opts.shell}`);
|
|
1923
|
+
return lines;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
//#endregion
|
|
1927
|
+
//#region src/completion/bash.ts
|
|
1928
|
+
/** Escape a string for use inside bash double-quotes */
|
|
1929
|
+
function escapeBashDQ(s) {
|
|
1930
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Hex-encode the UTF-8 byte representation of `s` so the resulting
|
|
1934
|
+
* string is safe as a suffix of a bash identifier. Each non-alnum byte
|
|
1935
|
+
* becomes `_HH` (exactly 2 hex digits, always upper-case). `_` is also
|
|
1936
|
+
* encoded (as `_5F`) to keep the join separator between encoded dep
|
|
1937
|
+
* values unambiguous — without that, `(v1="-", v2="")` and
|
|
1938
|
+
* `(v1="_2D", v2="")` would both render `_2D_`.
|
|
1939
|
+
*
|
|
1940
|
+
* Per-byte fixed-width encoding avoids the variable-length codepoint
|
|
1941
|
+
* collision where `-A` (codepoint 0x2D + literal `A`) and `˚` (codepoint
|
|
1942
|
+
* 0x02DA) would both produce `_2DA` under the previous scheme. Mirrors
|
|
1943
|
+
* the runtime `__<fn>_enc` helper emitted alongside expand tables; the
|
|
1944
|
+
* helper forces `LC_ALL=C` so its `${var:i:1}` iterates over the same
|
|
1945
|
+
* UTF-8 bytes this encoder emits.
|
|
1946
|
+
*/
|
|
1947
|
+
function bashEncodeKey(s) {
|
|
1948
|
+
let out = "";
|
|
1949
|
+
const bytes = new TextEncoder().encode(s);
|
|
1950
|
+
for (const byte of bytes) if (byte >= 48 && byte <= 57 || byte >= 65 && byte <= 90 || byte >= 97 && byte <= 122) out += String.fromCharCode(byte);
|
|
1951
|
+
else out += `_${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1952
|
+
return out;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Generate bash value completion code for a ValueCompletion spec.
|
|
1956
|
+
* `location` is required when `vc.type === "expand"` (otherwise unused).
|
|
1957
|
+
*/
|
|
1958
|
+
function bashValueLines(vc, inline, fn, location) {
|
|
1959
|
+
if (!vc) return [];
|
|
1960
|
+
switch (vc.type) {
|
|
1961
|
+
case "expand": {
|
|
1962
|
+
if (!location) throw new Error("bashValueLines: expand variant requires a location");
|
|
1963
|
+
const varName = expandTableVarName(fn, location.funcSuffix, location.fieldName);
|
|
1964
|
+
const encLines = [`local _enc_key='' _enc_v`];
|
|
1965
|
+
location.resolvedDeps.forEach((d, i) => {
|
|
1966
|
+
const safe = sanitize(d.name);
|
|
1967
|
+
const varRef = d.isGlobal ? `_global_arg_values_${safe}` : `_arg_values_${safe}`;
|
|
1968
|
+
encLines.push(`_enc_v="\${${varRef}:-}"`);
|
|
1969
|
+
encLines.push(i === 0 ? `_enc_key="$(__${fn}_enc "$_enc_v")"` : `_enc_key+="_$(__${fn}_enc "$_enc_v")"`);
|
|
1970
|
+
});
|
|
1971
|
+
const inlineExpr = inline ? `"\${_inline_prefix}\${_c}"` : `"$_c"`;
|
|
1972
|
+
const bucketRef = location.isGlobal ? `\${_global_used_field_keys_${sanitize(location.fieldName)}:-}` : `\${_used_field_keys_${sanitize(location.fieldName)}:-}`;
|
|
1973
|
+
const arrayDedupLines = location.isArrayOption ? [` if [[ -n "$_ck" && " ${bucketRef} " == *" $_ck "* ]]; then continue; fi`] : [];
|
|
1974
|
+
return [
|
|
1975
|
+
`compopt +o default 2>/dev/null`,
|
|
1976
|
+
...encLines,
|
|
1977
|
+
`local _varname=${varName}__\${_enc_key}`,
|
|
1978
|
+
`local _raw="\${!_varname:-}"`,
|
|
1979
|
+
`if [[ -n "$_raw" ]]; then`,
|
|
1980
|
+
` local -a _vals=()`,
|
|
1981
|
+
` local _line`,
|
|
1982
|
+
` while IFS= read -r _line; do _vals+=("$_line"); done <<< "$_raw"`,
|
|
1983
|
+
` local _c _ck _seen_keys=" " _had_key=0`,
|
|
1984
|
+
` for _c in "\${_vals[@]}"; do`,
|
|
1985
|
+
` [[ -z "$_c" ]] && continue`,
|
|
1986
|
+
` if [[ "$_c" == *=* ]]; then`,
|
|
1987
|
+
` _ck="\${_c%%=*}"`,
|
|
1988
|
+
...arrayDedupLines,
|
|
1989
|
+
` if [[ "$_cur" != *=* ]]; then`,
|
|
1990
|
+
` [[ "$_seen_keys" == *" $_ck "* ]] && continue`,
|
|
1991
|
+
` _seen_keys+="$_ck "`,
|
|
1992
|
+
` _c="\${_ck}="`,
|
|
1993
|
+
` _had_key=1`,
|
|
1994
|
+
` elif [[ "$_c" != *=?* ]]; then`,
|
|
1995
|
+
` continue`,
|
|
1996
|
+
` fi`,
|
|
1997
|
+
` fi`,
|
|
1998
|
+
` [[ "$_c" == "$_cur"* ]] && COMPREPLY+=(${inlineExpr})`,
|
|
1999
|
+
` done`,
|
|
2000
|
+
` if (( _had_key )); then compopt -o nospace 2>/dev/null; fi`,
|
|
2001
|
+
`fi`,
|
|
2002
|
+
`if (( \${#COMPREPLY[@]} == 0 )); then COMPREPLY=( "" ); fi`
|
|
2003
|
+
];
|
|
2004
|
+
}
|
|
2005
|
+
case "dynamic": return [
|
|
2006
|
+
`local _dyn_out`,
|
|
2007
|
+
`_dyn_out=$(__${fn}_invoke_complete bash "\${_words[@]}")`,
|
|
2008
|
+
`__${fn}_apply_dynamic_output "$_dyn_out"`
|
|
2009
|
+
];
|
|
2010
|
+
case "choices": {
|
|
2011
|
+
const lines = [
|
|
2012
|
+
`local -a _choices=(${vc.choices.map((c) => `"${escapeBashDQ(c)}"`).join(" ")})`,
|
|
2013
|
+
`COMPREPLY=()`,
|
|
2014
|
+
`local _c; for _c in "\${_choices[@]}"; do [[ "$_c" == "$_cur"* ]] && COMPREPLY+=("\${_inline_prefix}\${_c}"); done`
|
|
2015
|
+
];
|
|
2016
|
+
if (inline) lines.push(`compopt -o nospace`);
|
|
2017
|
+
lines.push(`compopt +o default 2>/dev/null`);
|
|
2018
|
+
return lines;
|
|
2019
|
+
}
|
|
2020
|
+
case "file":
|
|
2021
|
+
if (vc.matcher?.length) return bashFileFilter(vc.matcher.map((p) => `[[ "\${_f##*/}" == ${p} ]]`).join(" || "));
|
|
2022
|
+
if (vc.extensions?.length) return bashFileFilter(vc.extensions.map((ext) => `[[ "$_f" == *".${ext}" ]]`).join(" || "));
|
|
2023
|
+
return [`COMPREPLY=($(compgen -P "$_inline_prefix" -f -- "$_cur"))`, `compopt -o filenames`];
|
|
2024
|
+
case "directory": return [`COMPREPLY=($(compgen -P "$_inline_prefix" -d -- "$_cur"))`, `compopt -o filenames`];
|
|
2025
|
+
case "command": return [`COMPREPLY=($(compgen -P "$_inline_prefix" -W "$(${vc.shellCommand})" -- "$_cur"))`];
|
|
2026
|
+
case "none": return [`compopt +o default 2>/dev/null`];
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Snippet that unsets every shell variable matching `<prefix>*`. Bash 3.2
|
|
2031
|
+
* has no associative arrays, so the trackers use one variable per field;
|
|
2032
|
+
* the per-invocation reset clears them by enumerating with `compgen -v
|
|
2033
|
+
* <prefix>` and feeding the names to `unset`. `2>/dev/null` is paired both
|
|
2034
|
+
* sides so an empty enumeration result (no variables yet) doesn't print
|
|
2035
|
+
* the `unset:` usage line.
|
|
2036
|
+
*/
|
|
2037
|
+
function unsetPrefixed(prefix) {
|
|
2038
|
+
return `unset $(compgen -v ${prefix} 2>/dev/null) 2>/dev/null`;
|
|
2039
|
+
}
|
|
2040
|
+
function bashFileFilter(checks) {
|
|
2041
|
+
return [
|
|
2042
|
+
`local -a _all_entries=($(compgen -f -- "$_cur"))`,
|
|
2043
|
+
`for _f in "\${_all_entries[@]}"; do`,
|
|
2044
|
+
` if [[ -d "$_f" ]]; then`,
|
|
2045
|
+
` COMPREPLY+=("\${_inline_prefix}$_f")`,
|
|
2046
|
+
` elif ${checks}; then`,
|
|
2047
|
+
` COMPREPLY+=("\${_inline_prefix}$_f")`,
|
|
2048
|
+
` fi`,
|
|
2049
|
+
`done`,
|
|
2050
|
+
`compopt -o filenames`,
|
|
2051
|
+
`compopt +o default 2>/dev/null`
|
|
2052
|
+
];
|
|
2053
|
+
}
|
|
2054
|
+
/** Collect value-taking option patterns for case matching */
|
|
2055
|
+
function optionValueCases$2(options, positionals, inline, fn, funcSuffix) {
|
|
2056
|
+
const lines = [];
|
|
2057
|
+
for (const opt of options) {
|
|
2058
|
+
if (!opt.takesValue || !opt.valueCompletion) continue;
|
|
2059
|
+
const valLines = bashValueLines(opt.valueCompletion, inline, fn, {
|
|
2060
|
+
funcSuffix,
|
|
2061
|
+
...optionExpandLocation(opt, options, positionals)
|
|
2062
|
+
});
|
|
2063
|
+
if (valLines.length === 0) continue;
|
|
2064
|
+
const patterns = effectiveOptionTokens(opt, options);
|
|
2065
|
+
if (patterns.length === 0) continue;
|
|
2066
|
+
lines.push(` ${patterns.join("|")})`);
|
|
2067
|
+
for (const vl of valLines) lines.push(` ${vl}`);
|
|
2068
|
+
lines.push(` return ;;`);
|
|
2069
|
+
}
|
|
2070
|
+
return lines;
|
|
2071
|
+
}
|
|
2072
|
+
/** Generate positional completion block */
|
|
2073
|
+
function positionalBlock$2(positionals, fn, funcSuffix, options = []) {
|
|
2074
|
+
if (positionals.length === 0) return [];
|
|
2075
|
+
const lines = [];
|
|
2076
|
+
lines.push(` case "$_pos_count" in`);
|
|
2077
|
+
for (const pos of positionals) {
|
|
2078
|
+
if (pos.variadic) lines.push(` ${pos.position}|*)`);
|
|
2079
|
+
else lines.push(` ${pos.position})`);
|
|
2080
|
+
for (const vl of bashValueLines(pos.valueCompletion, false, fn, {
|
|
2081
|
+
funcSuffix,
|
|
2082
|
+
...positionalExpandLocation(pos, options, positionals)
|
|
2083
|
+
})) lines.push(` ${vl}`);
|
|
2084
|
+
lines.push(` ;;`);
|
|
2085
|
+
}
|
|
2086
|
+
lines.push(` esac`);
|
|
2087
|
+
return lines;
|
|
2088
|
+
}
|
|
2089
|
+
/** Generate prev/inline value completion blocks for options */
|
|
2090
|
+
function valueCompletionBlocks(options, positionals, fn, funcSuffix) {
|
|
2091
|
+
if (!options.some((o) => o.takesValue && o.valueCompletion)) return [];
|
|
2092
|
+
const lines = [];
|
|
2093
|
+
const prevCases = optionValueCases$2(options, positionals, false, fn, funcSuffix);
|
|
2094
|
+
if (prevCases.length > 0) {
|
|
2095
|
+
lines.push(` if [[ -z "$_inline_prefix" ]]; then`);
|
|
2096
|
+
lines.push(` case "$_prev" in`);
|
|
2097
|
+
lines.push(...prevCases);
|
|
2098
|
+
lines.push(` esac`);
|
|
2099
|
+
lines.push(` fi`);
|
|
2100
|
+
}
|
|
2101
|
+
const inlineCases = optionValueCases$2(options, positionals, true, fn, funcSuffix);
|
|
2102
|
+
if (inlineCases.length > 0) {
|
|
2103
|
+
lines.push(` if [[ -n "$_inline_prefix" ]]; then`);
|
|
2104
|
+
lines.push(` case "\${_inline_prefix%=}" in`);
|
|
2105
|
+
lines.push(...inlineCases);
|
|
2106
|
+
lines.push(` esac`);
|
|
2107
|
+
lines.push(` fi`);
|
|
2108
|
+
}
|
|
2109
|
+
return lines;
|
|
2110
|
+
}
|
|
2111
|
+
/** Generate available-options list lines */
|
|
2112
|
+
function availableOptionLines$2(options, fn) {
|
|
2113
|
+
const lines = [];
|
|
2114
|
+
for (const opt of options) {
|
|
2115
|
+
if (opt.valueType === "array") {
|
|
2116
|
+
lines.push(` _avail+=(--${opt.cliName})`);
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
const patterns = quotedAvailabilityTokens(opt.cliName, opt.alias, opt.negation, {
|
|
2120
|
+
isGlobal: opt.isGlobal === true,
|
|
2121
|
+
frameOptions: options
|
|
2122
|
+
});
|
|
2123
|
+
const guard = `__${fn}_not_used ${patterns.join(" ")}`;
|
|
2124
|
+
const emitNames = opt.negation ? [opt.cliName, opt.negation] : [opt.cliName];
|
|
2125
|
+
for (const name of emitNames) {
|
|
2126
|
+
if (!patterns.includes(`"--${name}"`)) continue;
|
|
2127
|
+
lines.push(` ${guard} && _avail+=(--${name})`);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
lines.push(` __${fn}_not_used "--help" && _avail+=(--help)`);
|
|
2131
|
+
return lines;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Generate a per-subcommand completion function.
|
|
2135
|
+
* Recursively generates functions for nested subcommands.
|
|
2136
|
+
*/
|
|
2137
|
+
function generateSubHandler$2(sub, fn, path) {
|
|
2138
|
+
const fullPath = [...path, sub.name];
|
|
2139
|
+
const funcSuffix = fullPath.map(sanitize).join("_");
|
|
2140
|
+
const funcName = `__${fn}_complete_${funcSuffix}`;
|
|
2141
|
+
const visibleSubs = getVisibleSubs(sub.subcommands);
|
|
2142
|
+
const lines = [];
|
|
2143
|
+
for (const child of visibleSubs) lines.push(...generateSubHandler$2(child, fn, fullPath));
|
|
2144
|
+
lines.push(`${funcName}() {`);
|
|
2145
|
+
lines.push(...valueCompletionBlocks(sub.options, sub.positionals, fn, funcSuffix));
|
|
2146
|
+
const fullPathStr = fullPath.join(":");
|
|
2147
|
+
lines.push(` if [[ -z "$_inline_prefix" ]] && __${fn}_opt_takes_value "${fullPathStr}" "$_prev"; then return; fi`);
|
|
2148
|
+
lines.push(` if [[ -n "$_inline_prefix" ]] && __${fn}_opt_takes_value "${fullPathStr}" "\${_inline_prefix%=}"; then return; fi`);
|
|
2149
|
+
if (sub.positionals.length > 0) {
|
|
2150
|
+
lines.push(` if (( _after_dd )); then`);
|
|
2151
|
+
lines.push(...positionalBlock$2(sub.positionals, fn, funcSuffix, sub.options).map((l) => ` ${l}`));
|
|
2152
|
+
lines.push(` return`);
|
|
2153
|
+
lines.push(` fi`);
|
|
2154
|
+
} else lines.push(` if (( _after_dd )); then return; fi`);
|
|
2155
|
+
lines.push(` if [[ "$_cur" == -* ]]; then`);
|
|
2156
|
+
lines.push(` local -a _avail=()`);
|
|
2157
|
+
lines.push(...availableOptionLines$2(sub.options, fn));
|
|
2158
|
+
lines.push(` COMPREPLY=($(compgen -W "\${_avail[*]}" -- "$_cur"))`);
|
|
2159
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2160
|
+
lines.push(` return`);
|
|
2161
|
+
lines.push(` fi`);
|
|
2162
|
+
if (visibleSubs.length > 0) {
|
|
2163
|
+
const subNames = getSubNamesWithAliases(sub.subcommands).map((s) => s.name).join(" ");
|
|
2164
|
+
lines.push(` COMPREPLY=($(compgen -W "${subNames}" -- "$_cur"))`);
|
|
2165
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2166
|
+
} else if (sub.positionals.length > 0) lines.push(...positionalBlock$2(sub.positionals, fn, funcSuffix, sub.options));
|
|
2167
|
+
lines.push(`}`);
|
|
2168
|
+
lines.push(``);
|
|
2169
|
+
return lines;
|
|
2170
|
+
}
|
|
2171
|
+
function generateBashCompletion(command, options) {
|
|
2172
|
+
const { programName } = options;
|
|
2173
|
+
const data = extractCompletionData(command, programName, options.globalArgsSchema);
|
|
2174
|
+
const fn = sanitize(programName);
|
|
2175
|
+
const root = data.command;
|
|
2176
|
+
const visibleSubs = getVisibleSubs(root.subcommands);
|
|
2177
|
+
const expandSpecs = collectExpandSpecs(root);
|
|
2178
|
+
const trackedFields = collectTrackedFields(root, expandSpecs, data.globalOptions);
|
|
2179
|
+
const lines = [];
|
|
2180
|
+
lines.push(...buildHeaderLines({
|
|
2181
|
+
programName,
|
|
2182
|
+
shell: "bash",
|
|
2183
|
+
binPath: options.binPath,
|
|
2184
|
+
programVersion: options.programVersion
|
|
2185
|
+
}));
|
|
2186
|
+
lines.push(`# Generated by politty`);
|
|
2187
|
+
lines.push(``);
|
|
2188
|
+
const hasExpand = expandSpecs.length > 0;
|
|
2189
|
+
const arrayExpandSpecs = expandSpecs.filter((s) => s.isArrayOption);
|
|
2190
|
+
const hasArrayExpand = arrayExpandSpecs.length > 0;
|
|
2191
|
+
if (hasExpand) {
|
|
2192
|
+
lines.push(`__${fn}_enc() {`);
|
|
2193
|
+
lines.push(` local LC_ALL=C`);
|
|
2194
|
+
lines.push(` local _s=$1 _r='' _c _i`);
|
|
2195
|
+
lines.push(` for (( _i=0; _i<\${#_s}; _i++ )); do`);
|
|
2196
|
+
lines.push(` _c=\${_s:_i:1}`);
|
|
2197
|
+
lines.push(` case "$_c" in`);
|
|
2198
|
+
lines.push(` [a-zA-Z0-9]) _r+="$_c" ;;`);
|
|
2199
|
+
lines.push(` *) printf -v _r '%s_%02X' "$_r" "'$_c" ;;`);
|
|
2200
|
+
lines.push(` esac`);
|
|
2201
|
+
lines.push(` done`);
|
|
2202
|
+
lines.push(` printf '%s' "$_r"`);
|
|
2203
|
+
lines.push(`}`);
|
|
2204
|
+
lines.push(``);
|
|
2205
|
+
}
|
|
2206
|
+
for (const spec of expandSpecs) {
|
|
2207
|
+
const varName = expandTableVarName(fn, spec.funcSuffix, spec.fieldName);
|
|
2208
|
+
for (const entry of spec.vc.table) {
|
|
2209
|
+
const encKey = entry.key.map(bashEncodeKey).join("_");
|
|
2210
|
+
const value = entry.candidates.map((c) => c.value).join("\n");
|
|
2211
|
+
lines.push(`${varName}__${encKey}=${ansiC(value)}`);
|
|
2212
|
+
}
|
|
2213
|
+
lines.push(``);
|
|
2214
|
+
}
|
|
2215
|
+
if (hasDynamicCompletion(root)) {
|
|
2216
|
+
lines.push(...dynamicInvokeCompleteLines(fn, programName));
|
|
2217
|
+
lines.push(``);
|
|
2218
|
+
lines.push(`__${fn}_apply_dynamic_output() {`);
|
|
2219
|
+
lines.push(` local _raw="$1"`);
|
|
2220
|
+
lines.push(` COMPREPLY=()`);
|
|
2221
|
+
lines.push(` local _directive=0`);
|
|
2222
|
+
lines.push(` local -a _lines=()`);
|
|
2223
|
+
lines.push(` local _line`);
|
|
2224
|
+
lines.push(` while IFS= read -r _line; do _lines+=("$_line"); done <<< "$_raw"`);
|
|
2225
|
+
lines.push(` local _last=$((\${#_lines[@]} - 1))`);
|
|
2226
|
+
lines.push(` if (( _last >= 0 )) && [[ "\${_lines[$_last]}" =~ ^:[0-9]+$ ]]; then`);
|
|
2227
|
+
lines.push(` _directive="\${_lines[$_last]#:}"`);
|
|
2228
|
+
lines.push(` unset '_lines[_last]'`);
|
|
2229
|
+
lines.push(` fi`);
|
|
2230
|
+
lines.push(` for _line in "\${_lines[@]}"; do`);
|
|
2231
|
+
lines.push(` [[ -z "$_line" ]] && continue`);
|
|
2232
|
+
lines.push(` COMPREPLY+=("$_line")`);
|
|
2233
|
+
lines.push(` done`);
|
|
2234
|
+
lines.push(` local _ip="\${_inline_prefix:-}"`);
|
|
2235
|
+
lines.push(` if (( _directive & ${CompletionDirective.DirectoryCompletion} )); then`);
|
|
2236
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2237
|
+
lines.push(` compopt -o filenames 2>/dev/null`);
|
|
2238
|
+
lines.push(` local _d`);
|
|
2239
|
+
lines.push(` while IFS= read -r _d; do COMPREPLY+=("\${_ip}\${_d}"); done < <(compgen -d -- "$_cur")`);
|
|
2240
|
+
lines.push(` if (( \${#COMPREPLY[@]} == 0 )); then COMPREPLY=( "" ); fi`);
|
|
2241
|
+
lines.push(` elif (( _directive & ${CompletionDirective.FileCompletion} )); then`);
|
|
2242
|
+
lines.push(` if (( \${#COMPREPLY[@]} > 0 )) || [[ -n "$_ip" ]]; then`);
|
|
2243
|
+
lines.push(` compopt -o filenames 2>/dev/null`);
|
|
2244
|
+
lines.push(` local _f`);
|
|
2245
|
+
lines.push(` while IFS= read -r _f; do COMPREPLY+=("\${_ip}\${_f}"); done < <(compgen -f -- "$_cur")`);
|
|
2246
|
+
lines.push(` else`);
|
|
2247
|
+
lines.push(` compopt -o default 2>/dev/null`);
|
|
2248
|
+
lines.push(` fi`);
|
|
2249
|
+
lines.push(` else`);
|
|
2250
|
+
lines.push(` if (( _directive & ${CompletionDirective.NoFileCompletion} )); then`);
|
|
2251
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2252
|
+
lines.push(` if (( \${#COMPREPLY[@]} == 0 )); then COMPREPLY=( "" ); fi`);
|
|
2253
|
+
lines.push(` fi`);
|
|
2254
|
+
lines.push(` fi`);
|
|
2255
|
+
lines.push(` if (( _directive & ${CompletionDirective.NoSpace} )); then`);
|
|
2256
|
+
lines.push(` compopt -o nospace 2>/dev/null`);
|
|
2257
|
+
lines.push(` fi`);
|
|
2258
|
+
lines.push(`}`);
|
|
2259
|
+
lines.push(``);
|
|
2260
|
+
}
|
|
2261
|
+
lines.push(`__${fn}_not_used() {`);
|
|
2262
|
+
lines.push(` for _u in "\${_used_opts[@]}"; do`);
|
|
2263
|
+
lines.push(` for _chk in "$@"; do`);
|
|
2264
|
+
lines.push(` [[ "$_u" == "$_chk" ]] && return 1`);
|
|
2265
|
+
lines.push(` done`);
|
|
2266
|
+
lines.push(` done`);
|
|
2267
|
+
lines.push(` return 0`);
|
|
2268
|
+
lines.push(`}`);
|
|
2269
|
+
lines.push(``);
|
|
2270
|
+
lines.push(`__${fn}_opt_takes_value() {`);
|
|
2271
|
+
lines.push(` case "$1:$2" in`);
|
|
2272
|
+
lines.push(...optTakesValueEntries(root, ""));
|
|
2273
|
+
lines.push(` esac`);
|
|
2274
|
+
lines.push(` return 1`);
|
|
2275
|
+
lines.push(`}`);
|
|
2276
|
+
lines.push(``);
|
|
2277
|
+
if (hasExpand) {
|
|
2278
|
+
lines.push(`__${fn}_track_opt() {`);
|
|
2279
|
+
lines.push(` case "$1:$2" in`);
|
|
2280
|
+
lines.push(...trackOptCaseLines(trackedFields, "bash"));
|
|
2281
|
+
lines.push(` esac`);
|
|
2282
|
+
lines.push(`}`);
|
|
2283
|
+
lines.push(``);
|
|
2284
|
+
lines.push(`__${fn}_track_pos() {`);
|
|
2285
|
+
lines.push(` case "$1:$2" in`);
|
|
2286
|
+
lines.push(...trackPosCaseLines(trackedFields, "bash"));
|
|
2287
|
+
lines.push(` esac`);
|
|
2288
|
+
lines.push(`}`);
|
|
2289
|
+
lines.push(``);
|
|
2290
|
+
}
|
|
2291
|
+
if (hasArrayExpand) {
|
|
2292
|
+
lines.push(`__${fn}_track_array_expand() {`);
|
|
2293
|
+
lines.push(` case "$1:$2" in`);
|
|
2294
|
+
lines.push(...trackArrayExpandCaseLines(arrayExpandSpecs, "bash"));
|
|
2295
|
+
lines.push(` esac`);
|
|
2296
|
+
lines.push(`}`);
|
|
2297
|
+
lines.push(``);
|
|
2298
|
+
}
|
|
2299
|
+
const routeEntries = collectRouteEntries(root);
|
|
2300
|
+
if (routeEntries.length > 0) {
|
|
2301
|
+
lines.push(`__${fn}_is_subcmd() {`);
|
|
2302
|
+
lines.push(` case "$1:$2" in`);
|
|
2303
|
+
lines.push(...isSubcmdCaseLines(routeEntries));
|
|
2304
|
+
lines.push(` esac`);
|
|
2305
|
+
lines.push(` return 1`);
|
|
2306
|
+
lines.push(`}`);
|
|
2307
|
+
lines.push(``);
|
|
2308
|
+
}
|
|
2309
|
+
for (const sub of visibleSubs) lines.push(...generateSubHandler$2(sub, fn, []));
|
|
2310
|
+
lines.push(`__${fn}_complete_root() {`);
|
|
2311
|
+
lines.push(...valueCompletionBlocks(root.options, root.positionals, fn, "root"));
|
|
2312
|
+
lines.push(` if [[ -z "$_inline_prefix" ]] && __${fn}_opt_takes_value "" "$_prev"; then return; fi`);
|
|
2313
|
+
lines.push(` if [[ -n "$_inline_prefix" ]] && __${fn}_opt_takes_value "" "\${_inline_prefix%=}"; then return; fi`);
|
|
2314
|
+
if (root.positionals.length > 0) {
|
|
2315
|
+
lines.push(` if (( _after_dd )); then`);
|
|
2316
|
+
lines.push(...positionalBlock$2(root.positionals, fn, "root", root.options).map((l) => ` ${l}`));
|
|
2317
|
+
lines.push(` return`);
|
|
2318
|
+
lines.push(` fi`);
|
|
2319
|
+
} else lines.push(` if (( _after_dd )); then return; fi`);
|
|
2320
|
+
lines.push(` if [[ "$_cur" == -* ]]; then`);
|
|
2321
|
+
lines.push(` local -a _avail=()`);
|
|
2322
|
+
lines.push(...availableOptionLines$2(root.options, fn));
|
|
2323
|
+
lines.push(` COMPREPLY=($(compgen -W "\${_avail[*]}" -- "$_cur"))`);
|
|
2324
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2325
|
+
if (visibleSubs.length > 0) {
|
|
2326
|
+
lines.push(` else`);
|
|
2327
|
+
const subNames = getSubNamesWithAliases(root.subcommands).map((s) => s.name).join(" ");
|
|
2328
|
+
lines.push(` COMPREPLY=($(compgen -W "${subNames}" -- "$_cur"))`);
|
|
2329
|
+
lines.push(` compopt +o default 2>/dev/null`);
|
|
2330
|
+
} else if (root.positionals.length > 0) {
|
|
2331
|
+
lines.push(` else`);
|
|
2332
|
+
lines.push(...positionalBlock$2(root.positionals, fn, "root", root.options).map((l) => ` ${l}`));
|
|
2333
|
+
}
|
|
2334
|
+
lines.push(` fi`);
|
|
2335
|
+
lines.push(`}`);
|
|
2336
|
+
lines.push(``);
|
|
2337
|
+
const subRouting = subDispatchCaseLines(routeEntries, fn).join("\n");
|
|
2338
|
+
lines.push(`_${fn}_completions() {`);
|
|
2339
|
+
lines.push(` COMPREPLY=()`);
|
|
2340
|
+
lines.push(``);
|
|
2341
|
+
lines.push(` # Rejoin words split by '=' in COMP_WORDBREAKS`);
|
|
2342
|
+
lines.push(` local -a _words=()`);
|
|
2343
|
+
lines.push(` local _i=1`);
|
|
2344
|
+
lines.push(` while (( _i <= COMP_CWORD )); do`);
|
|
2345
|
+
lines.push(` if [[ "\${COMP_WORDS[_i]}" == "=" && \${#_words[@]} -gt 0 ]]; then`);
|
|
2346
|
+
lines.push(` _words[\${#_words[@]}-1]+="=\${COMP_WORDS[_i+1]:-}"`);
|
|
2347
|
+
lines.push(` (( _i += 2 ))`);
|
|
2348
|
+
lines.push(` else`);
|
|
2349
|
+
lines.push(` _words+=("\${COMP_WORDS[_i]}")`);
|
|
2350
|
+
lines.push(` (( _i++ ))`);
|
|
2351
|
+
lines.push(` fi`);
|
|
2352
|
+
lines.push(` done`);
|
|
2353
|
+
lines.push(``);
|
|
2354
|
+
lines.push(` local _cur=""`);
|
|
2355
|
+
lines.push(` (( \${#_words[@]} > 0 )) && _cur="\${_words[\${#_words[@]}-1]}"`);
|
|
2356
|
+
lines.push(` local _inline_prefix=""`);
|
|
2357
|
+
lines.push(``);
|
|
2358
|
+
lines.push(` local _prev=""`);
|
|
2359
|
+
lines.push(` (( \${#_words[@]} > 1 )) && _prev="\${_words[\${#_words[@]}-2]}"`);
|
|
2360
|
+
lines.push(``);
|
|
2361
|
+
lines.push(` local _subcmd="" _after_dd=0 _pos_count=0 _skip_next=0`);
|
|
2362
|
+
lines.push(` local -a _used_opts=()`);
|
|
2363
|
+
if (hasExpand) {
|
|
2364
|
+
lines.push(` ${unsetPrefixed("_arg_values_")}`);
|
|
2365
|
+
lines.push(` ${unsetPrefixed("_global_arg_values_")}`);
|
|
2366
|
+
}
|
|
2367
|
+
if (hasArrayExpand) {
|
|
2368
|
+
lines.push(` ${unsetPrefixed("_used_field_keys_")}`);
|
|
2369
|
+
lines.push(` ${unsetPrefixed("_global_used_field_keys_")}`);
|
|
2370
|
+
lines.push(` ${unsetPrefixed("_global_arr_seen_")}`);
|
|
2371
|
+
}
|
|
2372
|
+
lines.push(``);
|
|
2373
|
+
lines.push(` local _j=0`);
|
|
2374
|
+
lines.push(` while (( _j < \${#_words[@]} - 1 )); do`);
|
|
2375
|
+
lines.push(` local _w="\${_words[_j]}"`);
|
|
2376
|
+
lines.push(` if (( _skip_next )); then _skip_next=0; (( _j++ )); continue; fi`);
|
|
2377
|
+
lines.push(` if [[ "$_w" == "--" ]]; then _after_dd=1; (( _j++ )); continue; fi`);
|
|
2378
|
+
const afterDdTrack = hasExpand ? `__${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"; ` : "";
|
|
2379
|
+
lines.push(` if (( _after_dd )); then ${afterDdTrack}(( _pos_count++ )); (( _j++ )); continue; fi`);
|
|
2380
|
+
lines.push(` if [[ "$_w" == -*=* ]]; then`);
|
|
2381
|
+
lines.push(` _used_opts+=("\${_w%%=*}")`);
|
|
2382
|
+
if (hasExpand) {
|
|
2383
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "\${_w%%=*}" "\${_w#*=}"`);
|
|
2384
|
+
if (hasArrayExpand) lines.push(` __${fn}_track_array_expand "$_subcmd" "\${_w%%=*}" "\${_w#*=}"`);
|
|
2385
|
+
}
|
|
2386
|
+
lines.push(` (( _j++ )); continue`);
|
|
2387
|
+
lines.push(` fi`);
|
|
2388
|
+
lines.push(` if [[ "$_w" == -* ]]; then`);
|
|
2389
|
+
lines.push(` _used_opts+=("$_w")`);
|
|
2390
|
+
lines.push(` if __${fn}_opt_takes_value "$_subcmd" "$_w"; then`);
|
|
2391
|
+
lines.push(` local _next="\${_words[_j+1]:-}"`);
|
|
2392
|
+
lines.push(` if [[ -n "$_next" && "$_next" != -* ]]; then _skip_next=1; fi`);
|
|
2393
|
+
if (hasExpand) {
|
|
2394
|
+
lines.push(` if (( _skip_next )); then`);
|
|
2395
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "$_w" "$_next"`);
|
|
2396
|
+
if (hasArrayExpand) {
|
|
2397
|
+
lines.push(` if (( _j + 2 < \${#_words[@]} )); then`);
|
|
2398
|
+
lines.push(` __${fn}_track_array_expand "$_subcmd" "$_w" "$_next"`);
|
|
2399
|
+
lines.push(` fi`);
|
|
2400
|
+
}
|
|
2401
|
+
lines.push(` fi`);
|
|
2402
|
+
}
|
|
2403
|
+
lines.push(` fi`);
|
|
2404
|
+
lines.push(` (( _j++ )); continue`);
|
|
2405
|
+
lines.push(` fi`);
|
|
2406
|
+
const clearState = hasArrayExpand ? `; ${unsetPrefixed("_arg_values_")}; ${unsetPrefixed("_used_field_keys_")}; ${unsetPrefixed("_global_arr_seen_")}` : hasExpand ? `; ${unsetPrefixed("_arg_values_")}` : "";
|
|
2407
|
+
const posTrack = hasExpand ? `__${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"; ` : "";
|
|
2408
|
+
if (routeEntries.length > 0) lines.push(` if __${fn}_is_subcmd "$_subcmd" "$_w"; then _subcmd="\${_subcmd:+\${_subcmd}:}$_w"; _used_opts=(); _pos_count=0${clearState}; else ${posTrack}(( _pos_count++ )); fi`);
|
|
2409
|
+
else {
|
|
2410
|
+
if (hasExpand) lines.push(` __${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"`);
|
|
2411
|
+
lines.push(` (( _pos_count++ ))`);
|
|
2412
|
+
}
|
|
2413
|
+
lines.push(` (( _j++ ))`);
|
|
2414
|
+
lines.push(` done`);
|
|
2415
|
+
lines.push(``);
|
|
2416
|
+
lines.push(` if (( ! _after_dd )) && [[ "$_cur" == -*=* ]]; then`);
|
|
2417
|
+
lines.push(` _inline_prefix="\${_cur%%=*}="`);
|
|
2418
|
+
lines.push(` _cur="\${_cur#*=}"`);
|
|
2419
|
+
lines.push(` fi`);
|
|
2420
|
+
lines.push(``);
|
|
2421
|
+
lines.push(` case "$_subcmd" in`);
|
|
2422
|
+
lines.push(subRouting);
|
|
2423
|
+
lines.push(` *) __${fn}_complete_root ;;`);
|
|
2424
|
+
lines.push(` esac`);
|
|
2425
|
+
lines.push(`}`);
|
|
2426
|
+
lines.push(``);
|
|
2427
|
+
lines.push(`complete -o default -F _${fn}_completions ${programName}`);
|
|
2428
|
+
lines.push(``);
|
|
2429
|
+
return {
|
|
2430
|
+
script: lines.join("\n"),
|
|
2431
|
+
shell: "bash",
|
|
2432
|
+
installInstructions: `# To enable completions, add the following to your ~/.bashrc:
|
|
2433
|
+
|
|
2434
|
+
# Option 1: Source directly
|
|
2435
|
+
eval "$(${programName} completion bash)"
|
|
2436
|
+
|
|
2437
|
+
# Option 2: Save to a file
|
|
2438
|
+
${programName} completion bash > ~/.local/share/bash-completion/completions/${programName}
|
|
2439
|
+
|
|
2440
|
+
# Then reload your shell or run:
|
|
2441
|
+
source ~/.bashrc`
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
//#endregion
|
|
2446
|
+
//#region src/completion/dynamic/shell-formatter.ts
|
|
2447
|
+
/**
|
|
2448
|
+
* Format completion candidates for the specified shell
|
|
2449
|
+
*
|
|
2450
|
+
* @returns Shell-ready output string (lines separated by newline, last line is :directive)
|
|
2451
|
+
*/
|
|
2452
|
+
function formatForShell(result, options) {
|
|
2453
|
+
switch (options.shell) {
|
|
2454
|
+
case "bash": return formatForBash(result, options);
|
|
2455
|
+
case "zsh": return formatForZsh(result, options);
|
|
2456
|
+
case "fish": return formatForFish(result, options);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Append extension metadata and directive to output lines
|
|
2461
|
+
*/
|
|
2462
|
+
function appendMetadata(lines, result) {
|
|
2463
|
+
if (result.fileExtensions && result.fileExtensions.length > 0) lines.push(`@ext:${result.fileExtensions.join(",")}`);
|
|
2464
|
+
if (result.fileMatchers && result.fileMatchers.length > 0) lines.push(`@matcher:${result.fileMatchers.join(",")}`);
|
|
2465
|
+
lines.push(`:${result.directive}`);
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Format for bash
|
|
2469
|
+
*
|
|
2470
|
+
* - Pre-filters candidates by currentWord prefix (replaces compgen -W)
|
|
2471
|
+
* - Handles --opt=value inline values by prepending prefix
|
|
2472
|
+
* - Outputs plain values only (no descriptions - bash COMPREPLY doesn't support them)
|
|
2473
|
+
* - Last line: :directive
|
|
2474
|
+
*/
|
|
2475
|
+
function formatForBash(result, options) {
|
|
2476
|
+
const lines = ((result.directive & CompletionDirective.FilterPrefix) !== 0 && options.currentWord ? result.candidates.filter((c) => c.value.startsWith(options.currentWord)) : result.candidates).map((c) => options.inlinePrefix ? `${options.inlinePrefix}${c.value}` : c.value);
|
|
2477
|
+
appendMetadata(lines, result);
|
|
2478
|
+
return lines.join("\n");
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Format for zsh
|
|
2482
|
+
*
|
|
2483
|
+
* - Outputs value:description pairs for _describe
|
|
2484
|
+
* - Colons in values/descriptions are escaped with backslash
|
|
2485
|
+
* - Last line: :directive
|
|
2486
|
+
*/
|
|
2487
|
+
function formatForZsh(result, _options) {
|
|
2488
|
+
const lines = result.candidates.map((c) => {
|
|
2489
|
+
const escapedValue = c.value.replace(/:/g, "\\:");
|
|
2490
|
+
if (c.description) return `${escapedValue}:${c.description.replace(/:/g, "\\:")}`;
|
|
2491
|
+
return escapedValue;
|
|
2492
|
+
});
|
|
2493
|
+
appendMetadata(lines, result);
|
|
2494
|
+
return lines.join("\n");
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Format for fish
|
|
2498
|
+
*
|
|
2499
|
+
* - Outputs value\tdescription pairs
|
|
2500
|
+
* - Last line: :directive
|
|
2501
|
+
*/
|
|
2502
|
+
function formatForFish(result, _options) {
|
|
2503
|
+
const lines = result.candidates.map((c) => {
|
|
2504
|
+
if (c.description) return `${c.value}\t${c.description}`;
|
|
2505
|
+
return c.value;
|
|
2506
|
+
});
|
|
2507
|
+
appendMetadata(lines, result);
|
|
2508
|
+
return lines.join("\n");
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
//#endregion
|
|
2512
|
+
//#region src/completion/dynamic/complete-command.ts
|
|
2513
|
+
/**
|
|
2514
|
+
* Dynamic completion command implementation
|
|
2515
|
+
*
|
|
2516
|
+
* This creates a hidden `__complete` command that outputs completion candidates
|
|
2517
|
+
* for shell scripts to consume. Usage:
|
|
2518
|
+
*
|
|
2519
|
+
* mycli __complete --shell bash -- build --fo
|
|
2520
|
+
* mycli __complete --shell zsh -- plugin add
|
|
2521
|
+
*
|
|
2522
|
+
* Output format depends on the target shell:
|
|
2523
|
+
* bash: plain values (pre-filtered by prefix), last line :directive
|
|
2524
|
+
* zsh: value:description pairs, last line :directive
|
|
2525
|
+
* fish: value\tdescription pairs, last line :directive
|
|
2526
|
+
*/
|
|
2527
|
+
/**
|
|
2528
|
+
* Schema for the __complete command
|
|
2529
|
+
*/
|
|
2530
|
+
const completeArgsSchema = z.object({
|
|
2531
|
+
shell: arg(z.enum([
|
|
2532
|
+
"bash",
|
|
2533
|
+
"zsh",
|
|
2534
|
+
"fish"
|
|
2535
|
+
]), { description: "Target shell for output formatting" }),
|
|
2536
|
+
args: arg(z.array(z.string()).default([]), {
|
|
2537
|
+
positional: true,
|
|
2538
|
+
description: "Arguments to complete",
|
|
2539
|
+
variadic: true
|
|
2540
|
+
})
|
|
2541
|
+
});
|
|
2542
|
+
/**
|
|
2543
|
+
* Create the dynamic completion command
|
|
2544
|
+
*
|
|
2545
|
+
* @param rootCommand - The root command to generate completions for
|
|
2546
|
+
* @param programName - The program name (optional, defaults to rootCommand.name)
|
|
2547
|
+
* @param globalArgsSchema - Global args schema. Forwarded to
|
|
2548
|
+
* `parseCompletionContext` so resolvers attached to global options remain
|
|
2549
|
+
* reachable at every subcommand level.
|
|
2550
|
+
* @returns A command that outputs completion candidates
|
|
2551
|
+
*/
|
|
2552
|
+
function createDynamicCompleteCommand(rootCommand, _programName, globalArgsSchema) {
|
|
2553
|
+
return defineCommand({
|
|
2554
|
+
name: "__complete",
|
|
2555
|
+
args: completeArgsSchema,
|
|
2556
|
+
async run(args) {
|
|
2557
|
+
const context = parseCompletionContext(args.args, rootCommand, globalArgsSchema);
|
|
2558
|
+
const inlinePrefix = context.completionType === "option-value" && context.targetOption ? detectInlineOptionPrefix(context.currentWord) : void 0;
|
|
2559
|
+
const effectiveWord = inlinePrefix ? context.currentWord.slice(inlinePrefix.length) : context.currentWord;
|
|
2560
|
+
const output = formatForShell(await generateCandidates(context, { shell: args.shell }), {
|
|
2561
|
+
shell: args.shell,
|
|
2562
|
+
currentWord: effectiveWord,
|
|
2563
|
+
inlinePrefix
|
|
2564
|
+
});
|
|
2565
|
+
console.log(output);
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Check if a command tree contains the __complete command
|
|
2571
|
+
*/
|
|
2572
|
+
function hasCompleteCommand(command) {
|
|
2573
|
+
return Boolean(command.subCommands?.["__complete"]);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
//#endregion
|
|
2577
|
+
//#region src/completion/fish.ts
|
|
2578
|
+
/** Escape shell-special characters for fish double-quoted strings */
|
|
2579
|
+
function escapeDesc$1(s) {
|
|
2580
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Escape a fish `switch` case pattern. Fish's `case` interprets its
|
|
2584
|
+
* arguments as globs even when double-quoted, so glob metacharacters
|
|
2585
|
+
* (`*`, `?`, `[`, `]`) must be backslash-escaped to keep the comparison
|
|
2586
|
+
* literal — otherwise a key like `prod*` would also match a runtime
|
|
2587
|
+
* value of `production`. Quote/dollar/backslash are escaped first so the
|
|
2588
|
+
* resulting string remains valid inside a double-quoted literal.
|
|
2589
|
+
*/
|
|
2590
|
+
function fishCaseEscape(s) {
|
|
2591
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\*/g, "\\*").replace(/\?/g, "\\?").replace(/\[/g, "\\[").replace(/]/g, "\\]");
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Generate fish value completion lines for a ValueCompletion spec.
|
|
2595
|
+
* Each line outputs candidates via echo (tab-separated value\tdescription).
|
|
2596
|
+
*
|
|
2597
|
+
* `location` is required for the expand variant (carries fieldName +
|
|
2598
|
+
* isArrayOption); other variants ignore it.
|
|
2599
|
+
*/
|
|
2600
|
+
function fishValueLines(vc, fn, location) {
|
|
2601
|
+
if (!vc) return [];
|
|
2602
|
+
switch (vc.type) {
|
|
2603
|
+
case "expand": {
|
|
2604
|
+
if (!location) throw new Error("fishValueLines: expand variant requires a location");
|
|
2605
|
+
const depExpr = (d) => {
|
|
2606
|
+
const safe = sanitize(d.name);
|
|
2607
|
+
return d.isGlobal ? `$_global_arg_values_${safe}` : `$_arg_values_${safe}`;
|
|
2608
|
+
};
|
|
2609
|
+
const depKey = location.resolvedDeps.map((d) => `"${depExpr(d)}"`).join(`\\x1f`);
|
|
2610
|
+
const bucket = sanitize(location.fieldName);
|
|
2611
|
+
const bucketList = location.isGlobal ? `$_global_used_field_keys_${bucket}` : `$_used_field_keys_${bucket}`;
|
|
2612
|
+
const out = [`switch ${depKey}`];
|
|
2613
|
+
for (const entry of vc.table) {
|
|
2614
|
+
const casePattern = entry.key.map((k) => `"${fishCaseEscape(k)}"`).join(`\\x1f`);
|
|
2615
|
+
out.push(` case ${casePattern}`);
|
|
2616
|
+
const keyOnlyLines = [];
|
|
2617
|
+
const fullLines = [];
|
|
2618
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
2619
|
+
const printfLine = (value, description) => description ? `printf '%s\\t%s\\n' "${escapeDesc$1(value)}" "${escapeDesc$1(description)}"` : `printf '%s\\n' "${escapeDesc$1(value)}"`;
|
|
2620
|
+
const wrapWithDedup = (echoLine, keyPart) => location.isArrayOption && keyPart.length > 0 ? [
|
|
2621
|
+
` if not contains -- "${escapeDesc$1(keyPart)}" ${bucketList}`,
|
|
2622
|
+
` ${echoLine}`,
|
|
2623
|
+
` end`
|
|
2624
|
+
] : [` ${echoLine}`];
|
|
2625
|
+
for (const c of entry.candidates) {
|
|
2626
|
+
const eqIdx = c.value.indexOf("=");
|
|
2627
|
+
const keyPart = eqIdx > 0 ? c.value.slice(0, eqIdx) : "";
|
|
2628
|
+
const echoLine = printfLine(c.value, c.description);
|
|
2629
|
+
if (!(keyPart.length > 0 && c.value.length === eqIdx + 1)) fullLines.push(...wrapWithDedup(echoLine, keyPart));
|
|
2630
|
+
if (keyPart.length === 0) keyOnlyLines.push(` ${echoLine}`);
|
|
2631
|
+
else if (!seenKeys.has(keyPart)) {
|
|
2632
|
+
seenKeys.add(keyPart);
|
|
2633
|
+
keyOnlyLines.push(...wrapWithDedup(printfLine(`${keyPart}=`, c.description), keyPart));
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
if (fullLines.length !== keyOnlyLines.length || fullLines.some((l, i) => l !== keyOnlyLines[i])) {
|
|
2637
|
+
out.push(` if string match -q '*=*' -- "$_cur"`);
|
|
2638
|
+
out.push(...fullLines);
|
|
2639
|
+
out.push(` else`);
|
|
2640
|
+
out.push(...keyOnlyLines);
|
|
2641
|
+
out.push(` end`);
|
|
2642
|
+
} else out.push(...fullLines);
|
|
2643
|
+
}
|
|
2644
|
+
out.push(`end`);
|
|
2645
|
+
return out;
|
|
2646
|
+
}
|
|
2647
|
+
case "dynamic": return [`__${fn}_invoke_complete fish $_args | __${fn}_apply_dynamic_output "$_cur"`];
|
|
2648
|
+
case "choices": return vc.choices.map((c) => `echo "${escapeDesc$1(c)}"`);
|
|
2649
|
+
case "file":
|
|
2650
|
+
if (vc.matcher?.length) return fishMatcherLines(vc.matcher);
|
|
2651
|
+
if (vc.extensions?.length) return fishExtensionLines(vc.extensions);
|
|
2652
|
+
return [`__fish_complete_path "$_cur"`];
|
|
2653
|
+
case "directory": return [`__fish_complete_directories "$_cur"`];
|
|
2654
|
+
case "command": return [
|
|
2655
|
+
`for _v in (${vc.shellCommand})`,
|
|
2656
|
+
` echo "$_v"`,
|
|
2657
|
+
`end`
|
|
2658
|
+
];
|
|
2659
|
+
case "none": return [];
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
/** Generate fish matcher-filtered file completion */
|
|
2663
|
+
function fishMatcherLines(patterns) {
|
|
2664
|
+
return [
|
|
2665
|
+
`__fish_complete_directories "$_cur"`,
|
|
2666
|
+
`set -l _dir ""`,
|
|
2667
|
+
`if string match -q '*/*' "$_cur"`,
|
|
2668
|
+
` set _dir (string replace -r '[^/]*$' '' "$_cur")`,
|
|
2669
|
+
`end`,
|
|
2670
|
+
...patterns.flatMap((p) => [
|
|
2671
|
+
`for _f in "$_dir"${p}`,
|
|
2672
|
+
` test -f "$_f"; and string match -q "$_cur*" "$_f"; and echo "$_f"`,
|
|
2673
|
+
`end`
|
|
2674
|
+
])
|
|
2675
|
+
];
|
|
2676
|
+
}
|
|
2677
|
+
/** Generate fish extension-filtered file completion */
|
|
2678
|
+
function fishExtensionLines(extensions) {
|
|
2679
|
+
const lines = [];
|
|
2680
|
+
lines.push(`__fish_complete_directories "$_cur"`);
|
|
2681
|
+
for (const ext of extensions) {
|
|
2682
|
+
lines.push(`for _f in "$_cur"*.${ext}`);
|
|
2683
|
+
lines.push(` test -f "$_f"; and echo "$_f"`);
|
|
2684
|
+
lines.push(`end`);
|
|
2685
|
+
}
|
|
2686
|
+
return lines;
|
|
2687
|
+
}
|
|
2688
|
+
/** Generate option-value switch cases for fish */
|
|
2689
|
+
function optionValueCases$1(options, positionals, fn) {
|
|
2690
|
+
const lines = [];
|
|
2691
|
+
for (const opt of options) {
|
|
2692
|
+
if (!opt.takesValue || !opt.valueCompletion) continue;
|
|
2693
|
+
const valLines = fishValueLines(opt.valueCompletion, fn, optionExpandLocation(opt, options, positionals));
|
|
2694
|
+
if (valLines.length === 0) continue;
|
|
2695
|
+
const tokens = effectiveOptionTokens(opt, options);
|
|
2696
|
+
if (tokens.length === 0) continue;
|
|
2697
|
+
const cond = tokens.map((t) => `test "$_prev" = "${t}"`).join("; or ");
|
|
2698
|
+
lines.push(` if ${cond}`);
|
|
2699
|
+
for (const vl of valLines) lines.push(` ${vl}`);
|
|
2700
|
+
lines.push(` return`);
|
|
2701
|
+
lines.push(` end`);
|
|
2702
|
+
}
|
|
2703
|
+
return lines;
|
|
2704
|
+
}
|
|
2705
|
+
/** Generate positional completion block for fish */
|
|
2706
|
+
function positionalBlock$1(positionals, fn, options = []) {
|
|
2707
|
+
if (positionals.length === 0) return [];
|
|
2708
|
+
const lines = [];
|
|
2709
|
+
for (const pos of positionals) {
|
|
2710
|
+
const valLines = fishValueLines(pos.valueCompletion, fn, positionalExpandLocation(pos, options, positionals));
|
|
2711
|
+
if (valLines.length === 0) continue;
|
|
2712
|
+
if (pos.variadic) lines.push(` if test $_pos_count -ge ${pos.position}`);
|
|
2713
|
+
else lines.push(` if test $_pos_count -eq ${pos.position}`);
|
|
2714
|
+
for (const vl of valLines) lines.push(` ${vl}`);
|
|
2715
|
+
lines.push(` return`);
|
|
2716
|
+
lines.push(` end`);
|
|
2717
|
+
}
|
|
2718
|
+
return lines;
|
|
2719
|
+
}
|
|
2720
|
+
/** Generate available-option echo lines for fish */
|
|
2721
|
+
function availableOptionLines$1(options, fn) {
|
|
2722
|
+
const lines = [];
|
|
2723
|
+
for (const opt of options) {
|
|
2724
|
+
const desc = escapeDesc$1(opt.description ?? "");
|
|
2725
|
+
if (opt.valueType === "array") {
|
|
2726
|
+
lines.push(` echo "--${opt.cliName}\t${desc}"`);
|
|
2727
|
+
continue;
|
|
2728
|
+
}
|
|
2729
|
+
const checks = quotedAvailabilityTokens(opt.cliName, opt.alias, opt.negation, {
|
|
2730
|
+
isGlobal: opt.isGlobal === true,
|
|
2731
|
+
frameOptions: options
|
|
2732
|
+
});
|
|
2733
|
+
const guard = `__${fn}_not_used ${checks.join(" ")}`;
|
|
2734
|
+
const negDesc = opt.negationDescription ? escapeDesc$1(opt.negationDescription) : desc;
|
|
2735
|
+
const entries = [{
|
|
2736
|
+
name: opt.cliName,
|
|
2737
|
+
desc
|
|
2738
|
+
}];
|
|
2739
|
+
if (opt.negation) entries.push({
|
|
2740
|
+
name: opt.negation,
|
|
2741
|
+
desc: negDesc
|
|
2742
|
+
});
|
|
2743
|
+
for (const e of entries) {
|
|
2744
|
+
if (!checks.includes(`"--${e.name}"`)) continue;
|
|
2745
|
+
lines.push(` ${guard}; and echo "--${e.name}\t${e.desc}"`);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
lines.push(` __${fn}_not_used "--help"; and echo "--help\tShow help"`);
|
|
2749
|
+
return lines;
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* Generate a per-subcommand completion function for fish.
|
|
2753
|
+
* Recursively generates functions for nested subcommands.
|
|
2754
|
+
*/
|
|
2755
|
+
function generateSubHandler$1(sub, fn, path) {
|
|
2756
|
+
const fullPath = [...path, sub.name];
|
|
2757
|
+
const funcName = `__${fn}_complete_${fullPath.map(sanitize).join("_")}`;
|
|
2758
|
+
const visibleSubs = getVisibleSubs(sub.subcommands);
|
|
2759
|
+
const lines = [];
|
|
2760
|
+
for (const child of visibleSubs) lines.push(...generateSubHandler$1(child, fn, fullPath));
|
|
2761
|
+
lines.push(`function ${funcName} --no-scope-shadowing`);
|
|
2762
|
+
lines.push(...optionValueCases$1(sub.options, sub.positionals, fn));
|
|
2763
|
+
const fullPathStr = fullPath.join(":");
|
|
2764
|
+
lines.push(` if __${fn}_opt_takes_value "${fullPathStr}" "$_prev"; return; end`);
|
|
2765
|
+
if (sub.positionals.length > 0) {
|
|
2766
|
+
lines.push(` if test $_after_dd -eq 1`);
|
|
2767
|
+
lines.push(...positionalBlock$1(sub.positionals, fn, sub.options).map((l) => ` ${l}`));
|
|
2768
|
+
lines.push(` return`);
|
|
2769
|
+
lines.push(` end`);
|
|
2770
|
+
} else lines.push(` if test $_after_dd -eq 1; return; end`);
|
|
2771
|
+
lines.push(` if string match -q -- '-*' "$_cur"`);
|
|
2772
|
+
lines.push(...availableOptionLines$1(sub.options, fn));
|
|
2773
|
+
lines.push(` return`);
|
|
2774
|
+
lines.push(` end`);
|
|
2775
|
+
if (visibleSubs.length > 0) for (const s of getSubNamesWithAliases(sub.subcommands)) {
|
|
2776
|
+
const desc = escapeDesc$1(s.description ?? "");
|
|
2777
|
+
lines.push(` echo "${s.name}\t${desc}"`);
|
|
2778
|
+
}
|
|
2779
|
+
else if (sub.positionals.length > 0) lines.push(...positionalBlock$1(sub.positionals, fn, sub.options));
|
|
2780
|
+
lines.push(`end`);
|
|
2781
|
+
lines.push(``);
|
|
2782
|
+
return lines;
|
|
2783
|
+
}
|
|
2784
|
+
/** Generate opt-takes-value entries for fish switch cases */
|
|
2785
|
+
function optTakesValueCases(sub, parentPath) {
|
|
2786
|
+
const lines = [];
|
|
2787
|
+
for (const row of walkOptTakesValueRows(sub, parentPath)) {
|
|
2788
|
+
const patterns = row.tokens.map((t) => `"${row.parentPath}:${t}"`);
|
|
2789
|
+
lines.push(` case ${patterns.join(" ")}`);
|
|
2790
|
+
lines.push(` return 0`);
|
|
2791
|
+
}
|
|
2792
|
+
return lines;
|
|
2793
|
+
}
|
|
2794
|
+
function generateFishCompletion(command, options) {
|
|
2795
|
+
const { programName } = options;
|
|
2796
|
+
const data = extractCompletionData(command, programName, options.globalArgsSchema);
|
|
2797
|
+
const fn = sanitize(programName);
|
|
2798
|
+
const root = data.command;
|
|
2799
|
+
const visibleSubs = getVisibleSubs(root.subcommands);
|
|
2800
|
+
const expandSpecs = collectExpandSpecs(root);
|
|
2801
|
+
const trackedFields = collectTrackedFields(root, expandSpecs, data.globalOptions);
|
|
2802
|
+
const hasExpand = expandSpecs.length > 0;
|
|
2803
|
+
const arrayExpandSpecs = expandSpecs.filter((s) => s.isArrayOption);
|
|
2804
|
+
const hasArrayExpand = arrayExpandSpecs.length > 0;
|
|
2805
|
+
const lines = [];
|
|
2806
|
+
lines.push(...buildHeaderLines({
|
|
2807
|
+
programName,
|
|
2808
|
+
shell: "fish",
|
|
2809
|
+
binPath: options.binPath,
|
|
2810
|
+
programVersion: options.programVersion
|
|
2811
|
+
}));
|
|
2812
|
+
lines.push(`# Generated by politty`);
|
|
2813
|
+
lines.push(``);
|
|
2814
|
+
const sig = computeBinSig(resolveBinPath(programName, options.binPath));
|
|
2815
|
+
const refreshFn = `__${fn}_refresh_completion`;
|
|
2816
|
+
lines.push(`function ${refreshFn} --no-scope-shadowing`);
|
|
2817
|
+
lines.push(` set -l _bin (command -v ${programName})`);
|
|
2818
|
+
lines.push(` test -z "$_bin"; and return 1`);
|
|
2819
|
+
lines.push(` set -l _sig (stat -L -c '%Y' "$_bin" 2>/dev/null; or stat -L -f '%m' "$_bin" 2>/dev/null)`);
|
|
2820
|
+
lines.push(` test "$_sig" = "${sig}"; and return 1`);
|
|
2821
|
+
lines.push(` set -l _target "$__fish_config_dir/completions/${programName}.fish"`);
|
|
2822
|
+
lines.push(` "$_bin" __refresh-completion fish 2>/dev/null`);
|
|
2823
|
+
lines.push(` and source "$_target" 2>/dev/null`);
|
|
2824
|
+
lines.push(` and return 0`);
|
|
2825
|
+
lines.push(` return 1`);
|
|
2826
|
+
lines.push(`end`);
|
|
2827
|
+
lines.push(`${refreshFn}`);
|
|
2828
|
+
lines.push(`set -l _politty_refreshed $status`);
|
|
2829
|
+
lines.push(`functions -e ${refreshFn}`);
|
|
2830
|
+
lines.push(`test $_politty_refreshed -eq 0; and return`);
|
|
2831
|
+
lines.push(``);
|
|
2832
|
+
if (hasDynamicCompletion(root)) {
|
|
2833
|
+
lines.push(`function __${fn}_invoke_complete`);
|
|
2834
|
+
lines.push(` set -l _shell $argv[1]`);
|
|
2835
|
+
lines.push(` set -l _argv $argv[2..]`);
|
|
2836
|
+
lines.push(` set -l _bin ${programName}`);
|
|
2837
|
+
lines.push(` if set -q ${binEnvVarName(fn)}`);
|
|
2838
|
+
lines.push(` set _bin $${binEnvVarName(fn)}`);
|
|
2839
|
+
lines.push(` end`);
|
|
2840
|
+
lines.push(` $_bin __complete --shell $_shell -- $_argv 2>/dev/null`);
|
|
2841
|
+
lines.push(`end`);
|
|
2842
|
+
lines.push(``);
|
|
2843
|
+
lines.push(`function __${fn}_apply_dynamic_output`);
|
|
2844
|
+
lines.push(` set -l _cur $argv[1]`);
|
|
2845
|
+
lines.push(` set -l _directive 0`);
|
|
2846
|
+
lines.push(` set -l _emitted 0`);
|
|
2847
|
+
lines.push(` set -l _prev ""`);
|
|
2848
|
+
lines.push(` set -l _has_prev 0`);
|
|
2849
|
+
lines.push(` while read -l _l`);
|
|
2850
|
+
lines.push(` if test $_has_prev -eq 1`);
|
|
2851
|
+
lines.push(` if test -n "$_prev"`);
|
|
2852
|
+
lines.push(` printf '%s\\n' "$_prev"`);
|
|
2853
|
+
lines.push(` set _emitted 1`);
|
|
2854
|
+
lines.push(` end`);
|
|
2855
|
+
lines.push(` end`);
|
|
2856
|
+
lines.push(` set _prev $_l`);
|
|
2857
|
+
lines.push(` set _has_prev 1`);
|
|
2858
|
+
lines.push(` end`);
|
|
2859
|
+
lines.push(` if test $_has_prev -eq 1`);
|
|
2860
|
+
lines.push(` if string match -qr '^:[0-9]+$' -- $_prev`);
|
|
2861
|
+
lines.push(` set _directive (string sub -s 2 -- $_prev)`);
|
|
2862
|
+
lines.push(` else`);
|
|
2863
|
+
lines.push(` if test -n "$_prev"`);
|
|
2864
|
+
lines.push(` printf '%s\\n' "$_prev"`);
|
|
2865
|
+
lines.push(` set _emitted 1`);
|
|
2866
|
+
lines.push(` end`);
|
|
2867
|
+
lines.push(` end`);
|
|
2868
|
+
lines.push(` end`);
|
|
2869
|
+
lines.push(` if test (math "bitand($_directive, ${CompletionDirective.DirectoryCompletion})") -ne 0`);
|
|
2870
|
+
lines.push(` __fish_complete_directories "$_cur"`);
|
|
2871
|
+
lines.push(` else if test (math "bitand($_directive, ${CompletionDirective.FileCompletion})") -ne 0`);
|
|
2872
|
+
lines.push(` __fish_complete_path "$_cur"`);
|
|
2873
|
+
lines.push(` else if test $_emitted -eq 0; and test (math "bitand($_directive, ${CompletionDirective.NoFileCompletion})") -eq 0`);
|
|
2874
|
+
lines.push(` __fish_complete_path "$_cur"`);
|
|
2875
|
+
lines.push(` end`);
|
|
2876
|
+
lines.push(`end`);
|
|
2877
|
+
lines.push(``);
|
|
2878
|
+
}
|
|
2879
|
+
lines.push(`function __${fn}_not_used --no-scope-shadowing`);
|
|
2880
|
+
lines.push(` for _chk in $argv`);
|
|
2881
|
+
lines.push(` if contains -- "$_chk" $_used_opts`);
|
|
2882
|
+
lines.push(` return 1`);
|
|
2883
|
+
lines.push(` end`);
|
|
2884
|
+
lines.push(` end`);
|
|
2885
|
+
lines.push(` return 0`);
|
|
2886
|
+
lines.push(`end`);
|
|
2887
|
+
lines.push(``);
|
|
2888
|
+
lines.push(`function __${fn}_opt_takes_value`);
|
|
2889
|
+
lines.push(` switch "$argv[1]:$argv[2]"`);
|
|
2890
|
+
lines.push(...optTakesValueCases(root, ""));
|
|
2891
|
+
lines.push(` end`);
|
|
2892
|
+
lines.push(` return 1`);
|
|
2893
|
+
lines.push(`end`);
|
|
2894
|
+
lines.push(``);
|
|
2895
|
+
if (hasExpand) {
|
|
2896
|
+
const trackerVar = (t) => `${t.isGlobal ? "_global_arg_values_" : "_arg_values_"}${sanitize(t.fieldName)}`;
|
|
2897
|
+
lines.push(`function __${fn}_track_opt --no-scope-shadowing`);
|
|
2898
|
+
lines.push(` switch "$argv[1]:$argv[2]"`);
|
|
2899
|
+
for (const t of trackedFields) {
|
|
2900
|
+
if (t.isPositional || !t.optionTokens || t.optionTokens.length === 0) continue;
|
|
2901
|
+
const cases = t.pathStrs.flatMap((p) => t.optionTokens.map((n) => `"${p}:${n}"`)).join(" ");
|
|
2902
|
+
lines.push(` case ${cases}`);
|
|
2903
|
+
lines.push(` set -g ${trackerVar(t)} "$argv[3]"`);
|
|
2904
|
+
}
|
|
2905
|
+
lines.push(` end`);
|
|
2906
|
+
lines.push(`end`);
|
|
2907
|
+
lines.push(``);
|
|
2908
|
+
lines.push(`function __${fn}_track_pos --no-scope-shadowing`);
|
|
2909
|
+
lines.push(` switch "$argv[1]:$argv[2]"`);
|
|
2910
|
+
for (const t of trackedFields) {
|
|
2911
|
+
if (!t.isPositional) continue;
|
|
2912
|
+
const cases = t.pathStrs.map((p) => `"${p}:${t.position}"`).join(" ");
|
|
2913
|
+
lines.push(` case ${cases}`);
|
|
2914
|
+
lines.push(` set -g ${trackerVar(t)} "$argv[3]"`);
|
|
2915
|
+
}
|
|
2916
|
+
lines.push(` end`);
|
|
2917
|
+
lines.push(`end`);
|
|
2918
|
+
lines.push(``);
|
|
2919
|
+
}
|
|
2920
|
+
if (hasArrayExpand) {
|
|
2921
|
+
lines.push(`function __${fn}_track_array_expand --no-scope-shadowing`);
|
|
2922
|
+
lines.push(` switch "$argv[1]:$argv[2]"`);
|
|
2923
|
+
for (const spec of arrayExpandSpecs) {
|
|
2924
|
+
if (spec.optionTokens.length === 0) continue;
|
|
2925
|
+
const cases = spec.pathStrs.flatMap((p) => spec.optionTokens.map((tok) => `"${p}:${tok}"`)).join(" ");
|
|
2926
|
+
const bucket = sanitize(spec.fieldName);
|
|
2927
|
+
const bucketVar = spec.isGlobal ? `_global_used_field_keys_` : `_used_field_keys_`;
|
|
2928
|
+
lines.push(` case ${cases}`);
|
|
2929
|
+
lines.push(` if string match -q '*=*' -- "$argv[3]"`);
|
|
2930
|
+
lines.push(` set -l _k (string replace -r '=.*' '' -- "$argv[3]")`);
|
|
2931
|
+
lines.push(` if test -n "$_k"`);
|
|
2932
|
+
if (spec.isGlobal) {
|
|
2933
|
+
lines.push(` if not set -q _global_arr_seen_${bucket}`);
|
|
2934
|
+
lines.push(` set -g ${bucketVar}${bucket} "$_k"`);
|
|
2935
|
+
lines.push(` set -g _global_arr_seen_${bucket} 1`);
|
|
2936
|
+
lines.push(` else if not contains -- "$_k" $${bucketVar}${bucket}`);
|
|
2937
|
+
lines.push(` set -ga ${bucketVar}${bucket} "$_k"`);
|
|
2938
|
+
lines.push(` end`);
|
|
2939
|
+
} else {
|
|
2940
|
+
lines.push(` if not contains -- "$_k" $${bucketVar}${bucket}`);
|
|
2941
|
+
lines.push(` set -ga ${bucketVar}${bucket} "$_k"`);
|
|
2942
|
+
lines.push(` end`);
|
|
2943
|
+
}
|
|
2944
|
+
lines.push(` end`);
|
|
2945
|
+
lines.push(` end`);
|
|
2946
|
+
}
|
|
2947
|
+
lines.push(` end`);
|
|
2948
|
+
lines.push(`end`);
|
|
2949
|
+
lines.push(``);
|
|
2950
|
+
}
|
|
2951
|
+
const routeEntries = collectRouteEntries(root);
|
|
2952
|
+
if (routeEntries.length > 0) {
|
|
2953
|
+
lines.push(`function __${fn}_is_subcmd`);
|
|
2954
|
+
lines.push(` switch "$argv[1]:$argv[2]"`);
|
|
2955
|
+
for (const r of routeEntries) {
|
|
2956
|
+
lines.push(` case "${r.lookupPattern}"`);
|
|
2957
|
+
lines.push(` return 0`);
|
|
2958
|
+
}
|
|
2959
|
+
lines.push(` end`);
|
|
2960
|
+
lines.push(` return 1`);
|
|
2961
|
+
lines.push(`end`);
|
|
2962
|
+
lines.push(``);
|
|
2963
|
+
}
|
|
2964
|
+
for (const sub of visibleSubs) lines.push(...generateSubHandler$1(sub, fn, []));
|
|
2965
|
+
lines.push(`function __${fn}_complete_root --no-scope-shadowing`);
|
|
2966
|
+
lines.push(...optionValueCases$1(root.options, root.positionals, fn));
|
|
2967
|
+
lines.push(` if __${fn}_opt_takes_value "" "$_prev"; return; end`);
|
|
2968
|
+
if (root.positionals.length > 0) {
|
|
2969
|
+
lines.push(` if test $_after_dd -eq 1`);
|
|
2970
|
+
lines.push(...positionalBlock$1(root.positionals, fn, root.options).map((l) => ` ${l}`));
|
|
2971
|
+
lines.push(` return`);
|
|
2972
|
+
lines.push(` end`);
|
|
2973
|
+
} else lines.push(` if test $_after_dd -eq 1; return; end`);
|
|
2974
|
+
lines.push(` if string match -q -- '-*' "$_cur"`);
|
|
2975
|
+
lines.push(...availableOptionLines$1(root.options, fn));
|
|
2976
|
+
if (visibleSubs.length > 0) {
|
|
2977
|
+
lines.push(` else`);
|
|
2978
|
+
for (const s of getSubNamesWithAliases(root.subcommands)) {
|
|
2979
|
+
const desc = escapeDesc$1(s.description ?? "");
|
|
2980
|
+
lines.push(` echo "${s.name}\t${desc}"`);
|
|
2981
|
+
}
|
|
2982
|
+
} else if (root.positionals.length > 0) {
|
|
2983
|
+
lines.push(` else`);
|
|
2984
|
+
lines.push(...positionalBlock$1(root.positionals, fn, root.options));
|
|
2985
|
+
}
|
|
2986
|
+
lines.push(` end`);
|
|
2987
|
+
lines.push(`end`);
|
|
2988
|
+
lines.push(``);
|
|
2989
|
+
lines.push(`function __fish_${fn}_complete`);
|
|
2990
|
+
lines.push(` set -l _args (commandline -opc)`);
|
|
2991
|
+
lines.push(` set -e _args[1]`);
|
|
2992
|
+
lines.push(``);
|
|
2993
|
+
lines.push(` set -l _ct (commandline -ct)`);
|
|
2994
|
+
lines.push(` if test (count $_ct) -eq 0`);
|
|
2995
|
+
lines.push(` set -a _args ""`);
|
|
2996
|
+
lines.push(` else`);
|
|
2997
|
+
lines.push(` set -a _args $_ct`);
|
|
2998
|
+
lines.push(` end`);
|
|
2999
|
+
lines.push(``);
|
|
3000
|
+
lines.push(` set -l _cur ""`);
|
|
3001
|
+
lines.push(` if test (count $_args) -gt 0`);
|
|
3002
|
+
lines.push(` set _cur "$_args[-1]"`);
|
|
3003
|
+
lines.push(` end`);
|
|
3004
|
+
lines.push(``);
|
|
3005
|
+
lines.push(` set -l _prev ""`);
|
|
3006
|
+
lines.push(` if test (count $_args) -gt 1`);
|
|
3007
|
+
lines.push(` set _prev "$_args[-2]"`);
|
|
3008
|
+
lines.push(` end`);
|
|
3009
|
+
lines.push(``);
|
|
3010
|
+
lines.push(` set -l _subcmd "" ; set -l _after_dd 0 ; set -l _pos_count 0 ; set -l _skip_next 0`);
|
|
3011
|
+
lines.push(` set -l _used_opts`);
|
|
3012
|
+
lines.push(``);
|
|
3013
|
+
if (hasExpand) for (const t of trackedFields) {
|
|
3014
|
+
lines.push(` set -e _arg_values_${sanitize(t.fieldName)}`);
|
|
3015
|
+
lines.push(` set -e _global_arg_values_${sanitize(t.fieldName)}`);
|
|
3016
|
+
}
|
|
3017
|
+
if (hasArrayExpand) for (const spec of arrayExpandSpecs) {
|
|
3018
|
+
lines.push(` set -e _used_field_keys_${sanitize(spec.fieldName)}`);
|
|
3019
|
+
lines.push(` set -e _global_used_field_keys_${sanitize(spec.fieldName)}`);
|
|
3020
|
+
lines.push(` set -e _global_arr_seen_${sanitize(spec.fieldName)}`);
|
|
3021
|
+
}
|
|
3022
|
+
lines.push(` set -l _j 1`);
|
|
3023
|
+
lines.push(` set -l _limit (math (count $_args) - 1)`);
|
|
3024
|
+
lines.push(` while test $_j -le $_limit`);
|
|
3025
|
+
lines.push(` set -l _w "$_args[$_j]"`);
|
|
3026
|
+
lines.push(` if test $_skip_next -eq 1; set _skip_next 0; set _j (math $_j + 1); continue; end`);
|
|
3027
|
+
lines.push(` if test "$_w" = "--"; set _after_dd 1; set _j (math $_j + 1); continue; end`);
|
|
3028
|
+
const afterDdTrack = hasExpand ? `__${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"; ` : "";
|
|
3029
|
+
lines.push(` if test $_after_dd -eq 1; ${afterDdTrack}set _pos_count (math $_pos_count + 1); set _j (math $_j + 1); continue; end`);
|
|
3030
|
+
lines.push(` if string match -q -- '-*=*' "$_w"`);
|
|
3031
|
+
lines.push(` set -l _opt (string replace -r '=.*' '' -- "$_w")`);
|
|
3032
|
+
lines.push(` set -a _used_opts "$_opt"`);
|
|
3033
|
+
if (hasExpand) {
|
|
3034
|
+
lines.push(` set -l _val (string replace -r '^[^=]*=' '' -- "$_w")`);
|
|
3035
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "$_opt" "$_val"`);
|
|
3036
|
+
if (hasArrayExpand) lines.push(` __${fn}_track_array_expand "$_subcmd" "$_opt" "$_val"`);
|
|
3037
|
+
}
|
|
3038
|
+
lines.push(` set _j (math $_j + 1); continue`);
|
|
3039
|
+
lines.push(` end`);
|
|
3040
|
+
lines.push(` if string match -q -- '-*' "$_w"`);
|
|
3041
|
+
lines.push(` set -a _used_opts "$_w"`);
|
|
3042
|
+
lines.push(` if __${fn}_opt_takes_value "$_subcmd" "$_w"`);
|
|
3043
|
+
lines.push(` set -l _next ""`);
|
|
3044
|
+
lines.push(` set -l _next_idx (math $_j + 1)`);
|
|
3045
|
+
lines.push(` if test $_next_idx -le (count $_args)`);
|
|
3046
|
+
lines.push(` set _next "$_args[$_next_idx]"`);
|
|
3047
|
+
lines.push(` end`);
|
|
3048
|
+
lines.push(` if test -n "$_next"; and not string match -q -- '-*' "$_next"`);
|
|
3049
|
+
lines.push(` set _skip_next 1`);
|
|
3050
|
+
if (hasExpand) {
|
|
3051
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "$_w" "$_next"`);
|
|
3052
|
+
if (hasArrayExpand) {
|
|
3053
|
+
lines.push(` if test $_j -lt $_limit`);
|
|
3054
|
+
lines.push(` __${fn}_track_array_expand "$_subcmd" "$_w" "$_next"`);
|
|
3055
|
+
lines.push(` end`);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
lines.push(` end`);
|
|
3059
|
+
lines.push(` end`);
|
|
3060
|
+
lines.push(` set _j (math $_j + 1); continue`);
|
|
3061
|
+
lines.push(` end`);
|
|
3062
|
+
if (routeEntries.length > 0) {
|
|
3063
|
+
lines.push(` if __${fn}_is_subcmd "$_subcmd" "$_w"`);
|
|
3064
|
+
lines.push(` test -n "$_subcmd"; and set _subcmd "$_subcmd:$_w"; or set _subcmd "$_w"`);
|
|
3065
|
+
lines.push(` set _used_opts; set _pos_count 0`);
|
|
3066
|
+
if (hasExpand) {
|
|
3067
|
+
for (const t of trackedFields) lines.push(` set -e _arg_values_${sanitize(t.fieldName)}`);
|
|
3068
|
+
if (hasArrayExpand) for (const spec of arrayExpandSpecs) {
|
|
3069
|
+
lines.push(` set -e _used_field_keys_${sanitize(spec.fieldName)}`);
|
|
3070
|
+
lines.push(` set -e _global_arr_seen_${sanitize(spec.fieldName)}`);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
lines.push(` else`);
|
|
3074
|
+
if (hasExpand) lines.push(` __${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"`);
|
|
3075
|
+
lines.push(` set _pos_count (math $_pos_count + 1)`);
|
|
3076
|
+
lines.push(` end`);
|
|
3077
|
+
} else {
|
|
3078
|
+
if (hasExpand) lines.push(` __${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"`);
|
|
3079
|
+
lines.push(` set _pos_count (math $_pos_count + 1)`);
|
|
3080
|
+
}
|
|
3081
|
+
lines.push(` set _j (math $_j + 1)`);
|
|
3082
|
+
lines.push(` end`);
|
|
3083
|
+
lines.push(``);
|
|
3084
|
+
lines.push(` switch "$_subcmd"`);
|
|
3085
|
+
for (const r of routeEntries) lines.push(` case "${r.pathStr}"; __${fn}_complete_${r.funcSuffix}`);
|
|
3086
|
+
lines.push(` case '*'; __${fn}_complete_root`);
|
|
3087
|
+
lines.push(` end`);
|
|
3088
|
+
lines.push(`end`);
|
|
3089
|
+
lines.push(``);
|
|
3090
|
+
lines.push(`# Clear existing completions`);
|
|
3091
|
+
lines.push(`complete -e -c ${programName}`);
|
|
3092
|
+
lines.push(``);
|
|
3093
|
+
lines.push(`# Register completion`);
|
|
3094
|
+
lines.push(`complete -c ${programName} -f -a '(__fish_${fn}_complete)'`);
|
|
3095
|
+
lines.push(``);
|
|
3096
|
+
return {
|
|
3097
|
+
script: lines.join("\n"),
|
|
3098
|
+
shell: "fish",
|
|
3099
|
+
installInstructions: `# To enable completions, run one of the following:
|
|
3100
|
+
|
|
3101
|
+
# Option 1: Source directly
|
|
3102
|
+
${programName} completion fish | source
|
|
3103
|
+
|
|
3104
|
+
# Option 2: Save to the fish completions directory
|
|
3105
|
+
${programName} completion fish > ~/.config/fish/completions/${programName}.fish
|
|
3106
|
+
|
|
3107
|
+
# The completion will be available immediately in new shell sessions.
|
|
3108
|
+
# To use in the current session, run:
|
|
3109
|
+
source ~/.config/fish/completions/${programName}.fish`
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
//#endregion
|
|
3114
|
+
//#region src/completion/loader.ts
|
|
3115
|
+
/**
|
|
3116
|
+
* Rc-loader generators (bash / zsh).
|
|
3117
|
+
*
|
|
3118
|
+
* These produce the small snippet a user adds once to `~/.bashrc` or
|
|
3119
|
+
* `~/.zshrc`. The snippet:
|
|
3120
|
+
*
|
|
3121
|
+
* 1. Looks up the binary on $PATH.
|
|
3122
|
+
* 2. Reads its mtime.
|
|
3123
|
+
* 3. If the on-disk completion cache is missing or its
|
|
3124
|
+
* `# politty-bin-sig:` header differs, regenerates the cache by
|
|
3125
|
+
* spawning the binary once.
|
|
3126
|
+
* 4. Sources the cache.
|
|
3127
|
+
*
|
|
3128
|
+
* All failure modes are silent no-ops so a broken / missing CLI never
|
|
3129
|
+
* blocks shell startup.
|
|
3130
|
+
*/
|
|
3131
|
+
/**
|
|
3132
|
+
* Single-quote escape: `'` -> `'\''`. Inside single quotes the shell
|
|
3133
|
+
* performs no expansion at all, so `$`, backticks, and `$(...)` are
|
|
3134
|
+
* inert. Used for hardcoded paths because callers may sources them
|
|
3135
|
+
* from env / config — we must not let metachars in the path execute as
|
|
3136
|
+
* commands when the rc snippet is sourced.
|
|
3137
|
+
*/
|
|
3138
|
+
function shSingleQuote(s) {
|
|
3139
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
3140
|
+
}
|
|
3141
|
+
function bashCachePathExpr(programName, cacheDir, shell) {
|
|
3142
|
+
if (cacheDir) return shSingleQuote(`${cacheDir}/completion.${shell}`);
|
|
3143
|
+
return `"\${XDG_CACHE_HOME:-$HOME/.cache}/${programName}/completion.${shell}"`;
|
|
3144
|
+
}
|
|
3145
|
+
function generateBashLoader(opts) {
|
|
3146
|
+
const fn = sanitize(opts.programName);
|
|
3147
|
+
const cache = bashCachePathExpr(opts.programName, opts.cacheDir, "bash");
|
|
3148
|
+
return `__${fn}_load_completion() {
|
|
3149
|
+
local _bin _cache _sig _hdr
|
|
3150
|
+
_bin=$(type -P ${opts.programName} 2>/dev/null)
|
|
3151
|
+
[[ -n "$_bin" ]] || return 0
|
|
3152
|
+
_cache=${cache}
|
|
3153
|
+
_sig=$(stat -L -c '%Y' "$_bin" 2>/dev/null || stat -L -f '%m' "$_bin" 2>/dev/null) || return 0
|
|
3154
|
+
_hdr="# politty-bin-sig: $_sig"
|
|
3155
|
+
if [[ ! -f "$_cache" ]] || ! head -5 "$_cache" 2>/dev/null | grep -qF "$_hdr"; then
|
|
3156
|
+
# Use the hidden __refresh-completion subcommand instead of
|
|
3157
|
+
# \`$_bin completion bash\`: the foreground completion command
|
|
3158
|
+
# is subject to user setup/cleanup/prompt and required
|
|
3159
|
+
# globalArgs validation, which can silently fail or block when
|
|
3160
|
+
# invoked from rc; runMain bypasses those for __-prefixed
|
|
3161
|
+
# internal subcommands.
|
|
3162
|
+
"$_bin" __refresh-completion bash 2>/dev/null
|
|
3163
|
+
fi
|
|
3164
|
+
# If regen failed but a stale cache survived from a previous run,
|
|
3165
|
+
# source it anyway — a stale completion is preferable to no
|
|
3166
|
+
# completion at all.
|
|
3167
|
+
[[ -f "$_cache" ]] || return 0
|
|
3168
|
+
# shellcheck disable=SC1090
|
|
3169
|
+
source "$_cache"
|
|
3170
|
+
}
|
|
3171
|
+
__${fn}_load_completion
|
|
3172
|
+
unset -f __${fn}_load_completion
|
|
3173
|
+
`;
|
|
3174
|
+
}
|
|
3175
|
+
function generateZshLoader(opts) {
|
|
3176
|
+
const fn = sanitize(opts.programName);
|
|
3177
|
+
const cache = bashCachePathExpr(opts.programName, opts.cacheDir, "zsh");
|
|
3178
|
+
return `__${fn}_load_completion() {
|
|
3179
|
+
emulate -L zsh
|
|
3180
|
+
setopt local_options no_aliases
|
|
3181
|
+
local _bin _cache _sig _hdr
|
|
3182
|
+
_bin=$(whence -p ${opts.programName} 2>/dev/null)
|
|
3183
|
+
[[ -n "$_bin" ]] || return 0
|
|
3184
|
+
_cache=${cache}
|
|
3185
|
+
_sig=$(stat -L -c '%Y' "$_bin" 2>/dev/null || stat -L -f '%m' "$_bin" 2>/dev/null) || return 0
|
|
3186
|
+
_hdr="# politty-bin-sig: $_sig"
|
|
3187
|
+
if [[ ! -f "$_cache" ]] || ! head -5 "$_cache" 2>/dev/null | grep -qF "$_hdr"; then
|
|
3188
|
+
# See bash loader for why we use __refresh-completion instead
|
|
3189
|
+
# of \`$_bin completion zsh\`.
|
|
3190
|
+
"$_bin" __refresh-completion zsh 2>/dev/null
|
|
3191
|
+
fi
|
|
3192
|
+
# See bash loader: keep stale completion over no completion.
|
|
3193
|
+
[[ -f "$_cache" ]] || return 0
|
|
3194
|
+
source "$_cache"
|
|
3195
|
+
}
|
|
3196
|
+
__${fn}_load_completion
|
|
3197
|
+
unfunction __${fn}_load_completion
|
|
3198
|
+
`;
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Build the rc-loader snippet for bash or zsh. Fish doesn't have an
|
|
3202
|
+
* rc-loader; instead, `<program> completion fish --install` writes a
|
|
3203
|
+
* self-rewriting autoload file.
|
|
3204
|
+
*/
|
|
3205
|
+
function generateLoader(opts) {
|
|
3206
|
+
switch (opts.shell) {
|
|
3207
|
+
case "bash": return generateBashLoader(opts);
|
|
3208
|
+
case "zsh": return generateZshLoader(opts);
|
|
3209
|
+
case "fish": throw new Error("fish does not use an rc loader. Run `<program> completion fish --install` to write the self-refreshing autoload file instead.");
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Default cache file path (used by `completion <bash|zsh> --install`
|
|
3214
|
+
* and the `__refresh-completion` subcommand). For fish, the install
|
|
3215
|
+
* path is `$__fish_config_dir/completions/<program>.fish` and is
|
|
3216
|
+
* computed inside `installPath()` instead.
|
|
3217
|
+
*/
|
|
3218
|
+
function defaultCacheDir(programName) {
|
|
3219
|
+
return `${process.env.XDG_CACHE_HOME ?? `${process.env.HOME ?? ""}/.cache`}/${programName}`;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
//#endregion
|
|
3223
|
+
//#region src/completion/install.ts
|
|
3224
|
+
/**
|
|
3225
|
+
* On-disk install + refresh helpers.
|
|
3226
|
+
*
|
|
3227
|
+
* `install` writes the generated script to its canonical cache /
|
|
3228
|
+
* autoload path. `refresh` is the body of the `__refresh-completion`
|
|
3229
|
+
* hidden subcommand and the runMain background hook — it regenerates
|
|
3230
|
+
* the cache only when the binary's mtime no longer matches the
|
|
3231
|
+
* embedded `# politty-bin-sig:` header.
|
|
3232
|
+
*
|
|
3233
|
+
* All file I/O is best-effort: failures fall through silently. A stale
|
|
3234
|
+
* (or missing) cache is preferable to crashing the user's shell.
|
|
3235
|
+
*/
|
|
3236
|
+
/**
|
|
3237
|
+
* Resolve where a script for the given shell should live on disk.
|
|
3238
|
+
*
|
|
3239
|
+
* - bash/zsh: `<cacheDir>/completion.<shell>` — sourced by the rc loader.
|
|
3240
|
+
* - fish: `$__fish_config_dir/completions/<program>.fish` — autoloaded
|
|
3241
|
+
* by fish on TAB. We approximate `$__fish_config_dir` from
|
|
3242
|
+
* `$XDG_CONFIG_HOME` / `$HOME`.
|
|
3243
|
+
*/
|
|
3244
|
+
function installPath(programName, shell, cacheDir) {
|
|
3245
|
+
if (shell === "fish") return join(process.env.XDG_CONFIG_HOME ?? `${process.env.HOME ?? ""}/.config`, "fish", "completions", `${programName}.fish`);
|
|
3246
|
+
return join(cacheDir ?? defaultCacheDir(programName), `completion.${shell}`);
|
|
3247
|
+
}
|
|
3248
|
+
/** Atomic write: tmp file in the same dir, then rename. */
|
|
3249
|
+
function writeAtomic(path, content) {
|
|
3250
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3251
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
3252
|
+
writeFileSync(tmp, content);
|
|
3253
|
+
renameSync(tmp, path);
|
|
3254
|
+
}
|
|
3255
|
+
function generateScript(ctx, shell) {
|
|
3256
|
+
return generateCompletion(ctx.rootCommand, {
|
|
3257
|
+
shell,
|
|
3258
|
+
programName: ctx.programName,
|
|
3259
|
+
includeDescriptions: true,
|
|
3260
|
+
...ctx.programVersion !== void 0 && { programVersion: ctx.programVersion },
|
|
3261
|
+
...ctx.binPath !== void 0 && { binPath: ctx.binPath },
|
|
3262
|
+
...ctx.cacheDir !== void 0 && { cacheDir: ctx.cacheDir },
|
|
3263
|
+
...ctx.globalArgsSchema !== void 0 && { globalArgsSchema: ctx.globalArgsSchema }
|
|
3264
|
+
}).script;
|
|
3265
|
+
}
|
|
3266
|
+
/** Write the script for `shell` to its install path. Returns the path. */
|
|
3267
|
+
function install(ctx, shell) {
|
|
3268
|
+
const target = installPath(ctx.programName, shell, ctx.cacheDir);
|
|
3269
|
+
writeAtomic(target, generateScript(ctx, shell));
|
|
3270
|
+
return target;
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Read the first ~5 lines of an existing cache file and return its
|
|
3274
|
+
* embedded bin-sig. Returns `null` when the file is missing, unreadable,
|
|
3275
|
+
* or doesn't have a sig header.
|
|
3276
|
+
*/
|
|
3277
|
+
function readCachedSig(path) {
|
|
3278
|
+
try {
|
|
3279
|
+
if (!existsSync(path)) return null;
|
|
3280
|
+
const m = readFileSync(path, "utf8").split("\n", 6).join("\n").match(/^# politty-bin-sig: (\S+)/m);
|
|
3281
|
+
return m ? m[1] : null;
|
|
3282
|
+
} catch {
|
|
3283
|
+
return null;
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
3287
|
+
* Rewrite the cache only when stale. Used by:
|
|
3288
|
+
* - `<program> __refresh-completion <shell>` (the hidden subcommand
|
|
3289
|
+
* spawned both by the rc loader and by the runMain background hook)
|
|
3290
|
+
*
|
|
3291
|
+
* Caller is responsible for gating: the runMain hook (`maybeSpawnRefresh`)
|
|
3292
|
+
* checks `hasManagedCache` before spawning so we don't silently create
|
|
3293
|
+
* a fish autoload the user never opted into. The rc loader / fish
|
|
3294
|
+
* autoload only run after the user has installed completion in the
|
|
3295
|
+
* first place, so they're allowed to refresh unconditionally.
|
|
3296
|
+
*
|
|
3297
|
+
* Must never throw — a stale completion is fine, a crash isn't.
|
|
3298
|
+
*/
|
|
3299
|
+
function refreshIfStale(ctx, shell) {
|
|
3300
|
+
try {
|
|
3301
|
+
const target = installPath(ctx.programName, shell, ctx.cacheDir);
|
|
3302
|
+
const binPath = resolveBinPath(ctx.programName, ctx.binPath);
|
|
3303
|
+
if (!binPath) return;
|
|
3304
|
+
let currentSig;
|
|
3305
|
+
try {
|
|
3306
|
+
currentSig = Math.floor(statSync(binPath).mtimeMs / 1e3).toString();
|
|
3307
|
+
} catch {
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
if (readCachedSig(target) === currentSig) return;
|
|
3311
|
+
writeAtomic(target, generateScript(ctx, shell));
|
|
3312
|
+
} catch {}
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Returns true when a politty-managed cache file already exists on disk
|
|
3316
|
+
* for the given shell — i.e. the user has installed completion via
|
|
3317
|
+
* `<program> completion <shell> --install` or the rc loader has already
|
|
3318
|
+
* sourced one. Used by the runMain background hook to avoid spawning
|
|
3319
|
+
* the refresher (and thereby silently creating files) on plain CLI runs
|
|
3320
|
+
* the user never opted into.
|
|
3321
|
+
*/
|
|
3322
|
+
function hasManagedCache(ctx, shell) {
|
|
3323
|
+
return readCachedSig(installPath(ctx.programName, shell, ctx.cacheDir)) !== null;
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* Spawn a detached child process that runs `<program> __refresh-completion <shell>`.
|
|
3327
|
+
* The child is fully decoupled (`stdio: "ignore"` + `unref()`), so it
|
|
3328
|
+
* outlives the parent without holding any handles.
|
|
3329
|
+
*
|
|
3330
|
+
* Caller is expected to gate this on the right conditions (interactive
|
|
3331
|
+
* shell, not running inside `__complete` itself, etc.).
|
|
3332
|
+
*
|
|
3333
|
+
* Returns `void` and never throws — even spawn failures are absorbed.
|
|
3334
|
+
*/
|
|
3335
|
+
function spawnBackgroundRefresh(programArgv0, shell) {
|
|
3336
|
+
try {
|
|
3337
|
+
spawn(process.execPath, [
|
|
3338
|
+
programArgv0,
|
|
3339
|
+
"__refresh-completion",
|
|
3340
|
+
shell
|
|
3341
|
+
], {
|
|
3342
|
+
detached: true,
|
|
3343
|
+
stdio: "ignore"
|
|
3344
|
+
}).unref();
|
|
3345
|
+
} catch {}
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
//#endregion
|
|
3349
|
+
//#region src/completion/zsh.ts
|
|
3350
|
+
function escapeDesc(s) {
|
|
3351
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/:/g, "\\:");
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* Escape a candidate value for use inside a `_describe` spec. `_describe`
|
|
3355
|
+
* splits each spec on the first unescaped `:` to separate value from
|
|
3356
|
+
* description, so any literal `:` in the value (URLs, namespaced ids) must
|
|
3357
|
+
* be backslash-escaped — and the escape itself must double up so the final
|
|
3358
|
+
* string interprets `\:` as a single literal.
|
|
3359
|
+
*/
|
|
3360
|
+
function escapeDescribeValue(s) {
|
|
3361
|
+
return s.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Generate zsh value completion lines for a ValueCompletion spec.
|
|
3365
|
+
* Uses `_vals` array (must be declared in the calling function scope).
|
|
3366
|
+
* `location` is required when `vc.type === "expand"`.
|
|
3367
|
+
*/
|
|
3368
|
+
function zshValueLines(vc, fn, location) {
|
|
3369
|
+
if (!vc) return [];
|
|
3370
|
+
switch (vc.type) {
|
|
3371
|
+
case "expand": {
|
|
3372
|
+
if (!location) throw new Error("zshValueLines: expand variant requires a location");
|
|
3373
|
+
const varName = expandTableVarName(fn, location.funcSuffix, location.fieldName);
|
|
3374
|
+
const depKey = location.resolvedDeps.map((d) => d.isGlobal ? `"\${_global_arg_values[${d.name}]:-}"` : `"\${_arg_values[${d.name}]:-}"`).join(`$'\\x1f'`);
|
|
3375
|
+
const bucket = sanitize(location.fieldName);
|
|
3376
|
+
const bucketRef = location.isGlobal ? `\${_global_used_field_keys[${bucket}]:-}` : `\${_used_field_keys[${bucket}]:-}`;
|
|
3377
|
+
const arrayDedupLines = location.isArrayOption ? [` if [[ -n "$_ck" && " ${bucketRef} " == *" $_ck "* ]]; then continue; fi`] : [];
|
|
3378
|
+
return [
|
|
3379
|
+
`local _key=${depKey}`,
|
|
3380
|
+
`local _raw="\${${varName}[$_key]:-}"`,
|
|
3381
|
+
`if [[ -n "$_raw" ]]; then`,
|
|
3382
|
+
` local -a _candidates=("\${(@f)_raw}")`,
|
|
3383
|
+
` _vals=()`,
|
|
3384
|
+
` local _c _ck _cke _vp _seen_keys=" " _desc _has_eq=0 _tmp`,
|
|
3385
|
+
` for _c in "\${_candidates[@]}"; do`,
|
|
3386
|
+
` _tmp="\${_c//\\\\:/$'\\x01'}"`,
|
|
3387
|
+
` _vp="\${_tmp%%:*}"`,
|
|
3388
|
+
` if [[ "$_vp" == *=* ]]; then`,
|
|
3389
|
+
` _cke="\${_c%%=*}"`,
|
|
3390
|
+
` _ck="\${_cke//\\\\:/:}"`,
|
|
3391
|
+
...arrayDedupLines,
|
|
3392
|
+
` if [[ "\${words[CURRENT]}" != *=* ]]; then`,
|
|
3393
|
+
` [[ "$_seen_keys" == *" $_ck "* ]] && continue`,
|
|
3394
|
+
` _seen_keys+="$_ck "`,
|
|
3395
|
+
` if [[ "$_tmp" == *:* ]]; then`,
|
|
3396
|
+
` _desc="\${\${_tmp#*:}//$'\\x01'/\\\\:}"`,
|
|
3397
|
+
` _c="\${_cke}=:$_desc"`,
|
|
3398
|
+
` else`,
|
|
3399
|
+
` _c="\${_cke}="`,
|
|
3400
|
+
` fi`,
|
|
3401
|
+
` _has_eq=1`,
|
|
3402
|
+
` else`,
|
|
3403
|
+
` [[ "$_vp" == *=?* ]] || continue`,
|
|
3404
|
+
` fi`,
|
|
3405
|
+
` fi`,
|
|
3406
|
+
` _vals+=("$_c")`,
|
|
3407
|
+
` done`,
|
|
3408
|
+
` if (( _has_eq )); then`,
|
|
3409
|
+
` __${fn}_cdescribe 'completions' _vals -S ''`,
|
|
3410
|
+
` else`,
|
|
3411
|
+
` __${fn}_cdescribe 'completions' _vals`,
|
|
3412
|
+
` fi`,
|
|
3413
|
+
`fi`
|
|
3414
|
+
];
|
|
3415
|
+
}
|
|
3416
|
+
case "dynamic": return [`__${fn}_apply_dynamic_output "$(__${fn}_invoke_complete zsh "\${(@)words[2,CURRENT]}")"`];
|
|
3417
|
+
case "choices": return [`_vals=(${vc.choices.map((c) => `"${escapeDesc(c)}"`).join(" ")})`, `__${fn}_cdescribe 'completions' _vals`];
|
|
3418
|
+
case "file":
|
|
3419
|
+
if (vc.matcher?.length) return vc.matcher.map((p) => `_files -g "${p}"`);
|
|
3420
|
+
if (vc.extensions?.length) return vc.extensions.map((ext) => `_files -g "*.${ext}"`);
|
|
3421
|
+
return [`_files`];
|
|
3422
|
+
case "directory": return [`_files -/`];
|
|
3423
|
+
case "command": return [`_vals=("\${(@f)$(${vc.shellCommand})}")`, `__${fn}_cdescribe 'completions' _vals`];
|
|
3424
|
+
case "none": return [];
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
/** Generate option-value case branches */
|
|
3428
|
+
function optionValueCases(options, positionals, fn, funcSuffix) {
|
|
3429
|
+
const lines = [];
|
|
3430
|
+
for (const opt of options) {
|
|
3431
|
+
if (!opt.takesValue || !opt.valueCompletion) continue;
|
|
3432
|
+
const valLines = zshValueLines(opt.valueCompletion, fn, {
|
|
3433
|
+
funcSuffix,
|
|
3434
|
+
...optionExpandLocation(opt, options, positionals)
|
|
3435
|
+
});
|
|
3436
|
+
if (valLines.length === 0) continue;
|
|
3437
|
+
const patterns = effectiveOptionTokens(opt, options);
|
|
3438
|
+
if (patterns.length === 0) continue;
|
|
3439
|
+
lines.push(` ${patterns.join("|")})`);
|
|
3440
|
+
for (const vl of valLines) lines.push(` ${vl}`);
|
|
3441
|
+
lines.push(` return 0 ;;`);
|
|
3442
|
+
}
|
|
3443
|
+
return lines;
|
|
3444
|
+
}
|
|
3445
|
+
/** Generate positional completion block */
|
|
3446
|
+
function positionalBlock(positionals, fn, funcSuffix, options = []) {
|
|
3447
|
+
if (positionals.length === 0) return [];
|
|
3448
|
+
const lines = [];
|
|
3449
|
+
lines.push(` case "$_pos_count" in`);
|
|
3450
|
+
for (const pos of positionals) {
|
|
3451
|
+
if (pos.variadic) lines.push(` ${pos.position}|*)`);
|
|
3452
|
+
else lines.push(` ${pos.position})`);
|
|
3453
|
+
const valLines = zshValueLines(pos.valueCompletion, fn, {
|
|
3454
|
+
funcSuffix,
|
|
3455
|
+
...positionalExpandLocation(pos, options, positionals)
|
|
3456
|
+
});
|
|
3457
|
+
for (const vl of valLines) lines.push(` ${vl}`);
|
|
3458
|
+
lines.push(` ;;`);
|
|
3459
|
+
}
|
|
3460
|
+
lines.push(` esac`);
|
|
3461
|
+
return lines;
|
|
3462
|
+
}
|
|
3463
|
+
/** Generate prev-word value completion case block */
|
|
3464
|
+
function valueCompletionBlock(options, positionals, fn, funcSuffix) {
|
|
3465
|
+
if (!options.some((o) => o.takesValue && o.valueCompletion)) return [];
|
|
3466
|
+
const prevCases = optionValueCases(options, positionals, fn, funcSuffix);
|
|
3467
|
+
if (prevCases.length === 0) return [];
|
|
3468
|
+
return [
|
|
3469
|
+
` case "\${words[CURRENT-1]}" in`,
|
|
3470
|
+
...prevCases,
|
|
3471
|
+
` esac`
|
|
3472
|
+
];
|
|
3473
|
+
}
|
|
3474
|
+
/** Generate available-options list lines */
|
|
3475
|
+
function availableOptionLines(options, fn) {
|
|
3476
|
+
const lines = [];
|
|
3477
|
+
for (const opt of options) {
|
|
3478
|
+
const desc = opt.description ? `:${escapeDesc(opt.description)}` : "";
|
|
3479
|
+
if (opt.valueType === "array") {
|
|
3480
|
+
lines.push(` _opts+=("--${opt.cliName}${desc}")`);
|
|
3481
|
+
continue;
|
|
3482
|
+
}
|
|
3483
|
+
const patterns = quotedAvailabilityTokens(opt.cliName, opt.alias, opt.negation, {
|
|
3484
|
+
isGlobal: opt.isGlobal === true,
|
|
3485
|
+
frameOptions: options
|
|
3486
|
+
});
|
|
3487
|
+
const guard = `__${fn}_not_used ${patterns.join(" ")}`;
|
|
3488
|
+
const negDesc = opt.negationDescription ? `:${escapeDesc(opt.negationDescription)}` : desc;
|
|
3489
|
+
const entries = [{
|
|
3490
|
+
name: opt.cliName,
|
|
3491
|
+
desc
|
|
3492
|
+
}];
|
|
3493
|
+
if (opt.negation) entries.push({
|
|
3494
|
+
name: opt.negation,
|
|
3495
|
+
desc: negDesc
|
|
3496
|
+
});
|
|
3497
|
+
for (const e of entries) {
|
|
3498
|
+
if (!patterns.includes(`"--${e.name}"`)) continue;
|
|
3499
|
+
lines.push(` ${guard} && _opts+=("--${e.name}${e.desc}")`);
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
lines.push(` __${fn}_not_used "--help" && _opts+=("--help:Show help")`);
|
|
3503
|
+
return lines;
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Generate a per-subcommand completion function.
|
|
3507
|
+
* Recursively generates functions for nested subcommands.
|
|
3508
|
+
*/
|
|
3509
|
+
function generateSubHandler(sub, fn, path) {
|
|
3510
|
+
const fullPath = [...path, sub.name];
|
|
3511
|
+
const funcSuffix = fullPath.map(sanitize).join("_");
|
|
3512
|
+
const funcName = `__${fn}_complete_${funcSuffix}`;
|
|
3513
|
+
const visibleSubs = getVisibleSubs(sub.subcommands);
|
|
3514
|
+
const lines = [];
|
|
3515
|
+
for (const child of visibleSubs) lines.push(...generateSubHandler(child, fn, fullPath));
|
|
3516
|
+
lines.push(`${funcName}() {`);
|
|
3517
|
+
lines.push(` local -a _vals=()`);
|
|
3518
|
+
lines.push(...valueCompletionBlock(sub.options, sub.positionals, fn, funcSuffix));
|
|
3519
|
+
const fullPathStr = fullPath.join(":");
|
|
3520
|
+
lines.push(` if __${fn}_opt_takes_value "${fullPathStr}" "\${words[CURRENT-1]}"; then return 0; fi`);
|
|
3521
|
+
if (sub.positionals.length > 0) {
|
|
3522
|
+
lines.push(` if (( _after_dd )); then`);
|
|
3523
|
+
lines.push(...positionalBlock(sub.positionals, fn, funcSuffix, sub.options).map((l) => ` ${l}`));
|
|
3524
|
+
lines.push(` return 0`);
|
|
3525
|
+
lines.push(` fi`);
|
|
3526
|
+
} else lines.push(` if (( _after_dd )); then return 0; fi`);
|
|
3527
|
+
lines.push(` if [[ "\${words[CURRENT]}" == -* ]]; then`);
|
|
3528
|
+
lines.push(` local -a _opts=()`);
|
|
3529
|
+
lines.push(...availableOptionLines(sub.options, fn));
|
|
3530
|
+
lines.push(` __${fn}_cdescribe 'options' _opts`);
|
|
3531
|
+
lines.push(` return 0`);
|
|
3532
|
+
lines.push(` fi`);
|
|
3533
|
+
if (visibleSubs.length > 0) {
|
|
3534
|
+
const subItems = getSubNamesWithAliases(sub.subcommands).map((s) => {
|
|
3535
|
+
const desc = s.description ? `:${escapeDesc(s.description)}` : "";
|
|
3536
|
+
return `"${s.name}${desc}"`;
|
|
3537
|
+
}).join(" ");
|
|
3538
|
+
lines.push(` local -a _subs=(${subItems})`);
|
|
3539
|
+
lines.push(` __${fn}_cdescribe 'subcommands' _subs`);
|
|
3540
|
+
} else if (sub.positionals.length > 0) lines.push(...positionalBlock(sub.positionals, fn, funcSuffix, sub.options));
|
|
3541
|
+
lines.push(`}`);
|
|
3542
|
+
lines.push(``);
|
|
3543
|
+
return lines;
|
|
3544
|
+
}
|
|
3545
|
+
function generateZshCompletion(command, options) {
|
|
3546
|
+
const { programName } = options;
|
|
3547
|
+
const data = extractCompletionData(command, programName, options.globalArgsSchema);
|
|
3548
|
+
const fn = sanitize(programName);
|
|
3549
|
+
const root = data.command;
|
|
3550
|
+
const visibleSubs = getVisibleSubs(root.subcommands);
|
|
3551
|
+
const expandSpecs = collectExpandSpecs(root);
|
|
3552
|
+
const trackedFields = collectTrackedFields(root, expandSpecs, data.globalOptions);
|
|
3553
|
+
const hasExpand = expandSpecs.length > 0;
|
|
3554
|
+
const arrayExpandSpecs = expandSpecs.filter((s) => s.isArrayOption);
|
|
3555
|
+
const hasArrayExpand = arrayExpandSpecs.length > 0;
|
|
3556
|
+
const lines = [];
|
|
3557
|
+
lines.push(`#compdef ${programName}`);
|
|
3558
|
+
lines.push(``);
|
|
3559
|
+
lines.push(...buildHeaderLines({
|
|
3560
|
+
programName,
|
|
3561
|
+
shell: "zsh",
|
|
3562
|
+
binPath: options.binPath,
|
|
3563
|
+
programVersion: options.programVersion
|
|
3564
|
+
}));
|
|
3565
|
+
lines.push(`# Generated by politty`);
|
|
3566
|
+
lines.push(``);
|
|
3567
|
+
for (const spec of expandSpecs) {
|
|
3568
|
+
const varName = expandTableVarName(fn, spec.funcSuffix, spec.fieldName);
|
|
3569
|
+
if (spec.vc.table.length === 0) lines.push(`typeset -gA ${varName}=()`);
|
|
3570
|
+
else {
|
|
3571
|
+
lines.push(`typeset -gA ${varName}=(`);
|
|
3572
|
+
for (const entry of spec.vc.table) {
|
|
3573
|
+
const key = entry.key.join("");
|
|
3574
|
+
const value = entry.candidates.map((c) => {
|
|
3575
|
+
const escapedValue = escapeDescribeValue(c.value);
|
|
3576
|
+
return c.description ? `${escapedValue}:${c.description}` : escapedValue;
|
|
3577
|
+
}).join("\n");
|
|
3578
|
+
lines.push(` ${ansiC(key)} ${ansiC(value)}`);
|
|
3579
|
+
}
|
|
3580
|
+
lines.push(`)`);
|
|
3581
|
+
}
|
|
3582
|
+
lines.push(``);
|
|
3583
|
+
}
|
|
3584
|
+
if (hasDynamicCompletion(root)) {
|
|
3585
|
+
lines.push(...dynamicInvokeCompleteLines(fn, programName));
|
|
3586
|
+
lines.push(``);
|
|
3587
|
+
lines.push(`__${fn}_apply_dynamic_output() {`);
|
|
3588
|
+
lines.push(` local _raw="$1"`);
|
|
3589
|
+
lines.push(` local _directive=0`);
|
|
3590
|
+
lines.push(` local -a _vals _lines`);
|
|
3591
|
+
lines.push(` _lines=("\${(@f)_raw}")`);
|
|
3592
|
+
lines.push(` local _last=$#_lines`);
|
|
3593
|
+
lines.push(` if (( _last >= 1 )) && [[ "\${_lines[$_last]}" == :<-> ]]; then`);
|
|
3594
|
+
lines.push(` _directive="\${_lines[$_last]#:}"`);
|
|
3595
|
+
lines.push(` _lines[$_last]=()`);
|
|
3596
|
+
lines.push(` fi`);
|
|
3597
|
+
lines.push(` local _l`);
|
|
3598
|
+
lines.push(` for _l in "\${_lines[@]}"; do`);
|
|
3599
|
+
lines.push(` [[ -z "$_l" ]] && continue`);
|
|
3600
|
+
lines.push(` _vals+=("$_l")`);
|
|
3601
|
+
lines.push(` done`);
|
|
3602
|
+
lines.push(` if (( \${#_vals[@]} > 0 )); then`);
|
|
3603
|
+
lines.push(` if (( _directive & ${CompletionDirective.NoSpace} )); then`);
|
|
3604
|
+
lines.push(` __${fn}_cdescribe 'completions' _vals -S ''`);
|
|
3605
|
+
lines.push(` else`);
|
|
3606
|
+
lines.push(` __${fn}_cdescribe 'completions' _vals`);
|
|
3607
|
+
lines.push(` fi`);
|
|
3608
|
+
lines.push(` fi`);
|
|
3609
|
+
lines.push(` if (( _directive & ${CompletionDirective.DirectoryCompletion} )); then`);
|
|
3610
|
+
lines.push(` _files -/`);
|
|
3611
|
+
lines.push(` elif (( _directive & ${CompletionDirective.FileCompletion} )); then`);
|
|
3612
|
+
lines.push(` _files`);
|
|
3613
|
+
lines.push(` elif (( \${#_vals[@]} == 0 )) && ! (( _directive & ${CompletionDirective.NoFileCompletion} )); then`);
|
|
3614
|
+
lines.push(` _files`);
|
|
3615
|
+
lines.push(` fi`);
|
|
3616
|
+
lines.push(`}`);
|
|
3617
|
+
lines.push(``);
|
|
3618
|
+
}
|
|
3619
|
+
lines.push(`__${fn}_not_used() {`);
|
|
3620
|
+
lines.push(` local _u _chk`);
|
|
3621
|
+
lines.push(` for _u in "\${_used_opts[@]}"; do`);
|
|
3622
|
+
lines.push(` for _chk in "$@"; do`);
|
|
3623
|
+
lines.push(` [[ "$_u" == "$_chk" ]] && return 1`);
|
|
3624
|
+
lines.push(` done`);
|
|
3625
|
+
lines.push(` done`);
|
|
3626
|
+
lines.push(` return 0`);
|
|
3627
|
+
lines.push(`}`);
|
|
3628
|
+
lines.push(``);
|
|
3629
|
+
lines.push(`__${fn}_cdescribe() {`);
|
|
3630
|
+
lines.push(` _describe "$@" 2>/dev/null && return 0`);
|
|
3631
|
+
lines.push(` shift`);
|
|
3632
|
+
lines.push(` local _cd_arr="$1"`);
|
|
3633
|
+
lines.push(` shift`);
|
|
3634
|
+
lines.push(` local -a _cd_vals=("\${(@)\${(P)_cd_arr}%%:*}")`);
|
|
3635
|
+
lines.push(` compadd "$@" -a _cd_vals 2>/dev/null`);
|
|
3636
|
+
lines.push(` return 0`);
|
|
3637
|
+
lines.push(`}`);
|
|
3638
|
+
lines.push(``);
|
|
3639
|
+
lines.push(`__${fn}_opt_takes_value() {`);
|
|
3640
|
+
lines.push(` case "$1:$2" in`);
|
|
3641
|
+
lines.push(...optTakesValueEntries(root, ""));
|
|
3642
|
+
lines.push(` esac`);
|
|
3643
|
+
lines.push(` return 1`);
|
|
3644
|
+
lines.push(`}`);
|
|
3645
|
+
lines.push(``);
|
|
3646
|
+
if (hasExpand) {
|
|
3647
|
+
lines.push(`__${fn}_track_opt() {`);
|
|
3648
|
+
lines.push(` case "$1:$2" in`);
|
|
3649
|
+
lines.push(...trackOptCaseLines(trackedFields, "zsh"));
|
|
3650
|
+
lines.push(` esac`);
|
|
3651
|
+
lines.push(`}`);
|
|
3652
|
+
lines.push(``);
|
|
3653
|
+
lines.push(`__${fn}_track_pos() {`);
|
|
3654
|
+
lines.push(` case "$1:$2" in`);
|
|
3655
|
+
lines.push(...trackPosCaseLines(trackedFields, "zsh"));
|
|
3656
|
+
lines.push(` esac`);
|
|
3657
|
+
lines.push(`}`);
|
|
3658
|
+
lines.push(``);
|
|
3659
|
+
}
|
|
3660
|
+
if (hasArrayExpand) {
|
|
3661
|
+
lines.push(`__${fn}_track_array_expand() {`);
|
|
3662
|
+
lines.push(` case "$1:$2" in`);
|
|
3663
|
+
lines.push(...trackArrayExpandCaseLines(arrayExpandSpecs, "zsh"));
|
|
3664
|
+
lines.push(` esac`);
|
|
3665
|
+
lines.push(`}`);
|
|
3666
|
+
lines.push(``);
|
|
3667
|
+
}
|
|
3668
|
+
const routeEntries = collectRouteEntries(root);
|
|
3669
|
+
if (routeEntries.length > 0) {
|
|
3670
|
+
lines.push(`__${fn}_is_subcmd() {`);
|
|
3671
|
+
lines.push(` case "$1:$2" in`);
|
|
3672
|
+
lines.push(...isSubcmdCaseLines(routeEntries));
|
|
3673
|
+
lines.push(` esac`);
|
|
3674
|
+
lines.push(` return 1`);
|
|
3675
|
+
lines.push(`}`);
|
|
3676
|
+
lines.push(``);
|
|
3677
|
+
}
|
|
3678
|
+
for (const sub of visibleSubs) lines.push(...generateSubHandler(sub, fn, []));
|
|
3679
|
+
lines.push(`__${fn}_complete_root() {`);
|
|
3680
|
+
lines.push(` local -a _vals=()`);
|
|
3681
|
+
lines.push(...valueCompletionBlock(root.options, root.positionals, fn, "root"));
|
|
3682
|
+
lines.push(` if __${fn}_opt_takes_value "" "\${words[CURRENT-1]}"; then return 0; fi`);
|
|
3683
|
+
if (root.positionals.length > 0) {
|
|
3684
|
+
lines.push(` if (( _after_dd )); then`);
|
|
3685
|
+
lines.push(...positionalBlock(root.positionals, fn, "root", root.options).map((l) => ` ${l}`));
|
|
3686
|
+
lines.push(` return 0`);
|
|
3687
|
+
lines.push(` fi`);
|
|
3688
|
+
} else lines.push(` if (( _after_dd )); then return 0; fi`);
|
|
3689
|
+
lines.push(` if [[ "\${words[CURRENT]}" == -* ]]; then`);
|
|
3690
|
+
lines.push(` local -a _opts=()`);
|
|
3691
|
+
lines.push(...availableOptionLines(root.options, fn));
|
|
3692
|
+
lines.push(` __${fn}_cdescribe 'options' _opts`);
|
|
3693
|
+
if (visibleSubs.length > 0) {
|
|
3694
|
+
lines.push(` else`);
|
|
3695
|
+
const subItems = getSubNamesWithAliases(root.subcommands).map((s) => {
|
|
3696
|
+
const desc = s.description ? `:${escapeDesc(s.description)}` : "";
|
|
3697
|
+
return `"${s.name}${desc}"`;
|
|
3698
|
+
}).join(" ");
|
|
3699
|
+
lines.push(` local -a _subs=(${subItems})`);
|
|
3700
|
+
lines.push(` __${fn}_cdescribe 'subcommands' _subs`);
|
|
3701
|
+
} else if (root.positionals.length > 0) {
|
|
3702
|
+
lines.push(` else`);
|
|
3703
|
+
lines.push(...positionalBlock(root.positionals, fn, "root", root.options).map((l) => ` ${l}`));
|
|
3704
|
+
}
|
|
3705
|
+
lines.push(` fi`);
|
|
3706
|
+
lines.push(`}`);
|
|
3707
|
+
lines.push(``);
|
|
3708
|
+
const subRouting = subDispatchCaseLines(routeEntries, fn).join("\n");
|
|
3709
|
+
lines.push(`_${fn}() {`);
|
|
3710
|
+
lines.push(` (( CURRENT )) || CURRENT=\${#words}`);
|
|
3711
|
+
lines.push(``);
|
|
3712
|
+
lines.push(` local _subcmd="" _after_dd=0 _pos_count=0 _skip_next=0`);
|
|
3713
|
+
lines.push(` local -a _used_opts=()`);
|
|
3714
|
+
if (hasExpand) {
|
|
3715
|
+
lines.push(` local -A _arg_values=()`);
|
|
3716
|
+
lines.push(` local -A _global_arg_values=()`);
|
|
3717
|
+
}
|
|
3718
|
+
if (hasArrayExpand) {
|
|
3719
|
+
lines.push(` local -A _used_field_keys=()`);
|
|
3720
|
+
lines.push(` local -A _global_used_field_keys=()`);
|
|
3721
|
+
lines.push(` local -A _global_arr_seen=()`);
|
|
3722
|
+
}
|
|
3723
|
+
lines.push(``);
|
|
3724
|
+
lines.push(` local _j=2`);
|
|
3725
|
+
lines.push(` while (( _j < CURRENT )); do`);
|
|
3726
|
+
lines.push(` local _w="\${words[_j]}"`);
|
|
3727
|
+
lines.push(` if (( _skip_next )); then _skip_next=0; (( _j++ )); continue; fi`);
|
|
3728
|
+
lines.push(` if [[ "$_w" == "--" ]]; then _after_dd=1; (( _j++ )); continue; fi`);
|
|
3729
|
+
const afterDdTrack = hasExpand ? `__${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"; ` : "";
|
|
3730
|
+
lines.push(` if (( _after_dd )); then ${afterDdTrack}(( _pos_count++ )); (( _j++ )); continue; fi`);
|
|
3731
|
+
lines.push(` if [[ "$_w" == -*=* ]]; then`);
|
|
3732
|
+
lines.push(` _used_opts+=("\${_w%%=*}")`);
|
|
3733
|
+
if (hasExpand) {
|
|
3734
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "\${_w%%=*}" "\${_w#*=}"`);
|
|
3735
|
+
if (hasArrayExpand) lines.push(` __${fn}_track_array_expand "$_subcmd" "\${_w%%=*}" "\${_w#*=}"`);
|
|
3736
|
+
}
|
|
3737
|
+
lines.push(` (( _j++ )); continue`);
|
|
3738
|
+
lines.push(` fi`);
|
|
3739
|
+
lines.push(` if [[ "$_w" == -* ]]; then`);
|
|
3740
|
+
lines.push(` _used_opts+=("$_w")`);
|
|
3741
|
+
lines.push(` if __${fn}_opt_takes_value "$_subcmd" "$_w"; then`);
|
|
3742
|
+
lines.push(` local _next="\${words[_j+1]:-}"`);
|
|
3743
|
+
lines.push(` if [[ -n "$_next" && "$_next" != -* ]]; then _skip_next=1; fi`);
|
|
3744
|
+
if (hasExpand) {
|
|
3745
|
+
lines.push(` if (( _skip_next )); then`);
|
|
3746
|
+
lines.push(` __${fn}_track_opt "$_subcmd" "$_w" "$_next"`);
|
|
3747
|
+
if (hasArrayExpand) {
|
|
3748
|
+
lines.push(` if (( _j + 1 < CURRENT )); then`);
|
|
3749
|
+
lines.push(` __${fn}_track_array_expand "$_subcmd" "$_w" "$_next"`);
|
|
3750
|
+
lines.push(` fi`);
|
|
3751
|
+
}
|
|
3752
|
+
lines.push(` fi`);
|
|
3753
|
+
}
|
|
3754
|
+
lines.push(` fi`);
|
|
3755
|
+
lines.push(` (( _j++ )); continue`);
|
|
3756
|
+
lines.push(` fi`);
|
|
3757
|
+
const clearState = hasArrayExpand ? `; _arg_values=(); _used_field_keys=(); _global_arr_seen=()` : hasExpand ? `; _arg_values=()` : "";
|
|
3758
|
+
const posTrack = hasExpand ? `__${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"; ` : "";
|
|
3759
|
+
if (routeEntries.length > 0) lines.push(` if __${fn}_is_subcmd "$_subcmd" "$_w"; then _subcmd="\${_subcmd:+\${_subcmd}:}$_w"; _used_opts=(); _pos_count=0${clearState}; else ${posTrack}(( _pos_count++ )); fi`);
|
|
3760
|
+
else {
|
|
3761
|
+
if (hasExpand) lines.push(` __${fn}_track_pos "$_subcmd" "$_pos_count" "$_w"`);
|
|
3762
|
+
lines.push(` (( _pos_count++ ))`);
|
|
3763
|
+
}
|
|
3764
|
+
lines.push(` (( _j++ ))`);
|
|
3765
|
+
lines.push(` done`);
|
|
3766
|
+
lines.push(``);
|
|
3767
|
+
lines.push(` case "$_subcmd" in`);
|
|
3768
|
+
lines.push(subRouting);
|
|
3769
|
+
lines.push(` *) __${fn}_complete_root ;;`);
|
|
3770
|
+
lines.push(` esac`);
|
|
3771
|
+
lines.push(`}`);
|
|
3772
|
+
lines.push(``);
|
|
3773
|
+
lines.push(`zstyle ':completion:*:*:${programName}:*' file-patterns '%p:globbed-files *(-/):directories'`);
|
|
3774
|
+
lines.push(``);
|
|
3775
|
+
lines.push(`compdef _${fn} ${programName}`);
|
|
3776
|
+
lines.push(``);
|
|
3777
|
+
return {
|
|
3778
|
+
script: lines.join("\n"),
|
|
3779
|
+
shell: "zsh",
|
|
3780
|
+
installInstructions: `# To enable completions, add the following to your ~/.zshrc:
|
|
3781
|
+
|
|
3782
|
+
# Option 1: Source directly (add before compinit)
|
|
3783
|
+
eval "$(${programName} completion zsh)"
|
|
3784
|
+
|
|
3785
|
+
# Option 2: Save to a file in your fpath
|
|
3786
|
+
${programName} completion zsh > ~/.zsh/completions/_${programName}
|
|
3787
|
+
|
|
3788
|
+
# Make sure your fpath includes the completions directory:
|
|
3789
|
+
# fpath=(~/.zsh/completions $fpath)
|
|
3790
|
+
# autoload -Uz compinit && compinit
|
|
3791
|
+
|
|
3792
|
+
# Then reload your shell or run:
|
|
3793
|
+
source ~/.zshrc`
|
|
3794
|
+
};
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
//#endregion
|
|
3798
|
+
//#region src/completion/index.ts
|
|
3799
|
+
/**
|
|
3800
|
+
* Shell completion generation module
|
|
3801
|
+
*
|
|
3802
|
+
* Provides utilities to generate shell completion scripts for bash, zsh, and fish.
|
|
3803
|
+
*
|
|
3804
|
+
* @example
|
|
3805
|
+
* ```typescript
|
|
3806
|
+
* import { generateCompletion, createCompletionCommand } from "politty/completion";
|
|
3807
|
+
*
|
|
3808
|
+
* // Generate completion script directly
|
|
3809
|
+
* const result = generateCompletion(myCommand, {
|
|
3810
|
+
* shell: "bash",
|
|
3811
|
+
* programName: "mycli"
|
|
3812
|
+
* });
|
|
3813
|
+
* console.log(result.script);
|
|
3814
|
+
*
|
|
3815
|
+
* // Or add a completion subcommand to your CLI
|
|
3816
|
+
* const mainCommand = withCompletionCommand(
|
|
3817
|
+
* defineCommand({
|
|
3818
|
+
* name: "mycli",
|
|
3819
|
+
* subCommands: { ... },
|
|
3820
|
+
* }),
|
|
3821
|
+
* );
|
|
3822
|
+
* ```
|
|
3823
|
+
*/
|
|
3824
|
+
/**
|
|
3825
|
+
* Generate completion script for the specified shell
|
|
3826
|
+
*/
|
|
3827
|
+
function generateCompletion(command, options) {
|
|
3828
|
+
switch (options.shell) {
|
|
3829
|
+
case "bash": return generateBashCompletion(command, options);
|
|
3830
|
+
case "zsh": return generateZshCompletion(command, options);
|
|
3831
|
+
case "fish": return generateFishCompletion(command, options);
|
|
3832
|
+
default: throw new Error(`Unsupported shell: ${options.shell}`);
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
/**
|
|
3836
|
+
* Get the list of supported shells
|
|
3837
|
+
*/
|
|
3838
|
+
function getSupportedShells() {
|
|
3839
|
+
return [
|
|
3840
|
+
"bash",
|
|
3841
|
+
"zsh",
|
|
3842
|
+
"fish"
|
|
3843
|
+
];
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Detect the current shell from environment
|
|
3847
|
+
*/
|
|
3848
|
+
function detectShell() {
|
|
3849
|
+
const shellName = (process.env.SHELL || "").split("/").pop()?.toLowerCase() || "";
|
|
3850
|
+
if (shellName.includes("bash")) return "bash";
|
|
3851
|
+
if (shellName.includes("zsh")) return "zsh";
|
|
3852
|
+
if (shellName.includes("fish")) return "fish";
|
|
3853
|
+
return null;
|
|
3854
|
+
}
|
|
3855
|
+
/**
|
|
3856
|
+
* Schema for the completion command arguments
|
|
3857
|
+
*/
|
|
3858
|
+
const completionArgsSchema = z.object({
|
|
3859
|
+
shell: arg(z.enum([
|
|
3860
|
+
"bash",
|
|
3861
|
+
"zsh",
|
|
3862
|
+
"fish"
|
|
3863
|
+
]).optional().describe("Shell type (auto-detected if not specified)"), {
|
|
3864
|
+
positional: true,
|
|
3865
|
+
description: "Shell type (bash, zsh, or fish)",
|
|
3866
|
+
placeholder: "SHELL"
|
|
3867
|
+
}),
|
|
3868
|
+
instructions: arg(z.boolean().default(false), {
|
|
3869
|
+
alias: "i",
|
|
3870
|
+
description: "Show installation instructions"
|
|
3871
|
+
}),
|
|
3872
|
+
loader: arg(z.boolean().default(false), { description: "Print just the rc loader snippet (bash/zsh). Add it to ~/.bashrc or ~/.zshrc; it auto-regenerates the cache when the binary changes." }),
|
|
3873
|
+
install: arg(z.boolean().default(false), { description: "Write the completion script to its on-disk cache (bash/zsh) or autoload location (fish) instead of printing it." })
|
|
3874
|
+
});
|
|
3875
|
+
const refreshArgsSchema = z.object({ shell: arg(z.enum([
|
|
3876
|
+
"bash",
|
|
3877
|
+
"zsh",
|
|
3878
|
+
"fish"
|
|
3879
|
+
]), {
|
|
3880
|
+
positional: true,
|
|
3881
|
+
description: "Shell to refresh",
|
|
3882
|
+
placeholder: "SHELL"
|
|
3883
|
+
}) });
|
|
3884
|
+
/**
|
|
3885
|
+
* Create a completion subcommand for your CLI
|
|
3886
|
+
*
|
|
3887
|
+
* This creates a ready-to-use subcommand that generates completion scripts.
|
|
3888
|
+
*
|
|
3889
|
+
* @example
|
|
3890
|
+
* ```typescript
|
|
3891
|
+
* const mainCommand = defineCommand({
|
|
3892
|
+
* name: "mycli",
|
|
3893
|
+
* subCommands: {
|
|
3894
|
+
* completion: createCompletionCommand(mainCommand)
|
|
3895
|
+
* }
|
|
3896
|
+
* });
|
|
3897
|
+
* ```
|
|
3898
|
+
*/
|
|
3899
|
+
function createCompletionCommand(rootCommand, programName, globalArgsSchema, extra = {}) {
|
|
3900
|
+
const resolvedProgramName = programName ?? rootCommand.name;
|
|
3901
|
+
const { cacheDir, programVersion } = extra;
|
|
3902
|
+
const refreshExtra = {
|
|
3903
|
+
...cacheDir !== void 0 && { cacheDir },
|
|
3904
|
+
...programVersion !== void 0 && { programVersion },
|
|
3905
|
+
...globalArgsSchema !== void 0 && { globalArgsSchema }
|
|
3906
|
+
};
|
|
3907
|
+
const installCtxBase = {
|
|
3908
|
+
programName: resolvedProgramName,
|
|
3909
|
+
...refreshExtra
|
|
3910
|
+
};
|
|
3911
|
+
const loaderOptsBase = {
|
|
3912
|
+
programName: resolvedProgramName,
|
|
3913
|
+
...cacheDir !== void 0 && { cacheDir }
|
|
3914
|
+
};
|
|
3915
|
+
if (!rootCommand.subCommands?.__complete) rootCommand.subCommands = {
|
|
3916
|
+
...rootCommand.subCommands,
|
|
3917
|
+
__complete: createDynamicCompleteCommand(rootCommand, resolvedProgramName, globalArgsSchema)
|
|
3918
|
+
};
|
|
3919
|
+
if (!rootCommand.subCommands?.["__refresh-completion"]) rootCommand.subCommands = {
|
|
3920
|
+
...rootCommand.subCommands,
|
|
3921
|
+
"__refresh-completion": createRefreshCompletionCommand(rootCommand, resolvedProgramName, refreshExtra)
|
|
3922
|
+
};
|
|
3923
|
+
return defineCommand({
|
|
3924
|
+
name: "completion",
|
|
3925
|
+
description: "Generate shell completion script",
|
|
3926
|
+
args: completionArgsSchema,
|
|
3927
|
+
run(args) {
|
|
3928
|
+
const shellType = args.shell || detectShell();
|
|
3929
|
+
if (!shellType) {
|
|
3930
|
+
console.error("Could not detect shell type. Please specify one of: bash, zsh, fish");
|
|
3931
|
+
process.exitCode = 1;
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
if (args.install) {
|
|
3935
|
+
let target;
|
|
3936
|
+
try {
|
|
3937
|
+
target = install({
|
|
3938
|
+
rootCommand,
|
|
3939
|
+
...installCtxBase
|
|
3940
|
+
}, shellType);
|
|
3941
|
+
} catch (e) {
|
|
3942
|
+
throw new Error(`install failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3943
|
+
}
|
|
3944
|
+
console.error(`installed: ${target}`);
|
|
3945
|
+
if (shellType !== "fish") {
|
|
3946
|
+
console.error("");
|
|
3947
|
+
console.error(`Add to your ~/.${shellType}rc:`);
|
|
3948
|
+
console.error("");
|
|
3949
|
+
console.error(generateLoader({
|
|
3950
|
+
...loaderOptsBase,
|
|
3951
|
+
shell: shellType
|
|
3952
|
+
}).trim().replace(/^/gm, " "));
|
|
3953
|
+
}
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3956
|
+
if (args.loader) {
|
|
3957
|
+
if (shellType === "fish") throw new Error("fish does not use an rc loader. Run `<program> completion fish --install` to write the self-refreshing autoload file instead.");
|
|
3958
|
+
process.stdout.write(generateLoader({
|
|
3959
|
+
...loaderOptsBase,
|
|
3960
|
+
shell: shellType
|
|
3961
|
+
}));
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
const result = generateCompletion(rootCommand, {
|
|
3965
|
+
shell: shellType,
|
|
3966
|
+
programName: resolvedProgramName,
|
|
3967
|
+
includeDescriptions: true,
|
|
3968
|
+
...globalArgsSchema !== void 0 && { globalArgsSchema },
|
|
3969
|
+
...programVersion !== void 0 && { programVersion },
|
|
3970
|
+
...cacheDir !== void 0 && { cacheDir }
|
|
3971
|
+
});
|
|
3972
|
+
if (args.instructions) console.log(result.installInstructions);
|
|
3973
|
+
else console.log(result.script);
|
|
3974
|
+
}
|
|
3975
|
+
});
|
|
3976
|
+
}
|
|
3977
|
+
/**
|
|
3978
|
+
* Hidden subcommand that the runMain background hook spawns. It does
|
|
3979
|
+
* the same stat-compare + atomic rewrite as the rc loader, but in a
|
|
3980
|
+
* detached child process so it's invisible to the user.
|
|
3981
|
+
*/
|
|
3982
|
+
function createRefreshCompletionCommand(rootCommand, programName, extra = {}) {
|
|
3983
|
+
return defineCommand({
|
|
3984
|
+
name: "__refresh-completion",
|
|
3985
|
+
description: "(internal) Refresh the on-disk completion cache if stale.",
|
|
3986
|
+
args: refreshArgsSchema,
|
|
3987
|
+
run(args) {
|
|
3988
|
+
refreshIfStale({
|
|
3989
|
+
rootCommand,
|
|
3990
|
+
programName,
|
|
3991
|
+
...extra
|
|
3992
|
+
}, args.shell);
|
|
3993
|
+
}
|
|
3994
|
+
});
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Wrap a command with a completion subcommand
|
|
3998
|
+
*
|
|
3999
|
+
* This avoids circular references that occur when a command references itself
|
|
4000
|
+
* in its subCommands (e.g., for completion generation).
|
|
4001
|
+
*
|
|
4002
|
+
* @param command - The command to wrap
|
|
4003
|
+
* @param options - Options including programName
|
|
4004
|
+
* @returns A new command with the completion subcommand added
|
|
4005
|
+
*
|
|
4006
|
+
* @example
|
|
4007
|
+
* ```typescript
|
|
4008
|
+
* const mainCommand = withCompletionCommand(
|
|
4009
|
+
* defineCommand({
|
|
4010
|
+
* name: "mycli",
|
|
4011
|
+
* subCommands: { ... },
|
|
4012
|
+
* }),
|
|
4013
|
+
* );
|
|
4014
|
+
* ```
|
|
4015
|
+
*/
|
|
4016
|
+
function withCompletionCommand(command, options) {
|
|
4017
|
+
const { programName, globalArgsSchema, cacheDir, programVersion } = typeof options === "string" ? { programName: options } : options ?? {};
|
|
4018
|
+
const resolvedProgramName = programName ?? command.name;
|
|
4019
|
+
const extra = {
|
|
4020
|
+
...cacheDir !== void 0 && { cacheDir },
|
|
4021
|
+
...programVersion !== void 0 && { programVersion },
|
|
4022
|
+
...globalArgsSchema !== void 0 && { globalArgsSchema }
|
|
4023
|
+
};
|
|
4024
|
+
const wrappedCommand = { ...command };
|
|
4025
|
+
wrappedCommand.subCommands = {
|
|
4026
|
+
...command.subCommands,
|
|
4027
|
+
completion: createCompletionCommand(wrappedCommand, programName, globalArgsSchema, extra),
|
|
4028
|
+
__complete: createDynamicCompleteCommand(wrappedCommand, programName, globalArgsSchema),
|
|
4029
|
+
"__refresh-completion": createRefreshCompletionCommand(wrappedCommand, resolvedProgramName, extra)
|
|
4030
|
+
};
|
|
4031
|
+
wrappedCommand.runMainHook = (argv) => {
|
|
4032
|
+
maybeSpawnRefresh(argv, {
|
|
4033
|
+
programName: resolvedProgramName,
|
|
4034
|
+
...cacheDir !== void 0 && { cacheDir }
|
|
4035
|
+
});
|
|
4036
|
+
};
|
|
4037
|
+
return wrappedCommand;
|
|
4038
|
+
}
|
|
4039
|
+
/**
|
|
4040
|
+
* Background-refresh trigger fired from `runMain` via `runMainHook`.
|
|
4041
|
+
*
|
|
4042
|
+
* Skipped when:
|
|
4043
|
+
* - the user is invoking `__complete` / `__refresh-completion` /
|
|
4044
|
+
* `completion` themselves (avoids loops and double work)
|
|
4045
|
+
* - $SHELL doesn't resolve to a known shell
|
|
4046
|
+
* - the user opted out via $POLITTY_NO_COMPLETION_REFRESH
|
|
4047
|
+
* - process.argv[1] is missing (shouldn't happen for normal CLIs)
|
|
4048
|
+
* - no politty-managed cache exists yet — i.e. the user hasn't
|
|
4049
|
+
* installed completion. Without this gate the detached child would
|
|
4050
|
+
* create a fish autoload (or any cache file) on every CLI run,
|
|
4051
|
+
* even though the user never opted in via `--install` or the rc loader.
|
|
4052
|
+
*/
|
|
4053
|
+
function maybeSpawnRefresh(argv, ctx) {
|
|
4054
|
+
if (process.env.POLITTY_NO_COMPLETION_REFRESH) return;
|
|
4055
|
+
const firstPositional = argv.find((a) => !a.startsWith("-"));
|
|
4056
|
+
if (firstPositional === "__complete" || firstPositional === "__refresh-completion" || firstPositional === "completion") return;
|
|
4057
|
+
const shell = detectShell();
|
|
4058
|
+
if (!shell) return;
|
|
4059
|
+
const argv0 = process.argv[1];
|
|
4060
|
+
if (!argv0) return;
|
|
4061
|
+
if (!hasManagedCache(ctx, shell)) return;
|
|
4062
|
+
spawnBackgroundRefresh(argv0, shell);
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
//#endregion
|
|
4066
|
+
export { defineCommand as _, getSupportedShells as a, hasCompleteCommand as c, extractPositionals as d, CompletionDirective as f, createDefineCommand as g, resolveValueCompletion as h, generateCompletion as i, formatForShell as l, parseCompletionContext as m, createRefreshCompletionCommand as n, withCompletionCommand as o, generateCandidates as p, detectShell as r, createDynamicCompleteCommand as s, createCompletionCommand as t, extractCompletionData as u };
|
|
4067
|
+
//# sourceMappingURL=completion-BA5JMvVG.js.map
|