@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,676 @@
|
|
|
1
|
+
import { nodeRef } from "../../ir/meta.js";
|
|
2
|
+
import type { AppMeta, NodeMeta, NodeRef, Output, OutputToken } from "../../ir/meta.js";
|
|
3
|
+
import type { Documentation } from "../../ir/types.js";
|
|
4
|
+
import type {
|
|
5
|
+
Alternative,
|
|
6
|
+
Expr,
|
|
7
|
+
Float,
|
|
8
|
+
Int,
|
|
9
|
+
Literal,
|
|
10
|
+
Optional,
|
|
11
|
+
Path,
|
|
12
|
+
Repeat,
|
|
13
|
+
Sequence,
|
|
14
|
+
Str,
|
|
15
|
+
} from "../../ir/node.js";
|
|
16
|
+
import type {
|
|
17
|
+
Frontend,
|
|
18
|
+
ParseError,
|
|
19
|
+
ParseResult,
|
|
20
|
+
ParseWarning,
|
|
21
|
+
SourceLocation,
|
|
22
|
+
} from "../frontend.js";
|
|
23
|
+
import { destructTemplate } from "./destruct-template.js";
|
|
24
|
+
import { boutiquesSplitCommand } from "./split-command.js";
|
|
25
|
+
|
|
26
|
+
// Type guards
|
|
27
|
+
|
|
28
|
+
function isObject(x: unknown): x is Record<string, unknown> {
|
|
29
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isString(x: unknown): x is string {
|
|
33
|
+
return typeof x === "string";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNumber(x: unknown): x is number {
|
|
37
|
+
return typeof x === "number";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isArray(x: unknown): x is unknown[] {
|
|
41
|
+
return Array.isArray(x);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Outputs attach to the rootSeq of the descriptor they were declared in
|
|
45
|
+
// (root or a subcommand's sequence). Per-ref gating is computed downstream
|
|
46
|
+
// from each referenced binding's `gate`.
|
|
47
|
+
|
|
48
|
+
// Boutiques types
|
|
49
|
+
|
|
50
|
+
type BtInput = Record<string, unknown>;
|
|
51
|
+
type BtDescriptor = Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
enum InputTypePrimitive {
|
|
54
|
+
String = "String",
|
|
55
|
+
Float = "Float",
|
|
56
|
+
Integer = "Integer",
|
|
57
|
+
File = "File",
|
|
58
|
+
Flag = "Flag",
|
|
59
|
+
SubCommand = "SubCommand",
|
|
60
|
+
SubCommandUnion = "SubCommandUnion",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface InputType {
|
|
64
|
+
primitive: InputTypePrimitive;
|
|
65
|
+
isList: boolean;
|
|
66
|
+
isOptional: boolean;
|
|
67
|
+
isEnum: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Parser
|
|
71
|
+
|
|
72
|
+
export class BoutiquesParser implements Frontend {
|
|
73
|
+
readonly name = "boutiques";
|
|
74
|
+
readonly extensions = ["json"];
|
|
75
|
+
|
|
76
|
+
private errors: ParseError[] = [];
|
|
77
|
+
private warnings: ParseWarning[] = [];
|
|
78
|
+
|
|
79
|
+
private reset(): void {
|
|
80
|
+
this.errors = [];
|
|
81
|
+
this.warnings = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private error(message: string, location?: SourceLocation): void {
|
|
85
|
+
this.errors.push({ message, location });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private warn(message: string, location?: SourceLocation): void {
|
|
89
|
+
this.warnings.push({ message, location });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// JSON parsing
|
|
93
|
+
|
|
94
|
+
private parseJSON(source: string): BtDescriptor | null {
|
|
95
|
+
let parsed: unknown;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(source);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.error(e instanceof SyntaxError ? e.message : "Invalid JSON");
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!isObject(parsed)) {
|
|
104
|
+
this.error("JSON source is not an object");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Input type detection
|
|
112
|
+
|
|
113
|
+
private getInputTypePrimitive(btInput: BtInput): InputTypePrimitive | null {
|
|
114
|
+
const btType = btInput.type;
|
|
115
|
+
|
|
116
|
+
if (btType === undefined) {
|
|
117
|
+
this.error(`type is missing for input: '${btInput.id}'`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isObject(btType)) return InputTypePrimitive.SubCommand;
|
|
122
|
+
if (isArray(btType)) return InputTypePrimitive.SubCommandUnion;
|
|
123
|
+
|
|
124
|
+
const typeName = isString(btType) ? btType : String(btType);
|
|
125
|
+
|
|
126
|
+
switch (typeName) {
|
|
127
|
+
case "String":
|
|
128
|
+
return InputTypePrimitive.String;
|
|
129
|
+
case "File":
|
|
130
|
+
return InputTypePrimitive.File;
|
|
131
|
+
case "Flag":
|
|
132
|
+
return InputTypePrimitive.Flag;
|
|
133
|
+
case "Number":
|
|
134
|
+
return btInput.integer ? InputTypePrimitive.Integer : InputTypePrimitive.Float;
|
|
135
|
+
default:
|
|
136
|
+
this.error(`Unknown input type: '${typeName}'`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private getInputType(btInput: BtInput): InputType | null {
|
|
142
|
+
const primitive = this.getInputTypePrimitive(btInput);
|
|
143
|
+
if (primitive === null) return null;
|
|
144
|
+
|
|
145
|
+
if (primitive === InputTypePrimitive.Flag) {
|
|
146
|
+
return { primitive, isList: false, isOptional: true, isEnum: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isList = btInput.list === true;
|
|
150
|
+
const isOptional = btInput.optional === true;
|
|
151
|
+
const isEnum = btInput["value-choices"] !== undefined;
|
|
152
|
+
|
|
153
|
+
if (primitive === InputTypePrimitive.File && isEnum) {
|
|
154
|
+
this.error(`File input '${btInput.id}' cannot have value-choices`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { primitive, isList, isOptional, isEnum };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Metadata building
|
|
162
|
+
|
|
163
|
+
private buildNodeMeta(btInput: BtInput): NodeMeta | undefined {
|
|
164
|
+
const name = btInput.id;
|
|
165
|
+
const title = btInput.name;
|
|
166
|
+
const description = btInput.description;
|
|
167
|
+
const defaultValue = btInput["default-value"];
|
|
168
|
+
|
|
169
|
+
const hasDefault =
|
|
170
|
+
isString(defaultValue) || isNumber(defaultValue) || typeof defaultValue === "boolean";
|
|
171
|
+
|
|
172
|
+
if (!isString(name) && !isString(title) && !isString(description) && !hasDefault) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...(isString(name) && { name }),
|
|
178
|
+
...((isString(title) || isString(description)) && {
|
|
179
|
+
doc: {
|
|
180
|
+
...(isString(title) && { title }),
|
|
181
|
+
...(isString(description) && { description }),
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
...(hasDefault && { defaultValue }),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private buildStreamMeta(
|
|
189
|
+
bt: Record<string, unknown>,
|
|
190
|
+
): { name: string; doc?: { title?: string; description?: string } } | undefined {
|
|
191
|
+
const id = bt.id;
|
|
192
|
+
if (!isString(id)) return undefined;
|
|
193
|
+
|
|
194
|
+
const name = bt.name;
|
|
195
|
+
const description = bt.description;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
name: id,
|
|
199
|
+
...((isString(name) || isString(description)) && {
|
|
200
|
+
doc: {
|
|
201
|
+
...(isString(name) && { title: name }),
|
|
202
|
+
...(isString(description) && { description }),
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private buildOutput(
|
|
209
|
+
out: BtInput,
|
|
210
|
+
lookup: Record<string, NodeRef>,
|
|
211
|
+
idOptional: Set<string>,
|
|
212
|
+
): { output: Output } | null {
|
|
213
|
+
const id = out.id;
|
|
214
|
+
if (!isString(id)) {
|
|
215
|
+
this.error("output-files entry missing id");
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const template = out["path-template"];
|
|
220
|
+
if (!isString(template)) {
|
|
221
|
+
this.error(`output-files entry '${id}' missing path-template`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const stripRaw = out["path-template-stripped-extensions"];
|
|
226
|
+
const stripExtensions =
|
|
227
|
+
isArray(stripRaw) && stripRaw.every(isString) && stripRaw.length > 0
|
|
228
|
+
? (stripRaw as string[])
|
|
229
|
+
: undefined;
|
|
230
|
+
|
|
231
|
+
const parts = destructTemplate<NodeRef>(template, lookup);
|
|
232
|
+
|
|
233
|
+
const tokens: OutputToken[] = parts.map((part) => {
|
|
234
|
+
if (typeof part === "string") return { kind: "literal" as const, value: part };
|
|
235
|
+
return {
|
|
236
|
+
kind: "ref" as const,
|
|
237
|
+
target: part,
|
|
238
|
+
...(stripExtensions && { stripExtensions }),
|
|
239
|
+
// Boutiques substitutes an unset optional input with the empty string.
|
|
240
|
+
...(idOptional.has(part.name) && { fallback: "" }),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const title = out.name;
|
|
245
|
+
const description = out.description;
|
|
246
|
+
const output: Output = { name: id, tokens };
|
|
247
|
+
if (isString(title) || isString(description)) {
|
|
248
|
+
output.doc = {
|
|
249
|
+
...(isString(title) && { title }),
|
|
250
|
+
...(isString(description) && { description }),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// Boutiques' `optional: bool` on output-files is a tool-author hint and is
|
|
254
|
+
// re-derived at emit time from the refs' bindings - we don't store it.
|
|
255
|
+
|
|
256
|
+
return { output };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Attach `output-files` entries to the descriptor's rootSeq. Per-output
|
|
261
|
+
* gating (which refs are optional, which arm we're inside) is recovered
|
|
262
|
+
* downstream from each ref binding's `gate`.
|
|
263
|
+
*/
|
|
264
|
+
private attachOutputs(rootSeq: Sequence, bt: BtDescriptor): void {
|
|
265
|
+
const outputFiles = bt["output-files"];
|
|
266
|
+
if (!isArray(outputFiles)) return;
|
|
267
|
+
|
|
268
|
+
const lookup: Record<string, NodeRef> = {};
|
|
269
|
+
const idOptional = new Set<string>();
|
|
270
|
+
const inputs = bt["inputs"];
|
|
271
|
+
if (isArray(inputs)) {
|
|
272
|
+
for (const input of inputs) {
|
|
273
|
+
if (isObject(input) && isString(input["value-key"]) && isString(input.id)) {
|
|
274
|
+
lookup[input["value-key"]] = nodeRef(input.id);
|
|
275
|
+
if (input.optional === true) idOptional.add(input.id);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const out of outputFiles) {
|
|
281
|
+
if (!isObject(out)) {
|
|
282
|
+
this.warn("Skipping non-object output-files entry");
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const built = this.buildOutput(out, lookup, idOptional);
|
|
286
|
+
if (!built) continue;
|
|
287
|
+
|
|
288
|
+
if (!rootSeq.meta) rootSeq.meta = {};
|
|
289
|
+
rootSeq.meta.outputs = [...(rootSeq.meta.outputs ?? []), built.output];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private buildAppMeta(bt: BtDescriptor): AppMeta | undefined {
|
|
294
|
+
const id = bt.id ?? bt.name;
|
|
295
|
+
if (!isString(id)) return undefined;
|
|
296
|
+
|
|
297
|
+
const name = bt.name;
|
|
298
|
+
const description = bt.description;
|
|
299
|
+
const version = bt["tool-version"];
|
|
300
|
+
const author = bt.author;
|
|
301
|
+
const url = bt.url;
|
|
302
|
+
const container = bt["container-image"];
|
|
303
|
+
const stdout = bt["stdout-output"];
|
|
304
|
+
const stderr = bt["stderr-output"];
|
|
305
|
+
|
|
306
|
+
const doc: Documentation = {
|
|
307
|
+
...(isString(name) && { title: name }),
|
|
308
|
+
...(isString(description) && { description }),
|
|
309
|
+
...(isString(author) && { authors: [author] }),
|
|
310
|
+
...(isString(url) && { urls: [url] }),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id,
|
|
315
|
+
...(isString(version) && { version }),
|
|
316
|
+
...(Object.keys(doc).length > 0 && { doc }),
|
|
317
|
+
...(isObject(container) &&
|
|
318
|
+
isString(container.image) && {
|
|
319
|
+
container: {
|
|
320
|
+
image: container.image,
|
|
321
|
+
...(isString(container.type) && {
|
|
322
|
+
type: container.type as "docker" | "singularity",
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
...(isObject(stdout) && { stdout: this.buildStreamMeta(stdout) }),
|
|
327
|
+
...(isObject(stderr) && { stderr: this.buildStreamMeta(stderr) }),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Terminal node building
|
|
332
|
+
|
|
333
|
+
private buildEnumAlternative(choices: unknown[], meta?: NodeMeta): Alternative | null {
|
|
334
|
+
const alts: Literal[] = [];
|
|
335
|
+
|
|
336
|
+
for (const choice of choices) {
|
|
337
|
+
if (isString(choice)) {
|
|
338
|
+
alts.push({ kind: "literal", attrs: { str: choice } });
|
|
339
|
+
} else if (isNumber(choice)) {
|
|
340
|
+
alts.push({ kind: "literal", attrs: { str: String(choice) } });
|
|
341
|
+
} else {
|
|
342
|
+
this.warn(`Ignoring non-string/number enum choice: ${JSON.stringify(choice)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (alts.length === 0) return null;
|
|
347
|
+
|
|
348
|
+
const node: Alternative = { kind: "alternative", attrs: { alts } };
|
|
349
|
+
if (meta) node.meta = meta;
|
|
350
|
+
return node;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private buildTerminal(btInput: BtInput, inputType: InputType): Expr | null {
|
|
354
|
+
const meta = this.buildNodeMeta(btInput);
|
|
355
|
+
|
|
356
|
+
if (inputType.isEnum) {
|
|
357
|
+
const choices = btInput["value-choices"];
|
|
358
|
+
if (!isArray(choices)) {
|
|
359
|
+
this.error(`Invalid value-choices for '${btInput.id}'`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return this.buildEnumAlternative(choices, meta);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
switch (inputType.primitive) {
|
|
366
|
+
case InputTypePrimitive.String: {
|
|
367
|
+
const node: Str = { kind: "str", attrs: {} };
|
|
368
|
+
if (meta) node.meta = meta;
|
|
369
|
+
return node;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
case InputTypePrimitive.Integer: {
|
|
373
|
+
const node: Int = { kind: "int", attrs: {} };
|
|
374
|
+
if (isNumber(btInput.minimum)) {
|
|
375
|
+
node.attrs.minValue = Math.floor(btInput.minimum);
|
|
376
|
+
if (btInput["exclusive-minimum"] === true) node.attrs.minValue += 1;
|
|
377
|
+
}
|
|
378
|
+
if (isNumber(btInput.maximum)) {
|
|
379
|
+
node.attrs.maxValue = Math.floor(btInput.maximum);
|
|
380
|
+
if (btInput["exclusive-maximum"] === true) node.attrs.maxValue -= 1;
|
|
381
|
+
}
|
|
382
|
+
if (meta) node.meta = meta;
|
|
383
|
+
return node;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case InputTypePrimitive.Float: {
|
|
387
|
+
const node: Float = { kind: "float", attrs: {} };
|
|
388
|
+
if (isNumber(btInput.minimum)) node.attrs.minValue = btInput.minimum;
|
|
389
|
+
if (isNumber(btInput.maximum)) node.attrs.maxValue = btInput.maximum;
|
|
390
|
+
if (meta) node.meta = meta;
|
|
391
|
+
return node;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case InputTypePrimitive.File: {
|
|
395
|
+
const node: Path = {
|
|
396
|
+
kind: "path",
|
|
397
|
+
attrs: {
|
|
398
|
+
...(btInput["resolve-parent"] === true && { resolveParent: true }),
|
|
399
|
+
...(btInput.mutable === true && { mutable: true }),
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
if (meta) node.meta = meta;
|
|
403
|
+
return node;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case InputTypePrimitive.Flag: {
|
|
407
|
+
const flag = btInput["command-line-flag"];
|
|
408
|
+
if (!isString(flag)) {
|
|
409
|
+
this.error(`Flag input '${btInput.id}' missing command-line-flag`);
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const literal: Literal = { kind: "literal", attrs: { str: flag } };
|
|
413
|
+
const node: Optional = { kind: "optional", attrs: { node: literal } };
|
|
414
|
+
const flagMeta = meta ?? {};
|
|
415
|
+
if (flagMeta.defaultValue === undefined) flagMeta.defaultValue = false;
|
|
416
|
+
node.meta = flagMeta;
|
|
417
|
+
return node;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
case InputTypePrimitive.SubCommand: {
|
|
421
|
+
const nested = btInput.type;
|
|
422
|
+
if (!isObject(nested)) {
|
|
423
|
+
this.error(`Invalid subcommand type for '${btInput.id}'`);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const node = this.parseDescriptor(nested);
|
|
427
|
+
if (node && meta) node.meta = meta;
|
|
428
|
+
return node;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
case InputTypePrimitive.SubCommandUnion: {
|
|
432
|
+
const alts = btInput.type;
|
|
433
|
+
if (!isArray(alts)) {
|
|
434
|
+
this.error(`Invalid subcommand union type for '${btInput.id}'`);
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const parsedAlts: Expr[] = [];
|
|
438
|
+
// A discriminated union dispatches on a unique `@type` (the subcommand
|
|
439
|
+
// id), recorded as `variantTag` so it survives a single-field
|
|
440
|
+
// sub-command collapsing onto its inner field. Two genuinely-duplicate
|
|
441
|
+
// ids (c3d c2d/c3d/c4d declare two byte-identical sub-commands) are
|
|
442
|
+
// dodged to a unique tag so both arms stay addressable rather than the
|
|
443
|
+
// second being an unreachable, codegen-breaking duplicate (the backend
|
|
444
|
+
// rejects duplicate `@type`s outright).
|
|
445
|
+
const usedTags = new Set<string>();
|
|
446
|
+
for (const alt of alts) {
|
|
447
|
+
if (!isObject(alt)) {
|
|
448
|
+
this.warn("Skipping non-object subcommand alternative");
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const parsed = this.parseDescriptor(alt);
|
|
452
|
+
if (parsed) {
|
|
453
|
+
// Tag the arm from the subcommand descriptor's id.
|
|
454
|
+
const altMeta = this.buildAppMeta(alt);
|
|
455
|
+
if (altMeta?.id) {
|
|
456
|
+
let tag = altMeta.id;
|
|
457
|
+
if (usedTags.has(tag)) {
|
|
458
|
+
let n = 2;
|
|
459
|
+
while (usedTags.has(`${altMeta.id}_${n}`)) n++;
|
|
460
|
+
tag = `${altMeta.id}_${n}`;
|
|
461
|
+
this.warn(
|
|
462
|
+
`Duplicate subcommand id '${altMeta.id}' in union '${btInput.id}'; ` +
|
|
463
|
+
`renamed variant to '${tag}' to keep the @type discriminator unique.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
usedTags.add(tag);
|
|
467
|
+
// `variantTag` is the discriminator (survives collapse); `name`
|
|
468
|
+
// gives a unique binding/type name for non-collapsing (multi-field)
|
|
469
|
+
// arms - it is clobbered by the inner field's name when the arm
|
|
470
|
+
// collapses, which is why the tag needs its own channel.
|
|
471
|
+
parsed.meta = { ...parsed.meta, name: tag, variantTag: tag };
|
|
472
|
+
}
|
|
473
|
+
parsedAlts.push(parsed);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (parsedAlts.length === 0) {
|
|
477
|
+
this.error(`No valid alternatives for subcommand union '${btInput.id}'`);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
if (parsedAlts.length === 1) {
|
|
481
|
+
const node = parsedAlts[0]!;
|
|
482
|
+
if (meta) node.meta = { ...node.meta, ...meta };
|
|
483
|
+
return node;
|
|
484
|
+
}
|
|
485
|
+
const node: Alternative = { kind: "alternative", attrs: { alts: parsedAlts } };
|
|
486
|
+
if (meta) node.meta = meta;
|
|
487
|
+
return node;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
default:
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Node wrapping (repeat, flag, optional)
|
|
496
|
+
|
|
497
|
+
private wrapWithRepeat(node: Expr, btInput: BtInput): Repeat {
|
|
498
|
+
return {
|
|
499
|
+
kind: "repeat",
|
|
500
|
+
attrs: {
|
|
501
|
+
node,
|
|
502
|
+
...(isString(btInput["list-separator"]) && { join: btInput["list-separator"] }),
|
|
503
|
+
...(isNumber(btInput["min-list-entries"]) && { countMin: btInput["min-list-entries"] }),
|
|
504
|
+
...(isNumber(btInput["max-list-entries"]) && { countMax: btInput["max-list-entries"] }),
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private wrapWithFlag(node: Expr, btInput: BtInput): Expr {
|
|
510
|
+
const flag = btInput["command-line-flag"];
|
|
511
|
+
if (!isString(flag)) return node;
|
|
512
|
+
|
|
513
|
+
const flagSep = btInput["command-line-flag-separator"];
|
|
514
|
+
const prefix: Literal = {
|
|
515
|
+
kind: "literal",
|
|
516
|
+
attrs: { str: flag + (flagSep ?? "") },
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
return { kind: "sequence", attrs: { nodes: [prefix, node] } };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private wrapWithOptional(node: Expr): Optional {
|
|
523
|
+
return { kind: "optional", attrs: { node } };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private wrapNode(node: Expr, btInput: BtInput, inputType: InputType): Expr {
|
|
527
|
+
// Flags handle their own optional wrapping
|
|
528
|
+
if (inputType.primitive === InputTypePrimitive.Flag) {
|
|
529
|
+
return node;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const inner = node;
|
|
533
|
+
|
|
534
|
+
// Order: repeat -> flag -> optional
|
|
535
|
+
// This produces: optional(sequence(flag, repeat(value)))
|
|
536
|
+
|
|
537
|
+
if (inputType.isList) {
|
|
538
|
+
node = this.wrapWithRepeat(node, btInput);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
node = this.wrapWithFlag(node, btInput);
|
|
542
|
+
|
|
543
|
+
if (inputType.isOptional) {
|
|
544
|
+
node = this.wrapWithOptional(node);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Hoist metadata (doc, default) to outermost wrapper so backends find it
|
|
548
|
+
// on the binding node. Keep name on inner for solver's findDeepName.
|
|
549
|
+
if (node !== inner && inner.meta) {
|
|
550
|
+
const { name, ...rest } = inner.meta;
|
|
551
|
+
if (Object.keys(rest).length > 0) {
|
|
552
|
+
node.meta = { ...node.meta, ...rest };
|
|
553
|
+
inner.meta = name ? { name } : undefined;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return node;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Command line parsing
|
|
561
|
+
|
|
562
|
+
private parseCommandLineTemplate(
|
|
563
|
+
template: string,
|
|
564
|
+
inputsLookup: Map<string, BtInput>,
|
|
565
|
+
): Array<Array<string | BtInput>> {
|
|
566
|
+
let args: string[];
|
|
567
|
+
try {
|
|
568
|
+
args = boutiquesSplitCommand(template);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
this.error(`Failed to parse command-line: ${e instanceof Error ? e.message : String(e)}`);
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const lookupObj = Object.fromEntries(inputsLookup);
|
|
575
|
+
return args.map((arg) => destructTemplate(arg, lookupObj));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private parseDescriptor(bt: BtDescriptor): Sequence | null {
|
|
579
|
+
// Build inputs lookup
|
|
580
|
+
const inputs = bt["inputs"];
|
|
581
|
+
const inputsLookup = new Map<string, BtInput>();
|
|
582
|
+
|
|
583
|
+
if (isArray(inputs)) {
|
|
584
|
+
for (const input of inputs) {
|
|
585
|
+
if (isObject(input) && isString(input["value-key"])) {
|
|
586
|
+
inputsLookup.set(input["value-key"], input);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Parse command line template
|
|
592
|
+
const commandLine = bt["command-line"];
|
|
593
|
+
const segments = isString(commandLine)
|
|
594
|
+
? this.parseCommandLineTemplate(commandLine, inputsLookup)
|
|
595
|
+
: [];
|
|
596
|
+
|
|
597
|
+
// Build IR
|
|
598
|
+
const rootSeq: Sequence = { kind: "sequence", attrs: { nodes: [] } };
|
|
599
|
+
|
|
600
|
+
for (const segment of segments) {
|
|
601
|
+
const seq: Sequence = { kind: "sequence", attrs: { nodes: [], join: "" } };
|
|
602
|
+
|
|
603
|
+
for (const elem of segment) {
|
|
604
|
+
if (isObject(elem)) {
|
|
605
|
+
const inputType = this.getInputType(elem);
|
|
606
|
+
if (inputType === null) continue;
|
|
607
|
+
|
|
608
|
+
let node = this.buildTerminal(elem, inputType);
|
|
609
|
+
if (node === null) continue;
|
|
610
|
+
|
|
611
|
+
node = this.wrapNode(node, elem, inputType);
|
|
612
|
+
seq.attrs.nodes.push(node);
|
|
613
|
+
} else {
|
|
614
|
+
seq.attrs.nodes.push({ kind: "literal", attrs: { str: elem } });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Flatten single-node sequences
|
|
619
|
+
if (seq.attrs.nodes.length === 1) {
|
|
620
|
+
rootSeq.attrs.nodes.push(seq.attrs.nodes[0]!);
|
|
621
|
+
} else if (seq.attrs.nodes.length > 1) {
|
|
622
|
+
rootSeq.attrs.nodes.push(seq);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
this.attachOutputs(rootSeq, bt);
|
|
627
|
+
return rootSeq;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Public API
|
|
631
|
+
|
|
632
|
+
parse(source: string, _filename?: string): ParseResult {
|
|
633
|
+
this.reset();
|
|
634
|
+
|
|
635
|
+
const bt = this.parseJSON(source);
|
|
636
|
+
if (bt === null) {
|
|
637
|
+
return {
|
|
638
|
+
expr: { kind: "sequence", attrs: { nodes: [] } },
|
|
639
|
+
errors: this.errors,
|
|
640
|
+
warnings: this.warnings,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const baseMeta = this.buildAppMeta(bt);
|
|
645
|
+
if (!baseMeta) {
|
|
646
|
+
this.error("Descriptor is missing id/name");
|
|
647
|
+
return {
|
|
648
|
+
expr: { kind: "sequence", attrs: { nodes: [] } },
|
|
649
|
+
errors: this.errors,
|
|
650
|
+
warnings: this.warnings,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const expr = this.parseDescriptor(bt);
|
|
655
|
+
if (expr === null) {
|
|
656
|
+
this.error("Failed to parse command structure");
|
|
657
|
+
return {
|
|
658
|
+
expr: { kind: "sequence", attrs: { nodes: [] } },
|
|
659
|
+
errors: this.errors,
|
|
660
|
+
warnings: this.warnings,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Set root struct name from descriptor id if not already set
|
|
665
|
+
if (!expr.meta?.name && baseMeta?.id) {
|
|
666
|
+
expr.meta = { ...expr.meta, name: baseMeta.id };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
meta: baseMeta,
|
|
671
|
+
expr,
|
|
672
|
+
errors: this.errors,
|
|
673
|
+
warnings: this.warnings,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|