diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index b1e6aa98f4..7859b95b09 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -2580,3 +2580,185 @@ function buildDynFilterFn(name: string, elemT: IrType, arity: number, loc: SrcLo loc, }; } + +export function lowerTuplePredicateCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + receiver: IrExpr, + elem: IrType, +): IrExpr { + const method = access.name.text; + const loc = locOf(call); + const argNode = call.arguments[0]!; + const shape = receiver.type.kind === "record" ? lowerer.shapes.get(receiver.type.shapeId)! : null; + const fields = [...(shape?.fields ?? [])].sort((a, b) => Number(a.name) - Number(b.name)); + if (call.arguments.length !== 1 || call.arguments.some(ts.isSpreadElement)) + lowerer.noLowering(`tuple .${method} with spread or extra arguments`, call); + const signatures = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode)); + if (signatures.some((signature) => signature.getParameters().length > 2)) + lowerer.noLowering( + `tuple .${method} callback array parameter`, + argNode, + "capture the original tuple explicitly to preserve its identity", + ); + const booleanFilter = + method === "filter" && + ts.isIdentifier(argNode) && + argNode.text === "Boolean" && + lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined); + const { fnArg, arity } = booleanFilter + ? { fnArg: truthyFilterCallback(lowerer, elem, loc), arity: 1 } + : lowerArrayCallback(lowerer, argNode, [elem], receiver.type); + if (!filterPredicateOk(lowerer, fnArg.type.ret)) + lowerer.unsupported( + "SC1090", + argNode, + `tuple .${method} with a predicate whose return value has no truthiness representation`, + ); + const outElem = + method === "filter" + ? fields.length + ? filterResultElem(lowerer, call, argNode, elem, elem, booleanFilter) + : elem + : elem; + if (method === "filter") requireProducedArrayElement(lowerer, call, "'.filter()'", outElem); + const resultType = + method === "filter" + ? arrayOf(outElem) + : method === "some" + ? BOOL + : method === "findIndex" + ? F64 + : fields.length + ? lowerer.irTypeOf(call) + : arrayValueType(lowerer, elem); + let findRetag: string | null = null; + const valueType = arrayValueType(lowerer, elem); + if (method === "find" && fields.length > 0 && !lowerer.coercibleValue(valueType, resultType)) { + const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); + const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; + const predicate = signature + ? lowerer.checker.getTypePredicateOfSignature(signature) + : undefined; + if (!inline || argNode.type || !predicate || predicate.parameterIndex !== 0) + lowerer.unsupported( + "SC1090", + argNode, + "tuple .find narrowing requires an inline callback with an inferred predicate", + ); + if (valueType.kind === "union" && resultType.kind === "union") + findRetag = lowerer.narrowedRetagHelper(call, valueType.unionId, resultType.unionId, loc); + if (!findRetag) + lowerer.unsupported("SC1090", call, "tuple .find narrowing to an incompatible result layout"); + } + const key = `tuple.${method}:${typeKey(receiver.type)}:${typeKey(fnArg.type)}:${typeKey(resultType)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%tuple.${method}.${lowerer.arrHofHelpers.size}`; + const tuple = varRef("a.0", receiver.type, loc); + const value = varRef("v.0", elem, loc); + const out = varRef("out.0", resultType, loc); + const locals: IrLocal[] = [ + { id: "a.0", name: "a", type: receiver.type, mutable: false }, + { id: "f.0", name: "f", type: fnArg.type, mutable: false }, + { id: "v.0", name: "v", type: elem, mutable: true }, + ]; + let retained: IrExpr = value; + if (method === "filter" && !typeEquals(outElem, elem)) { + const classView = + outElem.kind === "object" ? checkedClassAssertion(lowerer, value, outElem, loc) : null; + retained = classView ?? lowerer.coerceToExpected(value, outElem); + } + if (method === "find" && fields.length > 0) { + retained = findRetag + ? { + kind: "call", + callee: findRetag, + args: [lowerer.coerceToExpected(value, valueType)], + type: resultType, + loc, + } + : lowerer.coerceToExpected(value, resultType); + } + const body: IrStmt[] = []; + if (method === "filter") { + locals.push({ id: "out.0", name: "out", type: resultType, mutable: false }); + body.push({ + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: resultType, loc }, + loc, + }); + } + for (const field of fields) { + const read: IrExpr = { + kind: "recordGet", + obj: tuple, + shapeId: shape!.id, + field: field.name, + type: field.type, + loc, + }; + const init = lowerer.coerceToExpected(read, elem); + body.push( + field === fields[0] + ? { kind: "varDecl", localId: "v.0", init, loc } + : { kind: "assign", localId: "v.0", value: init, loc }, + ); + const invoke: IrExpr = { + kind: "callValue", + callee: varRef("f.0", fnArg.type, loc), + args: [value, numLit(Number(field.name), loc)].slice(0, arity), + type: fnArg.type.ret, + loc, + }; + const found: IrStmt = + method === "filter" + ? arrayValueStore( + lowerer, + out, + { kind: "arrIntrinsic", method: "length", receiver: out, args: [], type: F64, loc }, + retained, + outElem, + loc, + ) + : { + kind: "return", + value: + method === "find" + ? retained + : method === "some" + ? boolLit(true, loc) + : numLit(Number(field.name), loc), + loc, + }; + body.push({ kind: "if", cond: predicateToBoolean(invoke), then: [found], else_: null, loc }); + } + const miss = + method === "filter" + ? out + : method === "some" + ? boolLit(false, loc) + : method === "findIndex" + ? numLit(-1, loc) + : lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + resultType, + ); + body.push({ kind: "return", value: miss, loc }); + lowerer.arrHofHelpers.set(key, helper); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "a.0", name: "a", type: receiver.type }, + { localId: "f.0", name: "f", type: fnArg.type }, + ], + returnType: resultType, + locals, + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultType, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index ea821f0068..6ae5427240 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -46,6 +46,7 @@ import { lowerArrayFindLikeCall, lowerArrayFlatMapCall, lowerArrayReduceCall, + lowerTuplePredicateCall, } from "./array-callbacks.js"; import { lowerArraySpreadItems, @@ -402,6 +403,14 @@ export function lowerArrayMethodCall( if (receiver.type.kind === "record" && lowerer.shapes.get(receiver.type.shapeId)?.tuple) { return lowerTupleReadMethodCall(lowerer, call, access, receiver); } + const checkerType = lowerer.typeOf(access.expression); + if ( + receiver.type.kind === "array" && + ["filter", "find", "findIndex", "some"].includes(name) && + lowerer.checker.isTupleType(checkerType) && + lowerer.checker.getTypeArguments(checkerType as ts.TypeReference).length === 0 + ) + return lowerTuplePredicateCall(lowerer, call, access, receiver, F64); // An array-mapped CHECKER type whose VALUE lives in the checked-dynamic tree // (`Object.keys(dynObj).sort()` — the key-walk answers a dyn array): @@ -1847,11 +1856,13 @@ export function lowerTupleReadMethodCall( if (lowerer.chainBlocked(access, call)) return null; const method = access.name.text; const search = method === "includes" || method === "indexOf" || method === "lastIndexOf"; + const predicate = ["filter", "find", "findIndex", "some"].includes(method); if ( method !== "slice" && method !== "map" && method !== "flatMap" && method !== "join" && + !predicate && !search ) return null; @@ -1892,9 +1903,14 @@ export function lowerTupleReadMethodCall( field.type.kind === "union" ? lowerer.unions.get(field.type.unionId)!.arms : [field.type]; for (const type of types) if (!arms.some((arm) => typeEquals(arm, type))) arms.push(type); } - if (arms.length === 0) return null; + if (predicate) arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + if (arms.length === 0 && !predicate) return null; const elem: IrType = - arms.length === 1 ? arms[0]! : { kind: "union", unionId: lowerer.unions.intern(arms) }; + arms.length === 0 + ? F64 + : arms.length === 1 + ? arms[0]! + : { kind: "union", unionId: lowerer.unions.intern(arms) }; const loc = locOf(call); const tuple = varRef("a.0", receiver.type, loc); const read = (field: (typeof fields)[number]): IrExpr => @@ -2031,6 +2047,7 @@ export function lowerTupleReadMethodCall( } return { kind: "call", callee: helper, args: [receiver, ...args], type: resultType, loc }; } + if (predicate) return lowerTuplePredicateCall(lowerer, call, access, receiver, elem); if (call.arguments.length !== 1 || call.arguments.some(ts.isSpreadElement)) { lowerer.noLowering( `.${method} with ${call.arguments.length} arguments`, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-predicate-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-predicate-methods.ts.json new file mode 100644 index 0000000000..c11528ea6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-predicate-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tuple-predicate-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/tuple-predicate-methods.test.ts b/packages/compiler/test/tuple-predicate-methods.test.ts new file mode 100644 index 0000000000..8ec9228c2d --- /dev/null +++ b/packages/compiler/test/tuple-predicate-methods.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function compileSource(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-tuple-predicates-")); + const entry = join(directory, "main.ts"); + writeFileSync(entry, source); + try { + return await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test.each(["filter", "find", "findIndex", "some"])( + "tuple .%s keeps callback identity and thisArg boundaries explicit", + async (method) => { + for (const [invocation, code] of [ + [`${method}((value, index, array) => array[index] === value)`, "SC2020"], + [`${method}(() => true, {})`, "SC2020"], + [`${method}(() => {})`, "SC1090"], + ]) { + const result = await compileSource( + `const tuple = [1, 2] as const; console.log(tuple.${invocation});`, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected tuple predicate refusal"); + expect( + result.diagnostics.some((diagnostic) => diagnostic.code === code), + JSON.stringify(result.diagnostics), + ).toBe(true); + expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC9001")).toBe(false); + } + }, +); + +test.each(["filter", "find"])( + "tuple .%s rejects asserted narrowing even after an inferred helper was cached", + async (method) => { + const result = await compileSource(` +const tuple: [number, string] = [1, "two"]; +console.log(tuple.${method}(value => typeof value === "string")); +console.log(tuple.${method}((value): value is string => true)); +`); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected asserted narrowing refusal"); + expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC1090")).toBe(true); + expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC9001")).toBe(false); + }, +); diff --git a/tests/corpus/tuple-predicate-methods.ts b/tests/corpus/tuple-predicate-methods.ts new file mode 100644 index 0000000000..f9f8aa579f --- /dev/null +++ b/tests/corpus/tuple-predicate-methods.ts @@ -0,0 +1,103 @@ +export {}; + +const tools = ["read", "write", "search"] as const; +const allowed: string[] = ["read", "search"]; +console.log(JSON.stringify(tools.filter(name => allowed.includes(name)))); +console.log(tools.find(name => name === "write"), tools.findIndex(name => name === "search")); +console.log(tools.some(name => name.length === 4), tools.some(name => name.length === 20)); +console.log(tools.find(() => false), tools.findIndex(() => false)); + +const mixed: readonly [number, string, boolean] = [7, "eight", true]; +console.log(JSON.stringify(mixed.filter(value => typeof value === "string"))); +console.log(mixed.find(value => typeof value === "string")); +console.log(mixed.findIndex(value => typeof value === "boolean")); +console.log(mixed.some(value => typeof value === "number")); + +const values: [number, number, number] = [1, 2, 3]; +console.log(JSON.stringify(values.filter((value, index) => { + if (index === 0) { + values[0] = 10; + values[1] = 20; + } + return index !== 2; +}))); +let visited = ""; +console.log(values.find((value, index) => { + visited += String(index); + if (index === 0) values[1] = 30; + if (index === 1) values[1] = 40; + return index === 1; +}), visited); +visited = ""; +console.log(values.findIndex((value, index) => { + visited += String(index); + return value === 40; +}), visited); +visited = ""; +console.log(values.some((value, index) => { + visited += String(index); + return value; +}), visited); + +let evaluation = ""; +function receiver(): [number, number] { evaluation += "R"; return [4, 5]; } +function callback(): (value: number, index: number) => boolean { + evaluation += "C"; + return (value, index) => { evaluation += String(index); return value > 0; }; +} +console.log(JSON.stringify(receiver().filter(callback())), evaluation); + +let owner: [number, number] = [50, 60]; +const previous = owner; +function replaceBinding(): (value: number) => boolean { + owner = [70, 80]; + previous[1] = 90; + return value => value > 0; +} +console.log(JSON.stringify(owner.filter(replaceBinding())), owner[0]); + +const object = { count: 1 }; +const objects: [typeof object, typeof object] = [object, object]; +const selected = objects.filter(() => true); +console.log(selected[0] === object, objects.find(() => true) === object); +object.count = 2; +console.log(selected[1]!.count); + +const optional: [number | undefined, number] = [undefined, 9]; +console.log(JSON.stringify(optional.filter(value => value !== undefined))); +console.log(optional.find(value => value !== undefined)); +console.log(JSON.stringify(optional.filter(Boolean))); + +const empty: [] = []; +let calls = 0; +console.log(JSON.stringify(empty.filter(() => { calls++; return true; }))); +console.log(empty.find(() => { calls++; return true; })); +console.log(empty.findIndex(() => { calls++; return true; })); +console.log(empty.some(() => { calls++; return true; }), calls); + +try { + tools.filter((value, index) => { + if (index === 1) throw new Error(value); + return true; + }); +} catch (error) { if (error instanceof Error) console.log(error.message); } + +function generic(value: T): T[] { + const pair: [T, T] = [value, value]; + return pair.filter((_, index) => index === 1); +} +console.log(JSON.stringify(generic(12)), JSON.stringify(generic("thirteen"))); + +const quests = [ + { id: "spring", season: 0, count: 1 }, + { id: "summer", season: 1, count: 2 }, +] as const; +const foundQuest = quests.find(quest => quest.season === 1); +console.log(foundQuest === undefined ? "missing" : JSON.stringify(foundQuest)); +console.log(JSON.stringify(quests.filter(quest => quest.count > 0))); +console.log(quests.findIndex(quest => quest.id === "spring")); + +const truthy: [number, string, boolean, null, undefined] = [0, "text", false, null, undefined]; +console.log(JSON.stringify(truthy.filter(Boolean))); +console.log(truthy.find(value => value), truthy.findIndex(value => value)); +console.log(truthy.some(value => value));