lemmascript 0.3.2 → 0.3.3
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/README.md +1 -0
- package/package.json +3 -2
- package/tools/dist/dafny-emit.js +46 -12
- package/tools/dist/extract.js +98 -1
- package/tools/dist/lean-emit.js +2 -0
- package/tools/dist/resolve.js +118 -35
- package/tools/dist/transform.js +283 -62
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Each example and case study is verified in Lean 4 and/or Dafny from the same ann
|
|
|
13
13
|
See the internal [examples](examples).
|
|
14
14
|
|
|
15
15
|
See the external case studies:
|
|
16
|
+
- **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
|
|
16
17
|
- **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
|
|
17
18
|
- **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
|
|
18
19
|
- **[node-casbin-lemmascript](https://github.com/midspiral/node-casbin-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [node-casbin](https://github.com/casbin/node-casbin). 5 functions verified, 217 existing tests pass. End-to-end correctness and order independence for all 4 effect modes in both Lean and Dafny (39 lemmas).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc -p tools/tsconfig.json",
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
|
-
"typecheck": "tsc -p tools/tsconfig.json --noEmit"
|
|
15
|
+
"typecheck": "tsc -p tools/tsconfig.json --noEmit",
|
|
16
|
+
"typecheck:examples": "tsc -p examples/tsconfig.json"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"ts-morph": "^25.0.0"
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -325,6 +325,8 @@ function emitExpr(e) {
|
|
|
325
325
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
326
326
|
return `${ctorName}(${vals.join(", ")})`;
|
|
327
327
|
}
|
|
328
|
+
if (e.fields.length === 0)
|
|
329
|
+
return `map[]`;
|
|
328
330
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
329
331
|
return `(${vals.join(", ")})`;
|
|
330
332
|
}
|
|
@@ -473,6 +475,8 @@ function emitDecl(d) {
|
|
|
473
475
|
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
474
476
|
for (const r of d.requires)
|
|
475
477
|
lines.push(` requires ${emitExpr(r)}`);
|
|
478
|
+
if (d.decreases)
|
|
479
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
476
480
|
lines.push(`{`);
|
|
477
481
|
lines.push(emitPureExpr(d.body, 1));
|
|
478
482
|
lines.push(`}`);
|
|
@@ -491,6 +495,20 @@ function emitDecl(d) {
|
|
|
491
495
|
}
|
|
492
496
|
return lines.join("\n");
|
|
493
497
|
}
|
|
498
|
+
case "def-by-method": {
|
|
499
|
+
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
500
|
+
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
501
|
+
for (const r of d.requires)
|
|
502
|
+
lines.push(` requires ${emitExpr(r)}`);
|
|
503
|
+
if (d.decreases)
|
|
504
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
505
|
+
lines.push(`{`);
|
|
506
|
+
lines.push(`}`);
|
|
507
|
+
lines.push(`by method {`);
|
|
508
|
+
lines.push(emitStmts(d.methodBody, 1));
|
|
509
|
+
lines.push(`}`);
|
|
510
|
+
return lines.join("\n");
|
|
511
|
+
}
|
|
494
512
|
case "method": {
|
|
495
513
|
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
496
514
|
const lines = [`method ${d.name}${tp}(${paramList(d.params)}) returns (res: ${tyToDafny(d.returnType)})`];
|
|
@@ -656,6 +674,7 @@ const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
|
|
|
656
674
|
const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
657
675
|
ensures forall x :: x in s <==> x in res
|
|
658
676
|
ensures |res| == |s|
|
|
677
|
+
ensures forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
659
678
|
{
|
|
660
679
|
var remaining := s;
|
|
661
680
|
res := [];
|
|
@@ -663,6 +682,7 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
|
663
682
|
invariant remaining <= s
|
|
664
683
|
invariant forall x :: x in res <==> (x in s && x !in remaining)
|
|
665
684
|
invariant |res| + |remaining| == |s|
|
|
685
|
+
invariant forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
666
686
|
decreases remaining
|
|
667
687
|
{
|
|
668
688
|
var x :| x in remaining;
|
|
@@ -760,26 +780,40 @@ function translatePattern(pattern) {
|
|
|
760
780
|
export function emitDafnyFile(file, tsFileName) {
|
|
761
781
|
buildRecordCtorMap(file.decls);
|
|
762
782
|
_neededPreambles.clear();
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if (d.kind === "namespace") {
|
|
767
|
-
for (const inner of d.decls)
|
|
768
|
-
if (inner.kind === "def")
|
|
769
|
-
pureDefs.add(inner.name);
|
|
770
|
-
}
|
|
771
|
-
if (d.kind === "def")
|
|
772
|
-
pureDefs.add(d.name);
|
|
773
|
-
}
|
|
783
|
+
// Track successfully emitted pure defs — method wrappers are only
|
|
784
|
+
// skipped when the corresponding pure def was actually emitted.
|
|
785
|
+
const emittedPureDefs = new Set();
|
|
774
786
|
// Emit declarations
|
|
775
787
|
const declLines = [];
|
|
776
788
|
const skipped = [];
|
|
777
789
|
for (const decl of file.decls) {
|
|
778
|
-
if (decl.kind === "method" &&
|
|
790
|
+
if (decl.kind === "method" && emittedPureDefs.has(decl.name))
|
|
779
791
|
continue;
|
|
792
|
+
if (decl.kind === "namespace") {
|
|
793
|
+
// Emit each inner decl individually — if one fails, the rest survive
|
|
794
|
+
// and failed defs fall back to their method wrappers
|
|
795
|
+
for (const inner of decl.decls) {
|
|
796
|
+
try {
|
|
797
|
+
declLines.push("");
|
|
798
|
+
declLines.push(emitDecl(inner));
|
|
799
|
+
if (inner.kind === "def")
|
|
800
|
+
emittedPureDefs.add(inner.name);
|
|
801
|
+
}
|
|
802
|
+
catch (e) {
|
|
803
|
+
const name = "name" in inner ? inner.name : "unknown";
|
|
804
|
+
const msg = e.message;
|
|
805
|
+
console.error(`WARNING: skipping pure '${name}': ${msg}`);
|
|
806
|
+
declLines.push(`\n// LemmaScript: skipped pure ${name}`);
|
|
807
|
+
skipped.push(name);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
780
812
|
try {
|
|
781
813
|
declLines.push("");
|
|
782
814
|
declLines.push(emitDecl(decl));
|
|
815
|
+
if (decl.kind === "def-by-method")
|
|
816
|
+
emittedPureDefs.add(decl.name);
|
|
783
817
|
}
|
|
784
818
|
catch (e) {
|
|
785
819
|
const name = "name" in decl ? decl.name : "unknown";
|
package/tools/dist/extract.js
CHANGED
|
@@ -8,6 +8,13 @@ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
|
|
|
8
8
|
// ── Expression extraction ────────────────────────────────────
|
|
9
9
|
/** When set, calls whose function/method name matches this key are replaced with havoc. */
|
|
10
10
|
let _havocKey = null;
|
|
11
|
+
/**
|
|
12
|
+
* Maps property-name fingerprints to type alias names for collapsed single-variant unions.
|
|
13
|
+
* TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
|
|
14
|
+
* causing getAliasSymbol() to return null. This map lets typeToString recover the alias.
|
|
15
|
+
* Populated in extractModule before type extraction; used by typeToString.
|
|
16
|
+
*/
|
|
17
|
+
let _collapsedUnionMap = new Map();
|
|
11
18
|
/** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
|
|
12
19
|
let _typeParamMap = new Map();
|
|
13
20
|
function _eraseGenerics(tsType) {
|
|
@@ -106,6 +113,14 @@ function extractExpr(node) {
|
|
|
106
113
|
}
|
|
107
114
|
// Call expression: f(a, b)
|
|
108
115
|
if (Node.isCallExpression(node)) {
|
|
116
|
+
// Object.fromEntries(map) → identity (Map IS Record in Dafny)
|
|
117
|
+
const callee = node.getExpression();
|
|
118
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
119
|
+
callee.getExpression().getText() === "Object" &&
|
|
120
|
+
callee.getName() === "fromEntries" &&
|
|
121
|
+
node.getArguments().length === 1) {
|
|
122
|
+
return extractExpr(node.getArguments()[0]);
|
|
123
|
+
}
|
|
109
124
|
return {
|
|
110
125
|
kind: "call",
|
|
111
126
|
fn: extractExpr(node.getExpression()),
|
|
@@ -318,6 +333,17 @@ function collectAnnotations(node, body) {
|
|
|
318
333
|
return [...own, ...parseAnnotations(body[0])];
|
|
319
334
|
return own;
|
|
320
335
|
}
|
|
336
|
+
/** Check for bare `//@ pure` annotation (no expression). */
|
|
337
|
+
function hasPureAnnotation(node, body) {
|
|
338
|
+
const nodes = body && body.length > 0 ? [node, body[0]] : [node];
|
|
339
|
+
for (const n of nodes) {
|
|
340
|
+
for (const range of n.getLeadingCommentRanges()) {
|
|
341
|
+
if (range.getText().trim() === "//@ pure")
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
321
347
|
// ── Type declaration extraction ──────────────────────────────
|
|
322
348
|
function extractTypeDecl(decl, extraDecls) {
|
|
323
349
|
const name = decl.getName();
|
|
@@ -349,6 +375,30 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
349
375
|
}
|
|
350
376
|
}
|
|
351
377
|
}
|
|
378
|
+
// Single-variant discriminated union: type X = | { kind: 'Foo', ... }
|
|
379
|
+
// TypeScript collapses single-member unions to their member type,
|
|
380
|
+
// so type.isUnion() returns false. Detect by checking the source text
|
|
381
|
+
// for union syntax '|' AND a string-literal discriminant field.
|
|
382
|
+
if (type.isObject() && !type.isIntersection()) {
|
|
383
|
+
const srcText = decl.getTypeNode()?.getText() ?? "";
|
|
384
|
+
if (srcText.includes("|")) {
|
|
385
|
+
const disc = findDiscriminant([type]);
|
|
386
|
+
if (disc) {
|
|
387
|
+
const tagProp = type.getProperty(disc);
|
|
388
|
+
const tagType = tagProp?.getTypeAtLocation(decl);
|
|
389
|
+
const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue()) : null;
|
|
390
|
+
if (tag) {
|
|
391
|
+
const fields = [];
|
|
392
|
+
for (const prop of type.getProperties()) {
|
|
393
|
+
if (prop.getName() === disc)
|
|
394
|
+
continue;
|
|
395
|
+
fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
|
|
396
|
+
}
|
|
397
|
+
return { name, typeParams: tpField, kind: "discriminated-union", discriminant: disc, variants: [{ name: tag, fields }] };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
352
402
|
if (type.isObject() || type.isIntersection())
|
|
353
403
|
return extractRecord(name, type, decl, undefined, extraDecls);
|
|
354
404
|
// Primitive type alias: type TaskId = number → alias
|
|
@@ -461,6 +511,13 @@ function typeToString(type) {
|
|
|
461
511
|
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
|
462
512
|
if (symbol) {
|
|
463
513
|
const name = symbol.getName();
|
|
514
|
+
// Recover collapsed single-variant union alias via property fingerprint
|
|
515
|
+
if (name === "__type" && _collapsedUnionMap.size > 0) {
|
|
516
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
517
|
+
const alias = _collapsedUnionMap.get(props);
|
|
518
|
+
if (alias)
|
|
519
|
+
return alias;
|
|
520
|
+
}
|
|
464
521
|
const typeArgs = type.getTypeArguments();
|
|
465
522
|
if (typeArgs.length > 0) {
|
|
466
523
|
return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
|
|
@@ -632,13 +689,29 @@ function extractStmts(stmts) {
|
|
|
632
689
|
else {
|
|
633
690
|
names.push("_");
|
|
634
691
|
}
|
|
692
|
+
// Unwrap Object.entries(expr) / Object.values(expr) to bare map iteration
|
|
693
|
+
let iterableExpr = s.getExpression();
|
|
694
|
+
if (Node.isCallExpression(iterableExpr)) {
|
|
695
|
+
const callee = iterableExpr.getExpression();
|
|
696
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
697
|
+
callee.getExpression().getText() === "Object") {
|
|
698
|
+
const method = callee.getName();
|
|
699
|
+
if ((method === "entries" || method === "values") && iterableExpr.getArguments().length === 1) {
|
|
700
|
+
iterableExpr = iterableExpr.getArguments()[0];
|
|
701
|
+
// Object.values with single name → prepend "_" so it looks like [_, v] destructuring
|
|
702
|
+
if (method === "values" && names.length === 1) {
|
|
703
|
+
names.unshift("_");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
635
708
|
const bodyNode = s.getStatement();
|
|
636
709
|
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
637
710
|
const annots = collectAnnotations(s, bodyStmts);
|
|
638
711
|
result.push({
|
|
639
712
|
kind: "forof",
|
|
640
713
|
names,
|
|
641
|
-
iterable: extractExpr(
|
|
714
|
+
iterable: extractExpr(iterableExpr),
|
|
642
715
|
invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
|
|
643
716
|
doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
|
|
644
717
|
body: extractStmts(bodyStmts),
|
|
@@ -864,6 +937,8 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
864
937
|
})(),
|
|
865
938
|
requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
|
|
866
939
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
940
|
+
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
941
|
+
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
867
942
|
typeAnnotations,
|
|
868
943
|
body: extractedBody,
|
|
869
944
|
line: fn.getStartLineNumber(),
|
|
@@ -907,6 +982,22 @@ export function extractModule(sourceFile) {
|
|
|
907
982
|
typeDecls.push({ name, kind: "record", fields });
|
|
908
983
|
}
|
|
909
984
|
}
|
|
985
|
+
// Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
|
|
986
|
+
// TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
|
|
987
|
+
// the alias. We record a fingerprint (sorted property names) → alias name mapping.
|
|
988
|
+
_collapsedUnionMap = new Map();
|
|
989
|
+
for (const stmt of sourceFile.getStatements()) {
|
|
990
|
+
if (Node.isTypeAliasDeclaration(stmt)) {
|
|
991
|
+
const type = stmt.getType();
|
|
992
|
+
if (!type.isUnion() && type.isObject() && !type.isIntersection()) {
|
|
993
|
+
const srcText = stmt.getTypeNode()?.getText() ?? "";
|
|
994
|
+
if (srcText.includes("|") && findDiscriminant([type])) {
|
|
995
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
996
|
+
_collapsedUnionMap.set(props, stmt.getName());
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
910
1001
|
// Extract type declarations in source order to respect dependencies
|
|
911
1002
|
// Skip types already declared via //@ declare-type
|
|
912
1003
|
const declaredNames = new Set(typeDecls.map(d => d.name));
|
|
@@ -1266,6 +1357,12 @@ export function extractModule(sourceFile) {
|
|
|
1266
1357
|
}
|
|
1267
1358
|
const sym = retType.getSymbol();
|
|
1268
1359
|
if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
|
|
1360
|
+
// Try typeToString first — it resolves collapsed single-variant unions
|
|
1361
|
+
const resolved = typeToString(retType);
|
|
1362
|
+
if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
|
|
1363
|
+
fn.returnType = resolved;
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1269
1366
|
const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
|
|
1270
1367
|
if (!knownTypes.has(synName)) {
|
|
1271
1368
|
const extra = [];
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -325,6 +325,8 @@ function emitDecl(d) {
|
|
|
325
325
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
326
326
|
return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
|
|
327
327
|
}
|
|
328
|
+
case "def-by-method":
|
|
329
|
+
throw new Error("function by method is not supported for Lean backend");
|
|
328
330
|
case "method": {
|
|
329
331
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
330
332
|
const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
|
package/tools/dist/resolve.js
CHANGED
|
@@ -108,7 +108,9 @@ function wrapSome(value, optionalTy) {
|
|
|
108
108
|
}
|
|
109
109
|
/** Detect `v !== undefined` or `undefined !== v` where v: optional<T>.
|
|
110
110
|
* Handles simple variables, field access chains, and arbitrary expressions.
|
|
111
|
-
* When `fieldExpr` is returned
|
|
111
|
+
* When `fieldExpr` is returned for a simple field chain (obj.field), callers
|
|
112
|
+
* can narrow via `narrowedFields` context. For complex expressions (calls, etc.),
|
|
113
|
+
* callers should fall back to `substituteRawExpr`.
|
|
112
114
|
*
|
|
113
115
|
* Does NOT recurse into `&&` — callers that need to detect optional checks
|
|
114
116
|
* inside `&&` conditions should check `cond.left` explicitly. */
|
|
@@ -141,6 +143,19 @@ function detectOptionalCheck(cond, ctx) {
|
|
|
141
143
|
}
|
|
142
144
|
return null;
|
|
143
145
|
}
|
|
146
|
+
/** Collect all optional narrowings from an early-return condition.
|
|
147
|
+
* Handles single checks (x === undefined) and compound || chains
|
|
148
|
+
* (x === undefined || y === undefined). */
|
|
149
|
+
function collectEarlyReturnNarrowings(cond, ctx) {
|
|
150
|
+
if (cond.kind === "binop" && cond.op === "||") {
|
|
151
|
+
return [...collectEarlyReturnNarrowings(cond.left, ctx), ...collectEarlyReturnNarrowings(cond.right, ctx)];
|
|
152
|
+
}
|
|
153
|
+
const narrowed = detectOptionalCheck(cond, ctx);
|
|
154
|
+
if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
|
|
155
|
+
return [{ varName: narrowed.varName, innerTy: narrowed.innerTy }];
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
144
159
|
/** TS reference types that become value types in Dafny/Lean — const bindings need mutable var. */
|
|
145
160
|
function isRefMutableInTS(ty) {
|
|
146
161
|
return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
|
|
@@ -389,8 +404,21 @@ function resolveExpr(e, ctx) {
|
|
|
389
404
|
fn.obj.ty.elem.kind === "user") {
|
|
390
405
|
argCtx = { ...ctx, returnTy: fn.obj.ty.elem };
|
|
391
406
|
}
|
|
392
|
-
|
|
393
|
-
|
|
407
|
+
// Propagate parameter types to arguments for record literal resolution
|
|
408
|
+
// (enables inline discriminated union construction in function arguments)
|
|
409
|
+
const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
|
|
410
|
+
const args = coerceCallArgs(rawArgs.map((a, i) => {
|
|
411
|
+
let aCtx = argCtx;
|
|
412
|
+
if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
|
|
413
|
+
aCtx = { ...aCtx, returnTy: paramTypes[i] };
|
|
414
|
+
}
|
|
415
|
+
return resolveExpr(a, aCtx);
|
|
416
|
+
}), fn, ctx);
|
|
417
|
+
let ty = inferMethodReturnTy(fn, args, ctx);
|
|
418
|
+
// For same-file function calls, use the known return type
|
|
419
|
+
if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
|
|
420
|
+
ty = ctx.fnReturns.get(fn.name);
|
|
421
|
+
}
|
|
394
422
|
return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
|
|
395
423
|
}
|
|
396
424
|
case "index": {
|
|
@@ -405,16 +433,24 @@ function resolveExpr(e, ctx) {
|
|
|
405
433
|
const obj = resolveExpr(e.obj, ctx);
|
|
406
434
|
let isDiscriminant = false;
|
|
407
435
|
let ty = { kind: "unknown" };
|
|
408
|
-
|
|
436
|
+
// Check narrowed field context (from conditional optional checks on field chains)
|
|
437
|
+
if (obj.kind === "var" && ctx.narrowedFields.length > 0) {
|
|
438
|
+
const nf = ctx.narrowedFields.find(n => n.objName === obj.name && n.fieldName === e.field);
|
|
439
|
+
if (nf)
|
|
440
|
+
ty = nf.narrowedTy;
|
|
441
|
+
}
|
|
442
|
+
if (ty.kind === "unknown" && e.field === "length" && (obj.ty.kind === "array" || obj.ty.kind === "string")) {
|
|
409
443
|
ty = { kind: "nat" };
|
|
410
444
|
}
|
|
411
|
-
else if (e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
|
|
445
|
+
else if (ty.kind === "unknown" && e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
|
|
412
446
|
ty = { kind: "nat" };
|
|
413
447
|
}
|
|
414
|
-
else if (obj.ty.kind === "user") {
|
|
415
|
-
|
|
448
|
+
else if (ty.kind === "unknown" && obj.ty.kind === "user") {
|
|
449
|
+
// Strip generic args for type lookup: "Result<Model, Err>" → "Result"
|
|
450
|
+
const baseTyName = obj.ty.name.includes("<") ? obj.ty.name.slice(0, obj.ty.name.indexOf("<")) : obj.ty.name;
|
|
451
|
+
if (getDiscriminant(ctx, baseTyName) === e.field)
|
|
416
452
|
isDiscriminant = true;
|
|
417
|
-
const decl = findDecl(ctx,
|
|
453
|
+
const decl = findDecl(ctx, baseTyName);
|
|
418
454
|
if (decl?.kind === "record") {
|
|
419
455
|
const f = decl.fields?.find(f => f.name === e.field);
|
|
420
456
|
if (f)
|
|
@@ -442,8 +478,13 @@ function resolveExpr(e, ctx) {
|
|
|
442
478
|
// Clear returnTy for field values — it applies to THIS record, not nested ones
|
|
443
479
|
const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
|
|
444
480
|
const fields = e.fields.map(f => {
|
|
445
|
-
let value = resolveExpr(f.value, fieldCtx);
|
|
446
481
|
const fieldDecl = decl?.fields?.find(df => df.name === f.name);
|
|
482
|
+
// Propagate declared field type into context so nested records resolve
|
|
483
|
+
// their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle)
|
|
484
|
+
const valueCtx = (fieldDecl?.type?.kind === "user")
|
|
485
|
+
? { ...fieldCtx, returnTy: fieldDecl.type }
|
|
486
|
+
: fieldCtx;
|
|
487
|
+
let value = resolveExpr(f.value, valueCtx);
|
|
447
488
|
if (fieldDecl) {
|
|
448
489
|
const declTy = fieldDecl.type;
|
|
449
490
|
value = coerceStr(value, declTy);
|
|
@@ -499,7 +540,6 @@ function resolveExpr(e, ctx) {
|
|
|
499
540
|
case "conditional": {
|
|
500
541
|
const cond = resolveExpr(e.cond, ctx);
|
|
501
542
|
let narrowedVar;
|
|
502
|
-
let narrowedExprResolved;
|
|
503
543
|
let thenCtx = ctx;
|
|
504
544
|
let rawThen = e.then;
|
|
505
545
|
// Phase 1: Optional truthiness — cond itself is optional (e.g. opt ? X : Y)
|
|
@@ -515,23 +555,56 @@ function resolveExpr(e, ctx) {
|
|
|
515
555
|
thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, innerTy));
|
|
516
556
|
}
|
|
517
557
|
}
|
|
518
|
-
// Phase 2: Explicit check — v !== undefined
|
|
519
|
-
//
|
|
558
|
+
// Phase 2/3: Explicit check — v !== undefined, or && with optional check.
|
|
559
|
+
// Resolve only narrows the type environment; transform handles all structural
|
|
560
|
+
// narrowing (match generation, variable binding, && splitting).
|
|
561
|
+
let narrowedExprResolved;
|
|
562
|
+
let elseCtx = ctx;
|
|
520
563
|
if (!narrowedVar) {
|
|
521
564
|
const narrowed = detectOptionalCheck(e.cond, ctx)
|
|
522
|
-
// Phase 3: && with optional check — (v !== undefined && ...) ? ... : ...
|
|
523
565
|
?? (e.cond.kind === "binop" && e.cond.op === "&&" ? detectOptionalCheck(e.cond.left, ctx) : null);
|
|
524
566
|
if (narrowed && narrowed.inThen) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
567
|
+
if (!narrowed.fieldExpr) {
|
|
568
|
+
// Simple var: extend env only — transform will detect and generate match
|
|
569
|
+
thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
|
|
570
|
+
}
|
|
571
|
+
else if (narrowed.fieldExpr.kind === "field" && narrowed.fieldExpr.obj.kind === "var") {
|
|
572
|
+
// Simple field chain (obj.field): narrow via field context — no substitution
|
|
573
|
+
thenCtx = {
|
|
574
|
+
...thenCtx,
|
|
575
|
+
narrowedFields: [...thenCtx.narrowedFields, {
|
|
576
|
+
objName: narrowed.fieldExpr.obj.name,
|
|
577
|
+
fieldName: narrowed.fieldExpr.field,
|
|
578
|
+
narrowedTy: narrowed.innerTy,
|
|
579
|
+
}],
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
// Complex expression (call result, deep chain, etc.): transform can't detect these,
|
|
584
|
+
// so keep old behavior — substitute + narrowedVar + narrowedExpr
|
|
585
|
+
narrowedVar = narrowed.varName;
|
|
528
586
|
narrowedExprResolved = narrowed.narrowedExpr ?? resolveExpr(narrowed.fieldExpr, ctx);
|
|
529
587
|
rawThen = substituteRawExpr(e.then, narrowed.fieldExpr, { kind: "var", name: narrowed.varName });
|
|
588
|
+
thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
|
|
592
|
+
// v === undefined: narrow v in the else branch
|
|
593
|
+
elseCtx = withEnv(elseCtx, extend(elseCtx.env, narrowed.varName, narrowed.innerTy));
|
|
594
|
+
}
|
|
595
|
+
// Compound || with === undefined: narrow all checked vars in else branch
|
|
596
|
+
// e.g. if (a === undefined || b === undefined) then X else Y → narrow a,b in Y
|
|
597
|
+
// TODO: resolve-time narrowing works but transform doesn't emit match unwrap
|
|
598
|
+
// for || conditions yet — decompose || into nested matches in transform.
|
|
599
|
+
// Workaround: split || into separate if guards in user code.
|
|
600
|
+
if (!narrowed && e.cond.kind === "binop" && e.cond.op === "||") {
|
|
601
|
+
for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
|
|
602
|
+
elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
|
|
530
603
|
}
|
|
531
604
|
}
|
|
532
605
|
}
|
|
533
606
|
let then_ = resolveExpr(rawThen, thenCtx);
|
|
534
|
-
let else_ = resolveExpr(e.else,
|
|
607
|
+
let else_ = resolveExpr(e.else, elseCtx);
|
|
535
608
|
then_ = coerceStr(then_, else_.ty);
|
|
536
609
|
else_ = coerceStr(else_, then_.ty);
|
|
537
610
|
let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
|
|
@@ -543,8 +616,7 @@ function resolveExpr(e, ctx) {
|
|
|
543
616
|
ty = { kind: "optional", inner: then_.ty };
|
|
544
617
|
}
|
|
545
618
|
// When narrowedExpr is set AND a branch is void, the match produces Optional
|
|
546
|
-
|
|
547
|
-
if (narrowedExprResolved && hasVoidBranch && ty.kind !== "optional") {
|
|
619
|
+
if (narrowedExprResolved && (then_.ty.kind === "void" || else_.ty.kind === "void") && ty.kind !== "optional") {
|
|
548
620
|
ty = { kind: "optional", inner: ty };
|
|
549
621
|
}
|
|
550
622
|
return { kind: "conditional", cond, then: then_, else: else_, ty, narrowedVar, narrowedExpr: narrowedExprResolved };
|
|
@@ -585,12 +657,13 @@ function resolveBlock(stmts, ctx) {
|
|
|
585
657
|
result.push(typed);
|
|
586
658
|
env = nextEnv;
|
|
587
659
|
// Flow narrowing: if (x === undefined) { return } narrows x for rest of block.
|
|
660
|
+
// Also handles compound: if (x === undefined || y === undefined) { return }
|
|
588
661
|
// Field chains are excluded — resolve can't substitute in statement lists;
|
|
589
662
|
// transform's emitOptionalMatch handles field chains in statement contexts.
|
|
590
663
|
if (s.kind === "if" && s.then.length > 0 && s.then[s.then.length - 1].kind === "return" && s.else.length === 0) {
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
env = extend(env,
|
|
664
|
+
const narrowings = collectEarlyReturnNarrowings(s.cond, withEnv(ctx, env));
|
|
665
|
+
for (const n of narrowings) {
|
|
666
|
+
env = extend(env, n.varName, n.innerTy);
|
|
594
667
|
}
|
|
595
668
|
}
|
|
596
669
|
}
|
|
@@ -600,7 +673,10 @@ function resolveStmt(s, ctx) {
|
|
|
600
673
|
switch (s.kind) {
|
|
601
674
|
case "let": {
|
|
602
675
|
const declTy = resolveTsType(s.tsType, ctx.overrides, s.name);
|
|
603
|
-
|
|
676
|
+
// Propagate declared type as returnTy so nested record expressions
|
|
677
|
+
// resolve union variants correctly (e.g., EffectState → mode: EffectMode → { kind: 'Idle' })
|
|
678
|
+
const initCtx = declTy.kind === "user" ? { ...ctx, returnTy: declTy } : ctx;
|
|
679
|
+
const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
|
|
604
680
|
// Map indexing: TS says T, but access can fail → use Optional<T> from init
|
|
605
681
|
const ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
|
|
606
682
|
// const collections are mutable in value-semantics world (TS mutates in place, Dafny/Lean reassign)
|
|
@@ -833,6 +909,8 @@ function collectCallsStmts(stmts, fns, out) {
|
|
|
833
909
|
}
|
|
834
910
|
function computePureFns(functions) {
|
|
835
911
|
const allFnNames = new Set(functions.map(fn => fn.name));
|
|
912
|
+
// //@ pure functions are always considered pure — never taint callers
|
|
913
|
+
const forcePure = new Set(functions.filter(fn => fn.pure).map(fn => fn.name));
|
|
836
914
|
// Build call graph: fn → set of same-file functions it calls
|
|
837
915
|
const callGraph = new Map();
|
|
838
916
|
for (const fn of functions) {
|
|
@@ -840,8 +918,8 @@ function computePureFns(functions) {
|
|
|
840
918
|
collectCallsStmts(fn.body, allFnNames, calls);
|
|
841
919
|
callGraph.set(fn.name, calls);
|
|
842
920
|
}
|
|
843
|
-
// Seed: syntactically non-pure functions
|
|
844
|
-
const nonPure = new Set(functions.filter(fn => !isSyntacticallyPure(fn.body)).map(fn => fn.name));
|
|
921
|
+
// Seed: syntactically non-pure functions (skip //@ pure)
|
|
922
|
+
const nonPure = new Set(functions.filter(fn => !forcePure.has(fn.name) && !isSyntacticallyPure(fn.body)).map(fn => fn.name));
|
|
845
923
|
// Build reverse graph: fn → set of functions that call it
|
|
846
924
|
const callers = new Map();
|
|
847
925
|
for (const name of allFnNames)
|
|
@@ -850,12 +928,12 @@ function computePureFns(functions) {
|
|
|
850
928
|
for (const callee of callees)
|
|
851
929
|
callers.get(callee).add(caller);
|
|
852
930
|
}
|
|
853
|
-
// Propagate impurity through reverse call graph
|
|
931
|
+
// Propagate impurity through reverse call graph (skip //@ pure)
|
|
854
932
|
const worklist = [...nonPure];
|
|
855
933
|
while (worklist.length > 0) {
|
|
856
934
|
const fn = worklist.pop();
|
|
857
935
|
for (const caller of callers.get(fn) ?? []) {
|
|
858
|
-
if (!nonPure.has(caller)) {
|
|
936
|
+
if (!nonPure.has(caller) && !forcePure.has(caller)) {
|
|
859
937
|
nonPure.add(caller);
|
|
860
938
|
worklist.push(caller);
|
|
861
939
|
}
|
|
@@ -888,7 +966,7 @@ function containsReturn(stmts) {
|
|
|
888
966
|
return false;
|
|
889
967
|
}
|
|
890
968
|
// ── Resolve function / module ────────────────────────────────
|
|
891
|
-
function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
|
|
969
|
+
function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), opts) {
|
|
892
970
|
const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
|
|
893
971
|
const params = fn.params.map(p => ({ name: p.name, ty: resolveTsType(p.tsType, overrides, p.name) }));
|
|
894
972
|
const returnTy = resolveTsType(fn.returnType, overrides, "\\result");
|
|
@@ -897,7 +975,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
|
|
|
897
975
|
env = extend(env, opts.thisBinding.name, opts.thisBinding.ty);
|
|
898
976
|
for (const p of params)
|
|
899
977
|
env = extend(env, p.name, p.ty);
|
|
900
|
-
const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, inSpec: false, inLambda: false };
|
|
978
|
+
const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
|
|
901
979
|
const requiresCtx = { ...baseCtx, inSpec: true };
|
|
902
980
|
const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
|
|
903
981
|
// Apply type parameter constraints from //@ type T (==) annotations
|
|
@@ -909,17 +987,19 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
|
|
|
909
987
|
name: fn.name, typeParams, params, returnTy,
|
|
910
988
|
requires: resolveSpecs(fn.requires, requiresCtx),
|
|
911
989
|
ensures: resolveSpecs(fn.ensures, ensuresCtx),
|
|
990
|
+
decreases: fn.decreases ? resolveSpec(fn.decreases, requiresCtx) : null,
|
|
912
991
|
isPure: opts?.forcePure !== undefined ? opts.forcePure : pureFns.has(fn.name),
|
|
992
|
+
forcePure: fn.pure,
|
|
913
993
|
body: resolveBlock(fn.body, baseCtx),
|
|
914
994
|
};
|
|
915
995
|
}
|
|
916
|
-
function resolveClass(cls, typeDecls, pureFns, fnParams = new Map()) {
|
|
996
|
+
function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map()) {
|
|
917
997
|
const fields = cls.fields.map(f => ({ name: f.name, ty: parseTsType(f.tsType) }));
|
|
918
998
|
// Create a synthetic record type for 'this' so field access resolves
|
|
919
999
|
const thisType = { kind: "user", name: cls.name };
|
|
920
1000
|
const thisDecl = { name: cls.name, kind: "record", fields: cls.fields.map(f => ({ name: f.name, tsType: f.tsType, type: parseTsType(f.tsType) })) };
|
|
921
1001
|
const allTypeDecls = [...typeDecls, thisDecl];
|
|
922
|
-
const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, {
|
|
1002
|
+
const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, fnReturns, {
|
|
923
1003
|
thisBinding: { name: "this", ty: thisType },
|
|
924
1004
|
forcePure: false, // class methods are never pure (they access this)
|
|
925
1005
|
}));
|
|
@@ -941,15 +1021,18 @@ function precomputeFieldTypes(typeDecls) {
|
|
|
941
1021
|
}
|
|
942
1022
|
}
|
|
943
1023
|
export function resolveModule(raw) {
|
|
1024
|
+
_synVarCounter = 0;
|
|
944
1025
|
precomputeFieldTypes(raw.typeDecls);
|
|
945
1026
|
const pureFns = computePureFns(raw.functions);
|
|
946
|
-
// Pre-compute function parameter
|
|
1027
|
+
// Pre-compute function parameter and return types
|
|
947
1028
|
const fnParams = new Map();
|
|
1029
|
+
const fnReturns = new Map();
|
|
948
1030
|
for (const fn of raw.functions) {
|
|
949
1031
|
const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
|
|
950
1032
|
fnParams.set(fn.name, fn.params.map(p => resolveTsType(p.tsType, overrides, p.name)));
|
|
1033
|
+
fnReturns.set(fn.name, resolveTsType(fn.returnType, overrides, "\\result"));
|
|
951
1034
|
}
|
|
952
|
-
const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, inSpec: false, inLambda: false };
|
|
1035
|
+
const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
|
|
953
1036
|
const constants = (raw.constants ?? []).map(c => ({
|
|
954
1037
|
name: c.name,
|
|
955
1038
|
ty: parseTsType(c.tsType),
|
|
@@ -959,7 +1042,7 @@ export function resolveModule(raw) {
|
|
|
959
1042
|
file: raw.file,
|
|
960
1043
|
typeDecls: raw.typeDecls,
|
|
961
1044
|
constants,
|
|
962
|
-
functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams)),
|
|
963
|
-
classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams)),
|
|
1045
|
+
functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams, fnReturns)),
|
|
1046
|
+
classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams, fnReturns)),
|
|
964
1047
|
};
|
|
965
1048
|
}
|
package/tools/dist/transform.js
CHANGED
|
@@ -58,7 +58,10 @@ function mapStmt(s, f) {
|
|
|
58
58
|
case "break":
|
|
59
59
|
case "continue": return s;
|
|
60
60
|
case "if": return { ...s, cond: r(s.cond), then: s.then.map(t => mapStmt(t, f)), else: s.else.map(t => mapStmt(t, f)) };
|
|
61
|
-
case "match":
|
|
61
|
+
case "match": {
|
|
62
|
+
const scr = typeof s.scrutinee === "string" ? s.scrutinee : r(s.scrutinee);
|
|
63
|
+
return { ...s, scrutinee: scr, arms: s.arms.map(a => ({ ...a, body: a.body.map(t => mapStmt(t, f)) })) };
|
|
64
|
+
}
|
|
62
65
|
case "while": return { ...s, cond: r(s.cond), invariants: s.invariants.map(r), body: s.body.map(t => mapStmt(t, f)) };
|
|
63
66
|
case "forin": return { ...s, bound: r(s.bound), invariants: s.invariants.map(r), body: s.body.map(t => mapStmt(t, f)) };
|
|
64
67
|
case "ghostLet": return { ...s, value: r(s.value) };
|
|
@@ -347,6 +350,18 @@ function lowerExpr(e, binds) {
|
|
|
347
350
|
return { kind: "field", obj: transformExpr(e.obj), field: "length" };
|
|
348
351
|
if (e.field === "size" && (e.obj.ty.kind === "map" || e.obj.ty.kind === "set"))
|
|
349
352
|
return { kind: "field", obj: transformExpr(e.obj), field: "collectionSize" };
|
|
353
|
+
// Boolean discriminant bare access: `result.ok` where Result has variants
|
|
354
|
+
// {ok: true, ...} | {ok: false, ...}. String discriminants are always used via
|
|
355
|
+
// comparison (x.kind === 'Foo' → x.Foo?), but boolean discriminants are used
|
|
356
|
+
// as bare truthiness checks. Emit as the Dafny discriminator predicate for the
|
|
357
|
+
// 'true' variant: result.ok → result.true_?
|
|
358
|
+
if (e.isDiscriminant && e.obj.ty.kind === "user") {
|
|
359
|
+
const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
|
|
360
|
+
const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
|
|
361
|
+
if (decl?.variants?.some(v => v.name === "true")) {
|
|
362
|
+
return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
350
365
|
return { kind: "field", obj: transformExpr(e.obj), field: e.field };
|
|
351
366
|
case "index": {
|
|
352
367
|
const idx = transformExpr(e.idx);
|
|
@@ -397,6 +412,15 @@ function lowerExpr(e, binds) {
|
|
|
397
412
|
if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
|
|
398
413
|
method = "getDirect";
|
|
399
414
|
}
|
|
415
|
+
// map.set(k, v): if v is an Optional-wrapped map get, unwrap to getDirect
|
|
416
|
+
// (the desugared spread { ...m, [k]: m2[k] } becomes m.set(k, m2.get(k)),
|
|
417
|
+
// but the value should be direct access, not Optional)
|
|
418
|
+
if (method === "set" && e.fn.obj.ty.kind === "map" && args.length === 2) {
|
|
419
|
+
const val = args[1];
|
|
420
|
+
if (val.kind === "methodCall" && val.method === "get" && val.objTy.kind === "map") {
|
|
421
|
+
args[1] = { ...val, method: "getDirect" };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
400
424
|
// Check if any lambda arg has monadic body
|
|
401
425
|
const needsMonadic = _opts.monadic && args.some(a => a.kind === "lambda" && isMonadicBody(a.body));
|
|
402
426
|
const result = { kind: "methodCall", obj: recv, objTy: e.fn.obj.ty, method, args, monadic: needsMonadic };
|
|
@@ -479,6 +503,10 @@ function lowerExpr(e, binds) {
|
|
|
479
503
|
});
|
|
480
504
|
return { kind: "record", spread: lowerExpr(e.spread, binds), fields: loweredFields };
|
|
481
505
|
}
|
|
506
|
+
// Empty record with map type → empty map
|
|
507
|
+
if (e.fields.length === 0 && !e.spread && e.ty.kind === "map") {
|
|
508
|
+
return { kind: "emptyMap" };
|
|
509
|
+
}
|
|
482
510
|
return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
|
|
483
511
|
}
|
|
484
512
|
case "arrayLiteral":
|
|
@@ -497,19 +525,16 @@ function lowerExpr(e, binds) {
|
|
|
497
525
|
case "exists":
|
|
498
526
|
return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
|
|
499
527
|
case "conditional": {
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
const cond = lowerExpr(e.cond, condBinds);
|
|
503
|
-
let thenExpr = lowerExpr(e.then, binds);
|
|
504
|
-
let elseExpr = lowerExpr(e.else, binds);
|
|
505
|
-
// Explicit !== undefined with narrowedExpr → match Some/None on the optional expression
|
|
528
|
+
// Phase 0: Complex expression check (call results, etc.) — resolve substituted
|
|
529
|
+
// and set narrowedVar + narrowedExpr because transform can't detect these.
|
|
506
530
|
if (e.narrowedVar && e.narrowedExpr) {
|
|
507
531
|
const scrutinee = lowerExpr(e.narrowedExpr, binds);
|
|
508
532
|
const bound = matchBinder(e.narrowedVar);
|
|
533
|
+
let thenExpr = lowerExpr(e.then, binds);
|
|
534
|
+
let elseExpr = lowerExpr(e.else, binds);
|
|
509
535
|
if (bound !== e.narrowedVar) {
|
|
510
536
|
thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
|
|
511
537
|
}
|
|
512
|
-
// Wrap in Some/None only when result is optional (one branch is undefined)
|
|
513
538
|
if (e.ty.kind === "optional") {
|
|
514
539
|
thenExpr = wrapOptionalBranch(thenExpr, e.then);
|
|
515
540
|
elseExpr = wrapOptionalBranch(elseExpr, e.else);
|
|
@@ -522,14 +547,16 @@ function lowerExpr(e, binds) {
|
|
|
522
547
|
],
|
|
523
548
|
};
|
|
524
549
|
}
|
|
525
|
-
//
|
|
550
|
+
// Phase 1: Truthiness — cond itself is optional (e.g. opt ? X : Y)
|
|
551
|
+
// Uses narrowedVar set by resolve's Phase 1 (unchanged).
|
|
526
552
|
if (e.narrowedVar && e.cond.ty.kind === "optional") {
|
|
553
|
+
const cond = lowerExpr(e.cond, binds);
|
|
554
|
+
let thenExpr = lowerExpr(e.then, binds);
|
|
555
|
+
let elseExpr = lowerExpr(e.else, binds);
|
|
527
556
|
const bound = matchBinder(e.narrowedVar);
|
|
528
|
-
// Replace the synthetic/narrowed var with the match-bound name
|
|
529
557
|
if (bound !== e.narrowedVar) {
|
|
530
558
|
thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
|
|
531
559
|
}
|
|
532
|
-
// The match produces an Optional: wrap branches in Some/None.
|
|
533
560
|
thenExpr = wrapOptionalBranch(thenExpr, e.then);
|
|
534
561
|
elseExpr = wrapOptionalBranch(elseExpr, e.else);
|
|
535
562
|
return {
|
|
@@ -540,7 +567,26 @@ function lowerExpr(e, binds) {
|
|
|
540
567
|
],
|
|
541
568
|
};
|
|
542
569
|
}
|
|
543
|
-
//
|
|
570
|
+
// Phase 2: Explicit check — x !== undefined ? A : B
|
|
571
|
+
// Transform detects the pattern itself (no narrowedVar/narrowedExpr from resolve).
|
|
572
|
+
const check = parseOptionalCheck(e.cond);
|
|
573
|
+
if (check && !check.negated) {
|
|
574
|
+
return lowerOptionalConditional(e, check, null, binds);
|
|
575
|
+
}
|
|
576
|
+
// Phase 3: && with optional check — x !== undefined && guard(x) ? A : B
|
|
577
|
+
if (e.cond.kind === "binop" && e.cond.op === "&&") {
|
|
578
|
+
const extracted = extractLeftmostOptional(e.cond);
|
|
579
|
+
if (extracted) {
|
|
580
|
+
const innerCheck = parseOptionalCheck(extracted.optCond);
|
|
581
|
+
if (innerCheck && !innerCheck.negated) {
|
|
582
|
+
return lowerOptionalConditional(e, innerCheck, extracted.rest, binds);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Phase 4: Regular conditional (no optional narrowing)
|
|
587
|
+
const cond = lowerExpr(e.cond, binds);
|
|
588
|
+
let thenExpr = lowerExpr(e.then, binds);
|
|
589
|
+
let elseExpr = lowerExpr(e.else, binds);
|
|
544
590
|
if (e.ty.kind === "optional") {
|
|
545
591
|
thenExpr = wrapOptionalBranch(thenExpr, e.then);
|
|
546
592
|
elseExpr = wrapOptionalBranch(elseExpr, e.else);
|
|
@@ -644,13 +690,13 @@ function transformStmts(stmts, typeDecls) {
|
|
|
644
690
|
if (s.names.length === 1 && s.iterable.ty.kind === "map") {
|
|
645
691
|
const keyName = s.names[0];
|
|
646
692
|
const keyTy = s.nameTypes[0] ?? s.iterable.ty.key ?? { kind: "unknown" };
|
|
647
|
-
const keysSeqName = `_${keyName}_keys`;
|
|
648
|
-
const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
|
|
649
|
-
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
|
|
650
|
-
const keysVar = { kind: "var", name: keysSeqName };
|
|
651
693
|
const count = _forofCounters.get(keyName) ?? 0;
|
|
652
694
|
_forofCounters.set(keyName, count + 1);
|
|
653
695
|
const suffix = count === 0 ? "" : `${count + 1}`;
|
|
696
|
+
const keysSeqName = `_${keyName}_keys${suffix}`;
|
|
697
|
+
const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
|
|
698
|
+
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
|
|
699
|
+
const keysVar = { kind: "var", name: keysSeqName };
|
|
654
700
|
const idxName = `_${keyName}_idx${suffix}`;
|
|
655
701
|
const idx = { kind: "var", name: idxName };
|
|
656
702
|
const arrSize = { kind: "field", obj: keysVar, field: "size" };
|
|
@@ -670,13 +716,13 @@ function transformStmts(stmts, typeDecls) {
|
|
|
670
716
|
const keyName = s.names[0], valueName = s.names[1];
|
|
671
717
|
const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
|
|
672
718
|
const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
|
|
673
|
-
const keysSeqName = `_${keyName}_keys`;
|
|
674
|
-
const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
|
|
675
|
-
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
|
|
676
|
-
const keysVar = { kind: "var", name: keysSeqName };
|
|
677
719
|
const count = _forofCounters.get(keyName) ?? 0;
|
|
678
720
|
_forofCounters.set(keyName, count + 1);
|
|
679
721
|
const suffix = count === 0 ? "" : `${count + 1}`;
|
|
722
|
+
const keysSeqName = `_${keyName}_keys${suffix}`;
|
|
723
|
+
const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
|
|
724
|
+
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
|
|
725
|
+
const keysVar = { kind: "var", name: keysSeqName };
|
|
680
726
|
const idxName = `_${keyName}_idx${suffix}`;
|
|
681
727
|
const idx = { kind: "var", name: idxName };
|
|
682
728
|
const arrSize = { kind: "field", obj: keysVar, field: "size" };
|
|
@@ -798,6 +844,21 @@ function transformStmt(s, typeDecls) {
|
|
|
798
844
|
return stmts;
|
|
799
845
|
}
|
|
800
846
|
}
|
|
847
|
+
// Desugar: const x = optCheck && guard ? A : B
|
|
848
|
+
// → var x := B; if (optCheck && guard) { x := A; }
|
|
849
|
+
// This avoids putting method calls (from guard) inside a match expression,
|
|
850
|
+
// which Dafny doesn't allow. The if-case in transformStmt handles the &&
|
|
851
|
+
// via extractLeftmostOptional → emitOptionalMatch (statement-level match).
|
|
852
|
+
if (s.init.kind === "conditional" && s.init.cond.kind === "binop" && s.init.cond.op === "&&") {
|
|
853
|
+
const extracted = extractLeftmostOptional(s.init.cond);
|
|
854
|
+
if (extracted) {
|
|
855
|
+
const desugared = [
|
|
856
|
+
{ kind: "let", name: s.name, ty: s.ty, mutable: true, init: s.init.else },
|
|
857
|
+
{ kind: "if", cond: s.init.cond, then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] },
|
|
858
|
+
];
|
|
859
|
+
return desugared.flatMap(ds => transformStmt(ds, typeDecls));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
801
862
|
const { binds, expr } = liftMethodCalls(s.init);
|
|
802
863
|
return [...binds, { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: expr }];
|
|
803
864
|
}
|
|
@@ -945,7 +1006,8 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
|
|
|
945
1006
|
if (someBranch.length === 0 && restStmts && restStmts.length > 0) {
|
|
946
1007
|
someBranch = restStmts;
|
|
947
1008
|
}
|
|
948
|
-
const
|
|
1009
|
+
const sanitized = varName.replace(/\./g, "_");
|
|
1010
|
+
const bound = matchBinder(`${sanitized}_val`);
|
|
949
1011
|
// Replace the narrowed variable/field in the Some branch body.
|
|
950
1012
|
// Field chains: replace in TStmt before transform (so downstream narrowing sees simple vars).
|
|
951
1013
|
// Simple vars: replace in IR after transform (the original mechanism).
|
|
@@ -959,10 +1021,14 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
|
|
|
959
1021
|
}
|
|
960
1022
|
else {
|
|
961
1023
|
const transformed = transformStmts(someBranch, typeDecls);
|
|
962
|
-
someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound })));
|
|
1024
|
+
someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound }, true)));
|
|
963
1025
|
}
|
|
1026
|
+
// For field chains, use an Expr scrutinee so outer match replaceVar can
|
|
1027
|
+
// substitute the object variable (e.g. task → i_task_val in task.deletedFromList).
|
|
1028
|
+
// String scrutinees are opaque to replaceVar/mapExpr.
|
|
1029
|
+
const scrutinee = fieldExpr ? transformExpr(fieldExpr) : varName;
|
|
964
1030
|
return {
|
|
965
|
-
kind: "match", scrutinee
|
|
1031
|
+
kind: "match", scrutinee,
|
|
966
1032
|
arms: [
|
|
967
1033
|
{ pattern: `.some ${bound}`, body: someBody },
|
|
968
1034
|
{ pattern: ".none", body: noneBranch.length > 0 ? transformStmts(noneBranch, typeDecls) : [] },
|
|
@@ -975,25 +1041,21 @@ function mapStmtExprs(s, r) {
|
|
|
975
1041
|
}
|
|
976
1042
|
// ── Optional narrowing helpers ──────────────────────────────
|
|
977
1043
|
//
|
|
978
|
-
// Optional narrowing converts TS `if (x === undefined)`
|
|
979
|
-
// `match x { Some(val) => ..., None => ... }`.
|
|
1044
|
+
// Optional narrowing converts TS `if (x === undefined)` / `x !== undefined ? a : b`
|
|
1045
|
+
// patterns to `match x { Some(val) => ..., None => ... }`.
|
|
980
1046
|
//
|
|
981
|
-
// The resolve phase (resolve.ts) handles:
|
|
1047
|
+
// The resolve phase (resolve.ts) handles TYPE narrowing only:
|
|
982
1048
|
// - Flow narrowing: after `if (x === undefined) return`, x is non-optional
|
|
983
1049
|
// - && narrowing: in `x !== undefined && f(x)`, f(x) sees x as non-optional
|
|
984
|
-
// - Conditional narrowing:
|
|
985
|
-
//
|
|
1050
|
+
// - Conditional type narrowing: extends env (simple vars) or narrowedFields context
|
|
1051
|
+
// (field chains) so the then-branch resolves with the unwrapped type
|
|
986
1052
|
//
|
|
987
|
-
// The transform phase (here) handles:
|
|
1053
|
+
// The transform phase (here) handles ALL structural narrowing:
|
|
988
1054
|
// - Statement-level: `transformStmts` detects optional checks → `emitOptionalMatch`
|
|
989
|
-
// - Expression-level: `lowerExpr`
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// Both phases detect `v !== undefined` patterns. The resolve phase uses
|
|
994
|
-
// `detectOptionalCheck` (on RawExpr), the transform uses `parseOptionalCheck` (on TExpr).
|
|
995
|
-
// These are separate because they operate on different IR types, but both handle
|
|
996
|
-
// simple variables and field access chains.
|
|
1055
|
+
// - Expression-level: `lowerExpr` detects optional checks via `parseOptionalCheck`
|
|
1056
|
+
// and `extractLeftmostOptional` → `lowerOptionalConditional`
|
|
1057
|
+
// - && restructuring: `extractLeftmostOptional` splits `&&` chains, generating
|
|
1058
|
+
// match with guard: `match x { Some(val) => if guard then A else B, None => B }`
|
|
997
1059
|
/** Shared logic for optional match in both imperative and pure function paths.
|
|
998
1060
|
* Detects optional check, selects branches, handles early-return consumption.
|
|
999
1061
|
* Returns null if the condition is not an optional check. */
|
|
@@ -1006,7 +1068,8 @@ function prepareOptionalMatch(s, restStmts) {
|
|
|
1006
1068
|
// Early-return pattern: Some branch is empty → consume rest of block
|
|
1007
1069
|
if (someBranch.length === 0 && restStmts.length > 0)
|
|
1008
1070
|
someBranch = restStmts;
|
|
1009
|
-
const
|
|
1071
|
+
const sanitized = check.varName.replace(/\./g, "_");
|
|
1072
|
+
const bound = matchBinder(`${sanitized}_val`);
|
|
1010
1073
|
return { check, someBranch, noneBranch, bound };
|
|
1011
1074
|
}
|
|
1012
1075
|
/** Extract the leftmost optional check from a && chain, returning the check and the rest.
|
|
@@ -1120,6 +1183,96 @@ function replaceFieldAccessInTStmts(stmts, varName, fields) {
|
|
|
1120
1183
|
fallbackTy: f.type ?? parseTsType(f.tsType),
|
|
1121
1184
|
})));
|
|
1122
1185
|
}
|
|
1186
|
+
/** Replace obj.field → replacement var in typed IR expressions (before lowering).
|
|
1187
|
+
* Mirrors replaceFieldsInTStmts but operates on a single TExpr tree. */
|
|
1188
|
+
function replaceFieldInTExpr(expr, objName, replacements) {
|
|
1189
|
+
if (replacements.length === 0)
|
|
1190
|
+
return expr;
|
|
1191
|
+
return mapTExpr(expr, e => {
|
|
1192
|
+
if (e.kind === "field" && e.obj.kind === "var" && e.obj.name === objName) {
|
|
1193
|
+
const r = replacements.find(r => r.fieldName === e.field);
|
|
1194
|
+
if (r) {
|
|
1195
|
+
const ty = e.ty.kind !== "unknown" ? e.ty : r.fallbackTy;
|
|
1196
|
+
return { kind: "var", name: r.newName, ty };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return null;
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
/** Unwrap optional type on match-bound variables in TExpr.
|
|
1203
|
+
* After replaceFieldInTExpr, the replaced variable carries the original optional
|
|
1204
|
+
* type from the field declaration. The match binding unwraps it to the inner type. */
|
|
1205
|
+
function fixBoundType(expr, boundName) {
|
|
1206
|
+
return mapTExpr(expr, e => e.kind === "var" && e.name === boundName && e.ty.kind === "optional"
|
|
1207
|
+
? { ...e, ty: e.ty.inner } : null);
|
|
1208
|
+
}
|
|
1209
|
+
/** Lower a conditional with an optional check (Phase 2: explicit, Phase 3: && with guard).
|
|
1210
|
+
* Generates: match scrutinee { Some(val) => [if guard then] A [else B], None => B }
|
|
1211
|
+
* For field chains, replaces field accesses in TExpr before lowering.
|
|
1212
|
+
* For simple vars, replaces variable names in Expr after lowering. */
|
|
1213
|
+
function lowerOptionalConditional(e, check, guard, binds) {
|
|
1214
|
+
const sanitized = check.varName.replace(/\./g, "_");
|
|
1215
|
+
const bound = matchBinder(`${sanitized}_val`);
|
|
1216
|
+
const isFieldChain = check.fieldExpr && check.fieldExpr.kind === "field" &&
|
|
1217
|
+
check.fieldExpr.obj.kind === "var";
|
|
1218
|
+
// Determine which branch is Some (unwrapped) vs None
|
|
1219
|
+
let thenTExpr = e.then;
|
|
1220
|
+
let guardTExpr = guard;
|
|
1221
|
+
// For field chains: replace field access with bound var in TExpr before lowering
|
|
1222
|
+
if (isFieldChain) {
|
|
1223
|
+
const fe = check.fieldExpr;
|
|
1224
|
+
const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
|
|
1225
|
+
const replacements = [{ fieldName: fe.field, newName: bound, fallbackTy: innerTy }];
|
|
1226
|
+
thenTExpr = fixBoundType(replaceFieldInTExpr(thenTExpr, fe.obj.name, replacements), bound);
|
|
1227
|
+
if (guardTExpr) {
|
|
1228
|
+
guardTExpr = fixBoundType(replaceFieldInTExpr(guardTExpr, fe.obj.name, replacements), bound);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
let thenExpr = lowerExpr(thenTExpr, binds);
|
|
1232
|
+
let elseExpr = lowerExpr(e.else, binds);
|
|
1233
|
+
// For simple vars: replace after lowering
|
|
1234
|
+
if (!isFieldChain) {
|
|
1235
|
+
thenExpr = replaceVar(thenExpr, check.varName, { kind: "var", name: bound }, true);
|
|
1236
|
+
}
|
|
1237
|
+
// Optional wrapping: check if either branch is undefined (produces optional result)
|
|
1238
|
+
const isOptionalResult = (e.then.kind === "var" && e.then.name === "undefined") ||
|
|
1239
|
+
(e.else.kind === "var" && e.else.name === "undefined");
|
|
1240
|
+
if (isOptionalResult) {
|
|
1241
|
+
thenExpr = wrapOptionalBranch(thenExpr, e.then);
|
|
1242
|
+
elseExpr = wrapOptionalBranch(elseExpr, e.else);
|
|
1243
|
+
}
|
|
1244
|
+
// Build Some arm body — add guard for && patterns
|
|
1245
|
+
// Note: if the guard has method calls, the impure path desugars the let to a
|
|
1246
|
+
// statement-level if+match in transformStmt, so this expression-level path only
|
|
1247
|
+
// runs for pure guards. Use null for binds to avoid escaping the match scope.
|
|
1248
|
+
let someBody;
|
|
1249
|
+
if (guard) {
|
|
1250
|
+
let guardExpr = lowerExpr(guardTExpr, null);
|
|
1251
|
+
if (!isFieldChain) {
|
|
1252
|
+
guardExpr = replaceVar(guardExpr, check.varName, { kind: "var", name: bound }, true);
|
|
1253
|
+
}
|
|
1254
|
+
// Guard-else gets the same expression as None arm
|
|
1255
|
+
let guardElse = lowerExpr(e.else, null);
|
|
1256
|
+
if (isOptionalResult) {
|
|
1257
|
+
guardElse = wrapOptionalBranch(guardElse, e.else);
|
|
1258
|
+
}
|
|
1259
|
+
someBody = { kind: "if", cond: guardExpr, then: thenExpr, else: guardElse };
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
someBody = thenExpr;
|
|
1263
|
+
}
|
|
1264
|
+
// Build scrutinee
|
|
1265
|
+
const scrutinee = isFieldChain
|
|
1266
|
+
? lowerExpr(check.fieldExpr, binds)
|
|
1267
|
+
: check.varName;
|
|
1268
|
+
return {
|
|
1269
|
+
kind: "match", scrutinee,
|
|
1270
|
+
arms: [
|
|
1271
|
+
{ pattern: `.some ${bound}`, body: someBody },
|
|
1272
|
+
{ pattern: ".none", body: elseExpr },
|
|
1273
|
+
],
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1123
1276
|
// ── Pure function generation ─────────────────────────────────
|
|
1124
1277
|
function transformPureBody(stmts, typeDecls) {
|
|
1125
1278
|
// Detect discriminant if-chain
|
|
@@ -1140,16 +1293,42 @@ function transformPureBody(stmts, typeDecls) {
|
|
|
1140
1293
|
return { kind: "let", name: s.name, value: transformExpr(s.init), body: restExpr };
|
|
1141
1294
|
}
|
|
1142
1295
|
case "if": {
|
|
1296
|
+
// Restructure && with optional check: split into nested ifs so
|
|
1297
|
+
// prepareOptionalMatch can detect the optional check and bind the unwrapped value
|
|
1298
|
+
if (s.cond.kind === "binop" && s.cond.op === "&&" && s.else.length === 0) {
|
|
1299
|
+
const extracted = extractLeftmostOptional(s.cond);
|
|
1300
|
+
if (extracted) {
|
|
1301
|
+
const innerIf = { kind: "if", cond: extracted.rest, then: s.then, else: [] };
|
|
1302
|
+
const outerIf = { kind: "if", cond: extracted.optCond, then: [innerIf], else: [] };
|
|
1303
|
+
return transformPureBody([outerIf, ...rest], typeDecls);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1143
1306
|
// Optional narrowing: if (x === undefined) → match x { None => ..., Some(x_val) => ... }
|
|
1144
1307
|
const optMatch = prepareOptionalMatch(s, rest);
|
|
1145
1308
|
if (optMatch) {
|
|
1146
|
-
|
|
1309
|
+
// For field chains (a.dueDate !== undefined), replace in TStmt before transform
|
|
1310
|
+
let someBranch = [...optMatch.someBranch, ...rest];
|
|
1311
|
+
const fe = optMatch.check.fieldExpr;
|
|
1312
|
+
if (fe && fe.kind === "field" && fe.obj.kind === "var") {
|
|
1313
|
+
const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
|
|
1314
|
+
someBranch = replaceFieldsInTStmts(someBranch, fe.obj.name, [
|
|
1315
|
+
{ fieldName: fe.field, newName: optMatch.bound, fallbackTy: innerTy },
|
|
1316
|
+
]);
|
|
1317
|
+
// The replacement keeps the original optional type, but the match binding
|
|
1318
|
+
// unwraps it. Fix the type so downstream record coercion re-wraps with Some().
|
|
1319
|
+
someBranch = someBranch.map(s => mapTStmt(s, e => e.kind === "var" && e.name === optMatch.bound && e.ty.kind === "optional"
|
|
1320
|
+
? { ...e, ty: e.ty.inner } : null));
|
|
1321
|
+
}
|
|
1322
|
+
const someExpr = transformPureBody(someBranch, typeDecls);
|
|
1147
1323
|
if (!someExpr)
|
|
1148
1324
|
return null;
|
|
1149
1325
|
const noneExpr = transformPureBody(optMatch.noneBranch, typeDecls);
|
|
1150
1326
|
if (!noneExpr)
|
|
1151
1327
|
return null;
|
|
1152
|
-
|
|
1328
|
+
// For simple vars, replace in Expr after transform
|
|
1329
|
+
const someReplaced = optMatch.check.fieldExpr
|
|
1330
|
+
? someExpr
|
|
1331
|
+
: replaceVar(someExpr, optMatch.check.varName, { kind: "var", name: optMatch.bound }, true);
|
|
1153
1332
|
return {
|
|
1154
1333
|
kind: "match", scrutinee: optMatch.check.varName,
|
|
1155
1334
|
arms: [
|
|
@@ -1158,11 +1337,13 @@ function transformPureBody(stmts, typeDecls) {
|
|
|
1158
1337
|
],
|
|
1159
1338
|
};
|
|
1160
1339
|
}
|
|
1161
|
-
|
|
1340
|
+
// Append rest to both branches so nested ifs that fall through
|
|
1341
|
+
// can reach the continuation (e.g. early return inside then-branch)
|
|
1342
|
+
const thenExpr = transformPureBody([...s.then, ...rest], typeDecls);
|
|
1162
1343
|
if (!thenExpr)
|
|
1163
1344
|
return null;
|
|
1164
|
-
const
|
|
1165
|
-
const elseExpr = transformPureBody(
|
|
1345
|
+
const elseStmts = s.else.length > 0 ? [...s.else, ...rest] : rest;
|
|
1346
|
+
const elseExpr = transformPureBody(elseStmts, typeDecls);
|
|
1166
1347
|
if (!elseExpr)
|
|
1167
1348
|
return null;
|
|
1168
1349
|
return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
|
|
@@ -1293,17 +1474,38 @@ function findReassignedNames(stmts, names) {
|
|
|
1293
1474
|
return found;
|
|
1294
1475
|
}
|
|
1295
1476
|
/** Replace all occurrences of a variable name with a new expression. */
|
|
1296
|
-
|
|
1477
|
+
/**
|
|
1478
|
+
* Replace all occurrences of variable `name` with `replacement`.
|
|
1479
|
+
* If `narrowing` is true, the replacement is an unwrapped Optional value
|
|
1480
|
+
* (e.g., replacing `x: Option<T>` with `x_val: T`). In that case, when the
|
|
1481
|
+
* variable appears directly as a record spread field value, it's wrapped in
|
|
1482
|
+
* Some() to preserve the field's Optional type.
|
|
1483
|
+
*/
|
|
1484
|
+
function replaceVar(e, name, replacement, narrowing) {
|
|
1485
|
+
const rec = (expr) => replaceVar(expr, name, replacement, narrowing);
|
|
1297
1486
|
return mapExpr(e, x => {
|
|
1298
1487
|
if (x.kind === "var" && x.name === name)
|
|
1299
1488
|
return replacement;
|
|
1489
|
+
// Record spread: wrap direct variable uses in field values with Some when narrowing
|
|
1490
|
+
if (narrowing && x.kind === "record" && x.spread) {
|
|
1491
|
+
return {
|
|
1492
|
+
...x,
|
|
1493
|
+
spread: rec(x.spread),
|
|
1494
|
+
fields: x.fields.map(f => {
|
|
1495
|
+
if (f.value.kind === "var" && f.value.name === name) {
|
|
1496
|
+
return { ...f, value: { kind: "app", fn: "Some", args: [replacement] } };
|
|
1497
|
+
}
|
|
1498
|
+
return { ...f, value: rec(f.value) };
|
|
1499
|
+
}),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1300
1502
|
// Don't descend past bindings that shadow the name
|
|
1301
1503
|
if (x.kind === "forall" && x.var === name)
|
|
1302
1504
|
return x;
|
|
1303
1505
|
if (x.kind === "exists" && x.var === name)
|
|
1304
1506
|
return x;
|
|
1305
1507
|
if (x.kind === "let" && x.name === name)
|
|
1306
|
-
return { ...x, value: replaceVar(x.value, name, replacement) };
|
|
1508
|
+
return { ...x, value: replaceVar(x.value, name, replacement, narrowing) };
|
|
1307
1509
|
return null;
|
|
1308
1510
|
});
|
|
1309
1511
|
}
|
|
@@ -1344,25 +1546,43 @@ export function transformModule(mod, specImport) {
|
|
|
1344
1546
|
}));
|
|
1345
1547
|
// Pure function mirrors
|
|
1346
1548
|
const pureDefs = [];
|
|
1549
|
+
const defByMethods = [];
|
|
1347
1550
|
for (const fn of mod.functions) {
|
|
1348
1551
|
if (!fn.isPure)
|
|
1349
1552
|
continue;
|
|
1350
1553
|
const body = transformPureBody(fn.body, mod.typeDecls);
|
|
1351
|
-
if (
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1554
|
+
if (body) {
|
|
1555
|
+
// For ensures, replace \result (→ "res") with the function call
|
|
1556
|
+
const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
|
|
1557
|
+
const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
|
|
1558
|
+
pureDefs.push({
|
|
1559
|
+
kind: "def",
|
|
1560
|
+
name: fn.name,
|
|
1561
|
+
typeParams: fn.typeParams,
|
|
1562
|
+
params: fn.params.map(p => ({ name: p.name, type: p.ty })),
|
|
1563
|
+
returnType: fn.returnTy,
|
|
1564
|
+
requires: fn.requires.map(transformExpr),
|
|
1565
|
+
ensures,
|
|
1566
|
+
decreases: fn.decreases ? transformExpr(fn.decreases) : null,
|
|
1567
|
+
body,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
else if (fn.forcePure) {
|
|
1571
|
+
// //@ pure but body can't be auto-converted — emit function by method
|
|
1572
|
+
_forofCounters.clear();
|
|
1573
|
+
const methodBody = transformStmts(fn.body, mod.typeDecls);
|
|
1574
|
+
defByMethods.push({
|
|
1575
|
+
kind: "def-by-method",
|
|
1576
|
+
name: fn.name,
|
|
1577
|
+
typeParams: fn.typeParams,
|
|
1578
|
+
params: fn.params.map(p => ({ name: p.name, type: p.ty })),
|
|
1579
|
+
returnType: fn.returnTy,
|
|
1580
|
+
requires: fn.requires.map(transformExpr),
|
|
1581
|
+
ensures: fn.ensures.map(transformExpr),
|
|
1582
|
+
decreases: fn.decreases ? transformExpr(fn.decreases) : null,
|
|
1583
|
+
methodBody,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1366
1586
|
}
|
|
1367
1587
|
const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
|
|
1368
1588
|
// Types file
|
|
@@ -1381,7 +1601,8 @@ export function transformModule(mod, specImport) {
|
|
|
1381
1601
|
}
|
|
1382
1602
|
// Def file: Velvet methods
|
|
1383
1603
|
// Pure functions get a thin wrapper that calls Pure.fnName
|
|
1384
|
-
|
|
1604
|
+
// def-by-method functions also skip their method wrappers
|
|
1605
|
+
const pureDefNames = new Set([...pureDefs.map(d => d.name), ...defByMethods.map(d => d.name)]);
|
|
1385
1606
|
const methods = mod.functions.map(fn => {
|
|
1386
1607
|
const ensures = [];
|
|
1387
1608
|
for (const e of fn.ensures) {
|
|
@@ -1448,7 +1669,7 @@ export function transformModule(mod, specImport) {
|
|
|
1448
1669
|
{ key: "loom.semantics.termination", value: '"total"' },
|
|
1449
1670
|
{ key: "loom.semantics.choice", value: '"demonic"' },
|
|
1450
1671
|
],
|
|
1451
|
-
decls: [...constDecls, ...methods, ...classDecls],
|
|
1672
|
+
decls: [...constDecls, ...defByMethods, ...methods, ...classDecls],
|
|
1452
1673
|
};
|
|
1453
1674
|
return { typesFile, defFile };
|
|
1454
1675
|
}
|