@styx-api/core 0.1.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/index.cjs +7947 -0
- package/dist/index.d.cts +1143 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1143 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +7877 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
- package/src/backend/backend.ts +95 -0
- package/src/backend/boutiques/boutiques.ts +1049 -0
- package/src/backend/boutiques/index.ts +1 -0
- package/src/backend/code-builder.ts +49 -0
- package/src/backend/collect-field-info.ts +50 -0
- package/src/backend/collect-named-types.ts +103 -0
- package/src/backend/collect-output-fields.ts +222 -0
- package/src/backend/find-doc.ts +38 -0
- package/src/backend/find-struct-node.ts +66 -0
- package/src/backend/index.ts +39 -0
- package/src/backend/python/arg-builder.ts +454 -0
- package/src/backend/python/emit.ts +638 -0
- package/src/backend/python/index.ts +9 -0
- package/src/backend/python/outputs-emit.ts +430 -0
- package/src/backend/python/packaging.ts +173 -0
- package/src/backend/python/python.ts +558 -0
- package/src/backend/python/snippet.ts +84 -0
- package/src/backend/python/typemap.ts +131 -0
- package/src/backend/python/types.ts +8 -0
- package/src/backend/python/validate-emit.ts +356 -0
- package/src/backend/resolve-field-binding.ts +41 -0
- package/src/backend/resolve-output-tokens.ts +80 -0
- package/src/backend/schema/index.ts +2 -0
- package/src/backend/schema/jsonschema.ts +303 -0
- package/src/backend/scope.ts +50 -0
- package/src/backend/sig-entries.ts +97 -0
- package/src/backend/snippet-core.ts +185 -0
- package/src/backend/string-case.ts +30 -0
- package/src/backend/styxdefs-compat.ts +21 -0
- package/src/backend/type-keys.ts +52 -0
- package/src/backend/typescript/arg-builder.ts +420 -0
- package/src/backend/typescript/emit.ts +450 -0
- package/src/backend/typescript/index.ts +10 -0
- package/src/backend/typescript/outputs-emit.ts +389 -0
- package/src/backend/typescript/packaging.ts +130 -0
- package/src/backend/typescript/snippet.ts +60 -0
- package/src/backend/typescript/typemap.ts +47 -0
- package/src/backend/typescript/types.ts +8 -0
- package/src/backend/typescript/typescript.ts +507 -0
- package/src/backend/typescript/validate-emit.ts +341 -0
- package/src/backend/union-variants.ts +42 -0
- package/src/backend/validate-walk.ts +111 -0
- package/src/bindings/binding.ts +77 -0
- package/src/bindings/format.ts +176 -0
- package/src/bindings/index.ts +16 -0
- package/src/bindings/output-gate.ts +50 -0
- package/src/bindings/resolved-output.ts +56 -0
- package/src/bindings/types.ts +16 -0
- package/src/frontend/argdump/index.ts +1 -0
- package/src/frontend/argdump/parser.ts +914 -0
- package/src/frontend/boutiques/destruct-template.ts +50 -0
- package/src/frontend/boutiques/index.ts +1 -0
- package/src/frontend/boutiques/parser.ts +676 -0
- package/src/frontend/boutiques/split-command.ts +69 -0
- package/src/frontend/detect-format.ts +42 -0
- package/src/frontend/frontend.ts +31 -0
- package/src/frontend/index.ts +9 -0
- package/src/frontend/workbench/index.ts +1 -0
- package/src/frontend/workbench/parser.ts +351 -0
- package/src/index.ts +41 -0
- package/src/ir/builders.ts +69 -0
- package/src/ir/format.ts +157 -0
- package/src/ir/index.ts +32 -0
- package/src/ir/meta.ts +91 -0
- package/src/ir/node.ts +95 -0
- package/src/ir/passes/canonicalize.ts +108 -0
- package/src/ir/passes/flatten.ts +73 -0
- package/src/ir/passes/index.ts +7 -0
- package/src/ir/passes/pass.ts +86 -0
- package/src/ir/passes/pipeline.ts +21 -0
- package/src/ir/passes/remove-empty.ts +76 -0
- package/src/ir/passes/simplify.ts +179 -0
- package/src/ir/types.ts +15 -0
- package/src/manifest/context.ts +36 -0
- package/src/manifest/index.ts +3 -0
- package/src/manifest/types.ts +15 -0
- package/src/solver/assign-access.ts +218 -0
- package/src/solver/index.ts +4 -0
- package/src/solver/resolve-outputs.ts +233 -0
- package/src/solver/solver.ts +319 -0
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Binding,
|
|
3
|
+
BindingId,
|
|
4
|
+
BoundType,
|
|
5
|
+
BoundVariant,
|
|
6
|
+
GateAtom,
|
|
7
|
+
OutputScope,
|
|
8
|
+
ResolvedOutput,
|
|
9
|
+
} from "../../bindings/index.js";
|
|
10
|
+
import type { Expr, ScalarKind } from "../../ir/index.js";
|
|
11
|
+
import type { AppMeta } from "../../ir/meta.js";
|
|
12
|
+
import type { CodegenContext } from "../../manifest/index.js";
|
|
13
|
+
import type { Backend, EmittedApp, EmitWarning } from "../backend.js";
|
|
14
|
+
import { collectFieldInfo } from "../collect-field-info.js";
|
|
15
|
+
import { findDoc } from "../find-doc.js";
|
|
16
|
+
import { findStructNode } from "../find-struct-node.js";
|
|
17
|
+
import { outputGate } from "../resolve-output-tokens.js";
|
|
18
|
+
import { resolveFieldBinding } from "../resolve-field-binding.js";
|
|
19
|
+
import { Scope } from "../scope.js";
|
|
20
|
+
import { screamingSnakeCase } from "../string-case.js";
|
|
21
|
+
|
|
22
|
+
// Boutiques descriptor types (output format)
|
|
23
|
+
|
|
24
|
+
interface BtDescriptor {
|
|
25
|
+
name?: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
"schema-version"?: string;
|
|
29
|
+
"tool-version"?: string;
|
|
30
|
+
author?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
"container-image"?: { image: string; type?: string };
|
|
33
|
+
"command-line"?: string;
|
|
34
|
+
inputs?: BtInput[];
|
|
35
|
+
"stdout-output"?: { id: string; name?: string; description?: string };
|
|
36
|
+
"stderr-output"?: { id: string; name?: string; description?: string };
|
|
37
|
+
"output-files"?: BtOutputFile[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface BtOutputFile {
|
|
41
|
+
id: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
"path-template": string;
|
|
45
|
+
optional?: boolean;
|
|
46
|
+
list?: boolean;
|
|
47
|
+
"path-template-stripped-extensions"?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface BtInput {
|
|
51
|
+
id: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
type: string | BtDescriptor | BtDescriptor[];
|
|
55
|
+
"value-key": string;
|
|
56
|
+
optional?: boolean;
|
|
57
|
+
list?: boolean;
|
|
58
|
+
"list-separator"?: string;
|
|
59
|
+
"min-list-entries"?: number;
|
|
60
|
+
"max-list-entries"?: number;
|
|
61
|
+
"command-line-flag"?: string;
|
|
62
|
+
"command-line-flag-separator"?: string;
|
|
63
|
+
"value-choices"?: (string | number)[];
|
|
64
|
+
"default-value"?: string | number | boolean;
|
|
65
|
+
minimum?: number;
|
|
66
|
+
maximum?: number;
|
|
67
|
+
integer?: boolean;
|
|
68
|
+
"resolve-parent"?: boolean;
|
|
69
|
+
mutable?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Wrapper peeling result
|
|
73
|
+
|
|
74
|
+
interface PeeledInput {
|
|
75
|
+
isOptional: boolean;
|
|
76
|
+
isList: boolean;
|
|
77
|
+
listSeparator?: string;
|
|
78
|
+
minListEntries?: number;
|
|
79
|
+
maxListEntries?: number;
|
|
80
|
+
flag?: string;
|
|
81
|
+
flagSeparator?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class BoutiquesEmitter {
|
|
85
|
+
private warnings: EmitWarning[] = [];
|
|
86
|
+
// Scope binding id -> outputs declared on that struct. The solver forces a
|
|
87
|
+
// binding on every output-carrying sequence, so this is a one-liner.
|
|
88
|
+
private outputsByScope = new Map<BindingId, OutputScope>();
|
|
89
|
+
|
|
90
|
+
constructor(private ctx: CodegenContext) {
|
|
91
|
+
for (const scope of ctx.outputScopes) this.outputsByScope.set(scope.scope, scope);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private warn(message: string): void {
|
|
95
|
+
this.warnings.push({ message });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
emit(): { descriptor: BtDescriptor; warnings: EmitWarning[] } {
|
|
99
|
+
const descriptor = this.buildRootDescriptor();
|
|
100
|
+
return { descriptor, warnings: this.warnings };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private buildRootDescriptor(): BtDescriptor {
|
|
104
|
+
const bt: BtDescriptor = { "schema-version": "0.5+styx" };
|
|
105
|
+
|
|
106
|
+
// Map AppMeta to root descriptor fields
|
|
107
|
+
const app = this.ctx.app;
|
|
108
|
+
if (app) {
|
|
109
|
+
this.applyAppMeta(bt, app);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve root binding
|
|
113
|
+
const rootBinding = this.ctx.resolve(this.ctx.expr);
|
|
114
|
+
if (!rootBinding) {
|
|
115
|
+
// No root binding - the solver collapsed single-field sequences.
|
|
116
|
+
// Walk the expression tree directly to synthesize the descriptor.
|
|
117
|
+
this.buildFromUnboundSequence(bt, this.ctx.expr);
|
|
118
|
+
return bt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.buildDescriptorBody(bt, rootBinding, this.ctx.expr);
|
|
122
|
+
return bt;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private applyAppMeta(bt: BtDescriptor, app: AppMeta): void {
|
|
126
|
+
// Boutiques requires `name` at root and disallows `id` there.
|
|
127
|
+
const rootName = app.doc?.title ?? app.id;
|
|
128
|
+
if (rootName) bt.name = rootName;
|
|
129
|
+
if (app.doc?.description) bt.description = app.doc.description;
|
|
130
|
+
if (app.version) bt["tool-version"] = app.version;
|
|
131
|
+
if (app.doc?.authors?.[0]) bt.author = app.doc.authors[0];
|
|
132
|
+
if (app.doc?.urls?.[0]) bt.url = app.doc.urls[0];
|
|
133
|
+
if (app.container) {
|
|
134
|
+
bt["container-image"] = {
|
|
135
|
+
image: app.container.image,
|
|
136
|
+
...(app.container.type && { type: app.container.type }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (app.stdout) {
|
|
140
|
+
bt["stdout-output"] = {
|
|
141
|
+
id: app.stdout.name,
|
|
142
|
+
...(app.stdout.doc?.title && { name: app.stdout.doc.title }),
|
|
143
|
+
...(app.stdout.doc?.description && { description: app.stdout.doc.description }),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (app.stderr) {
|
|
147
|
+
bt["stderr-output"] = {
|
|
148
|
+
id: app.stderr.name,
|
|
149
|
+
...(app.stderr.doc?.title && { name: app.stderr.doc.title }),
|
|
150
|
+
...(app.stderr.doc?.description && { description: app.stderr.doc.description }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private buildDescriptorBody(bt: BtDescriptor, binding: Binding, expr: Expr): void {
|
|
156
|
+
const type = binding.type;
|
|
157
|
+
|
|
158
|
+
if (type.kind === "struct") {
|
|
159
|
+
this.buildFromStruct(bt, type, expr);
|
|
160
|
+
} else {
|
|
161
|
+
// Root is not a struct (e.g. simplified to a literal) - just build command line
|
|
162
|
+
bt["command-line"] = this.buildCommandLineFromExpr(expr);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle sequences where the solver collapsed single-field structs.
|
|
167
|
+
// Walk children, emitting literals as command-line text and bound nodes as inputs.
|
|
168
|
+
private buildFromUnboundSequence(bt: BtDescriptor, expr: Expr): void {
|
|
169
|
+
if (expr.kind !== "sequence") {
|
|
170
|
+
bt["command-line"] = this.buildCommandLineFromExpr(expr);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const scope = new Scope();
|
|
175
|
+
const idScope = new Scope();
|
|
176
|
+
const commandParts: string[] = [];
|
|
177
|
+
const inputs: BtInput[] = [];
|
|
178
|
+
const valueKeyByBinding = new Map<BindingId, string>();
|
|
179
|
+
|
|
180
|
+
for (const child of expr.attrs.nodes) {
|
|
181
|
+
if (child.kind === "literal") {
|
|
182
|
+
commandParts.push(child.attrs.str);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const binding = this.ctx.resolve(child);
|
|
187
|
+
if (binding) {
|
|
188
|
+
// Direct binding on this child
|
|
189
|
+
const id = idScope.add(this.sanitizeId(binding.name));
|
|
190
|
+
const valueKey = scope.add(screamingSnakeCase(id));
|
|
191
|
+
const valueKeyStr = `[${valueKey}]`;
|
|
192
|
+
valueKeyByBinding.set(binding.id, valueKeyStr);
|
|
193
|
+
const peeled = this.peelNode(child, binding.type);
|
|
194
|
+
if (this.isBool(binding.type) && !peeled.flag) {
|
|
195
|
+
const flagStr = this.extractBoolFlag(child);
|
|
196
|
+
if (flagStr) peeled.flag = flagStr;
|
|
197
|
+
}
|
|
198
|
+
const input = this.buildInputFromBinding(binding, id, valueKeyStr, peeled, child);
|
|
199
|
+
commandParts.push(valueKeyStr);
|
|
200
|
+
inputs.push(input);
|
|
201
|
+
} else {
|
|
202
|
+
// No direct binding - might be a nested sequence (subcommand).
|
|
203
|
+
// Try to find bindings deeper inside.
|
|
204
|
+
const deepBinding = this.findDeepBinding(child);
|
|
205
|
+
if (deepBinding) {
|
|
206
|
+
const rawName = child.meta?.name ?? deepBinding.name;
|
|
207
|
+
const id = idScope.add(this.sanitizeId(rawName));
|
|
208
|
+
const valueKey = scope.add(screamingSnakeCase(id));
|
|
209
|
+
const valueKeyStr = `[${valueKey}]`;
|
|
210
|
+
const subBt = this.buildSubCommandFromUnbound(child);
|
|
211
|
+
const input: BtInput = {
|
|
212
|
+
id,
|
|
213
|
+
type: subBt,
|
|
214
|
+
"value-key": valueKeyStr,
|
|
215
|
+
};
|
|
216
|
+
this.finalizeInput(input);
|
|
217
|
+
commandParts.push(valueKeyStr);
|
|
218
|
+
inputs.push(input);
|
|
219
|
+
} else {
|
|
220
|
+
commandParts.push(this.buildCommandLineFromExpr(child));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
bt["command-line"] = commandParts.join(" ");
|
|
226
|
+
bt.inputs = inputs;
|
|
227
|
+
this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Build a subcommand descriptor from an unbound sequence node
|
|
231
|
+
private buildSubCommandFromUnbound(node: Expr): BtDescriptor {
|
|
232
|
+
const bt: BtDescriptor = {};
|
|
233
|
+
if (node.meta?.name) {
|
|
234
|
+
bt.name = node.meta.name;
|
|
235
|
+
bt.id = this.sanitizeId(node.meta.name);
|
|
236
|
+
}
|
|
237
|
+
this.buildFromUnboundSequence(bt, node);
|
|
238
|
+
return bt;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Find any binding in a subtree
|
|
242
|
+
private findDeepBinding(node: Expr): Binding | undefined {
|
|
243
|
+
const binding = this.ctx.resolve(node);
|
|
244
|
+
if (binding) return binding;
|
|
245
|
+
|
|
246
|
+
switch (node.kind) {
|
|
247
|
+
case "sequence":
|
|
248
|
+
for (const child of node.attrs.nodes) {
|
|
249
|
+
const found = this.findDeepBinding(child);
|
|
250
|
+
if (found) return found;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
case "optional":
|
|
254
|
+
return this.findDeepBinding(node.attrs.node);
|
|
255
|
+
case "repeat":
|
|
256
|
+
return this.findDeepBinding(node.attrs.node);
|
|
257
|
+
case "alternative":
|
|
258
|
+
for (const alt of node.attrs.alts) {
|
|
259
|
+
const found = this.findDeepBinding(alt);
|
|
260
|
+
if (found) return found;
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
default:
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build an input from a binding directly (without struct context)
|
|
269
|
+
private buildInputFromBinding(
|
|
270
|
+
binding: Binding,
|
|
271
|
+
id: string,
|
|
272
|
+
valueKey: string,
|
|
273
|
+
peeled: PeeledInput,
|
|
274
|
+
wrapperNode: Expr,
|
|
275
|
+
): BtInput {
|
|
276
|
+
const innerType = this.unwrapType(binding.type);
|
|
277
|
+
const mapped = this.mapType(innerType, wrapperNode);
|
|
278
|
+
|
|
279
|
+
const input: BtInput = {
|
|
280
|
+
id,
|
|
281
|
+
type: mapped.type,
|
|
282
|
+
"value-key": valueKey,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (binding.node.meta?.doc?.title) input.name = binding.node.meta.doc.title;
|
|
286
|
+
if (binding.node.meta?.doc?.description) input.description = binding.node.meta.doc.description;
|
|
287
|
+
|
|
288
|
+
if (peeled.isOptional || mapped.optional) input.optional = true;
|
|
289
|
+
const isList = peeled.isList || mapped.list === true;
|
|
290
|
+
if (isList) {
|
|
291
|
+
input.list = true;
|
|
292
|
+
const listSep = peeled.listSeparator ?? mapped.listSeparator;
|
|
293
|
+
const minEntries = peeled.minListEntries ?? mapped.minListEntries;
|
|
294
|
+
if (listSep !== undefined) input["list-separator"] = listSep;
|
|
295
|
+
if (minEntries !== undefined) input["min-list-entries"] = minEntries;
|
|
296
|
+
if (peeled.maxListEntries !== undefined) input["max-list-entries"] = peeled.maxListEntries;
|
|
297
|
+
}
|
|
298
|
+
if (peeled.flag) {
|
|
299
|
+
input["command-line-flag"] = peeled.flag;
|
|
300
|
+
if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
|
|
301
|
+
}
|
|
302
|
+
if (mapped.integer) input.integer = true;
|
|
303
|
+
if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
|
|
304
|
+
if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
|
|
305
|
+
if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
|
|
306
|
+
if (mapped.resolveParent) input["resolve-parent"] = true;
|
|
307
|
+
if (mapped.mutable) input["mutable"] = true;
|
|
308
|
+
|
|
309
|
+
if (innerType.kind !== "count") {
|
|
310
|
+
const defaultValue = binding.node.meta?.defaultValue;
|
|
311
|
+
if (defaultValue !== undefined) input["default-value"] = defaultValue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.finalizeInput(input);
|
|
315
|
+
return input;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Boutiques' default-value substitutes when the user omits the input;
|
|
319
|
+
// argparse's default is the parser's internal fallback, not a CLI value
|
|
320
|
+
// to materialize. Surface it in the description instead.
|
|
321
|
+
private mergeDefaultIntoDescription(input: BtInput): void {
|
|
322
|
+
const dv = input["default-value"];
|
|
323
|
+
if (dv === undefined) return;
|
|
324
|
+
delete input["default-value"];
|
|
325
|
+
if (input.type === "Flag") return;
|
|
326
|
+
const formatted = typeof dv === "string" ? JSON.stringify(dv) : String(dv);
|
|
327
|
+
const suffix = `Default: ${formatted}`;
|
|
328
|
+
const desc = input.description;
|
|
329
|
+
input.description = desc ? `${desc.replace(/\s+$/, "")} (${suffix})` : suffix;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private buildFromStruct(
|
|
333
|
+
bt: BtDescriptor,
|
|
334
|
+
structType: Extract<BoundType, { kind: "struct" }>,
|
|
335
|
+
expr: Expr,
|
|
336
|
+
): void {
|
|
337
|
+
const structNode = findStructNode(expr, this.ctx, structType);
|
|
338
|
+
if (!structNode) {
|
|
339
|
+
bt["command-line"] = "";
|
|
340
|
+
bt.inputs = [];
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// `findStructNode` may descend below `expr` to the sequence whose direct
|
|
345
|
+
// children are the struct's fields (e.g. when a single-field inner sequence
|
|
346
|
+
// was preserved by flatten to keep its doc, then collapsed). Any command
|
|
347
|
+
// literals on the path from `expr` down to that node would otherwise be
|
|
348
|
+
// dropped - including the tool's own command name. Recover them as a prefix.
|
|
349
|
+
const prefixLiterals =
|
|
350
|
+
structNode === expr ? [] : (commandPrefixLiterals(expr, structNode) ?? []);
|
|
351
|
+
|
|
352
|
+
const scope = new Scope();
|
|
353
|
+
const idScope = new Scope();
|
|
354
|
+
const commandParts: string[] = [...prefixLiterals];
|
|
355
|
+
const inputs: BtInput[] = [];
|
|
356
|
+
const valueKeyByBinding = new Map<BindingId, string>();
|
|
357
|
+
const fieldInfo = collectFieldInfo(this.ctx, structType);
|
|
358
|
+
|
|
359
|
+
for (const child of structNode.attrs.nodes) {
|
|
360
|
+
// Literal nodes -> command-line text
|
|
361
|
+
if (child.kind === "literal") {
|
|
362
|
+
commandParts.push(child.attrs.str);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Try to resolve to a field binding
|
|
367
|
+
const match = resolveFieldBinding(child, this.ctx, structType);
|
|
368
|
+
if (!match) {
|
|
369
|
+
// Unbound non-literal node - no command-line text we can emit.
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { binding, wrapperNode } = match;
|
|
374
|
+
const fieldType = structType.fields[binding.name];
|
|
375
|
+
if (!fieldType) continue;
|
|
376
|
+
|
|
377
|
+
// Skip literal fields (union discriminators, not user-facing)
|
|
378
|
+
if (fieldType.kind === "literal") continue;
|
|
379
|
+
|
|
380
|
+
const id = idScope.add(this.sanitizeId(binding.name));
|
|
381
|
+
const valueKey = scope.add(screamingSnakeCase(id));
|
|
382
|
+
const valueKeyStr = `[${valueKey}]`;
|
|
383
|
+
valueKeyByBinding.set(binding.id, valueKeyStr);
|
|
384
|
+
|
|
385
|
+
// Peel wrapper layers from the IR node
|
|
386
|
+
const peeled = this.peelNode(wrapperNode, fieldType);
|
|
387
|
+
|
|
388
|
+
// Bool IR pattern: optional(literal("-v")) - the literal IS the flag.
|
|
389
|
+
if (this.isBool(fieldType) && !peeled.flag) {
|
|
390
|
+
const flagStr = this.extractBoolFlag(wrapperNode);
|
|
391
|
+
if (flagStr) peeled.flag = flagStr;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const input = this.buildInput(
|
|
395
|
+
binding,
|
|
396
|
+
id,
|
|
397
|
+
fieldType,
|
|
398
|
+
valueKeyStr,
|
|
399
|
+
peeled,
|
|
400
|
+
fieldInfo,
|
|
401
|
+
wrapperNode,
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Add flag to command line if present, then value-key
|
|
405
|
+
if (peeled.flag) {
|
|
406
|
+
if (
|
|
407
|
+
fieldType.kind === "bool" ||
|
|
408
|
+
(fieldType.kind === "optional" && this.isBool(fieldType))
|
|
409
|
+
) {
|
|
410
|
+
// Bool flags: the value-key IS the flag
|
|
411
|
+
commandParts.push(valueKeyStr);
|
|
412
|
+
} else {
|
|
413
|
+
// Value flags: flag then value-key as separate args
|
|
414
|
+
commandParts.push(valueKeyStr);
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
commandParts.push(valueKeyStr);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
inputs.push(input);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
bt["command-line"] = commandParts.join(" ");
|
|
424
|
+
bt.inputs = inputs;
|
|
425
|
+
this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Emit `output-files` entries declared on this struct binding. `optional`
|
|
429
|
+
// and `list` are derived from the unified gate (scope binding's gate plus
|
|
430
|
+
// each ref's binding gate plus type-derived atoms). Refs that point to
|
|
431
|
+
// bindings outside the scope are dropped with a warning; output ids share
|
|
432
|
+
// the input id scope so they cannot collide.
|
|
433
|
+
private emitOutputFiles(
|
|
434
|
+
bt: BtDescriptor,
|
|
435
|
+
scopeNode: Expr,
|
|
436
|
+
valueKeys: Map<BindingId, string>,
|
|
437
|
+
idScope: Scope,
|
|
438
|
+
): void {
|
|
439
|
+
const scopeBinding = this.ctx.resolve(scopeNode);
|
|
440
|
+
if (!scopeBinding) return;
|
|
441
|
+
const scope = this.outputsByScope.get(scopeBinding.id);
|
|
442
|
+
if (!scope || scope.outputs.length === 0) return;
|
|
443
|
+
|
|
444
|
+
const files: BtOutputFile[] = [];
|
|
445
|
+
for (const output of scope.outputs) {
|
|
446
|
+
const file = this.buildOutputFile(scopeBinding.gate, output, valueKeys, idScope);
|
|
447
|
+
if (file) files.push(file);
|
|
448
|
+
}
|
|
449
|
+
if (files.length > 0) bt["output-files"] = files;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private buildOutputFile(
|
|
453
|
+
scopeGate: GateAtom[],
|
|
454
|
+
output: ResolvedOutput,
|
|
455
|
+
valueKeys: Map<BindingId, string>,
|
|
456
|
+
idScope: Scope,
|
|
457
|
+
): BtOutputFile | null {
|
|
458
|
+
let template = "";
|
|
459
|
+
let stripExtensions: string[] | undefined;
|
|
460
|
+
let droppedRef = false;
|
|
461
|
+
|
|
462
|
+
for (const token of output.tokens) {
|
|
463
|
+
if (token.kind === "literal") {
|
|
464
|
+
template += token.value;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const key = valueKeys.get(token.binding);
|
|
468
|
+
if (!key) {
|
|
469
|
+
const bindingName = this.ctx.bindings.get(token.binding)?.name ?? "<unknown>";
|
|
470
|
+
this.warn(
|
|
471
|
+
`Output '${output.name}' references binding '${bindingName}' that is not in the same descriptor scope`,
|
|
472
|
+
);
|
|
473
|
+
droppedRef = true;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
template += key;
|
|
477
|
+
// The parser applies the same stripped-extensions list to every ref in
|
|
478
|
+
// an output, so taking the first non-empty list round-trips cleanly.
|
|
479
|
+
if (token.stripExtensions && !stripExtensions) stripExtensions = token.stripExtensions;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// If we couldn't resolve any refs and the template is empty, drop the
|
|
483
|
+
// output entirely. (A literal-only template stays.)
|
|
484
|
+
if (droppedRef && template === "") return null;
|
|
485
|
+
|
|
486
|
+
const gate = outputGate(scopeGate, output, this.ctx.bindings);
|
|
487
|
+
const isOptional = gate.some((a) => a.kind === "present" || a.kind === "variant");
|
|
488
|
+
const isList = gate.some((a) => a.kind === "iter");
|
|
489
|
+
|
|
490
|
+
const id = idScope.add(this.sanitizeId(output.name));
|
|
491
|
+
const file: BtOutputFile = { id, "path-template": template };
|
|
492
|
+
if (output.doc?.title) file.name = output.doc.title;
|
|
493
|
+
if (output.doc?.description) file.description = output.doc.description;
|
|
494
|
+
if (isOptional) file.optional = true;
|
|
495
|
+
if (isList) file.list = true;
|
|
496
|
+
if (stripExtensions) file["path-template-stripped-extensions"] = stripExtensions;
|
|
497
|
+
return file;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Extract flag literal from a bool IR pattern: optional(literal("-v"))
|
|
501
|
+
private extractBoolFlag(node: Expr): string | undefined {
|
|
502
|
+
if (node.kind === "optional") return this.extractBoolFlag(node.attrs.node);
|
|
503
|
+
if (node.kind === "literal") return node.attrs.str;
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private buildInput(
|
|
508
|
+
binding: Binding,
|
|
509
|
+
id: string,
|
|
510
|
+
fieldType: BoundType,
|
|
511
|
+
valueKey: string,
|
|
512
|
+
peeled: PeeledInput,
|
|
513
|
+
fieldInfo: Map<string, { doc?: string; defaultValue?: string | number | boolean }>,
|
|
514
|
+
wrapperNode: Expr,
|
|
515
|
+
): BtInput {
|
|
516
|
+
const info = fieldInfo.get(binding.name);
|
|
517
|
+
const innerType = this.unwrapType(fieldType);
|
|
518
|
+
const mapped = this.mapType(innerType, wrapperNode);
|
|
519
|
+
|
|
520
|
+
const input: BtInput = {
|
|
521
|
+
id,
|
|
522
|
+
type: mapped.type,
|
|
523
|
+
"value-key": valueKey,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// Name (short label) - only from explicit title, not description
|
|
527
|
+
const title = binding.node.meta?.doc?.title;
|
|
528
|
+
if (title) input.name = title;
|
|
529
|
+
|
|
530
|
+
// Description (longer help text)
|
|
531
|
+
const description =
|
|
532
|
+
info?.doc ?? binding.node.meta?.doc?.description ?? findDoc(binding.node, fieldType);
|
|
533
|
+
if (description) input.description = description;
|
|
534
|
+
|
|
535
|
+
if (peeled.isOptional || mapped.optional) input.optional = true;
|
|
536
|
+
|
|
537
|
+
const isList = peeled.isList || mapped.list === true;
|
|
538
|
+
if (isList) {
|
|
539
|
+
input.list = true;
|
|
540
|
+
const listSep = peeled.listSeparator ?? mapped.listSeparator;
|
|
541
|
+
const minEntries = peeled.minListEntries ?? mapped.minListEntries;
|
|
542
|
+
if (listSep !== undefined) input["list-separator"] = listSep;
|
|
543
|
+
if (minEntries !== undefined) input["min-list-entries"] = minEntries;
|
|
544
|
+
if (peeled.maxListEntries !== undefined) input["max-list-entries"] = peeled.maxListEntries;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Flag
|
|
548
|
+
if (peeled.flag) {
|
|
549
|
+
input["command-line-flag"] = peeled.flag;
|
|
550
|
+
if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Constraints from mapped type
|
|
554
|
+
if (mapped.integer) input.integer = true;
|
|
555
|
+
if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
|
|
556
|
+
if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
|
|
557
|
+
if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
|
|
558
|
+
if (mapped.resolveParent) input["resolve-parent"] = true;
|
|
559
|
+
if (mapped.mutable) input["mutable"] = true;
|
|
560
|
+
|
|
561
|
+
// count's "default" is implicit via min-list-entries:0; skip emitting it.
|
|
562
|
+
if (innerType.kind !== "count") {
|
|
563
|
+
const defaultValue = info?.defaultValue ?? binding.node.meta?.defaultValue;
|
|
564
|
+
if (defaultValue !== undefined) input["default-value"] = defaultValue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
this.finalizeInput(input);
|
|
568
|
+
return input;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Normalize an input so the descriptor is valid even when upstream types
|
|
572
|
+
// are dynamic (functools.partial, custom action classes) and the parser
|
|
573
|
+
// had to fall back to String.
|
|
574
|
+
private finalizeInput(input: BtInput): void {
|
|
575
|
+
if (input.name === undefined) input.name = input.id;
|
|
576
|
+
|
|
577
|
+
// A String with a bool default and no choices is really a Flag.
|
|
578
|
+
if (
|
|
579
|
+
input.type === "String" &&
|
|
580
|
+
typeof input["default-value"] === "boolean" &&
|
|
581
|
+
input["value-choices"] === undefined
|
|
582
|
+
) {
|
|
583
|
+
input.type = "Flag";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (input.type === "Flag") {
|
|
587
|
+
delete input["default-value"];
|
|
588
|
+
delete input["value-choices"];
|
|
589
|
+
delete input.list;
|
|
590
|
+
delete input["list-separator"];
|
|
591
|
+
delete input["min-list-entries"];
|
|
592
|
+
delete input["max-list-entries"];
|
|
593
|
+
} else if (input.type === "String") {
|
|
594
|
+
const dv = input["default-value"];
|
|
595
|
+
if (dv !== undefined && typeof dv !== "string") {
|
|
596
|
+
input["default-value"] = String(dv);
|
|
597
|
+
}
|
|
598
|
+
const choices = input["value-choices"];
|
|
599
|
+
if (Array.isArray(choices)) {
|
|
600
|
+
input["value-choices"] = choices.map((c) => (typeof c === "string" ? c : String(c)));
|
|
601
|
+
}
|
|
602
|
+
} else if (input.type === "Number") {
|
|
603
|
+
const dv = input["default-value"];
|
|
604
|
+
if (dv !== undefined && typeof dv !== "number") {
|
|
605
|
+
const num = Number(dv);
|
|
606
|
+
if (Number.isFinite(num)) input["default-value"] = num;
|
|
607
|
+
else delete input["default-value"];
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Default must be one of the choices, or dropped.
|
|
612
|
+
const choices = input["value-choices"];
|
|
613
|
+
const dv = input["default-value"];
|
|
614
|
+
if (Array.isArray(choices) && dv !== undefined && !choices.some((c) => c === dv)) {
|
|
615
|
+
delete input["default-value"];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
this.mergeDefaultIntoDescription(input);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Peel wrapper layers from an IR node to extract Boutiques input properties.
|
|
622
|
+
// Walks from outermost to innermost, detecting optional/repeat/flag patterns.
|
|
623
|
+
private peelNode(node: Expr, type: BoundType): PeeledInput {
|
|
624
|
+
const result: PeeledInput = { isOptional: false, isList: false };
|
|
625
|
+
this.peelNodeInner(node, type, result);
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private peelNodeInner(node: Expr, type: BoundType, result: PeeledInput): void {
|
|
630
|
+
switch (node.kind) {
|
|
631
|
+
case "optional":
|
|
632
|
+
result.isOptional = true;
|
|
633
|
+
this.peelNodeInner(node.attrs.node, type.kind === "optional" ? type.inner : type, result);
|
|
634
|
+
break;
|
|
635
|
+
|
|
636
|
+
case "repeat":
|
|
637
|
+
// count's Repeat is the count, not a list - mapType handles it.
|
|
638
|
+
if (this.isCount(type)) {
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
result.isList = true;
|
|
642
|
+
if (node.attrs.join !== undefined) result.listSeparator = node.attrs.join;
|
|
643
|
+
if (node.attrs.countMin !== undefined) result.minListEntries = node.attrs.countMin;
|
|
644
|
+
if (node.attrs.countMax !== undefined) result.maxListEntries = node.attrs.countMax;
|
|
645
|
+
this.peelNodeInner(node.attrs.node, type.kind === "list" ? type.item : type, result);
|
|
646
|
+
break;
|
|
647
|
+
|
|
648
|
+
case "sequence": {
|
|
649
|
+
// Detect flag pattern: seq(lit(flag), inner)
|
|
650
|
+
const nodes = node.attrs.nodes;
|
|
651
|
+
if (nodes.length === 2 && nodes[0]!.kind === "literal") {
|
|
652
|
+
const flagLit = nodes[0]!.attrs.str;
|
|
653
|
+
const { flag, separator } = this.splitFlagLiteral(flagLit);
|
|
654
|
+
result.flag = flag;
|
|
655
|
+
if (separator) result.flagSeparator = separator;
|
|
656
|
+
this.peelNodeInner(nodes[1]!, type, result);
|
|
657
|
+
}
|
|
658
|
+
// Otherwise don't peel further (it's a struct sequence or similar)
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
default:
|
|
663
|
+
// Terminal or other node - nothing more to peel
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Split a flag literal like "-f " or "--flag=" into flag + separator.
|
|
669
|
+
// The parser merges flag + separator into one literal: `flag + (flagSep ?? "")`
|
|
670
|
+
private splitFlagLiteral(str: string): { flag: string; separator: string } {
|
|
671
|
+
// If it ends with "=", split there
|
|
672
|
+
if (str.endsWith("=")) {
|
|
673
|
+
return { flag: str.slice(0, -1), separator: "=" };
|
|
674
|
+
}
|
|
675
|
+
// If it ends with whitespace, strip it
|
|
676
|
+
const trimmed = str.trimEnd();
|
|
677
|
+
if (trimmed.length < str.length) {
|
|
678
|
+
return { flag: trimmed, separator: str.slice(trimmed.length) };
|
|
679
|
+
}
|
|
680
|
+
// No separator
|
|
681
|
+
return { flag: str, separator: "" };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Unwrap optional/list layers to get the "core" type for mapping
|
|
685
|
+
private unwrapType(type: BoundType): BoundType {
|
|
686
|
+
if (type.kind === "optional") return this.unwrapType(type.inner);
|
|
687
|
+
if (type.kind === "list") return this.unwrapType(type.item);
|
|
688
|
+
return type;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Returned list/optional fields override peeled values (for `count`, whose
|
|
692
|
+
// IR Repeat does not correspond to a Boutiques list).
|
|
693
|
+
private mapType(
|
|
694
|
+
type: BoundType,
|
|
695
|
+
node: Expr,
|
|
696
|
+
): {
|
|
697
|
+
type: string | BtDescriptor | BtDescriptor[];
|
|
698
|
+
integer?: boolean;
|
|
699
|
+
minimum?: number;
|
|
700
|
+
maximum?: number;
|
|
701
|
+
valueChoices?: (string | number)[];
|
|
702
|
+
resolveParent?: boolean;
|
|
703
|
+
mutable?: boolean;
|
|
704
|
+
list?: boolean;
|
|
705
|
+
listSeparator?: string;
|
|
706
|
+
minListEntries?: number;
|
|
707
|
+
optional?: boolean;
|
|
708
|
+
} {
|
|
709
|
+
switch (type.kind) {
|
|
710
|
+
case "scalar":
|
|
711
|
+
return this.mapScalar(type.scalar, node);
|
|
712
|
+
|
|
713
|
+
case "bool":
|
|
714
|
+
return { type: "Flag" };
|
|
715
|
+
|
|
716
|
+
case "count":
|
|
717
|
+
return this.mapCount(node);
|
|
718
|
+
|
|
719
|
+
case "literal":
|
|
720
|
+
// Single literal - emit as String with value-choices
|
|
721
|
+
return {
|
|
722
|
+
type: "String",
|
|
723
|
+
valueChoices: [type.value],
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
case "struct":
|
|
727
|
+
return { type: this.buildSubCommand(type, node) };
|
|
728
|
+
|
|
729
|
+
case "union":
|
|
730
|
+
return this.mapUnion(type, node);
|
|
731
|
+
|
|
732
|
+
// optional/list should have been unwrapped already
|
|
733
|
+
case "optional":
|
|
734
|
+
return this.mapType(type.inner, node);
|
|
735
|
+
case "list":
|
|
736
|
+
return this.mapType(type.item, node);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private mapScalar(
|
|
741
|
+
scalar: ScalarKind,
|
|
742
|
+
node: Expr,
|
|
743
|
+
): {
|
|
744
|
+
type: string;
|
|
745
|
+
integer?: boolean;
|
|
746
|
+
minimum?: number;
|
|
747
|
+
maximum?: number;
|
|
748
|
+
resolveParent?: boolean;
|
|
749
|
+
mutable?: boolean;
|
|
750
|
+
} {
|
|
751
|
+
const terminal = this.findTerminal(node);
|
|
752
|
+
|
|
753
|
+
switch (scalar) {
|
|
754
|
+
case "int": {
|
|
755
|
+
const result: { type: string; integer: true; minimum?: number; maximum?: number } = {
|
|
756
|
+
type: "Number",
|
|
757
|
+
integer: true,
|
|
758
|
+
};
|
|
759
|
+
if (terminal?.kind === "int") {
|
|
760
|
+
if (terminal.attrs.minValue !== undefined) result.minimum = terminal.attrs.minValue;
|
|
761
|
+
if (terminal.attrs.maxValue !== undefined) result.maximum = terminal.attrs.maxValue;
|
|
762
|
+
}
|
|
763
|
+
return result;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
case "float": {
|
|
767
|
+
const result: { type: string; minimum?: number; maximum?: number } = { type: "Number" };
|
|
768
|
+
if (terminal?.kind === "float") {
|
|
769
|
+
if (terminal.attrs.minValue !== undefined) result.minimum = terminal.attrs.minValue;
|
|
770
|
+
if (terminal.attrs.maxValue !== undefined) result.maximum = terminal.attrs.maxValue;
|
|
771
|
+
}
|
|
772
|
+
return result;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
case "str":
|
|
776
|
+
return { type: "String" };
|
|
777
|
+
|
|
778
|
+
case "path": {
|
|
779
|
+
const result: { type: string; resolveParent?: boolean; mutable?: boolean } = {
|
|
780
|
+
type: "File",
|
|
781
|
+
};
|
|
782
|
+
if (terminal?.kind === "path") {
|
|
783
|
+
if (terminal.attrs.resolveParent) result.resolveParent = true;
|
|
784
|
+
if (terminal.attrs.mutable) result.mutable = true;
|
|
785
|
+
}
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private mapUnion(
|
|
792
|
+
type: Extract<BoundType, { kind: "union" }>,
|
|
793
|
+
node: Expr,
|
|
794
|
+
): {
|
|
795
|
+
type: string | BtDescriptor | BtDescriptor[];
|
|
796
|
+
valueChoices?: (string | number)[];
|
|
797
|
+
} {
|
|
798
|
+
// All-literal union -> value-choices
|
|
799
|
+
const allLiteral = type.variants.every((v: BoundVariant) => v.type.kind === "literal");
|
|
800
|
+
if (allLiteral) {
|
|
801
|
+
return {
|
|
802
|
+
type: "String",
|
|
803
|
+
valueChoices: type.variants.map((v: BoundVariant) =>
|
|
804
|
+
v.type.kind === "literal" ? v.type.value : "",
|
|
805
|
+
),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// All-struct union -> SubCommandUnion
|
|
810
|
+
const allStruct = type.variants.every((v: BoundVariant) => v.type.kind === "struct");
|
|
811
|
+
if (allStruct) {
|
|
812
|
+
return { type: this.buildSubCommandUnion(type, node) };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Mixed union -> wrap each variant as a SubCommand descriptor
|
|
816
|
+
return { type: this.buildMixedUnionAsSubCommands(type, node) };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private buildSubCommand(type: Extract<BoundType, { kind: "struct" }>, node: Expr): BtDescriptor {
|
|
820
|
+
const bt: BtDescriptor = {};
|
|
821
|
+
const structNode = findStructNode(node, this.ctx, type);
|
|
822
|
+
if (structNode) {
|
|
823
|
+
// Recursively serialize as nested descriptor
|
|
824
|
+
this.buildFromStruct(bt, type, node);
|
|
825
|
+
}
|
|
826
|
+
if (node.meta?.name) {
|
|
827
|
+
bt.name = node.meta.name;
|
|
828
|
+
bt.id = this.sanitizeId(node.meta.name);
|
|
829
|
+
}
|
|
830
|
+
return bt;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private buildSubCommandUnion(
|
|
834
|
+
type: Extract<BoundType, { kind: "union" }>,
|
|
835
|
+
node: Expr,
|
|
836
|
+
): BtDescriptor[] {
|
|
837
|
+
const alts = node.kind === "alternative" ? node.attrs.alts : [node];
|
|
838
|
+
|
|
839
|
+
return type.variants.map((variant: BoundVariant, i: number) => {
|
|
840
|
+
const altNode = alts[i] ?? node;
|
|
841
|
+
if (variant.type.kind === "struct") {
|
|
842
|
+
const bt = this.buildSubCommand(variant.type, altNode);
|
|
843
|
+
// The variant has its own name; the wrapperNode's name is the
|
|
844
|
+
// parent (mutex group) name and would otherwise leak down.
|
|
845
|
+
if (variant.name) {
|
|
846
|
+
bt.name = variant.name;
|
|
847
|
+
bt.id = this.sanitizeId(variant.name);
|
|
848
|
+
}
|
|
849
|
+
return bt;
|
|
850
|
+
}
|
|
851
|
+
return this.wrapAsDescriptor(variant, altNode);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private buildMixedUnionAsSubCommands(
|
|
856
|
+
type: Extract<BoundType, { kind: "union" }>,
|
|
857
|
+
node: Expr,
|
|
858
|
+
): BtDescriptor[] {
|
|
859
|
+
const alts = node.kind === "alternative" ? node.attrs.alts : [node];
|
|
860
|
+
|
|
861
|
+
return type.variants.map((variant: BoundVariant, i: number) => {
|
|
862
|
+
const altNode = alts[i] ?? node;
|
|
863
|
+
if (variant.type.kind === "struct") {
|
|
864
|
+
const bt = this.buildSubCommand(variant.type, altNode);
|
|
865
|
+
// The variant has its own name; the wrapperNode's name is the
|
|
866
|
+
// parent (mutex group) name and would otherwise leak down.
|
|
867
|
+
if (variant.name) {
|
|
868
|
+
bt.name = variant.name;
|
|
869
|
+
bt.id = this.sanitizeId(variant.name);
|
|
870
|
+
}
|
|
871
|
+
return bt;
|
|
872
|
+
}
|
|
873
|
+
return this.wrapAsDescriptor(variant, altNode);
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Wrap a non-struct variant as a trivial single-input descriptor
|
|
878
|
+
private wrapAsDescriptor(variant: BoundVariant, node: Expr): BtDescriptor {
|
|
879
|
+
const name = variant.name ?? "value";
|
|
880
|
+
const id = this.sanitizeId(name);
|
|
881
|
+
const mapped = this.mapType(variant.type, node);
|
|
882
|
+
const input: BtInput = {
|
|
883
|
+
id,
|
|
884
|
+
type: mapped.type,
|
|
885
|
+
"value-key": `[${screamingSnakeCase(id)}]`,
|
|
886
|
+
};
|
|
887
|
+
if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
|
|
888
|
+
if (mapped.integer) input.integer = true;
|
|
889
|
+
if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
|
|
890
|
+
if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
|
|
891
|
+
|
|
892
|
+
this.finalizeInput(input);
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
name,
|
|
896
|
+
id,
|
|
897
|
+
"command-line": `[${screamingSnakeCase(id)}]`,
|
|
898
|
+
inputs: [input],
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Find terminal node through wrappers
|
|
903
|
+
private findTerminal(node: Expr): Expr | undefined {
|
|
904
|
+
switch (node.kind) {
|
|
905
|
+
case "optional":
|
|
906
|
+
return this.findTerminal(node.attrs.node);
|
|
907
|
+
case "repeat":
|
|
908
|
+
return this.findTerminal(node.attrs.node);
|
|
909
|
+
case "sequence": {
|
|
910
|
+
const nonLiteral = node.attrs.nodes.find((n) => n.kind !== "literal");
|
|
911
|
+
return nonLiteral ? this.findTerminal(nonLiteral) : undefined;
|
|
912
|
+
}
|
|
913
|
+
default:
|
|
914
|
+
return node;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Build a simple command-line string from literal nodes in an expression
|
|
919
|
+
private buildCommandLineFromExpr(expr: Expr): string {
|
|
920
|
+
if (expr.kind === "literal") return expr.attrs.str;
|
|
921
|
+
if (expr.kind === "sequence") {
|
|
922
|
+
return expr.attrs.nodes.map((n) => this.buildCommandLineFromExpr(n)).join(" ");
|
|
923
|
+
}
|
|
924
|
+
return "";
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private isBool(type: BoundType): boolean {
|
|
928
|
+
if (type.kind === "bool") return true;
|
|
929
|
+
if (type.kind === "optional") return this.isBool(type.inner);
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private isCount(type: BoundType): boolean {
|
|
934
|
+
if (type.kind === "count") return true;
|
|
935
|
+
if (type.kind === "optional") return this.isCount(type.inner);
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Boutiques `id` must match /^[0-9A-Za-z_]+$/ (input ids, sub-descriptor
|
|
940
|
+
// ids, value-keys after [ ] removal). Non-matching chars (e.g. argparse
|
|
941
|
+
// subparser names with hyphens) become underscores.
|
|
942
|
+
private sanitizeId(raw: string): string {
|
|
943
|
+
const cleaned = raw.replace(/[^0-9A-Za-z_]/g, "_");
|
|
944
|
+
return cleaned.length > 0 ? cleaned : "id";
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private findCountRepeat(node: Expr): Extract<Expr, { kind: "repeat" }> | undefined {
|
|
948
|
+
switch (node.kind) {
|
|
949
|
+
case "repeat":
|
|
950
|
+
return node;
|
|
951
|
+
case "optional":
|
|
952
|
+
return this.findCountRepeat(node.attrs.node);
|
|
953
|
+
default:
|
|
954
|
+
return undefined;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Bounded count -> String + enumerated value-choices.
|
|
959
|
+
// Unbounded count -> SubCommand + list:true (no list-separator: each
|
|
960
|
+
// occurrence must be a separate argv element for argparse to count it).
|
|
961
|
+
private mapCount(node: Expr): {
|
|
962
|
+
type: string | BtDescriptor;
|
|
963
|
+
valueChoices?: string[];
|
|
964
|
+
list?: boolean;
|
|
965
|
+
listSeparator?: string;
|
|
966
|
+
minListEntries?: number;
|
|
967
|
+
optional?: boolean;
|
|
968
|
+
} {
|
|
969
|
+
const repeat = this.findCountRepeat(node);
|
|
970
|
+
if (!repeat || repeat.attrs.node.kind !== "literal") {
|
|
971
|
+
return { type: "Flag" };
|
|
972
|
+
}
|
|
973
|
+
const flag = repeat.attrs.node.attrs.str;
|
|
974
|
+
const countMin = repeat.attrs.countMin ?? 0;
|
|
975
|
+
const countMax = repeat.attrs.countMax;
|
|
976
|
+
|
|
977
|
+
if (countMax !== undefined && countMax >= Math.max(countMin, 1)) {
|
|
978
|
+
const choices: string[] = [];
|
|
979
|
+
const start = Math.max(countMin, 1);
|
|
980
|
+
for (let i = start; i <= countMax; i++) choices.push(flag.repeat(i));
|
|
981
|
+
return {
|
|
982
|
+
type: "String",
|
|
983
|
+
valueChoices: choices,
|
|
984
|
+
...(countMin === 0 && { optional: true }),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const dest = repeat.meta?.name;
|
|
989
|
+
const subId = this.sanitizeId(dest ? `${dest}_token` : "count_token");
|
|
990
|
+
const subBt: BtDescriptor = {
|
|
991
|
+
name: subId,
|
|
992
|
+
id: subId,
|
|
993
|
+
"command-line": flag,
|
|
994
|
+
inputs: [],
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
type: subBt,
|
|
999
|
+
list: true,
|
|
1000
|
+
minListEntries: countMin,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* The ordered command literals on the path from `node` down to `target`,
|
|
1007
|
+
* descending only through sequences (the structure `findStructNode` follows to
|
|
1008
|
+
* reach a struct whose fields are direct children). Returns `null` when
|
|
1009
|
+
* `target` is not reachable that way - callers then emit no prefix. Literals
|
|
1010
|
+
* inside `target` itself are excluded; the caller walks those separately.
|
|
1011
|
+
*/
|
|
1012
|
+
function commandPrefixLiterals(node: Expr, target: Expr): string[] | null {
|
|
1013
|
+
if (node === target) return [];
|
|
1014
|
+
if (node.kind !== "sequence") return null;
|
|
1015
|
+
const lits: string[] = [];
|
|
1016
|
+
for (const child of node.attrs.nodes) {
|
|
1017
|
+
if (child === target) return lits;
|
|
1018
|
+
if (child.kind === "literal") {
|
|
1019
|
+
lits.push(child.attrs.str);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const deeper = commandPrefixLiterals(child, target);
|
|
1023
|
+
if (deeper !== null) return [...lits, ...deeper];
|
|
1024
|
+
}
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function generateBoutiques(ctx: CodegenContext): {
|
|
1029
|
+
descriptor: BtDescriptor;
|
|
1030
|
+
warnings: EmitWarning[];
|
|
1031
|
+
} {
|
|
1032
|
+
return new BoutiquesEmitter(ctx).emit();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export class BoutiquesBackend implements Backend {
|
|
1036
|
+
readonly name = "boutiques";
|
|
1037
|
+
readonly target = "boutiques";
|
|
1038
|
+
|
|
1039
|
+
emitApp(ctx: CodegenContext): EmittedApp {
|
|
1040
|
+
const { descriptor, warnings } = generateBoutiques(ctx);
|
|
1041
|
+
const json = JSON.stringify(descriptor, null, 2);
|
|
1042
|
+
return {
|
|
1043
|
+
meta: ctx.app,
|
|
1044
|
+
files: new Map([["descriptor.json", json]]),
|
|
1045
|
+
errors: [],
|
|
1046
|
+
warnings,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|