mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-05 08:13:06 +08:00
847 lines
34 KiB
TypeScript
847 lines
34 KiB
TypeScript
import * as ts from "../ts7/adapter.js";
|
|
import { indexStoreInBounds } from "./runtime-optional-bounds.js";
|
|
|
|
/** Program queries the element-state scan needs from the optional-read
|
|
* analysis. Element keys name a native array's element ABI (`typeKey` of
|
|
* the element type); arrays only alias other arrays with the same key,
|
|
* because a conversion to another element type copies the elements and
|
|
* rejects holes and undefined while it does. */
|
|
export interface ElementStateHost {
|
|
/** Element keys of every native array `node` can evaluate to: `[]` when
|
|
* it is not a native array (or is a dynamic value, whose own storage is
|
|
* separate), `null` when its element ABI is not known yet (a generic
|
|
* element type). */
|
|
arrayKeys(node: ts.Expression): readonly string[] | null;
|
|
/** The element type behind this key admits `undefined` itself. */
|
|
elementAdmitsUndefined(key: string): boolean;
|
|
/** The value stored by `node` can be `undefined` at runtime. */
|
|
mayBeUndefined(node: ts.Expression): boolean;
|
|
/** A callback argument can return `undefined` at runtime. */
|
|
callbackReturnsUndefined(node: ts.Expression): boolean;
|
|
/** The value may be a plain array-like object, whose missing indices
|
|
* read as undefined. */
|
|
isArrayLikeObject(node: ts.Expression): boolean;
|
|
/** The expression's static type is a string. */
|
|
isString(node: ts.Expression): boolean;
|
|
/** `node` names the standard global `name` (`Array`, `Object`, ...). */
|
|
isGlobal(node: ts.Expression, name: string): boolean;
|
|
/** Element keys of the rest parameter that the spread arguments of
|
|
* `call` fill: `[]` when the callee has none, null when it is unknown. */
|
|
restKeys(call: ts.CallExpression | ts.NewExpression): readonly string[] | null;
|
|
/** Record that iterating `node`'s binding can observe `undefined`;
|
|
* `known` when the iterated array's own facts (not only those shared by
|
|
* its element ABI) say so. Returns true when the binding's record grew. */
|
|
markIterationBinding(node: ts.Identifier, known: boolean): boolean;
|
|
/** The storage slot that alone owns the array `node` references, whose
|
|
* facts are tracked for that slot instead of its element ABI. */
|
|
ownerOf(node: ts.Expression): ts.Symbol | null;
|
|
/** The owning slot a newly built array is stored into. */
|
|
allocationOwner(node: ts.Expression): ts.Symbol | null;
|
|
/** `node` is a dynamic value (`any`, `unknown`, `unknown[]`): when it is
|
|
* a live view of a native array, a store through it writes that array's
|
|
* storage. */
|
|
isDynamicArray(node: ts.Expression): boolean;
|
|
/** Element keys of the native arrays a value converted to a dynamic type
|
|
* can reach, as live views ("*" for any array). */
|
|
nativeViewKeys(): ReadonlySet<string>;
|
|
/** Debug provenance for a new fact. */
|
|
note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null;
|
|
/** The scan read, or added to, the facts of an element key, of an owning
|
|
* slot, or of every array (ANY_ARRAY), for dependency tracking
|
|
* (SiteDependencies). */
|
|
read?(fact: string | ts.Symbol): void;
|
|
changed?(fact: string | ts.Symbol): void;
|
|
}
|
|
|
|
/** The fact name of the facts shared by every array. */
|
|
const ANY_ARRAY = "*";
|
|
/** The fact name of holes stored through dynamic views. */
|
|
const VIEW_HOLES = "%view";
|
|
|
|
/** What an array value can contain besides present values. */
|
|
interface Contents {
|
|
undefined: boolean;
|
|
holes: boolean;
|
|
}
|
|
|
|
const NOTHING: Contents = { undefined: false, holes: false };
|
|
|
|
/** Methods whose callbacks also visit holes, as `undefined`. */
|
|
const HOLE_VISITING = new Set(["find", "findIndex", "findLast", "findLastIndex"]);
|
|
|
|
/** Array methods returning a new array, by what happens to the receiver's
|
|
* holes in it, and methods returning the receiver itself. */
|
|
const HOLES_KEPT = new Set(["slice", "splice", "concat"]);
|
|
const HOLES_SKIPPED = new Set(["filter", "flat"]);
|
|
const HOLES_FILLED = new Set(["toSorted", "toReversed", "toSpliced", "with"]);
|
|
const IN_PLACE = new Set(["sort", "reverse", "fill", "copyWithin"]);
|
|
|
|
/** Whole-program facts about which native array element ABIs can hold an
|
|
* explicit `undefined` value or a hole, refined per expression for arrays
|
|
* an expression creates itself. An element parameter of an array callback
|
|
* is present whenever its receiver can hold neither: the callback methods
|
|
* skip holes (except the find family), and every explicit `undefined`
|
|
* enters a native array through a store, a copy, or a builtin scanned
|
|
* here. Facts only grow, so the enclosing fixed point stays monotone. */
|
|
export class ArrayElementStates {
|
|
private readonly undefinedKeys = new Set<string>();
|
|
private readonly holeyKeys = new Set<string>();
|
|
/** A store wrote a possibly undefined value or a hole into an array
|
|
* whose element ABI is decided per generic instantiation. */
|
|
private anyUndefined = false;
|
|
private anyHoley = false;
|
|
/** A store through a dynamic value can leave holes; they land in every
|
|
* native array such a value views. */
|
|
private viewHoles = false;
|
|
private viewKeys: ReadonlySet<string> | null = null;
|
|
/** Facts of arrays owned by one storage slot. */
|
|
private readonly undefinedOwners = new Set<ts.Symbol>();
|
|
private readonly holeyOwners = new Set<ts.Symbol>();
|
|
|
|
/** The node being scanned, for provenance notes. */
|
|
private site: ts.Node | undefined;
|
|
/** While set, arrays judged only by their element ABI count as present. */
|
|
private ownFactsOnly = false;
|
|
|
|
constructor(private readonly host: ElementStateHost) {}
|
|
|
|
/** Every element a callback of `method` receives from `receiver` is a
|
|
* present value. */
|
|
elementsPresent(receiver: ts.Expression, method: string): boolean {
|
|
const contents = this.contents(receiver);
|
|
return (
|
|
contents !== null && !contents.undefined && !(HOLE_VISITING.has(method) && contents.holes)
|
|
);
|
|
}
|
|
|
|
/** Scans one syntax node; true when a fact was added. */
|
|
scan(node: ts.Node): boolean {
|
|
this.site = node;
|
|
if (ts.isBinaryExpression(node)) return this.scanAssignment(node);
|
|
if (
|
|
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
) {
|
|
// `xs[i]++` writes index i, and `xs.length++` grows the array.
|
|
const target = peel(node.operand as ts.Expression);
|
|
if (ts.isElementAccessExpression(target)) return this.addHolesTo(target.expression);
|
|
if (
|
|
ts.isPropertyAccessExpression(target) &&
|
|
target.name.text === "length" &&
|
|
node.operator === ts.SyntaxKind.PlusPlusToken
|
|
)
|
|
return this.addHolesTo(target.expression);
|
|
return false;
|
|
}
|
|
if (ts.isDeleteExpression(node)) {
|
|
const target = peel(node.expression);
|
|
return ts.isElementAccessExpression(target) ? this.addHolesTo(target.expression) : false;
|
|
}
|
|
if (ts.isArrayLiteralExpression(node)) return this.record(node);
|
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) return this.scanCall(node);
|
|
if (ts.isForOfStatement(node)) return this.scanForOf(node);
|
|
if (ts.isArrayBindingPattern(node)) return this.scanRestPattern(node);
|
|
return false;
|
|
}
|
|
|
|
/** `[a, ...rest] = source` copies the remaining indices into a new
|
|
* array: holes become undefined. */
|
|
private scanRestPattern(pattern: ts.ArrayBindingPattern): boolean {
|
|
const last = pattern.elements.nodes[pattern.elements.nodes.length - 1];
|
|
if (!last || ts.isOmittedExpression(last) || !last.dotDotDotToken) return false;
|
|
// A parameter or nested pattern has no source expression to judge.
|
|
const owner = pattern.parent;
|
|
const source =
|
|
ts.isVariableDeclaration(owner) && owner.name === pattern && owner.initializer
|
|
? this.contents(owner.initializer)
|
|
: null;
|
|
if (source !== null && !source.undefined && !source.holes) return false;
|
|
const target = last.name;
|
|
if (!ts.isIdentifier(target)) return false;
|
|
return this.addUndefined(this.host.arrayKeys(target));
|
|
}
|
|
|
|
/** `[a, ...rest] = source` as an assignment. */
|
|
private scanRestAssignment(node: ts.BinaryExpression): boolean {
|
|
const pattern = peel(node.left);
|
|
if (!ts.isArrayLiteralExpression(pattern)) return false;
|
|
const last = pattern.elements.nodes[pattern.elements.nodes.length - 1];
|
|
if (!last || !ts.isSpreadElement(last)) return false;
|
|
if (!this.mayYieldUndefined(node.right)) return false;
|
|
return this.addUndefinedTo(last.expression);
|
|
}
|
|
|
|
/** Contents of the array `node` evaluates to; null when its element ABI
|
|
* is generic. Arrays the expression creates are judged by how they are
|
|
* built; any other array by the facts of its element ABI. */
|
|
private contents(node: ts.Expression): Contents | null {
|
|
const e = peel(node);
|
|
if (ts.isArrayLiteralExpression(e)) {
|
|
let undefinedValue = false;
|
|
let holes = false;
|
|
for (const element of e.elements.nodes) {
|
|
if (ts.isOmittedExpression(element)) holes = true;
|
|
else if (this.valueMayBeUndefined(element)) undefinedValue = true;
|
|
}
|
|
return { undefined: undefinedValue, holes };
|
|
}
|
|
if (ts.isConditionalExpression(e))
|
|
return merge(this.contents(e.whenTrue), this.contents(e.whenFalse));
|
|
if (ts.isBinaryExpression(e)) {
|
|
const op = e.operatorToken.kind;
|
|
if (op === ts.SyntaxKind.QuestionQuestionToken || op === ts.SyntaxKind.BarBarToken)
|
|
return merge(this.contents(e.left), this.contents(e.right));
|
|
if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.CommaToken)
|
|
return this.contents(e.right);
|
|
}
|
|
if (ts.isCallExpression(e) || ts.isNewExpression(e)) {
|
|
const built = this.builtContents(e);
|
|
if (built !== undefined) return built;
|
|
}
|
|
const owner = this.host.ownerOf(e);
|
|
if (owner !== null) {
|
|
this.host.read?.(owner);
|
|
return { undefined: this.undefinedOwners.has(owner), holes: this.holeyOwners.has(owner) };
|
|
}
|
|
return this.stored(this.host.arrayKeys(e));
|
|
}
|
|
|
|
/** Contents of an array a call creates; undefined when the call is not a
|
|
* known array constructor or method. */
|
|
private builtContents(node: ts.CallExpression | ts.NewExpression): Contents | null | undefined {
|
|
const args = node.arguments?.nodes ?? [];
|
|
const callee = peel(node.expression);
|
|
if (ts.isIdentifier(callee)) {
|
|
if (!this.host.isGlobal(callee, "Array")) return undefined;
|
|
// `new Array(n)` allocates holes; more arguments are the elements.
|
|
if (args.length === 1 && !ts.isSpreadElement(args[0]!) && isNumberLike(args[0]!))
|
|
return { undefined: false, holes: true };
|
|
return { undefined: args.some((arg) => this.valueMayBeUndefined(arg)), holes: false };
|
|
}
|
|
if (!ts.isPropertyAccessExpression(callee)) return undefined;
|
|
const method = callee.name.text;
|
|
const owner = callee.expression;
|
|
if (this.host.isGlobal(owner, "Array")) {
|
|
if (method === "of")
|
|
return { undefined: args.some((arg) => this.valueMayBeUndefined(arg)), holes: false };
|
|
if (method === "from" || method === "fromAsync") {
|
|
const source = args[0];
|
|
const mapper = args[1];
|
|
// Without a mapper an array-like's missing indices become undefined.
|
|
const fromSource =
|
|
source !== undefined &&
|
|
!ts.isSpreadElement(source) &&
|
|
(this.mayYieldUndefined(source) ||
|
|
(mapper === undefined && this.host.isArrayLikeObject(source)));
|
|
const fromMapper = mapper !== undefined && this.host.callbackReturnsUndefined(mapper);
|
|
return { undefined: fromSource || fromMapper, holes: false };
|
|
}
|
|
return undefined;
|
|
}
|
|
if (this.host.isGlobal(owner, "Object")) {
|
|
// Present-undefined properties are listed as values.
|
|
return method === "values" || method === "entries"
|
|
? { undefined: true, holes: false }
|
|
: undefined;
|
|
}
|
|
if (this.host.isGlobal(owner, "Promise")) {
|
|
// A settled value can be a runtime-absent result.
|
|
return method === "all" || method === "allSettled"
|
|
? { undefined: true, holes: false }
|
|
: undefined;
|
|
}
|
|
if (method === "match" || method === "matchAll" || method === "exec" || method === "split") {
|
|
const separator = args[0];
|
|
if (
|
|
method === "split" &&
|
|
separator !== undefined &&
|
|
!ts.isSpreadElement(separator) &&
|
|
this.host.isString(separator)
|
|
)
|
|
return NOTHING;
|
|
// Non-participating capture groups produce undefined entries.
|
|
return { undefined: true, holes: false };
|
|
}
|
|
const receiverKeys = this.host.arrayKeys(owner);
|
|
if (receiverKeys !== null && receiverKeys.length === 0) return undefined;
|
|
if (method === "map") {
|
|
const callback = args[0];
|
|
const receiver = this.contents(owner);
|
|
// map keeps the receiver's holes; its values are the callback's.
|
|
if (receiver === null) return null;
|
|
return {
|
|
undefined: callback !== undefined && this.host.callbackReturnsUndefined(callback),
|
|
holes: receiver.holes,
|
|
};
|
|
}
|
|
if (method === "flatMap") {
|
|
const callback = args[0];
|
|
if (callback !== undefined && this.host.callbackReturnsUndefined(callback))
|
|
return { undefined: true, holes: false };
|
|
// Returned arrays are flattened, keeping their explicit undefined.
|
|
const flattened = this.stored(this.host.arrayKeys(node));
|
|
return flattened === null ? null : { undefined: flattened.undefined, holes: false };
|
|
}
|
|
if (
|
|
!IN_PLACE.has(method) &&
|
|
!HOLES_KEPT.has(method) &&
|
|
!HOLES_SKIPPED.has(method) &&
|
|
!HOLES_FILLED.has(method)
|
|
)
|
|
return undefined;
|
|
const receiver = this.contents(owner);
|
|
// A whole-array fill writes every slot.
|
|
if (method === "fill" && args.length === 1 && !ts.isSpreadElement(args[0]!))
|
|
return receiver === null
|
|
? null
|
|
: { undefined: this.host.mayBeUndefined(args[0]!), holes: false };
|
|
if (IN_PLACE.has(method) || receiver === null) return receiver;
|
|
if (HOLES_KEPT.has(method)) {
|
|
let undefinedValue = receiver.undefined;
|
|
let holes = receiver.holes;
|
|
// concat appends its arguments; the slices splice returns keep holes.
|
|
if (method === "concat") {
|
|
for (const arg of args) {
|
|
if (ts.isSpreadElement(arg)) {
|
|
if (this.mayYieldUndefined(arg.expression)) undefinedValue = true;
|
|
continue;
|
|
}
|
|
const keys = this.host.arrayKeys(arg);
|
|
if (keys !== null && keys.length === 0) {
|
|
if (this.host.mayBeUndefined(arg)) undefinedValue = true;
|
|
continue;
|
|
}
|
|
const other = this.contents(arg);
|
|
if (other === null) return null;
|
|
undefinedValue ||= other.undefined;
|
|
holes ||= other.holes;
|
|
}
|
|
}
|
|
return { undefined: undefinedValue, holes };
|
|
}
|
|
if (HOLES_SKIPPED.has(method)) {
|
|
if (method === "flat") {
|
|
// Nested arrays are flattened, keeping their explicit undefined.
|
|
const flattened = this.stored(this.host.arrayKeys(node));
|
|
if (flattened === null) return null;
|
|
return { undefined: receiver.undefined || flattened.undefined, holes: false };
|
|
}
|
|
return { undefined: receiver.undefined, holes: false };
|
|
}
|
|
let undefinedValue = receiver.undefined || receiver.holes;
|
|
if (method === "with" && args[1] !== undefined && this.valueMayBeUndefined(args[1]))
|
|
undefinedValue = true;
|
|
if (method === "toSpliced")
|
|
for (const arg of args.slice(2)) if (this.valueMayBeUndefined(arg)) undefinedValue = true;
|
|
return { undefined: undefinedValue, holes: false };
|
|
}
|
|
|
|
/** The facts recorded for arrays with these element ABIs. */
|
|
private stored(keys: readonly string[] | null): Contents | null {
|
|
if (this.ownFactsOnly) return NOTHING;
|
|
this.host.read?.(ANY_ARRAY);
|
|
if (keys === null) return null;
|
|
for (const key of keys) this.host.read?.(key);
|
|
this.host.read?.(VIEW_HOLES);
|
|
return {
|
|
undefined:
|
|
this.anyUndefined ||
|
|
keys.some((key) => this.undefinedKeys.has(key) || this.host.elementAdmitsUndefined(key)),
|
|
holes: this.anyHoley || keys.some((key) => this.holeyKeys.has(key) || this.viewed(key)),
|
|
};
|
|
}
|
|
|
|
/** Holes stored through a dynamic view can be in arrays of this key. */
|
|
private viewed(key: string): boolean {
|
|
if (!this.viewHoles) return false;
|
|
this.viewKeys ??= this.host.nativeViewKeys();
|
|
return this.viewKeys.has(key) || this.viewKeys.has(ANY_ARRAY);
|
|
}
|
|
|
|
/** A store through `receiver`, which has no native element key, can
|
|
* leave holes. Only a dynamic value can be a view of a native array. */
|
|
private addViewHoles(receiver: ts.Expression): boolean {
|
|
if (this.viewHoles || !this.host.isDynamicArray(receiver)) return false;
|
|
this.viewHoles = true;
|
|
this.host.changed?.(VIEW_HOLES);
|
|
this.host.note?.("holes", VIEW_HOLES, this.site);
|
|
return true;
|
|
}
|
|
|
|
/** Reading every index of `node` (a spread, an iteration, `Array.from`)
|
|
* can produce `undefined`. Only native arrays can: other iterables reject
|
|
* an undefined value when it is stored. */
|
|
private mayYieldUndefined(node: ts.Expression): boolean {
|
|
const keys = this.host.arrayKeys(node);
|
|
if (keys !== null && keys.length === 0) return false;
|
|
const contents = this.contents(node);
|
|
this.host.read?.(ANY_ARRAY);
|
|
return contents === null
|
|
? this.anyUndefined || this.anyHoley
|
|
: contents.undefined || contents.holes;
|
|
}
|
|
|
|
/** One stored value, or the elements a spread copies. */
|
|
private valueMayBeUndefined(value: ts.Expression): boolean {
|
|
if (ts.isSpreadElement(value)) return this.mayYieldUndefined(value.expression);
|
|
return this.host.mayBeUndefined(value);
|
|
}
|
|
|
|
/** Records an array the expression creates, for the slot that owns it
|
|
* or else under its element ABI. */
|
|
private record(node: ts.Expression): boolean {
|
|
// Only array constructors and methods build an array whose contents
|
|
// can differ from what its element ABI already records.
|
|
if ((ts.isCallExpression(node) || ts.isNewExpression(node)) && !buildsArray(node)) return false;
|
|
if (consumedWhereBuilt(node)) return false;
|
|
// An empty literal holds nothing.
|
|
if (ts.isArrayLiteralExpression(node) && node.elements.nodes.length === 0) return false;
|
|
const contents = this.contents(node);
|
|
if (contents === null || (!contents.undefined && !contents.holes)) return false;
|
|
const keys = this.keysAsUsed(node);
|
|
if (keys !== null && keys.length === 0) return false;
|
|
const owner = this.host.allocationOwner(node);
|
|
if (owner !== null) {
|
|
let changed = false;
|
|
if (contents.undefined && !this.undefinedOwners.has(owner)) {
|
|
this.undefinedOwners.add(owner);
|
|
changed = true;
|
|
}
|
|
if (contents.holes && !this.holeyOwners.has(owner)) {
|
|
this.holeyOwners.add(owner);
|
|
changed = true;
|
|
}
|
|
if (changed) this.host.changed?.(owner);
|
|
return changed;
|
|
}
|
|
let changed = false;
|
|
if (contents.undefined && this.addUndefined(keys)) changed = true;
|
|
if (contents.holes && this.addHoles(keys)) changed = true;
|
|
return changed;
|
|
}
|
|
|
|
/** Element ABIs of a built array, including those an enclosing type
|
|
* assertion gives it (`[a, , b] as T[]`). */
|
|
private keysAsUsed(node: ts.Expression): readonly string[] | null {
|
|
const keys = new Set<string>();
|
|
for (let e: ts.Node = node; ; e = e.parent!) {
|
|
const own = this.host.arrayKeys(e as ts.Expression);
|
|
if (own === null) return null;
|
|
for (const key of own) keys.add(key);
|
|
const parent = e.parent;
|
|
if (
|
|
!parent ||
|
|
!(
|
|
ts.isParenthesizedExpression(parent) ||
|
|
ts.isAsExpression(parent) ||
|
|
ts.isTypeAssertion(parent) ||
|
|
ts.isSatisfiesExpression(parent) ||
|
|
ts.isNonNullExpression(parent)
|
|
)
|
|
)
|
|
break;
|
|
}
|
|
return [...keys];
|
|
}
|
|
|
|
/** A store through `receiver` can add an undefined value. */
|
|
private addUndefinedTo(receiver: ts.Expression): boolean {
|
|
const owner = this.host.ownerOf(receiver);
|
|
if (owner === null) return this.addUndefined(this.host.arrayKeys(receiver));
|
|
if (this.undefinedOwners.has(owner)) return false;
|
|
this.undefinedOwners.add(owner);
|
|
this.host.changed?.(owner);
|
|
return true;
|
|
}
|
|
|
|
/** A store through `receiver` can leave holes. */
|
|
private addHolesTo(receiver: ts.Expression): boolean {
|
|
const owner = this.host.ownerOf(receiver);
|
|
if (owner === null) {
|
|
const keys = this.host.arrayKeys(receiver);
|
|
if (keys !== null && keys.length === 0) return this.addViewHoles(receiver);
|
|
return this.addHoles(keys);
|
|
}
|
|
if (this.holeyOwners.has(owner)) return false;
|
|
this.holeyOwners.add(owner);
|
|
this.host.changed?.(owner);
|
|
return true;
|
|
}
|
|
|
|
private addUndefined(keys: readonly string[] | null): boolean {
|
|
if (keys === null) {
|
|
if (this.anyUndefined) return false;
|
|
this.anyUndefined = true;
|
|
this.host.changed?.(ANY_ARRAY);
|
|
this.host.note?.("undefined", "*", this.site);
|
|
return true;
|
|
}
|
|
let changed = false;
|
|
for (const key of keys) {
|
|
if (this.undefinedKeys.has(key)) continue;
|
|
this.undefinedKeys.add(key);
|
|
this.host.changed?.(key);
|
|
this.host.note?.("undefined", key, this.site);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
private addHoles(keys: readonly string[] | null): boolean {
|
|
if (keys === null) {
|
|
if (this.anyHoley) return false;
|
|
this.anyHoley = true;
|
|
this.host.changed?.(ANY_ARRAY);
|
|
this.host.note?.("holes", "*", this.site);
|
|
return true;
|
|
}
|
|
let changed = false;
|
|
for (const key of keys) {
|
|
if (this.holeyKeys.has(key)) continue;
|
|
this.holeyKeys.add(key);
|
|
this.host.changed?.(key);
|
|
this.host.note?.("holes", key, this.site);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
private scanAssignment(node: ts.BinaryExpression): boolean {
|
|
const op = node.operatorToken.kind;
|
|
if (op < ts.SyntaxKind.FirstAssignment || op > ts.SyntaxKind.LastAssignment) return false;
|
|
const target = peel(node.left);
|
|
if (op === ts.SyntaxKind.EqualsToken && ts.isArrayLiteralExpression(target))
|
|
return this.scanRestAssignment(node);
|
|
if (ts.isElementAccessExpression(target)) {
|
|
// A write past the end leaves holes before it; appending at the
|
|
// receiver's own length does not.
|
|
const holes = !isAppendIndex(target) && !indexStoreInBounds(node);
|
|
const storesValue =
|
|
op === ts.SyntaxKind.EqualsToken ||
|
|
op === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
|
op === ts.SyntaxKind.BarBarEqualsToken ||
|
|
op === ts.SyntaxKind.AmpersandAmpersandEqualsToken;
|
|
const undefinedValue = storesValue && this.host.mayBeUndefined(node.right);
|
|
if (!holes && !undefinedValue) return false;
|
|
const keys = this.host.arrayKeys(target.expression);
|
|
if (keys !== null && keys.length === 0)
|
|
return holes && !namesProperty(target) && this.addViewHoles(target.expression);
|
|
let changed = false;
|
|
if (holes && this.addHolesTo(target.expression)) changed = true;
|
|
if (undefinedValue && this.addUndefinedTo(target.expression)) changed = true;
|
|
return changed;
|
|
}
|
|
if (
|
|
ts.isPropertyAccessExpression(target) &&
|
|
target.name.text === "length" &&
|
|
!isShrinkingLength(node, target)
|
|
) {
|
|
return this.addHolesTo(target.expression);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private scanCall(node: ts.CallExpression | ts.NewExpression): boolean {
|
|
let changed = false;
|
|
const args = node.arguments?.nodes ?? [];
|
|
// Spreading a sparse array into a rest parameter fills the holes.
|
|
if (args.some((arg) => ts.isSpreadElement(arg))) {
|
|
for (const arg of args) {
|
|
if (!ts.isSpreadElement(arg) || !this.mayYieldUndefined(arg.expression)) continue;
|
|
const rest = this.host.restKeys(node) ?? this.host.arrayKeys(arg.expression);
|
|
if (this.addUndefined(rest)) changed = true;
|
|
}
|
|
}
|
|
if (this.record(node)) changed = true;
|
|
const callee = peel(node.expression);
|
|
if (!ts.isPropertyAccessExpression(callee)) return changed;
|
|
if (this.scanReflectiveWrite(callee, args)) changed = true;
|
|
// In-place stores into the receiver.
|
|
let stored: readonly ts.Expression[];
|
|
switch (callee.name.text) {
|
|
case "push":
|
|
case "unshift":
|
|
stored = args;
|
|
break;
|
|
case "splice":
|
|
stored = args.slice(2);
|
|
break;
|
|
case "fill":
|
|
if (args.length === 0 && this.addUndefinedTo(callee.expression)) changed = true;
|
|
stored = args.slice(0, 1);
|
|
break;
|
|
default:
|
|
return changed;
|
|
}
|
|
const receiver = this.host.arrayKeys(callee.expression);
|
|
if (receiver !== null && receiver.length === 0) return changed;
|
|
for (const value of stored)
|
|
if (this.valueMayBeUndefined(value) && this.addUndefinedTo(callee.expression)) changed = true;
|
|
return changed;
|
|
}
|
|
|
|
/** Writes that bypass the array methods: `Object.assign(xs, ...)`,
|
|
* `Reflect.set(xs, ...)`, `Array.prototype.push.call(xs, ...)` and the
|
|
* like may store any value at any index of an array argument. */
|
|
private scanReflectiveWrite(
|
|
callee: ts.PropertyAccessExpression,
|
|
args: readonly ts.Expression[],
|
|
): boolean {
|
|
const method = callee.name.text;
|
|
const owner = peel(callee.expression);
|
|
const reflective =
|
|
(this.host.isGlobal(owner, "Object") &&
|
|
(method === "assign" ||
|
|
method === "defineProperty" ||
|
|
method === "defineProperties" ||
|
|
method === "setPrototypeOf")) ||
|
|
(this.host.isGlobal(owner, "Reflect") &&
|
|
(method === "set" || method === "defineProperty" || method === "apply")) ||
|
|
((method === "call" || method === "apply") &&
|
|
ts.isPropertyAccessExpression(owner) &&
|
|
ts.isPropertyAccessExpression(peel(owner.expression)) &&
|
|
(peel(owner.expression) as ts.PropertyAccessExpression).name.text === "prototype" &&
|
|
this.host.isGlobal(
|
|
(peel(owner.expression) as ts.PropertyAccessExpression).expression,
|
|
"Array",
|
|
));
|
|
if (!reflective) return false;
|
|
let changed = false;
|
|
// The written object: `Reflect.apply`'s receiver argument, otherwise
|
|
// the first argument.
|
|
const written = this.host.isGlobal(owner, "Reflect") && method === "apply" ? 1 : 0;
|
|
for (const [index, arg] of args.entries()) {
|
|
const target = ts.isSpreadElement(arg) ? arg.expression : arg;
|
|
const keys = this.host.arrayKeys(target);
|
|
if (keys !== null && keys.length === 0) {
|
|
if (index === written && this.addViewHoles(target)) changed = true;
|
|
continue;
|
|
}
|
|
if (this.addUndefinedTo(target)) changed = true;
|
|
if (this.addHolesTo(target)) changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/** A for-of loop over `node`'s iterable can bind `undefined`: a hole
|
|
* or a stored undefined in the array it walks. When it cannot, every
|
|
* element the loop reads is a present value. */
|
|
loopMayYieldUndefined(node: ts.ForOfStatement): boolean {
|
|
return this.mayYieldUndefined(this.loopSource(node));
|
|
}
|
|
|
|
/** The loop's array itself, as built or as owned by one slot, can hold
|
|
* a hole or undefined. Facts shared by every array with the same element
|
|
* ABI are coarser: a loop judged only by them keeps checked reads. */
|
|
private loopHolesKnown(node: ts.ForOfStatement): boolean {
|
|
const source = this.loopSource(node);
|
|
const keys = this.host.arrayKeys(source);
|
|
if (keys !== null && keys.length === 0) return false;
|
|
this.ownFactsOnly = true;
|
|
try {
|
|
const contents = this.contents(source);
|
|
return contents !== null && (contents.undefined || contents.holes);
|
|
} finally {
|
|
this.ownFactsOnly = false;
|
|
}
|
|
}
|
|
|
|
private loopSource(node: ts.ForOfStatement): ts.Expression {
|
|
let source = peel(node.expression);
|
|
if (
|
|
ts.isCallExpression(source) &&
|
|
ts.isPropertyAccessExpression(source.expression) &&
|
|
(source.expression.name.text === "values" || source.expression.name.text === "entries") &&
|
|
source.arguments.nodes.length === 0
|
|
) {
|
|
const receiver = this.host.arrayKeys(source.expression.expression);
|
|
if (receiver === null || receiver.length > 0) source = source.expression.expression;
|
|
}
|
|
return source;
|
|
}
|
|
|
|
private scanForOf(node: ts.ForOfStatement): boolean {
|
|
if (!this.loopMayYieldUndefined(node)) return false;
|
|
const known = this.loopHolesKnown(node);
|
|
const initializer = node.initializer;
|
|
let changed = false;
|
|
const mark = (name: ts.Node): void => {
|
|
if (ts.isIdentifier(name)) {
|
|
if (this.host.markIterationBinding(name, known)) changed = true;
|
|
return;
|
|
}
|
|
ts.forEachChild(name, mark);
|
|
};
|
|
if (ts.isVariableDeclarationList(initializer)) {
|
|
for (const declaration of initializer.declarations.nodes) mark(declaration.name);
|
|
} else mark(initializer);
|
|
return changed;
|
|
}
|
|
}
|
|
|
|
/** Names of the constructors, methods and builtins that return a new
|
|
* array or their receiver. */
|
|
const ARRAY_BUILDERS = new Set([
|
|
"Array",
|
|
"of",
|
|
"from",
|
|
"fromAsync",
|
|
"values",
|
|
"entries",
|
|
"all",
|
|
"allSettled",
|
|
"match",
|
|
"matchAll",
|
|
"exec",
|
|
"split",
|
|
"map",
|
|
"flatMap",
|
|
...HOLES_KEPT,
|
|
...HOLES_SKIPPED,
|
|
...HOLES_FILLED,
|
|
...IN_PLACE,
|
|
]);
|
|
|
|
function buildsArray(node: ts.CallExpression | ts.NewExpression): boolean {
|
|
const callee = peel(node.expression);
|
|
if (ts.isIdentifier(callee)) return callee.text === "Array";
|
|
return ts.isPropertyAccessExpression(callee) && ARRAY_BUILDERS.has(callee.name.text);
|
|
}
|
|
|
|
/** Callback methods, by the position of the parameter receiving the
|
|
* array itself. */
|
|
const ARRAY_ARGUMENT_AT = new Map([
|
|
["map", 2],
|
|
["filter", 2],
|
|
["forEach", 2],
|
|
["some", 2],
|
|
["every", 2],
|
|
["find", 2],
|
|
["findIndex", 2],
|
|
["findLast", 2],
|
|
["findLastIndex", 2],
|
|
["flatMap", 2],
|
|
["reduce", 3],
|
|
["reduceRight", 3],
|
|
]);
|
|
|
|
/** A newly built array that is used up where it is built: discarded, or
|
|
* read by a spread, an iteration, or a method call whose callback cannot
|
|
* see it. Nothing else ever references it, so only the value derived from
|
|
* it (judged by its own contents) can carry its holes or undefined. */
|
|
function consumedWhereBuilt(node: ts.Expression): boolean {
|
|
let child: ts.Node = node;
|
|
let parent = node.parent;
|
|
while (parent && ts.isParenthesizedExpression(parent)) {
|
|
child = parent;
|
|
parent = parent.parent;
|
|
}
|
|
if (!parent) return false;
|
|
if (ts.isExpressionStatement(parent)) return true;
|
|
if (ts.isSpreadElement(parent)) return true;
|
|
if (ts.isForOfStatement(parent)) return parent.expression === child;
|
|
if (
|
|
ts.isPropertyAccessExpression(parent) &&
|
|
parent.expression === child &&
|
|
parent.parent &&
|
|
ts.isCallExpression(parent.parent) &&
|
|
parent.parent.expression === parent
|
|
) {
|
|
const arrayAt = ARRAY_ARGUMENT_AT.get(parent.name.text);
|
|
if (arrayAt === undefined) return true;
|
|
const callback = parent.parent.arguments.nodes[0];
|
|
return (
|
|
callback !== undefined &&
|
|
(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) &&
|
|
callback.parameters.nodes.length <= arrayAt &&
|
|
!callback.parameters.nodes.some((parameter) => parameter.dotDotDotToken) &&
|
|
ts.isArrowFunction(callback)
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function merge(a: Contents | null, b: Contents | null): Contents | null {
|
|
if (a === null || b === null) return null;
|
|
return { undefined: a.undefined || b.undefined, holes: a.holes || b.holes };
|
|
}
|
|
|
|
function peel(node: ts.Expression): ts.Expression {
|
|
let e = node;
|
|
while (
|
|
ts.isParenthesizedExpression(e) ||
|
|
ts.isAsExpression(e) ||
|
|
ts.isTypeAssertion(e) ||
|
|
ts.isSatisfiesExpression(e) ||
|
|
ts.isNonNullExpression(e)
|
|
)
|
|
e = e.expression;
|
|
return e;
|
|
}
|
|
|
|
function sameReference(a: ts.Expression, b: ts.Expression): boolean {
|
|
const left = peel(a);
|
|
const right = peel(b);
|
|
if (ts.isIdentifier(left) && ts.isIdentifier(right)) return left.text === right.text;
|
|
if (left.kind === ts.SyntaxKind.ThisKeyword && right.kind === ts.SyntaxKind.ThisKeyword)
|
|
return true;
|
|
if (ts.isPropertyAccessExpression(left) && ts.isPropertyAccessExpression(right))
|
|
return left.name.text === right.name.text && sameReference(left.expression, right.expression);
|
|
return false;
|
|
}
|
|
|
|
/** `xs[xs.length] = v` appends without leaving a hole. */
|
|
function isAppendIndex(target: ts.ElementAccessExpression): boolean {
|
|
const index = peel(target.argumentExpression);
|
|
return (
|
|
ts.isPropertyAccessExpression(index) &&
|
|
index.name.text === "length" &&
|
|
sameReference(index.expression, target.expression)
|
|
);
|
|
}
|
|
|
|
/** `o["name"]`: a string literal key that is not an array index writes a
|
|
* named property, never an element. */
|
|
function namesProperty(target: ts.ElementAccessExpression): boolean {
|
|
const key = peel(target.argumentExpression);
|
|
if (!ts.isStringLiteral(key) && !ts.isNoSubstitutionTemplateLiteral(key)) return false;
|
|
return !/^(0|[1-9][0-9]*)$/.test(key.text) || Number(key.text) >= 2 ** 32 - 1;
|
|
}
|
|
|
|
/** `xs.length = 0`, `xs.length = xs.length - k` and `xs.length -= k`
|
|
* truncate; truncation never leaves holes. */
|
|
function isShrinkingLength(
|
|
node: ts.BinaryExpression,
|
|
target: ts.PropertyAccessExpression,
|
|
): boolean {
|
|
const op = node.operatorToken.kind;
|
|
const right = peel(node.right);
|
|
if (op === ts.SyntaxKind.MinusEqualsToken) return ts.isNumericLiteral(right);
|
|
if (op !== ts.SyntaxKind.EqualsToken) return false;
|
|
if (ts.isNumericLiteral(right)) return Number(right.text) === 0;
|
|
if (!ts.isBinaryExpression(right) || right.operatorToken.kind !== ts.SyntaxKind.MinusToken)
|
|
return false;
|
|
const length = peel(right.left);
|
|
return (
|
|
ts.isNumericLiteral(peel(right.right)) &&
|
|
ts.isPropertyAccessExpression(length) &&
|
|
length.name.text === "length" &&
|
|
sameReference(length.expression, target.expression)
|
|
);
|
|
}
|
|
|
|
function isNumberLike(node: ts.Expression): boolean {
|
|
const e = peel(node);
|
|
return !(
|
|
ts.isStringLiteral(e) ||
|
|
ts.isNoSubstitutionTemplateLiteral(e) ||
|
|
ts.isObjectLiteralExpression(e) ||
|
|
ts.isArrayLiteralExpression(e) ||
|
|
ts.isArrowFunction(e) ||
|
|
ts.isFunctionExpression(e) ||
|
|
e.kind === ts.SyntaxKind.TrueKeyword ||
|
|
e.kind === ts.SyntaxKind.FalseKeyword
|
|
);
|
|
}
|