diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts index f39c8e86c..9db04e95b 100644 --- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts +++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts @@ -6,7 +6,9 @@ import { varRef } from "../../../ir/build.js"; import { typeKey } from "../../type-mapper.js"; import { BOOL, + DYN, F64, + STRING, type IrExpr, type IrType, type SrcLoc, @@ -69,6 +71,20 @@ export function lowerArrayCallback( if (valueT.kind === "union") lowerer.requireTruthyUnion(valueT.unionId, argNode); return { fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), arity: 1 }; } + // The ambient `String` (`values.map(String)`): String(value) converts its + // first argument as a direct call does, whatever the element type; the + // constructor's own value form takes a string. + if ( + ts.isIdentifier(argNode) && + argNode.text === "String" && + !isJsSourceFile(ts.getSourceFileOfNode(argNode)) && + lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined) + ) { + const valueT = lead[0]; + if (valueT === undefined || valueT.kind === "void") + lowerer.badType(argNode, lowerer.typeOf(argNode)); + return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1 }; + } const full = [...lead, F64, arrT]; // A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`): // the contextual signature types the unannotated param `any` (the @@ -274,6 +290,49 @@ function runtimeOptionalPairVariant(lowerer: Lowerer, expected: IrType, mapped: return lowerer.isRuntimeOptionalRecordVariant(strip(expected), strip(mapped)); } +/** `String` as a one-parameter callback over `valueT`: the conversion a + * direct `String(value)` call lowers to (the runtime's String() for unknown + * values and unions holding a symbol, ToString otherwise), interned per + * value type. */ +function stringConversionCallback( + lowerer: Lowerer, + valueT: IrType, + node: ts.Expression, +): IrExpr & { type: IrType & { kind: "func" } } { + const loc = locOf(node); + const fnT: IrType & { kind: "func" } = { kind: "func", params: [valueT], ret: STRING }; + const key = `mapString:${typeKey(valueT)}`; + let name = lowerer.arrHofHelpers.get(key); + if (!name) { + name = `%arr.mapString.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const value = varRef("v.0", valueT, loc); + const runtimeString = + valueT.kind === "dyn" || + (valueT.kind === "union" && + lowerer.unions.get(valueT.unionId)?.arms.some((arm) => arm.kind === "symbol") === true && + lowerer.dynConvertible(valueT)); + const converted: IrExpr = runtimeString + ? { + kind: "libCall", + fn: "dyn.stringConstructor", + args: [lowerer.coerceToExpected(value, DYN)], + type: STRING, + loc, + } + : lowerer.ensureString(value, node); + lowerer.liftedFns.push({ + name, + params: [{ localId: "v.0", name: "v", type: valueT }], + returnType: STRING, + locals: [{ id: "v.0", name: "v", type: valueT, mutable: false }], + body: [{ kind: "return", value: converted, loc }], + loc, + }); + } + return { kind: "closure", fnName: name, captures: [], type: fnT, loc }; +} + /** A native closure for the ambient Boolean callback. Reifying the * JavaScript constructor itself is unnecessary; Array#filter only observes * its one-argument ToBoolean result. */ diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 00eae1041..8f119504c 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -184,6 +184,21 @@ export function lowerArrayLiteral( if (arms.length === 1) mapped = arms[0]!; } } + // A NON-EMPTY literal whose own type is an array, under a union with + // exactly one array arm and no tuple arm (`[1, { n: true }]` in a + // `Json[]` arm of a recursive `Json`): the checker types each element + // from the arm, so its object literals lower as the arm's elements, + // and the literal's own element type (`number | { n: boolean }`) + // cannot hold them. Build it as the arm; its value only flows into the + // union slot. + if (ctxUnion && expr.elements.nodes.length > 0 && mapped?.kind === "array") { + const def = lowerer.unions.get(ctxUnion.unionId); + const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; + const tupleArms = + def?.arms.filter((a) => a.kind === "record" && !!lowerer.shapes.get(a.shapeId)?.tuple) ?? + []; + if (arrayArms.length === 1 && tupleArms.length === 0) mapped = arrayArms[0]!; + } } // A Date's toJSON() element keeps its null in a literal with no declared // destination: the lib types say string. diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 16cca3b13..1f74cea42 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -11,6 +11,7 @@ import { DYN, F64, type IrExpr, + type IrLocal, type IrType, STRING, type SrcLoc, @@ -226,7 +227,10 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (use?.kind === "element") { const key = runtimeOptionalElementKey(use.access.argumentExpression); if (key === null) { - return varRef(local.id, local.type, loc); + return ( + runtimeOptionalKeyedRecord(lowerer, expr, local, loc) ?? + varRef(local.id, local.type, loc) + ); } return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc); } @@ -938,10 +942,43 @@ function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string { : sf.fileName; } -/** Object.keys/Object.entries require an object receiver, but their first - * argument is not syntactically a member receiver. Recognize that one - * standard-library position so a runtime-optional loop element can be - * checked against the checker-narrowed record arm before builtin lowering. */ +/** A computed-key access (`value[key]` after `typeof value === "object"`) + * of a runtime-optional binding the checker narrowed to one record arm of a + * wider union. The binding re-tags to that record or undefined, so the keyed + * read or write evaluates its operands and then throws Node's TypeError for + * an undefined receiver. The other arms, which the checker excluded, fail + * the checked re-tag. Null when the binding stores only the record and + * undefined, or the narrowed record is not one of its arms. */ +function runtimeOptionalKeyedRecord( + lowerer: Lowerer, + expr: ts.Identifier, + local: IrLocal, + loc: SrcLoc, +): IrExpr | null { + if (local.type.kind !== "union") return null; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (narrowed?.kind !== "record") return null; + const fromId = local.type.unionId; + const arms = lowerer.unions.get(fromId)?.arms; + const recordTag = lowerer.armTag(fromId, narrowed); + const undefTag = lowerer.armTag(fromId, UNDEFINED_T); + if (!arms || arms.length <= 2 || recordTag < 0 || undefTag < 0) return null; + const target = lowerer.withUndefinedArm(narrowed); + if (target.kind !== "union") return null; + const trappable = new Set(); + for (let tag = 0; tag < arms.length; tag++) { + if (tag !== recordTag && tag !== undefTag) trappable.add(tag); + } + const helper = lowerer.unionRetagHelper(fromId, target.unionId, loc, trappable); + if (!helper) return null; + return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), target, loc); +} + +/** Object.keys, Object.values and Object.entries require an object + * receiver, but their first argument is not syntactically a member + * receiver. Recognize that one standard-library position so a + * runtime-optional loop element can be checked against the checker-narrowed + * record arm before builtin lowering. */ function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean { let arg = expr; while ( @@ -962,7 +999,9 @@ function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): return false; const access = call.expression; return ( - (access.name.text === "keys" || access.name.text === "entries") && + (access.name.text === "keys" || + access.name.text === "values" || + access.name.text === "entries") && lowerer.isStdlibMember(access) ); } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c5e34306c..46f98871e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -173,6 +173,7 @@ import { stringWrapperToString, symbolFieldInfo, templateRawTextOf, + primitiveCtorClosure, } from "./lower-exprs.js"; import { isSafeToDiscard } from "./expressions/evaluation-safety.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; @@ -4343,7 +4344,25 @@ export function lowerDynDispatchMethodCall( } } } + // `String`, `Number` or `Boolean` as the callback of an array method + // (`values.map(String)` over an unknown array): the method calls it with + // each element, whatever its type, and cannot observe which function + // value it received, so it gets the constructor's form that converts any + // argument rather than the string-input form typed code stores. + const arrayCallbackReceiver = + ARRAY_CALLBACK_METHODS.has(method) && + lowerer.checker.isArrayType(lowerer.typeOf(access.expression)); const args = call.arguments.nodes.map((arg, i) => { + const primitive = + i === 0 && arrayCallbackReceiver && ts.isIdentifier(arg) + ? stdlibGlobalNameOf(lowerer, arg) + : null; + if (primitive === "String" || primitive === "Number" || primitive === "Boolean") + return lowerer.coerceInto( + arg, + primitiveCtorClosure(lowerer, primitive, locOf(arg), true), + DYN, + ); if (i === 0 && predicate) return lowerer.coerceInto(arg, predicate, DYN); const undefinedArg = lowerStaticallyUndefinedArgument(lowerer, arg); return undefinedArg @@ -4361,6 +4380,21 @@ export function lowerDynDispatchMethodCall( }; } +/** Array.prototype methods whose first argument is a callback they call + * with the elements. */ +const ARRAY_CALLBACK_METHODS = new Set([ + "map", + "filter", + "forEach", + "some", + "every", + "find", + "findIndex", + "findLast", + "findLastIndex", + "flatMap", +]); + /** Every String.prototype method name a typed or dyn string lowering * answers. */ function isStringMethodName(name: string): boolean { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index ba96bea8c..b5acaf9e6 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -3057,8 +3057,10 @@ export function lowerFieldCompound( * returns the zero-capture closure over it. Typed sources use the mapped * `(value: string) => primitive` signature; a recognized JavaScript * concise-arrow selector (`() => String`, the picocolors fallback) takes - * dyn so its ordinary coercion signature survives inference residue. The - * dyn bodies run exact ToString/ToNumber/ToBoolean, including object hooks. + * dyn so its ordinary coercion signature survives inference residue, and a + * boxed constructor value takes dyn too (coerceToExpected). The dyn bodies + * run what the constructors do for any argument: String() (a symbol's + * description form included), ToNumber and ToBoolean, with object hooks. * Interning makes every reference under one ABI the SAME immortal closure, * so `opt.type === String` compares like JS function identity. */ export function primitiveCtorClosure( @@ -3081,7 +3083,7 @@ export function primitiveCtorClosure( ? name === "BigInt" ? { kind: "libCall", fn: "dyn.bigintConstructor", args: [v], type: BIGINT_T, loc } : name === "String" - ? { kind: "libCall", fn: "dyn.toStringCoerce", args: [v], type: STRING, loc } + ? { kind: "libCall", fn: "dyn.stringConstructor", args: [v], type: STRING, loc } : name === "Number" ? { kind: "libCall", fn: "dyn.numberConstructor", args: [v], type: F64, loc } : { kind: "dynTest", test: "truthy", value: v, type: BOOL, loc } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-map-primitive-constructors.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-map-primitive-constructors.ts.json new file mode 100644 index 000000000..0bf7b0871 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-map-primitive-constructors.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6826-map-primitive-constructors.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6827-recursive-union-nested-literals.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6827-recursive-union-nested-literals.ts.json new file mode 100644 index 000000000..6e11aa685 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6827-recursive-union-nested-literals.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6827-recursive-union-nested-literals.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6828-recursive-union-object-walkers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6828-recursive-union-object-walkers.ts.json new file mode 100644 index 000000000..65c965b9e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6828-recursive-union-object-walkers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6828-recursive-union-object-walkers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6826-map-primitive-constructors.ts b/tests/corpus/6826-map-primitive-constructors.ts new file mode 100644 index 000000000..f6d61bd48 --- /dev/null +++ b/tests/corpus/6826-map-primitive-constructors.ts @@ -0,0 +1,25 @@ +// The String, Number and Boolean constructors passed as callbacks: over +// unknown arrays they convert any element, over typed arrays String converts +// each element as String(value) does, and the boxed values keep identity. +const values: unknown[] = [1, "b", true, null, undefined, Symbol("s"), [1, 2], { a: 1 }, 2n]; +console.log(values.map(String).join("|")); +console.log(values.slice(0, 5).map(Number), values.map(Boolean)); + +function joinAll(...parts: unknown[]): string { + return parts.map(String).join(","); +} +console.log(joinAll(1, "a", false, null)); + +const numbers = [1, 2.5, -0, NaN, Infinity]; +console.log(numbers.map(String)); +const mixed: (number | string | boolean)[] = [1, "x", false]; +console.log(mixed.map(String)); +const symbols: (symbol | string)[] = [Symbol("q"), "r", Symbol()]; +console.log(symbols.map(String)); +console.log([10n, -3n].map(String)); +console.log(["1", " 2 ", "x", ""].map(Number), ["", "0", "a"].map(Boolean)); + +const boxed: unknown = String; +const table: { [name: string]: unknown } = { s: String, n: Number, b: Boolean }; +console.log(boxed === String, table.n === Number, table.b === String, typeof boxed); +console.log(values.filter(Boolean).length, values.some(Boolean), numbers.every(Boolean)); diff --git a/tests/corpus/6827-recursive-union-nested-literals.ts b/tests/corpus/6827-recursive-union-nested-literals.ts new file mode 100644 index 000000000..900bbf977 --- /dev/null +++ b/tests/corpus/6827-recursive-union-nested-literals.ts @@ -0,0 +1,29 @@ +// A recursive JSON-like union with an array arm and an index-signature +// record arm: nested literals mix records into arrays and arrays into +// records at every depth. +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +function show(value: Json): string { + return JSON.stringify(value); +} + +const config: Json = { list: [1, "two", null, { nested: [true, { deeper: [] }] }], flag: false }; +console.log(show(config)); +console.log(show({ list: [{ n: true }] })); +console.log(show([1, { k: null }, [{ a: [2] }]])); +console.log(show({ a: { b: [{ c: "x" }, 3] } })); + +const rows: Json[] = [{ id: 1, tags: ["a", { t: 2 }] }, [null, { x: [] }]]; +console.log(rows.map(show).join(" | ")); + +let value: Json = []; +value = { replaced: [1, { again: [true] }] }; +console.log(show(value)); + +const doc: { [key: string]: Json } = { items: [{ name: "x", parts: [1, { p: "q" }] }] }; +console.log(show(doc)); +const items = doc["items"]; +if (Array.isArray(items)) { + items.push({ added: [9] }); + console.log(items.length, show(items)); +} diff --git a/tests/corpus/6828-recursive-union-object-walkers.ts b/tests/corpus/6828-recursive-union-object-walkers.ts new file mode 100644 index 000000000..5c854eb94 --- /dev/null +++ b/tests/corpus/6828-recursive-union-object-walkers.ts @@ -0,0 +1,83 @@ +// Recursive walkers over a JSON-like union: the array branch calls the +// walker for each element, and the record branch reads the narrowed record +// with Object.values, Object.keys and Object.entries. +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +function depth(value: Json): number { + if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(depth)); + if (value !== null && typeof value === "object") return 1 + Math.max(0, ...Object.values(value).map(depth)); + return 0; +} + +function count(value: Json): number { + let total = 1; + if (Array.isArray(value)) { + for (const item of value) total += count(item); + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) total += count(item); + } + return total; +} + +function paths(value: Json, prefix: string): string[] { + if (Array.isArray(value)) return value.flatMap((item, index) => paths(item, `${prefix}[${index}]`)); + if (value !== null && typeof value === "object") { + const out: string[] = []; + for (const key of Object.keys(value)) out.push(...paths(value[key]!, `${prefix}.${key}`)); + for (const [key, item] of Object.entries(value)) if (item === null) out.push(`${prefix}.${key}=null`); + return out; + } + return [`${prefix}=${String(value)}`]; +} + +const doc: Json = JSON.parse('{"a":[1,{"b":[true,null]}],"c":{"d":"x"},"e":[]}'); +console.log(depth(doc), count(doc)); +console.log(paths(doc, "$").join(" ")); +console.log(depth(3), count([[[]]]), paths([], "$")); + +// A computed-key read or write in a branch that an array hole reaches throws +// Node's TypeError after the key (and, for a write, the value) evaluates. +type Bag = number | string | number[] | { [key: string]: number }; +const keys: string[] = []; +function keyOf(k: string): string { + keys.push(k); + return k; +} +function isBag(value: Bag): value is { [key: string]: number } { + return !Array.isArray(value) && typeof value !== "string" && typeof value !== "number"; +} +function bump(value: Bag, k: string): string { + if (isBag(value)) { + value[keyOf(k)] = (value[keyOf(k)] ?? 0) + 1; + return String(value[keyOf(k)]); + } + return "-"; +} +function put(value: Bag, k: string): string { + if (isBag(value)) { + value[keyOf(k)] = keys.length; + return "put"; + } + return "-"; +} +function peek(value: Bag, k: string): string { + if (isBag(value)) return typeof value[keyOf(k)]; + return "-"; +} +const bags: Bag[] = [{ a: 1 }, "s", [1]]; +bags.length = 4; +for (const which of ["bump", "put", "peek"]) { + const out: string[] = []; + for (let i = 0; i < bags.length; i++) { + try { + const k = "a" + which.length; + if (which === "bump") out.push(bump(bags[i], k)); + else if (which === "put") out.push(put(bags[i], k)); + else out.push(peek(bags[i], k)); + } catch (e) { + out.push(e instanceof TypeError ? e.message : "other"); + } + } + console.log(out.join(" | ")); +} +console.log(keys.join(","), JSON.stringify(bags)); diff --git a/tests/harness/corpus-costs/6826-map-primitive-constructors.ts.json b/tests/harness/corpus-costs/6826-map-primitive-constructors.ts.json new file mode 100644 index 000000000..d2c795829 --- /dev/null +++ b/tests/harness/corpus-costs/6826-map-primitive-constructors.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 3500 } diff --git a/tests/harness/corpus-costs/6827-recursive-union-nested-literals.ts.json b/tests/harness/corpus-costs/6827-recursive-union-nested-literals.ts.json new file mode 100644 index 000000000..f44e89d4f --- /dev/null +++ b/tests/harness/corpus-costs/6827-recursive-union-nested-literals.ts.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3400 } diff --git a/tests/harness/corpus-costs/6828-recursive-union-object-walkers.ts.json b/tests/harness/corpus-costs/6828-recursive-union-object-walkers.ts.json new file mode 100644 index 000000000..77ddc81f8 --- /dev/null +++ b/tests/harness/corpus-costs/6828-recursive-union-object-walkers.ts.json @@ -0,0 +1 @@ +{ "plain": 4200, "san": 5800 }