Read missing elements through non-null asserted operands as Node does (#2331)

This commit is contained in:
Chris Tate
2026-10-26 18:35:37 -05:00
committed by GitHub
17 changed files with 911 additions and 36 deletions
@@ -19,6 +19,7 @@
* length, so reads never reach the state check for it; a write past the
* bound tests the flag and, without states, appends at the length. */
import { InternalCompilerError } from "../../errors.js";
import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js";
import {
f64Lit,
@@ -371,7 +372,9 @@ export function emitDenseArrayGet(
/** emitDenseArrayGet, with the int32 of an int32 array's element: the
* slot itself on the dense path, the runtime getter's double converted
* (exactly: the element is an int32) on the other. */
* (exactly: the element is an int32) on the other. With `inexact`, the
* getter's double may be something other than an int32 (NaN from
* `scr_arr_get_number`): the read branches there when it is. */
export function emitDenseArrayRead(
host: LlvmEmitterContext,
array: string,
@@ -382,6 +385,7 @@ export function emitDenseArrayRead(
getter: string,
retain: boolean,
proven = false,
inexact?: string,
): { value: string; int32: string | null } {
const B = host.B;
const ty = valueType(acc);
@@ -463,22 +467,68 @@ export function emitDenseArrayRead(
: READ_FALLBACK;
B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`);
let slowInt: string | null = null;
if (fastInt !== null) {
let slowLabel = slow;
if (fastInt !== null && inexact !== undefined) {
// An int32 that is not -0, or the inexact branch.
host.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)");
slowInt = B.tmp();
const back = B.tmp(),
exact = B.tmp(),
bits = B.tmp(),
notNegativeZero = B.tmp(),
ok = B.tmp();
B.line(`${slowInt} = call i32 @llvm.fptosi.sat.i32.f64(double ${slowValue})`);
B.line(`${back} = sitofp i32 ${slowInt} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${slowValue}`);
B.line(`${bits} = bitcast double ${slowValue} to i64`);
B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`);
B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`);
slowLabel = B.newLabel("arr.get.int32");
B.condBr(ok, slowLabel, inexact);
B.startBlock(slowLabel);
} else if (fastInt !== null) {
slowInt = B.tmp();
B.line(`${slowInt} = fptosi double ${slowValue} to i32`);
}
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`);
B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slowLabel} ]`);
let int32: string | null = null;
if (fastInt !== null && slowInt !== null) {
int32 = B.tmp();
B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slow} ]`);
B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slowLabel} ]`);
}
return { value: result, int32 };
}
/** emitDenseArrayNumber of an int32 array, with the element's int32: a
* read that answers NaN (or another value that is not an int32) branches
* to `inexact`. */
export function emitDenseArrayNumberInt32(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
proven: boolean,
inexact: string,
): { value: string; int32: string } {
const read = emitDenseArrayRead(
host,
array,
index,
indexExpr,
"i32",
{ kind: "f64" },
"scr_arr_get_number",
false,
proven,
inexact,
);
if (read.int32 === null) throw new InternalCompilerError("llvm emitter bug: int32 read");
return { value: read.value, int32: read.int32 };
}
/** `scr_arr_get_number`: a numeric element, or NaN for every missing,
* hole, or present-undefined slot (answered by the runtime). */
export function emitDenseArrayNumber(
@@ -62,12 +62,14 @@ import {
import { exactInteger, widenInteger } from "./integer-values.js";
import {
emitDenseArrayNumber,
emitDenseArrayNumberInt32,
emitDenseArrayPop,
emitDenseArrayPush,
runtimeAccess,
type ArrayAccess,
} from "./dense-array-access.js";
import { INT32_ARRAY_METHODS } from "../../ir/int32-arrays.js";
import { INT32_RANGE } from "../../ir/integer-ranges.js";
import { emitUnionWiden } from "./expr-records.js";
import { registerReplay } from "./emit-journal.js";
@@ -694,6 +696,25 @@ export function emitArrIntrinsic(
)!;
return { name: value, type: e.type };
}
// In a store statement of an int32 kind loop's fast copy, a read that
// is not an int32 (a missing element's NaN) leaves for the
// statement's resume point, so the element is at hand as an i32.
if (!table && elem.kind === "f64" && access === "i32" && host.int32KindResume !== null) {
const read = emitDenseArrayNumberInt32(
host,
r.name,
index,
e.args[0],
host.provenDenseIndex(e.receiver, e.args[0]!),
host.int32KindResume,
);
return {
name: read.value,
type: e.type,
uint32: read.int32,
integer: { name: read.int32, type: "i32", signed: true, range: INT32_RANGE },
};
}
if (!table && elem.kind === "f64")
return {
name: emitDenseArrayNumber(
@@ -57,6 +57,8 @@ function isPure(e: IrExpr): boolean {
case "arrayGet":
case "arrayHas":
return e.arr.kind === "varRef" && isPure(e.index);
case "arrIntrinsic":
return e.method === "getNumber" && e.receiver.kind === "varRef" && isPure(e.args[0]!);
default:
return false;
}
@@ -192,6 +192,8 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> |
case "ternary":
return expr(e.cond, "bool") && expr(e.then, want) && expr(e.else_, want);
case "arrIntrinsic":
if (e.method === "getNumber")
return want === "num" && arrayRef(e.receiver) && expr(e.args[0]!, "num");
return (
want === "num" && e.method === "length" && e.args.length === 0 && arrayRef(e.receiver)
);
@@ -269,6 +271,9 @@ function callsMatch(fn: IrFunction, byName: ReadonlyMap<string, IrFunction>): bo
case "arrayGet":
visit(e.index);
return;
case "arrIntrinsic":
if (e.method === "getNumber") visit(e.args[0]!);
return;
default:
return;
}
@@ -433,6 +438,8 @@ function callsAnything(e: IrExpr): boolean {
return callsAnything(e.cond) || callsAnything(e.then) || callsAnything(e.else_);
case "arrayGet":
return callsAnything(e.index);
case "arrIntrinsic":
return e.method === "getNumber" && callsAnything(e.args[0]!);
default:
return false;
}
@@ -1021,10 +1028,10 @@ class CloneEmitter {
* keeps every slot below `filled` a present value) whose element is
* exactly an int32. Everything else (holes, indices outside the prefix,
* other numbers) bails: the ordinary body reads it. */
private emitElement(e: Extract<IrExpr, { kind: "arrayGet" }>): string {
private emitElement(arr: IrExpr, indexExpr: IrExpr): string {
const { B } = this;
const array = this.emitArray(e.arr);
const index = this.narrow(this.emitNum(e.index));
const array = this.emitArray(arr);
const index = this.narrow(this.emitNum(indexExpr));
const filledField = B.tmp();
const filled = B.tmp();
const inside = B.tmp();
@@ -1188,9 +1195,13 @@ class CloneEmitter {
if (this.inlines(e)) return { name: this.emitInlineCall(e), wide: false };
return { name: this.emitCall(e), wide: true };
case "arrIntrinsic":
// `getNumber` reads an element like `arrayGet`: a missing one bails,
// and the ordinary body reads it as NaN.
if (e.method === "getNumber")
return { name: this.emitElement(e.receiver, e.args[0]!), wide: false };
return { name: this.emitLength(this.emitArray(e.receiver)), wide: false };
case "arrayGet":
return { name: this.emitElement(e), wide: false };
return { name: this.emitElement(e.arr, e.index), wide: false };
case "unary": {
const operand = this.narrow(this.emitNum(e.operand));
const value = B.tmp();
+9
View File
@@ -1584,6 +1584,15 @@ class ImmutableContracts {
for (let steps = 0; steps < 16; steps++) {
if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj;
else if (root.kind === "arrayGet") root = root.arr;
else if (root.kind === "seqExpr") root = root.result;
// A receiver that is the element when it is present and otherwise
// throws (`xs[i]!.push(v)`) is that element read.
else if (
root.kind === "ternary" &&
root.else_.kind === "libCall" &&
root.else_.fn === "error.nodeThrow"
)
root = root.then;
else if (
root.kind === "upcast" ||
root.kind === "downcast" ||
@@ -37,6 +37,7 @@ import {
dynCompoundValue,
lowerAbsenceProbe,
lowerDynMemberAssignment,
lowerDereferencedElementAssignment,
lowerOptionalNumber,
lowerPresenceOperand,
nullishBranches,
@@ -1068,6 +1069,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
if (prototypeWrite) return prototypeWrite;
const callableWrite = lowerNativeFunctionAssignment(lowerer, expr);
if (callableWrite) return callableWrite;
const dereferenced = lowerDereferencedElementAssignment(lowerer, expr);
if (dereferenced) return dereferenced;
}
if (
op === ts.SyntaxKind.QuestionQuestionEqualsToken ||
@@ -83,6 +83,8 @@ import { unitOnlyUnion } from "../type-mapper.js";
import { lowerYield } from "./lower-generators.js";
import { unionWideningTags } from "../../ir/analysis.js";
import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { everyExprChild } from "../../ir/traverse.js";
import { declareContextLocal } from "./function-context.js";
import { hasOptionalChainGuard, isOptionalChainTail } from "./expressions/optional-chains.js";
import { classToStringConversion, classUnionToString } from "./class-to-primitive.js";
import { lowerCjsTopLevelThis, lowerRequireCacheElement } from "./lower-node-module.js";
@@ -413,6 +415,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const probe = lowerAbsenceProbe(lowerer, expr.expression);
if (probe?.type.kind === "union") return probe;
}
const dereferenced = dereferencedElementReceiver(lowerer, expr, loc);
if (dereferenced) return dereferenced;
const element = assertedElementOperand(lowerer, expr, loc);
if (element) return element;
const inner = lowerer.lowerExpr(expr.expression);
if (inner.type.kind === "union") {
// `!` performs no runtime check: a missing array element stays
@@ -1075,18 +1081,10 @@ function assertionOperand(
loc: SrcLoc,
): IrExpr | null {
if (inner.type.kind !== "union" || target === null || target.kind === "union") return null;
const arms = lowerer.unions.get(inner.type.unionId)?.arms;
if (
!arms?.some(isUnitType) ||
!arms.every(
(arm) =>
arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || isUnitType(arm),
)
)
return null;
// Array element reads keep their own handling of a missing element. A
// typed-array read is optional here only where its use tells undefined
// from NaN (assertedReadObservesUndefined).
if (!scalarUnion(lowerer, inner.type.unionId)) return null;
// Array element reads take assertedElementOperand or keep their own
// handling of a missing element. A typed-array read is optional here only
// where its use tells undefined from NaN (assertedReadObservesUndefined).
let operand: ts.Expression = expr.expression;
while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
if (ts.isElementAccessExpression(operand)) {
@@ -1095,19 +1093,489 @@ function assertionOperand(
}
const use = convertingUseOf(lowerer, expr);
if (use === null) return null;
if (use === "value") return inner;
if (use === "value" || use === "string") return inner;
return lowerPrimitiveUnionNumber(lowerer, inner, expr, loc);
}
/** How the converting use of `x!` (see assertionOperand) reads it:
* "number" where the use applies ToNumber, "value" where the consumer
* lowers the union itself (strings, equality, truthiness), null for other
/** `xs[i]!` on an array as the operand of a converting use (see
* assertionOperand). The read answers undefined for a missing element, as
* in Node, instead of trapping: a numeric use reads NaN for it, and other
* uses get the optional element value. Returns null for other reads and
* uses. */
function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" | "value" | null {
function assertedElementOperand(
lowerer: Lowerer,
expr: ts.NonNullExpression,
loc: SrcLoc,
): IrExpr | null {
let read: ts.Expression = expr.expression;
while (ts.isParenthesizedExpression(read)) read = read.expression;
if (!ts.isElementAccessExpression(read) || read.questionDotToken) return null;
// Plain arrays only: regex match results and other array subtypes keep
// their own element reads.
const receiverType = lowerer.typeOf(read.expression);
if (
!lowerer.checker.isArrayType(receiverType) ||
lowerer.mapTypeOf(receiverType)?.kind !== "array"
)
return null;
// A counter below the length reads an existing index: the read keeps
// its plain element access.
if (lowerer.nanCodedReads?.counterIndexInBounds(read)) return null;
const target = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (!target || !isScalarArm(target)) return null;
const use = convertingUseOf(lowerer, expr);
if (use === null) return null;
if (use === "string" && target.kind === "string")
return stringElementOrUndefined(lowerer, read, loc);
const probe = lowerAbsenceProbe(lowerer, read);
if (probe?.type.kind !== "union" || !scalarUnion(lowerer, probe.type.unionId)) return null;
if (use === "value" || use === "string") return probe;
return lowerer.stripUndefinedArm(probe.type).kind === "f64"
? lowerOptionalNumber(lowerer, probe, loc)
: lowerPrimitiveUnionNumber(lowerer, probe, expr, loc);
}
/** `xs[i]!` on an array as the receiver of a member read
* (`xs[i]!.name`, `xs[i]!.size()`, `xs[i]![0]`). `!` performs no runtime
* check, so a missing element is undefined there and the member access
* throws Node's TypeError ("Cannot read properties of undefined (reading
* 'name')") instead of the bounds trap. The receiver is the element when it
* is present. A write (`xs[i]!.name = v`) is lowered as a statement by
* lowerDereferencedElementWrite, which evaluates the value before the
* receiver throws; elsewhere it keeps the bounds trap. Returns null for
* other reads and uses. */
function dereferencedElementReceiver(
lowerer: Lowerer,
expr: ts.NonNullExpression,
loc: SrcLoc,
): IrExpr | null {
const use = runtimeOptionalUseOf(expr);
if (!use || isAssignmentTarget(use.access)) return null;
const parts = dereferencedElementParts(lowerer, expr, loc, false);
if (parts?.message == null) return null;
const message = parts.message;
const value: IrExpr = {
kind: "ternary",
cond: parts.present,
then: parts.element,
else_: nodeTypeError(message, parts.element.type, loc),
type: parts.element.type,
loc,
};
return parts.stmts.length === 0
? value
: { kind: "seqExpr", stmts: parts.stmts, result: value, type: value.type, loc };
}
/** The parts of a member access through `xs[i]!` (see
* dereferencedElementReceiver): statements that evaluate the array and the
* index, whether the element is present, the element read, and Node's
* TypeError message for a missing one. A `write` holds its key, so its
* message may be left for dereferencedElementWrite to build from the key. */
export function dereferencedElementParts(
lowerer: Lowerer,
expr: ts.NonNullExpression,
loc: SrcLoc,
write: boolean,
): { stmts: IrStmt[]; present: IrExpr; element: IrExpr; message: IrExpr | null } | null {
let read: ts.Expression = expr.expression;
while (ts.isParenthesizedExpression(read)) read = read.expression;
if (!ts.isElementAccessExpression(read) || read.questionDotToken) return null;
const receiverType = lowerer.typeOf(read.expression);
if (
!lowerer.checker.isArrayType(receiverType) ||
lowerer.mapTypeOf(receiverType)?.kind !== "array"
)
return null;
if (lowerer.nanCodedReads?.counterIndexInBounds(read)) return null;
const use = runtimeOptionalUseOf(expr);
if (!use || use.optional || use.complex || use.comma || use.kind === "call") return null;
// A write holds its key, so the message can name any number or string
// key (dereferencedElementWrite builds it).
const message =
memberAccessMessage(lowerer, use.access) ??
(write && ts.isElementAccessExpression(use.access) && namedKey(lowerer, use.access)
? null
: undefined);
if (message === undefined) return null;
if (lowerer.mapTypeOf(lowerer.typeOf(read.argumentExpression))?.kind !== "f64") return null;
// The element must already have the asserted type: an element union the
// assertion or a guard narrows keeps its checked narrow.
const target = lowerer.mapTypeOf(lowerer.typeOf(expr));
const declared = lowerer.mapTypeOf(receiverType);
if (
!target ||
declared?.kind !== "array" ||
declared.elem.kind === "void" ||
!typeEquals(declared.elem, target)
)
return null;
const arr = lowerer.lowerExpr(read.expression);
if (arr.type.kind !== "array" || !typeEquals(arr.type.elem, target)) return null;
const elem = arr.type.elem;
const index = lowerer.lowerExprExpecting(read.argumentExpression, F64);
// Plain reads (a local, a field of one) evaluate once more instead of
// through a hidden local: the receiver then stays a borrowed element
// read, like the plain `xs[i]` it replaces. An index that can assign
// can replace the array binding or field (`xs[(xs = other, 0)]!`), so the
// array is held before it, as JavaScript reads it first.
const stmts: IrStmt[] = [];
const plain = (value: IrExpr): boolean => isSafeToRepeat(value) || plainProjection(value);
const held = (value: IrExpr, name: string): IrExpr => {
const tmp = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc });
return varRef(tmp.id, value.type, loc);
};
const a = !mayAssign(index) && plain(arr) ? arr : held(arr, "%elementArray");
const i = plain(index) ? index : held(index, "%elementIndex");
return {
stmts,
present: {
kind: "bin",
op: "===",
left: { kind: "arrayState", arr: a, index: i, type: F64, loc },
right: numLit(1, loc),
type: BOOL,
loc,
},
element: { kind: "arrayGet", arr: a, index: i, type: elem, loc },
message,
};
}
/** A write through `xs[i]!` (`xs[i]!.name = v`, `xs[i]![k] = v`): as in
* Node, the array, the index, the key and the value evaluate in order (the
* statements), then a missing element makes the write throw Node's
* TypeError and a present one takes the write, which reads the element,
* the key and the value through `overrides`. Null for other targets. */
export function dereferencedElementWrite(
lowerer: Lowerer,
expr: ts.BinaryExpression,
): {
stmts: IrStmt[];
present: IrExpr;
message: IrExpr;
overrides: { node: ts.Expression; value: IrExpr }[];
} | null {
if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null;
const target = expr.left;
if (
!(ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) ||
target.questionDotToken ||
lowerer.chainRecvByNode.has(target.expression)
)
return null;
let receiver: ts.Expression = target.expression;
while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression;
if (!ts.isNonNullExpression(receiver)) return null;
const loc = locOf(expr);
const keyValue = ts.isElementAccessExpression(target)
? lowerer.lowerExpr(target.argumentExpression)
: null;
const rightValue = lowerer.lowerExpr(expr.right);
const parts = dereferencedElementParts(lowerer, receiver, loc, true);
if (parts === null) return null;
const stmts = [...parts.stmts];
let present = parts.present;
let element = parts.element;
// JavaScript reads the element before the key and the value. When either
// has an effect, which can replace, remove or add the element, its
// presence and value are read first; otherwise the write tests and reads
// it after them.
if (!unobservable(rightValue) || (keyValue !== null && !unobservable(keyValue))) {
const presentLocal = lowerer.declareHiddenLocal("%elementPresent", BOOL);
// Assigned only when the element is present.
const elementLocal = declareContextLocal(
lowerer.ctx,
"%elementValue",
element.type,
true,
undefined,
undefined,
);
stmts.push(
{ kind: "varDecl", localId: presentLocal.id, init: present, loc },
{ kind: "varDecl", localId: elementLocal.id, init: null, loc },
);
present = varRef(presentLocal.id, BOOL, loc);
stmts.push({
kind: "if",
cond: present,
then: [{ kind: "assign", localId: elementLocal.id, value: element, loc }],
else_: null,
loc,
});
element = varRef(elementLocal.id, element.type, loc);
}
const overrides: { node: ts.Expression; value: IrExpr }[] = [];
// The write lowers the node with or without its parentheses.
const override = (node: ts.Expression, value: IrExpr): void => {
for (let n: ts.Expression = node; ; n = n.expression) {
overrides.push({ node: n, value });
if (!ts.isParenthesizedExpression(n)) break;
}
};
const held = (value: IrExpr, name: string, inline: boolean): IrExpr => {
if (inline) return value;
// A call without a result evaluates to undefined.
if (value.type.kind === "void") {
stmts.push({ kind: "exprStmt", expr: value, loc });
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc };
}
const tmp = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc });
return varRef(tmp.id, value.type, loc);
};
override(target.expression, element);
let message = parts.message;
if (ts.isElementAccessExpression(target) && keyValue !== null) {
// The key evaluates before the value, which can assign the binding or
// field it reads; a literal, or an unobservable key the value cannot
// change, may evaluate in the write.
const key = held(
keyValue,
"%elementWriteKey",
keyValue.kind === "numLit" ||
keyValue.kind === "strLit" ||
(unobservable(keyValue) && !mayAssign(rightValue)),
);
override(target.argumentExpression, key);
// The message names the key's value as the write evaluated it.
if (key !== keyValue || message === null)
message = keyMessage(messageHead(target), key, loc) ?? message;
}
// A value whose evaluation is unobservable may evaluate in the write
// itself, after the element is tested.
override(
expr.right,
held(rightValue, "%elementWriteValue", isSafeToRepeat(rightValue) || unobservable(rightValue)),
);
if (message === null) return null;
return { stmts, present, message, overrides };
}
/** dereferencedElementWrite in value position (`f(xs[i]!.name = v)`). */
export function lowerDereferencedElementAssignment(
lowerer: Lowerer,
expr: ts.BinaryExpression,
): IrExpr | null {
const write = dereferencedElementWrite(lowerer, expr);
if (write === null) return null;
const loc = locOf(expr);
const value = lowerer.withExpressionOverrides(write.overrides, () => lowerer.lowerExpr(expr));
return {
kind: "seqExpr",
stmts: write.stmts,
result: {
kind: "ternary",
cond: write.present,
then: value,
else_: nodeTypeError(write.message, value.type, loc),
type: value.type,
loc,
},
type: value.type,
loc,
};
}
/** Whether evaluating `e` has no effect and cannot throw: a discardable
* expression, or the compiler's element-or-undefined read of one. */
function unobservable(e: IrExpr): boolean {
if (e.kind === "call" && e.callee.startsWith("%arr.idxOr.")) return e.args.every(unobservable);
return isSafeToDiscard(e);
}
/** Whether evaluating `e` can assign a binding or a field: it contains an
* assignment, an increment, statements, a suspension, or a call that can
* run program code (a library call only through a function it is given). */
function mayAssign(e: IrExpr): boolean {
switch (e.kind) {
case "call":
// The compiler's element-or-undefined read and union narrows run no
// program code.
if (!e.callee.startsWith("%arr.idxOr.") && !e.callee.startsWith("%union.narrow."))
return true;
break;
case "assignExpr":
case "incDec":
case "seqExpr":
case "callValue":
case "virtualCall":
case "dynCall":
case "dynInvoke":
case "ffiCall":
case "new":
case "newValue":
case "awaitExpr":
case "awaitUnionExpr":
case "yieldExpr":
return true;
case "closure":
return false;
case "libCall":
if (e.args.some((arg) => arg.type.kind === "func")) return true;
break;
}
return !everyExprChild(
e,
(child) => !mayAssign(child),
() => false,
);
}
/** Node's TypeError, thrown in place of a value of `type`. */
export function nodeTypeError(message: IrExpr, type: IrType, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
fn: "error.nodeThrow",
args: [numLit(1, loc), strLit("", loc), message],
type,
loc,
};
}
/** Whether `access` is the target of `=`. */
function isAssignmentTarget(access: ts.Expression): boolean {
let outer: ts.Expression = access;
while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent;
const parent = outer.parent;
return (
ts.isBinaryExpression(parent) &&
parent.left === outer &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
);
}
/** A local, a number literal, or a field or record member read through a
* local: reading it again has no effect and yields the same value. */
function plainProjection(e: IrExpr): boolean {
if (e.kind === "varRef" || e.kind === "numLit") return true;
return (e.kind === "fieldGet" || e.kind === "recordGet") && plainProjection(e.obj);
}
/** Node's TypeError message for a member access on undefined: reading the
* member, or setting it when the access is the target of `=`. Null when
* the key is not a name, a literal, or a plain read of a number or string
* (which the message reads a second time). */
function memberAccessMessage(
lowerer: Lowerer,
access: ts.PropertyAccessExpression | ts.ElementAccessExpression | ts.CallExpression,
): IrExpr | null {
const head = messageHead(access);
const loc = locOf(access);
if (ts.isPropertyAccessExpression(access)) {
if (ts.isPrivateIdentifier(access.name)) return null;
return strLit(`${head}${access.name.text}')`, loc);
}
if (!ts.isElementAccessExpression(access)) return null;
const key = runtimeOptionalElementKey(access.argumentExpression);
if (key !== null) return strLit(`${head}${key}')`, loc);
let keyNode = access.argumentExpression;
while (ts.isParenthesizedExpression(keyNode)) keyNode = keyNode.expression;
if (!ts.isIdentifier(keyNode)) return null;
const keyType = lowerer.mapTypeOf(lowerer.typeOf(keyNode))?.kind;
if (keyType !== "f64" && keyType !== "string") return null;
const keyValue = lowerer.lowerExpr(keyNode);
if (!isSafeToRepeat(keyValue) || keyValue.type.kind !== keyType) return null;
return keyMessage(head, keyValue, loc);
}
/** The start of Node's TypeError message for a member access on undefined:
* setting the member when the access is the target of `=`, else reading
* it. */
function messageHead(access: ts.Expression): string {
return isAssignmentTarget(access)
? "Cannot set properties of undefined (setting '"
: "Cannot read properties of undefined (reading '";
}
/** Whether `access`'s key is typed a number or a string, which the message
* can name from its value. */
function namedKey(lowerer: Lowerer, access: ts.ElementAccessExpression): boolean {
const kind = lowerer.mapTypeOf(lowerer.typeOf(access.argumentExpression))?.kind;
return kind === "f64" || kind === "string";
}
/** The message naming a key from its value (a number or a string), or null
* for another type. */
function keyMessage(head: string, key: IrExpr, loc: SrcLoc): IrExpr | null {
if (key.type.kind !== "f64" && key.type.kind !== "string") return null;
const text: IrExpr =
key.type.kind === "string" ? key : { kind: "toString", operand: key, type: STRING, loc };
const concat = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "strConcat",
left,
right,
type: STRING,
loc,
});
return concat(concat(strLit(head, loc), text), strLit("')", loc));
}
/** A string array's element converted to a string: the element, or
* "undefined" for a missing index or a hole. */
function stringElementOrUndefined(
lowerer: Lowerer,
read: ts.ElementAccessExpression,
loc: SrcLoc,
): IrExpr | null {
if (lowerer.mapTypeOf(lowerer.typeOf(read.argumentExpression))?.kind !== "f64") return null;
const arr = lowerer.lowerExpr(read.expression);
if (arr.type.kind !== "array" || arr.type.elem.kind !== "string") return null;
const index = lowerer.lowerExprExpecting(read.argumentExpression, F64);
const stmts: IrStmt[] = [];
const held = (value: IrExpr, name: string): IrExpr => {
const tmp = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc });
return varRef(tmp.id, value.type, loc);
};
// An index that can assign can replace the array binding or field, so
// the array is held before it, as JavaScript reads it first.
const a = !mayAssign(index) && isSafeToRepeat(arr) ? arr : held(arr, "%elementArray");
const i = isSafeToRepeat(index) ? index : held(index, "%elementIndex");
const value: IrExpr = {
kind: "ternary",
cond: {
kind: "bin",
op: "===",
left: { kind: "arrayState", arr: a, index: i, type: F64, loc },
right: numLit(1, loc),
type: BOOL,
loc,
},
then: { kind: "arrayGet", arr: a, index: i, type: STRING, loc },
else_: strLit("undefined", loc),
type: STRING,
loc,
};
return stmts.length === 0 ? value : { kind: "seqExpr", stmts, result: value, type: STRING, loc };
}
function isScalarArm(arm: IrType): boolean {
return arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || isUnitType(arm);
}
/** A union of numbers, strings, booleans, null and undefined with at least
* one unit arm. */
function scalarUnion(lowerer: Lowerer, unionId: string): boolean {
const arms = lowerer.unions.get(unionId)?.arms;
return !!arms?.some(isUnitType) && arms.every(isScalarArm);
}
/** How the converting use of `x!` (see assertionOperand) reads it:
* "number" where the use applies ToNumber, "string" where it converts the
* value to a string (a template span, `+` with a string), "value" where the
* consumer lowers the union itself (equality, truthiness, a comparison with
* a string), null for other uses. */
function convertingUseOf(
lowerer: Lowerer,
expr: ts.NonNullExpression,
): "number" | "string" | "value" | null {
let outer: ts.Expression = expr;
while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent;
const parent = outer.parent;
if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "value" : null;
if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "string" : null;
if (ts.isTypeOfExpression(parent)) return "value";
if (ts.isPrefixUnaryExpression(parent)) {
if (parent.operator === ts.SyntaxKind.ExclamationToken) return "value";
@@ -1140,7 +1608,7 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number"
if (op === ts.SyntaxKind.PlusEqualsToken && parent.right !== outer) return null;
let partner = other;
while (ts.isParenthesizedExpression(partner)) partner = partner.expression;
if (isString(other)) return ts.isNonNullExpression(partner) ? null : "value";
if (isString(other)) return ts.isNonNullExpression(partner) ? null : "string";
if (!numericPartner(lowerer.typeOf(other))) return "value";
return isString(expr) ? null : "number";
}
@@ -1226,7 +1694,8 @@ export function assertedReadObservesUndefined(
lowerer: Lowerer,
node: ts.NonNullExpression,
): boolean {
if (convertingUseOf(lowerer, node) === "value") return true;
const use = convertingUseOf(lowerer, node);
if (use === "value" || use === "string") return true;
let outer: ts.Expression = node;
while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent;
const parent = outer.parent;
@@ -242,10 +242,12 @@ import { globalSymbolKey } from "./expressions/global-symbols.js";
import { identifiersNamed } from "./identifier-uses.js";
import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js";
import {
dereferencedElementWrite,
jsHoldsDynObject,
lowerEnvironmentKey,
lowerNativeFunctionAssignment,
lowerStrictEquality,
nodeTypeError,
} from "./lower-exprs.js";
import { keptIdentityAssertion } from "./assertion-identity.js";
import { isNativeProxyInitializer } from "./expressions/native-proxy.js";
@@ -7992,6 +7994,29 @@ function lowerCjsSingleValueExport(
* assignment, and `({ a, b } = e);` (destructuring ASSIGNMENT, which JS
* requires parenthesized in statement position) lands in the
* object-literal-target branch below. */
/** dereferencedElementWrite in statement position. */
function lowerDereferencedElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): IrStmt | null {
const write = dereferencedElementWrite(lowerer, expr);
if (write === null) return null;
const loc = locOf(expr);
return {
kind: "block",
body: [
...write.stmts,
{
kind: "if",
cond: write.present,
then: [
lowerer.withExpressionOverrides(write.overrides, () => lowerExprStatement(lowerer, expr)),
],
else_: [{ kind: "exprStmt", expr: nodeTypeError(write.message, VOID, loc), loc }],
loc,
},
],
loc,
};
}
export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStmt {
const stmtNode = expr.parent;
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
@@ -8254,6 +8279,10 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm
const ex = lowerExpandoAssignStmt(lowerer, expr);
if (ex) return ex;
}
{
const dereferenced = lowerDereferencedElementWrite(lowerer, expr);
if (dereferenced) return dereferenced;
}
if (ts.isElementAccessExpression(expr.left)) return lowerer.lowerElementWrite(expr);
if (ts.isPropertyAccessExpression(expr.left)) {
fenceNodeModuleMutation(lowerer, expr.left, "assignment");
@@ -451,6 +451,19 @@ export class NanCodedReads {
// ---- present reads ------------------------------------------------------
/** `xs[i]` whose index is a non-negative integer counter of the reading
* function and that a dominating `i < xs.length` guard bounds by the
* length: the read names an existing index. */
counterIndexInBounds(read: ts.ElementAccessExpression): boolean {
const index = peelTransparent(read.argumentExpression);
if (!ts.isIdentifier(index)) return false;
const symbol = this.symbolOf(index);
if (!symbol || !this.nonNegativeCounter(symbol)) return false;
const decl = this.host.declarationOf(symbol);
if (!decl || this.containingFunction(decl) !== this.containingFunction(read)) return false;
return indexReadInBounds(read);
}
/** `t[i]` names an existing element: `i` is a non-negative integer
* counter and a dominating guard bounds it by `t.length` (or by the
* length of a typed array that always has the same length).
+38 -7
View File
@@ -1484,6 +1484,39 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
continue;
}
const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e));
// The range of an exact integer that may also be NaN: an element read
// of a candidate through `getNumber` (`a[k]!` in arithmetic, NaN for a
// missing element), and `+`, `-` and `*` over such values and integers.
// A checked store widens the array at a NaN like at any value that is
// not an int32.
const integerOrNaN = (e: IrExpr): IntegerRange | null => {
const range = ranges.get(e);
if (range) return range;
if (e.kind === "numLit")
return Number.isInteger(e.value) ? { min: e.value, max: e.value } : null;
if (e.kind === "arrIntrinsic" && e.method === "getNumber" && e.receiver.kind === "varRef")
return slots.hasInt32ElementSlot(int32ArraySlot(int32ArrayKey(fn.name, e.receiver.localId)))
? INT32_RANGE
: null;
if (e.kind !== "bin" || (e.op !== "+" && e.op !== "-" && e.op !== "*")) return null;
const left = integerOrNaN(e.left);
const right = left && integerOrNaN(e.right);
if (!left || !right) return null;
const bounds =
e.op === "+"
? [left.min + right.min, left.max + right.max]
: e.op === "-"
? [left.min - right.max, left.max - right.min]
: [
left.min * right.min,
left.min * right.max,
left.max * right.min,
left.max * right.max,
];
const min = Math.min(...bounds);
const max = Math.max(...bounds);
return Math.abs(min) < 2 ** 53 && Math.abs(max) < 2 ** 53 ? { min, max } : null;
};
// Every element stored into an int32 array candidate must be an int32
// under the facts that assume the candidates' own elements are; a
// failing store drops its array's element facts, and the function is
@@ -1513,6 +1546,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
for (const [id, values] of int32ArrayStores(fn, arrays)) {
const key = int32ArrayKey(fn.name, id);
if (!slots.hasInt32ElementSlot(int32ArraySlot(key)) || values.every(int32)) continue;
// Checked stores serve a lone local: its allocations are the only
// arrays it holds. Its stores are judged under the element facts
// the ranges were computed with.
const checkable = values.every((value) => int32(value) || integerOrNaN(value) !== null);
// The array's group drops together: every function reading a
// member is analyzed again without its facts.
const group = arrayGroups.group.get(key) ?? [key];
@@ -1523,17 +1560,11 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
if (names) for (const name of names) requeue(name);
} else requeue(member.slice(0, member.indexOf("\0")));
}
// Checked stores serve a lone local: its allocations are the only
// arrays it holds.
const checkable = (value: IrExpr): boolean => {
const range = ranges.get(value);
return int32(value) || range != null;
};
if (
group.length === 1 &&
!key.startsWith("\0") &&
!params.has(id) &&
values.every(checkable) &&
checkable &&
checkableInt32Allocations(fn, id)
)
slots.checkedInt32Arrays.add(key);
@@ -122,7 +122,7 @@ function numericLoop(loop: IrStmt, functions: ReadonlyMap<string, IrFunction>):
const target = functions.get(e.callee);
allowed = target !== undefined && simple(target);
} else if (e.kind === "libCall") allowed = e.fn.startsWith("math.");
else if (e.kind === "arrIntrinsic") allowed = e.method === "length";
else if (e.kind === "arrIntrinsic") allowed = e.method === "length" || e.method === "getNumber";
return allowed && everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean =>
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7393-non-null-element-operands.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7395-non-null-element-dereference.ts"
],
"diags": []
}
@@ -0,0 +1,81 @@
// Non-null assertions on array element reads whose index is out of bounds
// or names a hole, used as operands of operators that convert them. `!`
// performs no runtime check, so the read is undefined, as in Node: NaN in
// arithmetic, false in comparisons, "undefined" in strings.
const values = [3, 1, 4, 1, 5];
const words = ["alpha", "beta"];
const flags = [true, false];
const holey: number[] = [];
holey[3] = 7;
function show(v: unknown): string {
return Object.is(v, -0) ? "-0" : String(v);
}
function line(label: string, parts: unknown[]): void {
console.log(label.padEnd(10), parts.map(show).join(" "));
}
for (const k of [0, 4, 5, -1, 1.5, 100]) {
line(`k=${k}`, [
values[k]! + 1,
values[k]! * 2,
-values[k]!,
values[k]! | 0,
values[k]! < 3,
values[k]! >= 1,
values[k]! === 1,
values[k]! !== 1,
`${values[k]!}`,
"v=" + values[k]!,
Math.max(values[k]!, 0),
words[k]! + "!",
`<${words[k]!}>`,
words[k]! < "b",
!flags[k]!,
]);
}
line("holes", [holey[0]! + 1, holey[3]! + 1, `${holey[1]!}`, holey[2]! === undefined, holey[9]! > 0]);
// `+` with an untyped partner concatenates or adds when it runs.
const untypedText: any = "x";
const untypedNumber: any = 5;
for (const k of [1, 9]) {
line(`untyped ${k}`, [
values[k]! + untypedText,
untypedText + values[k]!,
values[k]! + untypedNumber,
words[k]! + untypedNumber,
words[k]! + untypedText,
]);
}
let sum = 0;
let text = "";
for (let i = 0; i <= values.length; i++) {
sum += values[i]!;
text += words[i % 3]!;
text += ",";
}
line("compound", [sum, text]);
// A counter array: an index past the end reads NaN, and the store widens
// the array to doubles.
function counter(n: number, m: number): number[] {
const a = new Array(n).fill(0) as number[];
for (let i = 0; i < 6000; i++) a[i % m] = a[i % m]! + 1;
return [a[0]!, a[m - 1]!, a.length, a[n + 1]! + 0];
}
line("counter", counter(5000, 5000));
line("past", counter(5000, 5003));
line("zero", counter(4, 0));
const grid = [
[1, 2],
[3, 4],
];
let total = 0;
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) total += grid[r % 2]![c]! * 1;
line("grid", [total]);
@@ -0,0 +1,142 @@
// Member reads and writes through non-null asserted array element reads.
// `!` performs no runtime check: a missing element is undefined, so the
// member access throws Node's TypeError, which the program can catch.
class Cell {
count = 0;
label: string;
constructor(label: string) {
this.label = label;
}
bump(): number {
return ++this.count;
}
}
function cell(label: string): Cell {
return new Cell(label);
}
const cells = [cell("a"), cell("b")];
const words = ["alpha", "beta"];
const grid = [
[1, 2],
[3, 4],
];
const maps = [new Map<string, number>([["x", 1]])];
const holey: Cell[] = [];
holey[2] = cell("z");
let reads = 0;
function at(n: number): number {
reads++;
return n;
}
function attempt(label: string, f: () => unknown): void {
try {
console.log(label.padEnd(12), "ok", f());
} catch (e) {
const error = e as Error;
console.log(label.padEnd(12), error instanceof TypeError, error.message);
}
}
for (const k of [0, 1, 2, -1]) {
attempt(`label ${k}`, () => cells[k]!.label);
attempt(`bump ${k}`, () => cells[k]!.bump());
attempt(`set ${k}`, () => (cells[k]!.label = "set"));
attempt(`count++ ${k}`, () => cells[k]!.count++);
attempt(`length ${k}`, () => words[k]!.length);
attempt(`upper ${k}`, () => words[k]!.toUpperCase());
attempt(`grid ${k}`, () => grid[k]![0]);
attempt(`row ${k}`, () => {
const j = 1;
return grid[k]![j];
});
attempt(`push ${k}`, () => grid[k]!.push(9));
attempt(`map ${k}`, () => maps[k]!.get("x"));
}
attempt("hole", () => holey[0]!.label);
attempt("hole value", () => holey[2]!.label);
attempt("order", () => cells[at(7)]!.count + at(1));
console.log("reads", reads);
let total = 0;
try {
for (let i = 0; i < 4; i++) total += grid[i]![0]!;
} catch (e) {
console.log("loop", (e as Error).message, total);
}
console.log(cells.map((c) => `${c.label}:${c.count}`).join(" "));
// A write through a missing element evaluates the key and the value before
// it throws; an error the value throws is the one that propagates.
const events: string[] = [];
const note = (event: string, value: number): number => {
events.push(event);
return value;
};
attempt("write order", () => {
cells[note("index", 7)]!.count = note("value", 1);
});
attempt("write key order", () => {
grid[note("row", 9)]![note("column", 0)] = note("cell", 5);
});
attempt("write literal key", () => {
grid[9]![0] = note("cell 0", 5);
});
attempt("value throws", () => {
cells[7]!.count = (() => {
throw new Error("from the value");
})();
});
attempt("present write", () => {
cells[0]!.count = note("present", 41) + 1;
return cells[0]!.count;
});
console.log("events", events.join(","));
// The array is read before an index that replaces it.
let current = [new Cell("first")];
const replace = (): number => {
current = [];
return 0;
};
attempt("replaced read", () => current[replace()]!.label);
current = [new Cell("second")];
attempt("replaced write", () => {
current[replace()]!.label = "written";
});
let names = ["kept"];
attempt("replaced string", () => `${names[(names = [], 0)]!}`);
console.log(current.length, names.length);
// The element is read before the key and the value: a value that replaces,
// removes or adds it writes to, or misses, the element the target read, and
// a key the value reassigns is the one read before it.
const slots = [new Cell("p"), new Cell("q")];
const original = slots[0]!;
attempt("value replaces", () => {
slots[0]!.label = ((slots[0] = new Cell("r")), "set");
return `${original.label} ${slots[0]!.label}`;
});
const tail = slots[1]!;
attempt("value removes", () => {
slots[1]!.label = (slots.pop(), "gone");
return `${tail.label} ${slots.length}`;
});
attempt("value adds", () => {
slots[1]!.label = (slots.push(new Cell("s")), "added");
});
const table: number[][] = [[1, 2]];
let column = 0;
attempt("value moves key", () => {
table[0]![column] = ((column = 1), 5);
return table[0]!.join(",");
});
column = 0;
attempt("value moves missing key", () => {
table[4]![column] = ((column = 1), 5);
});
console.log(slots.length, column);
@@ -0,0 +1 @@
{ "plain": 3000, "san": 3800 }
@@ -0,0 +1 @@
{ "plain": 3585, "san": 5768 }