diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index ed52b8da5c..8fbb77bae5 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -3,8 +3,10 @@ import { InternalCompilerError } from "../../errors.js"; import { dynUndefinedExpr, helperLoc, varRef } from "../../ir/build.js"; import { BOOL, + DYN, F64, type IrExpr, + type IrLocal, type IrStmt, type IrType, isIntegerBytesElem, @@ -446,6 +448,167 @@ export function appendElement( ]; } +/** The argument a helper passes in place of an element its callback never + * reads (lowerArrayCallback's `unread`): a constant of the parameter's + * type, so the slot stays unread and an element the type cannot hold + * (stored through a dynamic view) passes. Null for a type without one. */ +export function unreadArgument(lowerer: Lowerer, type: IrType, loc: SrcLoc): IrExpr | null { + if (type.kind === "f64") return { kind: "numLit", value: NaN, type: F64, loc }; + if (type.kind === "bool") return { kind: "boolLit", value: false, type: BOOL, loc }; + if (type.kind === "string") return { kind: "strLit", value: "", type: STRING, loc }; + if (type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0) + return lowerer.wrappedUndefined(type, loc) ?? null; + return null; +} + +/** The locals of an element snapshot (arraySlotSnapshot): whether the slot + * held an element outside the element type, the value read otherwise, and + * that element's dynamic value otherwise. */ +export interface SlotSnapshot { + isForeign: string; + value: string; + foreign: string; +} + +/** The locals a snapshot of an `elem` element uses. */ +export function slotSnapshotLocals(lowerer: Lowerer, elem: IrType, ids: SlotSnapshot): IrLocal[] { + return [ + { id: ids.isForeign, name: "foreign", type: BOOL, mutable: false }, + { id: ids.value, name: "value", type: arrayValueType(lowerer, elem), mutable: false }, + { id: ids.foreign, name: "element", type: DYN, mutable: false }, + ]; +} + +/** Read the element at `arr[index]` as it is stored, before a callback runs, + * as JavaScript reads it before calling a callback that ignores it: a value + * or a present undefined into `ids.value`, or an element outside the element + * type (stored through a dynamic view) as its dynamic value into + * `ids.foreign`, which `ids.isForeign` tells apart. */ +export function arraySlotSnapshot( + lowerer: Lowerer, + arr: IrExpr, + index: IrExpr, + elem: IrType, + ids: SlotSnapshot, + loc: SrcLoc, +): IrStmt[] { + const valueT = arrayValueType(lowerer, elem); + const isForeign: IrExpr = { kind: "varRef", localId: ids.isForeign, type: BOOL, loc }; + const missing = + valueT.kind === "dyn" ? dynUndefinedExpr(loc) : lowerer.wrappedUndefined(valueT, loc)!; + return [ + { + kind: "varDecl", + localId: ids.isForeign, + init: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr, index, type: F64, loc }, + right: { kind: "numLit", value: 3, type: F64, loc }, + type: BOOL, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: ids.value, + init: { + kind: "ternary", + cond: isForeign, + then: missing, + else_: arrayValueRead(lowerer, arr, index, elem, loc), + type: valueT, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: ids.foreign, + init: { + kind: "ternary", + cond: isForeign, + then: { kind: "libCall", fn: "array.foreign", args: [arr, index], type: DYN, loc }, + else_: dynUndefinedExpr(loc), + type: DYN, + loc, + }, + loc, + }, + ]; +} + +/** Append a snapshot (arraySlotSnapshot) of an element to `out`, an array of + * the same element type, as it was stored: an element outside the element + * type stays one, appended as the view's push appends it. `out` is a + * binding. */ +export function arraySnapshotAppend( + lowerer: Lowerer, + out: IrExpr, + elem: IrType, + ids: SlotSnapshot, + loc: SrcLoc, +): IrStmt { + const end: IrExpr = { + kind: "arrIntrinsic", + method: "length", + receiver: out, + args: [], + type: F64, + loc, + }; + const value: IrExpr = { + kind: "varRef", + localId: ids.value, + type: arrayValueType(lowerer, elem), + loc, + }; + return { + kind: "if", + cond: { kind: "varRef", localId: ids.isForeign, type: BOOL, loc }, + then: [ + { + kind: "exprStmt", + expr: { + kind: "dynInvoke", + recv: { kind: "dynFrom", value: out, liveRef: true, type: DYN, loc }, + method: "push", + calleeName: "push", + args: [{ kind: "varRef", localId: ids.foreign, type: DYN, loc }], + type: DYN, + loc, + }, + loc, + }, + ], + else_: [arrayValueStore(lowerer, out, end, value, elem, loc)], + loc, + }; +} + +/** A snapshot (arraySlotSnapshot) as a value of `type`: the value read, or + * the element outside the element type through the checked conversion, + * which such an element fails as a typed read does. `typed` converts the + * value read. */ +export function arraySnapshotValue( + lowerer: Lowerer, + elem: IrType, + ids: SlotSnapshot, + type: IrType, + typed: (value: IrExpr) => IrExpr, + loc: SrcLoc, +): IrExpr { + return { + kind: "ternary", + cond: { kind: "varRef", localId: ids.isForeign, type: BOOL, loc }, + then: lowerer.coerceToExpected({ kind: "varRef", localId: ids.foreign, type: DYN, loc }, type), + else_: typed({ kind: "varRef", localId: ids.value, type: arrayValueType(lowerer, elem), loc }), + type, + loc, + }; +} + /** Store a stabilized value without changing the array's element ABI. */ export function arrayValueStore( lowerer: Lowerer, diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 2c4d5d9b41..497b50a27e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -38,6 +38,9 @@ import { } from "../lower-array-sort.js"; import { arrayIndexPresent, + arraySlotSnapshot, + arraySnapshotAppend, + arraySnapshotValue, arrayValueRead, arrayValueStore, arrayValueType, @@ -45,6 +48,9 @@ import { elementMayBeForeign, foreignElementThrow, primitiveElement, + slotSnapshotLocals, + unreadArgument, + type SlotSnapshot, } from "../array-values.js"; import { typeKey } from "../../type-mapper.js"; import { @@ -101,9 +107,12 @@ export function lowerArrayHofCall( } } const callback = booleanFilter - ? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1 } + ? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false } : lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem)); const { fnArg, arity } = callback; + // Only an array that can hold elements outside its element type needs to + // leave an element its callback ignores unread. + const unread = callback.unread && !present && elementMayBeForeign(lowerer, elem); const fnRet = fnArg.type.ret; if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) { // The result array U[] is unrepresentable here (no void elements; @@ -133,8 +142,9 @@ export function lowerArrayHofCall( : null; const outElem = filtered !== null ? filtered.elem : callbackArrayElement(lowerer, call, fnRet); // A filter that re-tags its elements keeps the single union-typed visit. + // A callback that never reads its element has no use for a typed one. const fast = - present || booleanFilter || (method === "filter" && !typeEquals(outElem, elem)) + present || booleanFilter || unread || (method === "filter" && !typeEquals(outElem, elem)) ? null : lowerFastCallback(lowerer, argNode, [valueT], 0, elem, arrayOf(elem), callback); const helper = arrayHofHelper( @@ -148,6 +158,7 @@ export function lowerArrayHofCall( callbackSite(fast ?? fnArg), fast?.type ?? null, present, + unread, ); const resultType: IrType = method === "map" || method === "filter" ? arrayOf(outElem) : VOID; const result: IrExpr = { @@ -541,12 +552,21 @@ function callbackValueRead( elem: IrType, present: boolean, loc: SrcLoc, + unread = false, ): IrExpr { + if (unread) { + const placeholder = unreadArgument(lowerer, callbackValueType(lowerer, elem, present), loc); + if (placeholder) return placeholder; + } return present ? { kind: "arrayGet", arr, index, type: elem, loc } : arrayValueRead(lowerer, arr, index, elem, loc); } +/** The snapshot locals of the filter and find helpers; `value` is their + * element local `v.0`. */ +const FILTER_SNAPSHOT: SlotSnapshot = { isForeign: "vf.0", value: "v.0", foreign: "vd.0" }; + /** Interned synthetic loop function for one (method, elem, fnRet, arity) * combo. Named `%arr..` ('%' keeps it out of the user * namespace); rides `liftedFns` into the module like a lifted lambda (it @@ -562,13 +582,23 @@ function arrayHofHelper( site = "", fastT: CallbackType | null = null, present = false, + unread = false, ): string { - const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}:${typeKey(outElem)}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`; + const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}:${typeKey(outElem)}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, `%arr.${method}`, key); lowerer.arrHofHelpers.set(key, name); - lowerer.arrHofInfo.set(name, { method, elem, fnRet, arity, outElem, present, split: !!fastT }); + lowerer.arrHofInfo.set(name, { + method, + elem, + fnRet, + arity, + outElem, + present, + split: !!fastT, + unread, + }); const fn = buildArrayHofFn( lowerer, name, @@ -580,6 +610,7 @@ function arrayHofHelper( outElem, fastT, present, + unread, ); lowerer.liftedFns.push(fastT ? withFastCallback(fn, fastT, elem) : fn); return name; @@ -794,6 +825,7 @@ function buildArrayHofFn( outElem: IrType, fastT: CallbackType | null = null, present = false, + unread = false, ): IrFunction { const arrT = arrayOf(elem); const valueT = callbackValueType(lowerer, elem, present); @@ -810,6 +842,19 @@ function buildArrayHofFn( present, loc, ); + // What the callback takes: the element, or a placeholder for one it + // ignores (callbackValueRead's `unread`). + const argElem = unread + ? callbackValueRead( + lowerer, + varRef("a.0", arrT, loc), + varRef("i.0", F64, loc), + elem, + present, + loc, + true, + ) + : getElem; const hasElem: IrExpr = { kind: "arrayHas", arr: varRef("a.0", arrT, loc), @@ -858,7 +903,7 @@ function buildArrayHofFn( kind: "if", cond: hasElem, then: [ - { kind: "varDecl", localId: "mapped.0", init: callF(getElem), loc }, + { kind: "varDecl", localId: "mapped.0", init: callF(argElem), loc }, arrayValueStore( lowerer, varRef("out.0", outT, loc), @@ -994,6 +1039,19 @@ function buildArrayHofFn( ); returnType = outT; const retained = filterRetainedValue(lowerer, varRef("v.0", valueT, loc), elem, outElem, loc); + // A predicate that ignores the element of an array that can hold + // elements outside its element type keeps a snapshot of the slot, read + // before the predicate runs, as stored (arraySlotSnapshot). + const copied = + (arity === 0 || unread) && + !present && + typeEquals(outElem, elem) && + elementMayBeForeign(lowerer, elem); + // `v.0` is declared above; the snapshot adds its other two locals. + if (copied) + locals.push( + ...slotSnapshotLocals(lowerer, elem, FILTER_SNAPSHOT).filter((l) => l.id !== "v.0"), + ); body = [ { kind: "varDecl", @@ -1006,34 +1064,62 @@ function buildArrayHofFn( { kind: "if", cond: hasElem, - then: [ - { kind: "varDecl", localId: "v.0", init: getElem, loc }, - { - kind: "if", - // ToBoolean over the predicate's answer — inert when it already - // returned bool, the per-union helper when it returned a union. - cond: filterCond(callF(varRef("v.0", valueT, loc)), fnRet, loc), - then: [ - arrayValueStore( + // A kept element is appended as it was stored, so one the element + // type cannot hold stays in the result. + then: copied + ? [ + ...arraySlotSnapshot( lowerer, - varRef("out.0", outT, loc), - { - kind: "arrIntrinsic", - method: "length", - receiver: varRef("out.0", outT, loc), - args: [], - type: F64, - loc, - }, - retained, - outElem, + varRef("a.0", arrT, loc), + varRef("i.0", F64, loc), + elem, + FILTER_SNAPSHOT, loc, ), + { + kind: "if", + cond: filterCond(callF(argElem), fnRet, loc), + then: [ + arraySnapshotAppend( + lowerer, + varRef("out.0", outT, loc), + elem, + FILTER_SNAPSHOT, + loc, + ), + ], + else_: null, + loc, + }, + ] + : [ + { kind: "varDecl", localId: "v.0", init: getElem, loc }, + { + kind: "if", + // ToBoolean over the predicate's answer — inert when it already + // returned bool, the per-union helper when it returned a union. + cond: filterCond(callF(varRef("v.0", valueT, loc)), fnRet, loc), + then: [ + arrayValueStore( + lowerer, + varRef("out.0", outT, loc), + { + kind: "arrIntrinsic", + method: "length", + receiver: varRef("out.0", outT, loc), + args: [], + type: F64, + loc, + }, + retained, + outElem, + loc, + ), + ], + else_: null, + loc, + }, ], - else_: null, - loc, - }, - ], else_: null, loc, }, @@ -1048,7 +1134,7 @@ function buildArrayHofFn( { kind: "if", cond: hasElem, - then: [{ kind: "exprStmt", expr: callF(getElem), loc }], + then: [{ kind: "exprStmt", expr: callF(argElem), loc }], else_: null, loc, }, @@ -1088,13 +1174,17 @@ export function lowerArrayFindLikeCall( presentElements(lowerer, call, elem) && ((method !== "find" && method !== "findLast") || (!bindUntyped && lowerer.coercibleValue(elem, lowerer.irTypeOf(call)))); - const { fnArg, arity } = lowerArrayCallback( + const lowered = lowerArrayCallback( lowerer, argNode, [callbackValueType(lowerer, elem, present)], arrT, bindUntyped, ); + const { fnArg, arity } = lowered; + // Only an array that can hold elements outside its element type needs to + // leave an element its callback ignores unread. + const unread = lowered.unread && !present && elementMayBeForeign(lowerer, elem); // JS takes the ToBoolean of the predicate's result — allowed wherever // that ToBoolean has a static answer (bool passes through; f64/string // by value; a truthy-answerable union by its arm — the @@ -1109,18 +1199,19 @@ export function lowerArrayFindLikeCall( method === "findIndex" || method === "findLastIndex" ) { - const fast = present - ? null - : lowerFastCallback( - lowerer, - argNode, - [arrayValueType(lowerer, elem)], - 0, - elem, - arrT, - { fnArg, arity }, - bindUntyped, - ); + const fast = + present || unread + ? null + : lowerFastCallback( + lowerer, + argNode, + [arrayValueType(lowerer, elem)], + 0, + elem, + arrT, + { fnArg, arity }, + bindUntyped, + ); const fastT = fast?.type ?? null; const helper = method === "findIndex" || method === "findLastIndex" @@ -1134,6 +1225,7 @@ export function lowerArrayFindLikeCall( callbackSite(fast ?? fnArg), fastT, present, + unread, ) : someEveryHelper( lowerer, @@ -1145,6 +1237,7 @@ export function lowerArrayFindLikeCall( callbackSite(fast ?? fnArg), fastT, present, + unread, ); return { kind: "call", @@ -1182,6 +1275,7 @@ export function lowerArrayFindLikeCall( retag, callbackSite(fnArg), present, + unread, ); return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc }; } @@ -1275,9 +1369,10 @@ function findHelper( retag: string | null, site = "", present = false, + unread = false, ): string { const method = last ? "findLast" : "find"; - const key = `${method}:${typeKey(elem)}:${typeKey(resultT)}:${typeKey(fnRet)}:${arity}${site}${present ? ":present" : ""}`; + const key = `${method}:${typeKey(elem)}:${typeKey(resultT)}:${typeKey(fnRet)}:${arity}${site}${present ? ":present" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, `%arr.${method}`, key); @@ -1296,28 +1391,62 @@ function findHelper( type: resultT, loc, }; - const visitBody: IrStmt[] = [ - { - kind: "varDecl", - localId: "v.0", - init: callbackValueRead( - lowerer, - varRef("a.0", arrT, loc), - varRef("i.0", F64, loc), - elem, - present, - loc, - ), + const read: IrStmt = { + kind: "varDecl", + localId: "v.0", + init: callbackValueRead( + lowerer, + varRef("a.0", arrT, loc), + varRef("i.0", F64, loc), + elem, + present, loc, - }, - { - kind: "if", - cond: predicateToBoolean(callArrayCallback(arrT, fnT, fnRet, arity, v, loc)), - then: [{ kind: "return", value: found, loc }], - else_: null, - loc, - }, - ]; + ), + loc, + }; + // A predicate that ignores the element of an array that can hold + // elements outside its element type keeps a snapshot of the slot, read + // before the predicate runs as JavaScript reads it (arraySlotSnapshot): + // the predicate is called without reading it as typed, and a found element + // outside the element type takes the checked conversion into the result. + const ignored = (arity === 0 || unread) && !present && elementMayBeForeign(lowerer, elem); + const placeholder = unread ? unreadArgument(lowerer, valueT, loc) : null; + const visitBody: IrStmt[] = ignored + ? [ + ...arraySlotSnapshot( + lowerer, + varRef("a.0", arrT, loc), + varRef("i.0", F64, loc), + elem, + FILTER_SNAPSHOT, + loc, + ), + { + kind: "if", + cond: predicateToBoolean( + callArrayCallback(arrT, fnT, fnRet, arity, placeholder ?? v, loc), + ), + then: [ + { + kind: "return", + value: arraySnapshotValue(lowerer, elem, FILTER_SNAPSHOT, resultT, () => found, loc), + loc, + }, + ], + else_: null, + loc, + }, + ] + : [ + read, + { + kind: "if", + cond: predicateToBoolean(callArrayCallback(arrT, fnT, fnRet, arity, v, loc)), + then: [{ kind: "return", value: found, loc }], + else_: null, + loc, + }, + ]; const visit: IrStmt[] = visitBody; const loop = last ? reverseArrayLoop(loc, visit) @@ -1334,6 +1463,9 @@ function findHelper( locals: [ ...arrayCallbackLocals(arrT, fnT), { id: "v.0", name: "v", type: valueT, mutable: false }, + ...(ignored + ? slotSnapshotLocals(lowerer, elem, FILTER_SNAPSHOT).filter((l) => l.id !== "v.0") + : []), ], body, loc, @@ -1375,9 +1507,10 @@ function findIndexHelper( site = "", fastT: CallbackType | null = null, present = false, + unread = false, ): string { const method = last ? "findLastIndex" : "findIndex"; - const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`; + const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, `%arr.${method}`, key); @@ -1391,6 +1524,7 @@ function findIndexHelper( outElem: elem, present, split: !!fastT, + unread, }); const arrT = arrayOf(elem); const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet); @@ -1411,6 +1545,7 @@ function findIndexHelper( elem, present, loc, + unread, ), loc, ), @@ -1460,8 +1595,9 @@ function someEveryHelper( site = "", fastT: CallbackType | null = null, present = false, + unread = false, ): string { - const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`; + const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, `%arr.${method}`, key); @@ -1474,6 +1610,7 @@ function someEveryHelper( outElem: elem, present, split: !!fastT, + unread, }); const arrT = arrayOf(elem); const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet); @@ -1490,6 +1627,7 @@ function someEveryHelper( elem, present, loc, + unread, ), loc, ), @@ -1553,12 +1691,16 @@ export function lowerArrayFlatMapCall( const argNode = call.arguments.nodes[0]; if (!argNode) lowerer.unsupported("SC1090", call, "this call form"); // tsc-guarded const present = presentElements(lowerer, call, elem); - const { fnArg, arity } = lowerArrayCallback( + const lowered = lowerArrayCallback( lowerer, argNode, [callbackValueType(lowerer, elem, present)], arrT, ); + const { fnArg, arity } = lowered; + // Only an array that can hold elements outside its element type needs to + // leave an element its callback ignores unread. + const unread = lowered.unread && !present && elementMayBeForeign(lowerer, elem); const fnRet = fnArg.type.ret; if ( fnRet.kind === "dyn" || @@ -1603,12 +1745,13 @@ export function lowerArrayFlatMapCall( if (fnRet.kind === "void" || fnRet.kind === "func") lowerer.badType(call, lowerer.typeOf(call)); requireProducedArrayElement(lowerer, call, "'.flatMap()'", fnRet); const outElem = callbackArrayElement(lowerer, call, fnRet); - const fast = present - ? null - : lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, { - fnArg, - arity, - }); + const fast = + present || unread + ? null + : lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, { + fnArg, + arity, + }); const helper = arrayHofHelper( lowerer, "flatMap", @@ -1620,6 +1763,7 @@ export function lowerArrayFlatMapCall( callbackSite(fast ?? fnArg), fast?.type ?? null, present, + unread, ); return { kind: "call", @@ -1629,12 +1773,13 @@ export function lowerArrayFlatMapCall( loc, }; } - const fast = present - ? null - : lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, { - fnArg, - arity, - }); + const fast = + present || unread + ? null + : lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, { + fnArg, + arity, + }); const helper = flatMapHelper( lowerer, elem, @@ -1644,6 +1789,7 @@ export function lowerArrayFlatMapCall( callbackSite(fast ?? fnArg), fast?.type ?? null, present, + unread, ); return { kind: "call", @@ -1943,8 +2089,9 @@ function flatMapHelper( site = "", fastT: CallbackType | null = null, present = false, + unread = false, ): string { - const key = `flatMap:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`; + const key = `flatMap:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%arr.flatMap", key); @@ -1957,6 +2104,7 @@ function flatMapHelper( outElem: fnRet.elem, present, split: !!fastT, + unread, }); const arrT = arrayOf(elem); const inner = fnRet.elem; @@ -2053,6 +2201,7 @@ function flatMapHelper( elem, present, loc, + unread, ), loc, ), @@ -2125,7 +2274,7 @@ export function lowerArrayReduceCall( lowerer.runtimeOptionalReduceTypes.get(call) ?? (hasInit ? lowerer.irTypeOf(call) : arrayValueType(lowerer, elem)); if (accT.kind === "void" || accT.kind === "func") lowerer.badType(call, lowerer.typeOf(call)); - const { fnArg, arity } = lowerArrayCallback( + const lowered = lowerArrayCallback( lowerer, argNode, [accT, arrayValueType(lowerer, elem)], @@ -2133,17 +2282,23 @@ export function lowerArrayReduceCall( false, accT, ); - const fast = lowerFastCallback( - lowerer, - argNode, - [accT, arrayValueType(lowerer, elem)], - 1, - elem, - arrT, - { fnArg, arity }, - false, - accT, - ); + const { fnArg, arity } = lowered; + // Only an array that can hold elements outside its element type needs to + // leave an element its callback ignores unread. + const unread = lowered.unread && elementMayBeForeign(lowerer, elem); + const fast = unread + ? null + : lowerFastCallback( + lowerer, + argNode, + [accT, arrayValueType(lowerer, elem)], + 1, + elem, + arrT, + { fnArg, arity }, + false, + accT, + ); const helper = reduceHelper( lowerer, method, @@ -2155,6 +2310,7 @@ export function lowerArrayReduceCall( loc, callbackSite(fast ?? fnArg), fast?.type ?? null, + unread, ); const args: IrExpr[] = [receiver, fnArg]; if (hasInit) args.push(lowerer.lowerExprExpecting(call.arguments.nodes[1]!, accT)); @@ -2185,8 +2341,9 @@ function reduceHelper( loc: SrcLoc, site = "", fastT: CallbackType | null = null, + unread = false, ): string { - const key = `${method}:${typeKey(elem)}:${typeKey(accT)}:${typeKey(fnRet)}:${arity}:${hasInit ? "init" : "seed"}${site}${fastT ? ":split" : ""}`; + const key = `${method}:${typeKey(elem)}:${typeKey(accT)}:${typeKey(fnRet)}:${arity}:${hasInit ? "init" : "seed"}${site}${fastT ? ":split" : ""}${unread ? ":unread" : ""}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, `%arr.${method}`, key); @@ -2201,6 +2358,7 @@ function reduceHelper( present: false, split: !!fastT, hasInit, + unread, }); const arrT = arrayOf(elem); const fnT = funcOf([accT, arrayValueType(lowerer, elem), F64, arrT].slice(0, arity), fnRet); @@ -2210,6 +2368,9 @@ function reduceHelper( const right = method === "reduceRight"; const at = (index: IrExpr): IrExpr => arrayValueRead(lowerer, varRef("a.0", arrT, loc), index, elem, loc); + // The visited element, or a placeholder for a callback that never reads it. + const placeholder = unread ? unreadArgument(lowerer, arrayValueType(lowerer, elem), loc) : null; + const visited = (): IrExpr => placeholder ?? at(i); const locals: IrLocal[] = [ { id: "a.0", name: "a", type: arrT, mutable: true }, { id: "f.0", name: "f", type: fnT, mutable: true }, @@ -2320,7 +2481,7 @@ function reduceHelper( { kind: "callValue", callee: varRef("f.0", fnT, loc), - args: [varRef("acc.0", accT, loc), at(i), i, varRef("a.0", arrT, loc)].slice( + args: [varRef("acc.0", accT, loc), visited(), i, varRef("a.0", arrT, loc)].slice( 0, arity, ), diff --git a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts index 6307d871f4..5cdbe4d31d 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts @@ -6,6 +6,7 @@ import { AmbientReceiverReaders } from "../../../ir/receiver-readers.js"; import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js"; import { BOOL, + DYN, F64, VOID, type IrBindingSource, @@ -32,9 +33,14 @@ import { import { arrayIndexPresent, arrayValueRead, + arraySlotSnapshot, + arraySnapshotAppend, + arraySnapshotValue, arrayValueStore, arrayValueType, elementMayBeForeign, + unreadArgument, + type SlotSnapshot, } from "../array-values.js"; import type { Lowerer } from "../lowerer.js"; @@ -62,6 +68,9 @@ export interface ArrayHofInfo { present: boolean; split: boolean; hasInit?: boolean; + /** The callback never reads the element it declares + * (lowerArrayCallback's `unread`). */ + unread?: boolean; } type FuncType = IrType & { kind: "func" }; @@ -610,7 +619,13 @@ function chainCallbacks(chain: Chain): Stage[] | null { /** The values a fused loop hands from one stage to the next: a local of a * known type, or (before the first read) the source slot itself. */ -type Flow = { kind: "value"; id: string; type: IrType } | { kind: "slot" }; +/** What reaches a stage: a value, the source slot not yet read, or a + * snapshot of the slot read before an earlier callback that ignored it + * (arraySlotSnapshot). */ +type Flow = + | { kind: "value"; id: string; type: IrType } + | { kind: "slot" } + | { kind: "snapshot"; ids: SlotSnapshot }; interface Plan { chain: Chain; @@ -1595,13 +1610,21 @@ class FusedLoop { stage: Stage, k: number, lead: IrExpr[], - flow: Flow & { kind: "value" }, + flow: Flow, ): { pre: IrStmt[]; value: IrExpr } | null => { const arity = stage.info.arity; - const value = varRef(flow.id, flow.type, loc); const at = lead.length; const pick = (): { arg: IrExpr & { type: FuncType }; value: IrExpr | null } | null => { if (arity <= at) return { arg: stage.slow, value: null }; + // A callback that reads the value reaches it read (`read`); one + // that never reads it takes a placeholder for the unread slot. + if (flow.kind !== "value") { + const placeholder = stage.info.unread + ? unreadArgument(this.lowerer, stage.slow.type.params[at]!, loc) + : null; + return placeholder ? { arg: stage.slow, value: placeholder } : null; + } + const value = varRef(flow.id, flow.type, loc); const fast = stage.fast; if (fast && typeEquals(fast.type.params[at]!, flow.type)) return { arg: fast, value }; const paramT = stage.slow.type.params[at]!; @@ -1630,11 +1653,62 @@ class FusedLoop { return { pre: [...before, ...called.pre], value: called.value }; }; + /** The stage's callback never reads the element at parameter `at`, and + * a placeholder of its type stands for it (unreadArgument). */ + const unreadSlot = (stage: Stage, at: number): boolean => + stage.info.unread === true && + unreadArgument(this.lowerer, stage.slow.type.params[at]!, loc) !== null; + const fail = (): IrStmt[] => { this.failed = true; return []; }; + /** Bring `flow` to the callback of `stage`, whose element parameter is + * at `at`. A slot is read for a callback that reads it. For one that + * ignores it, the slot of an array that can hold elements outside its + * element type is read as stored before the callback runs (a snapshot, + * as JavaScript reads the element first), so such an element passes the + * callback as it is; other arrays read the slot as usual. A snapshot + * reaching a callback that reads it becomes its value, and an element + * outside the element type takes the ordinary read's checked + * conversion there. */ + const reach = ( + stage: Stage, + at: number, + flow: Flow, + rest: (flow: Flow) => IrStmt[], + ): IrStmt[] => { + const reads = stage.info.arity > at && !unreadSlot(stage, at); + if (flow.kind === "slot") { + if (reads || !elementMayBeForeign(this.lowerer, arrT.elem)) + return read(stage, (value) => rest(value)); + const ids: SlotSnapshot = { + isForeign: this.local("sf", BOOL), + value: this.local("sv", arrayValueType(this.lowerer, arrT.elem)), + foreign: this.local("sd", DYN), + }; + return [ + ...arraySlotSnapshot(this.lowerer, a, i, arrT.elem, ids, loc), + ...rest({ kind: "snapshot", ids }), + ]; + } + if (flow.kind === "snapshot" && reads) { + const valueT = arrayValueType(this.lowerer, arrT.elem); + const u = this.local("u", valueT); + return [ + { + kind: "varDecl", + localId: u, + init: arraySnapshotValue(this.lowerer, arrT.elem, flow.ids, valueT, (v) => v, loc), + loc, + }, + ...rest({ kind: "value", id: u, type: valueT }), + ]; + } + return rest(flow); + }; + /** Turn the source slot into a value for the callback of `stage`. */ const read = (stage: Stage, rest: (flow: Flow & { kind: "value" }) => IrStmt[]): IrStmt[] => { if (stage.info.present) { @@ -1690,11 +1764,12 @@ class FusedLoop { }; /** The statements that run one value through stages k.. and the terminal. */ - const visit = (k: number, flow: Flow): IrStmt[] => { + const visit = (k: number, flow: Flow, reached = false): IrStmt[] => { if (k === chain.stages.length) return finish(flow); const stage = chain.stages[k]!; const info = stage.info; - if (flow.kind === "slot") return read(stage, (value) => visit(k, value)); + if (flow.kind !== "value" && !reached) + return reach(stage, 0, flow, (next) => visit(k, next, true)); const called = deliver(stage, k, [], flow); if (!called) return fail(); const { pre, value: result } = called; @@ -1791,11 +1866,23 @@ class FusedLoop { } }; + /** Append a snapshot of the source slot to the collected result: a + * filter whose callback ignored the element keeps it as it was stored + * (arraySnapshotAppend). */ + const copySlot = (flow: Flow & { kind: "snapshot" }): IrStmt[] => { + const outT = resultT as IrType & { kind: "array" }; + if (holes || !typeEquals(outT.elem, arrT.elem)) return fail(); + return [ + arraySnapshotAppend(this.lowerer, varRef(out!, resultT, loc), arrT.elem, flow.ids, loc), + ]; + }; /** The terminal's action for one value. */ - const finish = (flow: Flow): IrStmt[] => { + const finish = (flow: Flow, reached = false): IrStmt[] => { if (terminal.kind === "length") return [increment(count!, loc)]; if (terminal.kind === "collect") { - if (flow.kind !== "value") return fail(); + if (flow.kind === "snapshot") return copySlot(flow); + if (flow.kind === "slot") + return read(chain.stages[chain.stages.length - 1]!, (value) => finish(value)); const last = chain.stages[chain.stages.length - 1]!; const outT = resultT as IrType & { kind: "array" }; if (!typeEquals(outT.elem, last.info.outElem)) return fail(); @@ -1816,7 +1903,9 @@ class FusedLoop { } const stage = terminal.stage; const method = stage.info.method; - if (flow.kind === "slot") return read(stage, (value) => finish(value)); + const lead = method === "reduce" ? 1 : 0; + if (flow.kind !== "value" && !reached) + return reach(stage, lead, flow, (next) => finish(next, true)); if (method === "reduce") { const accT = stage.info.outElem; const called = deliver(stage, chain.stages.length, [varRef(acc!, accT, loc)], flow); diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts index de027e08c5..3b504861a4 100644 --- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts +++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts @@ -53,7 +53,7 @@ export function lowerArrayCallback( arrT: IrType, bindUntyped = false, expectedReturn?: IrType, -): { fnArg: IrExpr & { type: IrType & { kind: "func" } }; arity: number } { +): { fnArg: IrExpr & { type: IrType & { kind: "func" } }; arity: number; unread: boolean } { // `xs.map(((x) => x))`: a parenthesized callback is the function itself. while (ts.isParenthesizedExpression(argNode)) argNode = argNode.expression; // The ambient `Boolean` as the callback (`flags.every(Boolean)`, @@ -72,7 +72,11 @@ export function lowerArrayCallback( if (valueT === undefined || valueT.kind === "void" || isUnitType(valueT)) lowerer.badType(argNode, lowerer.typeOf(argNode)); if (valueT.kind === "union") lowerer.requireTruthyUnion(valueT.unionId, argNode); - return { fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), arity: 1 }; + return { + fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), + arity: 1, + unread: false, + }; } // The ambient `String` (`values.map(String)`): String(value) converts its // first argument as a direct call does, whatever the element type; the @@ -86,7 +90,7 @@ export function lowerArrayCallback( const valueT = lead[0]; if (valueT === undefined || valueT.kind === "void") lowerer.badType(argNode, lowerer.typeOf(argNode)); - return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1 }; + return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1, unread: false }; } const full = [...lead, F64, arrT]; // A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`): @@ -276,9 +280,32 @@ export function lowerArrayCallback( return { fnArg: fnArg as IrExpr & { type: IrType & { kind: "func" } }, arity: fnArg.type.params.length, + unread: !readsParameter(argNode, lead.length - 1), }; } +/** Whether a callback can observe its parameter at `index`: false for a + * function or arrow expression that declares no such parameter, or binds + * it to a plain name that appears nowhere else in it (any identifier of + * that name counts, including shorthand properties and shadowing + * bindings). Any other callback may. */ +function readsParameter(argNode: ts.Expression, index: number): boolean { + if (!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) return true; + const param = argNode.parameters.nodes[index]; + if (!param) return false; + if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return true; + const name = param.name.text; + const mentions = (node: ts.Node): boolean => + (node !== param.name && + ts.isIdentifier(node) && + (node.text === name || node.text === "arguments")) || + (ts.forEachChild(node, (child) => (mentions(child) ? true : undefined)) ?? false); + return ( + argNode.parameters.nodes.some((other) => other !== param && mentions(other)) || + mentions(argNode.body) + ); +} + /** Preserve the checker-selected payload ABI when a callback's runtime * return gained only undefined. The array state byte carries that value. */ export function callbackArrayElement( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8014-foreign-element-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8014-foreign-element-callbacks.ts.json new file mode 100644 index 0000000000..7754e8cb8f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8014-foreign-element-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8014-foreign-element-callbacks.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8014-foreign-element-callbacks.ts b/tests/corpus/8014-foreign-element-callbacks.ts new file mode 100644 index 0000000000..188a3119de --- /dev/null +++ b/tests/corpus/8014-foreign-element-callbacks.ts @@ -0,0 +1,118 @@ +// Callbacks over a number[] or string[] that holds elements written +// through an unknown[] view, when the callback never reads the element: +// it declares no element parameter, or never references the one it +// declares. The methods visit every element as in Node, and filter keeps +// a written element as it is. + +const numbers: number[] = [1, 2]; +const numbersView: unknown[] = numbers; +numbersView.push("x", undefined, { k: 1 }); +numbers.push(5); + +// Inline callbacks (fused loops). +console.log(numbers.filter(() => true)); +console.log(numbers.filter((_, i) => i % 2 === 0)); +console.log(numbers.map(() => 0)); +console.log(numbers.map((_, i) => i * 10)); +console.log(numbers.some(() => false), numbers.every((_v) => true)); +console.log(numbers.findIndex((_, i) => i === 2), numbers.findLastIndex((_, i) => i < 3)); +console.log(numbers.find(() => false), numbers.findLast((_, i) => i === 0)); +console.log(numbers.reduce((s) => s + 1, 0), numbers.reduce((s, v) => s + 1, 100)); +console.log(numbers.reduceRight((s, _v, i) => s + i, 0)); +let visits = 0; +numbers.forEach((_, i) => { + visits += i; +}); +console.log(visits); +console.log(numbers.filter((_, i) => i > 1).map((_, i) => i)); +console.log(numbers.flatMap(() => [7]), numbers.flatMap((_, i) => [i, -i])); +console.log(numbers.filter(() => true).length, typeof (numbers.filter(() => true) as unknown[])[2]); + +// Callback values (shared helpers). +const always = (): boolean => true; +const never = (): boolean => false; +const count = (s: number): number => s + 1; +console.log(numbers.filter(always)); +console.log(numbers.some(never), numbers.every(always), numbers.find(never)); +console.log(numbers.findIndex(never), numbers.reduce(count, 0)); +console.log(numbers.map(always)); + +// A kept written element is the same value in the result. +const kept = numbers.filter(always) as unknown[]; +console.log(kept[2] === "x", kept[3] === undefined, 3 in kept, kept[4], kept.length); + +// string[] the same way. +const words: string[] = ["a", "b"]; +const wordsView: unknown[] = words; +wordsView.push(7, null); +console.log(words.filter(() => true), words.map((_, i) => `#${i}`)); +console.log(words.filter((_w, i) => i !== 1), words.reduce((s) => s + 1, 0)); + +// Holes stay holes and are skipped as in Node. +const sparse: number[] = [1]; +const sparseView: unknown[] = sparse; +sparseView[3] = "z"; +console.log(sparse.filter(() => true), sparse.map((_, i) => i), sparse.findIndex((_, i) => i === 1)); + +// A parameter used only through a shorthand property is still read. +const plain = [1, 2, 3]; +console.log(plain.map((v) => ({ v })), plain.reduce((acc, v) => ({ sum: acc.sum + v, v }), { sum: 0, v: 0 })); +console.log(plain.filter((v) => [{ v }].length > 0 && v > 1)); + +// A callback that ignores its element still receives the element as it was +// before the callback ran: filter keeps it, and find returns it, even when +// the callback overwrites or removes the slot. +const changing: number[] = [1, 2, 3]; +const changingView: unknown[] = changing; +changingView.push("x"); +let at = 0; +console.log( + changing.filter(() => { + changingView[at] = at === 1 ? "w" : 100 + at; + at++; + return true; + }), + changing, +); +const found: number[] = [1, 2]; +(found as unknown[]).push("y"); +let step = 0; +console.log( + found.find(() => { + found[step] = 50; + step++; + return step === 2; + }), + found, +); +const shrinking: number[] = [4, 5]; +(shrinking as unknown[]).push("z", 6); +console.log( + shrinking.findLast(() => { + shrinking.length = 1; + return true; + }), + shrinking.length, +); +const cut: number[] = [7, 8, 9]; +(cut as unknown[]).push("q"); +console.log( + cut.filter((_v, i) => { + if (i === 1) cut.length = 1; + return true; + }), + cut.length, +); + +// Arrays proven to hold no undefined keep their elements in filter results +// when the predicate ignores them. +const dense = [1, 2, 3, 4]; +let calls = 0; +console.log( + dense.filter((_v, i) => { + calls++; + return i % 2 === 0; + }), + calls, + dense.filter((_v, i) => i > 1), +); diff --git a/tests/harness/corpus-costs/8014-foreign-element-callbacks.ts.json b/tests/harness/corpus-costs/8014-foreign-element-callbacks.ts.json new file mode 100644 index 0000000000..981bcbb55b --- /dev/null +++ b/tests/harness/corpus-costs/8014-foreign-element-callbacks.ts.json @@ -0,0 +1 @@ +{ "plain": 5089, "san": 8118 }