@styx-api/core 0.6.1 → 0.8.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.
Files changed (52) hide show
  1. package/dist/index.cjs +2559 -553
  2. package/dist/index.d.cts +41 -37
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +41 -37
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2558 -549
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
  10. package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
  11. package/src/backend/argtype/argtype.ts +45 -0
  12. package/src/backend/argtype/emit.ts +676 -0
  13. package/src/backend/argtype/index.ts +2 -0
  14. package/src/backend/boutiques/boutiques.ts +55 -47
  15. package/src/backend/field-defaults.ts +40 -0
  16. package/src/backend/find-struct-node.ts +7 -0
  17. package/src/backend/index.ts +2 -9
  18. package/src/backend/nipype/emit.ts +11 -3
  19. package/src/backend/python/arg-builder.ts +2 -27
  20. package/src/backend/python/emit.ts +18 -6
  21. package/src/backend/python/outputs-emit.ts +27 -42
  22. package/src/backend/python/python.ts +46 -22
  23. package/src/backend/python/validate-emit.ts +4 -1
  24. package/src/backend/resolve-output-tokens.ts +0 -76
  25. package/src/backend/typescript/arg-builder.ts +2 -24
  26. package/src/backend/typescript/emit.ts +7 -4
  27. package/src/backend/typescript/outputs-emit.ts +15 -39
  28. package/src/backend/typescript/typemap.ts +9 -1
  29. package/src/backend/typescript/typescript.ts +41 -18
  30. package/src/backend/typescript/validate-emit.ts +4 -1
  31. package/src/backend/union-variants.ts +41 -1
  32. package/src/frontend/argdump/parser.ts +20 -19
  33. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  34. package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
  35. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  36. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  37. package/src/frontend/argtype/ast.ts +114 -0
  38. package/src/frontend/argtype/doc.ts +56 -0
  39. package/src/frontend/argtype/frontmatter.ts +237 -0
  40. package/src/frontend/argtype/index.ts +1 -0
  41. package/src/frontend/argtype/lexer.ts +264 -0
  42. package/src/frontend/argtype/lower.ts +601 -0
  43. package/src/frontend/argtype/parser-frontend.ts +51 -0
  44. package/src/frontend/argtype/parser.ts +533 -0
  45. package/src/frontend/argtype/template.ts +147 -0
  46. package/src/frontend/boutiques/destruct-template.ts +24 -17
  47. package/src/frontend/boutiques/parser.ts +27 -8
  48. package/src/frontend/detect-format.ts +28 -3
  49. package/src/index.ts +23 -9
  50. package/src/ir/passes/canonicalize.ts +18 -5
  51. package/src/ir/passes/simplify.ts +17 -2
  52. package/src/solver/solver.ts +2 -2
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Lower an `AstDocument` to Styx IR (`Expr` + `AppMeta`).
3
+ *
4
+ * Mapping highlights:
5
+ * - Combinators map 1:1 except `set` -> `sequence` (order-not-meaningful is not
6
+ * modeled in the IR) and `any` -> its first branch (the spec's "emit branch 0"
7
+ * rule; the interchangeable alternatives are dropped with a warning).
8
+ * - Aliases are resolved by substitution with cycle detection.
9
+ * - `.output(...)` declarations attach to the nearest enclosing sequence scope,
10
+ * so an output nested in a repeat / subcommand keeps its list / struct shape
11
+ * (per-output gating is recovered downstream from each ref binding's gate).
12
+ */
13
+
14
+ import { alt, float, int, lit, opt, path as pathTerm, rep, seq, str } from "../../ir/builders.js";
15
+ import { nodeRef } from "../../ir/meta.js";
16
+ import type { AppMeta, NodeMeta, Output, OutputToken, StreamOutput } from "../../ir/meta.js";
17
+ import type { Documentation } from "../../ir/types.js";
18
+ import type { Expr, Sequence } from "../../ir/node.js";
19
+ import type { AstAlias, AstDocument, AstNode, AstOutput, SourceSpan } from "./ast.js";
20
+
21
+ /** A lowering diagnostic, optionally located at a source position. The location
22
+ * comes from the offending AST node's span, so an error like a misplaced
23
+ * `.join()` points at the node it was chained onto (parser/lexer diagnostics
24
+ * already carry positions; this extends the same to the lowering stage). */
25
+ export interface LowerDiagnostic {
26
+ message: string;
27
+ line?: number;
28
+ column?: number;
29
+ }
30
+
31
+ export interface LowerResult {
32
+ meta?: AppMeta;
33
+ expr: Sequence;
34
+ errors: LowerDiagnostic[];
35
+ warnings: LowerDiagnostic[];
36
+ }
37
+
38
+ /** Spread a node's span into a diagnostic (no-op when the node has no span). */
39
+ function at(span: SourceSpan | undefined): { line?: number; column?: number } {
40
+ return span ? { line: span.line, column: span.column } : {};
41
+ }
42
+
43
+ /** Build IR `Documentation` from an AST node's already-split title/description. */
44
+ function docFrom(node: { title?: string; description?: string }): Documentation | undefined {
45
+ const doc: Documentation = {
46
+ ...(node.title && { title: node.title }),
47
+ ...(node.description && { description: node.description }),
48
+ };
49
+ return Object.keys(doc).length > 0 ? doc : undefined;
50
+ }
51
+
52
+ class Lowerer {
53
+ readonly errors: LowerDiagnostic[] = [];
54
+ readonly warnings: LowerDiagnostic[] = [];
55
+ private aliases = new Map<string, AstNode>();
56
+
57
+ constructor(aliases: AstAlias[]) {
58
+ for (const a of aliases) {
59
+ if (this.aliases.has(a.name))
60
+ this.warn(`Duplicate alias '${a.name}'; last definition wins`, a.expr);
61
+ this.aliases.set(a.name, a.expr);
62
+ }
63
+ }
64
+
65
+ /** Record an error, located at `node`'s span when one is available. */
66
+ private err(message: string, node?: AstNode): void {
67
+ this.errors.push({ message, ...at(node?.span) });
68
+ }
69
+
70
+ /** Record a warning, located at `node`'s span when one is available. */
71
+ private warn(message: string, node?: AstNode): void {
72
+ this.warnings.push({ message, ...at(node?.span) });
73
+ }
74
+
75
+ lower(doc: AstDocument): LowerResult {
76
+ // Each output attaches to its nearest enclosing sequence scope (via the
77
+ // `sink`), preserving nesting so a per-repeat / per-subcommand output keeps
78
+ // its list/struct shape. `rootSink` catches outputs declared directly on a
79
+ // non-sequence root (a sequence root manages its own outputs).
80
+ const rootSink: Output[] = [];
81
+ const expr = this.lowerNode(doc.root, new Set(), undefined, rootSink);
82
+
83
+ const root: Sequence = expr.kind === "sequence" ? expr : seq(expr);
84
+ if (root !== expr) {
85
+ root.meta = { ...(doc.root.name && { name: doc.root.name }) };
86
+ }
87
+ if (rootSink.length > 0) {
88
+ root.meta = { ...root.meta, outputs: [...(root.meta?.outputs ?? []), ...rootSink] };
89
+ }
90
+
91
+ return { expr: root, errors: this.errors, warnings: this.warnings };
92
+ }
93
+
94
+ /**
95
+ * @param expanding - alias names currently being expanded (cycle guard).
96
+ * @param selfName - nearest enclosing named node, for `{}` self-references.
97
+ * @param sink - outputs array of the nearest enclosing sequence; a `.output()`
98
+ * on this node attaches here (seq/set nodes instead own their outputs).
99
+ */
100
+ private lowerNode(
101
+ node: AstNode,
102
+ expanding: Set<string>,
103
+ selfName: string | undefined,
104
+ sink: Output[],
105
+ ): Expr {
106
+ // Alias reference: substitute then lower.
107
+ if (node.kind === "ref") {
108
+ return this.lowerRef(node, expanding, selfName, sink);
109
+ }
110
+
111
+ const name = node.name ?? selfName;
112
+
113
+ // `.join` collapses a node's subtree into one argv element. It is carried on
114
+ // sequence/repeat in the IR directly, and on `opt` by pushing it onto the
115
+ // wrapped content (`lowerComb`). On any other node it would be silently
116
+ // dropped, changing command-line correctness with no failure signal (a tool
117
+ // gets `["A","B"]` instead of `"AB"`), so a misplaced modifier is a hard
118
+ // error, not a warning. Refs are already resolved above, so by here the
119
+ // node's concrete kind is known and these checks are accurate.
120
+ const joinable =
121
+ node.kind === "comb" &&
122
+ (node.op === "seq" || node.op === "set" || node.op === "rep" || node.op === "opt");
123
+ if (node.join !== undefined && !joinable) {
124
+ this.err("`.join()` is only supported on seq/set/rep/opt", node);
125
+ }
126
+
127
+ // A type-specific modifier that lands on an incompatible node is silently
128
+ // dropped downstream (only the matching lower* case reads it), which quietly
129
+ // discards a value/arity constraint - so, like `.join()`, it is a hard error.
130
+ const isNumericTerminal =
131
+ node.kind === "terminal" && (node.terminal === "int" || node.terminal === "float");
132
+ const isRep = node.kind === "comb" && node.op === "rep";
133
+ const isPath = node.kind === "terminal" && node.terminal === "path";
134
+ if ((node.min !== undefined || node.max !== undefined) && !isNumericTerminal) {
135
+ this.err("`.min()`/`.max()` is only supported on int/float terminals", node);
136
+ }
137
+ if ((node.countMin !== undefined || node.countMax !== undefined) && !isRep) {
138
+ this.err("`.count()`/`.countMin()`/`.countMax()` is only supported on rep", node);
139
+ }
140
+ if ((node.mutable || node.resolveParent) && !isPath) {
141
+ this.err("`.mutable()`/`.resolveParent()` is only supported on path", node);
142
+ }
143
+ if (node.mediaTypes?.length && !isPath) {
144
+ this.err("`.mediaType()` is only supported on path", node);
145
+ }
146
+
147
+ // `.default()` / `= value` is meaningful on a terminal, and also on the
148
+ // combinators that model a defaultable value: `opt` (its default when
149
+ // omitted), `alt` (a union's default variant), `rep` (a default list). Only a
150
+ // `seq`/`set` struct has no scalar to default - a default there is nonsensical
151
+ // and, left in place, would flow to the node's `defaultValue` (which backends
152
+ // read) as a bogus value, so drop it and warn. It never changes which argv is
153
+ // valid, so this is a warning, not an error.
154
+ const isStruct = node.kind === "comb" && (node.op === "seq" || node.op === "set");
155
+ if (node.default !== undefined && isStruct) {
156
+ this.warn("`= value` / `.default()` is not supported on a seq/set struct; ignored", node);
157
+ node.default = undefined;
158
+ }
159
+
160
+ // A `.output()` on a non-sequence node attaches to the enclosing sequence
161
+ // scope (`sink`); seq/set own their outputs (handled in `lowerComb`).
162
+ const isSeqSet = node.kind === "comb" && (node.op === "seq" || node.op === "set");
163
+ if (node.outputs?.length && !isSeqSet) {
164
+ for (const o of node.outputs) sink.push(this.toOutput(o, name));
165
+ }
166
+
167
+ switch (node.kind) {
168
+ case "literal": {
169
+ const e = lit(node.value ?? "");
170
+ this.applyMeta(e, node);
171
+ return e;
172
+ }
173
+ case "terminal":
174
+ return this.lowerTerminal(node);
175
+ case "comb":
176
+ return this.lowerComb(node, expanding, name, sink);
177
+ default: {
178
+ this.err(`Unknown AST node kind '${(node as AstNode).kind}'`, node);
179
+ return seq();
180
+ }
181
+ }
182
+ }
183
+
184
+ private lowerRef(
185
+ node: AstNode,
186
+ expanding: Set<string>,
187
+ selfName: string | undefined,
188
+ sink: Output[],
189
+ ): Expr {
190
+ const target = node.refName!;
191
+ const aliasExpr = this.aliases.get(target);
192
+ if (!aliasExpr) {
193
+ this.err(`Unknown alias '${target}'`, node);
194
+ return seq();
195
+ }
196
+ if (expanding.has(target)) {
197
+ this.err(`Recursive alias '${target}' is not allowed`, node);
198
+ return seq();
199
+ }
200
+ const clone = structuredClone(aliasExpr);
201
+ // Overlay use-site decorations onto the inlined alias. The use site wins for
202
+ // each scalar decoration it specifies; outputs and media types accumulate.
203
+ // Without this, `size: Dimension.join(",")` would silently drop the join.
204
+ // Point any diagnostic about the resolved node at the use site (a misplaced
205
+ // modifier there, not in the alias definition, is what the author must fix).
206
+ clone.span = node.span ?? clone.span;
207
+ clone.name = node.name ?? clone.name;
208
+ clone.title = node.title ?? clone.title;
209
+ clone.description = node.description ?? clone.description;
210
+ if (node.default !== undefined) clone.default = node.default;
211
+ if (node.min !== undefined) clone.min = node.min;
212
+ if (node.max !== undefined) clone.max = node.max;
213
+ if (node.join !== undefined) clone.join = node.join;
214
+ if (node.countMin !== undefined) clone.countMin = node.countMin;
215
+ if (node.countMax !== undefined) clone.countMax = node.countMax;
216
+ if (node.mediaTypes?.length)
217
+ clone.mediaTypes = [...(clone.mediaTypes ?? []), ...node.mediaTypes];
218
+ if (node.mutable) clone.mutable = true;
219
+ if (node.resolveParent) clone.resolveParent = true;
220
+ if (node.outputs?.length) clone.outputs = [...(clone.outputs ?? []), ...node.outputs];
221
+
222
+ const next = new Set(expanding);
223
+ next.add(target);
224
+ return this.lowerNode(clone, next, selfName, sink);
225
+ }
226
+
227
+ private lowerTerminal(node: AstNode): Expr {
228
+ switch (node.terminal) {
229
+ case "int": {
230
+ const e = int();
231
+ this.checkBounds(node.min, node.max, "value", "min", "max", node);
232
+ if (node.min !== undefined) e.attrs.minValue = node.min;
233
+ if (node.max !== undefined) e.attrs.maxValue = node.max;
234
+ this.applyMeta(e, node);
235
+ return e;
236
+ }
237
+ case "float": {
238
+ const e = float();
239
+ this.checkBounds(node.min, node.max, "value", "min", "max", node);
240
+ if (node.min !== undefined) e.attrs.minValue = node.min;
241
+ if (node.max !== undefined) e.attrs.maxValue = node.max;
242
+ this.applyMeta(e, node);
243
+ return e;
244
+ }
245
+ case "str": {
246
+ // A `str.mediaType(...)` is rejected generically in `lowerNode` (media
247
+ // types are a `path`-only annotation), so nothing to do here.
248
+ const e = str();
249
+ this.applyMeta(e, node);
250
+ return e;
251
+ }
252
+ case "path": {
253
+ const e = pathTerm();
254
+ if (node.mediaTypes?.length) e.attrs.mediaTypes = node.mediaTypes;
255
+ if (node.mutable) e.attrs.mutable = true;
256
+ if (node.resolveParent) e.attrs.resolveParent = true;
257
+ this.applyMeta(e, node);
258
+ return e;
259
+ }
260
+ default: {
261
+ this.err(`Unknown terminal '${String(node.terminal)}'`, node);
262
+ return str();
263
+ }
264
+ }
265
+ }
266
+
267
+ private lowerComb(
268
+ node: AstNode,
269
+ expanding: Set<string>,
270
+ name: string | undefined,
271
+ sink: Output[],
272
+ ): Expr {
273
+ const children = node.children ?? [];
274
+ const lowerChildren = (s: Output[]): Expr[] =>
275
+ children.map((c) => this.lowerNode(c, expanding, name, s));
276
+
277
+ switch (node.op) {
278
+ case "seq":
279
+ case "set": {
280
+ // `set` is lowered to a sequence: the IR does not model "order not
281
+ // meaningful". A sequence is an output scope, so its own `.output()`s
282
+ // and any outputs its children declare attach here (not the parent).
283
+ const selfOutputs: Output[] = [];
284
+ for (const o of node.outputs ?? []) selfOutputs.push(this.toOutput(o, name));
285
+ const lowered = lowerChildren(selfOutputs);
286
+ this.dedupeSiblingNames(lowered, node);
287
+ const e = seq(...lowered);
288
+ if (node.join !== undefined) e.attrs.join = node.join;
289
+ this.applyMeta(e, node);
290
+ if (selfOutputs.length > 0) {
291
+ e.meta = { ...e.meta, outputs: [...(e.meta?.outputs ?? []), ...selfOutputs] };
292
+ }
293
+ return e;
294
+ }
295
+ case "alt": {
296
+ // An alt arm's name is its discriminant, scoped to the union. Two arms
297
+ // with the same label would collide the same way two same-named seq/set
298
+ // fields do (the solver keys variants by name), so disambiguate them the
299
+ // same way. `any` is exempt: it emits branch 0 and its branches are
300
+ // deliberately binding-compatible (same names by design).
301
+ const arms = lowerChildren(sink);
302
+ this.dedupeSiblingNames(arms, node);
303
+ const e = alt(...arms);
304
+ this.applyMeta(e, node);
305
+ return e;
306
+ }
307
+ case "any": {
308
+ // Emit the first branch only. The `any` branches are interchangeable
309
+ // spellings of one token (`--output` / `-output` / `-o`), and the spec
310
+ // makes branch 0 authoritative for builders - so for a generator this
311
+ // is lossless and expected, not a degradation worth warning about.
312
+ // (Only a parser/validator would need to accept the other spellings.)
313
+ if (children.length === 0) {
314
+ this.err("`any(...)` requires at least one branch", node);
315
+ return seq();
316
+ }
317
+ const e = this.lowerNode(children[0]!, expanding, name, sink);
318
+ // Overlay the any's own name/doc onto the emitted branch.
319
+ if (node.name) e.meta = { ...e.meta, name: node.name };
320
+ {
321
+ const d = docFrom(node);
322
+ if (d) e.meta = { ...e.meta, doc: d };
323
+ }
324
+ return e;
325
+ }
326
+ case "opt": {
327
+ const inner = this.wrapChildren(children, expanding, name, sink);
328
+ // `.join()` on the opt collapses its content into one argv element; push
329
+ // it onto the inner sequence/repeat (a single terminal is already one
330
+ // element, so a join there is a harmless no-op).
331
+ if (node.join !== undefined && (inner.kind === "sequence" || inner.kind === "repeat")) {
332
+ inner.attrs.join = node.join;
333
+ }
334
+ const e = opt(inner);
335
+ this.applyMeta(e, node);
336
+ // A bare-literal flag resolves to a bool; give it a false default to
337
+ // match the other frontends' flag convention.
338
+ if (inner.kind === "literal") e.meta = { ...e.meta, defaultValue: false };
339
+ return e;
340
+ }
341
+ case "rep": {
342
+ const inner = this.wrapChildren(children, expanding, name, sink);
343
+ const e = rep(inner);
344
+ if (node.join !== undefined) e.attrs.join = node.join;
345
+ this.checkBounds(
346
+ node.countMin,
347
+ node.countMax,
348
+ "repetition count",
349
+ "countMin",
350
+ "countMax",
351
+ node,
352
+ );
353
+ if (node.countMin !== undefined) e.attrs.countMin = node.countMin;
354
+ if (node.countMax !== undefined) e.attrs.countMax = node.countMax;
355
+ this.applyMeta(e, node);
356
+ return e;
357
+ }
358
+ default: {
359
+ this.err(`Unknown combinator '${String(node.op)}'`, node);
360
+ return seq();
361
+ }
362
+ }
363
+ }
364
+
365
+ /** Warn on an inverted `[min, max]` pair (a lower bound above its upper
366
+ * bound), which yields an unsatisfiable constraint downstream. */
367
+ private checkBounds(
368
+ min: number | undefined,
369
+ max: number | undefined,
370
+ what: string,
371
+ minLabel: string,
372
+ maxLabel: string,
373
+ node?: AstNode,
374
+ ): void {
375
+ if (min !== undefined && max !== undefined && min > max) {
376
+ this.warn(`Inverted ${what} bounds: ${minLabel} (${min}) > ${maxLabel} (${max})`, node);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Disambiguate duplicate explicit names among the direct children of a
382
+ * sequence/set (struct fields) or the arms of an alternative (union
383
+ * discriminants). The solver turns each named child into a struct field / union
384
+ * variant keyed by its name; two siblings with the same name would silently
385
+ * collapse (the second overwrites the first, dropping a parameter). argtype is
386
+ * hand-authored
387
+ * and gives no uniqueness guarantee, so - like the mrtrix frontend - we rename
388
+ * collisions (`name_2`, `name_3`, ...) and warn. (This catches duplicate
389
+ * explicit labels; it does not detect collisions between solver-derived names
390
+ * of otherwise-unnamed children, which the solver also does not guard.)
391
+ */
392
+ private dedupeSiblingNames(children: Expr[], parent?: AstNode): void {
393
+ const used = new Set<string>();
394
+ for (const child of children) {
395
+ const nm = child.meta?.name;
396
+ if (nm === undefined) continue;
397
+ if (!used.has(nm)) {
398
+ used.add(nm);
399
+ continue;
400
+ }
401
+ let n = 2;
402
+ while (used.has(`${nm}_${n}`)) n++;
403
+ const renamed = `${nm}_${n}`;
404
+ used.add(renamed);
405
+ child.meta = { ...child.meta, name: renamed };
406
+ // Locate at the enclosing seq/set/alt node (the collision is between its
407
+ // direct children, which are lowered IR nodes without their own AST span).
408
+ this.warn(`Duplicate sibling name '${nm}'; renamed one occurrence to '${renamed}'`, parent);
409
+ }
410
+ }
411
+
412
+ /** `opt`/`rep` with multiple children implicitly wrap them in a sequence,
413
+ * which - like any sequence - is the output scope for those children. */
414
+ private wrapChildren(
415
+ children: AstNode[],
416
+ expanding: Set<string>,
417
+ name: string | undefined,
418
+ sink: Output[],
419
+ ): Expr {
420
+ if (children.length === 1) return this.lowerNode(children[0]!, expanding, name, sink);
421
+ const selfOutputs: Output[] = [];
422
+ const e = seq(...children.map((c) => this.lowerNode(c, expanding, name, selfOutputs)));
423
+ if (selfOutputs.length > 0) e.meta = { ...e.meta, outputs: selfOutputs };
424
+ return e;
425
+ }
426
+
427
+ /** Fold an AST node's name/doc/default decorations onto an IR node's meta. */
428
+ private applyMeta(e: Expr, node: AstNode): void {
429
+ const meta: NodeMeta = {};
430
+ if (node.name) meta.name = node.name;
431
+ const doc = docFrom(node);
432
+ if (doc) meta.doc = doc;
433
+ if (node.default !== undefined) meta.defaultValue = node.default;
434
+ if (Object.keys(meta).length > 0) e.meta = { ...e.meta, ...meta };
435
+ }
436
+
437
+ private toOutput(o: AstOutput, selfName: string | undefined): Output {
438
+ const tokens: OutputToken[] = [];
439
+ for (const t of o.tokens) {
440
+ if (t.kind === "literal") {
441
+ tokens.push({ kind: "literal", value: t.value });
442
+ continue;
443
+ }
444
+ const targetName = t.name ?? selfName;
445
+ if (!targetName) {
446
+ this.warn("Output self-reference '{}' has no named node to resolve to; emitting empty");
447
+ tokens.push({ kind: "literal", value: "" });
448
+ continue;
449
+ }
450
+ if (t.stripPrefix?.length)
451
+ this.warn("Output ref op 'strip_prefix' is not supported by the IR; ignored");
452
+ if (t.basename) this.warn("Output ref op 'basename' is not supported by the IR; ignored");
453
+ tokens.push({
454
+ kind: "ref",
455
+ target: nodeRef(targetName),
456
+ ...(t.stripSuffix?.length && { stripExtensions: t.stripSuffix }),
457
+ ...(t.or !== undefined && { fallback: t.or }),
458
+ });
459
+ }
460
+ if (o.fallback !== undefined) {
461
+ this.warn("Output-level '.or(...)' fallback is not supported by the IR; ignored");
462
+ }
463
+ const doc = docFrom(o);
464
+ return { ...(o.name && { name: o.name }), ...(doc && { doc }), tokens };
465
+ }
466
+ }
467
+
468
+ export function buildAppMeta(
469
+ fm: Record<string, unknown> | undefined,
470
+ rootDoc: { title?: string; description?: string },
471
+ rootName: string | undefined,
472
+ ): { meta?: AppMeta; warnings: string[] } {
473
+ const warnings: string[] = [];
474
+ const rootTitleDesc = docFrom(rootDoc);
475
+ if (!fm) {
476
+ // No frontmatter: id is the root definition name; still surface the root doc.
477
+ const meta: AppMeta = {
478
+ ...(rootName && { id: rootName }),
479
+ ...(rootTitleDesc && { doc: rootTitleDesc }),
480
+ };
481
+ return Object.keys(meta).length > 0 ? { meta, warnings } : { warnings };
482
+ }
483
+
484
+ const asStr = (x: unknown): string | undefined =>
485
+ typeof x === "string" && x.length > 0 ? x : undefined;
486
+ // The tool id is the root definition name; `exe` is the executable (argv[0]),
487
+ // which can differ (e.g. a `wb_command <sub>` tool has id `<sub>`, exe
488
+ // `wb_command`). Fall back to `exe`/`id` frontmatter only if the root is unnamed.
489
+ const id = asStr(rootName) ?? asStr(fm.exe) ?? asStr(fm.id);
490
+ // An unquoted numeric version (`version: 6.0`) is parsed as a number by the
491
+ // frontmatter reader; accept it by stringifying rather than dropping it.
492
+ const version =
493
+ asStr(fm.version) ?? (typeof fm.version === "number" ? String(fm.version) : undefined);
494
+ const strList = (x: unknown): string[] =>
495
+ Array.isArray(x) ? x.filter((a): a is string => typeof a === "string") : [];
496
+ const authors = strList(fm.authors);
497
+ const urls = strList(fm.urls);
498
+ const references = strList(fm.references);
499
+
500
+ // Validate the shape of known keys. A present-but-wrong-shape value is almost
501
+ // always an authoring mistake, and silently coercing it (dropping a scalar
502
+ // author, ignoring a malformed container) hides that - so warn rather than
503
+ // drop in silence. An empty key (`authors:` with no value, parsed as null) is
504
+ // left alone; only a non-null non-list scalar is flagged.
505
+ const checkList = (key: string, v: unknown): void => {
506
+ if (v !== undefined && v !== null && !Array.isArray(v)) {
507
+ warnings.push(`Frontmatter '${key}' should be a list; got a scalar - ignored`);
508
+ }
509
+ };
510
+ checkList("authors", fm.authors);
511
+ checkList("urls", fm.urls);
512
+ checkList("references", fm.references);
513
+ if (fm.version !== undefined && fm.version !== null && version === undefined) {
514
+ warnings.push(`Frontmatter 'version' should be a string or number; ignored`);
515
+ }
516
+ if (fm.container !== undefined && fm.container !== null) {
517
+ if (!isRecord(fm.container)) {
518
+ warnings.push(`Frontmatter 'container' should be a mapping with an 'image'; ignored`);
519
+ } else if (!asStr(fm.container.image)) {
520
+ warnings.push(`Frontmatter 'container' is missing an 'image'; ignored`);
521
+ }
522
+ }
523
+
524
+ const doc: Documentation = {
525
+ ...rootTitleDesc,
526
+ ...(authors.length > 0 && { authors }),
527
+ ...(urls.length > 0 && { urls }),
528
+ ...(references.length > 0 && { literature: references }),
529
+ };
530
+
531
+ const meta: AppMeta = {
532
+ ...(id && { id }),
533
+ ...(version && { version }),
534
+ ...(Object.keys(doc).length > 0 && { doc }),
535
+ };
536
+
537
+ // container: { image, type }
538
+ if (isRecord(fm.container)) {
539
+ const image = asStr(fm.container.image);
540
+ const type = asStr(fm.container.type);
541
+ if (image) {
542
+ meta.container = {
543
+ image,
544
+ ...(type === "docker" || type === "singularity" ? { type } : {}),
545
+ };
546
+ }
547
+ }
548
+
549
+ meta.stdout = streamFrom(fm.stdout, warnings, "stdout");
550
+ meta.stderr = streamFrom(fm.stderr, warnings, "stderr");
551
+ if (!meta.stdout) delete meta.stdout;
552
+ if (!meta.stderr) delete meta.stderr;
553
+
554
+ return { meta, warnings };
555
+ }
556
+
557
+ function streamFrom(x: unknown, warnings: string[], key: string): StreamOutput | undefined {
558
+ if (x === undefined || x === null) return undefined;
559
+ if (isRecord(x)) {
560
+ const name = typeof x.name === "string" ? x.name : undefined;
561
+ if (!name) {
562
+ warnings.push(`Frontmatter '${key}' is missing a 'name'; ignored`);
563
+ return undefined;
564
+ }
565
+ const description = typeof x.description === "string" ? x.description : undefined;
566
+ return { name, ...(description && { doc: { description } }) };
567
+ }
568
+ if (typeof x === "string") return { name: x };
569
+ warnings.push(`Frontmatter '${key}' has an unexpected shape; ignored`);
570
+ return undefined;
571
+ }
572
+
573
+ function isRecord(x: unknown): x is Record<string, unknown> {
574
+ return typeof x === "object" && x !== null && !Array.isArray(x);
575
+ }
576
+
577
+ export function lowerDocument(doc: AstDocument): LowerResult {
578
+ const lowerer = new Lowerer(doc.aliases);
579
+ const result = lowerer.lower(doc);
580
+
581
+ // argtype describes the arguments only (everything after argv[0]); the
582
+ // executable lives in frontmatter. Prepend it as the command's first token so
583
+ // the IR is a complete command line (the backend strips it back out).
584
+ const exe = typeof doc.frontmatter?.exe === "string" ? doc.frontmatter.exe : undefined;
585
+ if (exe) result.expr.attrs.nodes.unshift(lit(exe));
586
+
587
+ const { meta, warnings } = buildAppMeta(
588
+ doc.frontmatter,
589
+ { title: doc.root.title, description: doc.root.description },
590
+ doc.rootName,
591
+ );
592
+
593
+ return {
594
+ ...(meta && { meta }),
595
+ expr: result.expr,
596
+ errors: result.errors,
597
+ // Frontmatter warnings are location-less (the reader blanks those lines and
598
+ // does not track per-key positions), so they carry no line/column.
599
+ warnings: [...result.warnings, ...warnings.map((message) => ({ message }))],
600
+ };
601
+ }
@@ -0,0 +1,51 @@
1
+ import type { Sequence } from "../../ir/node.js";
2
+ import type { Frontend, ParseError, ParseResult, ParseWarning } from "../frontend.js";
3
+ import { parseArgtype } from "./parser.js";
4
+ import { lowerDocument } from "./lower.js";
5
+
6
+ /** A fresh empty root sequence for error returns (IR passes mutate in place). */
7
+ function emptyExpr(): Sequence {
8
+ return { kind: "sequence", attrs: { nodes: [] } };
9
+ }
10
+
11
+ /**
12
+ * Frontend for the argtype sugar DSL - the hand-authored, TypeScript-types-like
13
+ * language for describing CLI argument grammars (see the argtype spec). Parses
14
+ * the text into an AST, then lowers it to Styx IR + AppMeta.
15
+ *
16
+ * Supported today: the argtype core (combinators, terminals, literals, naming,
17
+ * aliases, value constraints, `.join`/`.count`/`.default`, doc comments,
18
+ * frontmatter) plus the `outputs`, `mediatypes`, and `paths` extensions. `set`
19
+ * lowers to a sequence and `any` to its first branch; the `constraints`
20
+ * extension is parsed-and-ignored.
21
+ */
22
+ export class ArgtypeParser implements Frontend {
23
+ readonly name = "argtype";
24
+ readonly extensions = ["argtype"];
25
+
26
+ parse(source: string, _filename?: string): ParseResult {
27
+ const { doc, errors: parseErrors, warnings: parseWarnings } = parseArgtype(source);
28
+ const toLocation = (e: { line?: number; column?: number }) =>
29
+ e.line !== undefined ? { location: { line: e.line, column: e.column } } : {};
30
+ const errors: ParseError[] = parseErrors.map((e) => ({ message: e.message, ...toLocation(e) }));
31
+ const warnings: ParseWarning[] = parseWarnings.map((e) => ({
32
+ message: e.message,
33
+ ...toLocation(e),
34
+ }));
35
+
36
+ if (!doc) {
37
+ return { expr: emptyExpr(), errors, warnings };
38
+ }
39
+
40
+ const lowered = lowerDocument(doc);
41
+ warnings.push(...lowered.warnings.map((d) => ({ message: d.message, ...toLocation(d) })));
42
+ errors.push(...lowered.errors.map((d) => ({ message: d.message, ...toLocation(d) })));
43
+
44
+ return {
45
+ ...(lowered.meta && { meta: lowered.meta }),
46
+ expr: lowered.expr,
47
+ errors,
48
+ warnings,
49
+ };
50
+ }
51
+ }