From 970a53c1ba17516b203fe445de2769392321ab2f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 00:05:48 -0500 Subject: [PATCH 1/5] Store (T | undefined)[] elements as T so widened arrays stay one array A number[], string[] or boolean[] seen as (T | undefined)[] through a cast, a binding or a parameter is the same array in Node: undefined written through either name is visible through both. The type mapper now stores (T | undefined)[] of a number, string or boolean T as T elements, which hold a present undefined by their slot state, so the wider name shares the storage instead of converting to a separate union array. Writers of these arrays store undefined as a present undefined: element writes, push, unshift and splice, literals, Array.from (with a length or a mapper), Array.of, generator drains, flatMap, rest packs, copies from Map and Set values and Object.values, and checked conversions from dynamic values. Promise.all over Promise[] fills the result inside the combinator's settlement, so it settles on Node's turn. The element-state analysis counts callbacks and iterables whose checker type admits undefined. Readers keep their optional reads: index reads through a union of arrays, searches with a direct needle, and asserted reads, which throw a TypeError for undefined. Comparators and callbacks that receive present elements take the element type, without an adapter. --- packages/compiler/src/backend/llvm/dyn.ts | 25 ++- .../src/backend/llvm/expr-stream-bridges.ts | 19 +- .../src/frontend/lowering/array-values.ts | 140 +++++++++++++++ .../frontend/lowering/builtins/promises.ts | 162 +++++++++++++++++- .../src/frontend/lowering/call-arguments.ts | 10 ++ .../frontend/lowering/coercions/structural.ts | 110 ++++++++++-- .../lowering/containers/array-callbacks.ts | 66 +++++-- .../lowering/containers/array-construction.ts | 67 +++++++- .../lowering/containers/array-methods.ts | 106 +++++++++++- .../lowering/containers/callback-arguments.ts | 13 +- .../lowering/containers/collection-methods.ts | 12 ++ .../lowering/containers/indexed-objects.ts | 106 +++++++++--- .../lowering/expressions/array-literals.ts | 23 ++- .../lowering/expressions/element-access.ts | 45 +++++ .../frontend/lowering/iterator-adapters.ts | 58 +++---- .../compiler/src/frontend/lowering/lowerer.ts | 58 ++++++- .../lowering/runtime-optional-elements.ts | 9 +- .../frontend/lowering/sanitize-class-types.ts | 2 + packages/compiler/src/frontend/type-mapper.ts | 19 +- packages/compiler/src/ir/ir.ts | 11 +- packages/compiler/src/ir/validate.ts | 10 +- ...ional-element-arrays-share-storage.ts.json | 6 + .../8001-optional-element-writers.ts.json | 6 + ...0-optional-element-arrays-share-storage.ts | 61 +++++++ tests/corpus/8001-optional-element-writers.ts | 103 +++++++++++ ...ional-element-arrays-share-storage.ts.json | 1 + .../8001-optional-element-writers.ts.json | 1 + 27 files changed, 1145 insertions(+), 104 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8000-optional-element-arrays-share-storage.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8001-optional-element-writers.ts.json create mode 100644 tests/corpus/8000-optional-element-arrays-share-storage.ts create mode 100644 tests/corpus/8001-optional-element-writers.ts create mode 100644 tests/harness/corpus-costs/8000-optional-element-arrays-share-storage.ts.json create mode 100644 tests/harness/corpus-costs/8001-optional-element-writers.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 6d02daa8b..f3c8231e1 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -547,6 +547,13 @@ export class LlDyn { /* ── dyn node plumbing ─────────────────────────────────────────────── */ /** Loads a dyn node's kind tag (after the size_t rc word). */ + /** i1: the checked-dynamic value `d` is undefined. */ + isUndefined(B: BlockBuilder, d: string): string { + const is = B.tmp(); + B.line(`${is} = icmp eq i32 ${this.kindOf(B, d)}, ${DYN_KIND.UNDEF}`); + return is; + } + private kindOf(B: BlockBuilder, d: string): string { const p = B.tmp(); const k = B.tmp(); @@ -1655,7 +1662,8 @@ export class LlDyn { * BUILD the typed value (+1), or throw the catchable path-annotated * TypeError and return a dummy with the pending flag set. */ dynCheckHelper(t: IrType, preserveRefs = false): string { - const key = typeKey(t); + // A `(T | undefined)[]` stored as T elements also takes undefined ones. + const key = t.kind === "array" && t.optionalElements ? `${typeKey(t)}?` : typeKey(t); const builderKey = preserveRefs ? `identity:${key}` : key; const existing = this.dynBuilders.get(builderKey); if (existing) return existing; @@ -2435,6 +2443,21 @@ export class LlDyn { B.line(`${ip} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 2`); B.line(`store ${host.sizeType} ${i}, ptr ${ip}`); const e = this.itemAt(B, items, i); + if (t.optionalElements) { + // An undefined element is a present undefined element. + const undef = B.tmp(); + const storeUndefined = B.newLabel("dca.undef"); + const checkLabel = B.newLabel("dca.check"); + B.line(`${undef} = icmp eq i32 ${this.kindOf(B, e)}, ${DYN_KIND.UNDEF}`); + B.condBr(undef, storeUndefined, checkLabel); + B.startBlock(storeUndefined); + host.declare(`declare void @scr_arr_set_undefined(ptr, double)`); + const at = B.tmp(); + B.line(`${at} = ${sizeToDouble(this.S)} ${this.S} ${i} to double`); + B.line(`call void @scr_arr_set_undefined(ptr ${a}, double ${at})`); + B.br(doneLabel); + B.startBlock(checkLabel); + } const v = B.tmp(); B.line(`${v} = call ${this.valTy(elem)} @${c}(ptr ${e}, ptr ${pathSlot})`); this.pendingBail( diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 46c2d884f..814ef76b5 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -744,13 +744,16 @@ export function streamTypedRefMaterializeAdapter( t: IrType, ): LlStreamTypedRefAdapter { const key = typeKey(t); - const existing = host.liveDynRefAdapters.get(key); + // A `(T | undefined)[]` stored as T elements shares T[]'s capsule type, + // but its adapters also store undefined elements. + const adapterKey = t.kind === "array" && t.optionalElements ? `${key}?` : key; + const existing = host.liveDynRefAdapters.get(adapterKey); if (existing) return existing; // The converter depends only on the static type. Intern the complete // graph across roots and union arms so recursive types emit once. const snapshot = `sc_ldr_${host.liveDynRefAdapters.size}_materialize`; const adapter: LlStreamTypedRefAdapter = { snapshot, commit: "null" }; - host.liveDynRefAdapters.set(key, adapter); + host.liveDynRefAdapters.set(adapterKey, adapter); host.journal?.allocated( snapshot.replace(/_materialize$/, ""), "streamTypedRefMaterializeAdapter", @@ -1257,6 +1260,18 @@ function liveArrayOps( G.terminate(`ret ptr ${boxed}`); const S = new BlockBuilder(); + if (t.optionalElements) { + // A `(T | undefined)[]` stores undefined as a present undefined. + const undef = host.dyn.isUndefined(S, "%v"); + const storeUndefined = S.newLabel("live.array.undefined"); + const other = S.newLabel("live.array.value"); + S.condBr(undef, storeUndefined, other); + S.startBlock(storeUndefined); + host.declare(`declare void @scr_arr_set_undefined(ptr, double)`); + S.line(`call void @scr_arr_set_undefined(ptr %p, double %i)`); + S.terminate(`ret i1 true`); + S.startBlock(other); + } const fits = S.tmp(); S.line(`${fits} = call zeroext i1 @${host.dyn.dynMatchHelper(elem)}(ptr %v)`); const store = S.newLabel("live.array.store"); diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index f191ad942..6e4a45f86 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -269,6 +269,140 @@ function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean { }); } +/** A number, string or boolean element type. Arrays of it hold a present + * undefined in the element's state, so `(T | undefined)[]` stores T + * elements (the type mapper) and a T[] seen as one stays the same array. */ +export function primitiveElement(elem: IrType): boolean { + return elem.kind === "f64" || elem.kind === "string" || elem.kind === "bool"; +} + +/** `value` is the element type with undefined, stored into an array of a + * primitive element type: a present undefined or the element. */ +export function optionalElementValue(lowerer: Lowerer, value: IrExpr, elem: IrType): boolean { + if (!primitiveElement(elem)) return false; + if (value.type.kind === "undefinedT") return true; + return ( + value.type.kind === "union" && + lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 && + typeEquals(lowerer.stripUndefinedArm(value.type), elem) + ); +} + +/** A value of a primitive element type that holds a slot until the value + * stored there replaces it (insertedValues), or stands for a slot that is + * not read. */ +export function elementPlaceholder(elem: IrType, loc: SrcLoc): IrExpr { + if (elem.kind === "f64") return { kind: "numLit", value: 0, type: F64, loc }; + if (elem.kind === "bool") return { kind: "boolLit", value: false, type: BOOL, loc }; + return { kind: "strLit", value: "", type: STRING, loc }; +} + +/** Values inserted into an array (push, unshift, splice, a literal), in + * source order. A value that can be undefined (optionalElementValue) is + * kept in a temporary: the insertion takes a placeholder for it, and + * `fill` then stores the value at the placeholder's index. */ +export function insertedValues( + lowerer: Lowerer, + values: readonly IrExpr[], + elem: IrType, + coerce: (value: IrExpr, index: number) => IrExpr, + loc: SrcLoc, +): { + stmts: IrStmt[]; + args: IrExpr[]; + optional: boolean; + fill: (arr: IrExpr, first: IrExpr) => IrStmt[]; +} { + if (!values.some((value) => optionalElementValue(lowerer, value, elem))) + return { stmts: [], args: values.map(coerce), optional: false, fill: () => [] }; + const stmts: IrStmt[] = []; + const args: IrExpr[] = []; + const held: { offset: number; value: IrExpr }[] = []; + values.forEach((value, offset) => { + // Every value is evaluated in order before the insertion. + if (value.type.kind === "undefinedT") { + if (value.kind !== "unitLit") stmts.push({ kind: "exprStmt", expr: value, loc }); + args.push(elementPlaceholder(elem, loc)); + held.push({ offset, value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc } }); + return; + } + const temp = lowerer.declareHiddenLocal("%inserted", value.type); + stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + const stable = varRef(temp.id, temp.type, loc); + if (optionalElementValue(lowerer, value, elem)) { + args.push(elementPlaceholder(elem, loc)); + held.push({ offset, value: stable }); + } else { + args.push(coerce(stable, offset)); + } + }); + const fill = (arr: IrExpr, first: IrExpr): IrStmt[] => + held.map(({ offset, value }) => + arrayValueStore( + lowerer, + arr, + offset === 0 + ? first + : { + kind: "bin", + op: "+", + left: first, + right: { kind: "numLit", value: offset, type: F64, loc }, + type: F64, + loc, + }, + value, + elem, + loc, + ), + ); + return { stmts, args, optional: true, fill }; +} + +/** Statements appending `value` to `arr` inside a lifted helper. A value + * that can be undefined in a primitive array (optionalElementValue) is held + * in the helper's local `slot` (which the caller declares with the value's + * type) and stored as a present undefined or the element; any other value + * converts to the element type. */ +export function appendElement( + lowerer: Lowerer, + arr: IrExpr, + value: IrExpr, + elem: IrType, + slot: string, + loc: SrcLoc, +): IrStmt[] { + if (optionalElementValue(lowerer, value, elem) && value.type.kind !== "undefinedT") { + const held = varRef(slot, value.type, loc); + const length: IrExpr = { + kind: "arrIntrinsic", + method: "length", + receiver: arr, + args: [], + type: F64, + loc, + }; + return [ + { kind: "varDecl", localId: slot, init: value, loc }, + arrayValueStore(lowerer, arr, length, held, elem, loc), + ]; + } + return [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: arr, + args: [lowerer.coerceToExpected(value, elem)], + type: F64, + loc, + }, + loc, + }, + ]; +} + /** Store a stabilized value without changing the array's element ABI. */ export function arrayValueStore( lowerer: Lowerer, @@ -279,6 +413,12 @@ export function arrayValueStore( loc: SrcLoc, ): IrStmt { if (typeEquals(value.type, elem)) return { kind: "arraySet", arr, index, value, loc }; + // An undefined value of a `(T | undefined)[]` (primitiveElement). + if (value.type.kind === "undefinedT" && primitiveElement(elem)) { + const write: IrStmt = { kind: "arraySetUndefined", arr, index, loc }; + if (value.kind === "unitLit") return write; + return { kind: "block", body: [{ kind: "exprStmt", expr: value, loc }, write], loc }; + } if (value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0) { // The tag test and the VALUE arm extraction both consume the value. Keep // an arbitrary expression (especially an array read or call) single diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index e9d4322ad..c3b2cd479 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -3,7 +3,8 @@ import { checkedPromiseAll, thenReader } from "../checked-promise-all.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; -import { varRef } from "../../../ir/build.js"; +import { countedFor, varRef } from "../../../ir/build.js"; +import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js"; import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { @@ -14,6 +15,7 @@ import { type IrFunction, type IrStmt, type IrType, + type SrcLoc, UNDEFINED_T, VOID, arrayOf, @@ -435,6 +437,28 @@ export function lowerPromiseStaticCall( inner.kind === "void" ? { kind: "promise", inner: VOID } : lowerer.mapTypeOf(lowerer.typeOf(call)); + // Promise[] combines into a `(T | undefined)[]`, which + // stores T elements (primitiveElement): collect the values, then copy + // them in with their present undefined values. + if ( + resultT?.kind === "promise" && + resultT.inner.kind === "array" && + primitiveElement(resultT.inner.elem) && + optionalElementValue( + lowerer, + { kind: "unitLit", unit: "undefined", type: inner, loc }, + resultT.inner.elem, + ) + ) { + const joined = optionalPromiseAll( + lowerer, + entries, + inner, + resultT as IrType & { kind: "promise" }, + loc, + ); + if (joined) return joined; + } if ( resultT?.kind !== "promise" || (inner.kind !== "void" && @@ -954,3 +978,139 @@ export function lowerAwaitedUniformPromiseAll( loc, }; } + +/** `Promise.all(ps)` over `Promise[]` for a number, string or + * boolean T, whose result is a `(T | undefined)[]` (stored as T elements). + * The combinator joins the entries (promise.allTuple), and its builder + * reads each fulfilled entry into the result with its present undefined, + * inside the combinator's settlement, so the result settles on Node's + * turn. Entries of another type keep the general combinator. */ +function optionalPromiseAll( + lowerer: Lowerer, + entries: IrExpr, + inner: IrType, + resultT: IrType & { kind: "promise" }, + loc: SrcLoc, +): IrExpr | null { + const entriesT = entries.type; + if (entriesT.kind !== "array" || entriesT.elem.kind !== "promise") return null; + if (!typeEquals(entriesT.elem.inner, inner)) return null; + const outT = resultT.inner as IrType & { kind: "array" }; + const voidPromise: IrType & { kind: "promise" } = { kind: "promise", inner: VOID }; + const joinedT = arrayOf(voidPromise); + const key = `promise.allOptional:${typeKey(entriesT)}:${typeKey(outT)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%promise.allOptional", key); + lowerer.arrHofHelpers.set(key, helper); + const builder = keyedHelperName(lowerer, "%promise.allOptionalValues", key); + const length = (arr: IrExpr): IrExpr => ({ + kind: "arrIntrinsic", + method: "length", + receiver: arr, + args: [], + type: F64, + loc, + }); + const joined = varRef("joined.0", joinedT, loc); + const out = varRef("out.0", outT, loc); + lowerer.liftedFns.push({ + name: builder, + params: [{ localId: "joined.0", name: "joined", type: joinedT }], + returnType: outT, + locals: [ + { id: "joined.0", name: "joined", type: joinedT, mutable: false }, + { id: "out.0", name: "out", type: outT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "v.0", name: "v", type: inner, mutable: false }, + ], + body: [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: outT, loc }, + loc, + }, + countedFor(loc, length(joined), (index) => [ + { + kind: "varDecl", + localId: "v.0", + init: { + kind: "libCall", + fn: "promise.fulfilledValue", + args: [{ kind: "arrayGet", arr: joined, index, type: voidPromise, loc }], + type: inner, + loc, + }, + loc, + }, + arrayValueStore(lowerer, out, index, varRef("v.0", inner, loc), outT.elem, loc), + ]), + { kind: "return", value: out, loc }, + ], + loc, + }); + const ps = varRef("ps.0", entriesT, loc); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "ps.0", name: "ps", type: entriesT }], + returnType: resultT, + locals: [ + { id: "ps.0", name: "ps", type: entriesT, mutable: false }, + { id: "joined.0", name: "joined", type: joinedT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "joined.0", + init: { kind: "arrayLit", elems: [], type: joinedT, loc }, + loc, + }, + countedFor(loc, length(ps), (index) => [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: joined, + args: [ + { + kind: "promiseVoidWiden", + value: { kind: "arrayGet", arr: ps, index, type: entriesT.elem, loc }, + type: voidPromise, + loc, + }, + ], + type: F64, + loc, + }, + loc, + }, + ]), + { + kind: "return", + value: { + kind: "libCall", + fn: "promise.allTuple", + args: [ + joined, + { + kind: "closure", + fnName: builder, + captures: [], + type: funcOf([joinedT], outT), + loc, + }, + ], + type: resultT, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: helper, args: [entries], type: resultT, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index 060ffe9d2..6da0cb337 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -28,6 +28,7 @@ import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { type ParamShape } from "./call-signatures.js"; import { arrayValueRead, arrayValueType } from "./array-values.js"; import { buildArrayConversion } from "./coercions/builders.js"; +import { lowerArraySpreadItems } from "./containers/array-construction.js"; import { restTupleElementTypes, typeKey } from "../type-mapper.js"; function fixedTupleSpreadInfo( @@ -403,6 +404,15 @@ export function completeArgs( } } if (restType.kind !== "array") lowerer.unsupported("SC1090", blame, "this call form"); + // A `(T | undefined)[]` rest (stored as T elements) packs undefined + // arguments as present undefined elements. + const restNodes = sources.slice(restAt); + if (restType.optionalElements === true && !restNodes.some(isIr)) { + out.push( + lowerArraySpreadItems(lowerer, restNodes as ts.Expression[], restType.elem, restType, loc), + ); + return out; + } // The rest pack is a fresh array per call; surplus SPREADS copy // their elements in (JS-exact — `f(a, ...xs, b, ...ys)` packs in // order, sources untouched). diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 4321a5ead..93ae1c231 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -1,12 +1,13 @@ import { keyedHelperName } from "../stable-names.js"; import { constructedValue } from "../constructor-returns.js"; -import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; +import { countedFor, dynUndefinedExpr, varRef } from "../../../ir/build.js"; import type { FieldLift } from "./structural-plans.js"; import { buildArrayConversion } from "./builders.js"; +import { arrayValueRead, arrayValueStore, primitiveElement } from "../array-values.js"; import type { WidthLift } from "../width-lift.js"; import { InternalCompilerError } from "../../../errors.js"; import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; -import { BOOL, DYN, F64, STRING, UNDEFINED_T } from "../../../ir/ir.js"; +import { BOOL, DYN, F64, STRING, UNDEFINED_T, typeEquals } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import { findStaticOn, findGenericStaticOn } from "../lower-classes.js"; import { lowerRecordOvfCaptureHelper } from "../containers/indexed-objects.js"; @@ -54,6 +55,9 @@ export function widthCoerce(lowerer: Lowerer, expr: IrExpr, expected: IrType): I return lowerer.classStaticsProjection(expr.type.className, expected.shapeId, expr.loc); } if (expected.kind === "array" && expr.type.kind === "array") { + const optional = optionalArrayHelper(lowerer, expr.type, expected, expr.loc); + if (optional) + return { kind: "call", callee: optional, args: [expr], type: expected, loc: expr.loc }; const helper = lowerer.arrayWidthHelper(expr.type, expected, expr.loc); if (helper) return { kind: "call", callee: helper, args: [expr], type: expected, loc: expr.loc }; @@ -518,6 +522,69 @@ export function emptyArrayLiftHelper( return name; } +/** Copy an array of `T | undefined` unions (a collection's values, an + * iterator drain) into a `(T | undefined)[]` of a number, string or + * boolean T, which stores T elements: undefined values become present + * undefined elements. Null for other element types. */ +function optionalArrayHelper( + lowerer: Lowerer, + fromT: IrType & { kind: "array" }, + toT: IrType & { kind: "array" }, + loc: SrcLoc, +): string | null { + const from = fromT.elem; + const to = toT.elem; + if ( + !primitiveElement(to) || + from.kind !== "union" || + lowerer.armTag(from.unionId, UNDEFINED_T) < 0 || + !typeEquals(lowerer.stripUndefinedArm(from), to) + ) + return null; + const key = `arrOptional:${typeKey(from)}:${typeKey(to)}`; + const existing = lowerer.valueHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%arr.width", key); + lowerer.valueHelpers.set(key, name); + const input = varRef("a.0", fromT, loc); + const out = varRef("out.0", toT, loc); + lowerer.liftedFns.push({ + name, + params: [{ localId: "a.0", name: "a", type: fromT }], + returnType: toT, + locals: [ + { id: "a.0", name: "a", type: fromT, mutable: true }, + { id: "out.0", name: "out", type: toT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "v.0", name: "v", type: from, mutable: false }, + ], + body: [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: toT, loc }, + loc, + }, + countedFor( + loc, + { kind: "arrIntrinsic", method: "length", receiver: input, args: [], type: F64, loc }, + (index) => [ + { + kind: "varDecl", + localId: "v.0", + init: arrayValueRead(lowerer, input, index, from, loc), + loc, + }, + arrayValueStore(lowerer, out, index, varRef("v.0", from, loc), to, loc), + ], + ), + { kind: "return", value: out, loc }, + ], + loc, + }); + return name; +} + /** Copy an array through its validated element conversion. Intern before * building the body so recursive element conversions can reuse this helper. */ export function arrayWidthHelper( @@ -542,14 +609,37 @@ export function arrayWidthHelper( name, arrT, { kind: "arrayLit", elems: [], type: outT, loc }, - (element, _index, result) => ({ - kind: "arrIntrinsic", - method: "push", - receiver: result, - args: [lowerer.applyWidthLift(elemLift, element, toElem, loc)], - type: F64, - loc, - }), + (element, index, result) => { + let value = lowerer.applyWidthLift(elemLift, element, toElem, loc); + // A number, string or boolean array can hold present undefined + // elements (`(T | undefined)[]` stores T): they, and holes, copy + // as the target's undefined. + const missing = primitiveElement(fromElem) ? lowerer.wrappedUndefined(toElem, loc) : null; + if (missing !== null) + value = { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: varRef("a.0", arrT, loc), index, type: F64, loc }, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: BOOL, + loc, + }, + then: value, + else_: missing, + type: toElem, + loc, + }; + return { + kind: "arrIntrinsic", + method: "push", + receiver: result, + args: [value], + type: F64, + loc, + }; + }, loc, ), ); diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index edf65ab2b..1062c56b1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -42,6 +42,7 @@ import { arrayValueStore, arrayValueType, currentArrayIndexPresent, + primitiveElement, } from "../array-values.js"; import { typeKey } from "../../type-mapper.js"; import { @@ -1689,6 +1690,8 @@ function flatMapUnionPlan( } const converts = (t: IrType): boolean => { if (typeEquals(t, outElem)) return true; + // A `(T | undefined)[]` result stores T elements (primitiveElement). + if (t.kind === "undefinedT" && primitiveElement(outElem)) return true; if (outElem.kind !== "union") return false; return lowerer.armTag(outElem.unionId, t) >= 0; }; @@ -1754,24 +1757,21 @@ function flatMapUnionHelper( }; if (plan.arms[tag] === "empty") return []; if (plan.arms[tag] === "value") { + // A unit arm's value is the unit itself, which has no narrowed + // representation. + const value: IrExpr = isUnitType(arm) + ? { kind: "unitLit", unit: arm.kind === "nullT" ? "null" : "undefined", type: arm, loc } + : narrowed; return [ arrayValueStore( lowerer, outRef(), outLength(), - lowerer.coerceToExpected( - // A unit arm's value is the unit itself, which has no - // narrowed representation. - isUnitType(arm) - ? { - kind: "unitLit", - unit: arm.kind === "nullT" ? "null" : "undefined", - type: arm, - loc, - } - : narrowed, - plan.outElem, - ), + // An undefined value is a present undefined element of a + // primitive array (arrayValueStore). + arm.kind === "undefinedT" && primitiveElement(plan.outElem) + ? value + : lowerer.coerceToExpected(value, plan.outElem), plan.outElem, loc, ), @@ -2347,6 +2347,43 @@ function reduceHelper( return name; } +/** The comparator of a sort over a `(T | undefined)[]`, which stores T + * elements. The comparator only receives present elements (undefined + * elements sort last without a call), so the unannotated parameters of an + * inline comparator take the element type, and the runtime calls it + * without an adapter. */ +function lowerOptionalElementComparator( + lowerer: Lowerer, + argNode: ts.Expression, + comparator: ts.Expression, + elem: IrType, + arrT: IrType & { kind: "array" }, +): IrExpr { + if ( + arrT.optionalElements !== true || + !(ts.isArrowFunction(comparator) || ts.isFunctionExpression(comparator)) || + isJsSourceFile(ts.getSourceFileOfNode(comparator)) + ) + return lowerer.lowerExpr(argNode); + const rebound: { symbol: ts.Symbol; previous: IrType | undefined }[] = []; + for (const param of comparator.parameters.nodes.slice(0, 2)) { + if (param.type || param.initializer || param.dotDotDotToken || !ts.isIdentifier(param.name)) + continue; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (!symbol) continue; + rebound.push({ symbol, previous: lowerer.runtimeOptionalBindingTypes.get(symbol) }); + lowerer.runtimeOptionalBindingTypes.set(symbol, elem); + } + try { + return lowerer.lowerExpr(argNode); + } finally { + for (const { symbol, previous } of rebound) { + if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(symbol); + else lowerer.runtimeOptionalBindingTypes.set(symbol, previous); + } + } +} + /** `a.sort(cmp)` / `a.toSorted(cmp)`, desugared to an interned synthetic * function like the other array HOFs. sort mutates and returns the receiver; * toSorted takes its shallow snapshot INSIDE the helper, after the receiver @@ -2435,7 +2472,8 @@ export function lowerArraySortCall( loc, }; } - let fnArg = undefinedArg ?? lowerer.lowerExpr(argNode); + let fnArg = + undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT); // Reusable JS comparators often accept checked values. Adapt the array's // element ABI just as other array callbacks do before sorting. if ( diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 1ca51f526..25a98c8a6 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -35,7 +35,13 @@ import { typeEquals, } from "../../../ir/ir.js"; import { isJsSourceFile, locOf } from "../../program.js"; -import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js"; +import { + arrayValueRead, + arrayValueStore, + arrayValueType, + optionalElementValue, + primitiveElement, +} from "../array-values.js"; import { typeKey } from "../../type-mapper.js"; import { newFnCtx } from "../lowerer.js"; import { iteratorCanStep, iteratorValue } from "../iterator-consumption.js"; @@ -88,7 +94,8 @@ export function lowerArrayValueItems( ]; for (const value of values) { const stored = - lowerer.runtimeOptionalWidening(value.type, elem) !== null + lowerer.runtimeOptionalWidening(value.type, elem) !== null || + optionalElementValue(lowerer, value, elem) ? value : lowerer.coerceToExpected(value, elem); body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc)); @@ -212,7 +219,8 @@ export function lowerArraySpreadItems( } else { const value = lowerer.lowerExpr(node); const stored = - lowerer.runtimeOptionalWidening(value.type, elem) !== null + lowerer.runtimeOptionalWidening(value.type, elem) !== null || + optionalElementValue(lowerer, value, elem) ? value : lowerer.coerceInto(node, value, elem); body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc)); @@ -518,6 +526,20 @@ export function lowerArrayOfCall( if (call.arguments.nodes.some(ts.isSpreadElement)) { return lowerArraySpreadItems(lowerer, call.arguments.nodes, result.elem, result, locOf(call)); } + // A `(T | undefined)[]` (primitiveElement) stores undefined arguments as + // present undefined elements. + if (result.optionalElements === true) { + const nodes = call.arguments.nodes; + const values = nodes.map((arg) => lowerer.lowerExpr(arg)); + if (values.some((value) => optionalElementValue(lowerer, value, result.elem))) + return lowerArrayValueItems(lowerer, values, result.elem, result, locOf(call)); + return { + kind: "arrayLit", + elems: values.map((value, i) => lowerer.coerceInto(nodes[i]!, value, result.elem)), + type: result, + loc: locOf(call), + }; + } return { kind: "arrayLit", elems: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, result.elem)), @@ -959,6 +981,31 @@ export function lowerArrayFromCall( const arrT = lowerer.mapTypeOf(lowerer.typeOf(call)); if (arrT?.kind !== "array") lowerer.badType(call, lowerer.typeOf(call)); const elem = arrT.elem; + // A `(T | undefined)[]` (primitiveElement): n present undefined + // elements. + const elemTs = lowerer.checker.isArrayType(lowerer.typeOf(call)) + ? lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0] + : undefined; + if ( + primitiveElement(elem) && + elemTs !== undefined && + (elemTs.isUnionType() ? ts.constituentTypes(elemTs) : [elemTs]).some( + (part) => (part.flags & ts.TypeFlags.Undefined) !== 0, + ) + ) { + return { + kind: "arrIntrinsic", + method: "fillUndefined", + receiver: { kind: "arrayNewLen", length: n, type: arrT, loc }, + // To the end: fill clamps an infinite end to the length. + args: [ + numLit(0, loc), + { kind: "bin", op: "/", left: numLit(1, loc), right: numLit(0, loc), type: F64, loc }, + ], + type: arrT, + loc, + }; + } const absent = elem.kind === "union" ? lowerer.wrappedUndefined(elem, loc) !== null || @@ -1085,7 +1132,10 @@ export function lowerArrayFromCall( // A generator (or an iterator adapted into one) drains in order. if (src.type.kind === "generator" && args.length === 1) { const arrT = lowerer.mapTypeOf(lowerer.typeOf(call)); - const drained = arrT?.kind === "array" ? generatorDrain(lowerer, src, arrT.elem, loc) : null; + const drained = + arrT?.kind === "array" + ? generatorDrain(lowerer, src, arrT.elem, loc, arrT.optionalElements === true) + : null; if (drained) return drained; } lowerer.noLowering( @@ -1159,6 +1209,11 @@ function buildArrayFromArrayFn( type: fnRet, loc, }; + // A result of another type than the element (`T | undefined` into a + // `(T | undefined)[]`, which stores T elements) is held in a local, so + // the store tests and converts one value. + const held = !typeEquals(fnRet, outElem); + const value: IrExpr = held ? varRef("v.0", fnRet, loc) : mapped; return { name, params: [ @@ -1171,6 +1226,7 @@ function buildArrayFromArrayFn( { id: "f.0", name: "f", type: fnT, mutable: true }, { id: "out.0", name: "out", type: outT, mutable: false }, { id: "i.0", name: "i", type: F64, mutable: true }, + ...(held ? [{ id: "v.0", name: "v", type: fnRet, mutable: false }] : []), ], body: [ { @@ -1191,7 +1247,8 @@ function buildArrayFromArrayFn( loc, }, body: [ - arrayValueStore(lowerer, out, i, mapped, outElem, loc), + ...(held ? [{ kind: "varDecl", localId: "v.0", init: mapped, loc } as const] : []), + arrayValueStore(lowerer, out, i, value, outElem, loc), { kind: "assign", localId: "i.0", diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 4ef203edd..f406e57eb 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -32,6 +32,10 @@ import { arrayValueRead, arrayValueStore, arrayValueType, + elementPlaceholder, + insertedValues, + optionalElementValue, + primitiveElement, } from "../array-values.js"; import { typeKey } from "../../type-mapper.js"; import { @@ -125,6 +129,14 @@ function lowerOptionalArrayPush( return { kind: "seqExpr", stmts, result: length(), type: F64, loc }; } +/** The checker type of `node` admits undefined. */ +function checkerTypeAdmitsUndefined(lowerer: Lowerer, node: ts.Expression): boolean { + const type = lowerer.typeOf(node); + return (type.isUnionType() ? ts.constituentTypes(type) : [type]).some( + (part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0, + ); +} + /** `a.splice(start, count, v)` where v may hold undefined at run time. * Receiver, start, count, and v evaluate once, in order; a present v is * inserted as a plain element literal (which a statement-position splice @@ -658,7 +670,10 @@ export function lowerArrayMethodCall( const hasSpread = itemNodes.some(ts.isSpreadElement); const itemProbes = hasSpread ? [] : itemNodes.map((arg) => tryLowerExpression(lowerer, arg)); const statefulItems = itemProbes.some( - (probe) => probe !== null && lowerer.runtimeOptionalWidening(probe.type, elem) !== null, + (probe) => + probe !== null && + (lowerer.runtimeOptionalWidening(probe.type, elem) !== null || + optionalElementValue(lowerer, probe, elem)), ); const items: IrExpr = hasSpread ? lowerArraySpreadItems(lowerer, itemNodes, elem, receiverIr, loc) @@ -875,6 +890,65 @@ export function lowerArrayMethodCall( loc, }; } + // A `(T | undefined)[]` (primitiveElement) takes undefined values as + // present undefined elements, stored after the insertion. + if ( + (name === "push" || name === "unshift") && + primitiveElement(elem) && + call.arguments.nodes.some((a) => checkerTypeAdmitsUndefined(lowerer, a)) + ) { + const nodes = call.arguments.nodes; + const values = nodes.map((a) => lowerer.lowerExpr(a)); + const inserted = insertedValues( + lowerer, + values, + elem, + (value, i) => lowerer.coerceInto(nodes[i]!, value, elem), + loc, + ); + if (inserted.optional) { + const array = lowerer.declareHiddenLocal("%insertInto", receiver.type); + const arr = varRef(array.id, array.type, loc); + const length = lowerer.declareHiddenLocal("%insertLength", F64); + const count: IrExpr = numLit(values.length, loc); + const first: IrExpr = + name === "push" + ? { + kind: "bin", + op: "-", + left: varRef(length.id, F64, loc), + right: count, + type: F64, + loc, + } + : numLit(0, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: array.id, init: receiver, loc }, + ...inserted.stmts, + { + kind: "varDecl", + localId: length.id, + init: { + kind: "arrIntrinsic", + method: name, + receiver: arr, + args: inserted.args, + type: F64, + loc, + }, + loc, + }, + ...inserted.fill(arr, first), + ], + result: varRef(length.id, F64, loc), + type: F64, + loc, + }; + } + return { kind: "arrIntrinsic", method: name, receiver, args: inserted.args, type: F64, loc }; + } // Inserted values flow into the element slot like an assignment would: // union-element arrays wrap plain arm values (coerceInto is inert // when the types already agree). @@ -996,9 +1070,12 @@ export function lowerArrayMethodCall( const hasSpread = itemNodes.some(ts.isSpreadElement); const itemProbes = hasSpread ? [] : itemNodes.map((arg) => tryLowerExpression(lowerer, arg)); const statefulItems = itemProbes.some( - (probe) => probe !== null && lowerer.runtimeOptionalWidening(probe.type, elem) !== null, + (probe) => + probe !== null && + (lowerer.runtimeOptionalWidening(probe.type, elem) !== null || + optionalElementValue(lowerer, probe, elem)), ); - if (statefulItems && itemNodes.length === 1) { + if (statefulItems && itemNodes.length === 1 && itemProbes[0]?.type.kind === "union") { return lowerOptionalSpliceItem( lowerer, receiver, @@ -1734,7 +1811,28 @@ function arraySearchHelper( type: valueT, loc, }; - const value = directNeedle ? rawRead : optionalValue; + // A direct needle matches only a present value (`match` tests the state + // first); a present undefined of a `(T | undefined)[]` (primitiveElement) + // must not be read as T. + const value = !directNeedle + ? optionalValue + : primitiveElement(elem) + ? { + kind: "ternary" as const, + cond: { + kind: "bin" as const, + op: "===" as const, + left: stateRef, + right: numLit(1, loc), + type: BOOL, + loc, + }, + then: rawRead, + else_: elementPlaceholder(elem, loc), + type: elem, + loc, + } + : rawRead; const readT = directNeedle ? elem : valueT; const equal = arraySearchEquality( lowerer, diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts index d95640978..de027e08c 100644 --- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts +++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts @@ -2,6 +2,7 @@ import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; import { isJsSourceFile, locOf } from "../../program.js"; +import { primitiveElement } from "../array-values.js"; import { varRef } from "../../../ir/build.js"; import { typeKey } from "../../type-mapper.js"; import { @@ -144,12 +145,22 @@ export function lowerArrayCallback( const untyped = (checkerType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0; const raw = untyped ? null : lowerer.mapTypeOf(checkerType); const mapped = raw ? lowerer.runtimeOptionalBindingType(param.name, raw) : null; + // A helper that passes present number, string or boolean elements + // (a `(T | undefined)[]` stores T elements) binds a `T | undefined` + // parameter to T, so the callback takes them without an adapter. + const presentElement = + mapped !== null && + primitiveElement(expected) && + mapped.kind === "union" && + lowerer.armTag(mapped.unionId, UNDEFINED_T) >= 0 && + typeEquals(lowerer.stripUndefinedArm(mapped), expected); if ( untyped ? !bindUntyped : mapped === null || (lowerer.coercibleValue(expected, mapped) && - !runtimeOptionalPairVariant(lowerer, expected, mapped)) + !runtimeOptionalPairVariant(lowerer, expected, mapped) && + !presentElement) ) return; const symbol = lowerer.checker.getSymbolAtLocation(param.name); diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index e043daf68..f18291a63 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -1,4 +1,5 @@ import { keyedHelperName } from "../stable-names.js"; +import { primitiveElement } from "../array-values.js"; import { nodeThrowExpr, boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; @@ -1137,6 +1138,17 @@ function setFromSeedValue( seed = strCharsCall(lowerer, source, loc); } else if (source.type.kind === "array" && typeEquals(source.type.elem, setT.elem)) { seed = source; + } else if ( + source.type.kind === "array" && + primitiveElement(source.type.elem) && + setT.elem.kind === "union" && + lowerer.armTag(setT.elem.unionId, UNDEFINED_T) >= 0 && + typeEquals(lowerer.stripUndefinedArm(setT.elem), source.type.elem) + ) { + // A `(T | undefined)[]` stores T elements: its present undefined + // elements copy as the Set's undefined. + seed = lowerer.coerceToExpected(source, arrayOf(setT.elem)); + if (!typeEquals(seed.type, arrayOf(setT.elem))) return null; } else if (source.type.kind === "record") { const shape = lowerer.shapes.get(source.type.shapeId); if (!shape?.tuple || !shape.fields.every((field) => typeEquals(field.type, setT.elem))) diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 668603d47..1c07885ae 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -1,6 +1,7 @@ import { keyedHelperName } from "../stable-names.js"; import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js"; import { countedFor, numLit, varRef } from "../../../ir/build.js"; +import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js"; import { objectEnumerationReceiver } from "../object-enumeration-receiver.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; @@ -156,7 +157,14 @@ export function objectIterOverIndexShape( // also holds numbers): each overflow value checks into the element type, // so a value of a dropped arm throws instead of mistyping. let ivNarrows = false; - if (valueT) { + // `T | undefined` values into a `(T | undefined)[]`, which stores T + // elements (primitiveElement): each value is stored with its present + // undefined. + const optionalValues = + member === "values" && + valueT !== null && + optionalElementValue(lowerer, { kind: "unitLit", unit: "undefined", type: iv, loc }, valueT); + if (valueT && !optionalValues) { const ivSurfaces = typeEquals(iv, valueT) || (valueT.kind === "union" && lowerer.armTag(valueT.unionId, iv) >= 0) || @@ -350,7 +358,25 @@ export function objectIterOverIndexShape( loc, }, then: [fieldStmt], - else_: undefinedElement ? [push(pushedOf(undefinedElement))] : null, + else_: undefinedElement + ? [push(pushedOf(undefinedElement))] + : member === "values" && valueT && primitiveElement(valueT) + ? [ + { + kind: "arraySetUndefined", + arr: outRef, + index: { + kind: "arrIntrinsic", + method: "length", + receiver: outRef, + args: [], + type: F64, + loc, + }, + loc, + }, + ] + : null, loc, }; } @@ -374,35 +400,45 @@ export function objectIterOverIndexShape( const kRef = varRef("k.0", STRING, loc); const readValue: IrExpr | null = !valueT ? null - : ivNarrows + : optionalValues ? { - kind: "dynCheck", - value: { - kind: "dynFrom", - value: { - kind: "recordKeyGet", - obj: rRef, - shapeId: argIr.shapeId, - key: kRef, - overflowOnly: true, - type: iv, - loc, - }, - type: DYN, - loc, - }, - type: valueT, - loc, - } - : { kind: "recordKeyGet", obj: rRef, shapeId: argIr.shapeId, key: kRef, overflowOnly: true, - type: valueT, + type: iv, loc, - }; + } + : ivNarrows + ? { + kind: "dynCheck", + value: { + kind: "dynFrom", + value: { + kind: "recordKeyGet", + obj: rRef, + shapeId: argIr.shapeId, + key: kRef, + overflowOnly: true, + type: iv, + loc, + }, + type: DYN, + loc, + }, + type: valueT, + loc, + } + : { + kind: "recordKeyGet", + obj: rRef, + shapeId: argIr.shapeId, + key: kRef, + overflowOnly: true, + type: valueT, + loc, + }; const loopPushed: IrExpr = member === "keys" ? kRef @@ -440,7 +476,26 @@ export function objectIterOverIndexShape( }, loc, }, - push(loopPushed), + ...(optionalValues + ? [ + { kind: "varDecl" as const, localId: "v.0", init: readValue!, loc }, + arrayValueStore( + lowerer, + outRef, + { + kind: "arrIntrinsic", + method: "length", + receiver: outRef, + args: [], + type: F64, + loc, + }, + varRef("v.0", iv, loc), + valueT!, + loc, + ), + ] + : [push(loopPushed)]), ], ), { kind: "return", value: outRef, loc }, @@ -457,6 +512,7 @@ export function objectIterOverIndexShape( { id: "ks.0", name: "ks", type: ksT, mutable: false }, { id: "i.0", name: "i", type: F64, mutable: true }, { id: "k.0", name: "k", type: STRING, mutable: false }, + ...(optionalValues ? [{ id: "v.0", name: "v", type: iv, mutable: false }] : []), ], body, loc, diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index c1b54a417..773f4cf0b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -31,7 +31,7 @@ import { strCharsCall } from "../containers/array-construction.js"; import { generatorDrain } from "../iterator-adapters.js"; import { isSearchParamsIterable } from "../builtins/url.js"; import { varRef } from "../../../ir/build.js"; -import { arrayValueStore } from "../array-values.js"; +import { arrayValueStore, primitiveElement } from "../array-values.js"; import { classGeneratorIterable } from "../class-iterables.js"; /** JavaScript: whether `expr` is or holds an existing checked-dynamic @@ -102,6 +102,13 @@ function emptyNeverLiteral(lowerer: Lowerer, expr: ts.ArrayLiteralExpression): b * tsc's API doesn't surface it (ternary arms under an array context — * tsc accepts the arm covariantly, but a tagged element representation * must be BUILT as the slot's element type). */ +/** The checker type of `node` admits undefined. */ +function mayHoldUndefined(lowerer: Lowerer, node: ts.Expression): boolean { + const type = lowerer.typeOf(node); + const parts = type.isUnionType() ? ts.constituentTypes(type) : [type]; + return parts.some((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0); +} + export function lowerArrayLiteral( lowerer: Lowerer, expr: ts.ArrayLiteralExpression, @@ -515,7 +522,13 @@ export function lowerArrayLiteral( // `[...it]` over a generator (or an iterator adapted into one): // drain it once, in order, into a fresh element array. if (src.type.kind === "generator") { - const drained = generatorDrain(lowerer, src, type.elem, locOf(el)); + const drained = generatorDrain( + lowerer, + src, + type.elem, + locOf(el), + type.optionalElements === true, + ); if (drained) src = drained; } // `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its @@ -606,6 +619,9 @@ export function lowerArrayLiteral( let lowered: IrExpr; const directOptionalCandidate = runtimeOptionalElements.has(i) || + // An element that can be undefined in a `(T | undefined)[]` + // (primitiveElement) is stored as a present undefined. + (primitiveElement(type.elem) && mayHoldUndefined(lowerer, el)) || ts.isElementAccessExpression(el) || (ts.isCallExpression(el) && ts.isPropertyAccessExpression(el.expression) && @@ -683,7 +699,8 @@ export function lowerArrayLiteral( continue; } let value = el; - if (!typeEquals(value.type, type.elem)) { + // An undefined element is stored directly (arrayValueStore). + if (!typeEquals(value.type, type.elem) && value.type.kind !== "undefinedT") { const temp = lowerer.declareHiddenLocal("%arrayValue", value.type); body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); value = varRef(temp.id, temp.type, loc); diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 9ac6a04b6..70909ca7b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -61,6 +61,8 @@ import { arrayValueType, lowerSafeBytesRead, lowerSafeIndexRead, + primitiveElement, + unionArrayValueRead, } from "../array-values.js"; import { lowerOptionalStringIndex, lowerUnionStringIndex } from "../string-index.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -594,6 +596,8 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre } } if (key.type.kind === "f64") { + const optional = optionalArmIndexRead(lowerer, value, key, locOf(expr)); + if (optional) return lowerer.maybeNarrow(optional, expr); const safe = lowerOptionalArrayIndexRead(lowerer, expr, value, key); if (safe) return safe; } @@ -884,9 +888,14 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre // must be the element type (library ABI checks and the catchable // RangeError contract for an out-of-bounds assert). Where undefined is // representable, the assertion keeps JavaScript's unchecked read. + // A declared `(T | undefined)[]` element (stored as T) keeps its + // optional read: the assertion then narrows it with a catchable + // TypeError, as for a union element. + const declaredOptional = arr.type.optionalElements === true; const provenPresent = ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr && + !declaredOptional && !assertedElementKeepsUndefined(lowerer, expr.parent); if (provenPresent) { return lowerer.maybeNarrow( @@ -2437,3 +2446,39 @@ export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrEx return { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc }; return lowerRecordPropertyKey(lowerer, key, node); } + +/** A number-keyed read of a union of arrays with a `(T | undefined)[]` arm + * (stored as T elements, primitiveElement) whose element facts admit + * missing values: each arm reads its element by its state + * (unionArrayValueRead), where the keyed read would trap on a present + * undefined. The receiver and key evaluate once. Null for other unions. */ +function optionalArmIndexRead( + lowerer: Lowerer, + value: IrExpr, + key: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const arms = lowerer.unions.get(value.type.unionId)?.arms ?? []; + if ( + !arms.some( + (arm) => + arm.kind === "array" && + primitiveElement(arm.elem) && + arrayValueType(lowerer, arm.elem).kind === "union", + ) + ) + return null; + if (unionArrayValueRead(lowerer, value, key, loc) === null) return null; + const stmts: IrStmt[] = []; + const stable = (e: IrExpr, name: string): IrExpr => { + if (e.kind === "varRef" || e.kind === "numLit") return e; + const local = lowerer.declareHiddenLocal(name, e.type); + stmts.push({ kind: "varDecl", localId: local.id, init: e, loc }); + return varRef(local.id, e.type, loc); + }; + const receiver = stable(value, "%indexReceiver"); + const index = stable(key, "%indexKey"); + const read = unionArrayValueRead(lowerer, receiver, index, loc)!; + return stmts.length === 0 ? read : { kind: "seqExpr", stmts, result: read, type: read.type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/iterator-adapters.ts b/packages/compiler/src/frontend/lowering/iterator-adapters.ts index 9f2b1e31f..4909e7f45 100644 --- a/packages/compiler/src/frontend/lowering/iterator-adapters.ts +++ b/packages/compiler/src/frontend/lowering/iterator-adapters.ts @@ -1,3 +1,4 @@ +import { appendElement, optionalElementValue, primitiveElement } from "./array-values.js"; import { keyedHelperName } from "./stable-names.js"; import { dynUndefinedExpr, varRef } from "../../ir/build.js"; import { @@ -964,16 +965,27 @@ function generatorRetypeAdapter( return { kind: "call", callee: name, args: [expr], type: expected, loc }; } +/** A checked-dynamic yielded value for a `(T | undefined)[]` of a + * number, string or boolean T: converted to `T | undefined`, which the + * drain stores as a T element or a present undefined. */ +function optionalYield(lowerer: Lowerer, value: IrExpr, elem: IrType, optional: boolean): IrExpr { + if (!optional || value.type.kind !== "dyn" || !primitiveElement(elem)) return value; + return lowerer.coerceToExpected(value, lowerer.withUndefinedArm(elem)); +} + /** Drain a synchronous generator into a fresh `elem[]` — the eager * consumers of an iterator (`[...it]`, `Array.from(it)`). Each step resumes * the generator once and appends its value; completion stops the drain * without appending the return value, as in JS. Null when the yielded - * values cannot be extracted into the element type. */ + * values cannot be extracted into the element type. `optional`: the + * result is a `(T | undefined)[]`, which stores T elements, so a + * checked-dynamic value may also be undefined. */ export function generatorDrain( lowerer: Lowerer, source: IrExpr, elem: IrType, loc: SrcLoc, + optional = false, ): IrExpr | null { const gen = source.type; if (gen.kind !== "generator" || gen.async || gen.yieldT.kind === "void") return null; @@ -982,14 +994,16 @@ export function generatorDrain( const valueT = shape?.fields.find((field) => field.name === "value")?.type; if (!resultType || !valueT) return null; const arrT: IrType = { kind: "array", elem }; - const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}`; + const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}${optional ? "?" : ""}`; let name = lowerer.valueHelpers.get(key); if (!name && stepDrivable(gen)) { // The record-free drive: test done, then take the yielded value. const genRef = varRef("gen.0", gen, loc) as IrExpr & { type: typeof gen }; const outRef = varRef("out.0", arrT, loc); - const value = lowerer.coerceToExpected(genValue(genRef, loc), elem); - if (!typeEquals(value.type, elem)) return null; + const yielded = optionalYield(lowerer, genValue(genRef, loc), elem, optional); + const present = optionalElementValue(lowerer, yielded, elem); + const value = present ? yielded : lowerer.coerceToExpected(yielded, elem); + if (!present && !typeEquals(value.type, elem)) return null; name = keyedHelperName(lowerer, "%gen.drain", key); lowerer.valueHelpers.set(key, name); lowerer.liftedFns.push({ @@ -999,6 +1013,7 @@ export function generatorDrain( locals: [ { id: "gen.0", name: "gen", type: gen, mutable: false }, { id: "out.0", name: "out", type: arrT, mutable: false }, + ...(present ? [{ id: "v.0", name: "v", type: value.type, mutable: false }] : []), ], body: [ { @@ -1010,20 +1025,7 @@ export function generatorDrain( { kind: "while", cond: { kind: "unary", op: "!", operand: genStep(genRef, loc), type: BOOL, loc }, - body: [ - { - kind: "exprStmt", - expr: { - kind: "arrIntrinsic", - method: "push", - receiver: outRef, - args: [value], - type: F64, - loc, - }, - loc, - }, - ], + body: appendElement(lowerer, outRef, value, elem, "v.0", loc), loc, }, { kind: "return", value: outRef, loc }, @@ -1050,8 +1052,10 @@ export function generatorDrain( loc, ); if (!raw) return null; - const value = lowerer.coerceToExpected(raw, elem); - if (!typeEquals(value.type, elem)) return null; + const yielded = optionalYield(lowerer, raw, elem, optional); + const present = optionalElementValue(lowerer, yielded, elem); + const value = present ? yielded : lowerer.coerceToExpected(yielded, elem); + if (!present && !typeEquals(value.type, elem)) return null; name = keyedHelperName(lowerer, "%gen.drain", key); lowerer.valueHelpers.set(key, name); lowerer.liftedFns.push({ @@ -1062,6 +1066,7 @@ export function generatorDrain( { id: "gen.0", name: "gen", type: gen, mutable: false }, { id: "out.0", name: "out", type: arrT, mutable: false }, { id: "step.0", name: "step", type: resultType, mutable: false }, + ...(present ? [{ id: "v.0", name: "v", type: value.type, mutable: false }] : []), ], body: [ { @@ -1101,18 +1106,7 @@ export function generatorDrain( else_: null, loc, }, - { - kind: "exprStmt", - expr: { - kind: "arrIntrinsic", - method: "push", - receiver: outRef, - args: [value], - type: F64, - loc, - }, - loc, - }, + ...appendElement(lowerer, outRef, value, elem, "v.0", loc), ], loc, }, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index af89c61a4..2ddf02dfc 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6515,6 +6515,17 @@ export class Lowerer { mayBeUndefined: (node) => { if (explicitlyNonNull(node)) return assertedReadMayBeUndefined(node); if (mayBeOptional(node)) return true; + // A value the checker types as possibly undefined: `(T | undefined)[]` + // stores T elements (the type mapper), so its undefined values are + // facts of T's arrays. + const checked = typeOrNull(node); + if ( + checked !== null && + (checked.isUnionType() ? ts.constituentTypes(checked) : [checked]).some( + (part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0, + ) + ) + return true; // `undefined as unknown as T` keeps the undefined value. for ( let inner: ts.Expression = node; @@ -6533,7 +6544,37 @@ export class Lowerer { const symbol = symbolOf(e); return symbol !== null && iterationSymbols.has(symbol) && !provenPresent(e, symbol); }, - callbackReturnsUndefined: (node) => callbackReturnsOptional(node), + iterationMayYieldUndefined: (node) => { + const type = typeOrNull(node); + if (type === null || this.checker.isArrayType(type) || this.checker.isTupleType(type)) + return false; + // The first type argument of Set, Map and Set iterators, + // generators and iterables is the value they yield. + const element = + (type.flags & ts.TypeFlags.Object) !== 0 + ? panicSafe(() => this.checker.getTypeArguments(type as ts.TypeReference)[0], undefined) + : undefined; + if (element === undefined) return false; + return (element.isUnionType() ? ts.constituentTypes(element) : [element]).some( + (part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0, + ); + }, + callbackReturnsUndefined: (node) => { + if (callbackReturnsOptional(node)) return true; + // A callback the checker types as possibly returning undefined + // (`(m) => map.get(m)`): its results are present undefined + // elements of a `(T | undefined)[]`, which stores T elements. + const type = typeOrNull(node); + return ( + type !== null && + this.checker.getCallSignatures(type).some((signature) => { + const result = this.checker.getReturnTypeOfSignature(signature); + return (result.isUnionType() ? ts.constituentTypes(result) : [result]).some( + (part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0, + ); + }) + ); + }, isGlobal: (node, name) => this.isStdlibGlobal(node, name), isString: (node) => { const type = typeOrNull(node); @@ -6594,6 +6635,7 @@ export class Lowerer { const cached = scanSites.get(sf); if (cached !== undefined) return cached; const nodes: ts.Node[] = []; + const values: ts.Node[] = []; ts.walkPreorder(sf, (node) => { if ( ts.isVariableDeclaration(node) || @@ -6609,7 +6651,21 @@ export class Lowerer { ts.isPostfixUnaryExpression(node) ) nodes.push(node); + // The values these sites store, whose checker types mayBeUndefined + // reads: batched here, since dead bodies are scanned too. + if (ts.isArrayLiteralExpression(node)) + for (const element of node.elements.nodes) + values.push(ts.isSpreadElement(element) ? element.expression : element); + else if (ts.isCallExpression(node) || ts.isNewExpression(node)) + for (const arg of node.arguments?.nodes ?? []) + values.push(ts.isSpreadElement(arg) ? arg.expression : arg); + else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + values.push(node.right); }); + this.checker.prefetchCollectionTypes(values); scanSites.set(sf, nodes); return nodes; }; diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts index 96d20d2c4..fffe79a51 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts @@ -16,6 +16,9 @@ export interface ElementStateHost { elementAdmitsUndefined(key: string): boolean; /** The value stored by `node` can be `undefined` at runtime. */ mayBeUndefined(node: ts.Expression): boolean; + /** Iterating `node`, an iterable other than an array (a Set, a Map's + * values, a generator), can yield `undefined`. */ + iterationMayYieldUndefined?(node: ts.Expression): boolean; /** A callback argument can return `undefined` at runtime. */ callbackReturnsUndefined(node: ts.Expression): boolean; /** The value may be a plain array-like object, whose missing indices @@ -353,7 +356,11 @@ export class ArrayElementStates { /** One stored value, or the elements a spread copies. */ private valueMayBeUndefined(value: ts.Expression): boolean { - if (ts.isSpreadElement(value)) return this.mayYieldUndefined(value.expression); + if (ts.isSpreadElement(value)) + return ( + this.mayYieldUndefined(value.expression) || + (this.host.iterationMayYieldUndefined?.(value.expression) ?? false) + ); return this.host.mayBeUndefined(value); } diff --git a/packages/compiler/src/frontend/lowering/sanitize-class-types.ts b/packages/compiler/src/frontend/lowering/sanitize-class-types.ts index 77478d76d..6b89b446a 100644 --- a/packages/compiler/src/frontend/lowering/sanitize-class-types.ts +++ b/packages/compiler/src/frontend/lowering/sanitize-class-types.ts @@ -16,6 +16,8 @@ export function sanitizeUnregisteredClassTypes( case "object": return hasClass(type.className) ? type : F64; case "array": + type.elem = rewrite(type.elem); + break; case "set": type.elem = rewrite(type.elem); break; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 161f73d27..16b77b7f5 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2341,6 +2341,23 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { elem = unitOnlyUnion(unions); if (!elem) return null; + // `(T | undefined)[]` of a number, string or boolean T stores T + // elements: an array slot holds a present undefined by its own state, + // so a T[] seen as `(T | undefined)[]` stays the same array. + let optionalElements = false; + if (elem.kind === "union") { + const arms = unions.get(elem.unionId)?.arms; + const present = + arms?.length === 2 ? arms.find((arm) => arm.kind !== "undefinedT") : undefined; + if ( + present !== undefined && + arms!.some((arm) => arm.kind === "undefinedT") && + (present.kind === "f64" || present.kind === "string" || present.kind === "bool") + ) { + elem = present; + optionalElements = true; + } + } // A dyn ELEMENT makes the WHOLE array the checked-dynamic value: // `unknown[]`, `object[]`, and the collapsed `(string | object)[]` // (the plugins-slot shape) — the checked-dynamic tree has real arrays, so length/ @@ -2362,7 +2379,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // drain of the auxiliary-server registries): handles are ordinary // refcounted REF elements (their `_v` adapters, no trace — both drop // their closures at their terminal event). - return arrayOf(elem); + return optionalElements ? { kind: "array", elem, optionalElements: true } : arrayOf(elem); } // Tuples: `[string, string]` maps to an interned RECORD shape flagged // `tuple`, with one field per position named by its index ("0", "1", ...) diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 8fb636608..58edd52d3 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -87,7 +87,16 @@ export type IrType = | { kind: "date" } | { kind: "string" } // heap, refcounted, UTF-8 | { kind: "bool" } - | { kind: "array"; elem: IrType } // heap, refcounted, monomorphic elements + | { + kind: "array"; + elem: IrType; + /** A `(T | undefined)[]` of a number, string or boolean T, stored as + * T elements (which hold a present undefined by their state). Not + * part of the type's identity (typeEquals, typeKey): it only lets a + * checked conversion from a dynamic value accept undefined + * elements. */ + optionalElements?: true; + } // heap, refcounted, monomorphic elements /** ES `Map` — heap, refcounted, insertion-ordered hash map with ONE * runtime representation (ScrMap) and type-directed key/value handling, * exactly the array pattern (never per-instantiation structs). Keys use diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index a694e8d2c..b9996fc6c 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2050,12 +2050,18 @@ function validateFunction( } case "arrayNewLen": { // Mapper-less Array.from({length: n}): absent-slot fill exists - // only for refcounted element kinds (frontend fences scalars). + // only for refcounted element kinds. Number and boolean slots are + // filled with present undefined values right after + // (`(T | undefined)[]`); the frontend fences other scalar uses. if (e.type.kind !== "array") { err(`arrayNewLen must be array-typed, got ${e.type.kind}`, e.loc); break; } - if (!isRefCounted(e.type.elem)) { + if ( + !isRefCounted(e.type.elem) && + e.type.elem.kind !== "f64" && + e.type.elem.kind !== "bool" + ) { err( `arrayNewLen with non-refcounted ${e.type.elem.kind} elements (no absent value)`, e.loc, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8000-optional-element-arrays-share-storage.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8000-optional-element-arrays-share-storage.ts.json new file mode 100644 index 000000000..8cdd32baf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8000-optional-element-arrays-share-storage.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8000-optional-element-arrays-share-storage.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8001-optional-element-writers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8001-optional-element-writers.ts.json new file mode 100644 index 000000000..fe45249bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8001-optional-element-writers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8001-optional-element-writers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8000-optional-element-arrays-share-storage.ts b/tests/corpus/8000-optional-element-arrays-share-storage.ts new file mode 100644 index 000000000..fa31fcad2 --- /dev/null +++ b/tests/corpus/8000-optional-element-arrays-share-storage.ts @@ -0,0 +1,61 @@ +// A number[] or string[] seen as `(T | undefined)[]` is the same array, as +// in Node: undefined written through either name is visible through both, +// whether the wider view is a cast, a binding or a parameter. + +const c: number[] = [1.5, 2]; +(c as (number | undefined)[])[1] = undefined; +console.log(c); +const e: number[] = [1.5, 2]; +const f = e as (number | undefined)[]; +f[1] = undefined; +console.log(e, f, e === f); + +function clearFirst(values: (number | undefined)[]): void { + values[0] = undefined; + values.push(undefined, 4); +} +const a: number[] = [1, 2, 3]; +clearFirst(a); +console.log(a, a.length, a.indexOf(4), a.includes(undefined as unknown as number)); + +const names: string[] = ["x"]; +const wider: (string | undefined)[] = names; +wider.push(undefined); +wider.unshift(undefined); +console.log(names, wider === names, names.length, JSON.stringify(names)); + +// The wider arrays themselves: literals, pushes, splices and copies keep +// their undefined elements. +const lit: (number | undefined)[] = [1, undefined, 3]; +const maybe = (i: number): number | undefined => (i % 2 ? i : undefined); +const built: (number | undefined)[] = []; +for (let i = 0; i < 4; i++) built.push(maybe(i)); +built.splice(1, 0, undefined); +console.log(lit, built, lit.indexOf(undefined), built.filter((x): boolean => x === undefined).length); +console.log(built.map((x) => x ?? -1), built.find((x) => x !== undefined), [...built].reverse()); +let sum = 0; +for (const x of built) sum += x ?? 0; +console.log(sum, built.join("-"), JSON.stringify(built)); + +const flags: (boolean | undefined)[] = [true, undefined, false]; +const strs: (string | undefined)[] = ["a", undefined]; +console.log(flags, flags.filter(Boolean).length, strs.map((s) => s?.toUpperCase())); + +// Values from other containers keep their undefined. +const m = new Map([["a", undefined], ["b", 2]]); +const fromMap = [...m.values()]; +const fromSet = Array.from(new Set(["s", undefined])); +const sized = Array.from({ length: 2 }); +console.log(fromMap, fromSet, sized, sized.length); +const dict: { [key: string]: number | undefined } = { a: 1, b: undefined }; +console.log(Object.values(dict)); + +// An asserted element that is undefined throws a catchable TypeError. +function lengthAt(items: (string | undefined)[], i: number): number { + try { + return items[i]!.length; + } catch { + return -1; + } +} +console.log(lengthAt(strs, 0), lengthAt(strs, 1)); diff --git a/tests/corpus/8001-optional-element-writers.ts b/tests/corpus/8001-optional-element-writers.ts new file mode 100644 index 000000000..5dff693c3 --- /dev/null +++ b/tests/corpus/8001-optional-element-writers.ts @@ -0,0 +1,103 @@ +// Every way a value reaches a `(T | undefined)[]` of numbers, strings or +// booleans keeps its undefined: callbacks, generators, Array.of, rest +// packs, destructuring, class fields and copies into Sets and Maps. + +const slots = new Map([["a", 1]]); +const looked = ["x", "a", "y"].map((key) => slots.get(key)); +console.log(looked[0] === undefined, looked, looked.length); +const fromMapped = Array.from(["a", "b"], (key) => slots.get(key)); +console.log(fromMapped[1] === undefined, fromMapped); +const objects: { n?: number }[] = [{ n: 1 }, {}]; +const fields = objects.map((o) => o.n); +function pick(o: { n?: number }): number | undefined { + return o.n; +} +console.log(fields[1] === undefined, objects.map(pick)[1] === undefined, fields); +function wrap(value: T): T[] { + return [value]; +} +console.log(wrap(undefined)[0] === undefined); + +function* maybe(): Generator { + yield undefined; + yield 2; +} +const drained = [...maybe()]; +console.log(drained[0] === undefined, drained, Array.from(maybe())); +const yielding = function* (): Generator { + yield "s"; + yield undefined; +}; +console.log([...yielding()]); +console.log(Array.of(undefined, 1), Array.of(true)); + +function sum(...values: (number | undefined)[]): number { + return values.reduce((total, v) => total + (v ?? 0), 0); +} +const spread: (number | undefined)[] = [undefined, 4]; +console.log(sum(1, undefined, 2), sum(...spread), sum(...spread, undefined, 1)); + +const [first, second = 7, third] = [3, undefined, 1] as (number | undefined)[]; +console.log(first, second, third); +const strings: (string | undefined)[] = ["a", undefined, "a"]; +const seen = new Set(strings); +console.log(seen.size, seen.has(undefined), [...seen]); +for (const [i, s] of strings.entries()) console.log(i, s); +for (const key in strings) console.log("key", key); + +class Holder { + values: (number | undefined)[] = []; + add(value: number | undefined): void { + this.values.push(value); + } + first(): number | undefined { + return this.values[0]; + } +} +const holder = new Holder(); +holder.add(undefined); +holder.add(2); +console.log(holder.first(), holder.values, holder.values.length); + +const xs: (number | undefined)[] = [3, undefined, 1]; +xs.forEach((x, i) => console.log(i, x, typeof x)); +console.log(xs.findIndex((x) => x === undefined), xs.findLast((x) => x !== undefined)); +console.log(xs.some((x) => x === undefined), xs.every((x) => x !== undefined)); +console.log(xs.reduce((acc, x) => acc + (x ?? 10), 0), xs.join("/"), xs.at(1), xs.at(-1)); +console.log(xs.toSorted((a, b) => (a ?? 0) - (b ?? 0)), [...xs].sort()); +xs[1] ??= 5; +console.log(xs); +const flags: (boolean | undefined)[] = [undefined, true]; +console.log(flags.map((b) => !b), flags.indexOf(undefined), flags.includes(true), String(flags)); +const nested: (number | undefined)[][] = [[undefined], [1]]; +console.log(nested.flat(), nested[0]![0]); +const byKey = new Map([["k", [undefined, 1]]]); +console.log(byKey.get("k")![0], byKey); + +// Promise.all over `Promise[]` settles on Node's turn, with +// its undefined values. +const lookup = async (key: string): Promise => { + if (key === "x") throw new Error("missing " + key); + return slots.get(key); +}; +const turns: string[] = []; +const pending: Promise[] = [lookup("a"), lookup("b")]; +void Promise.all(pending).then((vs) => turns.push("all " + vs.join(",") + " " + (vs[1] === undefined))); +const failing: Promise[] = [lookup("a"), lookup("x")]; +Promise.all(failing).then( + () => turns.push("unexpected"), + (error: unknown) => turns.push("rejected " + (error as Error).message), +); +void Promise.all([] as Promise[]).then((vs) => turns.push("empty " + vs.length)); +void Promise.resolve() + .then(() => turns.push("t1")) + .then(() => turns.push("t2")) + .then(() => turns.push("t3")) + .then(() => console.log(turns.join(" "))); + +// Index reads through a union of arrays, and searches with a number +// needle, see the present undefined elements. +const pairs: ((null | undefined)[] | (number | undefined)[])[] = [[null, undefined], [undefined, 0]]; +for (const pair of pairs) console.log(pair[0], pair[1], pair[2]); +const sparse: (number | undefined)[] = [1, , undefined, 1]; +console.log(sparse.lastIndexOf(undefined), sparse.lastIndexOf(1, 2), sparse.indexOf(1, 1)); diff --git a/tests/harness/corpus-costs/8000-optional-element-arrays-share-storage.ts.json b/tests/harness/corpus-costs/8000-optional-element-arrays-share-storage.ts.json new file mode 100644 index 000000000..b3db55643 --- /dev/null +++ b/tests/harness/corpus-costs/8000-optional-element-arrays-share-storage.ts.json @@ -0,0 +1 @@ +{ "plain": 6206, "san": 8487 } diff --git a/tests/harness/corpus-costs/8001-optional-element-writers.ts.json b/tests/harness/corpus-costs/8001-optional-element-writers.ts.json new file mode 100644 index 000000000..31695bc47 --- /dev/null +++ b/tests/harness/corpus-costs/8001-optional-element-writers.ts.json @@ -0,0 +1 @@ +{ "plain": 3690, "san": 4000 } From 01e6d55fc56e00e9ba91b55f29e021029d3289f3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 17:05:18 -0500 Subject: [PATCH 2/5] Keep optional-element arrays through nested conversions, union arms, rest packing and receiver reassignment - Checked conversions from dynamic values key their builders and matchers by the type with `?` after every `(T | undefined)[]`, at any depth. `number[][]` and `(number | undefined)[][]` share a typeKey, so the second conversion reused the first one's builder and rejected undefined elements. - The dynamic matcher of a `(T | undefined)[]` takes undefined elements, so a union with such an arm matches arrays that hold undefined. - An optional-element rest parameter packs tuple-spread values and plain arguments together as present undefined elements, each evaluated once in argument order, instead of converting each value to T. - The element-state scan counts an argument in a rest position that may be undefined, and a spread tuple with undefined elements, as undefined elements of the rest array, so reads in the callee check element states. - A union index read keeps the receiver in a temporary when the key can assign it, so `a[(a = other, 0)]` reads the array `a` held before the key. --- packages/compiler/src/backend/llvm/dyn.ts | 34 +++++++++++-- .../src/frontend/lowering/call-arguments.ts | 26 +++++++++- .../lowering/expressions/element-access.ts | 15 ++++-- .../compiler/src/frontend/lowering/lowerer.ts | 24 ++++++++- .../lowering/runtime-optional-elements.ts | 19 ++++++- .../8004-optional-element-conversions.ts.json | 6 +++ .../8004-optional-element-conversions.ts | 49 +++++++++++++++++++ .../8004-optional-element-conversions.ts.json | 1 + 8 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8004-optional-element-conversions.ts.json create mode 100644 tests/corpus/8004-optional-element-conversions.ts create mode 100644 tests/harness/corpus-costs/8004-optional-element-conversions.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 57c8b5177..4ec710b28 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -66,6 +66,25 @@ import { recordMayKeepSource, recordSourceFieldOrder } from "./record-sources.js import type { WalkerHost } from "./walkers.js"; import type { LlStreamTypedRefAdapter } from "./expr-context.js"; +/** typeKey with `?` after every `(T | undefined)[]` (optionalElements), + * at any depth of arrays, Maps, Sets and promises: checked conversions to + * `number[][]` and `(number | undefined)[][]` need separate helpers, though + * the two types share a typeKey. */ +function checkedConversionKey(t: IrType): string { + switch (t.kind) { + case "array": + return `array<${checkedConversionKey(t.elem)}>${t.optionalElements ? "?" : ""}`; + case "map": + return `map<${checkedConversionKey(t.key)},${checkedConversionKey(t.value)}>`; + case "set": + return `set<${checkedConversionKey(t.elem)}>`; + case "promise": + return `promise<${checkedConversionKey(t.inner)}>`; + default: + return typeKey(t); + } +} + /** Whether to-dyn of a record shape keeps the record itself: an empty shape * and an event-listener record (a `handleEvent` function field) box their * identity. Every other shape, tuples included, copies its fields into a @@ -1060,7 +1079,7 @@ export class LlDyn { /** `sc_dm_(ptr d) -> i1` — does this dyn fit T? Never throws. */ dynMatchHelper(t: IrType, preserveRefs = false): string { - const key = typeKey(t); + const key = checkedConversionKey(t); const matcherKey = preserveRefs ? `identity:${key}` : key; const existing = this.dynMatchers.get(matcherKey); if (existing) return existing; @@ -1375,7 +1394,7 @@ export class LlDyn { B.startBlock(l0); const n = this.lenOf(B, "%d"); const items = this.itemsOf(B, "%d"); - this.i64Loop(B, "dm.el", n, (i, brNext) => { + this.i64Loop(B, "dm.el", n, (i, brNext, next) => { this.host.declare(`declare zeroext i1 @scr_dyn_arr_has_index(ptr, ${this.S})`); const present = B.tmp(); B.line(`${present} = call zeroext i1 @scr_dyn_arr_has_index(ptr %d, ${this.S} ${i})`); @@ -1386,6 +1405,15 @@ export class LlDyn { brNext(); B.startBlock(valueLabel); const e = this.itemAt(B, items, i); + if (t.optionalElements) { + // A `(T | undefined)[]` stored as T elements takes undefined + // elements as well. + const undef = B.tmp(); + const checkLabel = B.newLabel("dm.check"); + B.line(`${undef} = icmp eq i32 ${this.kindOf(B, e)}, ${DYN_KIND.UNDEF}`); + B.condBr(undef, next, checkLabel); + B.startBlock(checkLabel); + } const ok = B.tmp(); B.line(`${ok} = call zeroext i1 @${m}(ptr ${e})`); const lOk = B.newLabel("dm.eo"); @@ -1798,7 +1826,7 @@ export class LlDyn { * TypeError and return a dummy with the pending flag set. */ dynCheckHelper(t: IrType, preserveRefs = false): string { // A `(T | undefined)[]` stored as T elements also takes undefined ones. - const key = t.kind === "array" && t.optionalElements ? `${typeKey(t)}?` : typeKey(t); + const key = checkedConversionKey(t); const builderKey = preserveRefs ? `identity:${key}` : key; const existing = this.dynBuilders.get(builderKey); if (existing) return existing; diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index 6da0cb337..b02d0b85c 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -28,7 +28,7 @@ import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { type ParamShape } from "./call-signatures.js"; import { arrayValueRead, arrayValueType } from "./array-values.js"; import { buildArrayConversion } from "./coercions/builders.js"; -import { lowerArraySpreadItems } from "./containers/array-construction.js"; +import { lowerArraySpreadItems, lowerArrayValueItems } from "./containers/array-construction.js"; import { restTupleElementTypes, typeKey } from "../type-mapper.js"; function fixedTupleSpreadInfo( @@ -413,6 +413,30 @@ export function completeArgs( ); return out; } + // Tuple-spread values (pre-lowered) and plain arguments pack the same + // way. Each value evaluates once, in argument order. + if ( + restType.optionalElements === true && + !restNodes.some((a) => !isIr(a) && ts.isSpreadElement(a)) + ) { + const stmts: IrStmt[] = []; + const values = restNodes.map((a): IrExpr => { + if (isIr(a)) { + if (a.ir.kind !== "seqExpr") return a.ir; + stmts.push(...a.ir.stmts); + return a.ir.result; + } + const value = lowerer.lowerExpr(a as ts.Expression); + const saved = lowerer.declareHiddenLocal("%callArg", value.type); + stmts.push({ kind: "varDecl", localId: saved.id, init: value, loc: value.loc }); + return { kind: "varRef", localId: saved.id, type: value.type, loc: value.loc }; + }); + const items = lowerArrayValueItems(lowerer, values, restType.elem, restType, loc); + out.push( + stmts.length === 0 ? items : { kind: "seqExpr", stmts, result: items, type: restType, loc }, + ); + return out; + } // The rest pack is a fresh array per call; surplus SPREADS copy // their elements in (JS-exact — `f(a, ...xs, b, ...ys)` packs in // order, sources untouched). diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 70909ca7b..a2f2ebdb6 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -2471,14 +2471,21 @@ function optionalArmIndexRead( return null; if (unionArrayValueRead(lowerer, value, key, loc) === null) return null; const stmts: IrStmt[] = []; - const stable = (e: IrExpr, name: string): IrExpr => { - if (e.kind === "varRef" || e.kind === "numLit") return e; + const stable = (e: IrExpr, name: string, keep: boolean): IrExpr => { + if (keep) return e; const local = lowerer.declareHiddenLocal(name, e.type); stmts.push({ kind: "varDecl", localId: local.id, init: e, loc }); return varRef(local.id, e.type, loc); }; - const receiver = stable(value, "%indexReceiver"); - const index = stable(key, "%indexKey"); + // A receiver local stays a direct read only when the key cannot assign + // it (`a[(a = other, 0)]` reads the array `a` held before the key). + const plainKey = key.kind === "varRef" || key.kind === "numLit"; + const receiver = stable( + value, + "%indexReceiver", + value.kind === "numLit" || (value.kind === "varRef" && plainKey), + ); + const index = stable(key, "%indexKey", plainKey); const read = unionArrayValueRead(lowerer, receiver, index, loc)!; return stmts.length === 0 ? read : { kind: "seqExpr", stmts, result: read, type: read.type, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 87825d030..28288a5f1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6546,8 +6546,20 @@ export class Lowerer { }, iterationMayYieldUndefined: (node) => { const type = typeOrNull(node); - if (type === null || this.checker.isArrayType(type) || this.checker.isTupleType(type)) - return false; + if (type === null || this.checker.isArrayType(type)) return false; + // A tuple yields its elements, in order. + if (this.checker.isTupleType(type)) + return panicSafe( + () => + this.checker + .getTypeArguments(type as ts.TypeReference) + .some((element) => + (element.isUnionType() ? ts.constituentTypes(element) : [element]).some( + (part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0, + ), + ), + true, + ); // The first type argument of Set, Map and Set iterators, // generators and iterables is the value they yield. const element = @@ -6601,6 +6613,14 @@ export class Lowerer { const rest = sig.params.find((shape) => shape.mode === "rest"); return rest?.type.kind === "array" ? [typeKey(rest.type.elem)] : []; }, + restStart: (call) => { + const callee = peel(call.expression); + if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)) return null; + const symbol = callableSymbolOf(callee); + const sig = symbol ? signatureBySymbol.get(symbol) : undefined; + const at = sig ? sig.params.findIndex((shape) => shape.mode === "rest") : -1; + return at >= 0 ? at : null; + }, markIterationBinding: (node, known) => { const symbol = symbolOf(node); if (!symbol) return false; diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts index fffe79a51..7561f4a65 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts @@ -31,6 +31,9 @@ export interface ElementStateHost { /** Element keys of the rest parameter that the spread arguments of * `call` fill: `[]` when the callee has none, null when it is unknown. */ restKeys(call: ts.CallExpression | ts.NewExpression): readonly string[] | null; + /** The argument index where the callee's rest parameter starts, or null + * when the callee has none or is unknown. */ + restStart?(call: ts.CallExpression | ts.NewExpression): number | null; /** Record that iterating `node`'s binding can observe `undefined`; * `known` when the iterated array's own facts (not only those shared by * its element ABI) say so. Returns true when the binding's record grew. */ @@ -518,14 +521,26 @@ export class ArrayElementStates { private scanCall(node: ts.CallExpression | ts.NewExpression): boolean { let changed = false; const args = node.arguments?.nodes ?? []; - // Spreading a sparse array into a rest parameter fills the holes. + // Spreading a sparse array, or a tuple with undefined elements, into a + // rest parameter fills the holes. if (args.some((arg) => ts.isSpreadElement(arg))) { for (const arg of args) { - if (!ts.isSpreadElement(arg) || !this.mayYieldUndefined(arg.expression)) continue; + if (!ts.isSpreadElement(arg) || !this.valueMayBeUndefined(arg)) continue; const rest = this.host.restKeys(node) ?? this.host.arrayKeys(arg.expression); if (this.addUndefined(rest)) changed = true; } } + // An argument in a rest position that may be undefined is a present + // undefined element of the rest array. + const restStart = this.host.restStart?.(node) ?? null; + if (restStart !== null) + for (const arg of args.slice(restStart)) + if ( + !ts.isSpreadElement(arg) && + this.host.mayBeUndefined(arg) && + this.addUndefined(this.host.restKeys(node)) + ) + changed = true; if (this.record(node)) changed = true; const callee = peel(node.expression); if (!ts.isPropertyAccessExpression(callee)) return changed; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8004-optional-element-conversions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8004-optional-element-conversions.ts.json new file mode 100644 index 000000000..2a1bc7c66 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8004-optional-element-conversions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8004-optional-element-conversions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8004-optional-element-conversions.ts b/tests/corpus/8004-optional-element-conversions.ts new file mode 100644 index 000000000..2a394c331 --- /dev/null +++ b/tests/corpus/8004-optional-element-conversions.ts @@ -0,0 +1,49 @@ +// `(T | undefined)[]` values built by checked conversions, rest parameters +// and union index reads: nested arrays converted beside their non-optional +// form, a union arm that is an optional-element array, undefined arguments +// and tuple spreads packed into a rest parameter, and an index expression +// that reassigns the receiver. + +const plainRows = JSON.parse("[[1, 2], [3]]") as number[][]; +console.log(plainRows); +const optionalSource: unknown = (JSON.parse("[[1, null], [null, 4]]") as unknown[][]).map((row) => + row.map((v) => (v === null ? undefined : v)), +); +const optionalRows = optionalSource as (number | undefined)[][]; +console.log(optionalRows, optionalRows[0]![1], optionalRows[1]!.length); + +const armSource: unknown = [1, undefined, 3]; +const arm = armSource as (number | undefined)[] | string; +console.log(typeof arm === "string" ? arm : arm.length, arm); +const stringArm = ["a", undefined] as unknown as (string | undefined)[] | number; +console.log(stringArm); +const textArm = "text" as unknown as (string | undefined)[] | string; +console.log(textArm); + +function flags(...values: (boolean | undefined)[]): string { + let text = ""; + for (const value of values) text += value === undefined ? "u" : value ? "t" : "f"; + return `${values.length}:${text}:${String(values[1])}`; +} +console.log(flags(true, undefined)); +const flagPair: [boolean, boolean | undefined] = [false, undefined]; +console.log(flags(...flagPair), flags(true, ...flagPair), flags(...flagPair, true)); +function label(first: string, ...rest: (string | undefined)[]): string { + return `${first}/${rest.length}/${rest.map((v) => v ?? "-").join("")}`; +} +const words: [string, string | undefined, string | undefined] = ["a", undefined, "c"]; +console.log(label(...words), label("b", undefined)); + +function pick(start: (number | undefined)[] | string[], other: (number | undefined)[] | string[]): string { + let a = start; + const read = a[((a = other), 0)]; + return `${String(read)} ${String(a[0])}`; +} +console.log(pick([1, undefined, 3], ["x", "y"]), pick([undefined, 2], ["z"])); +function counted(start: (number | undefined)[] | string[]): string { + let b = start; + let i = 1; + const read = b[((b = ["q"]), i--)]; + return `${String(read)} ${b.length} ${i}`; +} +console.log(counted([4, undefined]), counted(["s", "t"])); diff --git a/tests/harness/corpus-costs/8004-optional-element-conversions.ts.json b/tests/harness/corpus-costs/8004-optional-element-conversions.ts.json new file mode 100644 index 000000000..9f8eb9e05 --- /dev/null +++ b/tests/harness/corpus-costs/8004-optional-element-conversions.ts.json @@ -0,0 +1 @@ +{ "plain": 4882, "san": 6379 } From 2edebe7e2ff7df84903a9236aa147472950b3002 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 17:30:32 -0500 Subject: [PATCH 3/5] Read spread tuple fields before later rest arguments run The packing path for `(T | undefined)[]` rest parameters saved plain arguments to temporaries but read tuple fields only when it built the pack, after every later argument had run. A later argument that wrote the tuple changed values already spread into the call. Each tuple field is now saved where its spread appears, in argument order. --- .../src/frontend/lowering/call-arguments.ts | 14 +++++++++----- .../corpus/8004-optional-element-conversions.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index b02d0b85c..a139bfc64 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -421,12 +421,16 @@ export function completeArgs( ) { const stmts: IrStmt[] = []; const values = restNodes.map((a): IrExpr => { + // A tuple field is read where its spread appears: a later + // argument may write the tuple. + let value: IrExpr; if (isIr(a)) { - if (a.ir.kind !== "seqExpr") return a.ir; - stmts.push(...a.ir.stmts); - return a.ir.result; - } - const value = lowerer.lowerExpr(a as ts.Expression); + value = a.ir; + if (value.kind === "seqExpr") { + stmts.push(...value.stmts); + value = value.result; + } + } else value = lowerer.lowerExpr(a as ts.Expression); const saved = lowerer.declareHiddenLocal("%callArg", value.type); stmts.push({ kind: "varDecl", localId: saved.id, init: value, loc: value.loc }); return { kind: "varRef", localId: saved.id, type: value.type, loc: value.loc }; diff --git a/tests/corpus/8004-optional-element-conversions.ts b/tests/corpus/8004-optional-element-conversions.ts index 2a394c331..07178e9b0 100644 --- a/tests/corpus/8004-optional-element-conversions.ts +++ b/tests/corpus/8004-optional-element-conversions.ts @@ -33,6 +33,22 @@ function label(first: string, ...rest: (string | undefined)[]): string { } const words: [string, string | undefined, string | undefined] = ["a", undefined, "c"]; console.log(label(...words), label("b", undefined)); +// Spread tuple fields are read before later arguments run. +const changing: [boolean, boolean | undefined] = [true, undefined]; +function flip(pair: [boolean, boolean | undefined], second: boolean | undefined): boolean { + pair[0] = !pair[0]; + pair[1] = second; + return pair[0]; +} +console.log(flags(...changing, flip(changing, true))); +console.log(flags(...changing, flip(changing, undefined), ...changing)); +const swapped: [string, string | undefined] = ["x", "y"]; +function swap(pair: [string, string | undefined]): string { + pair[0] = "w"; + pair[1] = undefined; + return "v"; +} +console.log(label("z", ...swapped, swap(swapped), ...swapped)); function pick(start: (number | undefined)[] | string[], other: (number | undefined)[] | string[]): string { let a = start; From 534c08dd2f30fa2ce5611fdbfcce20598a8f8f77 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 04:34:24 -0500 Subject: [PATCH 4/5] Pack spread tuple fields together with other spreads into optional-element rests A call that spread a fixed tuple and also an array, a Set or another iterable into a `(T | undefined)[]` rest parameter took the general rest path, which converts every value to T, so an undefined tuple field threw ("undefined is not representable in the target union"). Such calls now pack every item in argument order through lowerArraySpreadItems, which takes values already lowered (the tuple's fields) beside argument nodes and keeps undefined values as present undefined elements. A tuple element typed only `undefined`, as in `...["b", undefined]`, has a `null | undefined` field. Stored into an array that keeps undefined elements it is now evaluated and stored as undefined, where it was an internal compiler error; the checker admits it there only as undefined. --- .../src/frontend/lowering/call-arguments.ts | 16 ++++-- .../lowering/containers/array-construction.ts | 53 +++++++++++++++++-- .../8004-optional-element-conversions.ts | 9 ++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index a139bfc64..79e891d4f 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -407,10 +407,18 @@ export function completeArgs( // A `(T | undefined)[]` rest (stored as T elements) packs undefined // arguments as present undefined elements. const restNodes = sources.slice(restAt); - if (restType.optionalElements === true && !restNodes.some(isIr)) { - out.push( - lowerArraySpreadItems(lowerer, restNodes as ts.Expression[], restType.elem, restType, loc), - ); + // Plain arguments and spreads, also together with spread tuple fields + // (pre-lowered): each item, in argument order. + if ( + restType.optionalElements === true && + (!restNodes.some(isIr) || restNodes.some((a) => !isIr(a) && ts.isSpreadElement(a))) + ) { + const items: (ts.Expression | IrExpr)[] = []; + for (const a of restNodes) { + if (isIr(a)) items.push(a.ir); + else items.push(a); + } + out.push(lowerArraySpreadItems(lowerer, items, restType.elem, restType, loc)); return out; } // Tuple-spread values (pre-lowered) and plain arguments pack the same diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 25a98c8a6..63e2e6e07 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -4,6 +4,7 @@ import { generatorDrain } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { liftedFreshSpread } from "../coercions/fresh-spread.js"; import { checkedIterableSpread } from "../checked-iterable-spread.js"; +import { AstNode } from "../../ts7/ast-node.js"; import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, @@ -27,7 +28,9 @@ import { type IrType, STRING, type SrcLoc, + UNDEFINED_T, arrayOf, + isUnitType, canConvertToDyn, funcOf, isRefCounted, @@ -57,6 +60,34 @@ import { } from "./callback-arguments.js"; import { classGeneratorIterable } from "../class-iterables.js"; +/** A value whose type admits only null and undefined, stored into an array + * that keeps undefined elements: the checker admits it there only when its + * type is `undefined` (a tuple element typed `undefined` maps to such a + * union), so it is evaluated and stored as undefined. */ +function undefinedForNullish( + lowerer: Lowerer, + value: IrExpr, + arrType: IrType & { kind: "array" }, +): IrExpr { + if (arrType.optionalElements !== true || value.type.kind !== "union") return value; + const arms = lowerer.unions.get(value.type.unionId)?.arms ?? []; + if (arms.length === 0 || !arms.every(isUnitType)) return value; + const loc = value.loc; + const optional: IrType = { + kind: "union", + unionId: lowerer.unions.intern([arrType.elem, UNDEFINED_T]), + }; + const missing = lowerer.wrappedUndefined(optional, loc); + if (!missing) return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: value, loc }], + result: missing, + type: optional, + loc, + }; +} + /** Build the argument array for a mutating/copying operation while keeping * the receiver's scalar payload ABI. A missing read is represented as the * present UNDEFINED state in this temporary, so push/unshift and toSpliced @@ -92,7 +123,8 @@ export function lowerArrayValueItems( loc, }, ]; - for (const value of values) { + for (const item of values) { + const value = undefinedForNullish(lowerer, item, arrType); const stored = lowerer.runtimeOptionalWidening(value.type, elem) !== null || optionalElementValue(lowerer, value, elem) @@ -103,16 +135,21 @@ export function lowerArrayValueItems( return { kind: "seqExpr", stmts: body, result: outRef, type: arrType, loc }; } +/** The items of an array built from argument nodes (a spread copies its + * elements in) and values already lowered (a spread tuple's fields), in + * order. Each value is stored when its turn comes. */ export function lowerArraySpreadItems( lowerer: Lowerer, - nodes: readonly ts.Expression[], + nodes: readonly (ts.Expression | IrExpr)[], elem: IrType, arrType: IrType & { kind: "array" }, loc: SrcLoc, ): IrExpr { const out = lowerer.declareHiddenLocal("%arrayItems", arrType); const outRef = varRef(out.id, arrType, loc); - const plainItems = nodes.filter((node) => !ts.isSpreadElement(node)).length; + const plainItems = nodes.filter( + (node) => !(node instanceof AstNode) || !ts.isSpreadElement(node), + ).length; const length = (): IrExpr => ({ kind: "arrIntrinsic", method: "length", @@ -136,7 +173,15 @@ export function lowerArraySpreadItems( }, ]; for (const node of nodes) { - if (ts.isSpreadElement(node)) { + if (!(node instanceof AstNode)) { + const value = undefinedForNullish(lowerer, node, arrType); + const stored = + lowerer.runtimeOptionalWidening(value.type, elem) !== null || + optionalElementValue(lowerer, value, elem) + ? value + : lowerer.coerceToExpected(value, elem); + body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc)); + } else if (ts.isSpreadElement(node)) { let source = lowerer.lowerExpr(node.expression); if ( source.type.kind === "set" && diff --git a/tests/corpus/8004-optional-element-conversions.ts b/tests/corpus/8004-optional-element-conversions.ts index 07178e9b0..9a4ac55ac 100644 --- a/tests/corpus/8004-optional-element-conversions.ts +++ b/tests/corpus/8004-optional-element-conversions.ts @@ -49,6 +49,15 @@ function swap(pair: [string, string | undefined]): string { return "v"; } console.log(label("z", ...swapped, swap(swapped), ...swapped)); +// Tuple spreads together with array and Set spreads keep undefined +// elements, and every item is read in argument order. +const mixedPair: [boolean, boolean | undefined] = [true, undefined]; +const mixedMore: (boolean | undefined)[] = [false, undefined]; +console.log(flags(...mixedPair, ...mixedMore), flags(...mixedMore, ...mixedPair), flags(true, ...mixedPair, ...mixedMore, undefined)); +const mixedChanging: [boolean, boolean | undefined] = [true, undefined]; +console.log(flags(...mixedChanging, ...mixedMore, flip(mixedChanging, true), ...mixedChanging)); +const mixedWords: [string, string | undefined] = ["a", undefined]; +console.log(label("z", ...["b", undefined, "c"], ...mixedWords), label("y", ...mixedWords, ...new Set(["s", undefined]))); function pick(start: (number | undefined)[] | string[], other: (number | undefined)[] | string[]): string { let a = start; From f8b1f9ffc36ade3f470c4e6d98d7625f5124caff Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 05:02:40 -0500 Subject: [PATCH 5/5] Keep a spread tuple alive until all its fields are packed into the rest A spread tuple's first field carries the statements that evaluate the tuple into a hidden local, and its later fields read that local. When lowerArraySpreadItems stored the first field as one item's expression, the local's frame ended with that store, so a tuple returned by a call was released before its later fields were read (a use-after-free under SCRIPTC_SAN=1). The field's statements now join the packing sequence at the item's position, whose frame lasts until every item is stored. --- .../frontend/lowering/containers/array-construction.ts | 10 +++++++++- tests/corpus/8004-optional-element-conversions.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 63e2e6e07..5029abc36 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -174,7 +174,15 @@ export function lowerArraySpreadItems( ]; for (const node of nodes) { if (!(node instanceof AstNode)) { - const value = undefinedForNullish(lowerer, node, arrType); + // A spread tuple's first field declares the tuple's storage, which + // the later fields read: its statements join this sequence, whose + // frame lasts until every item is stored. + let item = node; + if (item.kind === "seqExpr") { + body.push(...item.stmts); + item = item.result; + } + const value = undefinedForNullish(lowerer, item, arrType); const stored = lowerer.runtimeOptionalWidening(value.type, elem) !== null || optionalElementValue(lowerer, value, elem) diff --git a/tests/corpus/8004-optional-element-conversions.ts b/tests/corpus/8004-optional-element-conversions.ts index 9a4ac55ac..a592a4f32 100644 --- a/tests/corpus/8004-optional-element-conversions.ts +++ b/tests/corpus/8004-optional-element-conversions.ts @@ -58,6 +58,14 @@ const mixedChanging: [boolean, boolean | undefined] = [true, undefined]; console.log(flags(...mixedChanging, ...mixedMore, flip(mixedChanging, true), ...mixedChanging)); const mixedWords: [string, string | undefined] = ["a", undefined]; console.log(label("z", ...["b", undefined, "c"], ...mixedWords), label("y", ...mixedWords, ...new Set(["s", undefined]))); +// A tuple returned by a call keeps its string fields alive until every +// field is packed beside the other spreads. +function makeTuple(n: number): [string, string | undefined, string] { + return ["p" + n.toString(16).repeat(4), n % 2 ? undefined : "q" + n, "r".repeat((n % 7) + 3) + n]; +} +for (let i = 0; i < 3; i++) { + console.log(label("z", ...["m", undefined], ...makeTuple(i), ...mixedWords), label("y", ...makeTuple(i + 10), ...new Set(["s" + i]))); +} function pick(start: (number | undefined)[] | string[], other: (number | undefined)[] | string[]): string { let a = start;