lemmascript 0.5.13 → 0.5.15

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.13",
3
+ "version": "0.5.15",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,7 +32,7 @@
32
32
  "type": "git",
33
33
  "url": "https://github.com/midspiral/LemmaScript"
34
34
  },
35
- "homepage": "https://lemmascript.com",
35
+ "homepage": "https://lemmascript.org",
36
36
  "keywords": [
37
37
  "lemmascript",
38
38
  "verification",
@@ -2,7 +2,7 @@
2
2
  * Dafny emitter — IR → Dafny text.
3
3
  */
4
4
  import { usesName, usesNameInDecl } from "./ir.js";
5
- import { freshName } from "./names.js";
5
+ import { freshName, userNames } from "./names.js";
6
6
  import { renameFreeVar } from "./transform.js";
7
7
  /** Fresh binder for a comprehension wrapping the given subexpressions: `base`
8
8
  * verbatim unless one of them references it, then primed until free. A *local*
@@ -42,7 +42,7 @@ function tyToDafny(ty) {
42
42
  needPreamble("OptionType");
43
43
  return `Option<${tyToDafny(ty.inner)}>`;
44
44
  }
45
- case "user": return ty.name;
45
+ case "user": return escapeName(ty.name);
46
46
  case "fn": return `(${ty.params.map(tyToDafny).join(", ")}) -> ${tyToDafny(ty.result)}`;
47
47
  // Out-of-subset (`any`/`unknown`); opaque so real ops on it fail loudly
48
48
  // rather than silently verify as `int`. Mirrors the Lean backend's `_`.
@@ -75,28 +75,82 @@ const DAFNY_KEYWORDS = new Set([
75
75
  "copredicate", "inductive",
76
76
  ]);
77
77
  // The Dafny out-parameter name for the method currently being emitted. Default
78
- // `res`, but bumped (e.g. `res_`) when a parameter is named `res` — set by
79
- // methodHeader and reset per decl. `\result` in an ensures must use the *same*
80
- // name, so escapeName routes it here.
78
+ // `res`, but bumped (e.g. `res'`) when the method's own scope uses `res` — set
79
+ // by methodHeader and reset per decl. `\result` in an ensures must use the
80
+ // *same* name, so escapeName routes it here.
81
81
  let _resultName = "res";
82
+ // ── Dafny name allocation ──────────────────────────────────
83
+ //
84
+ // freshName (names.ts) freshens in the *raw TS* namespace — but that is not the
85
+ // namespace Dafny sees. Escaping maps `_x`→`i_x` and keyword `match`→`match_`,
86
+ // so two raw-distinct names can collapse *after* escaping: a raw-freshened temp
87
+ // `_t0'`→`i_t0'` colliding with a user `_t0` that escaped-and-primed to `i_t0'`.
88
+ // So Dafny hygiene is a second allocator, layered at emission: escape to a base,
89
+ // then freshen against the names already claimed in the Dafny namespace. User
90
+ // names are allocated up front (Dafny-safe ones kept exact); generated names are
91
+ // allocated on first sight and cached so a decl and its references agree. Reset
92
+ // per file. (The raw freshName layer stays — Lean has a different escaping story.)
93
+ function dafnyBaseName(name) {
94
+ if (DAFNY_KEYWORDS.has(name))
95
+ return `${name}_`;
96
+ if (name.startsWith("_"))
97
+ return `i${name}`; // Dafny forbids leading `_`
98
+ return name;
99
+ }
100
+ let _userDafnyNames = new Map();
101
+ let _generatedDafnyNames = new Map();
102
+ let _takenDafnyNames = new Set();
103
+ /** `base`, primed until free in the Dafny namespace. A prime can't occur in a
104
+ * TS identifier, so priming always leaves user-name space. */
105
+ function freshDafnyName(base) {
106
+ let out = base;
107
+ while (_takenDafnyNames.has(out))
108
+ out += "'";
109
+ return out;
110
+ }
111
+ function resetDafnyNameCache() {
112
+ _userDafnyNames = new Map();
113
+ _generatedDafnyNames = new Map();
114
+ _takenDafnyNames = new Set();
115
+ const raws = [...userNames()].sort();
116
+ // Dafny-safe source names keep their spelling; names that must mangle are then
117
+ // freshened in the emitted namespace (safe-first, sorted → deterministic).
118
+ for (const raw of raws)
119
+ if (dafnyBaseName(raw) === raw) {
120
+ _userDafnyNames.set(raw, raw);
121
+ _takenDafnyNames.add(raw);
122
+ }
123
+ for (const raw of raws)
124
+ if (dafnyBaseName(raw) !== raw) {
125
+ const emitted = freshDafnyName(dafnyBaseName(raw));
126
+ _userDafnyNames.set(raw, emitted);
127
+ _takenDafnyNames.add(emitted);
128
+ }
129
+ }
82
130
  function escapeName(name) {
83
- // \result is carried through the IR as the var name "\\result"; render it
84
- // as the current method's out-parameter name.
131
+ // \result is carried through the IR as var "\\result"; render it as the
132
+ // current method's out-parameter name (chosen locally by methodHeader).
85
133
  if (name === "\\result")
86
134
  return _resultName;
87
- let out = name;
88
- if (DAFNY_KEYWORDS.has(name))
89
- out = `${name}_`;
90
- // Dafny doesn't allow identifiers starting with _
91
- else if (name.startsWith("_"))
92
- out = `i${name}`;
93
- else
94
- return name;
95
- // Mangling must stay injective: the mangled form may itself be a name the
96
- // user wrote (`match` `match_` beside a real `match_`, `_x` `i_x` beside
97
- // a real `i_x`), silently merging two distinct variables. `freshName` primes
98
- // it clear of user-name space (a prime can't occur in a TS identifier).
99
- return freshName(out);
135
+ const user = _userDafnyNames.get(name);
136
+ if (user !== undefined)
137
+ return user;
138
+ return escapeGeneratedName(name);
139
+ }
140
+ /** Allocate a toolchain-generated name (an ANF temp, a comprehension binder, a
141
+ * companion `_ensures` lemma). Escapes to a base, then freshens in the Dafny
142
+ * namespace so it can't collapse onto an escaped user name. Bypasses the user
143
+ * map on purpose: the raw name is synthesized, so it must be freshened *away
144
+ * from* a same-spelled user name, not aliased onto it. Cached so a declaration
145
+ * and its references render identically. */
146
+ function escapeGeneratedName(name) {
147
+ const cached = _generatedDafnyNames.get(name);
148
+ if (cached !== undefined)
149
+ return cached;
150
+ const emitted = freshDafnyName(dafnyBaseName(name));
151
+ _generatedDafnyNames.set(name, emitted);
152
+ _takenDafnyNames.add(emitted);
153
+ return emitted;
100
154
  }
101
155
  /** Format a typed parameter list for Dafny: "x: int, y: seq<int>" */
102
156
  function paramList(params) {
@@ -139,7 +193,7 @@ function emitQuantifier(e, keyword) {
139
193
  while (body.kind === e.kind) {
140
194
  const dty = tyToDafny(body.type);
141
195
  const ann = dty === "string" ? "" : `: ${dty}`;
142
- vars.push(`${body.var}${ann}`);
196
+ vars.push(`${escapeName(body.var)}${ann}`);
143
197
  body = body.body;
144
198
  }
145
199
  return `${keyword} ${vars.join(", ")} :: ${emitExpr(body)}`;
@@ -337,7 +391,7 @@ function emitExpr(e) {
337
391
  // Minted comprehension binder: freshen so a user variable `k` in the
338
392
  // receiver or key isn't captured (`k != k` would delete nothing).
339
393
  // Local check — only this comprehension's own operands can collide.
340
- const k = freshBinder("k", e.obj, e.args[0]);
394
+ const k = escapeName(freshBinder("k", e.obj, e.args[0]));
341
395
  return `(map ${k} | ${k} in ${obj} && ${k} != ${args[0]} :: ${obj}[${k}])`;
342
396
  }
343
397
  }
@@ -485,9 +539,11 @@ function emitExpr(e) {
485
539
  }
486
540
  case "field": {
487
541
  const obj = emitExpr(e.obj);
488
- if (e.field === "size" || e.field === "length" || e.field === "collectionSize")
542
+ // `size`/`length`/`keys` are collection intrinsics unless the transform
543
+ // proved this is a declared datatype field (then project it).
544
+ if (!e.datatypeField && (e.field === "size" || e.field === "length" || e.field === "collectionSize"))
489
545
  return `|${obj}|`;
490
- if (e.field === "keys")
546
+ if (!e.datatypeField && e.field === "keys")
491
547
  return `${obj}.Keys`;
492
548
  if (e.field === "toNat")
493
549
  return obj;
@@ -719,23 +775,23 @@ function emitDecl(d) {
719
775
  const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name}` } : f);
720
776
  return `${escapeName(c.name)}(${paramList(fields)})`;
721
777
  });
722
- return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
778
+ return `datatype ${escapeName(d.name)}${tp} = ${ctors.join(" | ")}`;
723
779
  }
724
780
  case "structure": {
725
781
  const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
726
- return `datatype ${d.name}${tp} = ${d.name}(${paramList(d.fields)})`;
782
+ return `datatype ${escapeName(d.name)}${tp} = ${escapeName(d.name)}(${paramList(d.fields)})`;
727
783
  }
728
784
  case "type-alias": {
729
- return `type ${d.name} = ${tyToDafny(d.target)}`;
785
+ return `type ${escapeName(d.name)} = ${tyToDafny(d.target)}`;
730
786
  }
731
787
  case "opaque-type": {
732
788
  // Abstract type — no definition. `(==)` so it can sit inside datatypes
733
789
  // that derive structural equality. Never constructed or destructured.
734
- return `type ${d.name}(==)`;
790
+ return `type ${escapeName(d.name)}(==)`;
735
791
  }
736
792
  case "def": {
737
793
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
738
- const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
794
+ const lines = [`function ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
739
795
  for (const r of d.requires)
740
796
  lines.push(` requires ${emitExpr(r)}`);
741
797
  if (d.decreases)
@@ -748,7 +804,7 @@ function emitDecl(d) {
748
804
  // Strip constraints like (==) from type params — ghost lemmas don't need them
749
805
  const lemmaTP = d.typeParams.length > 0 ? `<${d.typeParams.map(t => t.replace(/\(.*\)/, '')).join(", ")}>` : "";
750
806
  lines.push("");
751
- lines.push(`lemma ${d.name}_ensures${lemmaTP}(${paramList(d.params)})`);
807
+ lines.push(`lemma ${escapeGeneratedName(`${d.name}_ensures`)}${lemmaTP}(${paramList(d.params)})`);
752
808
  for (const r of d.requires)
753
809
  lines.push(` requires ${emitExpr(r)}`);
754
810
  for (const e of d.ensures)
@@ -760,7 +816,7 @@ function emitDecl(d) {
760
816
  }
761
817
  case "def-by-method": {
762
818
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
763
- const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
819
+ const lines = [`function ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
764
820
  for (const r of d.requires)
765
821
  lines.push(` requires ${emitExpr(r)}`);
766
822
  if (d.decreases)
@@ -774,7 +830,7 @@ function emitDecl(d) {
774
830
  }
775
831
  case "method": {
776
832
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
777
- const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType, d)];
833
+ const lines = [methodHeader(`method ${escapeName(d.name)}${tp}`, d.params, d.returnType, d)];
778
834
  for (const r of d.requires)
779
835
  lines.push(` requires ${emitExpr(r)}`);
780
836
  for (const e of d.ensures)
@@ -787,14 +843,14 @@ function emitDecl(d) {
787
843
  return lines.join("\n");
788
844
  }
789
845
  case "class": {
790
- const lines = [`class ${d.name} {`];
846
+ const lines = [`class ${escapeName(d.name)} {`];
791
847
  for (const f of d.fields) {
792
848
  lines.push(` var ${escapeName(f.name)}: ${tyToDafny(f.type)}`);
793
849
  }
794
850
  if (d.fields.length > 0 && d.methods.length > 0)
795
851
  lines.push("");
796
852
  for (const m of d.methods) {
797
- lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType, m)}`);
853
+ lines.push(` ${methodHeader(`method ${escapeName(m.name)}`, m.params, m.returnType, m)}`);
798
854
  for (const r of m.requires)
799
855
  lines.push(` requires ${emitExpr(r)}`);
800
856
  for (const e of m.ensures)
@@ -1027,22 +1083,39 @@ const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T,
1027
1083
  ensures multiset(SeqSortBy(s, cmp)) == multiset(s)
1028
1084
  ensures |SeqSortBy(s, cmp)| == |s|
1029
1085
  ensures forall i: int, j: int :: 0 <= i <= j < |SeqSortBy(s, cmp)| ==> cmp(SeqSortBy(s, cmp)[i], SeqSortBy(s, cmp)[j]) <= 0`;
1030
- const STRING_TRIM = `function StringTrimLeft(s: string): string
1086
+ // TS/JS String.prototype.trim() strips ECMAScript WhiteSpace ∪ LineTerminator:
1087
+ // Unicode general-category Zs plus TAB/VT/FF/CR/LF, LS/PS, and the BOM — NOT just
1088
+ // U+0020, and NOT U+0085 (NEL, which is Cc). See
1089
+ // https://tc39.es/ecma262/#sec-white-space and
1090
+ // https://tc39.es/ecma262/#sec-line-terminators.
1091
+ // `\\U{..}` are Dafny char escapes (not JS: the string
1092
+ // is emitted verbatim), so the enumeration below is Dafny source, not decoded.
1093
+ const STRING_TRIM = `predicate IsJSWhitespace(c: char)
1094
+ {
1095
+ c == '\\U{0009}' || c == '\\U{000A}' || c == '\\U{000B}' || c == '\\U{000C}' || c == '\\U{000D}' ||
1096
+ c == '\\U{0020}' || c == '\\U{00A0}' || c == '\\U{1680}' ||
1097
+ ('\\U{2000}' <= c <= '\\U{200A}') ||
1098
+ c == '\\U{2028}' || c == '\\U{2029}' || c == '\\U{202F}' || c == '\\U{205F}' ||
1099
+ c == '\\U{3000}' || c == '\\U{FEFF}'
1100
+ }
1101
+
1102
+ function StringTrimLeft(s: string): string
1031
1103
  ensures |StringTrimLeft(s)| <= |s|
1032
- ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
1104
+ ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && !IsJSWhitespace(StringTrimLeft(s)[0]))
1033
1105
  decreases |s|
1034
1106
  {
1035
1107
  if |s| == 0 then ""
1036
- else if s[0] == ' ' then StringTrimLeft(s[1..])
1108
+ else if IsJSWhitespace(s[0]) then StringTrimLeft(s[1..])
1037
1109
  else s
1038
1110
  }
1039
1111
 
1040
1112
  function StringTrimRight(s: string): string
1041
1113
  ensures |StringTrimRight(s)| <= |s|
1114
+ ensures StringTrimRight(s) == "" || (|StringTrimRight(s)| > 0 && !IsJSWhitespace(StringTrimRight(s)[|StringTrimRight(s)|-1]))
1042
1115
  decreases |s|
1043
1116
  {
1044
1117
  if |s| == 0 then ""
1045
- else if s[|s|-1] == ' ' then StringTrimRight(s[..|s|-1])
1118
+ else if IsJSWhitespace(s[|s|-1]) then StringTrimRight(s[..|s|-1])
1046
1119
  else s
1047
1120
  }
1048
1121
 
@@ -1249,21 +1322,17 @@ function qualifyCtor(name, type) {
1249
1322
  * "_" → "_"
1250
1323
  */
1251
1324
  const CTOR_MAP = { "some": "Some", "none": "None" };
1252
- function translatePattern(pattern) {
1253
- if (pattern === "_")
1325
+ function translatePattern(p) {
1326
+ if (p.kind === "wild")
1254
1327
  return "_";
1255
- const m = pattern.match(/^\.(\w+)\s*(.*)$/);
1256
- if (!m)
1257
- return pattern;
1258
- const ctorName = CTOR_MAP[m[1]] ?? escapeName(m[1]);
1259
- const fields = m[2].trim();
1260
- if (!fields)
1328
+ const ctorName = CTOR_MAP[p.ctor] ?? escapeName(p.ctor);
1329
+ if (p.binders.length === 0)
1261
1330
  return ctorName;
1262
- const fieldNames = fields.split(/\s+/).map(escapeName);
1263
- return `${ctorName}(${fieldNames.join(", ")})`;
1331
+ return `${ctorName}(${p.binders.map(escapeName).join(", ")})`;
1264
1332
  }
1265
1333
  export function emitDafnyFile(file, tsFileName, opts) {
1266
1334
  _useSafeSlice = !!opts?.safeSlice;
1335
+ resetDafnyNameCache();
1267
1336
  buildRecordCtorMap(file.decls);
1268
1337
  _neededPreambles.clear();
1269
1338
  // Track successfully emitted pure defs — method wrappers are only
package/tools/dist/ir.js CHANGED
@@ -4,6 +4,19 @@
4
4
  * The transform phase produces these types.
5
5
  * The emit phase pretty-prints them to backend syntax (Lean or Dafny).
6
6
  */
7
+ export const pWild = () => ({ kind: "wild" });
8
+ export const pCtor = (ctor, ...binders) => ({ kind: "ctor", ctor, binders });
9
+ /** Binder identifiers a pattern introduces (`[]` for wildcard / nullary ctor). */
10
+ export function patternBinders(p) {
11
+ return p.kind === "ctor" ? p.binders : [];
12
+ }
13
+ export function patternCtor(p) {
14
+ return p.kind === "ctor" ? p.ctor : null;
15
+ }
16
+ /** Does the pattern bind `name`? */
17
+ export function patternBinds(p, name) {
18
+ return patternBinders(p).includes(name);
19
+ }
7
20
  export function anyExpr(e, pred) {
8
21
  if (pred(e))
9
22
  return true;
@@ -75,8 +88,32 @@ export function usesName(e, name) {
75
88
  export function usesNameInStmts(stmts, name) {
76
89
  return anyExprInStmts(stmts, _refsName(name));
77
90
  }
78
- /** Does a declaration's spec (requires/ensures) or body reference `name`? The
79
- * scope a method's out-parameter binder must dodge, shared by both emitters. */
91
+ /** Names a statement tree *binds, targets, or introduces* `let`/`let-bind`/
92
+ * `ghostLet` names, `assign`/`bind`/`ghostAssign` targets, `for-in` indices,
93
+ * and `match`-arm pattern binders — recursing through nested blocks. Distinct
94
+ * from `usesNameInStmts` (expression references only): an unread or assign-only
95
+ * local still duplicate-declares against a method's out-parameter in Dafny. */
96
+ export function bindsNameInStmts(stmts, name) {
97
+ return stmts.some(s => {
98
+ switch (s.kind) {
99
+ case "let":
100
+ case "let-bind":
101
+ case "ghostLet": return s.name === name;
102
+ case "assign":
103
+ case "bind":
104
+ case "ghostAssign": return s.target === name;
105
+ case "forin": return s.idx === name || bindsNameInStmts(s.body, name);
106
+ case "if": return bindsNameInStmts(s.then, name) || bindsNameInStmts(s.else, name);
107
+ case "while": return bindsNameInStmts(s.body, name);
108
+ case "match": return s.arms.some(a => patternBinds(a.pattern, name) || bindsNameInStmts(a.body, name));
109
+ default: return false;
110
+ }
111
+ });
112
+ }
113
+ /** Every occurrence of `name` a method's out-parameter binder must dodge —
114
+ * referenced in a spec (requires/ensures) or body, *or* bound/targeted anywhere
115
+ * in the body (an unread local still duplicate-declares). Both emitters share it. */
80
116
  export function usesNameInDecl(requires, ensures, body, name) {
81
- return requires.some(e => usesName(e, name)) || ensures.some(e => usesName(e, name)) || usesNameInStmts(body, name);
117
+ return requires.some(e => usesName(e, name)) || ensures.some(e => usesName(e, name))
118
+ || usesNameInStmts(body, name) || bindsNameInStmts(body, name);
82
119
  }
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Lean emitter — IR → Lean text.
3
- * No logic, no type decisions — just serialization.
2
+ * Lean emitter — IR → Lean text. Beyond serialization it makes type-driven
3
+ * decisions: Bool-vs-Prop connectives, dropping Repr/DecidableEq for
4
+ * opaque-tainted types, discriminator/destructor lowering, method dispatch,
5
+ * and support-import selection.
4
6
  */
5
- import { anyExpr, usesNameInDecl } from "./ir.js";
7
+ import { anyExpr, usesNameInDecl, patternBinders } from "./ir.js";
6
8
  import { freshName } from "./names.js";
7
9
  // ── Ty → Lean type string ──────────────────────────────────
8
10
  function tyToLean(ty) {
@@ -178,6 +180,10 @@ let _unknownEmitted = false; // across files in one run — the def file imports
178
180
  // built only from decidable atoms (comparisons, Bool-returning calls) coerces fine
179
181
  // and stays in the more proof-friendly Prop form.
180
182
  let _boolCtx = false;
183
+ /** Render a match pattern to Lean syntax: `_`, `.none`, `.some x`, `.syn seq`. */
184
+ function renderLeanPattern(p) {
185
+ return p.kind === "wild" ? "_" : "." + [p.ctor, ...p.binders].join(" ");
186
+ }
181
187
  // A Bool-valued atom that does NOT coerce to Prop: an inlined union discriminator
182
188
  // (lowered to a match-bool `match x with | .C .. => true | _ => false`) or a raw
183
189
  // `match` used as a Bool — neither has a `Decidable` instance Lean can synthesize
@@ -465,7 +471,7 @@ function emitExpr(e, parentPrec) {
465
471
  // Always parenthesize inline matches — Lean parses alternatives greedily,
466
472
  // so any token after an arm body (`→`, another match's `|`, etc.) would
467
473
  // bleed into the last `.none` case without explicit bracketing.
468
- const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
474
+ const arms = e.arms.map(a => `| ${renderLeanPattern(a.pattern)} => ${emitExpr(a.body)}`);
469
475
  const scrut = typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee);
470
476
  return `(match ${scrut} with ${arms.join(" ")})`;
471
477
  }
@@ -557,10 +563,10 @@ function emitStmt(s, indent) {
557
563
  const scrut = typeof s.scrutinee === "string" ? s.scrutinee : emitExpr(s.scrutinee);
558
564
  // Option match (.some/.none) → emit as if/let for WPGen.if compatibility
559
565
  if (s.arms.length === 2) {
560
- const someArm = s.arms.find(a => a.pattern.startsWith(".some "));
561
- const noneArm = s.arms.find(a => a.pattern === ".none");
566
+ const someArm = s.arms.find(a => a.pattern.kind === "ctor" && a.pattern.ctor === "some");
567
+ const noneArm = s.arms.find(a => a.pattern.kind === "ctor" && a.pattern.ctor === "none");
562
568
  if (someArm && noneArm) {
563
- const boundVar = someArm.pattern.slice(6); // strip ".some "
569
+ const boundVar = patternBinders(someArm.pattern)[0]; // ".some x" ⇒ "x"
564
570
  const hName = `h_${scrut.replace(/[^a-zA-Z0-9_]/g, "_")}`;
565
571
  const lines = [
566
572
  `${pad}if ${hName} : (${scrut}).isSome = true then`,
@@ -585,7 +591,7 @@ function emitStmt(s, indent) {
585
591
  // General match
586
592
  const lines = [`${pad}match ${scrut} with`];
587
593
  for (const arm of s.arms) {
588
- lines.push(`${pad}| ${arm.pattern} =>`);
594
+ lines.push(`${pad}| ${renderLeanPattern(arm.pattern)} =>`);
589
595
  if (arm.body.length === 0) {
590
596
  lines.push(`${pad} pure ()`);
591
597
  }
@@ -744,7 +750,7 @@ function emitPureExpr(e, indent) {
744
750
  case "match": {
745
751
  const lines = [`${pad}match ${typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee)} with`];
746
752
  for (const arm of e.arms) {
747
- lines.push(`${pad}| ${arm.pattern} =>`);
753
+ lines.push(`${pad}| ${renderLeanPattern(arm.pattern)} =>`);
748
754
  lines.push(emitPureExpr(arm.body, indent + 1));
749
755
  }
750
756
  return lines.join("\n");
@@ -756,6 +762,20 @@ function emitPureExpr(e, indent) {
756
762
  }
757
763
  }
758
764
  // ── File emission ────────────────────────────────────────────
765
+ /** Reset per-module emitter state. Call once per module before the types file
766
+ * (not between types and def — the def file reads the types file's registries). */
767
+ export function resetLeanModule() {
768
+ _resultName = "res";
769
+ _unionCtors.clear();
770
+ _opaqueNames.clear();
771
+ _opaqueNames.add("Unknown");
772
+ _typeRefs.clear();
773
+ _taintedTypes.clear();
774
+ _needsJSString = false;
775
+ _needsUnknown = false;
776
+ _unknownEmitted = false;
777
+ _boolCtx = false;
778
+ }
759
779
  export function emitLeanFile(file) {
760
780
  _needsJSString = false;
761
781
  _needsUnknown = false;
package/tools/dist/lsc.js CHANGED
@@ -15,7 +15,7 @@ import { narrowModule } from "./narrow.js";
15
15
  import { autoHavocModule } from "./autohavoc.js";
16
16
  import { transformModuleLean, transformModuleDafny } from "./transform.js";
17
17
  import { peepholeModule } from "./peephole.js";
18
- import { emitLeanFile } from "./lean-emit.js";
18
+ import { emitLeanFile, resetLeanModule } from "./lean-emit.js";
19
19
  import { emitDafnyFile } from "./dafny-emit.js";
20
20
  import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
21
21
  import { leanGen, leanCheck } from "./lean-commands.js";
@@ -261,6 +261,7 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
261
261
  if (typesFile)
262
262
  typesFile = peepholeModule(typesFile, "lean");
263
263
  defFile = peepholeModule(defFile, "lean");
264
+ resetLeanModule(); // clear per-module emitter state so batch mode doesn't leak into this module
264
265
  const typesPath = typesFile ? path.join(dir, `${leanBase}.types.lean`) : null;
265
266
  const typesText = typesFile ? emitLeanFile(typesFile) : null;
266
267
  const defPath = path.join(dir, `${leanBase}.def.lean`);
@@ -34,6 +34,11 @@ export function setUserNames(names) {
34
34
  export function isUserName(name) {
35
35
  return _userNames.has(name);
36
36
  }
37
+ /** The raw user identifiers, for a backend that needs to allocate its own
38
+ * emitted names against them (e.g. Dafny escaping — see dafny-emit). */
39
+ export function userNames() {
40
+ return [..._userNames];
41
+ }
37
42
  /** A toolchain-internal name: `base` verbatim, primed on collision. The one
38
43
  * place the priming rule lives. `taken` says what counts as a collision —
39
44
  * by default a user-written name anywhere in the module; callers that know
@@ -417,6 +417,26 @@ function ruleImplOptional(e) {
417
417
  ty: { kind: "bool" },
418
418
  };
419
419
  }
420
+ /** Apply an optional chain's steps (field / index / call) to a base expr —
421
+ * shared by `ruleOptChain` (base = binder) and `ruleOptChainIndex` (base = arr[i]). */
422
+ function applyChain(body, chain) {
423
+ for (const step of chain) {
424
+ if (step.kind === "field")
425
+ body = { kind: "field", obj: body, field: step.name, ty: step.ty };
426
+ else if (step.kind === "index")
427
+ body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
428
+ else
429
+ body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind };
430
+ }
431
+ return body;
432
+ }
433
+ /** `0 <= idx && idx < arr.length` — the in-bounds guard for an array index. */
434
+ function arrayBoundsCond(arr, idx) {
435
+ const len = { kind: "field", obj: arr, field: "length", ty: { kind: "int" } };
436
+ const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
437
+ const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
438
+ return { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
439
+ }
420
440
  /** Rule (expression): `left ?? right` — nullish coalescing.
421
441
  * → `someMatch left { Some(_v) => _v, None => right }`.
422
442
  * Single-evaluation: scrutinee may be any expression. */
@@ -448,11 +468,7 @@ function ruleNullishIndex(e) {
448
468
  return null;
449
469
  if (e.left.obj.ty.kind !== "array")
450
470
  return null;
451
- const idx = e.left.idx;
452
- const len = { kind: "field", obj: e.left.obj, field: "length", ty: { kind: "int" } };
453
- const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
454
- const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
455
- const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
471
+ const cond = arrayBoundsCond(e.left.obj, e.left.idx);
456
472
  return { kind: "conditional", cond, then: e.left, else: e.right, ty: e.ty };
457
473
  }
458
474
  /** Rule (expression): `arr[i]?.<chain>` — optional chaining on an array index,
@@ -470,23 +486,8 @@ function ruleOptChainIndex(e) {
470
486
  return null;
471
487
  if (e.obj.obj.ty.kind !== "array")
472
488
  return null;
473
- const idx = e.obj.idx;
474
- const len = { kind: "field", obj: e.obj.obj, field: "length", ty: { kind: "int" } };
475
- const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
476
- const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
477
- const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
478
- let body = e.obj; // arr[i] — in bounds under `cond`
479
- for (const step of e.chain) {
480
- if (step.kind === "field") {
481
- body = { kind: "field", obj: body, field: step.name, ty: step.ty };
482
- }
483
- else if (step.kind === "index") {
484
- body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
485
- }
486
- else {
487
- body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind };
488
- }
489
- }
489
+ const cond = arrayBoundsCond(e.obj.obj, e.obj.idx);
490
+ const body = applyChain(e.obj, e.chain); // arr[i] in bounds under `cond`
490
491
  const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
491
492
  return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
492
493
  }
@@ -501,18 +502,7 @@ function ruleOptChain(e) {
501
502
  return null;
502
503
  const innerTy = e.obj.ty.inner;
503
504
  const binder = freshName(`_oc${_ocCounter++}_val`);
504
- let body = { kind: "var", name: binder, ty: innerTy };
505
- for (const step of e.chain) {
506
- if (step.kind === "field") {
507
- body = { kind: "field", obj: body, field: step.name, ty: step.ty };
508
- }
509
- else if (step.kind === "index") {
510
- body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
511
- }
512
- else {
513
- body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind };
514
- }
515
- }
505
+ const body = applyChain({ kind: "var", name: binder, ty: innerTy }, e.chain);
516
506
  const noneBody = { kind: "var", name: "undefined", ty: { kind: "void" } };
517
507
  return {
518
508
  kind: "someMatch",
@@ -604,31 +594,39 @@ function ruleConditionalOptionalTruthy(e) {
604
594
  someBody: e.then, noneBody: e.else, ty: e.ty,
605
595
  };
606
596
  }
607
- /** Extract an optional check from any position in an `&&` chain.
608
- * `(x !== undefined && b) && c` { check, restCond: b && c }.
609
- * `a && (x !== undefined)` → { check, restCond: a }.
610
- * Conjunct order doesn't carry semantic weight, so either side is fine. */
611
- function extractLeftmostOptionalCheck(cond) {
597
+ /** Find the leftmost `parse`-matching conjunct anywhere in an `&&` chain,
598
+ * returning it plus the remaining conjunction. Conjunct order doesn't carry
599
+ * semantic weight, so either side is fine. Shared by the optional and
600
+ * Array.isArray chain extractors they differ only in `parse`.
601
+ * `(x !== undefined && b) && c` → { check, restCond: b && c }. */
602
+ function extractLeftmostCheck(cond, parse) {
612
603
  if (cond.kind !== "binop" || cond.op !== "&&")
613
604
  return null;
614
- const leftCheck = parseSimpleOptionalCheck(cond.left);
615
- if (leftCheck && !leftCheck.negated)
616
- return { check: leftCheck, restCond: cond.right };
617
- const rightCheck = parseSimpleOptionalCheck(cond.right);
618
- if (rightCheck && !rightCheck.negated)
619
- return { check: rightCheck, restCond: cond.left };
605
+ const left = parse(cond.left);
606
+ if (left)
607
+ return { check: left, restCond: cond.right };
608
+ const right = parse(cond.right);
609
+ if (right)
610
+ return { check: right, restCond: cond.left };
620
611
  if (cond.left.kind === "binop" && cond.left.op === "&&") {
621
- const inner = extractLeftmostOptionalCheck(cond.left);
612
+ const inner = extractLeftmostCheck(cond.left, parse);
622
613
  if (inner)
623
614
  return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
624
615
  }
625
616
  if (cond.right.kind === "binop" && cond.right.op === "&&") {
626
- const inner = extractLeftmostOptionalCheck(cond.right);
617
+ const inner = extractLeftmostCheck(cond.right, parse);
627
618
  if (inner)
628
619
  return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
629
620
  }
630
621
  return null;
631
622
  }
623
+ /** `&&`-chain extractor for a positive optional check. */
624
+ function extractLeftmostOptionalCheck(cond) {
625
+ return extractLeftmostCheck(cond, e => {
626
+ const c = parseSimpleOptionalCheck(e);
627
+ return c && !c.negated ? c : null;
628
+ });
629
+ }
632
630
  /** Rule: `if (x !== undefined && rest) then` (no else) where x is a pure
633
631
  * access path.
634
632
  * → `someMatch x { Some(_x_val) => if rest then then; , None => {} }`.
@@ -745,31 +743,11 @@ function isNarrowablePath(e) {
745
743
  return isNarrowablePath(e.obj);
746
744
  return false;
747
745
  }
748
- /** Mirror of `extractLeftmostOptionalCheck` for synth-array-union checks:
749
- * finds `Array.isArray(path)` somewhere in a `&&` chain, returns it plus
750
- * the remaining conjunction. The check must be the positive form (negated
751
- * `!Array.isArray(...)` would narrow to the wrong variant for then-body
752
- * consumers, so we leave those to the existing untouched-conditional path). */
746
+ /** `&&`-chain extractor for `Array.isArray(path)` (positive form only — a negated
747
+ * `!Array.isArray(...)` would narrow to the wrong variant for then-body consumers,
748
+ * so those are left to the untouched-conditional path). */
753
749
  function extractLeftmostArrayIsArrayCheck(cond) {
754
- if (cond.kind !== "binop" || cond.op !== "&&")
755
- return null;
756
- const leftCheck = parseArrayIsArrayCall(cond.left);
757
- if (leftCheck)
758
- return { check: leftCheck, restCond: cond.right };
759
- const rightCheck = parseArrayIsArrayCall(cond.right);
760
- if (rightCheck)
761
- return { check: rightCheck, restCond: cond.left };
762
- if (cond.left.kind === "binop" && cond.left.op === "&&") {
763
- const inner = extractLeftmostArrayIsArrayCheck(cond.left);
764
- if (inner)
765
- return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
766
- }
767
- if (cond.right.kind === "binop" && cond.right.op === "&&") {
768
- const inner = extractLeftmostArrayIsArrayCheck(cond.right);
769
- if (inner)
770
- return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
771
- }
772
- return null;
750
+ return extractLeftmostCheck(cond, parseArrayIsArrayCall);
773
751
  }
774
752
  /** Detect `x.kind === "variant"`, `'key' in x`, or `Array.isArray(x)` (synth
775
753
  * array-union) as a positive discriminant check. Returns the scrutinee var
@@ -1,3 +1,4 @@
1
+ import { patternCtor, patternBinders } from "./ir.js";
1
2
  // ── Generic walkers (same shape as transform.ts) ─────────────
2
3
  function mapExpr(e, f) {
3
4
  const hit = f(e);
@@ -44,21 +45,19 @@ function isMapGet(e) {
44
45
  return null;
45
46
  return { obj: e.obj, key: e.args[0], objTy: e.objTy };
46
47
  }
47
- /** Parse Some-arm pattern like ".some _val" returns binder name, or null for ".some _" or unparseable. */
48
- function parseSomeBinder(pattern) {
49
- if (!pattern.startsWith(".some"))
48
+ /** Binder of a Some armits name, or null for `.some _` / a non-`some` pattern. */
49
+ function parseSomeBinder(p) {
50
+ if (patternCtor(p) !== "some")
50
51
  return null;
51
- const rest = pattern.slice(5).trim();
52
- if (rest === "" || rest === "_")
53
- return null;
54
- return rest.split(/\s+/)[0];
52
+ const b = patternBinders(p)[0];
53
+ return b === undefined || b === "_" ? null : b;
55
54
  }
56
55
  /** Identify a Some/None match's arms. */
57
56
  function getSomeNoneArms(arms) {
58
57
  if (arms.length !== 2)
59
58
  return null;
60
- const someArm = arms.find(a => a.pattern.startsWith(".some"));
61
- const noneArm = arms.find(a => a.pattern === ".none");
59
+ const someArm = arms.find(a => patternCtor(a.pattern) === "some");
60
+ const noneArm = arms.find(a => patternCtor(a.pattern) === "none");
62
61
  if (!someArm || !noneArm)
63
62
  return null;
64
63
  return { someArm, noneArm, binder: parseSomeBinder(someArm.pattern) };
@@ -4,7 +4,7 @@
4
4
  * Consumes resolved types and classifications.
5
5
  * No type lookups, no string parsing, no re-inference.
6
6
  */
7
- import { anyExprInStmts } from "./ir.js";
7
+ import { anyExprInStmts, pWild, pCtor, patternBinders, patternBinds, patternCtor } from "./ir.js";
8
8
  import { parseTsType } from "./types.js";
9
9
  import { freshName } from "./names.js";
10
10
  // ── Generic IR walkers ──────────────────────────────────────
@@ -74,11 +74,6 @@ function mapStmt(s, f) {
74
74
  case "assert": return { ...s, expr: r(s.expr) };
75
75
  }
76
76
  }
77
- /** Binders a match-arm pattern string (`.Ctor a b`, `_x_val`, `_`) introduces. */
78
- function patternBinders(pattern) {
79
- const toks = pattern.match(/[A-Za-z_][A-Za-z0-9_']*/g) ?? [];
80
- return pattern.trimStart().startsWith(".") ? toks.slice(1) : toks;
81
- }
82
77
  /** Rename free occurrences of `from` to `to`, stopping at every construct that
83
78
  * rebinds `from` — lambda params, `let`/`let-bind`/`ghostLet` (shadows the
84
79
  * rest of the block), `match` arm patterns, `forall`/`exists`, and `for-in`
@@ -98,7 +93,7 @@ export function renameFreeVar(e, from, to) {
98
93
  if (x.kind === "match") {
99
94
  const scr = typeof x.scrutinee === "string"
100
95
  ? (x.scrutinee === from ? to : x.scrutinee) : mapExpr(x.scrutinee, f);
101
- return { ...x, scrutinee: scr, arms: x.arms.map(a => patternBinders(a.pattern).includes(from) ? a : { ...a, body: mapExpr(a.body, f) }) };
96
+ return { ...x, scrutinee: scr, arms: x.arms.map(a => patternBinds(a.pattern, from) ? a : { ...a, body: mapExpr(a.body, f) }) };
102
97
  }
103
98
  if (x.kind === "lambda") {
104
99
  if (x.params.some(p => p.name === from))
@@ -203,15 +198,19 @@ function matchBinder(fieldName, prefix) {
203
198
  }
204
199
  /** Build a match arm pattern like `.VariantName _v_field1 _v_field2` from variant info. */
205
200
  function buildMatchPattern(variantName, fields, scopePrefix) {
206
- if (fields.length === 0)
207
- return `.${variantName}`;
208
- return `.${variantName} ${fields.map(f => matchBinder(f.name, scopePrefix)).join(" ")}`;
201
+ return pCtor(variantName, ...fields.map(f => matchBinder(f.name, scopePrefix)));
209
202
  }
210
203
  const _forofCounters = new Map();
211
204
  function isNat(ty) { return ty.kind === "nat"; }
212
205
  function isIntegral(ty) { return ty.kind === "int" || ty.kind === "nat"; }
213
206
  function isArray(ty) { return ty.kind === "array"; }
214
207
  function isUser(ty) { return ty.kind === "user"; }
208
+ function isRecordType(ty) {
209
+ if (ty.kind !== "user")
210
+ return false;
211
+ const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
212
+ return _typeDecls.find(d => d.name === base)?.kind === "record";
213
+ }
215
214
  /** Truthiness test for a *lowered* value of source type `ty`, used by `||`
216
215
  * falsiness lowering. Mirrors narrow.ts's `canBeFalsy`: only int/nat/string/bool
217
216
  * values can be falsy in JS (`0`, `""`, `false`); every other value (array, user
@@ -432,8 +431,8 @@ function lowerExpr(e, binds) {
432
431
  return {
433
432
  kind: "match", scrutinee: lowerExpr(e.expr, binds),
434
433
  arms: [
435
- { pattern: `.some ${bound}`, body: truthy ? { kind: "unop", op: "¬", expr: truthy } : { kind: "bool", value: false } },
436
- { pattern: ".none", body: { kind: "bool", value: true } },
434
+ { pattern: pCtor("some", bound), body: truthy ? { kind: "unop", op: "¬", expr: truthy } : { kind: "bool", value: false } },
435
+ { pattern: pCtor("none"), body: { kind: "bool", value: true } },
437
436
  ],
438
437
  };
439
438
  }
@@ -485,8 +484,8 @@ function lowerExpr(e, binds) {
485
484
  return {
486
485
  kind: "match", scrutinee: optExpr,
487
486
  arms: [
488
- { pattern: ".some _", body: { kind: "bool", value: !isNone } },
489
- { pattern: ".none", body: { kind: "bool", value: isNone } },
487
+ { pattern: pCtor("some", "_"), body: { kind: "bool", value: !isNone } },
488
+ { pattern: pCtor("none"), body: { kind: "bool", value: isNone } },
490
489
  ],
491
490
  };
492
491
  }
@@ -503,8 +502,8 @@ function lowerExpr(e, binds) {
503
502
  return {
504
503
  kind: "match", scrutinee: optExpr,
505
504
  arms: [
506
- { pattern: `.some ${bound}`, body: { kind: "binop", op: cmpOp, left: { kind: "var", name: bound }, right: valExpr } },
507
- { pattern: ".none", body: { kind: "bool", value: noneVal } },
505
+ { pattern: pCtor("some", bound), body: { kind: "binop", op: cmpOp, left: { kind: "var", name: bound }, right: valExpr } },
506
+ { pattern: pCtor("none"), body: { kind: "bool", value: noneVal } },
508
507
  ],
509
508
  };
510
509
  }
@@ -522,12 +521,12 @@ function lowerExpr(e, binds) {
522
521
  return {
523
522
  kind: "match", scrutinee: optExpr,
524
523
  arms: [
525
- { pattern: `.some ${bound}`, body: {
524
+ { pattern: pCtor("some", bound), body: {
526
525
  kind: "if", cond: truthy,
527
526
  then: { kind: "app", fn: "Some", args: [{ kind: "var", name: bound }] },
528
527
  else: { kind: "var", name: "undefined" }
529
528
  } },
530
- { pattern: ".none", body: { kind: "var", name: "undefined" } },
529
+ { pattern: pCtor("none"), body: { kind: "var", name: "undefined" } },
531
530
  ],
532
531
  };
533
532
  }
@@ -546,8 +545,8 @@ function lowerExpr(e, binds) {
546
545
  return {
547
546
  kind: "match", scrutinee: optExpr,
548
547
  arms: [
549
- { pattern: `.some ${bound}`, body: someBody },
550
- { pattern: ".none", body: defaultExpr },
548
+ { pattern: pCtor("some", bound), body: someBody },
549
+ { pattern: pCtor("none"), body: defaultExpr },
551
550
  ],
552
551
  };
553
552
  }
@@ -692,10 +691,10 @@ function lowerExpr(e, binds) {
692
691
  const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
693
692
  const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
694
693
  if (decl?.variants?.some(v => v.fields.some(f => f.name === e.field))) {
695
- return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName };
694
+ return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName, datatypeField: true };
696
695
  }
697
696
  }
698
- return { kind: "field", obj: transformExpr(e.obj), field: e.field };
697
+ return { kind: "field", obj: transformExpr(e.obj), field: e.field, datatypeField: isRecordType(e.obj.ty) };
699
698
  case "index": {
700
699
  const idx = transformExpr(e.idx);
701
700
  if (e.obj.ty.kind === "map") {
@@ -997,8 +996,8 @@ function lowerExpr(e, binds) {
997
996
  return {
998
997
  kind: "match", scrutinee,
999
998
  arms: [
1000
- { pattern: `.some ${e.binder}`, body: someBody },
1001
- { pattern: ".none", body: noneBody },
999
+ { pattern: pCtor("some", e.binder), body: someBody },
1000
+ { pattern: pCtor("none"), body: noneBody },
1002
1001
  ],
1003
1002
  };
1004
1003
  }
@@ -1040,7 +1039,7 @@ function lowerExpr(e, binds) {
1040
1039
  let body = lowerExpr(e.fallthrough, binds);
1041
1040
  if (wrapOpt)
1042
1041
  body = wrapOptionalBranch(body, e.fallthrough);
1043
- arms.push({ pattern: "_", body });
1042
+ arms.push({ pattern: pWild(), body });
1044
1043
  }
1045
1044
  return { kind: "match", scrutinee: varName ?? scrutinee, arms };
1046
1045
  }
@@ -1082,7 +1081,7 @@ function ensuresToMatch(e, typeDecls) {
1082
1081
  const pattern = buildMatchPattern(variantName, fields, obj.name);
1083
1082
  let rhs = transformExpr(e.right);
1084
1083
  rhs = replaceFieldAccess(rhs, obj.name, fields);
1085
- return { kind: "match", scrutinee: obj.name, arms: [{ pattern, body: rhs }, { pattern: "_", body: { kind: "bool", value: true } }] };
1084
+ return { kind: "match", scrutinee: obj.name, arms: [{ pattern, body: rhs }, { pattern: pWild(), body: { kind: "bool", value: true } }] };
1086
1085
  }
1087
1086
  function replaceFieldAccess(e, varName, fields) {
1088
1087
  return mapExpr(e, x => {
@@ -1269,8 +1268,8 @@ function matchToIfChains(stmts) {
1269
1268
  if (s.kind !== "match")
1270
1269
  return [s];
1271
1270
  const arms = s.arms.map(a => ({ ...a, body: matchToIfChains(a.body) }));
1272
- const ctorArms = arms.filter(a => a.pattern.trim() !== "_");
1273
- const firstCtor = ctorArms[0]?.pattern.trim().split(/\s+/)[0].replace(/^\./, "");
1271
+ const ctorArms = arms.filter(a => a.pattern.kind !== "wild");
1272
+ const firstCtor = ctorArms[0] ? patternCtor(ctorArms[0].pattern) : undefined;
1274
1273
  const decl = firstCtor
1275
1274
  ? _typeDecls.find(d => (d.kind === "discriminated-union" || d.kind === "string-union") &&
1276
1275
  ((d.variants?.some(v => v.name === firstCtor)) || (d.values?.includes(firstCtor))))
@@ -1278,15 +1277,14 @@ function matchToIfChains(stmts) {
1278
1277
  if (!decl)
1279
1278
  return [{ ...s, arms }]; // not a user union (e.g. Option) — leave as match
1280
1279
  const scrutExpr = typeof s.scrutinee === "string" ? { kind: "var", name: s.scrutinee } : s.scrutinee;
1281
- const defaultArm = arms.find(a => a.pattern.trim() === "_");
1280
+ const defaultArm = arms.find(a => a.pattern.kind === "wild");
1282
1281
  let elseBranch = defaultArm ? defaultArm.body : [];
1283
1282
  for (let k = ctorArms.length - 1; k >= 0; k--) {
1284
1283
  const armBody = ctorArms[k].body;
1285
1284
  if (armBody.length === 0)
1286
1285
  continue; // empty arm (no-op) — let it fall through to `else`
1287
- const toks = ctorArms[k].pattern.trim().split(/\s+/);
1288
- const ctor = toks[0].replace(/^\./, "");
1289
- const binders = toks.slice(1);
1286
+ const ctor = patternCtor(ctorArms[k].pattern) ?? "";
1287
+ const binders = patternBinders(ctorArms[k].pattern);
1290
1288
  const variant = decl.variants?.find(v => v.name === ctor);
1291
1289
  // Discriminator condition. A nullary discriminated-union constructor would
1292
1290
  // need `DecidableEq` for `x = .Ctor` (which such unions don't derive), so
@@ -1294,8 +1292,8 @@ function matchToIfChains(stmts) {
1294
1292
  // string-unions derive DecidableEq, so `=` is fine there.
1295
1293
  const cond = decl.kind === "discriminated-union" && binders.length === 0
1296
1294
  ? { kind: "match", scrutinee: scrutExpr, arms: [
1297
- { pattern: `.${ctor}`, body: { kind: "bool", value: true } },
1298
- { pattern: "_", body: { kind: "bool", value: false } }
1295
+ { pattern: pCtor(ctor), body: { kind: "bool", value: true } },
1296
+ { pattern: pWild(), body: { kind: "bool", value: false } }
1299
1297
  ] }
1300
1298
  : { kind: "binop", op: "=", left: scrutExpr, right: { kind: "constructor", name: ctor, type: decl.name } };
1301
1299
  // Bind only the constructor-field binders the body actually uses, pinning the
@@ -1681,8 +1679,8 @@ function transformStmt(s, typeDecls) {
1681
1679
  return [{
1682
1680
  kind: "match", scrutinee,
1683
1681
  arms: [
1684
- { pattern: `.some ${s.binder}`, body: someBody },
1685
- { pattern: ".none", body: noneBody },
1682
+ { pattern: pCtor("some", s.binder), body: someBody },
1683
+ { pattern: pCtor("none"), body: noneBody },
1686
1684
  ],
1687
1685
  }];
1688
1686
  }
@@ -1766,7 +1764,7 @@ function emitMatchStmt(scrutinee, typeName, cases, fallthrough, typeDecls) {
1766
1764
  arms.push({ pattern, body });
1767
1765
  }
1768
1766
  else {
1769
- arms.push({ pattern: "_", body: transformStmts(fallthrough, typeDecls) });
1767
+ arms.push({ pattern: pWild(), body: transformStmts(fallthrough, typeDecls) });
1770
1768
  }
1771
1769
  }
1772
1770
  return { kind: "match", scrutinee: isPath ? transformExpr(scrutinee) : prefix, arms };
@@ -1822,7 +1820,7 @@ function emitSwitchStmt(s, typeDecls) {
1822
1820
  ? buildMatchArms(cases, undefined, ef.enumTyName, typeDecls, (body) => transformStmts(body, typeDecls))
1823
1821
  : buildMatchArms(cases, s.expr.kind === "var" ? s.expr.name : "?", s.expr.ty.kind === "user" ? s.expr.ty.name : undefined, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1824
1822
  if (s.defaultBody.length > 0)
1825
- arms.push({ pattern: "_", body: transformStmts(s.defaultBody, typeDecls) });
1823
+ arms.push({ pattern: pWild(), body: transformStmts(s.defaultBody, typeDecls) });
1826
1824
  return { kind: "match", scrutinee: ef ? ef.scrutinee : (s.expr.kind === "var" ? s.expr.name : "?"), arms };
1827
1825
  }
1828
1826
  /** Replace obj.field → replacement var in typed IR.
@@ -1954,8 +1952,8 @@ function transformPureBody(stmts, typeDecls) {
1954
1952
  return {
1955
1953
  kind: "match", scrutinee,
1956
1954
  arms: [
1957
- { pattern: `.some ${s.binder}`, body: someExpr },
1958
- { pattern: ".none", body: noneExpr },
1955
+ { pattern: pCtor("some", s.binder), body: someExpr },
1956
+ { pattern: pCtor("none"), body: noneExpr },
1959
1957
  ],
1960
1958
  };
1961
1959
  }
@@ -1977,7 +1975,7 @@ function transformPureSwitch(s, typeDecls) {
1977
1975
  const body = transformPureBody(s.defaultBody, typeDecls);
1978
1976
  if (!body)
1979
1977
  return null;
1980
- arms.push({ pattern: "_", body });
1978
+ arms.push({ pattern: pWild(), body });
1981
1979
  }
1982
1980
  return { kind: "match", scrutinee: ef.scrutinee, arms };
1983
1981
  }
@@ -2000,7 +1998,7 @@ function transformPureSwitch(s, typeDecls) {
2000
1998
  const body = transformPureBody(s.defaultBody, typeDecls);
2001
1999
  if (!body)
2002
2000
  return null;
2003
- arms.push({ pattern: "_", body });
2001
+ arms.push({ pattern: pWild(), body });
2004
2002
  }
2005
2003
  if (s.expr.kind !== "var")
2006
2004
  return null;
@@ -2048,7 +2046,7 @@ function transformPureMatch(chain, typeDecls) {
2048
2046
  const body = transformPureBody(chain.fallthrough, typeDecls);
2049
2047
  if (!body)
2050
2048
  return null;
2051
- arms.push({ pattern: "_", body });
2049
+ arms.push({ pattern: pWild(), body });
2052
2050
  }
2053
2051
  }
2054
2052
  return { kind: "match", scrutinee: chain.varName, arms };