Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
lowerArrayFindLikeCall,
lowerArrayFlatMapCall,
lowerArrayReduceCall,
lowerTuplePredicateCall,
} from "./array-callbacks.js";
import {
lowerArraySpreadItems,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/tuple-predicate-methods.ts"
],
"diags": []
}
57 changes: 57 additions & 0 deletions packages/compiler/test/tuple-predicate-methods.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
Loading