diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index c7b80b71d..9a91db0ca 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -176,6 +176,10 @@ export interface DynHost extends WalkerHost { dynPromiseAdapter(t: IrType): string; isErrorClass(className: string): boolean; classSubtypes(className: string): readonly string[]; + /** The strict subclasses whose class-value constructor takes the same + * arguments as `className`'s (classValueAbiEquals): the class values a + * checked `classval` slot accepts besides its own class. */ + classValueSubtypes(className: string): readonly string[]; /** emitter.ts's pendingTestLines: the inline pending-exception test. */ pendingTestLines(dest: string): string[]; } @@ -1810,6 +1814,26 @@ export class LlDyn { const B = new BlockBuilder(); if (t.kind === "classval") { host.declare(`declare ptr @scr_dyn_class_check(ptr, ptr, ptr)`); + // A subclass's class value checks into the base class's slot when + // construction through the slot can call its thunk with the base + // constructor's arguments; any other subclass fails the check. + const subclasses = host.classValueSubtypes(t.className); + if (subclasses.length > 0) { + host.declare(`declare zeroext i1 @scr_dyn_class_is(ptr, ptr)`); + for (const className of subclasses) { + const subKey = host.cstr(typeKey({ kind: "classval", className })); + const matched = B.tmp(); + B.line(`${matched} = call zeroext i1 @scr_dyn_class_is(ptr %d, ptr ${subKey})`); + const yes = B.newLabel("sub.yes"); + const no = B.newLabel("sub.no"); + B.condBr(matched, yes, no); + B.startBlock(yes); + const sub = B.tmp(); + B.line(`${sub} = call ptr @scr_dyn_class_check(ptr %d, ptr ${subKey}, ptr %path)`); + B.terminate(`ret ptr ${sub}`); + B.startBlock(no); + } + } const checked = B.tmp(); B.line( `${checked} = call ptr @scr_dyn_class_check(ptr %d, ptr ${host.cstr(key)}, ptr %path)`, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e89908cf6..218c81285 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -55,6 +55,7 @@ import { typeKey, VOID, classJsName, + classValueAbiEquals, } from "../../ir/ir.js"; import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js"; import { captureFreeFunctions, observesStacks } from "../stack-observations.js"; @@ -2053,14 +2054,14 @@ export class LlEmitter { const meta = this.classMeta.get(t.className); return meta !== undefined && programLifetimeClass(this.shapeHost, meta); }, - classSubtypes: (name) => { - const target = this.classMetaOf(name); - const intervals = classMembershipIntervals(this.classMeta, target.def.name); - return [...this.classMeta.values()] - .filter((meta) => - intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post), - ) - .map((meta) => meta.def.name); + classSubtypes: (name) => this.classSubtypes(name), + classValueSubtypes: (name) => { + const target = this.classMetaOf(name).def; + return this.classSubtypes(name).filter( + (sub) => + sub !== target.name && + classValueAbiEquals(this.classMetaOf(sub).def, target, this.fnByName), + ); }, pendingTestLines: (dest) => this.pendingTestLines(dest), virtualEntry: (implFn) => this.virtualEntry(implFn), @@ -4185,6 +4186,15 @@ export class LlEmitter { // ── class plumbing ────────────────────────────────────────────────────── + /** `name` and every class whose membership interval falls in its own. */ + private classSubtypes(name: string): string[] { + const target = this.classMetaOf(name); + const intervals = classMembershipIntervals(this.classMeta, target.def.name); + return [...this.classMeta.values()] + .filter((meta) => intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post)) + .map((meta) => meta.def.name); + } + classMetaOf(className: string): LlClassMeta { const meta = this.classMeta.get(className); if (!meta) throw new InternalCompilerError(`llvm emitter bug: unknown class ${className}`); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 47b7df2eb..dfc94733d 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -9831,6 +9831,75 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType return lowerer.currentClass?.fields.get(parent.left.name.text) ?? null; } +/** An array element read that construction narrows to a class value with a + * check (`%new.callee`, which throws "is not a constructor"). */ +interface NarrowedConstructorRead { + read: IrExpr; + check: string; + unionT: IrType & { kind: "union" }; + calleeT: IrType & { kind: "classval" }; + message: IrExpr; +} + +/** `new arr[i](args)` through a narrowed element read: an interned lifted + * function takes the read and the completed arguments, which evaluate in + * that order as call arguments, then runs the check and constructs. JS + * evaluates the arguments before it checks that the callee constructs, and + * the arguments stay in the one call, where a spread's argument pack lives + * until every argument has read it. */ +function checkedElementConstruction( + lowerer: Lowerer, + info: ClassInfo, + narrowed: NarrowedConstructorRead, + args: IrExpr[], + loc: SrcLoc, +): IrExpr { + const type: IrType = { kind: "object", className: info.def.name }; + const key = `newChecked:${narrowed.check}:${info.def.name}:${args.map((a) => typeKey(a.type)).join(",")}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + name = keyedHelperName(lowerer, "%new.checked", key); + lowerer.valueHelpers.set(key, name); + const params: IrParam[] = [ + { localId: "value", name: "value", type: narrowed.unionT }, + { localId: "message", name: "message", type: STRING }, + ...args.map((arg, i) => ({ localId: `arg.${i}`, name: `arg${i}`, type: arg.type })), + ]; + lowerer.liftedFns.push({ + name, + params, + returnType: type, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body: [ + { + kind: "return", + value: { + kind: "newValue", + callee: { + kind: "call", + callee: narrowed.check, + args: [varRef("value", narrowed.unionT, loc), varRef("message", STRING, loc)], + type: narrowed.calleeT, + loc, + }, + args: params.slice(2).map((p) => varRef(p.localId, p.type, loc)), + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { + kind: "call", + callee: name, + args: [narrowed.read, narrowed.message, ...args], + type, + loc, + }; +} /** Map, Set and the error classes construct in lowerNew's identifier * branch, which a module member alias of one (`new mod.M()`) also takes. */ function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean { @@ -11169,8 +11238,109 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { "constructing through a class value of an uninstantiated generic type (annotate the slot with a concrete instantiation — e.g. 'new (v: number) => Box')", ); } - const callee = lowerer.lowerExpr(expr.expression); + let callee = lowerer.lowerExpr(expr.expression); if (callee.type.kind === "dyn") return lowerCheckedConstructor(lowerer, expr, callee); + /** The union-typed element read a narrowing check guards, and the + * check (`%new.callee`) that yields the class value or throws. */ + let narrowedCallee: NarrowedConstructorRead | null = null; + // An array element read (`new T[0]()` over `[Dog, Animal]`, whose + // checker type is the common base) keeps the undefined an index past + // the end reads: the class value is narrowed out with a check, which + // throws a TypeError for undefined. + if (callee.type.kind === "union") { + const arms = lowerer.unions.get(callee.type.unionId)?.arms ?? []; + const classArms = arms.filter((arm) => arm.kind === "classval"); + if ( + classArms.length === 1 && + classArms[0]!.kind === "classval" && + classArms[0]!.className === calleeT.className && + arms.every((arm) => arm.kind === "classval" || arm.kind === "undefinedT") + ) { + const unionT = callee.type; + const helper = lowerer.narrowedArmHelper(unionT.unionId, calleeT, callee.loc); + if (helper) { + const loc = callee.loc; + const name = `%new.callee:${unionT.unionId}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const undefinedTag = lowerer.armTag(unionT.unionId, UNDEFINED_T); + const value: IrExpr = { kind: "varRef", localId: "value", type: unionT, loc }; + lowerer.liftedFns.push({ + name, + params: [ + { localId: "value", name: "value", type: unionT }, + { localId: "message", name: "message", type: STRING }, + ], + locals: [ + { id: "value", name: "value", type: unionT, mutable: false }, + { id: "message", name: "message", type: STRING, mutable: false }, + ], + returnType: calleeT, + body: [ + ...(undefinedTag >= 0 + ? [ + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: unionT.unionId, + tag: undefinedTag, + negated: false, + value, + type: BOOL, + loc, + }, + then: [ + { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [{ kind: "varRef", localId: "message", type: STRING, loc }], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }, + ], + else_: null, + loc, + } satisfies IrStmt, + ] + : []), + { + kind: "return", + value: lowerer.checkedNarrowCall(helper, value, calleeT, loc), + loc, + }, + ], + loc, + }); + } + // JavaScript evaluates the arguments before it checks that the + // callee constructs, so the check runs at the construction + // (checkedElementConstruction). + narrowedCallee = { + read: callee, + check: name, + unionT, + calleeT, + message: { + kind: "strLit", + value: `${expr.expression.getText()} is not a constructor`, + type: STRING, + loc, + }, + }; + callee = { + kind: "call", + callee: name, + args: [callee, narrowedCallee.message], + type: calleeT, + loc, + }; + } + } + } if (callee.type.kind !== "classval") lowerer.badType(expr.expression, lowerer.typeOf(expr.expression)); lowerer.noteEdge(classValueConstructor(info.def).fn); @@ -11210,13 +11380,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const built = constructedFromValue( lowerer, info, - { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }, + narrowedCallee !== null + ? checkedElementConstruction(lowerer, info, narrowedCallee, args, loc) + : { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, expr, ); // A runtime class slot widened from a union (`[Readable, Writable]`'s diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 1fdf76a3d..c6fedc18d 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1623,6 +1623,28 @@ export function classValueConstructor(def: { name: string; runtime?: true }): { : { fn: `%${def.name}.constructor`, firstArg: 1 }; } +/** Whether construction through a class value typed as `sup` may call + * `sub`'s construct thunk: both class-value constructors exist and take the + * same argument types after the receiver. The classval upcast rule and the + * checked class-value boundary both rest on it. */ +export function classValueAbiEquals( + sub: { name: string; runtime?: true }, + sup: { name: string; runtime?: true }, + functions: ReadonlyMap, +): boolean { + const subValueCtor = classValueConstructor(sub); + const supValueCtor = classValueConstructor(sup); + const subCtor = functions.get(subValueCtor.fn); + const supCtor = functions.get(supValueCtor.fn); + if (!subCtor || !supCtor) return false; + const subArgs = subCtor.params.slice(subValueCtor.firstArg); + const supArgs = supCtor.params.slice(supValueCtor.firstArg); + return ( + subArgs.length === supArgs.length && + subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type)) + ); +} + /** The JavaScript `.name` of a class: its jsName, or the runtime-provided * class's own name (`%TypeError` is TypeError). */ export function classJsName(def: { name: string; runtime?: true; jsName?: string }): string { diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index c085fd373..bcaeb744c 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -57,7 +57,12 @@ import { maybePromiseUnionArmsOk, VOID, } from "./ir.js"; -import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js"; +import { + DYN_CLASS_PROPERTIES, + callSiteReturnType, + classValueAbiEquals, + classValueConstructor, +} from "./ir.js"; import { unionWideningTags } from "./analysis.js"; import { alwaysReturns } from "./control-flow.js"; import { dynamicOperationOf, functionTier } from "./tiers.js"; @@ -2993,19 +2998,13 @@ function validateFunction( } const subClass = classes.get(sub); const supClass = classes.get(sup); - const subValueCtor = subClass ? classValueConstructor(subClass) : null; - const supValueCtor = supClass ? classValueConstructor(supClass) : null; - const subCtor = subValueCtor ? functions.get(subValueCtor.fn) : undefined; - const supCtor = supValueCtor ? functions.get(supValueCtor.fn) : undefined; - if (!subCtor || !supCtor || !subValueCtor || !supValueCtor) { + const subCtor = subClass ? functions.get(classValueConstructor(subClass).fn) : undefined; + const supCtor = supClass ? functions.get(classValueConstructor(supClass).fn) : undefined; + if (!subClass || !supClass || !subCtor || !supCtor) { err(`classval upcast: "${sub}"/"${sup}" lack constructor functions`, e.loc); break; } - const subArgs = subCtor.params.slice(subValueCtor.firstArg); - const supArgs = supCtor.params.slice(supValueCtor.firstArg); - const abiEqual = - subArgs.length === supArgs.length && - subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type)); + const abiEqual = classValueAbiEquals(subClass, supClass, functions); if (!abiEqual) { err(`classval upcast: "${sub}" and "${sup}" constructor ABIs differ`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json new file mode 100644 index 000000000..eefa82916 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6937-class-value-arrays.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json new file mode 100644 index 000000000..ff2a51546 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6938-class-value-arrays.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6937-class-value-arrays.mjs b/tests/corpus/6937-class-value-arrays.mjs new file mode 100644 index 000000000..8fcfcb815 --- /dev/null +++ b/tests/corpus/6937-class-value-arrays.mjs @@ -0,0 +1,53 @@ +// Arrays of class values that mix a base class and its subclasses: the +// elements construct, compare and name like the classes themselves. +class Animal { + speak() { + return "..."; + } +} +class Dog extends Animal { + speak() { + return "woof"; + } +} +class Cat extends Animal { + speak() { + return "meow"; + } +} +const kinds = [Dog, Cat, Animal]; +console.log(kinds.length, kinds.map((C) => C.name).join()); +console.log(new kinds[0]().speak(), new kinds[1]().speak(), new kinds[2]().speak()); +console.log(kinds[0] === Dog, kinds.indexOf(Cat), kinds.includes(Animal)); +const made = kinds.map((C) => new C()); +console.log(made.map((a) => a.speak()).join(), made[0] instanceof Animal, made[1] instanceof Dog); +try { + new kinds[5](); +} catch (e) { + console.log(e.name, e.message); +} +const byName = { dog: Dog, cat: Cat }; +console.log(new byName.dog().speak(), new byName["cat"]().speak()); +// The arguments are evaluated before the missing element throws. +class Point { + constructor(x, y) { + this.sum = x + y; + } +} +class Point3 extends Point {} +const points = [Point3, Point]; +const evaluated = []; +const arg = (v) => (evaluated.push(v), v); +console.log(new points[0](arg(1), arg(2)).sum, evaluated.join()); +try { + new points[2](arg(3), arg(4)); +} catch (e) { + console.log(e.name, e.message, evaluated.join()); +} +// Spread arguments too, through a valid index and past the end. +console.log(new points[1](...[5, 6]).sum); +try { + new points[3](...[arg(7), arg(8)]); +} catch (e) { + console.log(e.name, e.message, evaluated.join()); +} diff --git a/tests/corpus/6938-class-value-arrays.ts b/tests/corpus/6938-class-value-arrays.ts new file mode 100644 index 000000000..db695bc0a --- /dev/null +++ b/tests/corpus/6938-class-value-arrays.ts @@ -0,0 +1,48 @@ +// Arrays of class values that mix a base class and its subclasses, with the +// element type the checker infers (the base class): elements construct and +// dispatch to the subclass, and an index past the end throws Node's TypeError. +class Shape { + area(): number { + return 0; + } +} +class Square extends Shape { + area(): number { + return 4; + } +} +class Circle extends Shape { + area(): number { + return 3; + } +} +const shapes = [Square, Circle, Shape]; +console.log(shapes.length, new shapes[0]().area(), new shapes[1]().area(), new shapes[2]().area()); +let total = 0; +for (const S of shapes) total += new S().area(); +console.log(total, shapes[1] === Circle, shapes.map((S) => S.name).join()); +try { + new shapes[3](); +} catch (e) { + console.log((e as Error).name, (e as Error).message); +} +// A class value checked into a `typeof Shape` slot: a subclass whose +// constructor takes the same arguments constructs, and one whose constructor +// takes an argument the slot does not pass throws a TypeError (Node's from +// reading the missing argument, the compiled program's from the check). +class Sized extends Shape { + label: string; + constructor(side: number) { + super(); + this.label = side.toFixed(1); + } +} +const values: any[] = [Square, Sized, Shape]; +for (const value of values) { + try { + const S: typeof Shape = value; + console.log(new S().area()); + } catch (e) { + console.log((e as Error).name); + } +} diff --git a/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json b/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json new file mode 100644 index 000000000..7e905d65a --- /dev/null +++ b/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json @@ -0,0 +1 @@ +{ "plain": 4211, "san": 6383 } diff --git a/tests/harness/corpus-costs/6938-class-value-arrays.ts.json b/tests/harness/corpus-costs/6938-class-value-arrays.ts.json new file mode 100644 index 000000000..76098ce8d --- /dev/null +++ b/tests/harness/corpus-costs/6938-class-value-arrays.ts.json @@ -0,0 +1 @@ +{ "plain": 2399, "san": 4080 }