lemmascript 0.4.0 → 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.
@@ -24,6 +24,7 @@ function mapExpr(e, f) {
24
24
  case "emptyMap":
25
25
  case "emptySet":
26
26
  case "havoc": return e;
27
+ case "mapLiteral": return { ...e, entries: e.entries.map(en => ({ key: r(en.key), value: r(en.value) })) };
27
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) };
@@ -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) };
@@ -187,6 +187,40 @@ const BOOL_OP_MAP = {
187
187
  ...OP_MAP, "===": "==", "!==": "!=",
188
188
  };
189
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
+ }
190
224
  /**
191
225
  * Lower a typed expression to Backend IR.
192
226
  *
@@ -196,6 +230,18 @@ function transformExpr(e) { return lowerExpr(e, null); }
196
230
  * a method call can appear inline in TS. It does NOT propagate into
197
231
  * field, index, record, forall, or exists sub-expressions.
198
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
+ }
199
245
  /** Wrap an expression in Some/None for optional-typed conditionals.
200
246
  * If the raw TExpr is `undefined`, emit `.none`; otherwise wrap in `Some`. */
201
247
  function wrapOptionalBranch(expr, raw) {
@@ -225,7 +271,6 @@ function lowerExpr(e, binds) {
225
271
  case "var": return { kind: "var", name: e.name };
226
272
  case "num": return { kind: "num", value: e.value };
227
273
  case "bool": return { kind: "bool", value: e.value };
228
- case "result": return { kind: "var", name: "res" };
229
274
  case "str":
230
275
  if (e.ty.kind === "user")
231
276
  return { kind: "constructor", name: e.value, type: e.ty.name };
@@ -336,10 +381,27 @@ function lowerExpr(e, binds) {
336
381
  (e.left.ty.kind === "user" && e.right.ty.kind === "string"))) {
337
382
  const left = lowerExpr(e.left, binds);
338
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";
339
386
  return {
340
387
  kind: "if",
341
388
  cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: "size" }, right: { kind: "num", value: 0 } },
342
- 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" },
343
405
  };
344
406
  }
345
407
  // int + string → NatToString(int) + string (string concatenation)
@@ -397,6 +459,25 @@ function lowerExpr(e, binds) {
397
459
  return { kind: "index", arr: transformExpr(e.obj), idx: wrappedIdx };
398
460
  }
399
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
+ }
400
481
  // Math.abs/min/max → preamble functions
401
482
  if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "Math") {
402
483
  if (e.fn.field === "abs" && e.args.length === 1)
@@ -536,6 +617,19 @@ function lowerExpr(e, binds) {
536
617
  if (e.fields.length === 0 && !e.spread && e.ty.kind === "map") {
537
618
  return { kind: "emptyMap" };
538
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
+ }
539
633
  return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
540
634
  }
541
635
  case "arrayLiteral":
@@ -547,14 +641,29 @@ function lowerExpr(e, binds) {
547
641
  if (e.ty.kind === "set")
548
642
  return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
549
643
  return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
550
- case "lambda":
551
- 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
+ }
552
658
  case "forall":
553
659
  return { kind: "forall", var: e.var, type: e.varTy, body: transformExpr(e.body) };
554
660
  case "exists":
555
661
  return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
556
662
  case "conditional": {
557
- 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);
558
667
  let thenExpr = lowerExpr(e.then, binds);
559
668
  let elseExpr = lowerExpr(e.else, binds);
560
669
  if (e.ty.kind === "optional") {
@@ -586,7 +695,11 @@ function lowerExpr(e, binds) {
586
695
  // path with the binder pre-lowering.
587
696
  const replaced = replacePathInTExpr(e.someBody, path, e.binder, e.binderTy);
588
697
  someBody = lowerExpr(replaced, binds);
589
- scrutinee = path.fields.length === 0 ? path.rootVar : lowerExpr(e.scrutinee, 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);
590
703
  }
591
704
  else {
592
705
  // Complex scrutinee — narrow pre-bound the someBody to use the binder directly,
@@ -607,11 +720,48 @@ function lowerExpr(e, binds) {
607
720
  ],
608
721
  };
609
722
  }
610
- case "tagMatch":
611
- // Spec/expr-position tagMatch — narrow shouldn't produce these (it only
612
- // emits stmt-form tagMatch from if-chain detection on stmts). If reached,
613
- // bug in narrow.
614
- throw new Error(`tagMatch reached lowerExpr narrow only emits stmt-form tagMatch`);
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
+ }
615
765
  }
