lemmascript 0.3.3 → 0.5.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.
@@ -21,10 +21,11 @@ function mapExpr(e, f) {
21
21
  case "num":
22
22
  case "bool":
23
23
  case "str":
24
- case "constructor":
25
24
  case "emptyMap":
26
25
  case "emptySet":
27
26
  case "havoc": return e;
27
+ case "mapLiteral": return { ...e, entries: e.entries.map(en => ({ key: r(en.key), value: r(en.value) })) };
28
+ case "constructor": return e.args ? { ...e, args: e.args.map(r) } : e;
28
29
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
29
30
  case "unop": return { ...e, expr: r(e.expr) };
30
31
  case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
@@ -80,7 +81,6 @@ function mapTExpr(e, f) {
80
81
  case "num":
81
82
  case "str":
82
83
  case "bool":
83
- case "result":
84
84
  case "havoc": return e;
85
85
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
86
86
  case "unop": return { ...e, expr: r(e.expr) };
@@ -90,6 +90,15 @@ function mapTExpr(e, f) {
90
90
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
91
91
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
92
92
  case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
93
+ case "optChain": return { ...e, obj: r(e.obj),
94
+ chain: e.chain.map(s => s.kind === "call" ? { ...s, args: s.args.map(r) }
95
+ : s.kind === "index" ? { ...s, idx: r(s.idx) }
96
+ : s) };
97
+ case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
98
+ case "someMatch": return { ...e, scrutinee: r(e.scrutinee), someBody: r(e.someBody), noneBody: r(e.noneBody) };
99
+ case "tagMatch": return { ...e, scrutinee: r(e.scrutinee),
100
+ cases: e.cases.map(c => ({ ...c, body: r(c.body) })),
101
+ fallthrough: e.fallthrough ? r(e.fallthrough) : null };
93
102
  case "forall": return { ...e, body: r(e.body) };
94
103
  case "exists": return { ...e, body: r(e.body) };
95
104
  case "lambda": return e;
@@ -113,6 +122,10 @@ function mapTStmt(s, f) {
113
122
  case "ghostLet": return { ...s, init: r(s.init) };
114
123
  case "ghostAssign": return { ...s, value: r(s.value) };
115
124
  case "assert": return { ...s, expr: r(s.expr) };
125
+ case "someMatch": return { ...s, scrutinee: r(s.scrutinee), someBody: s.someBody.map(t => mapTStmt(t, f)), noneBody: s.noneBody.map(t => mapTStmt(t, f)) };
126
+ case "tagMatch": return { ...s, scrutinee: r(s.scrutinee),
127
+ cases: s.cases.map(c => ({ ...c, body: c.body.map(t => mapTStmt(t, f)) })),
128
+ fallthrough: s.fallthrough.map(t => mapTStmt(t, f)) };
116
129
  }
117
130
  }
118
131
  export const LEAN_OPTIONS = {
@@ -174,6 +187,40 @@ const BOOL_OP_MAP = {
174
187
  ...OP_MAP, "===": "==", "!==": "!=",
175
188
  };
176
189
  function transformExpr(e) { return lowerExpr(e, null); }
190
+ /** Reduce an if/let/return-shaped statement body to a single expression, for
191
+ * expression-only lambda bodies. Returns null for shapes that can't be a pure
192
+ * expression (loops, assignments, bare side effects), so callers leave the
193
+ * body as statements. A `return` is terminal — statements after it are
194
+ * unreachable and dropped.
195
+ * [return e] → e
196
+ * [let x = e, …rest] → Expr.let(x, e, flatten(rest))
197
+ * [if (c) thenStmts elseStmts, …rest] → Expr.if(c, …) where each branch
198
+ * absorbs `rest` if it doesn't already terminate with a return. */
199
+ function flattenLambdaBody(stmts) {
200
+ if (stmts.length === 0)
201
+ return null;
202
+ const first = stmts[0];
203
+ const rest = stmts.slice(1);
204
+ if (first.kind === "return")
205
+ return first.value;
206
+ if (first.kind === "let" && !first.mutable) {
207
+ const body = flattenLambdaBody(rest);
208
+ return body === null ? null : { kind: "let", name: first.name, value: first.value, body };
209
+ }
210
+ if (first.kind === "if") {
211
+ const thenTerminates = flattenLambdaBody(first.then);
212
+ if (thenTerminates !== null) {
213
+ // then-branch yields a value (ends in return) → `rest` is the else path.
214
+ const elseExpr = flattenLambdaBody(first.else.length > 0 ? [...first.else, ...rest] : rest);
215
+ return elseExpr === null ? null : { kind: "if", cond: first.cond, then: thenTerminates, else: elseExpr };
216
+ }
217
+ // then-branch falls through → both branches continue into `rest`.
218
+ const thenExpr = flattenLambdaBody([...first.then, ...rest]);
219
+ const elseExpr = flattenLambdaBody(first.else.length > 0 ? [...first.else, ...rest] : rest);
220
+ return thenExpr === null || elseExpr === null ? null : { kind: "if", cond: first.cond, then: thenExpr, else: elseExpr };
221
+ }
222
+ return null;
223
+ }
177
224
  /**
178
225
  * Lower a typed expression to Backend IR.
179
226
  *
@@ -183,28 +230,47 @@ function transformExpr(e) { return lowerExpr(e, null); }
183
230
  * a method call can appear inline in TS. It does NOT propagate into
184
231
  * field, index, record, forall, or exists sub-expressions.
185
232
  */
233
+ /** JS truthiness coercion for `if`/`while`/`?:` conditions.
234
+ * Dafny requires bool; TS treats number/string/array as truthy when non-empty.
235
+ * Optional conds are handled separately by narrow.ts (rewritten to someMatch). */
236
+ function coerceCondToBool(cond, ty) {
237
+ if (ty.kind === "bool")
238
+ return cond;
239
+ if (ty.kind === "int" || ty.kind === "nat")
240
+ return { kind: "binop", op: ">", left: cond, right: { kind: "num", value: 0 } };
241
+ if (ty.kind === "string" || ty.kind === "array")
242
+ return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "size" }, right: { kind: "num", value: 0 } };
243
+ return cond;
244
+ }
186
245
  /** Wrap an expression in Some/None for optional-typed conditionals.
187
246
  * If the raw TExpr is `undefined`, emit `.none`; otherwise wrap in `Some`. */
188
247
  function wrapOptionalBranch(expr, raw) {
189
- return (raw.kind === "var" && raw.name === "undefined")
190
- ? { kind: "constructor", name: ".none" }
191
- : { kind: "app", fn: "Some", args: [expr] };
248
+ // Set type: "Option" so Lean emits `Option.some`/`Option.none` (qualified).
249
+ // The dotted form `.some`/`.none` would be ambiguous in expression positions
250
+ // like the scrutinee of an outer match. Dafny treats `Option.Some` and bare
251
+ // `Some` equivalently — the qualification is harmless there.
252
+ if (raw.kind === "var" && raw.name === "undefined")
253
+ return { kind: "constructor", name: "none", type: "Option" };
254
+ if (raw.ty.kind === "optional")
255
+ return expr; // already Option<T>, don't double-wrap
256
+ return { kind: "constructor", name: "some", type: "Option", args: [expr] };
192
257
  }
193
258
  function lowerExpr(e, binds) {
194
- // Monadic lifting: extract embedded method calls to let-binds
195
- // Pass binds through to args so nested method calls are also lifted
196
- if (binds && e.kind === "call" && e.callKind === "method") {
259
+ // Monadic lifting: extract embedded method calls to let-binds.
260
+ // `callKind: "method"` means a global var-fn call (classifyCall returns
261
+ // "method" only for `fn.kind === "var"`). Receiver method calls have
262
+ // callKind "unknown" and fall through to the regular case below where
263
+ // they become `methodCall`.
264
+ if (binds && e.kind === "call" && e.callKind === "method" && e.fn.kind === "var") {
197
265
  const name = `_t${_liftCounter++}`;
198
- const fn = e.fn.kind === "var" ? e.fn.name : `${lowerExpr(e.fn, binds)}`;
199
266
  const args = e.args.map(a => lowerExpr(a, binds));
200
- binds.push({ kind: "let-bind", name, value: { kind: "app", fn, args } });
267
+ binds.push({ kind: "let-bind", name, value: { kind: "app", fn: e.fn.name, args } });
201
268
  return { kind: "var", name };
202
269
  }
203
270
  switch (e.kind) {
204
271
  case "var": return { kind: "var", name: e.name };
205
272
  case "num": return { kind: "num", value: e.value };
206
273
  case "bool": return { kind: "bool", value: e.value };
207
- case "result": return { kind: "var", name: "res" };
208
274
  case "str":
209
275
  if (e.ty.kind === "user")
210
276
  return { kind: "constructor", name: e.value, type: e.ty.name };
@@ -315,10 +381,27 @@ function lowerExpr(e, binds) {
315
381
  (e.left.ty.kind === "user" && e.right.ty.kind === "string"))) {
316
382
  const left = lowerExpr(e.left, binds);
317
383
  const right = lowerExpr(e.right, binds);
384
+ // `s || undefined` produces `Option<string>` — wrap the truthy branch in Some.
385
+ const rightIsUndef = e.right.kind === "var" && e.right.name === "undefined";
318
386
  return {
319
387
  kind: "if",
320
388
  cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: "size" }, right: { kind: "num", value: 0 } },
321
- then: left, else: right,
389
+ then: rightIsUndef ? { kind: "app", fn: "Some", args: [left] } : left,
390
+ else: right,
391
+ };
392
+ }
393
+ // `bool || undefined` → `if bool then Some(bool) else None`. Used in
394
+ // optional-field initialization where the source assigns a truthy/false
395
+ // bool to a `T?` field. Without this, emit produces `bool || None`,
396
+ // which Dafny rejects (bool || Option<?> is ill-typed).
397
+ if (e.op === "||" && e.left.ty.kind === "bool" &&
398
+ e.right.kind === "var" && e.right.name === "undefined") {
399
+ const left = lowerExpr(e.left, binds);
400
+ return {
401
+ kind: "if",
402
+ cond: left,
403
+ then: { kind: "app", fn: "Some", args: [left] },
404
+ else: { kind: "var", name: "undefined" },
322
405
  };
323
406
  }
324
407
  // int + string → NatToString(int) + string (string concatenation)
@@ -366,12 +449,35 @@ function lowerExpr(e, binds) {
366
449
  case "index": {
367
450
  const idx = transformExpr(e.idx);
368
451
  if (e.obj.ty.kind === "map") {
369
- return { kind: "methodCall", obj: transformExpr(e.obj), objTy: e.obj.ty, method: "get", args: [idx], monadic: false };
452
+ // Mirrors the .get() .getDirect switch at line ~453: when resolve has
453
+ // narrowed the index type to non-optional (via `k in m` atoms in scope),
454
+ // emit direct access; otherwise keep the Option-producing `get`.
455
+ const method = e.ty.kind !== "optional" ? "getDirect" : "get";
456
+ return { kind: "methodCall", obj: transformExpr(e.obj), objTy: e.obj.ty, method, args: [idx], monadic: false };
370
457
  }
371
458
  const wrappedIdx = isArray(e.obj.ty) && !isNat(e.idx.ty) ? { kind: "toNat", expr: idx } : idx;
372
459
  return { kind: "index", arr: transformExpr(e.obj), idx: wrappedIdx };
373
460
  }
374
461
  case "call": {
462
+ // Array.isArray(x) on a synth array-union (discriminant "__isArray__")
463
+ // → constructor predicate `x.ArrayBranch?`. Used in spec ensures and
464
+ // anywhere `Array.isArray` escapes the narrowing rule (narrow rewrites
465
+ // top-level if-cond Array.isArray uses; this catches the rest).
466
+ if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "Array" &&
467
+ e.fn.field === "isArray" && e.args.length === 1) {
468
+ const arg = e.args[0];
469
+ if (arg.ty.kind === "user") {
470
+ const baseName = arg.ty.name.includes("<") ? arg.ty.name.slice(0, arg.ty.name.indexOf("<")) : arg.ty.name;
471
+ const decl = _typeDecls.find(d => d.name === baseName);
472
+ if (decl?.kind === "discriminated-union" && decl.discriminant === "__isArray__") {
473
+ return {
474
+ kind: "binop", op: "=",
475
+ left: lowerExpr(arg, binds),
476
+ right: { kind: "constructor", name: "ArrayBranch", type: arg.ty.name },
477
+ };
478
+ }
479
+ }
480
+ }
375
481
  // Math.abs/min/max → preamble functions
376
482
  if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "Math") {
377
483
  if (e.fn.field === "abs" && e.args.length === 1)
@@ -408,6 +514,10 @@ function lowerExpr(e, binds) {
408
514
  return { kind: "toNat", expr: lowered };
409
515
  return lowered;
410
516
  });
517
+ // arr.concat(otherArr): array argument → real concatenation, not push
518
+ if (method === "concat" && e.fn.obj.ty.kind === "array" && e.args.length === 1 && e.args[0].ty.kind === "array") {
519
+ return { kind: "binop", op: "arrayConcat", left: recv, right: args[0] };
520
+ }
411
521
  // Spec-context map get: result type is non-optional → direct access
412
522
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
413
523
  method = "getDirect";
@@ -507,6 +617,19 @@ function lowerExpr(e, binds) {
507
617
  if (e.fields.length === 0 && !e.spread && e.ty.kind === "map") {
508
618
  return { kind: "emptyMap" };
509
619
  }
620
+ // Non-empty record literal with map type — emit as a flat Dafny map
621
+ // literal `map[k1 := v1, k2 := v2, ...]`. (A chain of `m["k" := v]`
622
+ // works for a handful of entries but Dafny's type resolver stack-
623
+ // overflows on hundreds; the flat form is fine at any size.)
624
+ if (e.fields.length > 0 && !e.spread && e.ty.kind === "map") {
625
+ return {
626
+ kind: "mapLiteral",
627
+ entries: e.fields.map(f => ({
628
+ key: { kind: "str", value: f.name },
629
+ value: lowerExpr(f.value, binds),
630
+ })),
631
+ };
632
+ }
510
633
  return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
511
634
  }
512
635
  case "arrayLiteral":
@@ -518,73 +641,29 @@ function lowerExpr(e, binds) {
518
641
  if (e.ty.kind === "set")
519
642
  return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
520
643
  return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
521
- case "lambda":
522
- return { kind: "lambda", params: e.params.map(p => ({ name: p.name, type: p.ty })), body: transformStmts(e.body, []) };
644
+ case "lambda": {
645
+ const body = transformStmts(e.body, []);
646
+ // Flatten an if/let/return-shaped multi-statement body into a single
647
+ // `return <expr>` so both backends' single-return-lambda fast path emits
648
+ // it (Dafny lambdas are expression-only; Lean prefers the expression form
649
+ // over a `do` block). Bodies with shapes we can't reduce (loops, bare
650
+ // side effects) are left as-is.
651
+ const flat = flattenLambdaBody(body);
652
+ return {
653
+ kind: "lambda",
654
+ params: e.params.map(p => ({ name: p.name, type: p.ty })),
655
+ body: flat === null ? body : [{ kind: "return", value: flat }],
656
+ };
657
+ }
523
658
  case "forall":
524
659
  return { kind: "forall", var: e.var, type: e.varTy, body: transformExpr(e.body) };
525
660
  case "exists":
526
661
  return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
527
662
  case "conditional": {
528
- // Phase 0: Complex expression check (call results, etc.) resolve substituted
529
- // and set narrowedVar + narrowedExpr because transform can't detect these.
530
- if (e.narrowedVar && e.narrowedExpr) {
531
- const scrutinee = lowerExpr(e.narrowedExpr, binds);
532
- const bound = matchBinder(e.narrowedVar);
533
- let thenExpr = lowerExpr(e.then, binds);
534
- let elseExpr = lowerExpr(e.else, binds);
535
- if (bound !== e.narrowedVar) {
536
- thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
537
- }
538
- if (e.ty.kind === "optional") {
539
- thenExpr = wrapOptionalBranch(thenExpr, e.then);
540
- elseExpr = wrapOptionalBranch(elseExpr, e.else);
541
- }
542
- return {
543
- kind: "match", scrutinee,
544
- arms: [
545
- { pattern: `.some ${bound}`, body: thenExpr },
546
- { pattern: ".none", body: elseExpr },
547
- ],
548
- };
549
- }
550
- // Phase 1: Truthiness — cond itself is optional (e.g. opt ? X : Y)
551
- // Uses narrowedVar set by resolve's Phase 1 (unchanged).
552
- if (e.narrowedVar && e.cond.ty.kind === "optional") {
553
- const cond = lowerExpr(e.cond, binds);
554
- let thenExpr = lowerExpr(e.then, binds);
555
- let elseExpr = lowerExpr(e.else, binds);
556
- const bound = matchBinder(e.narrowedVar);
557
- if (bound !== e.narrowedVar) {
558
- thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
559
- }
560
- thenExpr = wrapOptionalBranch(thenExpr, e.then);
561
- elseExpr = wrapOptionalBranch(elseExpr, e.else);
562
- return {
563
- kind: "match", scrutinee: cond,
564
- arms: [
565
- { pattern: `.some ${bound}`, body: thenExpr },
566
- { pattern: ".none", body: elseExpr },
567
- ],
568
- };
569
- }
570
- // Phase 2: Explicit check — x !== undefined ? A : B
571
- // Transform detects the pattern itself (no narrowedVar/narrowedExpr from resolve).
572
- const check = parseOptionalCheck(e.cond);
573
- if (check && !check.negated) {
574
- return lowerOptionalConditional(e, check, null, binds);
575
- }
576
- // Phase 3: && with optional check — x !== undefined && guard(x) ? A : B
577
- if (e.cond.kind === "binop" && e.cond.op === "&&") {
578
- const extracted = extractLeftmostOptional(e.cond);
579
- if (extracted) {
580
- const innerCheck = parseOptionalCheck(extracted.optCond);
581
- if (innerCheck && !innerCheck.negated) {
582
- return lowerOptionalConditional(e, innerCheck, extracted.rest, binds);
583
- }
584
- }
585
- }
586
- // Phase 4: Regular conditional (no optional narrowing)
587
- const cond = lowerExpr(e.cond, binds);
663
+ // JS truthiness coercion (string/array/int ... > 0). Matches SPEC §3.1
664
+ // negation forms (`!s` `s == ""`). Optional conds are already
665
+ // rewritten to someMatch by narrow.ts.
666
+ const cond = coerceCondToBool(lowerExpr(e.cond, binds), e.cond.ty);
588
667
  let thenExpr = lowerExpr(e.then, binds);
589
668
  let elseExpr = lowerExpr(e.else, binds);
590
669
  if (e.ty.kind === "optional") {
@@ -593,6 +672,12 @@ function lowerExpr(e, binds) {
593
672
  }
594
673
  return { kind: "if", cond, then: thenExpr, else: elseExpr };
595
674
  }
675
+ case "optChain":
676
+ // Narrow should have rewritten optChain to someMatch.
677
+ throw new Error(`optChain reached transform — narrow should have rewritten it`);
678
+ case "nullish":
679
+ // Narrow should have rewritten nullish to someMatch.
680
+ throw new Error(`nullish reached transform — narrow should have rewritten it`);
596
681
  case "havoc":
597
682
  // Dafny's * only works in var/assign positions — lift to own declaration
598
683
  if (binds) {
@@ -601,6 +686,82 @@ function lowerExpr(e, binds) {
601
686
  return { kind: "var", name };
602
687
  }
603
688
  return { kind: "havoc", type: e.ty };
689
+ case "someMatch": {
690
+ let someBody;
691
+ let scrutinee;
692
+ const path = asTAccessPath(e.scrutinee);
693
+ if (path) {
694
+ // Pure access path (var or any depth of obj.f.g.h) — substitute the
695
+ // path with the binder pre-lowering.
696
+ const replaced = replacePathInTExpr(e.someBody, path, e.binder, e.binderTy);
697
+ someBody = lowerExpr(replaced, binds);
698
+ // Bare-var shortcut, but route \result through lowerExpr so the
699
+ // lemma-side replaceVar pass can substitute it with the function call.
700
+ scrutinee = path.fields.length === 0 && path.rootVar !== "\\result"
701
+ ? path.rootVar
702
+ : lowerExpr(e.scrutinee, binds);
703
+ }
704
+ else {
705
+ // Complex scrutinee — narrow pre-bound the someBody to use the binder directly,
706
+ // so no substitution needed. Used by optChain rewrites.
707
+ someBody = lowerExpr(e.someBody, binds);
708
+ scrutinee = lowerExpr(e.scrutinee, binds);
709
+ }
710
+ let noneBody = lowerExpr(e.noneBody, binds);
711
+ if (e.ty.kind === "optional") {
712
+ someBody = wrapOptionalBranch(someBody, e.someBody);
713
+ noneBody = wrapOptionalBranch(noneBody, e.noneBody);
714
+ }
715
+ return {
716
+ kind: "match", scrutinee,
717
+ arms: [
718
+ { pattern: `.some ${e.binder}`, body: someBody },
719
+ { pattern: ".none", body: noneBody },
720
+ ],
721
+ };
722
+ }
723
+ case "tagMatch": {
724
+ // Expression-form tagMatch — emitted by `ruleImplArrayIsArray` for spec
725
+ // implications like `Array.isArray(x) ==> B` and by
726
+ // `ruleConditionalArrayIsArray` for ternary narrowing. Substitutes
727
+ // scrutinee field accesses and (for synth array-unions) scrutinee
728
+ // path occurrences inside each arm with the variant's payload binder.
729
+ // Path scrutinees (e.g. `m.content`) get a synthesized hint derived
730
+ // from the last field/var name so the binder reads naturally.
731
+ const scrutinee = lowerExpr(e.scrutinee, binds);
732
+ const decl = _typeDecls.find(d => d.name === e.typeName);
733
+ const isSynthArrayUnion = decl?.discriminant === "__isArray__";
734
+ const varName = e.scrutinee.kind === "var" ? e.scrutinee.name : undefined;
735
+ const pathHint = varName ?? scrutineeHint(e.scrutinee);
736
+ const wrapOpt = e.ty.kind === "optional";
737
+ const arms = e.cases.map(c => {
738
+ const variant = decl?.variants?.find(v => v.name === c.variant);
739
+ const fields = variant?.fields ?? [];
740
+ let body = lowerExpr(c.body, binds);
741
+ if (varName && fields.length > 0) {
742
+ body = replaceFieldAccess(body, varName, fields);
743
+ if (isSynthArrayUnion && fields.length === 1) {
744
+ body = replaceVarInExpr(body, varName, matchBinder(fields[0].name, varName));
745
+ }
746
+ }
747
+ else if (!varName && isSynthArrayUnion && fields.length === 1) {
748
+ // Path scrutinee (e.g. `m.content`): replace structural occurrences
749
+ // with the binder var ref.
750
+ const binderName = matchBinder(fields[0].name, pathHint);
751
+ body = replaceExprInExpr(body, scrutinee, { kind: "var", name: binderName });
752
+ }
753
+ if (wrapOpt)
754
+ body = wrapOptionalBranch(body, c.body);
755
+ return { pattern: buildMatchPattern(c.variant, fields, pathHint), body };
756
+ });
757
+ if (e.fallthrough) {
758
+ let body = lowerExpr(e.fallthrough, binds);
759
+ if (wrapOpt)
760
+ body = wrapOptionalBranch(body, e.fallthrough);
761
+ arms.push({ pattern: "_", body });
762
+ }
763
+ return { kind: "match", scrutinee: varName ?? scrutinee, arms };
764
+ }
604
765
  }
605
766
  }
606
767
  function flattenImpl(e) {
@@ -654,33 +815,124 @@ function replaceFieldAccess(e, varName, fields) {
654
815
  return null;
655
816
  });
656
817
  }
818
+ /** Replace bare `var(oldName)` references → `var(newName)` in lowered IR.
819
+ * Used inside synth array-union match arms: the user code refers to the
820
+ * scrutinee by its bare name (`content`), but in the arm body that name
821
+ * must refer to the variant's sole payload binder (`i_content_arr`). */
822
+ function replaceVarInExpr(e, oldName, newName) {
823
+ return mapExpr(e, x => {
824
+ if (x.kind === "var" && x.name === oldName)
825
+ return { kind: "var", name: newName };
826
+ // If a binding shadows the name, stop substituting inside its body.
827
+ if (x.kind === "let" && x.name === oldName)
828
+ return { ...x, value: replaceVarInExpr(x.value, oldName, newName) };
829
+ return null;
830
+ });
831
+ }
832
+ /** Structural equality on Expr access-paths (var / field chain). Enough to
833
+ * match the scrutinee `m.content` against later occurrences in a match arm. */
834
+ function exprPathEqual(a, b) {
835
+ if (a.kind !== b.kind)
836
+ return false;
837
+ if (a.kind === "var" && b.kind === "var")
838
+ return a.name === b.name;
839
+ if (a.kind === "field" && b.kind === "field")
840
+ return a.field === b.field && exprPathEqual(a.obj, b.obj);
841
+ return false;
842
+ }
843
+ /** Substitute every occurrence of `target` (an access-path Expr) with `repl`
844
+ * inside `e`. Mirror of `replaceVarInExpr` but keyed on a sub-path rather
845
+ * than a bare name — needed when the narrowing scrutinee is `m.content`
846
+ * (field access) rather than a bare `content` (var). */
847
+ function replaceExprInExpr(e, target, repl) {
848
+ return mapExpr(e, x => {
849
+ if (exprPathEqual(x, target))
850
+ return repl;
851
+ return null;
852
+ });
853
+ }
854
+ /** Extract a short reader-friendly hint for a TExpr access-path: the last
855
+ * field name in a field chain, or the var name. Used to derive a stable
856
+ * binder prefix when the scrutinee isn't a bare var. */
857
+ function scrutineeHint(e) {
858
+ if (e.kind === "var")
859
+ return e.name;
860
+ if (e.kind === "field")
861
+ return e.field;
862
+ return "x";
863
+ }
657
864
  // ── Transform statements ─────────────────────────────────────
865
+ // `if (X) continue; rest` → `if (!X) { rest }` at the top of a loop body.
866
+ // Dafny's lowered while-loops have the index increment at the bottom, so a
867
+ // `continue` would skip it and loop forever; rewriting to if/else lets the
868
+ // loop fall through normally.
869
+ function negateExpr(e) {
870
+ if (e.kind === "unop" && e.op === "!")
871
+ return e.expr;
872
+ return { kind: "unop", op: "!", expr: e };
873
+ }
874
+ /** Build the two pieces of an `arr.pop()` lowering on a named array variable:
875
+ * - `optValue` is `(if |arr|>0 then Some(arr[|arr|-1]) else None)` (the popped element)
876
+ * - `guardedTrunc` is `(if |arr|>0 then arr[..|arr|-1] else arr)` (the array minus its last element)
877
+ * Callers wrap these in let/assign statements appropriate to their context. */
878
+ function buildPopLowering(arrName, arrTy) {
879
+ const arrVar = { kind: "var", name: arrName };
880
+ const arrLen = { kind: "field", obj: arrVar, field: "size" };
881
+ const lastIdx = { kind: "binop", op: "-", left: arrLen, right: { kind: "num", value: 1 } };
882
+ const lastElem = { kind: "index", arr: arrVar, idx: lastIdx };
883
+ const isNonEmpty = { kind: "binop", op: ">", left: arrLen, right: { kind: "num", value: 0 } };
884
+ const optValue = { kind: "if", cond: isNonEmpty,
885
+ then: { kind: "app", fn: "Some", args: [lastElem] },
886
+ else: { kind: "var", name: "undefined" } };
887
+ const truncated = { kind: "methodCall", obj: arrVar, objTy: arrTy, method: "slice",
888
+ args: [{ kind: "num", value: 0 }, lastIdx], monadic: false };
889
+ const guardedTrunc = { kind: "if", cond: isNonEmpty, then: truncated, else: arrVar };
890
+ return { optValue, guardedTrunc };
891
+ }
892
+ function eliminateTopLevelContinue(stmts) {
893
+ const out = [];
894
+ for (let i = 0; i < stmts.length; i++) {
895
+ const s = stmts[i];
896
+ // `if (X) {...A, continue}; rest` (empty else, trailing continue in then)
897
+ // — if A is empty, rewrite to `if (!X) { rest }`; otherwise rewrite to
898
+ // `if (X) { ...A } else { rest }`. Either form lets the loop fall through
899
+ // naturally past the bottom of the body.
900
+ if (s.kind === "if" && s.else.length === 0 &&
901
+ s.then.length >= 1 && s.then[s.then.length - 1].kind === "continue") {
902
+ const rest = eliminateTopLevelContinue(stmts.slice(i + 1));
903
+ const thenWithoutContinue = s.then.slice(0, -1);
904
+ if (thenWithoutContinue.length === 0) {
905
+ out.push({ kind: "if", cond: negateExpr(s.cond), then: rest, else: [] });
906
+ }
907
+ else {
908
+ out.push({ kind: "if", cond: s.cond, then: thenWithoutContinue, else: rest });
909
+ }
910
+ return out;
911
+ }
912
+ // narrow.ts's ruleEarlyReturnConsume rewrites `if (!x) continue; rest`
913
+ // (when x is Optional) to a someMatch which transform.ts then emits as a
914
+ // `match`. A trailing `continue` inside a match arm is a no-op when the
915
+ // match is the last statement in the loop body — drop it.
916
+ if (s.kind === "match" && i === stmts.length - 1) {
917
+ const arms = s.arms.map(arm => {
918
+ const b = arm.body;
919
+ if (b.length > 0 && b[b.length - 1].kind === "continue") {
920
+ return { ...arm, body: b.slice(0, -1) };
921
+ }
922
+ return arm;
923
+ });
924
+ out.push({ ...s, arms });
925
+ continue;
926
+ }
927
+ out.push(s);
928
+ }
929
+ return out;
930
+ }
658
931
  function transformStmts(stmts, typeDecls) {
659
932
  const result = [];
660
933
  let i = 0;
661
934
  while (i < stmts.length) {
662
935
  const s = stmts[i];
663
- // Detect discriminant if-chain → match
664
- if (s.kind === "if") {
665
- const chain = detectDiscriminantChain(stmts.slice(i));
666
- if (chain) {
667
- result.push(emitMatchStmt(chain.chain, typeDecls));
668
- i += chain.consumed;
669
- continue;
670
- }
671
- // Detect optional check → match on Some/None
672
- const optMatch = prepareOptionalMatch(s, stmts.slice(i + 1));
673
- if (optMatch) {
674
- result.push(emitOptionalMatch(optMatch.check.varName, optMatch.check.negated, s, typeDecls, stmts.slice(i + 1), optMatch.check.fieldExpr));
675
- // If rest was consumed into the Some branch, skip remaining
676
- const origSome = optMatch.check.negated ? s.else : s.then;
677
- if (origSome.length === 0 && i + 1 < stmts.length) {
678
- return result;
679
- }
680
- i++;
681
- continue;
682
- }
683
- }
684
936
  // Transform for-of → for-in over range
685
937
  if (s.kind === "forof") {
686
938
  const varName = s.names[0];
@@ -700,7 +952,7 @@ function transformStmts(stmts, typeDecls) {
700
952
  const idxName = `_${keyName}_idx${suffix}`;
701
953
  const idx = { kind: "var", name: idxName };
702
954
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
703
- const bodyStmts = transformStmts(s.body, typeDecls);
955
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
704
956
  const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
705
957
  const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
706
958
  result.push({
@@ -726,7 +978,7 @@ function transformStmts(stmts, typeDecls) {
726
978
  const idxName = `_${keyName}_idx${suffix}`;
727
979
  const idx = { kind: "var", name: idxName };
728
980
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
729
- const bodyStmts = transformStmts(s.body, typeDecls);
981
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
730
982
  const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
731
983
  const letVal = { kind: "let", name: valueName, type: valueTy, mutable: false,
732
984
  value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: keyName }], monadic: false } };
@@ -753,7 +1005,7 @@ function transformStmts(stmts, typeDecls) {
753
1005
  const idxName = `_${varName}_idx${suffix}`;
754
1006
  const idx = { kind: "var", name: idxName };
755
1007
  const arrSize = { kind: "field", obj: iterExpr, field: "size" };
756
- const bodyStmts = transformStmts(s.body, typeDecls);
1008
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
757
1009
  const letElem = { kind: "let", name: varName, type: varTy, mutable: false, value: { kind: "index", arr: iterExpr, idx } };
758
1010
  // Auto-add bound invariant: idx ≤ bound (always true for range loops)
759
1011
  const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
@@ -798,6 +1050,17 @@ function transformStmt(s, typeDecls) {
798
1050
  return [letHead, sliceTail];
799
1051
  }
800
1052
  }
1053
+ // let x = arr.pop() → let x: T? = (Option-expr); arr := (truncated-or-self)
1054
+ if (init && init.fn.kind === "field" && init.fn.field === "pop" && init.fn.obj.ty.kind === "array") {
1055
+ const arrName = init.fn.obj.kind === "var" ? init.fn.obj.name : undefined;
1056
+ if (arrName) {
1057
+ const { optValue, guardedTrunc } = buildPopLowering(arrName, init.fn.obj.ty);
1058
+ return [
1059
+ { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: optValue },
1060
+ { kind: "assign", target: arrName, value: guardedTrunc },
1061
+ ];
1062
+ }
1063
+ }
801
1064
  // new Map(arr.map(n => [n.field, n])) → let m = map[]; for (n of arr) m[n.field] := n
802
1065
  if (init && init.fn.kind === "var" && init.fn.name === "__mapFromArray" &&
803
1066
  init.args.length === 1 && init.args[0].kind === "call" &&
@@ -844,25 +1107,21 @@ function transformStmt(s, typeDecls) {
844
1107
  return stmts;
845
1108
  }
846
1109
  }
847
- // Desugar: const x = optCheck && guard ? A : B
848
- // → var x := B; if (optCheck && guard) { x := A; }
849
- // This avoids putting method calls (from guard) inside a match expression,
850
- // which Dafny doesn't allow. The if-case in transformStmt handles the &&
851
- // via extractLeftmostOptional → emitOptionalMatch (statement-level match).
852
- if (s.init.kind === "conditional" && s.init.cond.kind === "binop" && s.init.cond.op === "&&") {
853
- const extracted = extractLeftmostOptional(s.init.cond);
854
- if (extracted) {
855
- const desugared = [
856
- { kind: "let", name: s.name, ty: s.ty, mutable: true, init: s.init.else },
857
- { kind: "if", cond: s.init.cond, then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] },
858
- ];
859
- return desugared.flatMap(ds => transformStmt(ds, typeDecls));
860
- }
861
- }
862
1110
  const { binds, expr } = liftMethodCalls(s.init);
863
1111
  return [...binds, { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: expr }];
864
1112
  }
865
1113
  case "assign": {
1114
+ // x = arr.pop() → x := (Option-expr); arr := (truncated-or-self)
1115
+ if (s.value.kind === "call" && s.value.fn.kind === "field" &&
1116
+ s.value.fn.field === "pop" && s.value.fn.obj.ty.kind === "array" &&
1117
+ s.value.fn.obj.kind === "var") {
1118
+ const arrName = s.value.fn.obj.name;
1119
+ const { optValue, guardedTrunc } = buildPopLowering(arrName, s.value.fn.obj.ty);
1120
+ return [
1121
+ { kind: "assign", target: s.target, value: optValue },
1122
+ { kind: "assign", target: arrName, value: guardedTrunc },
1123
+ ];
1124
+ }
866
1125
  // Top-level method call → direct monadic bind, no lifting needed
867
1126
  if (s.value.kind === "call" && s.value.callKind === "method")
868
1127
  return [{ kind: "bind", target: s.target, value: transformExpr(s.value) }];
@@ -889,15 +1148,19 @@ function transformStmt(s, typeDecls) {
889
1148
  const { binds, expr } = liftMethodCalls(s.expr);
890
1149
  return [...binds, { kind: "assign", target: receiver, value: expr }];
891
1150
  }
892
- // Optional chaining on map.get: m.get(k)?.push(v) → if k in m { m[k] := m[k] + [v] }
893
- if (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
894
- s.expr.fn.obj.kind === "call" && s.expr.fn.obj.fn.kind === "field" &&
895
- s.expr.fn.obj.fn.obj.ty.kind === "map" && s.expr.fn.obj.fn.field === "get" &&
896
- s.expr.fn.field === "push") {
897
- const mapExpr = s.expr.fn.obj.fn.obj;
1151
+ // Optional chaining on map.get at statement level: m.get(k)?.push(v)
1152
+ // → if k in m { m[k] := m[k] + [v] } (actual mutation, not value-discard).
1153
+ // Narrow rewrote this to a someMatch — destructure to find the underlying
1154
+ // m.get(k) scrutinee and the .push(v) body call.
1155
+ if (s.expr.kind === "someMatch" &&
1156
+ s.expr.scrutinee.kind === "call" && s.expr.scrutinee.fn.kind === "field" &&
1157
+ s.expr.scrutinee.fn.field === "get" && s.expr.scrutinee.fn.obj.ty.kind === "map" &&
1158
+ s.expr.someBody.kind === "call" && s.expr.someBody.fn.kind === "field" &&
1159
+ s.expr.someBody.fn.field === "push") {
1160
+ const mapExpr = s.expr.scrutinee.fn.obj;
898
1161
  const mapName = mapExpr.kind === "var" ? mapExpr.name : undefined;
899
- const keyExpr = lowerExpr(s.expr.fn.obj.args[0], null);
900
- const pushArg = lowerExpr(s.expr.args[0], null);
1162
+ const keyExpr = lowerExpr(s.expr.scrutinee.args[0], null);
1163
+ const pushArg = lowerExpr(s.expr.someBody.args[0], null);
901
1164
  if (mapName) {
902
1165
  const mapVar = { kind: "var", name: mapName };
903
1166
  const directGet = { kind: "methodCall", obj: mapVar, objTy: mapExpr.ty, method: "getDirect", args: [keyExpr], monadic: false };
@@ -911,29 +1174,18 @@ function transformStmt(s, typeDecls) {
911
1174
  return [...binds, { kind: "assign", target: "_", value: expr }];
912
1175
  }
913
1176
  case "if": {
914
- // Restructure && with optional check: extract the leftmost optional check
915
- // from a && chain and nest the rest inside. Handles left-associative chains:
916
- // if ((x !== undefined && b) && c) → if (x !== undefined) { if (b && c) { ... } }
917
- if (s.cond.kind === "binop" && s.cond.op === "&&" && s.else.length === 0) {
918
- const extracted = extractLeftmostOptional(s.cond);
919
- if (extracted) {
920
- const innerIf = { kind: "if", cond: extracted.rest, then: s.then, else: [] };
921
- const outerIf = { kind: "if", cond: extracted.optCond, then: [innerIf], else: [] };
922
- return transformStmts([outerIf], typeDecls);
923
- }
924
- }
925
- // Lift from condition only (Lean rule: don't lift from branches)
1177
+ // Lift from condition only (Lean rule: don't lift from branches).
926
1178
  const { binds, expr: cond } = liftMethodCalls(s.cond);
927
- return [...binds, { kind: "if", cond, then: transformStmts(s.then, typeDecls), else: transformStmts(s.else, typeDecls) }];
1179
+ return [...binds, { kind: "if", cond: coerceCondToBool(cond, s.cond.ty), then: transformStmts(s.then, typeDecls), else: transformStmts(s.else, typeDecls) }];
928
1180
  }
929
1181
  case "while":
930
1182
  return [{
931
1183
  kind: "while",
932
- cond: transformExpr(s.cond),
1184
+ cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty),
933
1185
  invariants: s.invariants.map(transformExpr),
934
1186
  decreasing: s.decreases ? transformExpr(s.decreases) : null,
935
1187
  doneWith: s.doneWith ? transformExpr(s.doneWith) : null,
936
- body: transformStmts(s.body, typeDecls),
1188
+ body: eliminateTopLevelContinue(transformStmts(s.body, typeDecls)),
937
1189
  }];
938
1190
  case "throw":
939
1191
  return [{ kind: "assert", expr: { kind: "bool", value: false } }];
@@ -946,181 +1198,32 @@ function transformStmt(s, typeDecls) {
946
1198
  case "ghostAssign":
947
1199
  return [{ kind: "ghostAssign", target: s.target, value: transformExpr(s.value) }];
948
1200
  case "assert":
949
- return [{ kind: "assert", expr: transformExpr(s.expr) }];
950
- }
951
- }
952
- function detectDiscriminantChain(stmts) {
953
- if (stmts.length === 0 || stmts[0].kind !== "if")
954
- return null;
955
- const first = parseDiscriminantCond(stmts[0].cond);
956
- if (!first)
957
- return null;
958
- const cases = [];
959
- // Follow else branches within one if-else-if tree
960
- function collectElse(s) {
961
- const p = parseDiscriminantCond(s.cond);
962
- if (!p || p.varName !== first.varName)
963
- return [s];
964
- cases.push({ variant: p.variant, body: s.then });
965
- if (s.else.length === 0)
966
- return [];
967
- if (s.else.length === 1 && s.else[0].kind === "if")
968
- return collectElse(s.else[0]);
969
- return s.else;
970
- }
971
- // Walk consecutive top-level ifs on the same discriminant
972
- let consumed = 0;
973
- for (let i = 0; i < stmts.length; i++) {
974
- const s = stmts[i];
975
- if (s.kind !== "if")
976
- break;
977
- const p = parseDiscriminantCond(s.cond);
978
- if (!p || p.varName !== first.varName)
979
- break;
980
- cases.push({ variant: p.variant, body: s.then });
981
- consumed = i + 1;
982
- if (s.else.length > 0) {
983
- const ft = (s.else.length === 1 && s.else[0].kind === "if") ? collectElse(s.else[0]) : s.else;
984
- return cases.length > 0 ? { chain: { ...first, cases, fallthrough: ft }, consumed } : null;
1201
+ return [{ kind: "assert", expr: transformExpr(s.expr), assumed: s.assumed }];
1202
+ case "someMatch": {
1203
+ const path = asTAccessPath(s.scrutinee);
1204
+ if (path) {
1205
+ const replaced = replacePathInTStmts(s.someBody, path, s.binder, s.binderTy);
1206
+ const someBody = transformStmts(replaced, typeDecls);
1207
+ const noneBody = transformStmts(s.noneBody, typeDecls);
1208
+ const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
1209
+ return [{
1210
+ kind: "match", scrutinee,
1211
+ arms: [
1212
+ { pattern: `.some ${s.binder}`, body: someBody },
1213
+ { pattern: ".none", body: noneBody },
1214
+ ],
1215
+ }];
1216
+ }
1217
+ throw new Error(`someMatch stmt scrutinee must be a pure access path, got ${s.scrutinee.kind}`);
985
1218
  }
1219
+ case "tagMatch":
1220
+ return [emitMatchStmt(s.scrutinee, s.typeName, s.cases, s.fallthrough, typeDecls)];
986
1221
  }
987
- if (cases.length === 0)
988
- return null;
989
- return { chain: { ...first, cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
990
- }
991
- function parseDiscriminantCond(cond) {
992
- // Pattern: x.discriminant === "variant"
993
- if (cond.kind !== "binop" || cond.op !== "===" || cond.right.kind !== "str")
994
- return null;
995
- if (cond.left.kind !== "field" || !cond.left.isDiscriminant)
996
- return null;
997
- if (cond.left.obj.kind !== "var" || cond.left.obj.ty.kind !== "user")
998
- return null;
999
- return { varName: cond.left.obj.name, typeName: cond.left.obj.ty.name, variant: cond.right.value };
1000
- }
1001
- function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr) {
1002
- let someBranch = negated ? s.else : s.then;
1003
- const noneBranch = negated ? s.then : s.else;
1004
- // Early-return pattern: if (x === undefined) { return ... } — Some branch is empty,
1005
- // so include remaining statements as the Some body
1006
- if (someBranch.length === 0 && restStmts && restStmts.length > 0) {
1007
- someBranch = restStmts;
1008
- }
1009
- const sanitized = varName.replace(/\./g, "_");
1010
- const bound = matchBinder(`${sanitized}_val`);
1011
- // Replace the narrowed variable/field in the Some branch body.
1012
- // Field chains: replace in TStmt before transform (so downstream narrowing sees simple vars).
1013
- // Simple vars: replace in IR after transform (the original mechanism).
1014
- let someBody;
1015
- if (fieldExpr && fieldExpr.kind === "field" && fieldExpr.obj.kind === "var") {
1016
- const innerTy = fieldExpr.ty.kind === "optional" ? fieldExpr.ty.inner : fieldExpr.ty;
1017
- const replaced = replaceFieldsInTStmts(someBranch, fieldExpr.obj.name, [
1018
- { fieldName: fieldExpr.field, newName: bound, fallbackTy: innerTy },
1019
- ]);
1020
- someBody = transformStmts(replaced, typeDecls);
1021
- }
1022
- else {
1023
- const transformed = transformStmts(someBranch, typeDecls);
1024
- someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound }, true)));
1025
- }
1026
- // For field chains, use an Expr scrutinee so outer match replaceVar can
1027
- // substitute the object variable (e.g. task → i_task_val in task.deletedFromList).
1028
- // String scrutinees are opaque to replaceVar/mapExpr.
1029
- const scrutinee = fieldExpr ? transformExpr(fieldExpr) : varName;
1030
- return {
1031
- kind: "match", scrutinee,
1032
- arms: [
1033
- { pattern: `.some ${bound}`, body: someBody },
1034
- { pattern: ".none", body: noneBranch.length > 0 ? transformStmts(noneBranch, typeDecls) : [] },
1035
- ],
1036
- };
1037
1222
  }
1038
1223
  /** Apply an expression transform to all expressions in a statement (convenience wrapper). */
1039
1224
  function mapStmtExprs(s, r) {
1040
1225
  return mapStmt(s, e => r(e));
1041
1226
  }
1042
- // ── Optional narrowing helpers ──────────────────────────────
1043
- //
1044
- // Optional narrowing converts TS `if (x === undefined)` / `x !== undefined ? a : b`
1045
- // patterns to `match x { Some(val) => ..., None => ... }`.
1046
- //
1047
- // The resolve phase (resolve.ts) handles TYPE narrowing only:
1048
- // - Flow narrowing: after `if (x === undefined) return`, x is non-optional
1049
- // - && narrowing: in `x !== undefined && f(x)`, f(x) sees x as non-optional
1050
- // - Conditional type narrowing: extends env (simple vars) or narrowedFields context
1051
- // (field chains) so the then-branch resolves with the unwrapped type
1052
- //
1053
- // The transform phase (here) handles ALL structural narrowing:
1054
- // - Statement-level: `transformStmts` detects optional checks → `emitOptionalMatch`
1055
- // - Expression-level: `lowerExpr` detects optional checks via `parseOptionalCheck`
1056
- // and `extractLeftmostOptional` → `lowerOptionalConditional`
1057
- // - && restructuring: `extractLeftmostOptional` splits `&&` chains, generating
1058
- // match with guard: `match x { Some(val) => if guard then A else B, None => B }`
1059
- /** Shared logic for optional match in both imperative and pure function paths.
1060
- * Detects optional check, selects branches, handles early-return consumption.
1061
- * Returns null if the condition is not an optional check. */
1062
- function prepareOptionalMatch(s, restStmts) {
1063
- const check = parseOptionalCheck(s.cond);
1064
- if (!check)
1065
- return null;
1066
- let someBranch = check.negated ? s.else : s.then;
1067
- const noneBranch = check.negated ? s.then : (s.else.length > 0 ? s.else : restStmts);
1068
- // Early-return pattern: Some branch is empty → consume rest of block
1069
- if (someBranch.length === 0 && restStmts.length > 0)
1070
- someBranch = restStmts;
1071
- const sanitized = check.varName.replace(/\./g, "_");
1072
- const bound = matchBinder(`${sanitized}_val`);
1073
- return { check, someBranch, noneBranch, bound };
1074
- }
1075
- /** Extract the leftmost optional check from a && chain, returning the check and the rest.
1076
- * (x !== undefined && b) && c → { optCond: x !== undefined, rest: b && c } */
1077
- function extractLeftmostOptional(cond) {
1078
- if (cond.kind !== "binop" || cond.op !== "&&")
1079
- return null;
1080
- const check = parseOptionalCheck(cond.left);
1081
- if (check && !check.negated)
1082
- return { optCond: cond.left, rest: cond.right };
1083
- if (cond.left.kind === "binop" && cond.left.op === "&&") {
1084
- const inner = extractLeftmostOptional(cond.left);
1085
- if (inner)
1086
- return { optCond: inner.optCond, rest: { ...cond, left: inner.rest } };
1087
- }
1088
- return null;
1089
- }
1090
- /** Detect `v !== undefined` or `undefined !== v` where v has optional type.
1091
- * Also handles field access chains like `obj.field !== undefined`.
1092
- * When `fieldExpr` is returned, callers must use field-aware replacement. */
1093
- function parseOptionalCheck(cond) {
1094
- if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "==="))
1095
- return null;
1096
- let varExpr = null;
1097
- if (cond.right.kind === "var" && cond.right.name === "undefined")
1098
- varExpr = cond.left;
1099
- if (cond.left.kind === "var" && cond.left.name === "undefined")
1100
- varExpr = cond.right;
1101
- if (!varExpr)
1102
- return null;
1103
- if (varExpr.kind === "var" && varExpr.ty.kind === "optional") {
1104
- return { varName: varExpr.name, negated: cond.op === "===" };
1105
- }
1106
- if (varExpr.kind === "field" && varExpr.ty.kind === "optional") {
1107
- // Serialize field chain as a dotted name for use as match scrutinee
1108
- const chain = serializeFieldChain(varExpr);
1109
- if (chain)
1110
- return { varName: chain, negated: cond.op === "===", fieldExpr: varExpr };
1111
- }
1112
- return null;
1113
- }
1114
- /** Serialize a field access chain to a dotted variable path, or null if not a simple chain. */
1115
- function serializeFieldChain(e) {
1116
- if (e.kind === "var")
1117
- return e.name;
1118
- if (e.kind === "field") {
1119
- const parent = serializeFieldChain(e.obj);
1120
- return parent ? `${parent}.${e.field}` : null;
1121
- }
1122
- return null;
1123
- }
1124
1227
  /** Build match arms from variant cases — shared by imperative and pure paths.
1125
1228
  * Looks up variant fields from typeDecls, builds patterns via buildMatchPattern,
1126
1229
  * and delegates body transformation to the caller-provided function.
@@ -1139,12 +1242,84 @@ function buildMatchArms(cases, varName, typeName, typeDecls, transformBody) {
1139
1242
  }
1140
1243
  return arms;
1141
1244
  }
1142
- function emitMatchStmt(chain, typeDecls) {
1143
- const cases = chain.cases.map(c => ({ name: c.variant, body: c.body }));
1144
- const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1145
- if (chain.fallthrough.length > 0)
1146
- arms.push({ pattern: "_", body: transformStmts(chain.fallthrough, typeDecls) });
1147
- return { kind: "match", scrutinee: chain.varName, arms };
1245
+ function emitMatchStmt(scrutinee, typeName, cases, fallthrough, typeDecls) {
1246
+ const decl = typeDecls.find(d => d.name === typeName);
1247
+ // Synth array-unions (discriminant "__isArray__") have single-field variants
1248
+ // ArrayBranch(arr) / NonArrayBranch(val). The matched arm refers to the
1249
+ // scrutinee by its bare name/path (`content`, `m.content`), not `.arr`, so
1250
+ // we substitute that whole reference with the variant's sole field binder.
1251
+ const isSynthArrayUnion = decl?.discriminant === "__isArray__";
1252
+ // The scrutinee is a bare var (`current`) or a field-access path
1253
+ // (`current.content`). `prefix` names the binder scope — the var name, or a
1254
+ // safe id derived from the path (`current.content` → `current_content`) —
1255
+ // and is used for both pattern binders and arm-body substitution so they
1256
+ // always agree. A var scrutinee has empty `fields`, so `prefix` is just its
1257
+ // name and the emitted code is unchanged from before this generalization.
1258
+ const path = asTAccessPath(scrutinee);
1259
+ const isPath = !!path && path.fields.length > 0;
1260
+ const prefix = path ? [path.rootVar, ...path.fields].join("_") : "?";
1261
+ function transformArmBody(body, fields) {
1262
+ let stmts;
1263
+ if (isPath && path) {
1264
+ // Path scrutinee: the matched value is referred to by the bare path, so
1265
+ // substitute the whole path (only the synth single-field shape arises
1266
+ // here — discriminant chains require a var scrutinee).
1267
+ stmts = isSynthArrayUnion && fields.length === 1
1268
+ ? replacePathInTStmts(body, path, matchBinder(fields[0].name, prefix), fields[0].type ?? parseTsType(fields[0].tsType))
1269
+ : body;
1270
+ }
1271
+ else {
1272
+ stmts = replaceFieldAccessInTStmts(body, prefix, fields);
1273
+ if (isSynthArrayUnion && fields.length === 1) {
1274
+ const f = fields[0];
1275
+ stmts = replaceVarInTStmts(stmts, prefix, matchBinder(f.name, prefix), f.type ?? parseTsType(f.tsType));
1276
+ }
1277
+ }
1278
+ return transformStmts(stmts, typeDecls);
1279
+ }
1280
+ const armCases = cases.map(c => ({ name: c.variant, body: c.body }));
1281
+ const arms = buildMatchArms(armCases, prefix, typeName, typeDecls, (body, _vn, fields) => transformArmBody(body, fields));
1282
+ // Add the fallthrough arm whenever the listed cases don't cover every
1283
+ // variant — needed for exhaustiveness even when there's no `else`
1284
+ // (`fallthrough` empty), e.g. `if (Array.isArray(x)) {...}` with no else
1285
+ // becomes `match x { case ArrayBranch(..) => ... case NonArrayBranch(..) => }`.
1286
+ const allCovered = !!decl?.variants && cases.length >= decl.variants.length;
1287
+ if (!allCovered) {
1288
+ const remaining = remainingVariant(typeName, cases, typeDecls);
1289
+ if (remaining) {
1290
+ // Exactly one variant left — destructure so the fallthrough body can
1291
+ // access variant-specific fields (Lean requires this; Dafny tolerates `_`).
1292
+ const pattern = buildMatchPattern(remaining.name, remaining.fields, prefix);
1293
+ const body = transformArmBody(fallthrough, remaining.fields);
1294
+ arms.push({ pattern, body });
1295
+ }
1296
+ else {
1297
+ arms.push({ pattern: "_", body: transformStmts(fallthrough, typeDecls) });
1298
+ }
1299
+ }
1300
+ return { kind: "match", scrutinee: isPath ? transformExpr(scrutinee) : prefix, arms };
1301
+ }
1302
+ /** Replace bare `var(oldName)` references → `var(newName)` with the given type.
1303
+ * Used by emitMatchStmt for synth array-unions where the variant has a single
1304
+ * payload field and the user code refers to the scrutinee by its bare name. */
1305
+ function replaceVarInTStmts(stmts, oldName, newName, newTy) {
1306
+ return stmts.map(s => mapTStmt(s, e => {
1307
+ if (e.kind === "var" && e.name === oldName) {
1308
+ return { kind: "var", name: newName, ty: newTy };
1309
+ }
1310
+ return null;
1311
+ }));
1312
+ }
1313
+ /** If the chain has matched all variants but one, return that remaining variant. */
1314
+ function remainingVariant(typeName, cases, typeDecls) {
1315
+ const decl = typeDecls.find(d => d.name === typeName);
1316
+ if (!decl?.variants)
1317
+ return null;
1318
+ const matched = new Set(cases.map(c => c.variant));
1319
+ const remaining = decl.variants.filter(v => !matched.has(v.name));
1320
+ if (remaining.length !== 1)
1321
+ return null;
1322
+ return remaining[0];
1148
1323
  }
1149
1324
  function emitSwitchStmt(s, typeDecls) {
1150
1325
  const varName = s.expr.kind === "var" ? s.expr.name : "?";
@@ -1155,10 +1330,7 @@ function emitSwitchStmt(s, typeDecls) {
1155
1330
  arms.push({ pattern: "_", body: transformStmts(s.defaultBody, typeDecls) });
1156
1331
  return { kind: "match", scrutinee: varName, arms };
1157
1332
  }
1158
- /** Replace obj.field → replacement var in typed IR (before transform).
1159
- * Used by discriminant match/switch and optional match to rewrite field accesses
1160
- * into simple variables before the transform phase, so downstream narrowing
1161
- * (parseOptionalCheck, extractLeftmostOptional) sees simple variable references.
1333
+ /** Replace obj.field → replacement var in typed IR.
1162
1334
  * Uses the TExpr's resolved type when available, falling back to `fallbackTy`. */
1163
1335
  function replaceFieldsInTStmts(stmts, objName, replacements) {
1164
1336
  if (replacements.length === 0)
@@ -1199,6 +1371,40 @@ function replaceFieldInTExpr(expr, objName, replacements) {
1199
1371
  return null;
1200
1372
  });
1201
1373
  }
1374
+ function asTAccessPath(e) {
1375
+ if (e.kind === "var")
1376
+ return { rootVar: e.name, fields: [] };
1377
+ if (e.kind === "field") {
1378
+ const inner = asTAccessPath(e.obj);
1379
+ if (!inner)
1380
+ return null;
1381
+ return { rootVar: inner.rootVar, fields: [...inner.fields, e.field] };
1382
+ }
1383
+ return null;
1384
+ }
1385
+ /** Does TExpr `e` match the given access path exactly? */
1386
+ function matchesAccessPath(e, path) {
1387
+ const collected = [];
1388
+ let cur = e;
1389
+ while (cur.kind === "field") {
1390
+ collected.unshift(cur.field);
1391
+ cur = cur.obj;
1392
+ }
1393
+ if (cur.kind !== "var" || cur.name !== path.rootVar)
1394
+ return false;
1395
+ if (collected.length !== path.fields.length)
1396
+ return false;
1397
+ return collected.every((f, i) => f === path.fields[i]);
1398
+ }
1399
+ /** Replace every TExpr matching `path` with `var(binder, binderTy)`. */
1400
+ function replacePathInTExpr(expr, path, binder, binderTy) {
1401
+ return mapTExpr(expr, e => matchesAccessPath(e, path)
1402
+ ? { kind: "var", name: binder, ty: binderTy } : null);
1403
+ }
1404
+ function replacePathInTStmts(stmts, path, binder, binderTy) {
1405
+ return stmts.map(s => mapTStmt(s, e => matchesAccessPath(e, path)
1406
+ ? { kind: "var", name: binder, ty: binderTy } : null));
1407
+ }
1202
1408
  /** Unwrap optional type on match-bound variables in TExpr.
1203
1409
  * After replaceFieldInTExpr, the replaced variable carries the original optional
1204
1410
  * type from the field declaration. The match binding unwraps it to the inner type. */
@@ -1206,80 +1412,14 @@ function fixBoundType(expr, boundName) {
1206
1412
  return mapTExpr(expr, e => e.kind === "var" && e.name === boundName && e.ty.kind === "optional"
1207
1413
  ? { ...e, ty: e.ty.inner } : null);
1208
1414
  }
1209
- /** Lower a conditional with an optional check (Phase 2: explicit, Phase 3: && with guard).
1210
- * Generates: match scrutinee { Some(val) => [if guard then] A [else B], None => B }
1211
- * For field chains, replaces field accesses in TExpr before lowering.
1212
- * For simple vars, replaces variable names in Expr after lowering. */
1213
- function lowerOptionalConditional(e, check, guard, binds) {
1214
- const sanitized = check.varName.replace(/\./g, "_");
1215
- const bound = matchBinder(`${sanitized}_val`);
1216
- const isFieldChain = check.fieldExpr && check.fieldExpr.kind === "field" &&
1217
- check.fieldExpr.obj.kind === "var";
1218
- // Determine which branch is Some (unwrapped) vs None
1219
- let thenTExpr = e.then;
1220
- let guardTExpr = guard;
1221
- // For field chains: replace field access with bound var in TExpr before lowering
1222
- if (isFieldChain) {
1223
- const fe = check.fieldExpr;
1224
- const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
1225
- const replacements = [{ fieldName: fe.field, newName: bound, fallbackTy: innerTy }];
1226
- thenTExpr = fixBoundType(replaceFieldInTExpr(thenTExpr, fe.obj.name, replacements), bound);
1227
- if (guardTExpr) {
1228
- guardTExpr = fixBoundType(replaceFieldInTExpr(guardTExpr, fe.obj.name, replacements), bound);
1229
- }
1230
- }
1231
- let thenExpr = lowerExpr(thenTExpr, binds);
1232
- let elseExpr = lowerExpr(e.else, binds);
1233
- // For simple vars: replace after lowering
1234
- if (!isFieldChain) {
1235
- thenExpr = replaceVar(thenExpr, check.varName, { kind: "var", name: bound }, true);
1236
- }
1237
- // Optional wrapping: check if either branch is undefined (produces optional result)
1238
- const isOptionalResult = (e.then.kind === "var" && e.then.name === "undefined") ||
1239
- (e.else.kind === "var" && e.else.name === "undefined");
1240
- if (isOptionalResult) {
1241
- thenExpr = wrapOptionalBranch(thenExpr, e.then);
1242
- elseExpr = wrapOptionalBranch(elseExpr, e.else);
1243
- }
1244
- // Build Some arm body — add guard for && patterns
1245
- // Note: if the guard has method calls, the impure path desugars the let to a
1246
- // statement-level if+match in transformStmt, so this expression-level path only
1247
- // runs for pure guards. Use null for binds to avoid escaping the match scope.
1248
- let someBody;
1249
- if (guard) {
1250
- let guardExpr = lowerExpr(guardTExpr, null);
1251
- if (!isFieldChain) {
1252
- guardExpr = replaceVar(guardExpr, check.varName, { kind: "var", name: bound }, true);
1253
- }
1254
- // Guard-else gets the same expression as None arm
1255
- let guardElse = lowerExpr(e.else, null);
1256
- if (isOptionalResult) {
1257
- guardElse = wrapOptionalBranch(guardElse, e.else);
1258
- }
1259
- someBody = { kind: "if", cond: guardExpr, then: thenExpr, else: guardElse };
1260
- }
1261
- else {
1262
- someBody = thenExpr;
1263
- }
1264
- // Build scrutinee
1265
- const scrutinee = isFieldChain
1266
- ? lowerExpr(check.fieldExpr, binds)
1267
- : check.varName;
1268
- return {
1269
- kind: "match", scrutinee,
1270
- arms: [
1271
- { pattern: `.some ${bound}`, body: someBody },
1272
- { pattern: ".none", body: elseExpr },
1273
- ],
1274
- };
1275
- }
1276
1415
  // ── Pure function generation ─────────────────────────────────
1277
1416
  function transformPureBody(stmts, typeDecls) {
1278
- // Detect discriminant if-chain
1279
- if (stmts.length > 0 && stmts[0].kind === "if") {
1280
- const chain = detectDiscriminantChain(stmts);
1281
- if (chain)
1282
- return transformPureMatch(chain.chain, typeDecls);
1417
+ // tagMatch (from narrow's discriminant detection) is the leading stmt and consumes the rest.
1418
+ if (stmts.length > 0 && stmts[0].kind === "tagMatch") {
1419
+ const t = stmts[0];
1420
+ const varName = t.scrutinee.kind === "var" ? t.scrutinee.name : "?";
1421
+ const chain = { varName, typeName: t.typeName, cases: t.cases, fallthrough: t.fallthrough };
1422
+ return transformPureMatch(chain, typeDecls);
1283
1423
  }
1284
1424
  for (let i = 0; i < stmts.length; i++) {
1285
1425
  const s = stmts[i];
@@ -1293,52 +1433,8 @@ function transformPureBody(stmts, typeDecls) {
1293
1433
  return { kind: "let", name: s.name, value: transformExpr(s.init), body: restExpr };
1294
1434
  }
1295
1435
  case "if": {
1296
- // Restructure && with optional check: split into nested ifs so
1297
- // prepareOptionalMatch can detect the optional check and bind the unwrapped value
1298
- if (s.cond.kind === "binop" && s.cond.op === "&&" && s.else.length === 0) {
1299
- const extracted = extractLeftmostOptional(s.cond);
1300
- if (extracted) {
1301
- const innerIf = { kind: "if", cond: extracted.rest, then: s.then, else: [] };
1302
- const outerIf = { kind: "if", cond: extracted.optCond, then: [innerIf], else: [] };
1303
- return transformPureBody([outerIf, ...rest], typeDecls);
1304
- }
1305
- }
1306
- // Optional narrowing: if (x === undefined) → match x { None => ..., Some(x_val) => ... }
1307
- const optMatch = prepareOptionalMatch(s, rest);
1308
- if (optMatch) {
1309
- // For field chains (a.dueDate !== undefined), replace in TStmt before transform
1310
- let someBranch = [...optMatch.someBranch, ...rest];
1311
- const fe = optMatch.check.fieldExpr;
1312
- if (fe && fe.kind === "field" && fe.obj.kind === "var") {
1313
- const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
1314
- someBranch = replaceFieldsInTStmts(someBranch, fe.obj.name, [
1315
- { fieldName: fe.field, newName: optMatch.bound, fallbackTy: innerTy },
1316
- ]);
1317
- // The replacement keeps the original optional type, but the match binding
1318
- // unwraps it. Fix the type so downstream record coercion re-wraps with Some().
1319
- someBranch = someBranch.map(s => mapTStmt(s, e => e.kind === "var" && e.name === optMatch.bound && e.ty.kind === "optional"
1320
- ? { ...e, ty: e.ty.inner } : null));
1321
- }
1322
- const someExpr = transformPureBody(someBranch, typeDecls);
1323
- if (!someExpr)
1324
- return null;
1325
- const noneExpr = transformPureBody(optMatch.noneBranch, typeDecls);
1326
- if (!noneExpr)
1327
- return null;
1328
- // For simple vars, replace in Expr after transform
1329
- const someReplaced = optMatch.check.fieldExpr
1330
- ? someExpr
1331
- : replaceVar(someExpr, optMatch.check.varName, { kind: "var", name: optMatch.bound }, true);
1332
- return {
1333
- kind: "match", scrutinee: optMatch.check.varName,
1334
- arms: [
1335
- { pattern: `.some ${optMatch.bound}`, body: someReplaced },
1336
- { pattern: ".none", body: noneExpr },
1337
- ],
1338
- };
1339
- }
1340
1436
  // Append rest to both branches so nested ifs that fall through
1341
- // can reach the continuation (e.g. early return inside then-branch)
1437
+ // can reach the continuation (e.g. early return inside then-branch).
1342
1438
  const thenExpr = transformPureBody([...s.then, ...rest], typeDecls);
1343
1439
  if (!thenExpr)
1344
1440
  return null;
@@ -1349,6 +1445,27 @@ function transformPureBody(stmts, typeDecls) {
1349
1445
  return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
1350
1446
  }
1351
1447
  case "switch": return transformPureSwitch(s, typeDecls);
1448
+ case "someMatch": {
1449
+ const path = asTAccessPath(s.scrutinee);
1450
+ if (path) {
1451
+ const replaced = replacePathInTStmts(s.someBody, path, s.binder, s.binderTy);
1452
+ const someExpr = transformPureBody([...replaced, ...rest], typeDecls);
1453
+ if (!someExpr)
1454
+ return null;
1455
+ const noneExpr = transformPureBody([...s.noneBody, ...rest], typeDecls);
1456
+ if (!noneExpr)
1457
+ return null;
1458
+ const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
1459
+ return {
1460
+ kind: "match", scrutinee,
1461
+ arms: [
1462
+ { pattern: `.some ${s.binder}`, body: someExpr },
1463
+ { pattern: ".none", body: noneExpr },
1464
+ ],
1465
+ };
1466
+ }
1467
+ throw new Error(`someMatch pure-body scrutinee must be a pure access path, got ${s.scrutinee.kind}`);
1468
+ }
1352
1469
  default: return null;
1353
1470
  }
1354
1471
  }
@@ -1382,12 +1499,20 @@ function transformPureSwitch(s, typeDecls) {
1382
1499
  }
1383
1500
  function transformPureMatch(chain, typeDecls) {
1384
1501
  const cases = chain.cases.map(c => ({ name: c.variant, body: c.body }));
1502
+ const decl = typeDecls.find(d => d.name === chain.typeName);
1503
+ // Synth array-unions have single-field variants and user code refers to the
1504
+ // scrutinee by its bare name, not field-accessed. See emitMatchStmt for
1505
+ // the statement-level counterpart of this substitution.
1506
+ const isSynthArrayUnion = decl?.discriminant === "__isArray__";
1385
1507
  const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => {
1386
1508
  let result = transformPureBody(body, typeDecls);
1387
1509
  if (!result)
1388
1510
  return null;
1389
1511
  if (fields.length > 0 && vn)
1390
1512
  result = replaceFieldAccess(result, vn, fields);
1513
+ if (isSynthArrayUnion && fields.length === 1 && vn) {
1514
+ result = replaceVarInExpr(result, vn, matchBinder(fields[0].name, vn));
1515
+ }
1391
1516
  return result;
1392
1517
  });
1393
1518
  if (!arms)
@@ -1395,13 +1520,27 @@ function transformPureMatch(chain, typeDecls) {
1395
1520
  // Idiomatic TS often has an unreachable fallthrough after exhaustive if-chains on
1396
1521
  // discriminated unions. Skip the catch-all arm when all variants are matched,
1397
1522
  // since Lean errors on redundant match arms.
1398
- const decl = typeDecls.find(d => d.name === chain.typeName);
1399
1523
  const allCovered = decl?.variants && chain.cases.length >= decl.variants.length;
1400
1524
  if (chain.fallthrough.length > 0 && !allCovered) {
1401
- const body = transformPureBody(chain.fallthrough, typeDecls);
1402
- if (!body)
1403
- return null;
1404
- arms.push({ pattern: "_", body });
1525
+ const remaining = remainingVariant(chain.typeName, chain.cases, typeDecls);
1526
+ if (remaining) {
1527
+ // Exactly one variant left — destructure for variant-specific field access.
1528
+ let body = transformPureBody(chain.fallthrough, typeDecls);
1529
+ if (!body)
1530
+ return null;
1531
+ if (remaining.fields.length > 0)
1532
+ body = replaceFieldAccess(body, chain.varName, remaining.fields);
1533
+ if (isSynthArrayUnion && remaining.fields.length === 1) {
1534
+ body = replaceVarInExpr(body, chain.varName, matchBinder(remaining.fields[0].name, chain.varName));
1535
+ }
1536
+ arms.push({ pattern: buildMatchPattern(remaining.name, remaining.fields, chain.varName), body });
1537
+ }
1538
+ else {
1539
+ const body = transformPureBody(chain.fallthrough, typeDecls);
1540
+ if (!body)
1541
+ return null;
1542
+ arms.push({ pattern: "_", body });
1543
+ }
1405
1544
  }
1406
1545
  return { kind: "match", scrutinee: chain.varName, arms };
1407
1546
  }
@@ -1552,9 +1691,9 @@ export function transformModule(mod, specImport) {
1552
1691
  continue;
1553
1692
  const body = transformPureBody(fn.body, mod.typeDecls);
1554
1693
  if (body) {
1555
- // For ensures, replace \result (→ "res") with the function call
1694
+ // For pure-function lemmas, replace \result with the function call.
1556
1695
  const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
1557
- const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
1696
+ const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "\\result", fnCall));
1558
1697
  pureDefs.push({
1559
1698
  kind: "def",
1560
1699
  name: fn.name,
@@ -1585,18 +1724,35 @@ export function transformModule(mod, specImport) {
1585
1724
  }
1586
1725
  }
1587
1726
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
1727
+ // Externs: emit as top-of-file `function {:axiom}` (Dafny) declarations.
1728
+ // Any `requires`/`ensures` from the source declaration come along so callers
1729
+ // see the same spec the source itself verified. Substitute `\result` with the
1730
+ // function call (same pattern as for in-file pure-function ensures).
1731
+ const externDecls = (mod.externs ?? []).map(ext => {
1732
+ const fnCall = { kind: "app", fn: ext.flat, args: ext.params.map(p => ({ kind: "var", name: p.name })) };
1733
+ return {
1734
+ kind: "extern",
1735
+ name: ext.flat,
1736
+ typeParams: ext.typeParams,
1737
+ params: ext.params.map(p => ({ name: p.name, type: p.ty })),
1738
+ returnType: ext.returnTy,
1739
+ requires: ext.requires.map(transformExpr),
1740
+ ensures: ext.ensures.map(e => replaceVar(transformExpr(e), "\\result", fnCall)),
1741
+ };
1742
+ });
1588
1743
  // Types file
1589
1744
  const typesImports = ["LemmaScript"];
1590
1745
  let typesFile = null;
1591
1746
  const pureNamespace = pureDefs.length > 0
1592
1747
  ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
1593
1748
  : [];
1594
- if (typeDecls.length > 0 || pureDefs.length > 0) {
1749
+ if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0) {
1595
1750
  typesFile = {
1596
1751
  comment: " Generated by lsc — Lean types and pure function mirrors.",
1597
1752
  imports: typesImports,
1598
1753
  options: [],
1599
- decls: [...typeDecls, ...pureNamespace],
1754
+ // Externs come first so they're in scope for every later declaration.
1755
+ decls: [...externDecls, ...typeDecls, ...pureNamespace],
1600
1756
  };
1601
1757
  }
1602
1758
  // Def file: Velvet methods