@styx-api/core 0.7.0 → 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 +430 -356
- package/dist/index.d.cts +1 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -34
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +431 -352
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/backend/argtype/__fixtures__/microsyntax.argtype +0 -2
- package/src/backend/argtype/emit.ts +2 -51
- 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 +1 -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 +0 -2
- 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 +14 -1
- package/src/frontend/argtype/frontmatter.ts +49 -5
- package/src/frontend/argtype/lexer.ts +20 -0
- package/src/frontend/argtype/lower.ts +115 -75
- package/src/frontend/argtype/parser-frontend.ts +2 -2
- package/src/frontend/argtype/parser.ts +70 -24
- package/src/frontend/boutiques/destruct-template.ts +24 -17
- package/src/frontend/boutiques/parser.ts +27 -8
- package/src/ir/passes/canonicalize.ts +18 -5
- package/src/ir/passes/simplify.ts +17 -2
- package/src/solver/solver.ts +2 -2
|
@@ -16,13 +16,28 @@ import { nodeRef } from "../../ir/meta.js";
|
|
|
16
16
|
import type { AppMeta, NodeMeta, Output, OutputToken, StreamOutput } from "../../ir/meta.js";
|
|
17
17
|
import type { Documentation } from "../../ir/types.js";
|
|
18
18
|
import type { Expr, Sequence } from "../../ir/node.js";
|
|
19
|
-
import type { AstAlias, AstDocument, AstNode, AstOutput } from "./ast.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
|
+
}
|
|
20
30
|
|
|
21
31
|
export interface LowerResult {
|
|
22
32
|
meta?: AppMeta;
|
|
23
33
|
expr: Sequence;
|
|
24
|
-
errors:
|
|
25
|
-
warnings:
|
|
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 } : {};
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/** Build IR `Documentation` from an AST node's already-split title/description. */
|
|
@@ -35,21 +50,28 @@ function docFrom(node: { title?: string; description?: string }): Documentation
|
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
class Lowerer {
|
|
38
|
-
readonly errors:
|
|
39
|
-
readonly warnings:
|
|
40
|
-
/** Extensions whose annotations the document actually uses, collected during
|
|
41
|
-
* lowering so `lowerDocument` can flag any used-but-undeclared in frontmatter. */
|
|
42
|
-
readonly usedExtensions = new Set<string>();
|
|
53
|
+
readonly errors: LowerDiagnostic[] = [];
|
|
54
|
+
readonly warnings: LowerDiagnostic[] = [];
|
|
43
55
|
private aliases = new Map<string, AstNode>();
|
|
44
56
|
|
|
45
57
|
constructor(aliases: AstAlias[]) {
|
|
46
58
|
for (const a of aliases) {
|
|
47
59
|
if (this.aliases.has(a.name))
|
|
48
|
-
this.
|
|
60
|
+
this.warn(`Duplicate alias '${a.name}'; last definition wins`, a.expr);
|
|
49
61
|
this.aliases.set(a.name, a.expr);
|
|
50
62
|
}
|
|
51
63
|
}
|
|
52
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
|
+
|
|
53
75
|
lower(doc: AstDocument): LowerResult {
|
|
54
76
|
// Each output attaches to its nearest enclosing sequence scope (via the
|
|
55
77
|
// `sink`), preserving nesting so a per-repeat / per-subcommand output keeps
|
|
@@ -90,47 +112,50 @@ class Lowerer {
|
|
|
90
112
|
|
|
91
113
|
// `.join` collapses a node's subtree into one argv element. It is carried on
|
|
92
114
|
// sequence/repeat in the IR directly, and on `opt` by pushing it onto the
|
|
93
|
-
// wrapped content (`lowerComb`). On other
|
|
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.
|
|
94
120
|
const joinable =
|
|
95
121
|
node.kind === "comb" &&
|
|
96
122
|
(node.op === "seq" || node.op === "set" || node.op === "rep" || node.op === "opt");
|
|
97
123
|
if (node.join !== undefined && !joinable) {
|
|
98
|
-
this.
|
|
124
|
+
this.err("`.join()` is only supported on seq/set/rep/opt", node);
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
// A type-specific modifier that lands on an incompatible node is silently
|
|
102
|
-
// dropped downstream (only the matching lower* case reads it),
|
|
103
|
-
//
|
|
104
|
-
// by here the node's concrete kind is known and these checks are accurate.
|
|
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.
|
|
105
130
|
const isNumericTerminal =
|
|
106
131
|
node.kind === "terminal" && (node.terminal === "int" || node.terminal === "float");
|
|
107
132
|
const isRep = node.kind === "comb" && node.op === "rep";
|
|
108
133
|
const isPath = node.kind === "terminal" && node.terminal === "path";
|
|
109
134
|
if ((node.min !== undefined || node.max !== undefined) && !isNumericTerminal) {
|
|
110
|
-
this.
|
|
111
|
-
"`.min()`/`.max()` is only supported on int/float terminals; ignored here",
|
|
112
|
-
);
|
|
135
|
+
this.err("`.min()`/`.max()` is only supported on int/float terminals", node);
|
|
113
136
|
}
|
|
114
137
|
if ((node.countMin !== undefined || node.countMax !== undefined) && !isRep) {
|
|
115
|
-
this.
|
|
116
|
-
"`.count()`/`.countMin()`/`.countMax()` is only supported on rep; ignored here",
|
|
117
|
-
);
|
|
138
|
+
this.err("`.count()`/`.countMin()`/`.countMax()` is only supported on rep", node);
|
|
118
139
|
}
|
|
119
140
|
if ((node.mutable || node.resolveParent) && !isPath) {
|
|
120
|
-
this.
|
|
141
|
+
this.err("`.mutable()`/`.resolveParent()` is only supported on path", node);
|
|
121
142
|
}
|
|
122
143
|
if (node.mediaTypes?.length && !isPath) {
|
|
123
|
-
this.
|
|
144
|
+
this.err("`.mediaType()` is only supported on path", node);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
//
|
|
127
|
-
// that
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
159
|
|
|
135
160
|
// A `.output()` on a non-sequence node attaches to the enclosing sequence
|
|
136
161
|
// scope (`sink`); seq/set own their outputs (handled in `lowerComb`).
|
|
@@ -150,7 +175,7 @@ class Lowerer {
|
|
|
150
175
|
case "comb":
|
|
151
176
|
return this.lowerComb(node, expanding, name, sink);
|
|
152
177
|
default: {
|
|
153
|
-
this.
|
|
178
|
+
this.err(`Unknown AST node kind '${(node as AstNode).kind}'`, node);
|
|
154
179
|
return seq();
|
|
155
180
|
}
|
|
156
181
|
}
|
|
@@ -165,17 +190,20 @@ class Lowerer {
|
|
|
165
190
|
const target = node.refName!;
|
|
166
191
|
const aliasExpr = this.aliases.get(target);
|
|
167
192
|
if (!aliasExpr) {
|
|
168
|
-
this.
|
|
193
|
+
this.err(`Unknown alias '${target}'`, node);
|
|
169
194
|
return seq();
|
|
170
195
|
}
|
|
171
196
|
if (expanding.has(target)) {
|
|
172
|
-
this.
|
|
197
|
+
this.err(`Recursive alias '${target}' is not allowed`, node);
|
|
173
198
|
return seq();
|
|
174
199
|
}
|
|
175
200
|
const clone = structuredClone(aliasExpr);
|
|
176
201
|
// Overlay use-site decorations onto the inlined alias. The use site wins for
|
|
177
202
|
// each scalar decoration it specifies; outputs and media types accumulate.
|
|
178
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;
|
|
179
207
|
clone.name = node.name ?? clone.name;
|
|
180
208
|
clone.title = node.title ?? clone.title;
|
|
181
209
|
clone.description = node.description ?? clone.description;
|
|
@@ -200,7 +228,7 @@ class Lowerer {
|
|
|
200
228
|
switch (node.terminal) {
|
|
201
229
|
case "int": {
|
|
202
230
|
const e = int();
|
|
203
|
-
this.checkBounds(node.min, node.max, "value", "min", "max");
|
|
231
|
+
this.checkBounds(node.min, node.max, "value", "min", "max", node);
|
|
204
232
|
if (node.min !== undefined) e.attrs.minValue = node.min;
|
|
205
233
|
if (node.max !== undefined) e.attrs.maxValue = node.max;
|
|
206
234
|
this.applyMeta(e, node);
|
|
@@ -208,15 +236,15 @@ class Lowerer {
|
|
|
208
236
|
}
|
|
209
237
|
case "float": {
|
|
210
238
|
const e = float();
|
|
211
|
-
this.checkBounds(node.min, node.max, "value", "min", "max");
|
|
239
|
+
this.checkBounds(node.min, node.max, "value", "min", "max", node);
|
|
212
240
|
if (node.min !== undefined) e.attrs.minValue = node.min;
|
|
213
241
|
if (node.max !== undefined) e.attrs.maxValue = node.max;
|
|
214
242
|
this.applyMeta(e, node);
|
|
215
243
|
return e;
|
|
216
244
|
}
|
|
217
245
|
case "str": {
|
|
218
|
-
// A `str.mediaType(...)` is
|
|
219
|
-
//
|
|
246
|
+
// A `str.mediaType(...)` is rejected generically in `lowerNode` (media
|
|
247
|
+
// types are a `path`-only annotation), so nothing to do here.
|
|
220
248
|
const e = str();
|
|
221
249
|
this.applyMeta(e, node);
|
|
222
250
|
return e;
|
|
@@ -230,7 +258,7 @@ class Lowerer {
|
|
|
230
258
|
return e;
|
|
231
259
|
}
|
|
232
260
|
default: {
|
|
233
|
-
this.
|
|
261
|
+
this.err(`Unknown terminal '${String(node.terminal)}'`, node);
|
|
234
262
|
return str();
|
|
235
263
|
}
|
|
236
264
|
}
|
|
@@ -255,7 +283,7 @@ class Lowerer {
|
|
|
255
283
|
const selfOutputs: Output[] = [];
|
|
256
284
|
for (const o of node.outputs ?? []) selfOutputs.push(this.toOutput(o, name));
|
|
257
285
|
const lowered = lowerChildren(selfOutputs);
|
|
258
|
-
this.dedupeSiblingNames(lowered);
|
|
286
|
+
this.dedupeSiblingNames(lowered, node);
|
|
259
287
|
const e = seq(...lowered);
|
|
260
288
|
if (node.join !== undefined) e.attrs.join = node.join;
|
|
261
289
|
this.applyMeta(e, node);
|
|
@@ -271,7 +299,7 @@ class Lowerer {
|
|
|
271
299
|
// same way. `any` is exempt: it emits branch 0 and its branches are
|
|
272
300
|
// deliberately binding-compatible (same names by design).
|
|
273
301
|
const arms = lowerChildren(sink);
|
|
274
|
-
this.dedupeSiblingNames(arms);
|
|
302
|
+
this.dedupeSiblingNames(arms, node);
|
|
275
303
|
const e = alt(...arms);
|
|
276
304
|
this.applyMeta(e, node);
|
|
277
305
|
return e;
|
|
@@ -283,7 +311,7 @@ class Lowerer {
|
|
|
283
311
|
// is lossless and expected, not a degradation worth warning about.
|
|
284
312
|
// (Only a parser/validator would need to accept the other spellings.)
|
|
285
313
|
if (children.length === 0) {
|
|
286
|
-
this.
|
|
314
|
+
this.err("`any(...)` requires at least one branch", node);
|
|
287
315
|
return seq();
|
|
288
316
|
}
|
|
289
317
|
const e = this.lowerNode(children[0]!, expanding, name, sink);
|
|
@@ -314,14 +342,21 @@ class Lowerer {
|
|
|
314
342
|
const inner = this.wrapChildren(children, expanding, name, sink);
|
|
315
343
|
const e = rep(inner);
|
|
316
344
|
if (node.join !== undefined) e.attrs.join = node.join;
|
|
317
|
-
this.checkBounds(
|
|
345
|
+
this.checkBounds(
|
|
346
|
+
node.countMin,
|
|
347
|
+
node.countMax,
|
|
348
|
+
"repetition count",
|
|
349
|
+
"countMin",
|
|
350
|
+
"countMax",
|
|
351
|
+
node,
|
|
352
|
+
);
|
|
318
353
|
if (node.countMin !== undefined) e.attrs.countMin = node.countMin;
|
|
319
354
|
if (node.countMax !== undefined) e.attrs.countMax = node.countMax;
|
|
320
355
|
this.applyMeta(e, node);
|
|
321
356
|
return e;
|
|
322
357
|
}
|
|
323
358
|
default: {
|
|
324
|
-
this.
|
|
359
|
+
this.err(`Unknown combinator '${String(node.op)}'`, node);
|
|
325
360
|
return seq();
|
|
326
361
|
}
|
|
327
362
|
}
|
|
@@ -335,9 +370,10 @@ class Lowerer {
|
|
|
335
370
|
what: string,
|
|
336
371
|
minLabel: string,
|
|
337
372
|
maxLabel: string,
|
|
373
|
+
node?: AstNode,
|
|
338
374
|
): void {
|
|
339
375
|
if (min !== undefined && max !== undefined && min > max) {
|
|
340
|
-
this.
|
|
376
|
+
this.warn(`Inverted ${what} bounds: ${minLabel} (${min}) > ${maxLabel} (${max})`, node);
|
|
341
377
|
}
|
|
342
378
|
}
|
|
343
379
|
|
|
@@ -353,7 +389,7 @@ class Lowerer {
|
|
|
353
389
|
* explicit labels; it does not detect collisions between solver-derived names
|
|
354
390
|
* of otherwise-unnamed children, which the solver also does not guard.)
|
|
355
391
|
*/
|
|
356
|
-
private dedupeSiblingNames(children: Expr[]): void {
|
|
392
|
+
private dedupeSiblingNames(children: Expr[], parent?: AstNode): void {
|
|
357
393
|
const used = new Set<string>();
|
|
358
394
|
for (const child of children) {
|
|
359
395
|
const nm = child.meta?.name;
|
|
@@ -367,7 +403,9 @@ class Lowerer {
|
|
|
367
403
|
const renamed = `${nm}_${n}`;
|
|
368
404
|
used.add(renamed);
|
|
369
405
|
child.meta = { ...child.meta, name: renamed };
|
|
370
|
-
|
|
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);
|
|
371
409
|
}
|
|
372
410
|
}
|
|
373
411
|
|
|
@@ -405,16 +443,13 @@ class Lowerer {
|
|
|
405
443
|
}
|
|
406
444
|
const targetName = t.name ?? selfName;
|
|
407
445
|
if (!targetName) {
|
|
408
|
-
this.
|
|
409
|
-
"Output self-reference '{}' has no named node to resolve to; emitting empty",
|
|
410
|
-
);
|
|
446
|
+
this.warn("Output self-reference '{}' has no named node to resolve to; emitting empty");
|
|
411
447
|
tokens.push({ kind: "literal", value: "" });
|
|
412
448
|
continue;
|
|
413
449
|
}
|
|
414
450
|
if (t.stripPrefix?.length)
|
|
415
|
-
this.
|
|
416
|
-
if (t.basename)
|
|
417
|
-
this.warnings.push("Output ref op 'basename' is not supported by the IR; ignored");
|
|
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");
|
|
418
453
|
tokens.push({
|
|
419
454
|
kind: "ref",
|
|
420
455
|
target: nodeRef(targetName),
|
|
@@ -423,7 +458,7 @@ class Lowerer {
|
|
|
423
458
|
});
|
|
424
459
|
}
|
|
425
460
|
if (o.fallback !== undefined) {
|
|
426
|
-
this.
|
|
461
|
+
this.warn("Output-level '.or(...)' fallback is not supported by the IR; ignored");
|
|
427
462
|
}
|
|
428
463
|
const doc = docFrom(o);
|
|
429
464
|
return { ...(o.name && { name: o.name }), ...(doc && { doc }), tokens };
|
|
@@ -462,6 +497,30 @@ export function buildAppMeta(
|
|
|
462
497
|
const urls = strList(fm.urls);
|
|
463
498
|
const references = strList(fm.references);
|
|
464
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
|
+
|
|
465
524
|
const doc: Documentation = {
|
|
466
525
|
...rootTitleDesc,
|
|
467
526
|
...(authors.length > 0 && { authors }),
|
|
@@ -531,31 +590,12 @@ export function lowerDocument(doc: AstDocument): LowerResult {
|
|
|
531
590
|
doc.rootName,
|
|
532
591
|
);
|
|
533
592
|
|
|
534
|
-
// Declare-before-use: when a document has frontmatter, an extension whose
|
|
535
|
-
// annotations it uses should be listed in `extensions:`. We only check this
|
|
536
|
-
// when frontmatter is present - a frontmatter-less snippet hasn't opted into
|
|
537
|
-
// the declaration discipline, so flagging every quick `.output()` would be
|
|
538
|
-
// noise. The warning is advisory (extensions are ignorable by contract).
|
|
539
|
-
const extWarnings: string[] = [];
|
|
540
|
-
if (doc.frontmatter) {
|
|
541
|
-
const declared = new Set(
|
|
542
|
-
Array.isArray(doc.frontmatter.extensions)
|
|
543
|
-
? doc.frontmatter.extensions.filter((e): e is string => typeof e === "string")
|
|
544
|
-
: [],
|
|
545
|
-
);
|
|
546
|
-
for (const used of lowerer.usedExtensions) {
|
|
547
|
-
if (!declared.has(used)) {
|
|
548
|
-
extWarnings.push(
|
|
549
|
-
`Uses the '${used}' extension but does not declare it in frontmatter (add it to 'extensions:')`,
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
593
|
return {
|
|
556
594
|
...(meta && { meta }),
|
|
557
595
|
expr: result.expr,
|
|
558
596
|
errors: result.errors,
|
|
559
|
-
|
|
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 }))],
|
|
560
600
|
};
|
|
561
601
|
}
|
|
@@ -38,8 +38,8 @@ export class ArgtypeParser implements Frontend {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const lowered = lowerDocument(doc);
|
|
41
|
-
warnings.push(...lowered.warnings.map((
|
|
42
|
-
errors.push(...lowered.errors.map((
|
|
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
43
|
|
|
44
44
|
return {
|
|
45
45
|
...(lowered.meta && { meta: lowered.meta }),
|
|
@@ -57,6 +57,45 @@ const KNOWN_METHODS = new Set([
|
|
|
57
57
|
"resolveParent",
|
|
58
58
|
]);
|
|
59
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
|
+
|
|
60
99
|
class Parser {
|
|
61
100
|
private tokens: Token[];
|
|
62
101
|
private pos = 0;
|
|
@@ -215,67 +254,61 @@ class Parser {
|
|
|
215
254
|
this.next();
|
|
216
255
|
alts.push(this.parseChain());
|
|
217
256
|
}
|
|
218
|
-
|
|
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 }) };
|
|
219
260
|
}
|
|
220
261
|
|
|
221
262
|
private parseChain(): AstNode {
|
|
222
263
|
const node = this.parsePrimary();
|
|
223
264
|
// Method chain.
|
|
224
|
-
let chained = false;
|
|
225
265
|
while (this.at("dot")) {
|
|
226
266
|
this.next();
|
|
227
267
|
this.applyMethod(node);
|
|
228
|
-
chained = true;
|
|
229
268
|
}
|
|
230
|
-
// `= value`
|
|
231
|
-
//
|
|
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.
|
|
232
272
|
if (this.at("eq")) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (node.kind === "terminal" && !chained) {
|
|
236
|
-
node.default = value;
|
|
237
|
-
} else {
|
|
238
|
-
this.error(
|
|
239
|
-
"`= value` default is only allowed on a bare terminal; use `.default(...)` after a method chain",
|
|
240
|
-
eqTok,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
273
|
+
this.next(); // '='
|
|
274
|
+
node.default = this.parseValue();
|
|
243
275
|
}
|
|
244
276
|
return node;
|
|
245
277
|
}
|
|
246
278
|
|
|
247
279
|
private parsePrimary(): AstNode {
|
|
248
280
|
const tok = this.peek();
|
|
281
|
+
const span = { line: tok.line, column: tok.column };
|
|
249
282
|
|
|
250
283
|
if (tok.kind === "string") {
|
|
251
284
|
this.next();
|
|
252
|
-
return { kind: "literal", value: tok.value };
|
|
285
|
+
return { kind: "literal", value: tok.value, span };
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
if (tok.kind === "lparen") {
|
|
256
289
|
// Anonymous sequence group.
|
|
257
290
|
const children = this.parseParenList();
|
|
258
|
-
return { kind: "comb", op: "seq", children };
|
|
291
|
+
return { kind: "comb", op: "seq", children, span };
|
|
259
292
|
}
|
|
260
293
|
|
|
261
294
|
if (tok.kind === "ident") {
|
|
262
295
|
if (COMBINATORS.has(tok.value as Combinator) && this.peek(1).kind === "lparen") {
|
|
263
296
|
this.next();
|
|
264
297
|
const children = this.parseParenList();
|
|
265
|
-
return { kind: "comb", op: tok.value as Combinator, children };
|
|
298
|
+
return { kind: "comb", op: tok.value as Combinator, children, span };
|
|
266
299
|
}
|
|
267
300
|
if (TERMINALS.has(tok.value as Terminal)) {
|
|
268
301
|
this.next();
|
|
269
|
-
return { kind: "terminal", terminal: tok.value as Terminal };
|
|
302
|
+
return { kind: "terminal", terminal: tok.value as Terminal, span };
|
|
270
303
|
}
|
|
271
304
|
// Otherwise an alias reference.
|
|
272
305
|
this.next();
|
|
273
|
-
return { kind: "ref", refName: tok.value };
|
|
306
|
+
return { kind: "ref", refName: tok.value, span };
|
|
274
307
|
}
|
|
275
308
|
|
|
276
309
|
this.error(`Unexpected token '${tok.value || tok.kind}' in expression`, tok);
|
|
277
310
|
this.next();
|
|
278
|
-
return { kind: "literal", value: "" };
|
|
311
|
+
return { kind: "literal", value: "", span };
|
|
279
312
|
}
|
|
280
313
|
|
|
281
314
|
/** Parse `( elem, elem, ... )` with optional leading docs and trailing comma. */
|
|
@@ -308,9 +341,17 @@ class Parser {
|
|
|
308
341
|
// An extension method we don't implement (e.g. the draft `constraints`
|
|
309
342
|
// extension's `.requires()`/`.conflicts()`). The spec requires unknown
|
|
310
343
|
// extension annotations to be ignorable, so skip the argument list
|
|
311
|
-
// (whatever its shape) and continue rather than failing the parse.
|
|
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).
|
|
312
348
|
this.skipBalancedArgs();
|
|
313
|
-
|
|
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
|
+
);
|
|
314
355
|
return;
|
|
315
356
|
}
|
|
316
357
|
|
|
@@ -447,7 +488,12 @@ class Parser {
|
|
|
447
488
|
}
|
|
448
489
|
} else {
|
|
449
490
|
this.skipBalancedArgs();
|
|
450
|
-
|
|
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
|
+
);
|
|
451
497
|
}
|
|
452
498
|
}
|
|
453
499
|
|
|
@@ -20,29 +20,36 @@ export function destructTemplate<T>(template: string, lookup: Record<string, T>)
|
|
|
20
20
|
continue;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Pick the leftmost match; on a tie in position, the longest alias wins
|
|
24
|
+
// (maximal munch), so a value-key that is a prefix of another (e.g. `foo`
|
|
25
|
+
// vs `foobar`) does not shadow it by mere iteration order.
|
|
26
|
+
let best: { idx: number; alias: string; replacement: T } | null = null;
|
|
25
27
|
for (const [alias, replacement] of Object.entries(lookup)) {
|
|
28
|
+
if (alias.length === 0) continue;
|
|
26
29
|
const idx = x.indexOf(alias);
|
|
27
|
-
if (idx
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
stack.unshift(replacement);
|
|
35
|
-
if (left.length > 0) {
|
|
36
|
-
stack.unshift(left);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
didSplit = true;
|
|
40
|
-
break;
|
|
30
|
+
if (idx === -1) continue;
|
|
31
|
+
if (
|
|
32
|
+
best === null ||
|
|
33
|
+
idx < best.idx ||
|
|
34
|
+
(idx === best.idx && alias.length > best.alias.length)
|
|
35
|
+
) {
|
|
36
|
+
best = { idx, alias, replacement };
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
if (
|
|
40
|
+
if (best === null) {
|
|
45
41
|
destructed.push(x);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const left = x.slice(0, best.idx);
|
|
46
|
+
const right = x.slice(best.idx + best.alias.length);
|
|
47
|
+
if (right.length > 0) {
|
|
48
|
+
stack.unshift(right);
|
|
49
|
+
}
|
|
50
|
+
stack.unshift(best.replacement);
|
|
51
|
+
if (left.length > 0) {
|
|
52
|
+
stack.unshift(left);
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|