616
766
  }
617
767
  function flattenImpl(e) {
@@ -665,7 +815,119 @@ function replaceFieldAccess(e, varName, fields) {
665
815
  return null;
666
816
  });
667
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
+ }
668
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
+ }
669
931
  function transformStmts(stmts, typeDecls) {
670
932
  const result = [];
671
933
  let i = 0;
@@ -690,7 +952,7 @@ function transformStmts(stmts, typeDecls) {
690
952
  const idxName = `_${keyName}_idx${suffix}`;
691
953
  const idx = { kind: "var", name: idxName };
692
954
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
693
- const bodyStmts = transformStmts(s.body, typeDecls);
955
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
694
956
  const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
695
957
  const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
696
958
  result.push({
@@ -716,7 +978,7 @@ function transformStmts(stmts, typeDecls) {
716
978
  const idxName = `_${keyName}_idx${suffix}`;
717
979
  const idx = { kind: "var", name: idxName };
718
980
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
719
- const bodyStmts = transformStmts(s.body, typeDecls);
981
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
720
982
  const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
721
983
  const letVal = { kind: "let", name: valueName, type: valueTy, mutable: false,
722
984
  value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: keyName }], monadic: false } };
@@ -743,7 +1005,7 @@ function transformStmts(stmts, typeDecls) {
743
1005
  const idxName = `_${varName}_idx${suffix}`;
744
1006
  const idx = { kind: "var", name: idxName };
745
1007
  const arrSize = { kind: "field", obj: iterExpr, field: "size" };
746
- const bodyStmts = transformStmts(s.body, typeDecls);
1008
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
747
1009
  const letElem = { kind: "let", name: varName, type: varTy, mutable: false, value: { kind: "index", arr: iterExpr, idx } };
748
1010
  // Auto-add bound invariant: idx ≤ bound (always true for range loops)
749
1011
  const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
@@ -788,6 +1050,17 @@ function transformStmt(s, typeDecls) {
788
1050
  return [letHead, sliceTail];
789
1051
  }
790
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
+ }
791
1064
  // new Map(arr.map(n => [n.field, n])) → let m = map[]; for (n of arr) m[n.field] := n
792
1065
  if (init && init.fn.kind === "var" && init.fn.name === "__mapFromArray" &&
793
1066
  init.args.length === 1 && init.args[0].kind === "call" &&
@@ -838,6 +1111,17 @@ function transformStmt(s, typeDecls) {
838
1111
  return [...binds, { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: expr }];
839
1112
  }
840
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
+ }
841
1125
  // Top-level method call → direct monadic bind, no lifting needed
842
1126
  if (s.value.kind === "call" && s.value.callKind === "method")
843
1127
  return [{ kind: "bind", target: s.target, value: transformExpr(s.value) }];
@@ -892,16 +1176,16 @@ function transformStmt(s, typeDecls) {
892
1176
  case "if": {
893
1177
  // Lift from condition only (Lean rule: don't lift from branches).
894
1178
  const { binds, expr: cond } = liftMethodCalls(s.cond);
895
- 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) }];
896
1180
  }
897
1181
  case "while":
