Files
scriptc/packages/compiler/src/frontend/lowering/binding-analysis.ts
T

405 lines
16 KiB
TypeScript

import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { isJsSourceFile } from "../program.js";
import { dynFallbackType } from "./lowerer.js";
import { isGenericCallableMemberType } from "../type-mapper.js";
/** True when nothing in `sym`'s DECLARING FILE ever writes it after the
* initializer: assignments (plain and compound, destructuring targets
* included), ++/--, and for-of/for-in expression targets all count.
* Sound file-locally for module-scope bindings because ESM import
* bindings are read-only — no other file can write one. Cached per
* symbol (the scan walks the whole file once). */
export function bindingNeverReassigned(
lowerer: Lowerer,
sym: ts.Symbol,
decl: ts.Node,
resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined = (ident) =>
lowerer.resolveValueSymbol(ident),
): boolean {
const cached = lowerer.neverReassignedCache.get(sym);
if (cached !== undefined) return cached;
const written = fileWritesBinding(sym, ts.getSourceFileOfNode(decl), resolve);
lowerer.neverReassignedCache.set(sym, !written);
return !written;
}
/** True when something in `file` writes `sym` (the writes
* bindingNeverReassigned counts), resolving identifiers with `resolve`. */
export function fileWritesBinding(
sym: ts.Symbol,
file: ts.SourceFile,
resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined,
): boolean {
let written = false;
// Text pre-check keeps the file walk cheap: only same-named
// identifiers pay a symbol resolution.
const symText = ts.symbolName(sym);
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && resolve(e) === sym;
const scanTarget = (t: ts.Expression): void => {
let e: ts.Expression = t;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (namesSym(e)) {
written = true;
return;
}
// Destructuring assignment targets: any identifier inside the LHS
// pattern could be the binding — over-approximate by scanning.
if (ts.isArrayLiteralExpression(e) || ts.isObjectLiteralExpression(e)) {
const walk = (n: ts.Node): void => {
if (namesSym(n)) written = true;
else ts.forEachChild(n, walk);
};
walk(e);
}
};
const visit = (n: ts.Node): void => {
if (written) return;
if (ts.isBinaryExpression(n)) {
const k = n.operatorToken.kind;
if (k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment) {
scanTarget(n.left);
}
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)
) {
scanTarget(n.operand as ts.Expression);
} else if (
(ts.isForOfStatement(n) || ts.isForInStatement(n)) &&
!ts.isVariableDeclarationList(n.initializer)
) {
scanTarget(n.initializer as ts.Expression);
}
ts.forEachChild(n, visit);
};
ts.forEachChild(file, visit);
return written;
}
/** Strips the value-preserving wrappers off an expression: parens,
* non-null assertions, `as`/`satisfies`/angle-bracket casts. What
* remains is the expression that actually evaluates. */
export function stripValueWrappers(e: ts.Expression): ts.Expression {
let v: ts.Expression = e;
for (;;) {
if (
ts.isParenthesizedExpression(v) ||
ts.isNonNullExpression(v) ||
ts.isAsExpression(v) ||
ts.isSatisfiesExpression(v) ||
ts.isTypeAssertion(v)
) {
v = v.expression;
continue;
}
return v;
}
}
/** The nullish unit `e` provably evaluates to: a bare null/undefined
* literal (assertion-wrapped — `null as any`, `null!`), or a read of a
* registered NULLISH binding (nullishGenericBindingUnitOf). Null when
* the value could be anything else. */
export function nullishExprUnitOf(lowerer: Lowerer, e: ts.Expression): "null" | "undefined" | null {
const v = stripValueWrappers(e);
if (v.kind === ts.SyntaxKind.NullKeyword) return "null";
if (ts.isIdentifier(v)) {
if (v.text === "undefined" && (lowerer.typeOf(v).flags & ts.TypeFlags.Undefined) !== 0) {
return "undefined";
}
return nullishValueUnitOf(lowerer, lowerer.resolveValueSymbol(v));
}
return null;
}
/** The nullish unit a binding provably holds FOREVER, by VALUE alone: its
* initializer is nullish (`const i: I<A & B> = null as any`) and every
* write in its declaring file is nullish too (`a = b` where b is
* another nullish binding). No type condition — callers add their own
* (nullishGenericBindingUnitOf gates the no-storage family on
* unmappable types; the generic-method call path rescues its fence with
* the value fact alone). Cached per symbol; the pre-seeded null entry
* guards probe cycles (mutually-assigned bindings resolve link by link,
* declaration order). */
export function nullishValueUnitOf(
lowerer: Lowerer,
sym: ts.Symbol | null,
): "null" | "undefined" | null {
if (!sym) return null;
const cached = lowerer.nullishBindings.get(sym);
if (cached !== undefined) return cached;
lowerer.nullishBindings.set(sym, null); // cycle guard: self-referential probes answer non-qualifying
const decl = sym.valueDeclaration;
// Statement-position declarators only: a for-loop head (`for (let x =
// null as any; ...)`) declares a LOCAL with per-iteration semantics —
// lowerVarDeclList's contract requires a lowered statement for it, so
// the no-storage family never claims it.
if (
!decl ||
!ts.isVariableDeclaration(decl) ||
!ts.isIdentifier(decl.name) ||
ts.getSourceFileOfNode(decl).isDeclarationFile ||
decl.initializer === undefined ||
!ts.isVariableStatement(decl.parent?.parent)
) {
return null;
}
const unit = nullishExprUnitOf(lowerer, decl.initializer);
if (unit === null) return null;
// Bindings with a CHECKED-DYNAMIC fallback (`const maybe: any =
// undefined`, JS inference residue) keep that story: the dyn world
// already holds null/undefined correctly and serves every read form
// (optional chains included) — this family exists for types with NO
// other home.
if (dynFallbackType(lowerer, decl.name, lowerer.checker.getTypeOfSymbol(sym)) !== null)
return null;
if (!allWritesNullish(lowerer, sym, decl)) return null;
// A use inside a class HERITAGE clause (`class X extends Mixin(...)`)
// declines the whole family: heritage resolution is structural (the
// mixin machinery can pin the instantiation from the ARGUMENT class
// expression without ever reading the callee binding), so a claimed
// nullish callee would compile a working class where Node throws
// "Mixin is not a function" evaluating the extends expression. The
// declaration keeps its type fence instead.
if (usedInHeritageClause(lowerer, sym)) return null;
lowerer.nullishBindings.set(sym, unit);
return unit;
}
/** True when any identifier resolving to `sym` sits inside a class
* heritage clause anywhere in the program. */
function usedInHeritageClause(lowerer: Lowerer, sym: ts.Symbol): boolean {
const symText = ts.symbolName(sym);
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
if (ts.isIdentifier(n) && n.text === symText && lowerer.resolveValueSymbol(n) === sym) {
for (
let p: ts.Node | undefined = n.parent;
p !== undefined && !ts.isSourceFile(p);
p = p.parent
) {
if (ts.isHeritageClause(p)) {
found = true;
return;
}
}
return;
}
ts.forEachChild(n, visit);
};
for (const file of lowerer.program.getImplementationSourceFiles()) {
if (found) break;
if (file.isDeclarationFile) continue;
ts.forEachChild(file, visit);
}
return found;
}
/** nullishValueUnitOf gated on a declared type that CANNOT hold the
* value — the NO-STORAGE family: an unmappable type has no other story,
* and a RECORD-mapped one (`const i: I<A & B> = null as any` — an
* interface whose members are all generic signatures interns an empty
* shape) has a slot null can never inhabit, so storing would throw the
* representation error where Node stores null. Either way the
* declaration emits nothing and reads know the value. Null-tolerant
* mappings (unions with a null/undefined arm, dyn) keep their real
* storage and every ordinary lowering. */
export function nullishGenericBindingUnitOf(
lowerer: Lowerer,
sym: ts.Symbol | null,
): "null" | "undefined" | null {
if (!sym) return null;
// The VALUE probe first — it is purely syntactic, so no checker type
// query runs for the overwhelmingly common non-nullish declarations
// (a query can even panic upstream — the 1e999 checker bug).
const unit = nullishValueUnitOf(lowerer, sym);
if (unit === null) return null;
const declared = lowerer.checker.getTypeOfSymbol(sym);
const mapped = lowerer.mapTypeOf(declared);
if (mapped !== null) {
// Only the EMPTY interned shape qualifies among record mappings —
// the all-generic-signature interface (`I<A & B>`) whose struct has
// no slot at all. A record with DATA fields (`const value: { inner:
// number | string } = null as any`) keeps its real storage and
// every ordinary lowering: its reads flow through positions (comma
// chains, call arguments) the no-storage read paths never claim,
// so claiming the binding would fence working programs.
if (mapped.kind !== "record") return null;
const shape = lowerer.shapes.get(mapped.shapeId);
// Erased generic members are function slots, not data: an interface
// of generic signatures only keeps the no-storage story.
const onlyGenericMembers = lowerer.checker
.getPropertiesOfType(declared)
.every((property) =>
isGenericCallableMemberType(lowerer.checker.getTypeOfSymbol(property), lowerer.checker),
);
if (
!shape ||
(shape.fields.length > 0 && !onlyGenericMembers) ||
shape.tuple !== undefined ||
shape.indexValue !== undefined
) {
return null;
}
}
return unit;
}
/** True when every write of `sym` in its declaring file is a plain `x =
* <nullish>` assignment — the discipline that keeps a nullish binding's
* value knowable. Compound assignments, ++/--, for-in/of cursors, and
* destructuring targets all disqualify. */
function allWritesNullish(lowerer: Lowerer, sym: ts.Symbol, decl: ts.Node): boolean {
const symText = ts.symbolName(sym);
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && lowerer.resolveValueSymbol(e) === sym;
let ok = true;
const visit = (n: ts.Node): void => {
if (!ok) return;
if (ts.isBinaryExpression(n)) {
const k = n.operatorToken.kind;
if (k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment) {
let lhs: ts.Expression = n.left;
while (ts.isParenthesizedExpression(lhs)) lhs = lhs.expression;
if (namesSym(lhs)) {
if (k !== ts.SyntaxKind.EqualsToken || nullishExprUnitOf(lowerer, n.right) === null)
ok = false;
} else if (ts.isArrayLiteralExpression(lhs) || ts.isObjectLiteralExpression(lhs)) {
const walk = (m: ts.Node): void => {
if (namesSym(m)) ok = false;
else ts.forEachChild(m, walk);
};
walk(lhs);
}
}
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)
) {
let op: ts.Expression = n.operand as ts.Expression;
while (ts.isParenthesizedExpression(op)) op = op.expression;
if (namesSym(op)) ok = false;
} else if (
(ts.isForOfStatement(n) || ts.isForInStatement(n)) &&
!ts.isVariableDeclarationList(n.initializer)
) {
let t: ts.Node = n.initializer;
while (ts.isParenthesizedExpression(t as ts.Expression))
t = (t as ts.ParenthesizedExpression).expression;
if (namesSym(t)) ok = false;
}
ts.forEachChild(n, visit);
};
ts.forEachChild(ts.getSourceFileOfNode(decl), visit);
return ok;
}
/** A VALUE-ONLY expression: materializing it has no observable effect
* beyond the value itself — function/arrow literals, class-free
* literals, nullish units. The dead-binding rule's purity test: Node
* builds the value and drops it, so skipping the build entirely is
* unobservable. Bare identifier reads stay OUT (a read above a `let`
* declaration is a TDZ throw Node WOULD serve). */
function sideEffectFreeValueExpr(lowerer: Lowerer, e: ts.Expression): boolean {
const v = stripValueWrappers(e);
if (ts.isArrowFunction(v) || ts.isFunctionExpression(v)) return true;
if (
ts.isLiteralExpression(v) ||
v.kind === ts.SyntaxKind.NullKeyword ||
v.kind === ts.SyntaxKind.TrueKeyword ||
v.kind === ts.SyntaxKind.FalseKeyword
) {
return true;
}
if (
ts.isIdentifier(v) &&
v.text === "undefined" &&
(lowerer.typeOf(v).flags & ts.TypeFlags.Undefined) !== 0
) {
return true;
}
return false;
}
/** True when `sym` — a binding whose type has NO static mapping — is DEAD:
* never read anywhere in the program, not exported through a specifier,
* declared with no initializer or a side-effect-free one, and written
* (if at all) only by plain assignments of side-effect-free values. Node
* materializes those values and drops them — zero observable effect —
* so the declaration and its writes lower to NOTHING instead of fencing
* on a type the program never consumes (`var xs2: typeof Array;`, the
* write-only `var f2: { <T, U>(x: T, y: U): T }`). TS program files
* only: JS bindings keep their checked-dynamic fallbacks. Positive
* answers register in lowerer.deadBindings (the assignment lowering skips
* writes by the same set). */
export function deadUnmappableBinding(
lowerer: Lowerer,
sym: ts.Symbol | null,
decl: ts.VariableDeclaration,
): boolean {
if (!sym) return false;
if (lowerer.deadBindings.has(sym)) return true;
if (!ts.isIdentifier(decl.name)) return false;
// Statement-position declarators only: a for-loop head (`for (let x;
// false;) {}`) declares a LOCAL with per-iteration semantics —
// lowerVarDeclList's contract requires a lowered statement for it, so
// the no-storage family never claims it (catch bindings sit outside a
// variable statement too and stay out the same way).
if (!ts.isVariableStatement(decl.parent?.parent)) return false;
const sf = ts.getSourceFileOfNode(decl);
if (sf.isDeclarationFile || isJsSourceFile(sf)) return false;
if (decl.initializer !== undefined && !sideEffectFreeValueExpr(lowerer, decl.initializer))
return false;
// Exported bindings stay out: a library build's exports are consumed
// from outside the graph, and export specifiers double as reads.
if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) return false;
// The type gate LAST among the cheap checks: querying the checker for
// a type is the expensive step (and can panic upstream — the 1e999
// bug), so only survivors of the syntactic filters pay it. Mappable
// types keep their real storage.
if (lowerer.mapTypeOf(lowerer.checker.getTypeOfSymbol(sym)) !== null) return false;
const symText = ts.symbolName(sym);
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && lowerer.resolveValueSymbol(e) === sym;
let dead = true;
const visit = (n: ts.Node): void => {
if (!dead) return;
if (ts.isIdentifier(n) && n.text === symText) {
// Declaration-name occurrences are not reads.
if (n.parent !== undefined && ts.isVariableDeclaration(n.parent) && n.parent.name === n) {
ts.forEachChild(n, visit);
return;
}
// A plain-assignment LHS is a WRITE — dead only when the RHS
// builds no observable effect (the value is dropped with the
// binding).
const p = n.parent;
if (
p !== undefined &&
ts.isBinaryExpression(p) &&
p.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
p.left === n
) {
if (!namesSym(n)) return;
if (!sideEffectFreeValueExpr(lowerer, p.right)) dead = false;
return;
}
// Import/export specifiers, and every other occurrence, count as
// reads.
if (namesSym(n)) dead = false;
return;
}
ts.forEachChild(n, visit);
};
for (const file of lowerer.program.getImplementationSourceFiles()) {
if (!dead) break;
if (file.isDeclarationFile) continue;
ts.forEachChild(file, visit);
}
if (dead) lowerer.deadBindings.add(sym);
return dead;
}