@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.
- package/dist/index.cjs +2559 -553
- package/dist/index.d.cts +41 -37
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +41 -37
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2558 -549
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
- package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
- package/src/backend/argtype/argtype.ts +45 -0
- package/src/backend/argtype/emit.ts +676 -0
- package/src/backend/argtype/index.ts +2 -0
- package/src/backend/boutiques/boutiques.ts +55 -47
- package/src/backend/field-defaults.ts +40 -0
- package/src/backend/find-struct-node.ts +7 -0
- package/src/backend/index.ts +2 -9
- package/src/backend/nipype/emit.ts +11 -3
- package/src/backend/python/arg-builder.ts +2 -27
- package/src/backend/python/emit.ts +18 -6
- package/src/backend/python/outputs-emit.ts +27 -42
- package/src/backend/python/python.ts +46 -22
- package/src/backend/python/validate-emit.ts +4 -1
- package/src/backend/resolve-output-tokens.ts +0 -76
- package/src/backend/typescript/arg-builder.ts +2 -24
- package/src/backend/typescript/emit.ts +7 -4
- package/src/backend/typescript/outputs-emit.ts +15 -39
- package/src/backend/typescript/typemap.ts +9 -1
- package/src/backend/typescript/typescript.ts +41 -18
- package/src/backend/typescript/validate-emit.ts +4 -1
- package/src/backend/union-variants.ts +41 -1
- package/src/frontend/argdump/parser.ts +20 -19
- package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
- package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
- package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
- package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
- package/src/frontend/argtype/ast.ts +114 -0
- package/src/frontend/argtype/doc.ts +56 -0
- package/src/frontend/argtype/frontmatter.ts +237 -0
- package/src/frontend/argtype/index.ts +1 -0
- package/src/frontend/argtype/lexer.ts +264 -0
- package/src/frontend/argtype/lower.ts +601 -0
- package/src/frontend/argtype/parser-frontend.ts +51 -0
- package/src/frontend/argtype/parser.ts +533 -0
- package/src/frontend/argtype/template.ts +147 -0
- package/src/frontend/boutiques/destruct-template.ts +24 -17
- package/src/frontend/boutiques/parser.ts +27 -8
- package/src/frontend/detect-format.ts +28 -3
- package/src/index.ts +23 -9
- package/src/ir/passes/canonicalize.ts +18 -5
- package/src/ir/passes/simplify.ts +17 -2
- 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
|
+
}
|