898
1182
  return [{
899
1183
  kind: "while",
900
- cond: transformExpr(s.cond),
1184
+ cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty),
901
1185
  invariants: s.invariants.map(transformExpr),
902
1186
  decreasing: s.decreases ? transformExpr(s.decreases) : null,
903
1187
  doneWith: s.doneWith ? transformExpr(s.doneWith) : null,
904
- body: transformStmts(s.body, typeDecls),
1188
+ body: eliminateTopLevelContinue(transformStmts(s.body, typeDecls)),
905
1189
  }];
906
1190
  case "throw":
907
1191
  return [{ kind: "assert", expr: { kind: "bool", value: false } }];
@@ -914,7 +1198,7 @@ function transformStmt(s, typeDecls) {
914
1198
  case "ghostAssign":
915
1199
  return [{ kind: "ghostAssign", target: s.target, value: transformExpr(s.value) }];
916
1200
  case "assert":
917
- return [{ kind: "assert", expr: transformExpr(s.expr) }];
1201
+ return [{ kind: "assert", expr: transformExpr(s.expr), assumed: s.assumed }];
918
1202
  case "someMatch": {
919
1203
  const path = asTAccessPath(s.scrutinee);
920
1204
  if (path) {
@@ -932,11 +1216,8 @@ function transformStmt(s, typeDecls) {
932
1216
  }
933
1217
  throw new Error(`someMatch stmt scrutinee must be a pure access path, got ${s.scrutinee.kind}`);
934
1218
  }
935
- case "tagMatch": {
936
- const varName = s.scrutinee.kind === "var" ? s.scrutinee.name : "?";
937
- const chain = { varName, typeName: s.typeName, cases: s.cases, fallthrough: s.fallthrough };
938
- return [emitMatchStmt(chain, typeDecls)];
939
- }
1219
+ case "tagMatch":
1220
+ return [emitMatchStmt(s.scrutinee, s.typeName, s.cases, s.fallthrough, typeDecls)];
940
1221
  }
941
1222
  }
942
1223
  /** Apply an expression transform to all expressions in a statement (convenience wrapper). */
@@ -961,30 +1242,80 @@ function buildMatchArms(cases, varName, typeName, typeDecls, transformBody) {
961
1242
  }
962
1243
  return arms;
963
1244
  }
964
- function emitMatchStmt(chain, typeDecls) {
965
- const cases = chain.cases.map(c => ({ name: c.variant, body: c.body }));
966
- const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
967
- if (chain.fallthrough.length > 0) {
968
- const remaining = remainingVariant(chain, typeDecls);
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);
969
1289
  if (remaining) {
970
1290
  // Exactly one variant left — destructure so the fallthrough body can
971
1291
  // access variant-specific fields (Lean requires this; Dafny tolerates `_`).
972
- const pattern = buildMatchPattern(remaining.name, remaining.fields, chain.varName);
973
- const body = transformStmts(replaceFieldAccessInTStmts(chain.fallthrough, chain.varName, remaining.fields), typeDecls);
1292
+ const pattern = buildMatchPattern(remaining.name, remaining.fields, prefix);
1293
+ const body = transformArmBody(fallthrough, remaining.fields);
974
1294
  arms.push({ pattern, body });
975
1295
  }
976
1296
  else {
977
- arms.push({ pattern: "_", body: transformStmts(chain.fallthrough, typeDecls) });
1297
+ arms.push({ pattern: "_", body: transformStmts(fallthrough, typeDecls) });
978
1298
  }
979
1299
  }
980
- return { kind: "match", scrutinee: chain.varName, arms };
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
+ }));
981
1312
  }
982
1313
  /** If the chain has matched all variants but one, return that remaining variant. */
