lemmascript 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,7 +28,9 @@ export function dafnyCheckDiff(genPath, dfyPath) {
28
28
  diff = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf-8");
29
29
  }
30
30
  else {
31
- return true;
31
+ // git couldn't be spawned: the check never ran, so fail loud, don't green-pass.
32
+ console.error(`ERROR: could not run \`git diff\` to verify ${path.basename(dfyPath)} is additions-only (is git installed?)`);
33
+ return false;
32
34
  }
33
35
  }
34
36
  const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
@@ -86,7 +86,7 @@ function methodHeader(prefix, params, returnType) {
86
86
  // ── Lean op → Dafny op ─────────────────────────────────────
87
87
  const OP_MAP = {
88
88
  "=": "==", "≠": "!=", "≥": ">=", "≤": "<=",
89
- "∧": "&&", "∨": "||", "¬": "!",
89
+ "∧": "&&", "∨": "||", "¬": "!", "↔": "<==>",
90
90
  "arrayConcat": "+",
91
91
  };
92
92
  function mapOp(op) { return OP_MAP[op] ?? op; }
@@ -152,15 +152,15 @@ function emitExpr(e) {
152
152
  if (e.method === "with")
153
153
  return `${obj}[${args[0]} := ${args[1]}]`;
154
154
  if (e.method === "includes")
155
- return `(${args[0]} in ${obj})`;
155
+ return args.length > 1 ? `(${args[0]} in ${obj}[${args[1]}..])` : `(${args[0]} in ${obj})`;
156
156
  if (e.method === "indexOf") {
157
157
  needPreamble("SeqIndexOf");
158
- return `SeqIndexOf(${obj}, ${args[0]})`;
158
+ return args.length > 1 ? `SeqIndexOfFrom(${obj}, ${args[0]}, ${args[1]})` : `SeqIndexOf(${obj}, ${args[0]})`;
159
159
  }
160
160
  if (e.method === "push")
161
- return `(${obj} + [${args[0]}])`;
161
+ return `(${obj} + [${args.join(", ")}])`;
162
162
  if (e.method === "concat")
163
- return `(${obj} + [${args[0]}])`;
163
+ return `(${obj} + [${args.join(", ")}])`;
164
164
  // No-arg slice is a full copy; Dafny seq is an immutable value type, so
165
165
  // the copy is just the seq itself (the idiom for "copy then mutate").
166
166
  if (e.method === "slice" && args.length === 0)
@@ -399,12 +399,22 @@ function emitExpr(e) {
399
399
  return `{${args.join(", ")}}`;
400
400
  if (e.fn === "JSFloorDiv")
401
401
  needPreamble("JSFloorDiv");
402
+ if (e.fn === "JSRem")
403
+ needPreamble("JSRem");
404
+ if (e.fn === "JSTruncDiv")
405
+ needPreamble("JSTruncDiv");
406
+ if (e.fn === "JSStringLt")
407
+ needPreamble("JSStringLt");
402
408
  if (e.fn === "CeilReal")
403
409
  needPreamble("CeilReal");
404
410
  if (e.fn === "FloorReal")
405
411
  needPreamble("FloorReal");
406
412
  if (e.fn === "NatToString")
407
413
  needPreamble("NatToString");
414
+ if (e.fn === "IntToString") {
415
+ needPreamble("NatToString");
416
+ needPreamble("IntToString");
417
+ }
408
418
  if (e.fn === "MathAbs")
409
419
  needPreamble("MathAbs");
410
420
  if (e.fn === "MathMin")
@@ -421,6 +431,8 @@ function emitExpr(e) {
421
431
  }
422
432
  if (e.fn === "Perm")
423
433
  needPreamble("Perm");
434
+ if (e.fn === "SetFromSeq")
435
+ needPreamble("SetFromSeq");
424
436
  return `${escapeName(e.fn)}(${args.join(", ")})`;
425
437
  }
426
438
  case "field": {
@@ -804,6 +816,26 @@ const JS_FLOOR_DIV = `function JSFloorDiv(a: int, b: int): int
804
816
  if a <= 0 then (-a) / (-b)
805
817
  else -((a - 1) / (-b)) - 1
806
818
  }`;
819
+ const JS_REM = `function JSRem(a: int, b: int): int
820
+ requires b != 0
821
+ {
822
+ var r := (if a < 0 then -a else a) % (if b < 0 then -b else b);
823
+ if a < 0 then -r else r
824
+ }`;
825
+ const JS_TRUNC_DIV = `function JSTruncDiv(a: int, b: int): int
826
+ requires b != 0
827
+ {
828
+ var q := (if a < 0 then -a else a) / (if b < 0 then -b else b);
829
+ if (a < 0) != (b < 0) then -q else q
830
+ }`;
831
+ const JS_STRING_LT = `predicate JSStringLt(s: string, t: string)
832
+ decreases |s|
833
+ {
834
+ if |s| == 0 then |t| > 0
835
+ else if |t| == 0 then false
836
+ else if s[0] != t[0] then s[0] < t[0]
837
+ else JSStringLt(s[1..], t[1..])
838
+ }`;
807
839
  const FLOOR_REAL = `function FloorReal(x: real): int
808
840
  {
809
841
  x.Floor
@@ -1031,11 +1063,16 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
1031
1063
  if n < 10 then [digit]
1032
1064
  else NatToString(n / 10) + [digit]
1033
1065
  }`;
1066
+ const INT_TO_STRING = `function IntToString(n: int): string
1067
+ {
1068
+ if n < 0 then "-" + NatToString(-n) else NatToString(n)
1069
+ }`;
1034
1070
  const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
1035
1071
  // perm(a, b) — `a` and `b` are reorderings of each other (equal as multisets).
1036
1072
  // Transparent (Dafny unfolds it), so hand-proofs can reason with `multiset`
1037
1073
  // directly. The `(==)` bound requires the element type to support equality.
1038
1074
  const PERM = `predicate Perm<T(==)>(a: seq<T>, b: seq<T>) { multiset(a) == multiset(b) }`;
1075
+ const SET_FROM_SEQ = `function SetFromSeq<T(==)>(s: seq<T>): set<T> { set x | x in s }`;
1039
1076
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
1040
1077
  ensures forall x :: x in s <==> x in res
1041
1078
  ensures |res| == |s|
@@ -1063,6 +1100,9 @@ const PREAMBLE_CODE = [
1063
1100
  ["BitAnd", BIT_AND],
1064
1101
  ["BitOr", BIT_OR],
1065
1102
  ["JSFloorDiv", JS_FLOOR_DIV],
1103
+ ["JSRem", JS_REM],
1104
+ ["JSTruncDiv", JS_TRUNC_DIV],
1105
+ ["JSStringLt", JS_STRING_LT],
1066
1106
  ["CeilReal", CEIL_REAL],
1067
1107
  ["FloorReal", FLOOR_REAL],
1068
1108
  ["SeqIndexOf", SEQ_INDEX_OF],
@@ -1078,12 +1118,14 @@ const PREAMBLE_CODE = [
1078
1118
  ["StringToLower", STRING_TO_LOWER],
1079
1119
  ["StringToUpper", STRING_TO_UPPER],
1080
1120
  ["NatToString", NAT_TO_STRING],
1121
+ ["IntToString", INT_TO_STRING],
1081
1122
  ["MathAbs", MATH_ABS],
1082
1123
  ["MathMin", MATH_MIN],
1083
1124
  ["MathMax", MATH_MAX],
1084
1125
  ["MaxOfSeq", MAX_OF_SEQ],
1085
1126
  ["MinOfSeq", MIN_OF_SEQ],
1086
1127
  ["Perm", PERM],
1128
+ ["SetFromSeq", SET_FROM_SEQ],
1087
1129
  ];
1088
1130
  // ── Constructor and record helpers ───────────────────────────
1089
1131
  let _recordCtors = new Map();
@@ -4,7 +4,7 @@
4
4
  * Produces structured AST nodes, not strings.
5
5
  * The only strings are //@ annotation expressions (parsed later by specparser).
6
6
  */
7
- import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
7
+ import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
8
8
  import { initTypeParser } from "./types.js";
9
9
  // ── Expression extraction ────────────────────────────────────
10
10
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
@@ -87,11 +87,16 @@ function detectCrossFileExtern(callee, sourceFile) {
87
87
  // types in the callee's own type-parameter namespace, so these names match
88
88
  // what `params`/`returnType` reference — declare them on the emitted axiom.
89
89
  const typeParams = sig.getTypeParameters().map(tp => tp.getText());
90
+ // Print types relative to the call site (enclosingNode = callee, alias names
91
+ // kept): a bare `TMsg`, not `import("/abs/path/transcript").TMsg` — the
92
+ // importing module declares the datatype locally, so the axiom must use the
93
+ // local name.
94
+ const externTypeText = (t) => t.getText(callee, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
90
95
  const params = sig.getParameters().map(p => ({
91
96
  name: p.getName(),
92
- tsType: p.getTypeAtLocation(callee).getText(),
97
+ tsType: externTypeText(p.getTypeAtLocation(callee)),
93
98
  }));
94
- const returnType = sig.getReturnType().getText();
99
+ const returnType = externTypeText(sig.getReturnType());
95
100
  let qualified;
96
101
  if (Node.isPropertyAccessExpression(callee)) {
97
102
  qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
@@ -309,9 +314,10 @@ function extractExpr(node) {
309
314
  // Template literal: `foo${x}bar` → "foo" + x + "bar"
310
315
  if (Node.isTemplateExpression(node)) {
311
316
  const parts = [];
312
- const head = node.getHead().getLiteralText();
313
- if (head)
314
- parts.push({ kind: "str", value: head });
317
+ // Always push the head, even when empty: a leading string literal anchors the
318
+ // whole chain as string-typed so each interpolated value is stringified (not
319
+ // added numerically — `${a}${b}` is concatenation, not `a + b`).
320
+ parts.push({ kind: "str", value: node.getHead().getLiteralText() });
315
321
  for (const span of node.getTemplateSpans()) {
316
322
  parts.push(extractExpr(span.getExpression()));
317
323
  const text = span.getLiteral().getLiteralText();
@@ -481,43 +487,42 @@ function extractExpr(node) {
481
487
  }
482
488
  // Object literal: { res: true, done: false } or { ...obj, res: true }
483
489
  if (Node.isObjectLiteralExpression(node)) {
484
- let spread = null;
485
- const fields = [];
486
- const computedFields = [];
490
+ // Fold properties in source order: a later spread overrides everything before
491
+ // it, a named field is a record-update on the accumulator, a computed key is a
492
+ // map `.set`. Order matters: if `a` has `k`, `{ k: v, ...a }` is `a`, not `a.(k := v)`.
493
+ let acc = null;
494
+ const update = (name, value) => {
495
+ acc = acc && acc.kind === "record"
496
+ ? { kind: "record", spread: acc.spread, fields: [...acc.fields, { name, value }] }
497
+ : { kind: "record", spread: acc, fields: [{ name, value }] };
498
+ };
487
499
  for (const prop of node.getProperties()) {
488
500
  if (Node.isSpreadAssignment(prop)) {
489
- spread = extractExpr(prop.getExpression());
501
+ acc = extractExpr(prop.getExpression());
490
502
  }
491
503
  else if (Node.isShorthandPropertyAssignment(prop)) {
492
504
  const name = prop.getName();
493
- fields.push({ name, value: { kind: "var", name } });
505
+ update(name, { kind: "var", name });
494
506
  }
495
507
  else if (Node.isPropertyAssignment(prop)) {
496
508
  const nameNode = prop.getNameNode();
497
509
  const init = prop.getInitializer();
498
- if (init && Node.isComputedPropertyName(nameNode)) {
499
- computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
510
+ if (!init)
511
+ continue;
512
+ if (Node.isComputedPropertyName(nameNode)) {
513
+ // { ...base, [k]: v } → base.set(k, v); { [k]: v } → {}.set(k, v)
514
+ const base = acc ?? { kind: "record", spread: null, fields: [] };
515
+ acc = { kind: "call", fn: { kind: "field", obj: base, field: "set" }, args: [extractExpr(nameNode.getExpression()), extractExpr(init)] };
500
516
  }
501
- else if (init) {
517
+ else {
502
518
  // String-literal keys (e.g. `"bun run": 3`) — use the unquoted literal
503
519
  // value; otherwise `prop.getName()` may include surrounding quotes.
504
520
  const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName();
505
- fields.push({ name, value: extractExpr(init) });
521
+ update(name, extractExpr(init));
506
522
  }
507
523
  }
508
524
  }
509
- // Desugar computed keys: { ...base, [k]: v } → base.set(k, v)
510
- // No spread: { [k]: v } → {}.set(k, v) (empty map base)
511
- if (computedFields.length > 0) {
512
- let result = spread ?? { kind: "record", spread: null, fields: [] };
513
- for (const cf of computedFields) {
514
- result = { kind: "call",
515
- fn: { kind: "field", obj: result, field: "set" },
516
- args: [cf.key, cf.value] };
517
- }
518
- return result;
519
- }
520
- return { kind: "record", spread, fields };
525
+ return acc ?? { kind: "record", spread: null, fields: [] };
521
526
  }
522
527
  // Ternary: cond ? then : else
523
528
  if (Node.isConditionalExpression(node)) {
@@ -555,8 +560,12 @@ function extractExpr(node) {
555
560
  if (Node.isArrayLiteralExpression(arg)) {
556
561
  return { kind: "emptyCollection", collectionType: "Set", tsType, initElems: arg.getElements().map(e => extractExpr(e)) };
557
562
  }
558
- // new Set(existingSet) pass through
559
- return extractExpr(arg);
563
+ const argSymbol = arg.getType().getSymbol()?.getName() ?? arg.getType().getAliasSymbol()?.getName();
564
+ // new Set(existingSet) — identity (sets are value types)
565
+ if (argSymbol === "Set")
566
+ return extractExpr(arg);
567
+ // new Set(arr) — build a deduplicated set from the array's elements
568
+ return { kind: "call", fn: { kind: "var", name: "__setFromArray" }, args: [extractExpr(arg)] };
560
569
  }
561
570
  return { kind: "emptyCollection", collectionType: name, tsType };
562
571
  }
@@ -1557,6 +1566,7 @@ function extractStmts(stmts) {
1557
1566
  // inside a `{ }` case block is stripped, while a `break` inside a
1558
1567
  // nested loop stays put.
1559
1568
  const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
1569
+ const isExit = (st) => !!st && ["break", "return", "throw", "continue"].includes(st.kind);
1560
1570
  let fallthrough = [];
1561
1571
  for (const clause of s.getClauses()) {
1562
1572
  if (Node.isCaseClause(clause)) {
@@ -1565,7 +1575,10 @@ function extractStmts(stmts) {
1565
1575
  fallthrough.push(label);
1566
1576
  continue;
1567
1577
  }
1568
- const body = stripExitBreaks(extractStmts(clause.getStatements()));
1578
+ const raw = extractStmts(clause.getStatements());
1579
+ if (!isExit(raw[raw.length - 1]))
1580
+ throw new Error(`switch case "${label}" at line ${line}: a non-empty case must end with break/return/throw; fall-through into the next case is not supported`);
1581
+ const body = stripExitBreaks(raw);
1569
1582
  for (const l of fallthrough)
1570
1583
  cases.push({ label: l, body });
1571
1584
  cases.push({ label, body });
@@ -77,6 +77,7 @@ function escapeName(name) {
77
77
  }
78
78
  // ── Operator precedence (for parenthesization) ──────────────
79
79
  const PREC = {
80
+ "↔": 0, // Lean: Iff (20) binds looser than → (25)
80
81
  "→": 1, "∨": 2, "∧": 3,
81
82
  "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
82
83
  "+": 5, "-": 5, "++": 5, "arrayConcat": 5, "*": 6, "/": 6, "%": 6,
@@ -96,15 +97,15 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
96
97
  if (method === "some")
97
98
  return `${obj}.${monadic ? "anyM" : "any"} ${args[0]}`;
98
99
  if (method === "includes")
99
- return `${obj}.contains ${args[0]}`;
100
+ return args.length > 1 ? `(${obj}.extract ${args[1]} ${obj}.size).contains ${args[0]}` : `${obj}.contains ${args[0]}`;
100
101
  if (method === "find")
101
102
  return `${obj}.find? ${args[0]}`;
102
103
  if (method === "with")
103
104
  return `${obj}.set! ${args[0]} ${args[1]}`;
104
105
  if (method === "push")
105
- return `Array.push ${obj} ${args[0]}`;
106
+ return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
106
107
  if (method === "concat")
107
- return `Array.push ${obj} ${args[0]}`;
108
+ return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
108
109
  // arr.slice → Array.extract. No-arg slice is a full copy (Array is a value
109
110
  // type in Lean, so the receiver itself); one arg drops the prefix, two args
110
111
  // give the half-open range. Matches JS for non-negative bounds (negative
@@ -184,7 +185,7 @@ function emitExpr(e, parentPrec) {
184
185
  case "emptySet": return `Std.HashSet.empty`;
185
186
  case "methodCall": {
186
187
  const obj = emitExpr(e.obj);
187
- const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "methodCall";
188
+ const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "methodCall" || e.obj.kind === "if" || e.obj.kind === "let";
188
189
  const receiver = wrap ? `(${obj})` : obj;
189
190
  const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app" || a.kind === "methodCall") ? `(${emitExpr(a)})` : emitExpr(a));
190
191
  return emitMethodCall(e.objTy.kind, e.method, e.monadic, receiver, args);
@@ -213,11 +214,23 @@ function emitExpr(e, parentPrec) {
213
214
  return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
214
215
  }
215
216
  const op = e.op === "arrayConcat" ? "++" : e.op;
216
- const s = `${wrapOperand(e.left, prec(e.op))} ${op} ${wrapOperand(e.right, prec(e.op))}`;
217
+ // does not chain in Lean — a nested iff operand needs parens.
218
+ const childPrec = e.op === "↔" ? prec(e.op) + 1 : prec(e.op);
219
+ // `-`, `/`, `%` are left-associative and non-associative, so an equal-
220
+ // precedence right operand must be parenthesized: `a - (b - c)` would
221
+ // otherwise emit as `a - b - c`, i.e. `(a - b) - c`.
222
+ const rightPrec = e.op === "↔" ? childPrec
223
+ : ["-", "/", "%"].includes(e.op) ? prec(e.op) + 1 : childPrec;
224
+ const s = `${wrapOperand(e.left, childPrec)} ${op} ${wrapOperand(e.right, rightPrec)}`;
217
225
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
218
226
  }
219
227
  case "implies": {
220
- const parts = [...e.premises.map(p => wrapOperand(p)), emitExpr(e.conclusion)];
228
+ // Premises bind at →'s level: a nested-implication premise must keep its
229
+ // parens (→ is right-associative, so `(a → b) → c` ≠ `a → b → c`), and
230
+ // ↔ binds looser than → in Lean. The conclusion is the right-assoc tail,
231
+ // where a nested implication is safe bare — only ↔ needs parens there.
232
+ const wrapIff = (x) => x.kind === "binop" && x.op === "↔" ? `(${emitExpr(x)})` : undefined;
233
+ const parts = [...e.premises.map(p => wrapOperand(p, prec("→"))), wrapIff(e.conclusion) ?? emitExpr(e.conclusion)];
221
234
  const s = parts.join(" → ");
222
235
  return parentPrec !== undefined ? `(${s})` : s;
223
236
  }
@@ -231,6 +244,16 @@ function emitExpr(e, parentPrec) {
231
244
  // SetToSeq → .toArray for Lean (HashSet has native toArray)
232
245
  if (e.fn === "SetToSeq" && args.length === 1)
233
246
  return `${args[0]}.toArray`;
247
+ if (e.fn === "SetFromSeq" && args.length === 1)
248
+ return `Std.HashSet.ofList ${args[0]}.toList`;
249
+ if (e.fn === "ToString" && args.length === 1)
250
+ return `toString ${args[0]}`;
251
+ // JSRem (JS truncated remainder) → Lean's native truncated `Int.tmod`
252
+ if (e.fn === "JSRem" && args.length === 2)
253
+ return `Int.tmod ${args[0]} ${args[1]}`;
254
+ // JSTruncDiv (JS truncated bigint division) → Lean's native `Int.tdiv`
255
+ if (e.fn === "JSTruncDiv" && args.length === 2)
256
+ return `Int.tdiv ${args[0]} ${args[1]}`;
234
257
  // perm(a, b) → `List.Perm` on the underlying lists. Dafny lowers it to
235
258
  // `multiset(a) == multiset(b)`; the Lean image is `a.toList ~ b.toList`,
236
259
  // which mathlib's `List.Perm` provides (reflexivity, symmetry,
@@ -249,6 +249,8 @@ function ruleEarlyReturnConsume(s, rest) {
249
249
  const noneBranch = check.negated ? s.then : s.else;
250
250
  if (someBranch.length !== 0)
251
251
  return null;
252
+ if (!isTerminating(noneBranch))
253
+ return null;
252
254
  return {
253
255
  kind: "someMatch",
254
256
  scrutinee: check.scrutinee, binderTy: check.innerTy,
@@ -292,11 +294,14 @@ function ruleEarlyReturnOrChain(s, rest) {
292
294
  let inner = rest;
293
295
  for (let i = checks.length - 1; i >= 0; i--) {
294
296
  const check = checks[i];
297
+ const someBody = canBeFalsy(check)
298
+ ? [{ kind: "if", cond: bound(check), then: inner, else: s.then }]
299
+ : inner;
295
300
  inner = [{
296
301
  kind: "someMatch",
297
302
  scrutinee: check.scrutinee, binderTy: check.innerTy,
298
303
  binder: check.binderHint,
299
- someBody: inner,
304
+ someBody,
300
305
  noneBody: s.then,
301
306
  }];
302
307
  }
@@ -582,11 +587,14 @@ function ruleIfAndOptional(s) {
582
587
  return null;
583
588
  const { check, restCond } = extracted;
584
589
  const innerIf = { kind: "if", cond: restCond, then: s.then, else: [] };
590
+ const someBody = canBeFalsy(check)
591
+ ? [{ kind: "if", cond: bound(check), then: [walkStmt(innerIf)], else: [] }]
592
+ : [walkStmt(innerIf)];
585
593
  return {
586
594
  kind: "someMatch",
587
595
  scrutinee: check.scrutinee, binderTy: check.innerTy,
588
596
  binder: check.binderHint,
589
- someBody: [walkStmt(innerIf)],
597
+ someBody,
590
598
  noneBody: [],
591
599
  };
592
600
  }
@@ -776,8 +784,16 @@ function ruleDiscriminantChain(stmts) {
776
784
  }
777
785
  if (cases.length === 0)
778
786
  return null;
787
+ // If every case terminates, the trailing statements are the default arm
788
+ // (preserving the clean dispatch-as-expression shape). Otherwise the tail runs
789
+ // after the match for every variant, so leave it to the caller (empty default)
790
+ // rather than mis-routing it into the default arm only.
791
+ if (cases.every(c => isTerminating(c.body))) {
792
+ return { stmt: { kind: "tagMatch", scrutinee: first.scrutinee, typeName: first.typeName,
793
+ cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
794
+ }
779
795
  return { stmt: { kind: "tagMatch", scrutinee: first.scrutinee, typeName: first.typeName,
780
- cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
796
+ cases, fallthrough: [] }, consumed };
781
797
  }
782
798
  /** Rule (list-level): `if (x.kind !== "v") terminate; rest` → tagMatch
783
799
  * with cases = [{ variant: v, body: rest }] and fallthrough = terminate. */
@@ -809,12 +825,16 @@ function ruleLetCondAndOptional(s) {
809
825
  if (!extracted)
810
826
  return null;
811
827
  const { check, restCond } = extracted;
828
+ const assignIf = { kind: "if", cond: restCond,
829
+ then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] };
830
+ const someBody = canBeFalsy(check)
831
+ ? [{ kind: "if", cond: bound(check), then: [assignIf], else: [] }]
832
+ : [assignIf];
812
833
  const sm = {
813
834
  kind: "someMatch",
814
835
  scrutinee: check.scrutinee, binderTy: check.innerTy,
815
836
  binder: check.binderHint,
816
- someBody: [{ kind: "if", cond: restCond,
817
- then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] }],
837
+ someBody,
818
838
  noneBody: [],
819
839
  };
820
840
  return [
@@ -889,11 +909,15 @@ function ruleConditionalAndOptional(e) {
889
909
  kind: "conditional",
890
910
  cond: restCond, then: e.then, else: e.else, ty: e.ty,
891
911
  };
912
+ const someExpr = walkExpr(innerCond);
913
+ const someBody = canBeFalsy(check)
914
+ ? { kind: "conditional", cond: bound(check), then: someExpr, else: e.else, ty: e.ty }
915
+ : someExpr;
892
916
  return {
893
917
  kind: "someMatch",
894
918
  scrutinee: check.scrutinee, binderTy: check.innerTy,
895
919
  binder: check.binderHint,
896
- someBody: walkExpr(innerCond), noneBody: e.else, ty: e.ty,
920
+ someBody, noneBody: e.else, ty: e.ty,
897
921
  };
898
922
  }
899
923
  /** Rule (statement): `if (<rest> && Array.isArray(path) && <more>) then [else]`
@@ -685,7 +685,9 @@ function resolveExpr(e, ctx) {
685
685
  }
686
686
  }
687
687
  let ty = { kind: "unknown" };
688
- if (["===", "!==", ">=", "<=", ">", "<", "in"].includes(e.op))
688
+ // <==> is bool like the comparisons; unlike ==>, neither side narrows
689
+ // the other (no premise to assume).
690
+ if (["===", "!==", ">=", "<=", ">", "<", "in", "<==>"].includes(e.op))
689
691
  ty = { kind: "bool" };
690
692
  else if (e.op === "&&")
691
693
  ty = right.ty;
@@ -727,6 +729,16 @@ function resolveExpr(e, ctx) {
727
729
  const fn = { kind: "var", name: "Perm", ty: { kind: "unknown" } };
728
730
  return { kind: "call", fn, args: [a, b], ty: { kind: "bool" }, callKind: "pure" };
729
731
  }
732
+ // new Set(arr): build a deduplicated set from the array's elements (extract
733
+ // marks the array form `__setFromArray`). Lowers to the SetFromSeq preamble
734
+ // (Dafny `set x | x in s`); size/membership are then set semantics.
735
+ if (e.fn.kind === "var" && e.fn.name === "__setFromArray" && e.args.length === 1) {
736
+ const arr = resolveExpr(e.args[0], ctx);
737
+ if (arr.ty.kind !== "array")
738
+ throw new Error(`new Set(...) expects an array argument (got ${arr.ty.kind})`);
739
+ const fn = { kind: "var", name: "SetFromSeq", ty: { kind: "unknown" } };
740
+ return { kind: "call", fn, args: [arr], ty: { kind: "set", elem: arr.ty.elem }, callKind: "pure" };
741
+ }
730
742
  // Extern dispatch: `NS.method(args)` where NS.method is declared via
731
743
  // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
732
744
  // rest of the pipeline sees an ordinary pure function. The extern's
@@ -2,7 +2,7 @@
2
2
  * Spec expression parser.
3
3
  * Parses //@ annotation expressions into RawExpr AST nodes.
4
4
  */
5
- const MULTI_OPS = ["==>", "===", "!==", "==", "!=", ">=", "<=", "&&", "||"];
5
+ const MULTI_OPS = ["<==>", "==>", "===", "!==", "==", "!=", ">=", "<=", "&&", "||"];
6
6
  function tokenize(input) {
7
7
  const tokens = [];
8
8
  let i = 0;
@@ -20,8 +20,22 @@ function tokenize(input) {
20
20
  const quote = input[i];
21
21
  i++;
22
22
  let s = "";
23
- while (i < input.length && input[i] !== quote)
24
- s += input[i++];
23
+ while (i < input.length && input[i] !== quote) {
24
+ if (input[i] === "\\") {
25
+ // Standard escapes, where TS source, Dafny, and Lean all agree.
26
+ // The emitters re-escape on output, so the round trip is faithful.
27
+ const esc = input[i + 1];
28
+ const mapped = esc === "n" ? "\n" : esc === "r" ? "\r" : esc === "t" ? "\t"
29
+ : esc === "0" ? "\0" : esc === "\\" || esc === '"' || esc === "'" ? esc : null;
30
+ if (mapped === null)
31
+ throw new Error(`Unsupported string escape '\\${esc}' at ${i} in: ${input}`);
32
+ s += mapped;
33
+ i += 2;
34
+ }
35
+ else {
36
+ s += input[i++];
37
+ }
38
+ }
25
39
  if (i < input.length)
26
40
  i++;
27
41
  tokens.push({ type: "str", value: s });
@@ -103,11 +117,19 @@ class Parser {
103
117
  return false;
104
118
  }
105
119
  parse() {
106
- const r = this.parseImplies();
120
+ const r = this.parseIff();
107
121
  if (this.pos < this.tokens.length)
108
122
  throw new Error(`Unexpected: ${JSON.stringify(this.peek())}`);
109
123
  return r;
110
124
  }
125
+ // <==> binds loosest (Dafny precedence: a ==> b <==> c is (a ==> b) <==> c),
126
+ // right-associative like ==> — immaterial semantically, iff is associative.
127
+ parseIff() {
128
+ const left = this.parseImplies();
129
+ if (this.match("op", "<==>"))
130
+ return { kind: "binop", op: "<==>", left, right: this.parseIff() };
131
+ return left;
132
+ }
111
133
  parseImplies() {
112
134
  const left = this.parseTernary();
113
135
  if (this.match("op", "==>"))
@@ -117,9 +139,9 @@ class Parser {
117
139
  parseTernary() {
118
140
  const cond = this.parseOr();
119
141
  if (this.match("op", "?")) {
120
- const then_ = this.parseImplies();
142
+ const then_ = this.parseIff();
121
143
  this.expect("punc", ":");
122
- const else_ = this.parseImplies();
144
+ const else_ = this.parseIff();
123
145
  return { kind: "conditional", cond, then: then_, else: else_ };
124
146
  }
125
147
  return cond;
@@ -187,16 +209,16 @@ class Parser {
187
209
  expr = { kind: "field", obj: expr, field: this.expect("ident").value };
188
210
  }
189
211
  else if (this.match("punc", "[")) {
190
- const idx = this.parseImplies();
212
+ const idx = this.parseIff();
191
213
  this.expect("punc", "]");
192
214
  expr = { kind: "index", obj: expr, idx };
193
215
  }
194
216
  else if (this.match("punc", "(")) {
195
217
  const args = [];
196
218
  if (!this.match("punc", ")")) {
197
- args.push(this.parseImplies());
219
+ args.push(this.parseIff());
198
220
  while (this.match("punc", ","))
199
- args.push(this.parseImplies());
221
+ args.push(this.parseIff());
200
222
  this.expect("punc", ")");
201
223
  }
202
224
  expr = { kind: "call", fn: expr, args };
@@ -276,7 +298,7 @@ class Parser {
276
298
  varType = ty;
277
299
  }
278
300
  this.expect("punc", ",");
279
- const body = this.parseImplies();
301
+ const body = this.parseIff();
280
302
  this.expect("punc", ")");
281
303
  return { kind: q, var: v, varType, body };
282
304
  }
@@ -285,7 +307,7 @@ class Parser {
285
307
  }
286
308
  if (t.type === "punc" && t.value === "(") {
287
309
  this.advance();
288
- const expr = this.parseImplies();
310
+ const expr = this.parseIff();
289
311
  this.expect("punc", ")");
290
312
  return expr;
291
313
  }
@@ -293,9 +315,9 @@ class Parser {
293
315
  this.advance();
294
316
  const elems = [];
295
317
  if (!this.match("punc", "]")) {
296
- elems.push(this.parseImplies());
318
+ elems.push(this.parseIff());
297
319
  while (this.match("punc", ","))
298
- elems.push(this.parseImplies());
320
+ elems.push(this.parseIff());
299
321
  this.expect("punc", "]");
300
322
  }
301
323
  return { kind: "arrayLiteral", elems };
@@ -306,11 +328,11 @@ class Parser {
306
328
  if (!this.match("punc", "}")) {
307
329
  const name = this.expect("ident").value;
308
330
  this.expect("punc", ":");
309
- fields.push({ name, value: this.parseImplies() });
331
+ fields.push({ name, value: this.parseIff() });
310
332
  while (this.match("punc", ",")) {
311
333
  const n = this.expect("ident").value;
312
334
  this.expect("punc", ":");
313
- fields.push({ name: n, value: this.parseImplies() });
335
+ fields.push({ name: n, value: this.parseIff() });
314
336
  }
315
337
  this.expect("punc", "}");
316
338
  }
@@ -158,6 +158,24 @@ function isNat(ty) { return ty.kind === "nat"; }
158
158
  function isIntegral(ty) { return ty.kind === "int" || ty.kind === "nat"; }
159
159
  function isArray(ty) { return ty.kind === "array"; }
160
160
  function isUser(ty) { return ty.kind === "user"; }
161
+ /** Truthiness test for a *lowered* value of source type `ty`, used by `||`
162
+ * falsiness lowering. Mirrors narrow.ts's `canBeFalsy`: only int/nat/string/bool
163
+ * values can be falsy in JS (`0`, `""`, `false`); every other value (array, user
164
+ * type, …) is always truthy. Returns null for the always-truthy types so callers
165
+ * can unwrap directly instead of emitting a redundant guard. */
166
+ function valueTruthyCond(value, ty) {
167
+ switch (ty.kind) {
168
+ case "int":
169
+ case "nat":
170
+ return { kind: "binop", op: "≠", left: value, right: { kind: "num", value: 0 } };
171
+ case "string":
172
+ return { kind: "binop", op: ">", left: { kind: "field", obj: value, field: "length" }, right: { kind: "num", value: 0 } };
173
+ case "bool":
174
+ return value;
175
+ default:
176
+ return null;
177
+ }
178
+ }
161
179
  /** Check if transformed lambda body contains monadic binds. */
162
180
  function isMonadicBody(stmts) {
163
181
  for (const s of stmts) {
@@ -182,7 +200,7 @@ function isMonadicBody(stmts) {
182
200
  const OP_MAP = {
183
201
  "===": "=", "!==": "≠", ">=": "≥", "<=": "≤", ">": ">", "<": "<",
184
202
  "&&": "∧", "||": "∨", "+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
185
- "==": "=", "!=": "≠",
203
+ "==": "=", "!=": "≠", "<==>": "↔",
186
204
  };
187
205
  /** Bool-valued operators (for code-level conditions needing Decidable). */
188
206
  const BOOL_OP_MAP = {
@@ -275,6 +293,55 @@ function wrapOptionalBranch(expr, raw) {
275
293
  return expr; // already Option<T>, don't double-wrap
276
294
  return { kind: "constructor", name: "some", type: "Option", args: [expr] };
277
295
  }
296
+ /** Lean needs `let mut` for any local that is later reassigned. A const local
297
+ * whose collection field is mutated (`b.items.push(v)` → `b := b.(items := …)`)
298
+ * becomes an assign in the lowered body, so scan for assign targets and flip
299
+ * matching lets to mutable. Harmless on Dafny (method locals are `var`); and an
300
+ * assigned let already forces a method, so purity is unaffected. */
301
+ function promoteAssignedLets(stmts) {
302
+ const assigned = new Set();
303
+ const collect = (ss) => {
304
+ for (const s of ss) {
305
+ if (s.kind === "assign")
306
+ assigned.add(s.target);
307
+ else if (s.kind === "if") {
308
+ collect(s.then);
309
+ collect(s.else);
310
+ }
311
+ else if (s.kind === "while" || s.kind === "forin")
312
+ collect(s.body);
313
+ else if (s.kind === "match")
314
+ s.arms.forEach(a => collect(a.body));
315
+ }
316
+ };
317
+ collect(stmts);
318
+ if (assigned.size === 0)
319
+ return stmts;
320
+ const fix = (ss) => ss.map(s => {
321
+ const s2 = s.kind === "let" && !s.mutable && assigned.has(s.name) ? { ...s, mutable: true } : s;
322
+ if (s2.kind === "if")
323
+ return { ...s2, then: fix(s2.then), else: fix(s2.else) };
324
+ if (s2.kind === "while" || s2.kind === "forin")
325
+ return { ...s2, body: fix(s2.body) };
326
+ if (s2.kind === "match")
327
+ return { ...s2, arms: s2.arms.map(a => ({ ...a, body: fix(a.body) })) };
328
+ return s2;
329
+ });
330
+ return fix(stmts);
331
+ }
332
+ /** Build a nested record-update assigning `newVal` to a field-path receiver
333
+ * rooted at a var: `b.a.items` → `b := b.(a := b.a.(items := newVal))`.
334
+ * Returns null if the path isn't a chain of field accesses ending at a var. */
335
+ function buildNestedFieldUpdate(recv, newVal) {
336
+ if (recv.kind !== "field")
337
+ return null;
338
+ const upd = { kind: "record", spread: lowerExpr(recv.obj, null), fields: [{ name: recv.field, value: newVal }] };
339
+ if (recv.obj.kind === "var")
340
+ return { root: recv.obj.name, value: upd };
341
+ if (recv.obj.kind === "field")
342
+ return buildNestedFieldUpdate(recv.obj, upd);
343
+ return null;
344
+ }
278
345
  function lowerExpr(e, binds) {
279
346
  // Monadic lifting: extract embedded method calls to let-binds.
280
347
  // `callKind: "method"` means a global var-fn call (classifyCall returns
@@ -301,13 +368,17 @@ function lowerExpr(e, binds) {
301
368
  // String truthiness: !str → str == ""
302
369
  if (e.op === "!" && e.expr.ty.kind === "string")
303
370
  return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "str", value: "" } };
304
- // Optional truthiness: !opt → opt is None
371
+ // Optional truthiness: !opt → None negates to `true`. The Some branch is
372
+ // `!(value truthy)`: always-truthy inners (array/user) give a plain `false`,
373
+ // while falsy-capable inners re-test the wrapped value (`!Some(0)` is `true`).
374
+ // Mirrors the `||` falsiness rule.
305
375
  if (e.op === "!" && e.expr.ty.kind === "optional") {
306
376
  const bound = matchBinder("value");
377
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.expr.ty.inner);
307
378
  return {
308
379
  kind: "match", scrutinee: lowerExpr(e.expr, binds),
309
380
  arms: [
310
- { pattern: `.some ${bound}`, body: { kind: "bool", value: false } },
381
+ { pattern: `.some ${bound}`, body: truthy ? { kind: "unop", op: "¬", expr: truthy } : { kind: "bool", value: false } },
311
382
  { pattern: ".none", body: { kind: "bool", value: true } },
312
383
  ],
313
384
  };
@@ -383,37 +454,76 @@ function lowerExpr(e, binds) {
383
454
  ],
384
455
  };
385
456
  }
386
- // || undefined on optional → identity (no-op: x || undefined = x)
457
+ // || undefined on optional → identity (no-op: x || undefined = x) when the
458
+ // inner type is always truthy. When it can be falsy, JS still drops the
459
+ // wrapped value: `Some(0) || undefined === undefined`, so the Some arm
460
+ // re-tests and falls back to None.
387
461
  if (e.op === "||" && e.left.ty.kind === "optional" &&
388
462
  e.right.kind === "var" && e.right.name === "undefined") {
389
- return lowerExpr(e.left, binds);
463
+ const optExpr = lowerExpr(e.left, binds);
464
+ const bound = matchBinder("value");
465
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.left.ty.inner);
466
+ if (!truthy)
467
+ return optExpr;
468
+ return {
469
+ kind: "match", scrutinee: optExpr,
470
+ arms: [
471
+ { pattern: `.some ${bound}`, body: {
472
+ kind: "if", cond: truthy,
473
+ then: { kind: "app", fn: "Some", args: [{ kind: "var", name: bound }] },
474
+ else: { kind: "var", name: "undefined" }
475
+ } },
476
+ { pattern: ".none", body: { kind: "var", name: "undefined" } },
477
+ ],
478
+ };
390
479
  }
391
- // || on optional → match Some/None with default
480
+ // || on optional → match Some/None with default. JS `||` tests falsiness of
481
+ // the *unwrapped* value, so when the inner type can be falsy the Some arm must
482
+ // re-test (`Some(0) || 1 === 1`); array/user inners are always truthy and
483
+ // unwrap directly. Mirrors narrow.ts's canBeFalsy gate.
392
484
  if (e.op === "||" && e.left.ty.kind === "optional") {
393
485
  const optExpr = lowerExpr(e.left, binds);
394
486
  const defaultExpr = lowerExpr(e.right, binds);
395
487
  const bound = matchBinder("value");
488
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.left.ty.inner);
489
+ const someBody = truthy
490
+ ? { kind: "if", cond: truthy, then: { kind: "var", name: bound }, else: defaultExpr }
491
+ : { kind: "var", name: bound };
396
492
  return {
397
493
  kind: "match", scrutinee: optExpr,
398
494
  arms: [
399
- { pattern: `.some ${bound}`, body: { kind: "var", name: bound } },
495
+ { pattern: `.some ${bound}`, body: someBody },
400
496
  { pattern: ".none", body: defaultExpr },
401
497
  ],
402
498
  };
403
499
  }
404
- // || on map index → if key in map then map[key] else default
500
+ // || on map index → if key in map then map[key] else default. The stored
501
+ // value is still subject to JS falsiness (`counts.get(k) || 1` returns 1 when
502
+ // the stored value is 0), so for falsy-capable value types the present branch
503
+ // re-tests the value too. Always-truthy value types unwrap directly.
405
504
  if (e.op === "||" && e.left.kind === "index" && e.left.obj.ty.kind === "map") {
406
505
  const map = lowerExpr(e.left.obj, binds);
407
506
  const key = lowerExpr(e.left.idx, binds);
408
507
  const right = lowerExpr(e.right, binds);
508
+ const got = { kind: "index", arr: map, idx: key };
509
+ const truthy = valueTruthyCond(got, e.left.obj.ty.value);
409
510
  return {
410
511
  kind: "if",
411
512
  cond: { kind: "binop", op: "in", left: key, right: map },
412
- then: { kind: "index", arr: map, idx: key }, else: right,
513
+ then: truthy ? { kind: "if", cond: truthy, then: got, else: right } : got,
514
+ else: right,
413
515
  };
414
516
  }
415
- // || on non-optional string/array/userif non-empty then x else default
416
- if (e.op === "||" && (e.left.ty.kind === "string" || e.left.ty.kind === "array" ||
517
+ // || on non-optional array → `xs` itself: every array (even `[]`) is truthy
518
+ // in JS, so `xs || ys` short-circuits to `xs` and `ys` is never evaluated.
519
+ // resolve types the whole `||` as the array, so any optional context (e.g.
520
+ // `xs || undefined`) gets its single Some-wrap from the standard coercion at
521
+ // the use site — this rule must not add one. Mirrors the `!array` rule above.
522
+ if (e.op === "||" && e.left.ty.kind === "array") {
523
+ return lowerExpr(e.left, binds);
524
+ }
525
+ // || on non-optional string/user → if non-empty then x else default
526
+ if (e.op === "||" && (e.left.ty.kind === "string" ||
417
527
  (e.left.ty.kind === "user" && e.right.ty.kind === "string"))) {
418
528
  const left = lowerExpr(e.left, binds);
419
529
  const right = lowerExpr(e.right, binds);
@@ -421,8 +531,8 @@ function lowerExpr(e, binds) {
421
531
  const rightIsUndef = e.right.kind === "var" && e.right.name === "undefined";
422
532
  return {
423
533
  kind: "if",
424
- // strings carry the `length` marker, arrays `size` both render to `|x|`
425
- // in Dafny, but Lean's String has no `.size` field (it's `.length`).
534
+ // strings carry the `length` marker — it renders to `|x|` in Dafny and
535
+ // `.length` in Lean (whose String has no `.size` field).
426
536
  cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: e.left.ty.kind === "string" ? "length" : "size" }, right: { kind: "num", value: 0 } },
427
537
  then: rightIsUndef ? { kind: "app", fn: "Some", args: [left] } : left,
428
538
  else: right,
@@ -442,20 +552,43 @@ function lowerExpr(e, binds) {
442
552
  else: { kind: "var", name: "undefined" },
443
553
  };
444
554
  }
445
- // int + string NatToString(int) + string (string concatenation)
446
- if (e.op === "+" && _opts.backend === "dafny") {
447
- const isIntL = e.left.ty.kind === "int" || e.left.ty.kind === "nat";
448
- const isIntR = e.right.ty.kind === "int" || e.right.ty.kind === "nat";
449
- if (isIntL && e.right.ty.kind === "string") {
450
- return { kind: "binop", op: "+",
451
- left: { kind: "app", fn: "NatToString", args: [lowerExpr(e.left, binds)] },
452
- right: lowerExpr(e.right, binds) };
453
- }
454
- if (e.left.ty.kind === "string" && isIntR) {
455
- return { kind: "binop", op: "+",
456
- left: lowerExpr(e.left, binds),
457
- right: { kind: "app", fn: "NatToString", args: [lowerExpr(e.right, binds)] } };
458
- }
555
+ // String concatenation: `+` with a string operand. Stringify int/nat
556
+ // operands (Dafny NatToString, Lean toString) and join with arrayConcat
557
+ // (rendered `+` in Dafny, `++` in Lean).
558
+ if (e.op === "+" && (e.left.ty.kind === "string" || e.right.ty.kind === "string")) {
559
+ const strify = (o) => {
560
+ if (o.ty.kind !== "int" && o.ty.kind !== "nat")
561
+ return lowerExpr(o, binds);
562
+ // Lean `toString` handles any Int; Dafny needs IntToString for signed
563
+ // ints (NatToString is nat-only).
564
+ const fn = _opts.backend !== "dafny" ? "ToString" : o.ty.kind === "nat" ? "NatToString" : "IntToString";
565
+ return { kind: "app", fn, args: [lowerExpr(o, binds)] };
566
+ };
567
+ return { kind: "binop", op: "arrayConcat", left: strify(e.left), right: strify(e.right) };
568
+ }
569
+ // JS `%` is truncated (sign of the dividend); a signed `int` differs from the
570
+ // Euclidean `%` of Dafny/Lean, so route it through JSRem (Lean: `Int.tmod`).
571
+ if (e.op === "%" && e.left.ty.kind === "int") {
572
+ return { kind: "app", fn: "JSRem", args: [lowerExpr(e.left, binds), lowerExpr(e.right, binds)] };
573
+ }
574
+ // JS bigint `/` truncates toward zero (`-3n / 2n === -1n`); it differs from the
575
+ // floored `/` of Dafny/Lean, so route it through JSTruncDiv (Lean: `Int.tdiv`).
576
+ if (e.op === "/" && e.ty.kind === "int") {
577
+ return { kind: "app", fn: "JSTruncDiv", args: [lowerExpr(e.left, binds), lowerExpr(e.right, binds)] };
578
+ }
579
+ // JS string ordering is lexicographic vs Dafny's seq prefix order, so route
580
+ // through JSStringLt. Dafny-only: Lean's native `<` is already lexicographic.
581
+ if (_opts.backend === "dafny" && ["<", "<=", ">", ">="].includes(e.op) && e.left.ty.kind === "string") {
582
+ const l = lowerExpr(e.left, binds), r = lowerExpr(e.right, binds);
583
+ const lt = (x, y) => ({ kind: "app", fn: "JSStringLt", args: [x, y] });
584
+ const not = (x) => ({ kind: "unop", op: "¬", expr: x });
585
+ if (e.op === "<")
586
+ return lt(l, r);
587
+ if (e.op === ">")
588
+ return lt(r, l);
589
+ if (e.op === "<=")
590
+ return not(lt(r, l));
591
+ return not(lt(l, r)); // >=
459
592
  }
460
593
  // Numeric int→real coercion. After resolve, `/` is always real, and any
461
594
  // arithmetic/comparison mixing real and integral operands is real-valued.
@@ -571,14 +704,21 @@ function lowerExpr(e, binds) {
571
704
  let method = e.fn.field;
572
705
  const args = e.args.map((a, i) => {
573
706
  const lowered = lowerExpr(a, binds);
574
- // arr.with index (first arg) needs .toNat when Int-typed
575
- if (e.fn.kind === "field" && e.fn.field === "with" && e.fn.obj.ty.kind === "array" && i === 0 && !isNat(a.ty))
707
+ // Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1).
708
+ const isArrIdxArg = e.fn.kind === "field" && e.fn.obj.ty.kind === "array" &&
709
+ ((e.fn.field === "with" && i === 0) || ((e.fn.field === "includes" || e.fn.field === "indexOf") && i === 1));
710
+ if (isArrIdxArg && !isNat(a.ty))
576
711
  return { kind: "toNat", expr: lowered };
577
712
  return lowered;
578
713
  });
579
- // arr.concat(otherArr): array argument real concatenation, not push
580
- if (method === "concat" && e.fn.obj.ty.kind === "array" && e.args.length === 1 && e.args[0].ty.kind === "array") {
581
- return { kind: "binop", op: "arrayConcat", left: recv, right: args[0] };
714
+ // arr.concat(...args): each array arg is spread, each value arg appended.
715
+ if (method === "concat" && e.fn.obj.ty.kind === "array") {
716
+ let acc = recv;
717
+ for (let k = 0; k < args.length; k++) {
718
+ const piece = e.args[k].ty.kind === "array" ? args[k] : { kind: "arrayLiteral", elems: [args[k]] };
719
+ acc = { kind: "binop", op: "arrayConcat", left: acc, right: piece };
720
+ }
721
+ return acc;
582
722
  }
583
723
  // Spec-context map get: result type is non-optional → direct access
584
724
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
@@ -1212,18 +1352,24 @@ function transformStmt(s, typeDecls) {
1212
1352
  case "break": return [{ kind: "break" }];
1213
1353
  case "continue": return [{ kind: "continue" }];
1214
1354
  case "expr": {
1215
- // Mutating collection call: m.set(k, v) → m := m.set(k, v)
1216
- // Same for s.add(x) on sets, arr.push(x)
1217
- if (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1218
- s.expr.fn.obj.kind === "var" &&
1219
- ((s.expr.fn.obj.ty.kind === "map" || s.expr.fn.obj.ty.kind === "set") &&
1220
- (s.expr.fn.field === "set" || s.expr.fn.field === "add" || s.expr.fn.field === "delete")) ||
1221
- (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1222
- s.expr.fn.obj.kind === "var" && s.expr.fn.obj.ty.kind === "array" &&
1223
- s.expr.fn.field === "push")) {
1224
- const receiver = s.expr.fn.obj.name;
1225
- const { binds, expr } = liftMethodCalls(s.expr);
1226
- return [...binds, { kind: "assign", target: receiver, value: expr }];
1355
+ // Mutating collection call: m.set(k, v) → m := m.set(k, v) (same for set
1356
+ // .add/.delete and array .push). The receiver may be a bare var, or a
1357
+ // field path rooted at a var (b.items.push(v) b := b.(items := b.items + [v])).
1358
+ if (s.expr.kind === "call" && s.expr.fn.kind === "field") {
1359
+ const recv = s.expr.fn.obj;
1360
+ const f = s.expr.fn.field;
1361
+ const isMutating = ((recv.ty.kind === "map" || recv.ty.kind === "set") && (f === "set" || f === "add" || f === "delete")) ||
1362
+ (recv.ty.kind === "array" && f === "push");
1363
+ if (isMutating && recv.kind === "var") {
1364
+ const { binds, expr } = liftMethodCalls(s.expr);
1365
+ return [...binds, { kind: "assign", target: recv.name, value: expr }];
1366
+ }
1367
+ if (isMutating && recv.kind === "field") {
1368
+ const { binds, expr } = liftMethodCalls(s.expr);
1369
+ const upd = buildNestedFieldUpdate(recv, expr);
1370
+ if (upd)
1371
+ return [...binds, { kind: "assign", target: upd.root, value: upd.value }];
1372
+ }
1227
1373
  }
1228
1374
  // Optional chaining on map.get at statement level: m.get(k)?.push(v)
1229
1375
  // → if k in m { m[k] := m[k] + [v] } (actual mutation, not value-discard).
@@ -1826,7 +1972,7 @@ export function transformModule(mod, specImport) {
1826
1972
  else if (fn.forcePure) {
1827
1973
  // //@ pure but body can't be auto-converted — emit function by method
1828
1974
  _forofCounters.clear();
1829
- const methodBody = transformStmts(fn.body, mod.typeDecls);
1975
+ const methodBody = promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1830
1976
  defByMethods.push({
1831
1977
  kind: "def-by-method",
1832
1978
  name: fn.name,
@@ -1888,7 +2034,7 @@ export function transformModule(mod, specImport) {
1888
2034
  _forofCounters.clear();
1889
2035
  let body = pureDefNames.has(fn.name)
1890
2036
  ? [{ kind: "return", value: { kind: "app", fn: `Pure.${fn.name}`, args: fn.params.map(p => ({ kind: "var", name: p.name })) } }]
1891
- : transformStmts(fn.body, mod.typeDecls);
2037
+ : promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1892
2038
  // Shadow reassigned parameters with mutable locals
1893
2039
  const paramNames = new Set(fn.params.map(p => p.name));
1894
2040
  const reassigned = findReassignedNames(fn.body, paramNames);
@@ -1915,7 +2061,7 @@ export function transformModule(mod, specImport) {
1915
2061
  const classMethods = cls.methods.map(fn => {
1916
2062
  const ensures = fn.ensures.map(transformExpr);
1917
2063
  _forofCounters.clear();
1918
- const body = transformStmts(fn.body, mod.typeDecls);
2064
+ const body = promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1919
2065
  return {
1920
2066
  kind: "method",
1921
2067
  name: fn.name,
@@ -1,253 +0,0 @@
1
- /**
2
- * Lean IR → text. Trivial pretty-printer.
3
- * No logic, no type decisions — just serialization.
4
- */
5
- // ── Lean keyword escaping ────────────────────────────────────
6
- const LEAN_KEYWORDS = new Set([
7
- "def", "theorem", "lemma", "example", "structure", "class", "instance",
8
- "inductive", "where", "match", "with", "if", "then", "else", "do",
9
- "let", "mut", "return", "for", "in", "while", "break", "continue",
10
- "import", "open", "section", "namespace", "end", "set_option",
11
- "variable", "axiom", "constant", "private", "protected", "noncomputable",
12
- "partial", "unsafe", "macro", "syntax", "by", "fun", "have", "show",
13
- "at", "from", "to", "deriving", "extends", "true", "false",
14
- ]);
15
- function escapeName(name) {
16
- return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
17
- }
18
- // ── Operator precedence (for parenthesization) ──────────────
19
- const PREC = {
20
- "→": 1, "∨": 2, "∧": 3,
21
- "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
22
- "+": 5, "-": 5, "*": 6, "/": 6, "%": 6,
23
- };
24
- function prec(op) { return PREC[op] ?? 10; }
25
- // ── Expression emission ─────────────────────────────────────
26
- function emitExpr(e, parentPrec) {
27
- switch (e.kind) {
28
- case "var": return escapeName(e.name);
29
- case "num": return `${e.value}`;
30
- case "bool": return e.value ? "true" : "false";
31
- case "str": return `"${e.value}"`;
32
- case "constructor": return `.${e.name}`;
33
- case "arrayLiteral":
34
- if (e.elems.length === 0)
35
- return `#[]`;
36
- return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
37
- case "dotCall": {
38
- const obj = emitExpr(e.obj);
39
- const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "dotCall";
40
- const receiver = wrap ? `(${obj})` : obj;
41
- const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
42
- return args.length > 0 ? `${receiver}.${e.method} ${args.join(" ")}` : `${receiver}.${e.method}`;
43
- }
44
- case "lambda": {
45
- const params = e.params.map(p => p.name).join(" ");
46
- // Single return statement → expression lambda
47
- if (e.body.length === 1 && e.body[0].kind === "return") {
48
- return `(fun ${params} => ${emitExpr(e.body[0].value)})`;
49
- }
50
- // Multi-statement → do block
51
- return `(fun ${params} => do\n${emitStmts(e.body, 2)})`;
52
- }
53
- case "unop":
54
- if (e.op === "¬")
55
- return `¬(${emitExpr(e.expr)})`;
56
- if (e.op === "-" && e.expr.kind === "num")
57
- return `-${e.expr.value}`;
58
- return `(-${emitExpr(e.expr)})`;
59
- case "binop": {
60
- const s = `${emitExpr(e.left, prec(e.op))} ${e.op} ${emitExpr(e.right, prec(e.op))}`;
61
- return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
62
- }
63
- case "implies": {
64
- const parts = [...e.premises.map(p => emitExpr(p)), emitExpr(e.conclusion)];
65
- const s = parts.join(" → ");
66
- return parentPrec !== undefined ? `(${s})` : s;
67
- }
68
- case "app": {
69
- const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
70
- return `${e.fn} ${args.join(" ")}`;
71
- }
72
- case "field": {
73
- const obj = emitExpr(e.obj);
74
- const wrap = e.obj.kind !== "var" && e.obj.kind !== "num" && e.obj.kind !== "bool";
75
- return wrap ? `(${obj}).${escapeName(e.field)}` : `${obj}.${escapeName(e.field)}`;
76
- }
77
- case "toNat": {
78
- const inner = emitExpr(e.expr);
79
- const wrap = e.expr.kind !== "var" && e.expr.kind !== "num";
80
- return wrap ? `(${inner}).toNat` : `${inner}.toNat`;
81
- }
82
- case "index":
83
- return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]!`;
84
- case "record": {
85
- const fields = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
86
- if (e.spread)
87
- return `{ ${emitExpr(e.spread)} with ${fields.join(", ")} }`;
88
- return `{ ${fields.join(", ")} }`;
89
- }
90
- case "if":
91
- return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
92
- case "match": {
93
- const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
94
- return `match ${e.scrutinee} with ${arms.join(" ")}`;
95
- }
96
- case "forall": return `∀ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
97
- case "exists": return `∃ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
98
- case "let": return `let ${e.name} := ${emitExpr(e.value)}\n${emitExpr(e.body)}`;
99
- }
100
- }
101
- // ── Statement emission ──────────────────────────────────────
102
- function emitStmts(stmts, indent) {
103
- const pad = " ".repeat(indent);
104
- return stmts.map(s => emitStmt(s, indent)).join("\n");
105
- }
106
- function emitStmt(s, indent) {
107
- const pad = " ".repeat(indent);
108
- switch (s.kind) {
109
- case "let":
110
- return s.mutable
111
- ? `${pad}let mut ${escapeName(s.name)} : ${s.type} := ${emitExpr(s.value)}`
112
- : `${pad}let ${escapeName(s.name)} := ${emitExpr(s.value)}`;
113
- case "assign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
114
- case "bind": return `${pad}${escapeName(s.target)} ← ${emitExpr(s.value)}`;
115
- case "let-bind": return `${pad}let ${s.name} ← ${emitExpr(s.value)}`;
116
- case "return": return `${pad}return ${emitExpr(s.value)}`;
117
- case "break": return `${pad}break`;
118
- case "continue": return `${pad}continue`;
119
- case "if": {
120
- let out = `${pad}if ${emitExpr(s.cond)} then\n${emitStmts(s.then, indent + 1)}`;
121
- if (s.else.length > 0) {
122
- if (s.else.length === 1 && s.else[0].kind === "if") {
123
- const ei = s.else[0];
124
- out += `\n${pad}else if ${emitExpr(ei.cond)} then\n${emitStmts(ei.then, indent + 1)}`;
125
- if (ei.else.length > 0)
126
- out += `\n${pad}else\n${emitStmts(ei.else, indent + 1)}`;
127
- }
128
- else {
129
- out += `\n${pad}else\n${emitStmts(s.else, indent + 1)}`;
130
- }
131
- }
132
- return out;
133
- }
134
- case "match": {
135
- const lines = [`${pad}match ${s.scrutinee} with`];
136
- for (const arm of s.arms) {
137
- lines.push(`${pad}| ${arm.pattern} =>`);
138
- lines.push(emitStmts(arm.body, indent + 1));
139
- }
140
- return lines.join("\n");
141
- }
142
- case "while": {
143
- const lines = [`${pad}while ${emitExpr(s.cond)}`];
144
- for (const inv of s.invariants)
145
- lines.push(`${pad} invariant ${emitExpr(inv)}`);
146
- if (s.doneWith)
147
- lines.push(`${pad} done_with ${emitExpr(s.doneWith)}`);
148
- if (s.decreasing)
149
- lines.push(`${pad} decreasing ${emitExpr(s.decreasing)}`);
150
- lines.push(`${pad}do`);
151
- lines.push(emitStmts(s.body, indent + 1));
152
- return lines.join("\n");
153
- }
154
- case "forin": {
155
- const lines = [`${pad}for ${s.idx} in [:${emitExpr(s.bound)}]`];
156
- for (const inv of s.invariants)
157
- lines.push(`${pad} invariant ${emitExpr(inv)}`);
158
- lines.push(`${pad}do`);
159
- lines.push(emitStmts(s.body, indent + 1));
160
- return lines.join("\n");
161
- }
162
- }
163
- }
164
- // ── Declaration emission ─────────────────────────────────────
165
- function emitDecl(d) {
166
- switch (d.kind) {
167
- case "inductive": {
168
- const lines = [`inductive ${d.name} where`];
169
- for (const c of d.constructors) {
170
- if (c.fields.length === 0) {
171
- lines.push(` | ${c.name} : ${d.name}`);
172
- }
173
- else {
174
- const params = c.fields.map(f => `(${escapeName(f.name)} : ${f.type})`).join(" ");
175
- lines.push(` | ${c.name} ${params} : ${d.name}`);
176
- }
177
- }
178
- if (d.deriving.length > 0)
179
- lines.push(`deriving ${d.deriving.join(", ")}`);
180
- return lines.join("\n");
181
- }
182
- case "structure": {
183
- const lines = [`structure ${d.name} where`];
184
- for (const f of d.fields)
185
- lines.push(` ${escapeName(f.name)} : ${f.type}`);
186
- if (d.deriving.length > 0)
187
- lines.push(`deriving ${d.deriving.join(", ")}`);
188
- return lines.join("\n");
189
- }
190
- case "def": {
191
- const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
192
- return `def ${d.name} ${params} : ${d.returnType} :=\n${emitPureExpr(d.body, 1)}`;
193
- }
194
- case "method": {
195
- const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
196
- const lines = [`method ${d.name} ${params} return (res : ${d.returnType})`];
197
- for (const r of d.requires)
198
- lines.push(` require ${emitExpr(r)}`);
199
- for (const e of d.ensures)
200
- lines.push(` ensures ${emitExpr(e)}`);
201
- lines.push(" do");
202
- lines.push(emitStmts(d.body, 2));
203
- return lines.join("\n");
204
- }
205
- case "namespace": {
206
- const lines = [`namespace ${d.name}`];
207
- for (const inner of d.decls)
208
- lines.push("", emitDecl(inner));
209
- lines.push("", `end ${d.name}`);
210
- return lines.join("\n");
211
- }
212
- }
213
- }
214
- /** Emit a pure expression with indented if/match blocks. */
215
- function emitPureExpr(e, indent) {
216
- const pad = " ".repeat(indent);
217
- switch (e.kind) {
218
- case "if":
219
- return `${pad}if ${emitExpr(e.cond)} then\n${emitPureExpr(e.then, indent + 1)}\n${pad}else\n${emitPureExpr(e.else, indent + 1)}`;
220
- case "match": {
221
- const lines = [`${pad}match ${e.scrutinee} with`];
222
- for (const arm of e.arms) {
223
- lines.push(`${pad}| ${arm.pattern} =>`);
224
- lines.push(emitPureExpr(arm.body, indent + 1));
225
- }
226
- return lines.join("\n");
227
- }
228
- case "let":
229
- return `${pad}let ${e.name} := ${emitExpr(e.value)}\n${emitPureExpr(e.body, indent)}`;
230
- default:
231
- return `${pad}${emitExpr(e)}`;
232
- }
233
- }
234
- // ── File emission ────────────────────────────────────────────
235
- export function emitFile(file) {
236
- const lines = [];
237
- if (file.comment) {
238
- lines.push("/-");
239
- lines.push(file.comment);
240
- lines.push("-/");
241
- }
242
- for (const imp of file.imports)
243
- lines.push(`import ${imp}`);
244
- if (file.options.length > 0)
245
- lines.push("");
246
- for (const opt of file.options)
247
- lines.push(`set_option ${opt.key} ${opt.value}`);
248
- for (const decl of file.decls) {
249
- lines.push("");
250
- lines.push(emitDecl(decl));
251
- }
252
- return lines.join("\n") + "\n";
253
- }