lemmascript 0.4.0 → 0.5.1
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 +17 -12
- package/package.json +4 -1
- package/tools/dist/dafny-emit.js +294 -12
- package/tools/dist/extract.js +1093 -165
- package/tools/dist/info-command.js +38 -0
- package/tools/dist/lean-emit.js +28 -2
- package/tools/dist/lsc.js +16 -6
- package/tools/dist/narrow.js +211 -16
- package/tools/dist/peephole.js +5 -2
- package/tools/dist/resolve.js +416 -46
- package/tools/dist/specparser.js +6 -0
- package/tools/dist/transform.js +400 -42
- package/tools/dist/types.js +128 -69
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsc info` — emit a JSON summary of verified functions in a TS file.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: extract only (no resolve/transform/emit). Walks Raw IR for
|
|
5
|
+
* top-level functions and class methods, preserving the original `//@ `
|
|
6
|
+
* source text for clauses (no specparser round-trip).
|
|
7
|
+
*
|
|
8
|
+
* Output: `foo.ts.json` next to `foo.ts`, with shape:
|
|
9
|
+
* { method: { sig, requires, ensures, decreases }, ... }
|
|
10
|
+
* Class methods key as `ClassName.method`.
|
|
11
|
+
*/
|
|
12
|
+
import { writeFileSync } from "fs";
|
|
13
|
+
import { parseTsType, tyToCanonical } from "./types.js";
|
|
14
|
+
function renderSig(fn) {
|
|
15
|
+
const params = fn.params.map(p => `${p.name}: ${tyToCanonical(parseTsType(p.tsType))}`).join(", ");
|
|
16
|
+
return `(${params}): ${tyToCanonical(parseTsType(fn.returnType))}`;
|
|
17
|
+
}
|
|
18
|
+
function fnToInfo(fn) {
|
|
19
|
+
return {
|
|
20
|
+
sig: renderSig(fn),
|
|
21
|
+
requires: fn.requires,
|
|
22
|
+
ensures: fn.ensures,
|
|
23
|
+
decreases: fn.decreases,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function runInfo(raw, outPath) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const fn of raw.functions) {
|
|
29
|
+
out[fn.name] = fnToInfo(fn);
|
|
30
|
+
}
|
|
31
|
+
for (const cls of raw.classes) {
|
|
32
|
+
for (const m of cls.methods) {
|
|
33
|
+
out[`${cls.name}.${m.name}`] = fnToInfo(m);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(outPath, JSON.stringify(out, null, 2) + "\n");
|
|
37
|
+
console.log(`Wrote ${outPath}`);
|
|
38
|
+
}
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -31,6 +31,15 @@ function tyToLean(ty) {
|
|
|
31
31
|
return inner.includes(" ") ? `Option (${inner})` : `Option ${inner}`;
|
|
32
32
|
}
|
|
33
33
|
case "user": return ty.name;
|
|
34
|
+
case "fn": {
|
|
35
|
+
const params = ty.params.map(p => {
|
|
36
|
+
const s = tyToLean(p);
|
|
37
|
+
return s.includes(" ") ? `(${s})` : s;
|
|
38
|
+
});
|
|
39
|
+
const ret = tyToLean(ty.result);
|
|
40
|
+
const retStr = ret.includes(" ") ? `(${ret})` : ret;
|
|
41
|
+
return [...params, retStr].join(" → ");
|
|
42
|
+
}
|
|
34
43
|
case "unknown": return "_";
|
|
35
44
|
}
|
|
36
45
|
}
|
|
@@ -45,6 +54,10 @@ const LEAN_KEYWORDS = new Set([
|
|
|
45
54
|
"at", "from", "to", "deriving", "extends", "true", "false",
|
|
46
55
|
]);
|
|
47
56
|
function escapeName(name) {
|
|
57
|
+
// \result is carried through the IR as the var name "\\result"; render it
|
|
58
|
+
// as Lean's canonical return-value identifier (matches `return (res : T)`).
|
|
59
|
+
if (name === "\\result")
|
|
60
|
+
return "res";
|
|
48
61
|
return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
|
|
49
62
|
}
|
|
50
63
|
// ── Operator precedence (for parenthesization) ──────────────
|
|
@@ -109,6 +122,13 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
|
|
|
109
122
|
throw new Error(`Unsupported Lean method call: .${method}() on ${tyKind}`);
|
|
110
123
|
}
|
|
111
124
|
// ── Expression emission ─────────────────────────────────────
|
|
125
|
+
// Lean's `∀`/`∃` body extends as far as possible. So `(∃ x, P) <op> Q`
|
|
126
|
+
// (or `∃ x, P → Q`) would parse with the operator absorbed into the body.
|
|
127
|
+
// Wrap a quantifier in parens to terminate its body before the operator.
|
|
128
|
+
function wrapQuantifier(sub, parentPrec) {
|
|
129
|
+
const inner = emitExpr(sub, parentPrec);
|
|
130
|
+
return (sub.kind === "forall" || sub.kind === "exists") ? `(${inner})` : inner;
|
|
131
|
+
}
|
|
112
132
|
function emitExpr(e, parentPrec) {
|
|
113
133
|
switch (e.kind) {
|
|
114
134
|
case "var": return escapeName(e.name);
|
|
@@ -163,11 +183,11 @@ function emitExpr(e, parentPrec) {
|
|
|
163
183
|
return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
|
|
164
184
|
}
|
|
165
185
|
const op = e.op === "arrayConcat" ? "++" : e.op;
|
|
166
|
-
const s = `${
|
|
186
|
+
const s = `${wrapQuantifier(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
|
|
167
187
|
return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
|
|
168
188
|
}
|
|
169
189
|
case "implies": {
|
|
170
|
-
const parts = [...e.premises.map(p =>
|
|
190
|
+
const parts = [...e.premises.map(p => wrapQuantifier(p)), emitExpr(e.conclusion)];
|
|
171
191
|
const s = parts.join(" → ");
|
|
172
192
|
return parentPrec !== undefined ? `(${s})` : s;
|
|
173
193
|
}
|
|
@@ -249,6 +269,8 @@ function emitStmt(s, indent) {
|
|
|
249
269
|
return `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`;
|
|
250
270
|
case "ghostAssign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
|
|
251
271
|
case "assert": {
|
|
272
|
+
if (s.assumed)
|
|
273
|
+
throw new Error("//@ assume: not supported in Lean backend.");
|
|
252
274
|
// WPGen.assert needs a Prop; bare Bool expressions (`k in m` → `.contains`,
|
|
253
275
|
// method calls, vars) lack a matching WPGen instance and silently fall back
|
|
254
276
|
// to WPGen.default, which drops the assertion. Coerce to Prop via `= true`.
|
|
@@ -399,6 +421,10 @@ function emitDecl(d) {
|
|
|
399
421
|
throw new Error(`Lean class support not yet implemented: ${d.name}`);
|
|
400
422
|
case "const":
|
|
401
423
|
return `def ${escapeName(d.name)} : ${tyToLean(d.type)} := ${emitExpr(d.value)}`;
|
|
424
|
+
case "extern":
|
|
425
|
+
// Lean: emit an opaque function declaration. The user is expected to
|
|
426
|
+
// provide an axiomatic body or a stub in the companion spec file.
|
|
427
|
+
throw new Error(`Lean extern support not yet implemented: ${d.name}`);
|
|
402
428
|
}
|
|
403
429
|
}
|
|
404
430
|
/** Emit a pure expression with indented if/match blocks. */
|
package/tools/dist/lsc.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lsc — LemmaScript compiler CLI
|
|
4
4
|
*
|
|
5
|
-
* Pipeline: extract → resolve → transform → emit
|
|
5
|
+
* Pipeline: extract → resolve → narrow → transform → peephole → emit
|
|
6
6
|
*/
|
|
7
7
|
import { Project, ScriptTarget } from "ts-morph";
|
|
8
8
|
import { existsSync } from "fs";
|
|
@@ -16,6 +16,7 @@ import { emitLeanFile } from "./lean-emit.js";
|
|
|
16
16
|
import { emitDafnyFile } from "./dafny-emit.js";
|
|
17
17
|
import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
|
|
18
18
|
import { leanGen, leanCheck } from "./lean-commands.js";
|
|
19
|
+
import { runInfo } from "./info-command.js";
|
|
19
20
|
function main() {
|
|
20
21
|
const args = process.argv.slice(2);
|
|
21
22
|
const backendIdx = args.findIndex(a => a.startsWith("--backend="));
|
|
@@ -43,7 +44,7 @@ function main() {
|
|
|
43
44
|
}
|
|
44
45
|
const [cmd, filePath] = args;
|
|
45
46
|
if (!cmd || !filePath) {
|
|
46
|
-
console.error("Usage: lsc <gen|check|regen|extract> [--backend=lean|dafny] <file.ts>");
|
|
47
|
+
console.error("Usage: lsc <gen|check|regen|extract|info> [--backend=lean|dafny] <file.ts>");
|
|
47
48
|
process.exit(1);
|
|
48
49
|
}
|
|
49
50
|
const absPath = path.resolve(filePath);
|
|
@@ -70,18 +71,27 @@ function main() {
|
|
|
70
71
|
: new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
|
|
71
72
|
const sourceFile = project.addSourceFileAtPath(absPath);
|
|
72
73
|
project.resolveSourceFileDependencies();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const fullText = sourceFile.getFullText();
|
|
75
|
+
// Check //@ backend directive — skip if backend doesn't match.
|
|
76
|
+
// `extract` and `info` are backend-neutral and always run.
|
|
77
|
+
const backendDirective = fullText.match(/\/\/@ backend (\w+)/);
|
|
78
|
+
if (cmd !== "extract" && cmd !== "info" && backendDirective && backendDirective[1] !== backend) {
|
|
76
79
|
console.log(`Skipped: ${path.basename(filePath)} (//@ backend ${backendDirective[1]}, current: ${backend})`);
|
|
77
80
|
return;
|
|
78
81
|
}
|
|
82
|
+
// File-level directives consumed by the Dafny emitter.
|
|
83
|
+
const safeSlice = /\/\/@ safe-slice\b/.test(fullText);
|
|
79
84
|
// Extract: ts-morph → Raw IR
|
|
80
85
|
const raw = extractModule(sourceFile);
|
|
81
86
|
if (cmd === "extract") {
|
|
82
87
|
console.log(JSON.stringify(raw, null, 2));
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
90
|
+
if (cmd === "info") {
|
|
91
|
+
const outPath = path.join(path.dirname(absPath), `${path.basename(filePath, ".ts")}.ts.json`);
|
|
92
|
+
runInfo(raw, outPath);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
85
95
|
// Resolve: Raw IR → Typed IR
|
|
86
96
|
const resolved = resolveModule(raw);
|
|
87
97
|
// Narrow: Typed IR → Typed IR (rewrites optional-narrowing patterns to someMatch)
|
|
@@ -96,7 +106,7 @@ function main() {
|
|
|
96
106
|
defFile = peepholeModule(defFile, "dafny");
|
|
97
107
|
const allDecls = [...(typesFile?.decls ?? []), ...defFile.decls];
|
|
98
108
|
const merged = { ...defFile, decls: allDecls };
|
|
99
|
-
const text = emitDafnyFile(merged, path.basename(filePath));
|
|
109
|
+
const text = emitDafnyFile(merged, path.basename(filePath), { safeSlice });
|
|
100
110
|
const genPath = path.join(dir, `${base}.dfy.gen`);
|
|
101
111
|
const dfyPath = path.join(dir, `${base}.dfy`);
|
|
102
112
|
const basePath = path.join(dir, `${base}.dfy.base`);
|
package/tools/dist/narrow.js
CHANGED
|
@@ -87,14 +87,16 @@ function binderHintFor(e) {
|
|
|
87
87
|
}
|
|
88
88
|
if (cur.kind !== "var")
|
|
89
89
|
return null;
|
|
90
|
-
|
|
90
|
+
// \result is stored as the IR var name "\\result"; sanitize for a valid identifier.
|
|
91
|
+
const root = cur.name === "\\result" ? "result" : cur.name;
|
|
92
|
+
return fields.length === 0 ? `_${root}_val` : `_${root}_${fields.join("_")}_val`;
|
|
91
93
|
}
|
|
92
94
|
// Aliased for code that historically called the simpler check.
|
|
93
95
|
const parseSimpleOptionalCheck = parseOptionalCheck;
|
|
94
96
|
// ── Walkers ──────────────────────────────────────────────────
|
|
95
97
|
function walkExpr(e) {
|
|
96
98
|
const r = recurseExpr(e);
|
|
97
|
-
return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
|
|
99
|
+
return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
|
|
98
100
|
}
|
|
99
101
|
function recurseExpr(e) {
|
|
100
102
|
const re = walkExpr;
|
|
@@ -103,7 +105,6 @@ function recurseExpr(e) {
|
|
|
103
105
|
case "num":
|
|
104
106
|
case "str":
|
|
105
107
|
case "bool":
|
|
106
|
-
case "result":
|
|
107
108
|
case "havoc":
|
|
108
109
|
return e;
|
|
109
110
|
case "binop": return { ...e, left: re(e.left), right: re(e.right) };
|
|
@@ -132,10 +133,14 @@ function recurseExpr(e) {
|
|
|
132
133
|
function walkStmt(s) {
|
|
133
134
|
// Recurse into children first, then try rules at this node.
|
|
134
135
|
const r = recurseStmt(s);
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
136
|
+
// Optional narrowing fires before Array.isArray narrowing: in a chain like
|
|
137
|
+
// `next && Array.isArray(next.content)` the optional check must unwrap `next`
|
|
138
|
+
// *outside* the array match, since `next.content` is unreachable until then.
|
|
139
|
+
// (When the chain has no leading optional, ruleIfAndOptional no-ops and the
|
|
140
|
+
// array rule fires; independent narrows commute, so the order is harmless.)
|
|
141
|
+
// && rules fire before the simple rule because they produce nested ifs whose
|
|
142
|
+
// inner shape doesn't match the simple rule directly.
|
|
143
|
+
return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? r;
|
|
139
144
|
}
|
|
140
145
|
function walkStmts(stmts) {
|
|
141
146
|
const result = [];
|
|
@@ -307,6 +312,60 @@ function ruleConditionalOptionalSimple(e) {
|
|
|
307
312
|
ty: e.ty,
|
|
308
313
|
};
|
|
309
314
|
}
|
|
315
|
+
/** Rule (expression): `Array.isArray(x) ==> B` or `!Array.isArray(x) ==> B` —
|
|
316
|
+
* premise narrowing for spec implications. Mirrors `ruleImplOptional` but for
|
|
317
|
+
* synth array-union discriminators.
|
|
318
|
+
* → `tagMatch x { ArrayBranch => walkExpr(B), _ => true }` (or NonArrayBranch).
|
|
319
|
+
* The other variant becomes a vacuous-true fallthrough (the implication is
|
|
320
|
+
* trivially satisfied when the premise is false). */
|
|
321
|
+
function ruleImplArrayIsArray(e) {
|
|
322
|
+
if (e.kind !== "binop" || e.op !== "==>")
|
|
323
|
+
return null;
|
|
324
|
+
const pos = parseArrayIsArrayCall(e.left);
|
|
325
|
+
const neg = e.left.kind === "unop" && e.left.op === "!"
|
|
326
|
+
? parseArrayIsArrayCall(e.left.expr)
|
|
327
|
+
: null;
|
|
328
|
+
const matched = pos ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
|
|
329
|
+
if (!matched)
|
|
330
|
+
return null;
|
|
331
|
+
return {
|
|
332
|
+
kind: "tagMatch",
|
|
333
|
+
scrutinee: matched.scrutinee,
|
|
334
|
+
typeName: matched.typeName,
|
|
335
|
+
cases: [{ variant: matched.variant, body: walkExpr(e.right) }],
|
|
336
|
+
fallthrough: { kind: "bool", value: true, ty: { kind: "bool" } },
|
|
337
|
+
ty: { kind: "bool" },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Rule (expression): `Array.isArray(x) ? a : b` — ternary narrowing for
|
|
341
|
+
* synth array-unions. Mirrors `ruleImplArrayIsArray` but at the conditional
|
|
342
|
+
* position rather than the `==>` position.
|
|
343
|
+
* → `tagMatch x { ArrayBranch => walkExpr(a) } fallthrough walkExpr(b)`
|
|
344
|
+
* (or NonArrayBranch when the condition is negated).
|
|
345
|
+
* Inside the matched arm, bare references to `x` are rewritten to the
|
|
346
|
+
* variant's payload field (e.g. `x.arr`) by `transformExpr` when emitting
|
|
347
|
+
* the tagMatch — same mechanism `ruleImplArrayIsArray` already relies on. */
|
|
348
|
+
function ruleConditionalArrayIsArray(e) {
|
|
349
|
+
if (e.kind !== "conditional")
|
|
350
|
+
return null;
|
|
351
|
+
const pos = parseArrayIsArrayCall(e.cond);
|
|
352
|
+
const neg = e.cond.kind === "unop" && e.cond.op === "!"
|
|
353
|
+
? parseArrayIsArrayCall(e.cond.expr)
|
|
354
|
+
: null;
|
|
355
|
+
const matched = pos ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
|
|
356
|
+
if (!matched)
|
|
357
|
+
return null;
|
|
358
|
+
const thenBody = pos ? e.then : e.else;
|
|
359
|
+
const elseBody = pos ? e.else : e.then;
|
|
360
|
+
return {
|
|
361
|
+
kind: "tagMatch",
|
|
362
|
+
scrutinee: matched.scrutinee,
|
|
363
|
+
typeName: matched.typeName,
|
|
364
|
+
cases: [{ variant: matched.variant, body: walkExpr(thenBody) }],
|
|
365
|
+
fallthrough: walkExpr(elseBody),
|
|
366
|
+
ty: e.ty,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
310
369
|
/** Rule (expression): `(path !== undefined [&& rest]) ==> B` — premise narrowing
|
|
311
370
|
* for spec implications (ensures/requires). The premise's optional checks
|
|
312
371
|
* bind narrowed values that the conclusion can use.
|
|
@@ -471,19 +530,29 @@ function ruleConditionalOptionalTruthy(e) {
|
|
|
471
530
|
someBody: e.then, noneBody: e.else, ty: e.ty,
|
|
472
531
|
};
|
|
473
532
|
}
|
|
474
|
-
/** Extract
|
|
475
|
-
* `(x !== undefined && b) && c` → { check, restCond: b && c }.
|
|
533
|
+
/** Extract an optional check from any position in an `&&` chain.
|
|
534
|
+
* `(x !== undefined && b) && c` → { check, restCond: b && c }.
|
|
535
|
+
* `a && (x !== undefined)` → { check, restCond: a }.
|
|
536
|
+
* Conjunct order doesn't carry semantic weight, so either side is fine. */
|
|
476
537
|
function extractLeftmostOptionalCheck(cond) {
|
|
477
538
|
if (cond.kind !== "binop" || cond.op !== "&&")
|
|
478
539
|
return null;
|
|
479
|
-
const
|
|
480
|
-
if (
|
|
481
|
-
return { check, restCond: cond.right };
|
|
540
|
+
const leftCheck = parseSimpleOptionalCheck(cond.left);
|
|
541
|
+
if (leftCheck && !leftCheck.negated)
|
|
542
|
+
return { check: leftCheck, restCond: cond.right };
|
|
543
|
+
const rightCheck = parseSimpleOptionalCheck(cond.right);
|
|
544
|
+
if (rightCheck && !rightCheck.negated)
|
|
545
|
+
return { check: rightCheck, restCond: cond.left };
|
|
482
546
|
if (cond.left.kind === "binop" && cond.left.op === "&&") {
|
|
483
547
|
const inner = extractLeftmostOptionalCheck(cond.left);
|
|
484
548
|
if (inner)
|
|
485
549
|
return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
|
|
486
550
|
}
|
|
551
|
+
if (cond.right.kind === "binop" && cond.right.op === "&&") {
|
|
552
|
+
const inner = extractLeftmostOptionalCheck(cond.right);
|
|
553
|
+
if (inner)
|
|
554
|
+
return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
|
|
555
|
+
}
|
|
487
556
|
return null;
|
|
488
557
|
}
|
|
489
558
|
/** Rule: `if (x !== undefined && rest) then` (no else) where x is a pure
|
|
@@ -510,8 +579,68 @@ function ruleIfAndOptional(s) {
|
|
|
510
579
|
};
|
|
511
580
|
}
|
|
512
581
|
// ── Discriminant narrowing ──────────────────────────────────
|
|
513
|
-
/** Detect `
|
|
514
|
-
*
|
|
582
|
+
/** Detect `Array.isArray(<path>)` where `<path>` is a var or a chain of
|
|
583
|
+
* field accesses rooted at a var, and the path's type is a synthesized
|
|
584
|
+
* array-union (discriminant `"__isArray__"`). Returns the variant name to
|
|
585
|
+
* narrow to. The scrutinee is whatever path the user wrote — downstream
|
|
586
|
+
* transforms substitute it inside the matched arm. */
|
|
587
|
+
function parseArrayIsArrayCall(call) {
|
|
588
|
+
if (call.kind !== "call")
|
|
589
|
+
return null;
|
|
590
|
+
if (call.fn.kind !== "field" || call.fn.field !== "isArray")
|
|
591
|
+
return null;
|
|
592
|
+
if (call.fn.obj.kind !== "var" || call.fn.obj.name !== "Array")
|
|
593
|
+
return null;
|
|
594
|
+
if (call.args.length !== 1)
|
|
595
|
+
return null;
|
|
596
|
+
const arg = call.args[0];
|
|
597
|
+
if (!isNarrowablePath(arg) || arg.ty.kind !== "user")
|
|
598
|
+
return null;
|
|
599
|
+
const baseTyName = arg.ty.name.includes("<") ? arg.ty.name.slice(0, arg.ty.name.indexOf("<")) : arg.ty.name;
|
|
600
|
+
const decl = _typeDecls.find(d => d.name === baseTyName);
|
|
601
|
+
if (decl?.kind !== "discriminated-union" || decl.discriminant !== "__isArray__")
|
|
602
|
+
return null;
|
|
603
|
+
return { scrutinee: arg, typeName: arg.ty.name, variant: "ArrayBranch" };
|
|
604
|
+
}
|
|
605
|
+
/** A "narrowable path" is a var or a chain of field accesses rooted at a var
|
|
606
|
+
* — i.e., pure and structurally addressable, so transforms can substitute
|
|
607
|
+
* occurrences inside a matched arm without worrying about re-evaluation. */
|
|
608
|
+
function isNarrowablePath(e) {
|
|
609
|
+
if (e.kind === "var")
|
|
610
|
+
return true;
|
|
611
|
+
if (e.kind === "field")
|
|
612
|
+
return isNarrowablePath(e.obj);
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
/** Mirror of `extractLeftmostOptionalCheck` for synth-array-union checks:
|
|
616
|
+
* finds `Array.isArray(path)` somewhere in a `&&` chain, returns it plus
|
|
617
|
+
* the remaining conjunction. The check must be the positive form (negated
|
|
618
|
+
* `!Array.isArray(...)` would narrow to the wrong variant for then-body
|
|
619
|
+
* consumers, so we leave those to the existing untouched-conditional path). */
|
|
620
|
+
function extractLeftmostArrayIsArrayCheck(cond) {
|
|
621
|
+
if (cond.kind !== "binop" || cond.op !== "&&")
|
|
622
|
+
return null;
|
|
623
|
+
const leftCheck = parseArrayIsArrayCall(cond.left);
|
|
624
|
+
if (leftCheck)
|
|
625
|
+
return { check: leftCheck, restCond: cond.right };
|
|
626
|
+
const rightCheck = parseArrayIsArrayCall(cond.right);
|
|
627
|
+
if (rightCheck)
|
|
628
|
+
return { check: rightCheck, restCond: cond.left };
|
|
629
|
+
if (cond.left.kind === "binop" && cond.left.op === "&&") {
|
|
630
|
+
const inner = extractLeftmostArrayIsArrayCheck(cond.left);
|
|
631
|
+
if (inner)
|
|
632
|
+
return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
|
|
633
|
+
}
|
|
634
|
+
if (cond.right.kind === "binop" && cond.right.op === "&&") {
|
|
635
|
+
const inner = extractLeftmostArrayIsArrayCheck(cond.right);
|
|
636
|
+
if (inner)
|
|
637
|
+
return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
/** Detect `x.kind === "variant"`, `'key' in x`, or `Array.isArray(x)` (synth
|
|
642
|
+
* array-union) as a positive discriminant check. Returns the scrutinee var
|
|
643
|
+
* (with its type), type name, and variant. */
|
|
515
644
|
function parseDiscriminantCond(cond) {
|
|
516
645
|
// Pattern: x.discriminant === "variant"
|
|
517
646
|
if (cond.kind === "binop" && cond.op === "===" && cond.right.kind === "str" &&
|
|
@@ -534,15 +663,35 @@ function parseDiscriminantCond(cond) {
|
|
|
534
663
|
}
|
|
535
664
|
}
|
|
536
665
|
}
|
|
666
|
+
// Pattern: Array.isArray(x) — narrows x to the ArrayBranch variant of a
|
|
667
|
+
// synthesized array-union (discriminant "__isArray__"). Statement-level
|
|
668
|
+
// discriminant chains (`if (Array.isArray(x)) {...} else if (...)`) still
|
|
669
|
+
// require a bare-var scrutinee since the existing var-name-keyed
|
|
670
|
+
// replacement machinery in transform.ts only handles that shape; path
|
|
671
|
+
// scrutinees (e.g. `m.content`) are handled exclusively by
|
|
672
|
+
// `ruleConditionalArrayIsArray` and the expression-form tagMatch path.
|
|
673
|
+
const arrCheck = parseArrayIsArrayCall(cond);
|
|
674
|
+
if (arrCheck && arrCheck.scrutinee.kind === "var") {
|
|
675
|
+
return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: arrCheck.variant };
|
|
676
|
+
}
|
|
537
677
|
return null;
|
|
538
678
|
}
|
|
539
|
-
/** Detect `x.kind !== "variant"` (negative discriminant check)
|
|
679
|
+
/** Detect `x.kind !== "variant"` (negative discriminant check) or
|
|
680
|
+
* `!Array.isArray(x)` (synth array-union, narrows to NonArrayBranch). */
|
|
540
681
|
function parseNegativeDiscriminantCond(cond) {
|
|
541
682
|
if (cond.kind === "binop" && cond.op === "!==" && cond.right.kind === "str" &&
|
|
542
683
|
cond.left.kind === "field" && cond.left.isDiscriminant &&
|
|
543
684
|
cond.left.obj.kind === "var" && cond.left.obj.ty.kind === "user") {
|
|
544
685
|
return { scrutinee: cond.left.obj, typeName: cond.left.obj.ty.name, variant: cond.right.value };
|
|
545
686
|
}
|
|
687
|
+
// Pattern: !Array.isArray(x) — narrows x to the NonArrayBranch variant.
|
|
688
|
+
// Same var-scrutinee restriction as parseDiscriminantCond.
|
|
689
|
+
if (cond.kind === "unop" && cond.op === "!") {
|
|
690
|
+
const arrCheck = parseArrayIsArrayCall(cond.expr);
|
|
691
|
+
if (arrCheck && arrCheck.scrutinee.kind === "var") {
|
|
692
|
+
return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: "NonArrayBranch" };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
546
695
|
return null;
|
|
547
696
|
}
|
|
548
697
|
function isTerminating(stmts) {
|
|
@@ -657,7 +806,6 @@ function containsMethodCall(e) {
|
|
|
657
806
|
case "num":
|
|
658
807
|
case "str":
|
|
659
808
|
case "bool":
|
|
660
|
-
case "result":
|
|
661
809
|
case "havoc":
|
|
662
810
|
return false;
|
|
663
811
|
case "binop": return containsMethodCall(e.left) || containsMethodCall(e.right);
|
|
@@ -712,6 +860,53 @@ function ruleConditionalAndOptional(e) {
|
|
|
712
860
|
someBody: walkExpr(innerCond), noneBody: e.else, ty: e.ty,
|
|
713
861
|
};
|
|
714
862
|
}
|
|
863
|
+
/** Rule (statement): `if (<rest> && Array.isArray(path) && <more>) then [else]`
|
|
864
|
+
* → `tagMatch path { ArrayBranch => if (<rest && more>) then [else] }`.
|
|
865
|
+
* The remaining conjuncts move inside the matched arm so any narrowing the
|
|
866
|
+
* `then` body relies on (typed `path` accesses) sees the unwrapped variant.
|
|
867
|
+
* Mirrors `ruleIfAndOptional` but for synth array-unions. */
|
|
868
|
+
function ruleIfAndArrayIsArray(s) {
|
|
869
|
+
if (s.kind !== "if")
|
|
870
|
+
return null;
|
|
871
|
+
const extracted = extractLeftmostArrayIsArrayCheck(s.cond);
|
|
872
|
+
if (!extracted)
|
|
873
|
+
return null;
|
|
874
|
+
const { check, restCond } = extracted;
|
|
875
|
+
// Inner if uses the remaining conjunction (or just the then-body if rest is
|
|
876
|
+
// a tautology — but in practice extractLeftmost leaves at least one other
|
|
877
|
+
// conjunct). Walk recursively so nested checks compose.
|
|
878
|
+
const innerThen = [{ kind: "if", cond: restCond, then: s.then, else: s.else }];
|
|
879
|
+
return {
|
|
880
|
+
kind: "tagMatch",
|
|
881
|
+
scrutinee: check.scrutinee,
|
|
882
|
+
typeName: check.typeName,
|
|
883
|
+
cases: [{ variant: check.variant, body: innerThen.map(walkStmt) }],
|
|
884
|
+
fallthrough: s.else,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/** Rule (expression): `(<rest> && Array.isArray(path)) ? a : b`
|
|
888
|
+
* → `tagMatch path { ArrayBranch => (<rest>) ? a : b } fallthrough b`.
|
|
889
|
+
* Mirrors `ruleConditionalAndOptional`. */
|
|
890
|
+
function ruleConditionalAndArrayIsArray(e) {
|
|
891
|
+
if (e.kind !== "conditional")
|
|
892
|
+
return null;
|
|
893
|
+
const extracted = extractLeftmostArrayIsArrayCheck(e.cond);
|
|
894
|
+
if (!extracted)
|
|
895
|
+
return null;
|
|
896
|
+
const { check, restCond } = extracted;
|
|
897
|
+
const innerCond = {
|
|
898
|
+
kind: "conditional",
|
|
899
|
+
cond: restCond, then: e.then, else: e.else, ty: e.ty,
|
|
900
|
+
};
|
|
901
|
+
return {
|
|
902
|
+
kind: "tagMatch",
|
|
903
|
+
scrutinee: check.scrutinee,
|
|
904
|
+
typeName: check.typeName,
|
|
905
|
+
cases: [{ variant: check.variant, body: walkExpr(innerCond) }],
|
|
906
|
+
fallthrough: e.else,
|
|
907
|
+
ty: e.ty,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
715
910
|
// ── Function / module entry ──────────────────────────────────
|
|
716
911
|
function narrowFunction(fn) {
|
|
717
912
|
return {
|
package/tools/dist/peephole.js
CHANGED
|
@@ -12,7 +12,8 @@ function mapExpr(e, f) {
|
|
|
12
12
|
case "constructor":
|
|
13
13
|
case "emptyMap":
|
|
14
14
|
case "emptySet":
|
|
15
|
-
case "havoc":
|
|
15
|
+
case "havoc":
|
|
16
|
+
case "mapLiteral": return e;
|
|
16
17
|
case "binop": return { ...e, left: r(e.left), right: r(e.right) };
|
|
17
18
|
case "unop": return { ...e, expr: r(e.expr) };
|
|
18
19
|
case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
|
|
@@ -355,7 +356,8 @@ function rewriteChildrenExpr(e) {
|
|
|
355
356
|
case "constructor":
|
|
356
357
|
case "emptyMap":
|
|
357
358
|
case "emptySet":
|
|
358
|
-
case "havoc":
|
|
359
|
+
case "havoc":
|
|
360
|
+
case "mapLiteral": return e;
|
|
359
361
|
case "binop": return { ...e, left: r(e.left), right: r(e.right) };
|
|
360
362
|
case "unop": return { ...e, expr: r(e.expr) };
|
|
361
363
|
case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
|
|
@@ -443,6 +445,7 @@ function peepholeDecl(d) {
|
|
|
443
445
|
case "inductive":
|
|
444
446
|
case "structure":
|
|
445
447
|
case "type-alias":
|
|
448
|
+
case "extern":
|
|
446
449
|
return d;
|
|
447
450
|
}
|
|
448
451
|
}
|