983
- function remainingVariant(chain, typeDecls) {
984
- const decl = typeDecls.find(d => d.name === chain.typeName);
1314
+ function remainingVariant(typeName, cases, typeDecls) {
1315
+ const decl = typeDecls.find(d => d.name === typeName);
985
1316
  if (!decl?.variants)
986
1317
  return null;
987
- const matched = new Set(chain.cases.map(c => c.variant));
1318
+ const matched = new Set(cases.map(c => c.variant));
988
1319
  const remaining = decl.variants.filter(v => !matched.has(v.name));
989
1320
  if (remaining.length !== 1)
990
1321
  return null;
@@ -1168,12 +1499,20 @@ function transformPureSwitch(s, typeDecls) {
1168
1499
  }
1169
1500
  function transformPureMatch(chain, typeDecls) {
1170
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__";
1171
1507
  const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => {
1172
1508
  let result = transformPureBody(body, typeDecls);
1173
1509
  if (!result)
1174
1510
  return null;
1175
1511
  if (fields.length > 0 && vn)
1176
1512
  result = replaceFieldAccess(result, vn, fields);
1513
+ if (isSynthArrayUnion && fields.length === 1 && vn) {
1514
+ result = replaceVarInExpr(result, vn, matchBinder(fields[0].name, vn));
1515
+ }
1177
1516
  return result;
1178
1517
  });
1179
1518
  if (!arms)
@@ -1181,10 +1520,9 @@ function transformPureMatch(chain, typeDecls) {
1181
1520
  // Idiomatic TS often has an unreachable fallthrough after exhaustive if-chains on
1182
1521
  // discriminated unions. Skip the catch-all arm when all variants are matched,
1183
1522
  // since Lean errors on redundant match arms.
1184
- const decl = typeDecls.find(d => d.name === chain.typeName);
1185
1523
  const allCovered = decl?.variants && chain.cases.length >= decl.variants.length;
1186
1524
  if (chain.fallthrough.length > 0 && !allCovered) {
1187
- const remaining = remainingVariant(chain, typeDecls);
1525
+ const remaining = remainingVariant(chain.typeName, chain.cases, typeDecls);
1188
1526
  if (remaining) {
1189
1527
  // Exactly one variant left — destructure for variant-specific field access.
1190
1528
  let body = transformPureBody(chain.fallthrough, typeDecls);
@@ -1192,6 +1530,9 @@ function transformPureMatch(chain, typeDecls) {
1192
1530
  return null;
1193
1531
  if (remaining.fields.length > 0)
1194
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
+ }
1195
1536
  arms.push({ pattern: buildMatchPattern(remaining.name, remaining.fields, chain.varName), body });
1196
1537
  }
1197
1538
  else {
@@ -1350,9 +1691,9 @@ export function transformModule(mod, specImport) {
1350
1691
  continue;
1351
1692
  const body = transformPureBody(fn.body, mod.typeDecls);
1352
1693
  if (body) {
1353
- // For ensures, replace \result (→ "res") with the function call
1694
+ // For pure-function lemmas, replace \result with the function call.
1354
1695
  const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
1355
- const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
1696
+ const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "\\result", fnCall));
1356
1697
  pureDefs.push({
1357
1698
  kind: "def",
1358
1699
  name: fn.name,
@@ -1383,18 +1724,35 @@ export function transformModule(mod, specImport) {
1383
1724
  }
1384
1725
  }
1385
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
+ });
1386
1743
  // Types file
1387
1744
  const typesImports = ["LemmaScript"];
1388
1745
  let typesFile = null;
1389
1746
  const pureNamespace = pureDefs.length > 0
1390
1747
  ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
1391
1748
  : [];
1392
- if (typeDecls.length > 0 || pureDefs.length > 0) {
1749
+ if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0) {
1393
1750
  typesFile = {
1394
1751
  comment: " Generated by lsc — Lean types and pure function mirrors.",
1395
1752
  imports: typesImports,
1396
1753
  options: [],
1397
- decls: [...typeDecls, ...pureNamespace],
1754
+ // Externs come first so they're in scope for every later declaration.
1755
+ decls: [...externDecls, ...typeDecls, ...pureNamespace],
1398
1756
  };
1399
1757
  }
1400
1758
  // Def file: Velvet methods