@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,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive-descent parser for the argtype sugar DSL, producing an `AstDocument`
|
|
3
|
+
* (frontmatter + aliases + a single root definition). Lowering to Styx IR is a
|
|
4
|
+
* separate step (`lower.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Precedence, loosest to tightest: `,` (list separator) < `label:` < `|` (alt)
|
|
7
|
+
* < `.method()` (chaining). See the spec's "Sugar DSL" section.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AstAlias, AstDocument, AstNode, AstOutput, Combinator, Terminal } from "./ast.js";
|
|
11
|
+
import { lex } from "./lexer.js";
|
|
12
|
+
import type { Token, TokenKind } from "./lexer.js";
|
|
13
|
+
import { splitFrontmatter } from "./frontmatter.js";
|
|
14
|
+
import { parseTemplate } from "./template.js";
|
|
15
|
+
import { splitDocText } from "./doc.js";
|
|
16
|
+
|
|
17
|
+
/** Split a `///` block and attach its title/description to a node. Per the spec,
|
|
18
|
+
* the block "is applied first and wins": for each field the block provides it
|
|
19
|
+
* overrides a value a chained `.title()`/`.description()` already set, while a
|
|
20
|
+
* field the block leaves unset keeps whatever the chain supplied. */
|
|
21
|
+
function attachDocText(target: { title?: string; description?: string }, raw: string): void {
|
|
22
|
+
const { title, description } = splitDocText(raw);
|
|
23
|
+
if (title !== undefined) target.title = title;
|
|
24
|
+
if (description !== undefined) target.description = description;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const COMBINATORS = new Set<Combinator>(["seq", "set", "opt", "rep", "alt", "any"]);
|
|
28
|
+
const TERMINALS = new Set<Terminal>(["int", "float", "str", "path"]);
|
|
29
|
+
|
|
30
|
+
export interface AstParseError {
|
|
31
|
+
message: string;
|
|
32
|
+
line?: number;
|
|
33
|
+
column?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AstParseResult {
|
|
37
|
+
doc?: AstDocument;
|
|
38
|
+
errors: AstParseError[];
|
|
39
|
+
warnings: AstParseError[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Core + supported-extension chaining methods. Anything else is an extension
|
|
43
|
+
* we don't implement and is parsed-and-ignored (the spec's "ignorable" rule). */
|
|
44
|
+
const KNOWN_METHODS = new Set([
|
|
45
|
+
"name",
|
|
46
|
+
"title",
|
|
47
|
+
"description",
|
|
48
|
+
"default",
|
|
49
|
+
"min",
|
|
50
|
+
"max",
|
|
51
|
+
"join",
|
|
52
|
+
"count",
|
|
53
|
+
"countMin",
|
|
54
|
+
"countMax",
|
|
55
|
+
"mediaType",
|
|
56
|
+
"mutable",
|
|
57
|
+
"resolveParent",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Levenshtein edit distance between two strings. */
|
|
61
|
+
function editDistance(a: string, b: string): number {
|
|
62
|
+
const m = a.length;
|
|
63
|
+
const n = b.length;
|
|
64
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
65
|
+
for (let i = 1; i <= m; i++) {
|
|
66
|
+
const cur = [i];
|
|
67
|
+
for (let j = 1; j <= n; j++) {
|
|
68
|
+
cur[j] = Math.min(
|
|
69
|
+
prev[j]! + 1,
|
|
70
|
+
cur[j - 1]! + 1,
|
|
71
|
+
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
prev = cur;
|
|
75
|
+
}
|
|
76
|
+
return prev[n]!;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The closest candidate to `name` within a small edit distance, for a "did you
|
|
80
|
+
* mean?" hint on an unknown method. Returns undefined when nothing is close
|
|
81
|
+
* enough, so a deliberate unknown-extension method gets no spurious suggestion.
|
|
82
|
+
* A typo (`reqires`, `mediaTpe`) lands within distance 2 of a real method; an
|
|
83
|
+
* unrelated extension method (`conflicts`) does not. The `bestDist < name.length`
|
|
84
|
+
* clause additionally stops a very short unknown name from matching a longer
|
|
85
|
+
* method by pure insertion (a 1-char `.n()` is not "did you mean `.min()`"). */
|
|
86
|
+
function suggestMethod(name: string, candidates: Iterable<string>): string | undefined {
|
|
87
|
+
let best: string | undefined;
|
|
88
|
+
let bestDist = Infinity;
|
|
89
|
+
for (const c of candidates) {
|
|
90
|
+
const d = editDistance(name, c);
|
|
91
|
+
if (d < bestDist) {
|
|
92
|
+
bestDist = d;
|
|
93
|
+
best = c;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return best !== undefined && bestDist <= 2 && bestDist < name.length ? best : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class Parser {
|
|
100
|
+
private tokens: Token[];
|
|
101
|
+
private pos = 0;
|
|
102
|
+
readonly errors: AstParseError[] = [];
|
|
103
|
+
readonly warnings: AstParseError[] = [];
|
|
104
|
+
|
|
105
|
+
constructor(tokens: Token[]) {
|
|
106
|
+
this.tokens = tokens;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private peek(o = 0): Token {
|
|
110
|
+
return this.tokens[Math.min(this.pos + o, this.tokens.length - 1)]!;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private at(kind: TokenKind): boolean {
|
|
114
|
+
return this.peek().kind === kind;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private next(): Token {
|
|
118
|
+
return this.tokens[this.pos++] ?? this.tokens[this.tokens.length - 1]!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private expect(kind: TokenKind, what: string): Token {
|
|
122
|
+
if (this.at(kind)) return this.next();
|
|
123
|
+
const tok = this.peek();
|
|
124
|
+
this.error(`Expected ${what} but found '${tok.value || tok.kind}'`, tok);
|
|
125
|
+
return tok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private error(message: string, tok: Token = this.peek()): void {
|
|
129
|
+
this.errors.push({ message, line: tok.line, column: tok.column });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private warn(message: string, tok: Token = this.peek()): void {
|
|
133
|
+
this.warnings.push({ message, line: tok.line, column: tok.column });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -- Top level --
|
|
137
|
+
|
|
138
|
+
parseDocument(frontmatter: Record<string, unknown> | undefined): AstDocument | undefined {
|
|
139
|
+
const aliases: AstAlias[] = [];
|
|
140
|
+
let rootName: string | undefined;
|
|
141
|
+
let root: AstNode | undefined;
|
|
142
|
+
|
|
143
|
+
while (!this.at("eof")) {
|
|
144
|
+
const docs = this.collectDocs();
|
|
145
|
+
if (this.at("eof")) break; // trailing docs before end of file
|
|
146
|
+
|
|
147
|
+
// An alias (`Name = expr`) or a named root (`name: expr`) both begin with
|
|
148
|
+
// an identifier / quoted label followed by `=` / `:`. Anything else at the
|
|
149
|
+
// top level is a bare, anonymous root expression - the spec lets the root
|
|
150
|
+
// be unnamed (its id then falls back to `exe`/`id` frontmatter).
|
|
151
|
+
const labelLike = this.at("ident") || this.at("string");
|
|
152
|
+
const following = this.peek(1).kind;
|
|
153
|
+
|
|
154
|
+
if (labelLike && following === "eq") {
|
|
155
|
+
// Alias: Name = expr. Alias names must be identifiers (they are
|
|
156
|
+
// referenced by bare name at each use site).
|
|
157
|
+
const nameTok = this.next();
|
|
158
|
+
if (nameTok.kind === "string")
|
|
159
|
+
this.error("Alias names must be identifiers, not quoted strings", nameTok);
|
|
160
|
+
this.next(); // '='
|
|
161
|
+
const expr = this.parseElement();
|
|
162
|
+
if (docs) attachDocText(expr, docs);
|
|
163
|
+
aliases.push({ name: nameTok.value, expr });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (labelLike && following === "colon") {
|
|
168
|
+
// Named root definition: name: expr
|
|
169
|
+
const nameTok = this.next();
|
|
170
|
+
this.next(); // ':'
|
|
171
|
+
const expr = this.parseElement();
|
|
172
|
+
if (docs) attachDocText(expr, docs);
|
|
173
|
+
if (root) {
|
|
174
|
+
this.error(
|
|
175
|
+
`Multiple root definitions; '${nameTok.value}' ignored (already have '${rootName ?? "<anonymous>"}')`,
|
|
176
|
+
nameTok,
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
rootName = nameTok.value;
|
|
180
|
+
root = expr;
|
|
181
|
+
if (!root.name) root.name = nameTok.value;
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Anonymous root: a bare expression (`seq(...)`, `rep(str)`, `path`,
|
|
187
|
+
// `"literal"`, `(...)`, or an alias reference). It leaves `rootName`
|
|
188
|
+
// unset; lowering derives the tool id from frontmatter. Every root
|
|
189
|
+
// expression starts with an identifier, a string, or `(`; a top-level
|
|
190
|
+
// token that cannot start an expression is a syntax error, not an
|
|
191
|
+
// anonymous root (guarding this keeps `parsePrimary`'s empty-literal
|
|
192
|
+
// fallback from being adopted as the root and then reported as a
|
|
193
|
+
// spurious duplicate of the real one).
|
|
194
|
+
if (!this.at("ident") && !this.at("string") && !this.at("lparen")) {
|
|
195
|
+
this.error(
|
|
196
|
+
"Expected a definition (name: expr), an alias (Name = expr), or a root expression",
|
|
197
|
+
);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const expr = this.parseElement();
|
|
201
|
+
if (docs) attachDocText(expr, docs);
|
|
202
|
+
if (root) {
|
|
203
|
+
this.error("Multiple root definitions; a second top-level expression is not allowed");
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
root = expr;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!root) {
|
|
210
|
+
this.error("No root definition (expected `name: expr` or a bare root expression)");
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...(frontmatter && { frontmatter }),
|
|
215
|
+
aliases,
|
|
216
|
+
...(rootName !== undefined && { rootName }),
|
|
217
|
+
root,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Collect consecutive `///` doc lines, joined with newlines. */
|
|
222
|
+
private collectDocs(): string | undefined {
|
|
223
|
+
const lines: string[] = [];
|
|
224
|
+
while (this.at("doc")) lines.push(this.next().value);
|
|
225
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// -- Expressions --
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* An element is the unit inside a comma list: an optionally-named expression.
|
|
232
|
+
* `label:` is looser than `|`, so a name binds the whole alternative that
|
|
233
|
+
* follows it.
|
|
234
|
+
*/
|
|
235
|
+
private parseElement(): AstNode {
|
|
236
|
+
// A label is a bare identifier or a quoted string (for names that are not
|
|
237
|
+
// valid identifiers), followed by `:`. A quoted string NOT followed by `:`
|
|
238
|
+
// is a literal, so the colon lookahead disambiguates.
|
|
239
|
+
if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
|
|
240
|
+
const nameTok = this.next();
|
|
241
|
+
this.next(); // colon
|
|
242
|
+
const inner = this.parseElement();
|
|
243
|
+
inner.name = nameTok.value;
|
|
244
|
+
return inner;
|
|
245
|
+
}
|
|
246
|
+
return this.parseAlt();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private parseAlt(): AstNode {
|
|
250
|
+
const first = this.parseChain();
|
|
251
|
+
if (!this.at("pipe")) return first;
|
|
252
|
+
const alts: AstNode[] = [first];
|
|
253
|
+
while (this.at("pipe")) {
|
|
254
|
+
this.next();
|
|
255
|
+
alts.push(this.parseChain());
|
|
256
|
+
}
|
|
257
|
+
// The synthesized alt has no token of its own; point diagnostics at its first
|
|
258
|
+
// arm (e.g. a misplaced `.join()` on `(a | b).join()`).
|
|
259
|
+
return { kind: "comb", op: "alt", children: alts, ...(first.span && { span: first.span }) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private parseChain(): AstNode {
|
|
263
|
+
const node = this.parsePrimary();
|
|
264
|
+
// Method chain.
|
|
265
|
+
while (this.at("dot")) {
|
|
266
|
+
this.next();
|
|
267
|
+
this.applyMethod(node);
|
|
268
|
+
}
|
|
269
|
+
// `= value` is pure sugar for `.default(value)`, with no positional
|
|
270
|
+
// restriction: it binds after a method chain too (`float.min(0) = 0.5`),
|
|
271
|
+
// since the meaning is unambiguous and the terminal-only rule was a footgun.
|
|
272
|
+
if (this.at("eq")) {
|
|
273
|
+
this.next(); // '='
|
|
274
|
+
node.default = this.parseValue();
|
|
275
|
+
}
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private parsePrimary(): AstNode {
|
|
280
|
+
const tok = this.peek();
|
|
281
|
+
const span = { line: tok.line, column: tok.column };
|
|
282
|
+
|
|
283
|
+
if (tok.kind === "string") {
|
|
284
|
+
this.next();
|
|
285
|
+
return { kind: "literal", value: tok.value, span };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (tok.kind === "lparen") {
|
|
289
|
+
// Anonymous sequence group.
|
|
290
|
+
const children = this.parseParenList();
|
|
291
|
+
return { kind: "comb", op: "seq", children, span };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (tok.kind === "ident") {
|
|
295
|
+
if (COMBINATORS.has(tok.value as Combinator) && this.peek(1).kind === "lparen") {
|
|
296
|
+
this.next();
|
|
297
|
+
const children = this.parseParenList();
|
|
298
|
+
return { kind: "comb", op: tok.value as Combinator, children, span };
|
|
299
|
+
}
|
|
300
|
+
if (TERMINALS.has(tok.value as Terminal)) {
|
|
301
|
+
this.next();
|
|
302
|
+
return { kind: "terminal", terminal: tok.value as Terminal, span };
|
|
303
|
+
}
|
|
304
|
+
// Otherwise an alias reference.
|
|
305
|
+
this.next();
|
|
306
|
+
return { kind: "ref", refName: tok.value, span };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this.error(`Unexpected token '${tok.value || tok.kind}' in expression`, tok);
|
|
310
|
+
this.next();
|
|
311
|
+
return { kind: "literal", value: "", span };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Parse `( elem, elem, ... )` with optional leading docs and trailing comma. */
|
|
315
|
+
private parseParenList(): AstNode[] {
|
|
316
|
+
this.expect("lparen", "'('");
|
|
317
|
+
const items: AstNode[] = [];
|
|
318
|
+
while (!this.at("rparen") && !this.at("eof")) {
|
|
319
|
+
const docs = this.collectDocs();
|
|
320
|
+
if (this.at("rparen")) break; // trailing docs before close
|
|
321
|
+
const elem = this.parseElement();
|
|
322
|
+
if (docs) attachDocText(elem, docs);
|
|
323
|
+
items.push(elem);
|
|
324
|
+
if (this.at("comma")) this.next();
|
|
325
|
+
else break;
|
|
326
|
+
}
|
|
327
|
+
this.expect("rparen", "')'");
|
|
328
|
+
return items;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- Method calls --
|
|
332
|
+
|
|
333
|
+
private applyMethod(node: AstNode): void {
|
|
334
|
+
const nameTok = this.expect("ident", "a method name");
|
|
335
|
+
const method = nameTok.value;
|
|
336
|
+
if (method === "output") {
|
|
337
|
+
node.outputs = [...(node.outputs ?? []), ...this.parseOutputArgs()];
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!KNOWN_METHODS.has(method)) {
|
|
341
|
+
// An extension method we don't implement (e.g. the draft `constraints`
|
|
342
|
+
// extension's `.requires()`/`.conflicts()`). The spec requires unknown
|
|
343
|
+
// extension annotations to be ignorable, so skip the argument list
|
|
344
|
+
// (whatever its shape) and continue rather than failing the parse. A
|
|
345
|
+
// near-miss to a known method is almost certainly a typo, though, so add a
|
|
346
|
+
// "did you mean?" hint (the warning still just ignores it, preserving the
|
|
347
|
+
// ignorable contract for genuine extension methods).
|
|
348
|
+
this.skipBalancedArgs();
|
|
349
|
+
const hint = suggestMethod(method, [...KNOWN_METHODS, "output"]);
|
|
350
|
+
this.warn(
|
|
351
|
+
`Ignoring unsupported method '.${method}()'` +
|
|
352
|
+
(hint ? ` (did you mean '.${hint}()'?)` : ""),
|
|
353
|
+
nameTok,
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const args = this.parseScalarArgs();
|
|
359
|
+
switch (method) {
|
|
360
|
+
case "name":
|
|
361
|
+
if (typeof args[0] === "string") node.name = args[0];
|
|
362
|
+
break;
|
|
363
|
+
case "title":
|
|
364
|
+
if (typeof args[0] === "string") node.title = args[0];
|
|
365
|
+
break;
|
|
366
|
+
case "description":
|
|
367
|
+
// `.description()` sets the description directly; the `# ` heading
|
|
368
|
+
// convention is sugar for the `///` form only, so a leading `#` here is a
|
|
369
|
+
// literal part of the description, not a title.
|
|
370
|
+
if (typeof args[0] === "string") node.description = args[0];
|
|
371
|
+
break;
|
|
372
|
+
case "default":
|
|
373
|
+
if (args[0] !== undefined) node.default = args[0];
|
|
374
|
+
break;
|
|
375
|
+
case "min":
|
|
376
|
+
if (typeof args[0] === "number") node.min = args[0];
|
|
377
|
+
break;
|
|
378
|
+
case "max":
|
|
379
|
+
if (typeof args[0] === "number") node.max = args[0];
|
|
380
|
+
break;
|
|
381
|
+
case "join":
|
|
382
|
+
node.join = typeof args[0] === "string" ? args[0] : "";
|
|
383
|
+
break;
|
|
384
|
+
case "count":
|
|
385
|
+
// `.count(n)` is exactly n (sugar for `.countMin(n).countMax(n)`). For a
|
|
386
|
+
// one-sided or asymmetric bound, use `.countMin` / `.countMax` directly.
|
|
387
|
+
if (typeof args[0] === "number") {
|
|
388
|
+
node.countMin = args[0];
|
|
389
|
+
node.countMax = args[0];
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
case "countMin":
|
|
393
|
+
if (typeof args[0] === "number") node.countMin = args[0];
|
|
394
|
+
break;
|
|
395
|
+
case "countMax":
|
|
396
|
+
if (typeof args[0] === "number") node.countMax = args[0];
|
|
397
|
+
break;
|
|
398
|
+
case "mediaType":
|
|
399
|
+
if (typeof args[0] === "string") (node.mediaTypes ??= []).push(args[0]);
|
|
400
|
+
break;
|
|
401
|
+
case "mutable":
|
|
402
|
+
node.mutable = true;
|
|
403
|
+
break;
|
|
404
|
+
case "resolveParent":
|
|
405
|
+
node.resolveParent = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Parse a parenthesized list of plain scalar arguments (numbers / strings). */
|
|
411
|
+
private parseScalarArgs(): (string | number)[] {
|
|
412
|
+
this.expect("lparen", "'('");
|
|
413
|
+
const args: (string | number)[] = [];
|
|
414
|
+
while (!this.at("rparen") && !this.at("eof")) {
|
|
415
|
+
args.push(this.parseValue());
|
|
416
|
+
if (this.at("comma")) this.next();
|
|
417
|
+
else break;
|
|
418
|
+
}
|
|
419
|
+
this.expect("rparen", "')'");
|
|
420
|
+
return args;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Consume a balanced `( ... )` group without interpreting its contents.
|
|
424
|
+
* Used to skip the arguments of an unsupported extension method. */
|
|
425
|
+
private skipBalancedArgs(): void {
|
|
426
|
+
if (!this.at("lparen")) return;
|
|
427
|
+
let depth = 0;
|
|
428
|
+
do {
|
|
429
|
+
const tok = this.next();
|
|
430
|
+
if (tok.kind === "lparen") depth++;
|
|
431
|
+
else if (tok.kind === "rparen") depth--;
|
|
432
|
+
else if (tok.kind === "eof") break;
|
|
433
|
+
} while (depth > 0);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Parse the argument list of `.output(...)`: one or more template expressions. */
|
|
437
|
+
private parseOutputArgs(): AstOutput[] {
|
|
438
|
+
this.expect("lparen", "'('");
|
|
439
|
+
const outputs: AstOutput[] = [];
|
|
440
|
+
while (!this.at("rparen") && !this.at("eof")) {
|
|
441
|
+
const docs = this.collectDocs();
|
|
442
|
+
if (this.at("rparen")) break; // trailing docs before close
|
|
443
|
+
const out = this.parseOutputTemplate();
|
|
444
|
+
if (docs) attachDocText(out, docs);
|
|
445
|
+
outputs.push(out);
|
|
446
|
+
if (this.at("comma")) this.next();
|
|
447
|
+
else break;
|
|
448
|
+
}
|
|
449
|
+
this.expect("rparen", "')'");
|
|
450
|
+
return outputs;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** `label: \`tpl\`` or `\`tpl\`` followed by optional `.name(...)` / `.or(...)`. */
|
|
454
|
+
private parseOutputTemplate(): AstOutput {
|
|
455
|
+
let name: string | undefined;
|
|
456
|
+
// The output name may be a bare identifier or a quoted label (for
|
|
457
|
+
// non-identifier output names), mirroring `label:` naming.
|
|
458
|
+
if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
|
|
459
|
+
name = this.next().value;
|
|
460
|
+
this.next(); // colon
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const out: AstOutput = { tokens: [] };
|
|
464
|
+
if (this.at("template")) {
|
|
465
|
+
const tmplTok = this.next();
|
|
466
|
+
const { tokens, errors } = parseTemplate(tmplTok.value);
|
|
467
|
+
out.tokens = tokens;
|
|
468
|
+
for (const e of errors) this.error(e, tmplTok);
|
|
469
|
+
} else {
|
|
470
|
+
this.error(`Expected an output template literal`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Template-level chaining: .name("x") / .or("fallback") / .title("t") /
|
|
474
|
+
// .description("d"). An unrecognized method is an ignorable extension
|
|
475
|
+
// annotation (same rule as node method chains): skip its arguments and warn
|
|
476
|
+
// rather than failing the parse.
|
|
477
|
+
const CHAIN = new Set(["name", "or", "title", "description"]);
|
|
478
|
+
while (this.at("dot")) {
|
|
479
|
+
this.next();
|
|
480
|
+
const m = this.expect("ident", "a template method");
|
|
481
|
+
if (CHAIN.has(m.value)) {
|
|
482
|
+
const arg = this.parseScalarArgs()[0];
|
|
483
|
+
if (typeof arg === "string") {
|
|
484
|
+
if (m.value === "name") name = arg;
|
|
485
|
+
else if (m.value === "or") out.fallback = arg;
|
|
486
|
+
else if (m.value === "title") out.title = arg;
|
|
487
|
+
else out.description = arg; // description
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
this.skipBalancedArgs();
|
|
491
|
+
const hint = suggestMethod(m.value, CHAIN);
|
|
492
|
+
this.warn(
|
|
493
|
+
`Ignoring unsupported output-template method '.${m.value}()'` +
|
|
494
|
+
(hint ? ` (did you mean '.${hint}()'?)` : ""),
|
|
495
|
+
m,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (name !== undefined) out.name = name;
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private parseValue(): string | number {
|
|
505
|
+
const tok = this.peek();
|
|
506
|
+
if (tok.kind === "string") {
|
|
507
|
+
this.next();
|
|
508
|
+
return tok.value;
|
|
509
|
+
}
|
|
510
|
+
if (tok.kind === "number") {
|
|
511
|
+
this.next();
|
|
512
|
+
return Number(tok.value);
|
|
513
|
+
}
|
|
514
|
+
this.error(`Expected a value (string or number) but found '${tok.value || tok.kind}'`, tok);
|
|
515
|
+
this.next();
|
|
516
|
+
return "";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function parseArgtype(source: string): AstParseResult {
|
|
521
|
+
const { frontmatter, body, errors: fmErrors } = splitFrontmatter(source);
|
|
522
|
+
const { tokens, errors: lexErrors } = lex(body);
|
|
523
|
+
|
|
524
|
+
const parser = new Parser(tokens);
|
|
525
|
+
const doc = parser.parseDocument(frontmatter);
|
|
526
|
+
|
|
527
|
+
const errors: AstParseError[] = [
|
|
528
|
+
...fmErrors.map((m) => ({ message: m })),
|
|
529
|
+
...lexErrors.map((e) => ({ message: e.message, line: e.line, column: e.column })),
|
|
530
|
+
...parser.errors,
|
|
531
|
+
];
|
|
532
|
+
return { ...(doc && { doc }), errors, warnings: parser.warnings };
|
|
533
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an output template literal body (the text between backticks) into
|
|
3
|
+
* `AstOutputToken[]`. Literal runs become literal tokens; `{...}` interpolations
|
|
4
|
+
* become ref tokens carrying any reference operations (`strip_suffix`,
|
|
5
|
+
* `strip_prefix`, `basename`, `or`).
|
|
6
|
+
*
|
|
7
|
+
* `{}` (empty braces) is a self-reference: the value of the node the output is
|
|
8
|
+
* attached to. Lowering resolves it to that node's name.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AstOutputToken } from "./ast.js";
|
|
12
|
+
|
|
13
|
+
export interface TemplateParseResult {
|
|
14
|
+
tokens: AstOutputToken[];
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Index of the `}` that closes an interpolation opened at `open` (the position
|
|
20
|
+
* just past the `{`), or -1 if unterminated. A naive `indexOf("}")` would stop
|
|
21
|
+
* at the first `}` even when it sits inside a quoted ref name (`{"a}b"}`) or a
|
|
22
|
+
* quoted operation argument (`{a.or("{b}")}`); this scan skips double-quoted
|
|
23
|
+
* spans (honoring `\"`) and balances nested `{}` so those round-trip.
|
|
24
|
+
*/
|
|
25
|
+
function interpolationEnd(body: string, open: number): number {
|
|
26
|
+
let depth = 1;
|
|
27
|
+
let inQuote = false;
|
|
28
|
+
for (let j = open; j < body.length; j++) {
|
|
29
|
+
const c = body[j]!;
|
|
30
|
+
if (inQuote) {
|
|
31
|
+
if (c === "\\")
|
|
32
|
+
j++; // skip the escaped char (e.g. \" or \`)
|
|
33
|
+
else if (c === '"') inQuote = false;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === '"') inQuote = true;
|
|
37
|
+
else if (c === "{") depth++;
|
|
38
|
+
else if (c === "}" && --depth === 0) return j;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseTemplate(body: string): TemplateParseResult {
|
|
44
|
+
const tokens: AstOutputToken[] = [];
|
|
45
|
+
const errors: string[] = [];
|
|
46
|
+
let lit = "";
|
|
47
|
+
let i = 0;
|
|
48
|
+
|
|
49
|
+
const flushLit = (): void => {
|
|
50
|
+
if (lit.length > 0) {
|
|
51
|
+
tokens.push({ kind: "literal", value: lit });
|
|
52
|
+
lit = "";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
while (i < body.length) {
|
|
57
|
+
const ch = body[i]!;
|
|
58
|
+
// A backslash escapes the next character: `\{`, `\}`, `` \` ``, `\\` become
|
|
59
|
+
// that literal char (so `{` need not start an interpolation).
|
|
60
|
+
if (ch === "\\" && i + 1 < body.length) {
|
|
61
|
+
const next = body[i + 1]!;
|
|
62
|
+
lit += next === "{" || next === "}" || next === "`" || next === "\\" ? next : "\\" + next;
|
|
63
|
+
i += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (ch === "{") {
|
|
67
|
+
const close = interpolationEnd(body, i + 1);
|
|
68
|
+
if (close === -1) {
|
|
69
|
+
errors.push("Unterminated '{' in output template");
|
|
70
|
+
lit += body.slice(i);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
flushLit();
|
|
74
|
+
const inner = body.slice(i + 1, close).trim();
|
|
75
|
+
const { token, error } = parseRef(inner);
|
|
76
|
+
if (error) errors.push(error);
|
|
77
|
+
tokens.push(token);
|
|
78
|
+
i = close + 1;
|
|
79
|
+
} else {
|
|
80
|
+
lit += ch;
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
flushLit();
|
|
85
|
+
return { tokens, errors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse the inside of `{...}`: an optional name followed by `.op(arg)` calls.
|
|
89
|
+
* The name is a bare identifier, a double-quoted string (for a non-identifier
|
|
90
|
+
* target name, e.g. `{"4d_output"}`), or absent for a self-reference (`{}`). */
|
|
91
|
+
function parseRef(inner: string): { token: AstOutputToken; error?: string } {
|
|
92
|
+
const token: AstOutputToken & { kind: "ref" } = { kind: "ref" };
|
|
93
|
+
|
|
94
|
+
let rest = inner;
|
|
95
|
+
const quoted = /^"((?:[^"\\]|\\.)*)"/.exec(inner);
|
|
96
|
+
if (quoted) {
|
|
97
|
+
// A quoted target name carries an arbitrary (non-identifier) name verbatim,
|
|
98
|
+
// mirroring a quoted `label:` (a `}` inside the quotes is fine - the scanner
|
|
99
|
+
// is quote-aware). A literal backtick still cannot appear in a name: the
|
|
100
|
+
// lexer ends the template at the first unescaped backtick.
|
|
101
|
+
token.name = unescapeArg(quoted[1]!);
|
|
102
|
+
rest = inner.slice(quoted[0].length);
|
|
103
|
+
} else {
|
|
104
|
+
// Leading identifier (the referenced name).
|
|
105
|
+
const nameMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(inner);
|
|
106
|
+
if (nameMatch) {
|
|
107
|
+
token.name = nameMatch[0];
|
|
108
|
+
rest = inner.slice(nameMatch[0].length);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Method chain: .op("arg") or .op()
|
|
113
|
+
const opRe = /^\.\s*([A-Za-z_]+)\s*\(\s*(?:"((?:[^"\\]|\\.)*)")?\s*\)/;
|
|
114
|
+
let error: string | undefined;
|
|
115
|
+
while (rest.length > 0) {
|
|
116
|
+
const m = opRe.exec(rest);
|
|
117
|
+
if (!m) {
|
|
118
|
+
error = `Unrecognized output-reference operation near '${rest}'`;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const op = m[1]!;
|
|
122
|
+
const arg = m[2];
|
|
123
|
+
switch (op) {
|
|
124
|
+
case "strip_suffix":
|
|
125
|
+
if (arg !== undefined) (token.stripSuffix ??= []).push(unescapeArg(arg));
|
|
126
|
+
break;
|
|
127
|
+
case "strip_prefix":
|
|
128
|
+
if (arg !== undefined) (token.stripPrefix ??= []).push(unescapeArg(arg));
|
|
129
|
+
break;
|
|
130
|
+
case "basename":
|
|
131
|
+
token.basename = true;
|
|
132
|
+
break;
|
|
133
|
+
case "or":
|
|
134
|
+
if (arg !== undefined) token.or = unescapeArg(arg);
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
error = `Unknown output-reference operation '${op}'`;
|
|
138
|
+
}
|
|
139
|
+
rest = rest.slice(m[0].length).trimStart();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { token, ...(error && { error }) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unescapeArg(s: string): string {
|
|
146
|
+
return s.replace(/\\(.)/g, "$1");
|
|
147
|
+
}
|