diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts index 07f0142bd..d12ef08b9 100644 --- a/packages/compiler/src/backend/llvm/byte-numbers.ts +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -3,6 +3,7 @@ import type { IrExpr } from "../../ir/ir.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { emitTruncF64 } from "./trunc.js"; /** Supported LLVM targets are little-endian. All memory accesses use align * 1: Buffer fields and DataView windows can start at arbitrary byte offsets. */ @@ -101,11 +102,9 @@ export function emitByteNumber( if (spec.dataView) { const nan = B.tmp(), finiteOrZero = B.tmp(); - normalized = B.tmp(); B.line(`${nan} = fcmp uno double ${offset.name}, ${offset.name}`); B.line(`${finiteOrZero} = select i1 ${nan}, double ${f64Lit(0)}, double ${offset.name}`); - host.declare("declare double @llvm.trunc.f64(double)"); - B.line(`${normalized} = call double @llvm.trunc.f64(double ${finiteOrZero})`); + normalized = emitTruncF64(host, finiteOrZero); } const cap = B.tmp(), positive = B.tmp(), diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..a15782b31 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -516,6 +516,10 @@ export class LlEmitter { readonly ffiExtendNarrowIntegers: boolean; readonly cycleColorOffset: number; readonly wasi: boolean; + /** The target lacks a rounding instruction at its baseline CPU (x86-64 + * before SSE4.1), so `llvm.trunc` would lower to a libm call: emit + * truncation inline through an integer round trip instead. */ + readonly inlineTrunc: boolean; /** ELF worker executables give thread-locals the executable TLS models. */ private readonly executableTls: boolean; private readonly emitLibraryIdentity: boolean; @@ -807,6 +811,14 @@ export class LlEmitter { this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); this.wasi = options.wasi === true; + // An empty triple targets the host, as for executableTls below: the + // Node-hosted CLI passes none, the native CLI passes its toolchain's, and + // both must emit the same module for the same target. + this.inlineTrunc = + !this.wasi && + (options.targetTriple + ? /^(x86_64|amd64)\b/i.test(options.targetTriple) + : process.arch === "x64"); this.executableTls = mod.workers === true && mod.lib === undefined && diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 2d64ae788..21fd1ed32 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -371,15 +371,14 @@ export function emitToUint32( B.condBr(finite, finiteLabel, nonfiniteLabel); B.startBlock(finiteLabel); - host.declare(`declare double @llvm.trunc.f64(double)`); - const truncated = B.tmp(); + // Only magnitudes above 2^53 reach this block, and every such double is + // already an integer: no truncation is needed before the reduction. const residue = B.tmp(); const negative = B.tmp(); const wrapped = B.tmp(); const normalized = B.tmp(); const finiteU32 = B.tmp(); - B.line(`${truncated} = call double @llvm.trunc.f64(double ${value})`); - B.line(`${residue} = frem double ${truncated}, ${f64Lit(4294967296)}`); + B.line(`${residue} = frem double ${value}, ${f64Lit(4294967296)}`); B.line(`${negative} = fcmp olt double ${residue}, ${f64Lit(0)}`); B.line(`${wrapped} = fadd double ${residue}, ${f64Lit(4294967296)}`); B.line(`${normalized} = select i1 ${negative}, double ${wrapped}, double ${residue}`); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 5a70a55a8..41d93317b 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -7,6 +7,7 @@ import { arrNewCall, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { emitAlwaysThrowLibCall } from "./lib-shared.js"; +import { emitTruncF64 } from "./trunc.js"; const FS_ALWAYS_THROW_SYMS: Readonly> = { "fs.mkdtempChk": "scr_fs_mkdtemp_chk", @@ -663,8 +664,23 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; - if (e.fn === "math.floor" || e.fn === "math.trunc" || e.fn === "math.ceil") { - const intr = e.fn === "math.floor" ? "floor" : e.fn === "math.trunc" ? "trunc" : "ceil"; + if (e.fn === "math.min" || e.fn === "math.max") { + // IEEE 754-2019 minimum/maximum: NaN propagates and -0 orders below +0, + // exactly Math.min/Math.max of two numbers. + const a = host.emitExpr(e.args[0]!); + const b = host.emitExpr(e.args[1]!); + const intr = e.fn === "math.min" ? "minimum" : "maximum"; + host.declare(`declare double @llvm.${intr}.f64(double, double)`); + const t = B.tmp(); + B.line(`${t} = call double @llvm.${intr}.f64(double ${a.name}, double ${b.name})`); + return { name: t, type: e.type }; + } + if (e.fn === "math.trunc") { + const v = host.emitExpr(e.args[0]!); + return { name: emitTruncF64(host, v.name), type: e.type }; + } + if (e.fn === "math.floor" || e.fn === "math.ceil") { + const intr = e.fn === "math.floor" ? "floor" : "ceil"; const v = host.emitExpr(e.args[0]!); host.declare(`declare double @llvm.${intr}.f64(double)`); const t = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/trunc.ts b/packages/compiler/src/backend/llvm/trunc.ts new file mode 100644 index 000000000..6d7b886aa --- /dev/null +++ b/packages/compiler/src/backend/llvm/trunc.ts @@ -0,0 +1,35 @@ +/* ToIntegerOrInfinity-style truncation of a double toward zero. */ +import type { LlvmEmitterContext } from "./expr-context.js"; +import { f64Lit } from "./common.js"; + +/** `Math.trunc(value)`. Targets with a rounding instruction use + * `llvm.trunc`; on baseline x86-64 that intrinsic is a libm call, so the + * value instead takes an integer round trip. Every double of magnitude at + * least 2^52 is already an integer, and NaN and the infinities fail the + * magnitude test, so those keep their value; the round trip of a smaller + * value restores its sign so that `-0.5` and `-0` truncate to `-0`. */ +export function emitTruncF64(host: LlvmEmitterContext, value: string): string { + const B = host.B; + if (!host.inlineTrunc) { + host.declare("declare double @llvm.trunc.f64(double)"); + const out = B.tmp(); + B.line(`${out} = call double @llvm.trunc.f64(double ${value})`); + return out; + } + host.declare("declare double @llvm.fabs.f64(double)"); + host.declare("declare double @llvm.copysign.f64(double, double)"); + const magnitude = B.tmp(), + small = B.tmp(), + integer = B.tmp(), + back = B.tmp(), + signed = B.tmp(), + out = B.tmp(); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${value})`); + B.line(`${small} = fcmp olt double ${magnitude}, ${f64Lit(2 ** 52)}`); + // Poison for a large or non-finite value, which the select never picks. + B.line(`${integer} = fptosi double ${value} to i64`); + B.line(`${back} = sitofp i64 ${integer} to double`); + B.line(`${signed} = call double @llvm.copysign.f64(double ${back}, double ${value})`); + B.line(`${out} = select i1 ${small}, double ${signed}, double ${value}`); + return out; +} diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 80438dbf0..a682e0401 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -7,6 +7,7 @@ import { type IrStmt, type IrType, JSVAL, + isIntegerBytesElem, type SrcLoc, UNDEFINED_T, typeEquals, @@ -282,6 +283,42 @@ export function lowerSafeBytesRead( type: F64, loc: at, }; + if (isIntegerBytesElem(bytesT.elem)) { + // An integer element is never NaN, so the NaN-for-invalid read + // decides presence with one inline index check (no separate range + // and integrality tests). + const v = varRef("v.0", F64, at); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "b.0", name: "b", type: bytesT }, + { localId: "i.0", name: "i", type: F64 }, + ], + returnType: resultT, + locals: [ + { id: "b.0", name: "b", type: bytesT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: false }, + { id: "v.0", name: "v", type: F64, mutable: false }, + ], + body: [ + { kind: "varDecl", localId: "v.0", init: { ...read, invalidNaN: true }, loc: at }, + { + kind: "return", + value: { + kind: "ternary", + cond: { kind: "libCall", fn: "num.isNaN", args: [v], type: BOOL, loc: at }, + then: lowerer.wrappedUndefined(resultT, at)!, + else_: lowerer.coerceToExpected(v, resultT), + type: resultT, + loc: at, + }, + loc: at, + }, + ], + loc: at, + }); + return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc }; + } lowerer.liftedFns.push({ name, params: [ diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index b625b41d7..677b29e67 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -362,7 +362,14 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { } lowerExprDepth++; try { - const lowered = lowerExprInner(lowerer, expr); + let lowered = lowerExprInner(lowerer, expr); + const nanCoded = lowerer.nanCodedReads; + if (nanCoded) { + if (lowered.type.kind === "f64" && nanCoded.reboxes(expr)) + lowered = reboxNanCoded(lowerer, lowered, locOf(expr)); + else if (lowered.type.kind === "union" && nanCoded.convertsSource(expr)) + lowered = lowerOptionalNumber(lowerer, lowered, locOf(expr)); + } if (lowered.type.kind === "void" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Never) !== 0) lowerer.neverValued.add(lowered); return lowered; @@ -371,6 +378,28 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { } } +/** `number | undefined` from a NaN-coded double whose slot never holds a + * genuine NaN (nan-coded-reads.ts): NaN is exactly undefined. */ +function reboxNanCoded(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr { + const optional = lowerer.withUndefinedArm(F64); + const stmts: IrStmt[] = []; + let stable = value; + if (value.kind !== "varRef") { + const tmp = lowerer.declareHiddenLocal("%nanCoded", F64); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + stable = varRef(tmp.id, F64, loc); + } + const result: IrExpr = { + kind: "ternary", + cond: { kind: "libCall", fn: "num.isNaN", args: [stable], type: BOOL, loc }, + then: lowerer.wrappedUndefined(optional, loc)!, + else_: lowerer.coerceToExpected(stable, optional), + type: optional, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: optional, loc }; +} + function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const loc = locOf(expr); @@ -6768,7 +6797,12 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre // `t[i]!` asserts presence: the numeric element read (NaN when // invalid). Every other read answers undefined for an invalid index, // like Node. - if (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) { + // A read whose every consumer treats undefined like NaN (see + // nan-coded-reads.ts) is the same plain numeric read. + if ( + (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + lowerer.nanCodedReads?.isNumericRead(expr) === true + ) { return { kind: "bytesIntrinsic", method: "get", @@ -9670,6 +9704,65 @@ export function lowerCompoundValueToTarget( lowerer.unsupported("SC1043", expr); } +/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may + * stand for undefined and never holds a genuine NaN: Node's undefined equals + * undefined, so two NaNs compare equal here. Operands evaluate once, in + * order. */ +function lowerNanCodedEquality(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { + const loc = locOf(expr); + const left = lowerer.lowerExpr(expr.left); + const right = lowerer.lowerExpr(expr.right); + if (left.type.kind !== "f64" || right.type.kind !== "f64") { + throw new InternalCompilerError("NaN-coded equality operand is not a plain number"); + } + const stmts: IrStmt[] = []; + const stable = (value: IrExpr, name: string): IrExpr => { + if (value.kind === "varRef" || value.kind === "numLit") return value; + const tmp = lowerer.declareHiddenLocal(name, F64); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, F64, loc); + }; + const a = stable(left, "%eqLeft"); + const b = stable(right, "%eqRight"); + const isNaN = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "num.isNaN", + args: [value], + type: BOOL, + loc, + }); + const bothUndefined: IrExpr = { + kind: "logical", + op: "&&", + left: isNaN(a), + right: isNaN(b), + type: BOOL, + loc, + }; + const op = expr.operatorToken.kind; + const negated = + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + const result: IrExpr = negated + ? { + kind: "logical", + op: "&&", + left: { kind: "bin", op: "!==", left: a, right: b, type: BOOL, loc }, + right: { kind: "unary", op: "!", operand: bothUndefined, type: BOOL, loc }, + type: BOOL, + loc, + } + : { + kind: "logical", + op: "||", + left: { kind: "bin", op: "===", left: a, right: b, type: BOOL, loc }, + right: bothUndefined, + type: BOOL, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; +} + export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { const loc = locOf(expr); const op = expr.operatorToken.kind; @@ -9677,6 +9770,11 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const cacheHas = lowerRequireCacheHas(lowerer, expr); if (cacheHas) return cacheHas; + if (lowerer.nanCodedReads?.needsUndefinedEquality(expr)) { + const undefinedEquality = lowerNanCodedEquality(lowerer, expr); + if (undefinedEquality) return undefinedEquality; + } + if ( op === ts.SyntaxKind.EqualsToken || (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 20ba4b9a7..9cea42ed9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -75,6 +75,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js"; import { indexReadInBounds } from "./runtime-optional-bounds.js"; import { ArrayOwnership } from "./runtime-optional-ownership.js"; import { StringIndexBounds } from "./string-index-bounds.js"; +import { NanCodedReads } from "./nan-coded-reads.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js"; import { UnregisteredClassTypes } from "./unregistered-class-types.js"; @@ -1641,6 +1642,10 @@ export class Lowerer { /** Per-symbol result of the never-reassigned file scan * (bindingNeverReassigned — object-literal generic-method receivers). */ readonly neverReassignedCache = new Map(); + /** Typed-array element reads whose every consumer treats undefined like + * NaN, which lower to plain doubles (see nan-coded-reads.ts). Settled by + * analyzeRuntimeOptionalArrayReads before any body lowers. */ + nanCodedReads: NanCodedReads | null = null; private stringIndexBoundsCache: StringIndexBounds | null = null; /** Proofs that a string element read names an existing code unit. The * indexed-read analysis and expression lowering share one instance, so a @@ -3894,6 +3899,38 @@ export class Lowerer { } } } + const mappedOrNull = (node: ts.Node): IrType | null => + panicSafe(() => this.mapTypeOf(this.typeOf(node)), null); + const nanCoded = new NanCodedReads( + { + symbolOf, + declarationOf: (symbol) => this.checker.valueDeclarationOf(symbol), + bytesElemOf: (receiver) => { + const t = mappedOrNull(receiver); + return t?.kind === "bytes" ? t.elem : null; + }, + isNumber: (node) => mappedOrNull(node)?.kind === "f64", + isString: (node) => mappedOrNull(node)?.kind === "string", + hasLength: (node) => { + const kind = mappedOrNull(node)?.kind; + return kind === "array" || kind === "bytes"; + }, + isStdlibGlobal: (node, name) => this.isStdlibGlobal(node, name), + paramIsNumber: (fn, index) => { + const param = signatureBySymbol.get(fn)?.params[index]; + return ( + param !== undefined && + param.type.kind === "f64" && + (param.mode === "required" || param.mode === "omittable") + ); + }, + returnIsNumber: (fn) => signatureBySymbol.get(fn)?.returnType.kind === "f64", + methodOverridden: (fn) => (familyBySymbol.get(fn)?.length ?? 1) > 1, + }, + sourceFiles, + ); + nanCoded.analyze(); + this.nanCodedReads = nanCoded; const peel = (node: ts.Expression): ts.Expression => { let e = node; while ( @@ -4005,8 +4042,10 @@ export class Lowerer { if (!ts.isElementAccessExpression(e)) return false; const kind = this.mapTypeOf(this.typeOf(e.expression))?.kind; if (kind === "string") return !this.stringIndexBounds.inBounds(e); - // Typed-array reads answer undefined for an invalid index too. - return kind === "array" || kind === "bytes"; + // Typed-array reads answer undefined for an invalid index too, unless + // every consumer treats undefined like NaN. + if (kind === "bytes") return !nanCoded.isNumericRead(e); + return kind === "array"; }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); @@ -4094,6 +4133,11 @@ export class Lowerer { ) >= 0 ) return true; + // A NaN-coded value that reaches a consumer able to observe undefined + // is rebuilt as the ordinary optional value there; every other read + // of a NaN-coded slot is a plain number. + if (nanCoded.reboxes(e)) return true; + if (nanCoded.readsNanCodedSlot(e)) return false; if (ts.isIdentifier(e)) { const symbol = symbolOf(e); return ( @@ -5372,6 +5416,21 @@ export class Lowerer { } } } + // NaN-coded slots convert optional values on entry instead of holding + // the union, so promotion never applies to them. + for (const symbol of [...optionalSymbols]) + if (nanCoded.nanCodedBinding(symbol)) optionalSymbols.delete(symbol); + for (const symbol of [...optionalReturns]) + if (nanCoded.nanCodedReturn(symbol)) optionalReturns.delete(symbol); + for (const [symbol, params] of optionalParams) { + const decl = functionDeclBySymbol.get(symbol); + for (const index of [...params]) { + const name = decl?.parameters[index]?.name; + const param = name && ts.isIdentifier(name) ? symbolOf(name) : null; + if (param && nanCoded.nanCodedBinding(param)) params.delete(index); + } + if (params.size === 0) optionalParams.delete(symbol); + } for (const [site, present] of presentSites) if (present) this.presentElementCalls.add(site); for (const [loop, present] of presentLoops) if (present) this.presentElementLoops.add(loop); const globalsById = new Map(this.globalsList.map((global) => [global.id, global])); diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts new file mode 100644 index 000000000..6f6a0306d --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -0,0 +1,1099 @@ +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFile } from "../program.js"; +import type { IrBytesElem } from "../../ir/ir.js"; +import { isIntegerBytesElem } from "../../ir/ir.js"; + +/* NaN-coded typed-array reads. + * + * In Node a typed-array element read `t[i]` answers undefined when `i` is not + * an integer in [0, length). The checker types the read as `number`, so the + * default lowering returns a heap-boxed `number | undefined` union and widens + * every binding, parameter and return it reaches. + * + * Many consumers cannot tell undefined from NaN: relational and arithmetic + * operators and bitwise operators convert undefined to NaN (or 0, exactly as + * NaN converts), truthiness treats both as false, `Math` functions convert + * them alike, and strict or loose equality with a number that is never + * undefined is false for both. Where every consumer of a read is of this + * kind, the read lowers to a plain double that holds NaN for an invalid + * index: no box, no widening, and no observable difference. + * + * The analysis follows values through local `let`/`const` bindings, + * parameters of directly called functions and methods, and their returns. + * Such a slot is NaN-coded when every value it receives is a NaN-coded value + * or a number that is never undefined, and every use of it is one of the + * consumers above or flows into another NaN-coded slot. Equality between two + * NaN-coded values must answer true when both stand for undefined, so it is + * lowered as `a === b || (a !== a && b !== b)`; that is exact only while + * neither side can also hold a genuine NaN, which the analysis tracks (an + * integer element is never NaN; arithmetic and float elements may be). + * + * Everything else keeps the established `number | undefined` lowering. */ + +export interface NanCodedHost { + symbolOf(node: ts.Node): ts.Symbol | null; + declarationOf(symbol: ts.Symbol): ts.Node | undefined; + /** The element kind of a typed-array receiver, or null. */ + bytesElemOf(receiver: ts.Expression): IrBytesElem | null; + /** The static IR type of the node is a plain number. */ + isNumber(node: ts.Node): boolean; + isString(node: ts.Expression): boolean; + /** The receiver is an array or a typed array (for `.length`). */ + hasLength(node: ts.Expression): boolean; + isStdlibGlobal(node: ts.Expression, name: string): boolean; + /** Parameter `index` of the callable is a plain `number` in its signature. */ + paramIsNumber(fn: ts.Symbol, index: number): boolean; + /** The callable's signature returns a plain `number`. */ + returnIsNumber(fn: ts.Symbol): boolean; + /** The method shares its dispatch slot with another class's method. */ + methodOverridden(fn: ts.Symbol): boolean; +} + +interface ValueInfo { + /** May stand for undefined (NaN-coded). */ + nce: boolean; + /** May also hold a genuine NaN. */ + nan: boolean; +} + +type Slot = BindingSlot | ReturnSlot; + +interface SlotBase { + /** Values written into the slot (initializers, assignments, arguments, + * return expressions); null marks a write of unknown value. */ + sources: (ts.Expression | null)[]; + /** Value occurrences whose consumers must be NaN-insensitive. */ + uses: ts.Expression[]; + /** Compound writes and increments store arithmetic results. */ + arithmeticWrites: boolean; +} + +interface BindingSlot extends SlotBase { + kind: "binding"; + symbol: ts.Symbol; +} + +interface ReturnSlot extends SlotBase { + kind: "return"; + symbol: ts.Symbol; +} + +function peelTransparent(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; +} + +/** The node whose consumer decides how `node`'s value is used. */ +function transparentParent(node: ts.Expression): ts.Expression { + let e: ts.Expression = node; + for (;;) { + const p = e.parent; + if ( + p && + (ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isTypeAssertion(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p)) && + p.expression === e + ) { + e = p; + continue; + } + return e; + } +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +const RELATIONAL = new Set([ + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); +const EQUALITY = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); +/** Results never NaN-free: ToNumber of each operand may be NaN. */ +const ARITHMETIC = new Set([ + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ts.SyntaxKind.AsteriskAsteriskToken, +]); +/** ToInt32/ToUint32 map undefined and NaN to 0 alike; results are integers. */ +const BITWISE = new Set([ + ts.SyntaxKind.AmpersandToken, + ts.SyntaxKind.BarToken, + ts.SyntaxKind.CaretToken, + ts.SyntaxKind.LessThanLessThanToken, + ts.SyntaxKind.GreaterThanGreaterThanToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, +]); +/** Compound assignments whose operator converts undefined like NaN. */ +const NUMERIC_COMPOUND = new Set([ + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, +]); + +/** `Math` functions convert every argument with ToNumber. */ +const MATH_INTEGER_RESULTS = new Set(["imul", "clz32", "sign"]); + +function isLoopOrIf(node: ts.Node): boolean { + return ( + ts.isIfStatement(node) || + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) + ); +} + +export class NanCodedReads { + /** Typed-array element reads that can lower as NaN-coded doubles. */ + private readonly reads = new Map(); + private readonly bindings = new Map(); + private readonly notSlots = new Set(); + /** Every slot's value occurrences. */ + private readonly useNodes = new Set(); + private readonly returns = new Map(); + /** Identifiers by text across the analyzed files. */ + private readonly names = new Map(); + private readonly symbolCache = new Map(); + private readonly pneCache = new Map(); + private readonly pneReturnCache = new Map(); + private readonly fnEligibility = new Map(); + private readonly callSites = new Map(); + /** Slots that are NaN-coded after the fixed point. */ + private readonly eligible = new Set(); + private readonly genuineNaN = new Set(); + private readonly numericReads = new Set(); + private analyzed = false; + + constructor( + private readonly host: NanCodedHost, + private readonly files: readonly ts.SourceFile[], + ) {} + + /** The read lowers to a double holding NaN for an invalid index. */ + isNumericRead(read: ts.ElementAccessExpression): boolean { + return this.numericReads.has(read); + } + + /** Both operands of this equality are NaN-coded values that may stand + * for undefined: undefined equals undefined, so equal NaNs compare equal. */ + needsUndefinedEquality(expr: ts.BinaryExpression): boolean { + if (!EQUALITY.has(expr.operatorToken.kind)) return false; + const left = this.loweredInfo(expr.left); + const right = this.loweredInfo(expr.right); + return left.nce && right.nce && !left.nan && !right.nan; + } + + /** Bindings and returns that are NaN-coded (consistency checks). */ + nanCodedBinding(symbol: ts.Symbol): boolean { + const slot = this.bindings.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + nanCodedReturn(symbol: ts.Symbol): boolean { + const slot = this.returns.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + get size(): { reads: number; slots: number } { + return { reads: this.numericReads.size, slots: this.eligible.size }; + } + + analyze(): void { + if (this.analyzed) return; + this.analyzed = true; + for (const sf of this.files) { + if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if (ts.isIdentifier(node)) { + const list = this.names.get(node.text); + if (list) list.push(node); + else this.names.set(node.text, [node]); + return; + } + if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) { + const elem = this.host.bytesElemOf(node.expression); + if (elem) this.reads.set(node, elem); + } + }); + } + if (this.reads.size === 0) return; + // Forward: the slots a read can reach. + const work: ts.Expression[] = [...this.reads.keys()]; + const reached = new Set(); + while (work.length > 0) { + const value = work.pop()!; + const slot = this.sinkOf(value); + if (!slot || reached.has(slot)) continue; + reached.add(slot); + for (const use of slot.uses) work.push(use); + } + for (const slot of reached) this.eligible.add(slot); + // Backward: drop slots with an unsafe use or source until stable. + for (let changed = true; changed;) { + changed = false; + this.computeGenuineNaN(); + for (const slot of [...this.eligible]) { + if (this.slotOk(slot)) continue; + if (this.removals) this.removals.set(slot, this.removalReason(slot)); + this.eligible.delete(slot); + changed = true; + } + } + this.computeGenuineNaN(); + for (const read of this.reads.keys()) if (this.useSafe(read)) this.numericReads.add(read); + if (process.env["SCRIPTC_DEBUG_NAN_CODED"]) this.debugReport(reached); + } + + private readonly removals: Map | null = process.env["SCRIPTC_DEBUG_NAN_CODED"] + ? new Map() + : null; + + private removalReason(slot: Slot): string { + const badSource = slot.sources.find((s) => s === null || this.sourceInfo(s) === null); + if (badSource !== undefined) + return `source ${badSource ? badSource.getText().slice(0, 60) : ""}`; + const nan = this.genuineNaN.has(slot); + const badUse = slot.uses.find((u) => !this.useSafe(u) && (nan || !this.reboxConsumer(u))); + if (badUse) + return `${nan ? "nan-use" : "use"} ${transparentParent(badUse).parent?.getText().slice(0, 80) ?? "?"}`; + return "?"; + } + + private debugReport(reached: Set): void { + const name = (slot: Slot): string => { + const decl = this.host.declarationOf(slot.symbol); + const sf = decl?.getSourceFile(); + const where = sf && decl ? `${sf.fileName}:${decl.getStart()}` : "?"; + return `${slot.kind} ${slot.symbol.name} @ ${where}`; + }; + for (const slot of reached) { + const ok = this.eligible.has(slot); + const why = ok + ? this.genuineNaN.has(slot) + ? "(nan)" + : `(rebox ${slot.uses.filter((u) => !this.useSafe(u)).length})` + : (this.removals?.get(slot) ?? "?"); + process.stderr.write(`nan-coded ${ok ? "yes" : "no "} ${name(slot)} ${why}\n`); + } + process.stderr.write( + `nan-coded reads ${this.numericReads.size}/${this.reads.size}, slots ${this.eligible.size}/${reached.size}\n`, + ); + } + + private symbolOf(node: ts.Node): ts.Symbol | null { + // A cached null is an answer: `!` would be a checked extraction in a + // native build of the compiler. + const cached = this.symbolCache.get(node); + if (cached !== undefined) return cached; + const symbol = this.host.symbolOf(node); + this.symbolCache.set(node, symbol); + return symbol; + } + + /** An element read (never a write target) of a TypeScript file. */ + private isReadCandidate(read: ts.ElementAccessExpression): boolean { + if (read.questionDotToken) return false; + let n: ts.Node = read; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + // `t[i]!` asserts presence: it already lowers to the plain numeric read + // and is never optional. + if (!p || ts.isNonNullExpression(p)) return false; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) + return false; + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return false; + if (ts.isDeleteExpression(p)) return false; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isForOfStatement(p) || + ts.isForInStatement(p) + ) + return false; + return true; + } + + // ---- slots -------------------------------------------------------------- + + /** The function whose `return` statement contains `node`. */ + private containingFunction(node: ts.Node): ts.Node | undefined { + let n = node.parent; + while ( + n && + !ts.isFunctionLike(n) && + !ts.isClassStaticBlockDeclaration(n) && + !ts.isSourceFile(n) + ) + n = n.parent; + return n; + } + + /** The declaration of a directly called function or method, when every + * reference to it is a direct call (no value use, no overrides). */ + private eligibleFunction(symbol: ts.Symbol): ts.FunctionLikeDeclaration | null { + const known = this.fnEligibility.get(symbol); + if (known !== undefined) return known; + this.fnEligibility.set(symbol, null); + const decl = this.host.declarationOf(symbol); + if (!decl || (!ts.isFunctionDeclaration(decl) && !ts.isMethodDeclaration(decl))) return null; + if (!decl.body || decl.asteriskToken || decl.typeParameters || !decl.name) return null; + if (!ts.isIdentifier(decl.name)) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + const flags = ts.getCombinedModifierFlags(decl); + if (flags & (ts.ModifierFlags.Async | ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) + return null; + if (ts.isMethodDeclaration(decl)) { + const cls = decl.parent; + if (!ts.isClassDeclaration(cls) || (cls.heritageClauses?.length ?? 0) > 0) return null; + if (this.host.methodOverridden(symbol)) return null; + } + // Sloppy-mode `arguments` aliases parameters. + let usesArguments = false; + ts.walkPreorder(decl.body, (node) => { + if (ts.isIdentifier(node) && node.text === "arguments") usesArguments = true; + }); + if (usesArguments) return null; + const calls = this.directCalls(symbol, decl.name); + if (!calls) return null; + this.fnEligibility.set(symbol, decl); + return decl; + } + + /** Every call of the function, or null when some reference is not a + * direct, non-optional call. */ + private directCalls(symbol: ts.Symbol, declName: ts.Identifier): ts.CallExpression[] | null { + const known = this.callSites.get(symbol); + if (known !== undefined) return known; + const calls: ts.CallExpression[] = []; + let ok = true; + for (const id of this.names.get(declName.text) ?? []) { + if (id === declName) continue; + // Import and export specifiers only alias the function. + const parent = id.parent; + if ( + parent && + (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isImportClause(parent)) + ) + continue; + if (this.symbolOf(id) !== symbol) continue; + let callee: ts.Expression = id; + const p = id.parent; + if (p && ts.isPropertyAccessExpression(p) && p.name === id) { + if (p.questionDotToken) ok = false; + callee = p; + } + const call = callee.parent; + if ( + !call || + !ts.isCallExpression(call) || + call.expression !== callee || + call.questionDotToken || + call.arguments.some((a) => ts.isSpreadElement(a)) + ) { + ok = false; + break; + } + calls.push(call); + } + const result = ok ? calls : null; + this.callSites.set(symbol, result); + return result; + } + + private bindingSlot(symbol: ts.Symbol): BindingSlot | null { + const existing = this.bindings.get(symbol); + if (existing) return existing; + if (this.notSlots.has(symbol)) return null; + const slot = this.scanBindingSlot(symbol); + if (slot) this.bindings.set(symbol, slot); + else this.notSlots.add(symbol); + return slot; + } + + private scanBindingSlot(symbol: ts.Symbol): BindingSlot | null { + const decl = this.host.declarationOf(symbol); + if (!decl) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + let nameNode: ts.Identifier; + const slot: BindingSlot = { + kind: "binding", + symbol, + sources: [], + uses: [], + arithmeticWrites: false, + }; + if (ts.isVariableDeclaration(decl)) { + const list = decl.parent; + if ( + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(list) || + !(list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) || + ts.isForOfStatement(list.parent) || + ts.isForInStatement(list.parent) + ) + return null; + // Module bindings are globals with their own promotion rules. + if (ts.isVariableStatement(list.parent) && ts.isSourceFile(list.parent.parent)) return null; + if (!this.host.isNumber(decl.name)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + } else if (ts.isParameter(decl)) { + if (!ts.isIdentifier(decl.name) || decl.dotDotDotToken || decl.questionToken) return null; + const fn = decl.parent; + if (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) return null; + const fnSymbol = fn.name ? this.symbolOf(fn.name) : null; + if (!fnSymbol || !this.eligibleFunction(fnSymbol)) return null; + const index = fn.parameters.indexOf(decl); + if (index < 0 || !this.host.paramIsNumber(fnSymbol, index)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + for (const call of this.callSites.get(fnSymbol) ?? []) { + const arg = call.arguments[index]; + if (arg) slot.sources.push(arg); + else if (!decl.initializer) slot.sources.push(null); + } + } else return null; + for (const id of this.names.get(nameNode.text) ?? []) { + if (id === nameNode || this.symbolOf(id) !== symbol) continue; + const write = this.writeOf(id); + if (write === "read") slot.uses.push(id); + else if (write === "arith") { + slot.arithmeticWrites = true; + // The compound operator reads the binding as well. + slot.uses.push(id); + } else if (write === "unknown") slot.sources.push(null); + else slot.sources.push(write); + } + for (const use of slot.uses) this.useNodes.add(use); + return slot; + } + + /** How an identifier reference touches its binding. */ + private writeOf(id: ts.Identifier): "read" | "arith" | "unknown" | ts.Expression { + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return "read"; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right; + if (k === ts.SyntaxKind.PlusEqualsToken) + return this.host.isNumber(p.right) ? "arith" : "unknown"; + return NUMERIC_COMPOUND.has(k) ? "arith" : "unknown"; + } + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return "arith"; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + (ts.isPropertyAssignment(p) && p.initializer === n) || + ((ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n) + ) { + // A destructuring target, or an object literal value (a read). + return ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) + ? this.inDestructuringTarget(p) + ? "unknown" + : "read" + : ts.isForOfStatement(p) || ts.isForInStatement(p) + ? "unknown" + : this.inDestructuringTarget(p) + ? "unknown" + : "read"; + } + return "read"; + } + + private inDestructuringTarget(node: ts.Node): boolean { + let n: ts.Node = node; + let p = n.parent; + while ( + p && + (ts.isArrayLiteralExpression(p) || + ts.isObjectLiteralExpression(p) || + ts.isPropertyAssignment(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isSpreadAssignment(p) || + ts.isParenthesizedExpression(p)) + ) { + n = p; + p = n.parent; + } + if (!p) return false; + if ( + ts.isBinaryExpression(p) && + p.left === n && + p.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + return true; + return (ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n; + } + + private returnSlot(symbol: ts.Symbol): ReturnSlot | null { + const existing = this.returns.get(symbol); + if (existing) return existing; + const decl = this.eligibleFunction(symbol); + if (!decl || !decl.body || !this.host.returnIsNumber(symbol)) return null; + const slot: ReturnSlot = { + kind: "return", + symbol, + sources: [], + uses: [...(this.callSites.get(symbol) ?? [])], + arithmeticWrites: false, + }; + const body = decl.body; + ts.walkPreorder(body, (node) => { + if (!ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + slot.sources.push(node.expression ?? null); + }); + for (const use of slot.uses) this.useNodes.add(use); + this.returns.set(symbol, slot); + return slot; + } + + /** The callee of a call: a function or method symbol. */ + private calleeSymbol(call: ts.CallExpression): ts.Symbol | null { + if (call.questionDotToken) return null; + let callee = call.expression; + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + if (ts.isIdentifier(callee)) return this.symbolOf(callee); + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) + return this.symbolOf(callee.name); + return null; + } + + /** The slot a value flows into, through transparent wrappers and + * conditional branches; null for a direct consumer. */ + private sinkOf(value: ts.Expression): Slot | null { + const e = transparentParent(value); + const p = e.parent; + if (!p) return null; + if (ts.isConditionalExpression(p) && p.condition !== e) return this.sinkOf(p); + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.CommaToken && p.right === e) return this.sinkOf(p); + if (k === ts.SyntaxKind.EqualsToken && p.right === e) { + const target = peelTransparent(p.left); + if (!ts.isIdentifier(target)) return null; + const symbol = this.symbolOf(target); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + if (ts.isVariableDeclaration(p) && p.initializer === e && ts.isIdentifier(p.name)) { + const symbol = this.symbolOf(p.name); + return symbol ? this.bindingSlot(symbol) : null; + } + if (ts.isReturnStatement(p)) { + const fn = this.containingFunction(p); + if (!fn || (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) || !fn.name) + return null; + const symbol = this.symbolOf(fn.name); + return symbol ? this.returnSlot(symbol) : null; + } + if (ts.isCallExpression(p) && p.expression !== e) { + const index = p.arguments.indexOf(e); + const fn = this.calleeSymbol(p); + if (index < 0 || !fn) return null; + const decl = this.eligibleFunction(fn); + const param = decl?.parameters[index]; + if (!param || !ts.isIdentifier(param.name)) return null; + const symbol = this.symbolOf(param.name); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + + /** A slot that never holds a genuine NaN encodes undefined exactly, so + * any consumer can rebuild the union (see `reboxes`); a slot that may + * hold a genuine NaN needs NaN-insensitive consumers throughout. */ + private slotOk(slot: Slot): boolean { + for (const source of slot.sources) + if (source === null || !this.sourceInfo(source)) return false; + const nan = this.genuineNaN.has(slot); + for (const use of slot.uses) + if (!this.useSafe(use) && (nan || !this.reboxConsumer(use))) return false; + return true; + } + + /** The eligible slot whose value this occurrence reads, if any. */ + private slotOfValue(node: ts.Expression): Slot | null { + if (ts.isIdentifier(node)) { + const symbol = this.symbolOf(node); + const slot = symbol ? this.bindings.get(symbol) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + if (ts.isCallExpression(node)) { + const fn = this.calleeSymbol(node); + const slot = fn ? this.returns.get(fn) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + return null; + } + + /** This read of a NaN-coded slot reaches a consumer that could observe + * undefined: lowering rebuilds `number | undefined` from the double (NaN + * is undefined, exactly, since the slot never holds a genuine NaN), and + * the optional-read analysis treats the occurrence as optional, so the + * consumer lowers exactly as it does for an ordinary read. */ + reboxes(node: ts.Expression): boolean { + if (!ts.isIdentifier(node) && !ts.isCallExpression(node)) return false; + const slot = this.slotOfValue(node); + return slot !== null && !this.genuineNaN.has(slot) && !this.useSafe(node); + } + + /** A consumer that takes the value whole, so the rebuilt union lowers + * there exactly like an ordinary optional read: an argument, a stored + * or returned value, a literal element, or string concatenation. Any + * other consumer (a member access, `typeof`, `??`, ...) keeps the slot + * on the ordinary optional lowering instead. */ + private reboxConsumer(value: ts.Expression): boolean { + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition !== e && this.reboxConsumer(p); + if (ts.isCallExpression(p) || ts.isNewExpression(p)) return p.expression !== e; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) return true; + if (ts.isArrayLiteralExpression(p) || ts.isTemplateSpan(p)) return true; + if (ts.isPropertyAssignment(p)) return p.initializer === e; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right === e; + if (k === ts.SyntaxKind.PlusToken) return true; + if (k === ts.SyntaxKind.CommaToken) return p.right === e && this.reboxConsumer(p); + } + return false; + } + + private computeGenuineNaN(): void { + this.genuineNaN.clear(); + for (let changed = true; changed;) { + changed = false; + for (const slot of this.eligible) { + if (this.genuineNaN.has(slot)) continue; + let nan = slot.arithmeticWrites; + for (const source of slot.sources) { + if (nan) break; + if (source) nan = this.sourceInfo(source)?.nan ?? true; + } + if (nan) { + this.genuineNaN.add(slot); + changed = true; + } + } + } + } + + // ---- values ------------------------------------------------------------- + + /** A value allowed into a NaN-coded slot, or null. */ + private sourceInfo(node: ts.Expression): ValueInfo | null { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.sourceInfo(e.whenTrue); + const b = a && this.sourceInfo(e.whenFalse); + return a && b ? { nce: a.nce || b.nce, nan: a.nan || b.nan } : null; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined) return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + if (ts.isIdentifier(e)) { + const symbol = this.symbolOf(e); + const slot = symbol ? this.bindings.get(symbol) : undefined; + if (slot && this.eligible.has(slot)) return { nce: true, nan: this.genuineNaN.has(slot) }; + } + if (ts.isCallExpression(e)) { + const fn = this.calleeSymbol(e); + const slot = fn ? this.returns.get(fn) : undefined; + if (slot && this.eligible.has(slot) && this.useNodes.has(e)) + return { nce: true, nan: this.genuineNaN.has(slot) }; + } + if (ts.isBinaryExpression(e) && this.nanFreeArithmetic(e, 0)) return { nce: false, nan: false }; + const plain = this.plainNumber(e, 0); + if (plain) return plain; + // Any other number value may be undefined at run time (an optional + // read the default lowering widens): lowering converts it with + // ToNumber on entry (see `convertsSource`), so the slot may then hold + // a genuine NaN. + return this.host.isNumber(e) ? { nce: true, nan: true } : null; + } + + /** The value flows into a NaN-coded slot: lowering converts an optional + * number to NaN-for-undefined on entry. */ + convertsSource(node: ts.Expression): boolean { + const slot = this.sinkOf(node); + return slot !== null && this.eligible.has(slot); + } + + /** The occurrence reads a NaN-coded slot. */ + readsNanCodedSlot(node: ts.Expression): boolean { + return this.slotOfValue(node) !== null; + } + + /** `+`, `-` or `*` over finite literals and integer values proven present + * where they are read: the result is never NaN. A NaN-coded slot that + * never holds a genuine NaN holds integers, finite plain numbers or NaN + * for undefined, and a comparison guarding the read excludes NaN. */ + private nanFreeArithmetic(node: ts.Expression, depth: number): boolean { + if (depth > 6) return false; + const e = peelTransparent(node); + if (ts.isNumericLiteral(e)) + return Number.isFinite(Number(e.text)) && Math.abs(Number(e.text)) < 2 ** 53; + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) + return this.nanFreeArithmetic(e.operand, depth + 1); + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.PlusToken || k === ts.SyntaxKind.MinusToken) { + if (!this.host.isNumber(e.left) || !this.host.isNumber(e.right)) return false; + return ( + this.nanFreeArithmetic(e.left, depth + 1) && this.nanFreeArithmetic(e.right, depth + 1) + ); + } + if (k === ts.SyntaxKind.AsteriskToken) + return ( + (ts.isNumericLiteral(peelTransparent(e.left)) || + ts.isNumericLiteral(peelTransparent(e.right))) && + this.nanFreeArithmetic(e.left, depth + 1) && + this.nanFreeArithmetic(e.right, depth + 1) + ); + return false; + } + if (ts.isPropertyAccessExpression(e)) { + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + if (!ts.isIdentifier(e)) return false; + const symbol = this.symbolOf(e); + if (!symbol) return false; + const slot = this.bindings.get(symbol); + if (slot && this.eligible.has(slot)) { + return ( + !this.genuineNaN.has(slot) && + slot.sources.length <= 1 && + !slot.arithmeticWrites && + this.provenPresent(e, symbol) + ); + } + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + + /** A dominating condition compares the never-reassigned binding with a + * number literal, which is false for undefined. */ + private provenPresent(use: ts.Identifier, symbol: ts.Symbol): boolean { + let child: ts.Node = use; + for (let n = use.parent; n && !ts.isFunctionLike(n); child = n, n = n.parent) { + let condition: ts.Expression | undefined; + if (ts.isIfStatement(n) && n.thenStatement === child) condition = n.expression; + else if (ts.isConditionalExpression(n) && n.whenTrue === child) condition = n.condition; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + n.right === child + ) + condition = n.left; + if (condition && this.conditionExcludesUndefined(condition, symbol)) return true; + } + return false; + } + + private conditionExcludesUndefined(condition: ts.Expression, symbol: ts.Symbol): boolean { + const e = peelTransparent(condition); + if (!ts.isBinaryExpression(e)) return false; + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken) + return ( + this.conditionExcludesUndefined(e.left, symbol) || + this.conditionExcludesUndefined(e.right, symbol) + ); + if (!RELATIONAL.has(k) && k !== ts.SyntaxKind.EqualsEqualsEqualsToken) return false; + const left = peelTransparent(e.left); + const right = peelTransparent(e.right); + const names = (x: ts.Expression): boolean => ts.isIdentifier(x) && this.symbolOf(x) === symbol; + const literal = (x: ts.Expression): boolean => { + const info = this.plainNumber(x, 0); + return info !== null && !info.nan; + }; + return (names(left) && literal(right)) || (names(right) && literal(left)); + } + + /** How the value lowers once the analysis is final: NaN-coded (a numeric + * read or a read of a NaN-coded slot) or not. */ + private loweredInfo(node: ts.Expression): ValueInfo { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.loweredInfo(e.whenTrue); + const b = this.loweredInfo(e.whenFalse); + return { nce: a.nce || b.nce, nan: a.nan || b.nan }; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined && this.numericReads.has(e)) + return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + const slot = this.slotOfValue(e); + if (slot) return { nce: true, nan: this.genuineNaN.has(slot) }; + return { nce: false, nan: true }; + } + + /** A number expression that is never undefined in Node and always lowers + * to a plain double, whatever the optional-read analysis decides. */ + private plainNumber(node: ts.Expression, depth: number): ValueInfo | null { + if (depth > 8) return null; + const cached = this.pneCache.get(node); + if (cached !== undefined) return cached; + const result = this.computePlainNumber(node, depth); + this.pneCache.set(node, result); + return result; + } + + private computePlainNumber(node: ts.Expression, depth: number): ValueInfo | null { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) + e = e.expression; + if (ts.isNumericLiteral(e)) return { nce: false, nan: false }; + if (ts.isPrefixUnaryExpression(e)) { + if (e.operator === ts.SyntaxKind.TildeToken) return { nce: false, nan: false }; + if (e.operator === ts.SyntaxKind.MinusToken || e.operator === ts.SyntaxKind.PlusToken) + return { nce: false, nan: !ts.isNumericLiteral(e.operand) }; + return null; + } + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (BITWISE.has(k)) return { nce: false, nan: false }; + if (ARITHMETIC.has(k)) return { nce: false, nan: true }; + if ( + k === ts.SyntaxKind.PlusToken && + this.host.isNumber(e.left) && + this.host.isNumber(e.right) + ) + return { nce: false, nan: true }; + // A number fallback for a nullish (or falsy) left value: never + // undefined. The left value is used as is, so it may be NaN. + if ( + (k === ts.SyntaxKind.QuestionQuestionToken || k === ts.SyntaxKind.BarBarToken) && + this.host.isNumber(e) && + this.plainNumber(e.right, depth + 1) !== null + ) + return { nce: false, nan: true }; + return null; + } + if (ts.isPropertyAccessExpression(e) && !e.questionDotToken) { + const symbol = this.symbolOf(e.name); + if (symbol && symbol.flags & ts.SymbolFlags.EnumMember && this.host.isNumber(e)) + return { nce: false, nan: false }; + if ( + e.name.text === "length" && + (this.host.isString(e.expression) || this.host.hasLength(e.expression)) + ) + return { nce: false, nan: false }; + return null; + } + if (ts.isCallExpression(e) && !e.questionDotToken) { + const callee = e.expression; + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) { + if (this.host.isStdlibGlobal(callee.expression, "Math")) + return { nce: false, nan: !MATH_INTEGER_RESULTS.has(callee.name.text) }; + if (callee.name.text === "charCodeAt" && this.host.isString(callee.expression)) + return { nce: false, nan: true }; + } + const fn = this.calleeSymbol(e); + return fn ? this.plainReturn(fn, depth) : null; + } + if (ts.isIdentifier(e)) { + const symbol = this.symbolOf(e); + const decl = symbol ? this.host.declarationOf(symbol) : undefined; + if ( + decl && + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isVariableDeclarationList(decl.parent) && + decl.parent.flags & ts.NodeFlags.Const && + !isJsSourceFile(decl.getSourceFile()) + ) + return this.plainNumber(decl.initializer, depth + 1); + return null; + } + return null; + } + + /** A function whose every return value is a plain number. */ + private plainReturn(fn: ts.Symbol, depth: number): ValueInfo | null { + const known = this.pneReturnCache.get(fn); + if (known !== undefined) return known; + this.pneReturnCache.set(fn, null); + const decl = this.eligibleFunction(fn); + if (!decl?.body || !this.host.returnIsNumber(fn)) return null; + let result: ValueInfo | null = { nce: false, nan: false }; + ts.walkPreorder(decl.body, (node) => { + if (!result || !ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + const info = node.expression ? this.plainNumber(node.expression, depth + 1) : null; + result = info ? { nce: false, nan: result.nan || info.nan } : null; + }); + this.pneReturnCache.set(fn, result); + return result; + } + + // ---- uses --------------------------------------------------------------- + + /** Whether the value's consumer cannot tell undefined from NaN. */ + private useSafe(value: ts.Expression): boolean { + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition === e || this.useSafe(p); + if (ts.isExpressionStatement(p) || ts.isVoidExpression(p)) return true; + if (isLoopOrIf(p)) { + if (ts.isForStatement(p)) return p.condition === e || p.incrementor === e; + return true; + } + if (ts.isPrefixUnaryExpression(p)) { + return ( + p.operator === ts.SyntaxKind.MinusToken || + p.operator === ts.SyntaxKind.PlusToken || + p.operator === ts.SyntaxKind.TildeToken || + p.operator === ts.SyntaxKind.ExclamationToken + ); + } + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + const other = p.left === e ? p.right : p.left; + if (RELATIONAL.has(k) || ARITHMETIC.has(k) || BITWISE.has(k)) return true; + if (k === ts.SyntaxKind.PlusToken) return this.host.isNumber(other); + if (EQUALITY.has(k)) { + const otherInfo = this.sourceInfo(other); + if (!otherInfo) return false; + if (!otherInfo.nce) return true; + const self = this.sourceInfo(e); + return self !== null && !self.nan && !otherInfo.nan; + } + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + if (k === ts.SyntaxKind.CommaToken) return p.left === e || this.useSafe(p); + if (p.left === e) { + // Compound assignment reads of a binding: arithmetic. + return NUMERIC_COMPOUND.has(k) || k === ts.SyntaxKind.PlusEqualsToken; + } + if (k === ts.SyntaxKind.EqualsToken) { + const slot = this.sinkOf(e); + if (!slot || !this.eligible.has(slot)) return false; + // The assignment's own value flows on when it is used. + const outer = transparentParent(p); + const consumer = outer.parent; + if ( + !consumer || + ts.isExpressionStatement(consumer) || + (ts.isForStatement(consumer) && + (consumer.incrementor === outer || consumer.initializer === outer)) + ) + return true; + return this.useSafe(p); + } + return ( + NUMERIC_COMPOUND.has(k) || + (k === ts.SyntaxKind.PlusEqualsToken && this.host.isNumber(p.left)) + ); + } + if (ts.isPostfixUnaryExpression(p)) return true; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) { + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + if (ts.isCallExpression(p) && p.expression !== e) { + const callee = p.expression; + if ( + ts.isPropertyAccessExpression(callee) && + !callee.questionDotToken && + !p.questionDotToken && + (this.host.isStdlibGlobal(callee.expression, "Math") || + (callee.name.text === "fromCharCode" && + this.host.isStdlibGlobal(callee.expression, "String"))) + ) + return true; + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + // A typed-array element read answers undefined for both an undefined + // and a NaN index (neither names an integer index). + if ( + ts.isElementAccessExpression(p) && + p.argumentExpression === e && + this.isReadCandidate(p) && + this.host.bytesElemOf(p.expression) !== null + ) + return true; + if (ts.isSwitchStatement(p)) { + return p.caseBlock.clauses.every( + (clause) => !ts.isCaseClause(clause) || this.plainNumber(clause.expression, 0) !== null, + ); + } + return false; + } + + /** The value of a logical expression is only tested for truthiness. */ + private inConditionContext(node: ts.Expression): boolean { + const e = transparentParent(node); + const p = e.parent; + if (!p) return false; + if (ts.isIfStatement(p) || ts.isWhileStatement(p) || ts.isDoStatement(p)) + return p.expression === e; + if (ts.isForStatement(p)) return p.condition === e; + if (ts.isConditionalExpression(p)) return p.condition === e; + if (ts.isPrefixUnaryExpression(p)) return p.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isExpressionStatement(p)) return true; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + } + return false; + } +} diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 28ef1f431..f7ac00459 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -42,6 +42,11 @@ export const BYTES_ELEMENT_SIZE: Record = { f64: 8, }; +/** Integer element kinds: a valid element read is never NaN. */ +export function isIntegerBytesElem(elem: IrBytesElem): boolean { + return elem !== "f32" && elem !== "f64"; +} + export const BYTES_ELEMENT_NAME: Record = { u8: "Uint8Array", u8c: "Uint8ClampedArray", diff --git a/packages/compiler/test/byte-number-emission.test.ts b/packages/compiler/test/byte-number-emission.test.ts index 18c41814e..fc36fd23d 100644 --- a/packages/compiler/test/byte-number-emission.test.ts +++ b/packages/compiler/test/byte-number-emission.test.ts @@ -45,7 +45,8 @@ test("numeric byte pipelines retain checked fallbacks and use field widths on bo expect(wide).toContain("phi i64"); expect(wide).toContain("@llvm.bswap.i64"); const offsets = body(ll, "offsets"); - expect(offsets).toContain("@llvm.trunc.f64"); + // Truncation is the intrinsic, or inline on an x86-64 host (trunc.ts). + expect(offsets).toMatch(/@llvm\.trunc\.f64|@llvm\.copysign\.f64/); expect(offsets).toContain("@scr_dataview_set"); expect(offsets).toContain("@scr_dataview_get"); expect(offsets).toMatch(/icmp ule i(?:32|64)/); diff --git a/packages/compiler/test/nan-coded-reads.test.ts b/packages/compiler/test/nan-coded-reads.test.ts new file mode 100644 index 000000000..5820e8f3d --- /dev/null +++ b/packages/compiler/test/nan-coded-reads.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import type { IrFunction, IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-nan-coded-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(mod)).toEqual([]); + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function fn(mod: IrModule, name: string): IrFunction { + const found = mod.functions.find((f) => f.name === name || f.name.endsWith(`.${name}`)); + if (!found) + throw new Error(`no function ${name}: ${mod.functions.map((f) => f.name).join(", ")}`); + return found; +} + +function calls(value: unknown): string[] { + const out: string[] = []; + const visit = (node: unknown): void => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) return node.forEach(visit); + const n = node as { kind?: string; callee?: string; fn?: string }; + if (n.kind === "call" && n.callee) out.push(n.callee); + if (n.kind === "libCall" && n.fn) out.push(n.fn); + Object.values(node).forEach(visit); + }; + visit(value); + return out; +} + +const scanner = ` +class Cursor { + pos = 0; + readonly chars: Uint16Array; + readonly end: number; + constructor(chars: Uint16Array) { + this.chars = chars; + this.end = chars.length; + } + char(): number { + return this.pos < this.end ? this.chars[this.pos] : -1; + } +} +function isSpace(ch: number): boolean { + return ch === 32 || ch === 9; +} +function sameAt(a: Uint16Array, b: Uint16Array, i: number): boolean { + return a[i] === b[i]; +} +function show(chars: Uint16Array, i: number): string { + return String(chars[i]); +} +const chars = new Uint16Array([32, 97]); +const cursor = new Cursor(chars); +let spaces = 0; +for (; cursor.pos <= cursor.end; cursor.pos++) if (isSpace(cursor.char())) spaces++; +console.log(spaces, sameAt(chars, chars, 9), show(chars, 9)); +`; + +test("reads whose consumers treat undefined like NaN lower to plain numbers", async () => { + const mod = await lower(scanner); + expect(fn(mod, "isSpace").params[0]!.type).toEqual({ kind: "f64" }); + expect(fn(mod, "char").returnType).toEqual({ kind: "f64" }); + expect(calls(fn(mod, "char").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(false); + // Two missing elements are equal: the equality also tests for two NaNs. + const same = calls(fn(mod, "sameAt").body); + expect(same.some((c) => c.startsWith("%bytes.idxOr"))).toBe(false); + expect(same).toContain("num.isNaN"); + // String() observes undefined: the ordinary optional read remains. + expect(calls(fn(mod, "show").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(true); +}); diff --git a/packages/compiler/test/trunc-emission.test.ts b/packages/compiler/test/trunc-emission.test.ts new file mode 100644 index 000000000..5f46097e5 --- /dev/null +++ b/packages/compiler/test/trunc-emission.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-trunc-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(mod)).toEqual([]); + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("Math.trunc is inline on x86-64 and the intrinsic elsewhere", async () => { + const mod = await lower( + "const x: number = process.argv.length + 0.5;\nconsole.log(Math.trunc(x), Math.trunc(-x));\n", + ); + const x86 = emitLlvmModule(mod, { targetTriple: "x86_64-unknown-linux-gnu", pointerBits: 64 }); + expect(x86).not.toContain("@llvm.trunc.f64(double"); + expect(x86).toContain("@llvm.copysign.f64"); + const arm = emitLlvmModule(mod, { targetTriple: "arm64-apple-darwin", pointerBits: 64 }); + expect(arm).toContain("call double @llvm.trunc.f64(double"); +}); + +test("integer typed-array reads decide presence without a truncation", async () => { + const mod = await lower( + "const t = new Uint16Array(4);\nconst i: number = process.argv.length + 0.5;\nconsole.log(t[i], t[1]);\n", + ); + const helper = mod.functions.find((f) => f.name.startsWith("%bytes.idxOr")); + expect(helper).toBeDefined(); + expect(JSON.stringify(helper!.body)).not.toContain("math.trunc"); + expect(JSON.stringify(helper!.body)).toContain("num.isNaN"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..1c7788be6 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11409,6 +11409,12 @@ ], "diags": [] }, + "/tests/corpus/typed-array-nan-coded-reads.ts": { + "order": [ + "/tests/corpus/typed-array-nan-coded-reads.ts" + ], + "diags": [] + }, "/tests/corpus/typed-array-unknown.ts": { "order": [ "/tests/corpus/typed-array-unknown.ts" diff --git a/tests/corpus/typed-array-nan-coded-reads.ts b/tests/corpus/typed-array-nan-coded-reads.ts new file mode 100644 index 000000000..02b53cd4f --- /dev/null +++ b/tests/corpus/typed-array-nan-coded-reads.ts @@ -0,0 +1,165 @@ +// Typed-array element reads flowing through bindings, parameters and returns +// whose consumers cannot tell undefined from NaN, next to consumers that can. +// An index that is not an integer in [0, length) reads undefined, like Node; +// the compiler may carry such reads as plain numbers only where that is +// unobservable. +const base: number = process.argv.length; // 2 under both runtimes +const text = "let x = 1; /* note */ y\u{1F600}"; +const chars = new Uint16Array(text.length); +for (let i = 0; i < text.length; i++) chars[i] = text.charCodeAt(i); + +const far = base + 100; +const negative = -base; +const fraction = base + 0.5; +const nan = base / 0 - base / 0; +const negativeZero = -0 * base; + +class Cursor { + pos = 0; + readonly end: number; + token: number | undefined = 0; + readonly data: Uint16Array; + constructor(data: Uint16Array) { + this.data = data; + this.end = data.length; + } + char(): number { + return this.pos < this.end ? this.data[this.pos] : -1; + } + peek(offset: number): number { + return this.data[this.pos + offset]; + } + codePointAt(pos: number): number { + const ch = this.data[pos]; + if (ch >= 0xd800 && ch <= 0xdbff) { + const low = this.data[pos + 1]; + if (low >= 0xdc00 && low <= 0xdfff) return (ch - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000; + } + return ch; + } +} + +function isLetter(ch: number): boolean { + return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90); +} + +function isSpace(ch: number): boolean { + return ch === 32 || ch === 9; +} + +function kind(ch: number): string { + switch (ch) { + case 32: + return "space"; + case 61: + return "equals"; + case -1: + return "eof"; + default: + return isLetter(ch) ? "letter" : "other"; + } +} + +function describe(ch: number): string { + // String() observes undefined: the read is rebuilt as `number | undefined`. + return ch === 120 ? "x" : String(ch); +} + +function skipComment(data: Uint16Array, pos: number, end: number): number { + const view = data.subarray(pos, end); + for (let i = 0; i < view.length; i++) { + const c = view[i]; + if (c === 42 || c === 10 || c > 127) return pos + i; + } + return end; +} + +const cursor = new Cursor(chars); +const kinds: string[] = []; +for (; cursor.pos <= cursor.end; cursor.pos++) kinds.push(kind(cursor.char())); +console.log("kinds", kinds.join(",")); +cursor.pos = 0; +console.log("peek", cursor.peek(1), cursor.peek(far), cursor.peek(negative), cursor.peek(fraction)); +console.log("peek kinds", kind(cursor.peek(2)), kind(cursor.peek(far)), isSpace(cursor.peek(far))); +console.log("describe", describe(chars[4]), describe(chars[0]), describe(chars[far]), describe(chars[nan])); +console.log("code points", cursor.codePointAt(text.length - 2), cursor.codePointAt(text.length - 1), cursor.codePointAt(far)); +console.log("skip", skipComment(chars, 13, chars.length), skipComment(chars, 0, 3)); + +// Index conversions: NaN, fractions, negatives and -0. +const reads = [chars[nan], chars[fraction], chars[negative], chars[negativeZero], chars[far], chars[-0]]; +console.log("index forms", reads.join("|"), reads.map((r) => r === undefined).join(",")); +let present = 0; +for (const index of [0, negativeZero, 1.5, -1, nan, Infinity, -Infinity, 2 ** 32, chars.length - 1, chars.length]) { + const c = chars[index]; + if (c > 0) present++; +} +console.log("present", present); + +function bindings(): void { +// Equality of two reads: undefined equals undefined. +const a = chars[far]; +const b = chars[far + 1]; +const c0 = chars[0]; +console.log("equal missing", a === b, a !== b, a == b, a === c0, c0 === chars[0], chars[far] === chars[negative]); +console.log("equal mixed", a === 108, c0 === 108, c0 !== 108, a !== 108, a == null, a === undefined); +console.log("relational", a < 1, a >= 0, c0 > 100, chars[far] <= chars[far]); +console.log("arithmetic", a + 1, c0 + 1, a | 0, c0 >> 1, -a, ~a, Math.max(a, 1)); +console.log("truthy", a ? "yes" : "no", !a, c0 && 5, chars[far] || 7, a ?? -1); +console.log("typeof", typeof a, typeof c0, `${a}/${c0}`, [a, c0], JSON.stringify({ a, c0 })); + +// Stored and returned values keep undefined. +cursor.token = cursor.peek(far); +console.log("field", cursor.token === undefined); +cursor.token = cursor.char(); +console.log("field", cursor.token); +const kept: (number | undefined)[] = []; +kept.push(cursor.peek(far), cursor.peek(0)); +console.log("pushed", kept, kept.indexOf(undefined)); +function passThrough(value: unknown): string { + return value === undefined ? "undefined" : typeof value; +} +console.log("unknown", passThrough(chars[far]), passThrough(chars[1])); + +// A binding that may hold a genuine NaN next to a missing read. +const floats = new Float64Array([nan, 1.5]); +const f0 = floats[0]; +const fMissing = floats[far]; +console.log("floats", f0 === fMissing, f0, fMissing, Number.isNaN(f0), fMissing === undefined); +let counter = chars[far]; +counter++; +console.log("incremented", counter, counter === chars[far], Number.isNaN(counter)); + +} +bindings(); + +// Only NaN-insensitive consumers: the reads stay plain numbers, and equality +// of two missing reads is still true. +function equalities(offset: number): string { + const p = chars[far + offset]; + const q = chars[negative - offset]; + const r = chars[offset]; + const s = chars[offset + 1]; + const flags = [p === q, p !== q, p == q, p != q, p === r, r === s, r !== s, p < r, r > p]; + let score = 0; + if (p === q) score += 1; + if (q !== r) score += 2; + if (r === chars[offset]) score += 4; + if (p === chars[fraction]) score += 8; + switch (p) { + case 0: + score += 100; + break; + default: + score += 16; + } + return flags.join(",") + " " + score + " " + JSON.stringify(String.fromCharCode(p, r, Math.max(q, s))); +} +console.log("equalities", equalities(0), equalities(1)); + +// Views: a subarray answers undefined past its own length even when the +// underlying buffer continues. +const view = chars.subarray(2, 5); +console.log("view", view.length, view[0], view[2], view[3], view[far], view[3] === chars[far]); +let hash = 0x811c9dc5 | 0; +for (let i = 0; i <= view.length; i++) hash = Math.imul(hash ^ view[i], 0x01000193); +console.log("hash", hash >>> 0);