@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.
Files changed (44) hide show
  1. package/dist/index.cjs +430 -356
  2. package/dist/index.d.cts +1 -34
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +1 -34
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +431 -352
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +0 -2
  10. package/src/backend/argtype/emit.ts +2 -51
  11. package/src/backend/boutiques/boutiques.ts +55 -47
  12. package/src/backend/field-defaults.ts +40 -0
  13. package/src/backend/find-struct-node.ts +7 -0
  14. package/src/backend/index.ts +1 -9
  15. package/src/backend/nipype/emit.ts +11 -3
  16. package/src/backend/python/arg-builder.ts +2 -27
  17. package/src/backend/python/emit.ts +18 -6
  18. package/src/backend/python/outputs-emit.ts +27 -42
  19. package/src/backend/python/python.ts +46 -22
  20. package/src/backend/python/validate-emit.ts +4 -1
  21. package/src/backend/resolve-output-tokens.ts +0 -76
  22. package/src/backend/typescript/arg-builder.ts +2 -24
  23. package/src/backend/typescript/emit.ts +7 -4
  24. package/src/backend/typescript/outputs-emit.ts +15 -39
  25. package/src/backend/typescript/typemap.ts +9 -1
  26. package/src/backend/typescript/typescript.ts +41 -18
  27. package/src/backend/typescript/validate-emit.ts +4 -1
  28. package/src/backend/union-variants.ts +41 -1
  29. package/src/frontend/argdump/parser.ts +20 -19
  30. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  31. package/src/frontend/argtype/__fixtures__/bet.argtype +0 -2
  32. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  33. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  34. package/src/frontend/argtype/ast.ts +14 -1
  35. package/src/frontend/argtype/frontmatter.ts +49 -5
  36. package/src/frontend/argtype/lexer.ts +20 -0
  37. package/src/frontend/argtype/lower.ts +115 -75
  38. package/src/frontend/argtype/parser-frontend.ts +2 -2
  39. package/src/frontend/argtype/parser.ts +70 -24
  40. package/src/frontend/boutiques/destruct-template.ts +24 -17
  41. package/src/frontend/boutiques/parser.ts +27 -8
  42. package/src/ir/passes/canonicalize.ts +18 -5
  43. package/src/ir/passes/simplify.ts +17 -2
  44. 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: string[];
25
- warnings: string[];
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: string[] = [];
39
- readonly warnings: string[] = [];
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.warnings.push(`Duplicate alias '${a.name}'; last definition wins`);
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 nodes it has no meaning, so warn.
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.warnings.push("`.join()` is only supported on seq/set/rep/opt; ignored here");
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), so warn -
103
- // same contract as the `.join()` check. Refs are already resolved above, so
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.warnings.push(
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.warnings.push(
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.warnings.push("`.mutable()`/`.resolveParent()` is only supported on path; ignored here");
141
+ this.err("`.mutable()`/`.resolveParent()` is only supported on path", node);
121
142
  }
122
143
  if (node.mediaTypes?.length && !isPath) {
123
- this.warnings.push("`.mediaType()` is only supported on path; ignored here");
144
+ this.err("`.mediaType()` is only supported on path", node);
124
145
  }
125
146
 
126
- // Record which extensions' annotations the author reached for, so a document
127
- // that uses one without declaring it in frontmatter can be flagged. Only count
128
- // an annotation that actually applies here: a misapplied `path`-only modifier
129
- // is already warned about and dropped just above, so recording it would make
130
- // declare-before-use contradict that ("ignored" yet "declare it").
131
- if (node.outputs?.length) this.usedExtensions.add("outputs");
132
- if (node.mediaTypes?.length && isPath) this.usedExtensions.add("mediatypes");
133
- if ((node.mutable || node.resolveParent) && isPath) this.usedExtensions.add("paths");
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.errors.push(`Unknown AST node kind '${(node as AstNode).kind}'`);
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.errors.push(`Unknown alias '${target}'`);
193
+ this.err(`Unknown alias '${target}'`, node);
169
194
  return seq();
170
195
  }
171
196
  if (expanding.has(target)) {
172
- this.errors.push(`Recursive alias '${target}' is not allowed`);
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 warned about generically in `lowerNode`
219
- // (media types are a `path`-only annotation), so nothing to do here.
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.errors.push(`Unknown terminal '${String(node.terminal)}'`);
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.errors.push("`any(...)` requires at least one branch");
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(node.countMin, node.countMax, "repetition count", "countMin", "countMax");
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.errors.push(`Unknown combinator '${String(node.op)}'`);
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.warnings.push(`Inverted ${what} bounds: ${minLabel} (${min}) > ${maxLabel} (${max})`);
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
- this.warnings.push(`Duplicate sibling name '${nm}'; renamed one occurrence to '${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);
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.warnings.push(
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.warnings.push("Output ref op 'strip_prefix' is not supported by the IR; ignored");
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.warnings.push("Output-level '.or(...)' fallback is not supported by the IR; ignored");
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
- warnings: [...result.warnings, ...warnings, ...extWarnings],
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((message) => ({ message })));
42
- errors.push(...lowered.errors.map((message) => ({ message })));
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
- return { kind: "comb", op: "alt", children: alts };
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` default sugar. The spec restricts it to a bare terminal: once a
231
- // chain has started, `.default(...)` must be used instead.
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
- const eqTok = this.next();
234
- const value = this.parseValue();
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
- this.warn(`Ignoring unsupported method '.${method}()'`, nameTok);
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
- this.warn(`Ignoring unsupported output-template method '.${m.value}()'`, m);
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
- let didSplit = false;
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 !== -1) {
28
- const left = x.slice(0, idx);
29
- const right = x.slice(idx + alias.length);
30
-
31
- if (right.length > 0) {
32
- stack.unshift(right);
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 (!didSplit) {
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