lemmascript 0.5.5 → 0.5.7

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.
@@ -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
  }