diff --git a/packages/compiler/src/frontend/lowering/class-construction.ts b/packages/compiler/src/frontend/lowering/class-construction.ts index 4dae7ecd0..7f4a003c9 100644 --- a/packages/compiler/src/frontend/lowering/class-construction.ts +++ b/packages/compiler/src/frontend/lowering/class-construction.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { BOOL, DYN, @@ -389,13 +390,13 @@ export class ClassConstructionDispatch { if (!args) continue; const type: IrType = { kind: "classval", className: name }; const callee = varRef("p.0", DYN, loc); - const value: IrExpr = { + const value = constructedValue(lowerer, info, { kind: "newValue", callee: { kind: "dynCheck", value: callee, type, loc }, args, type: { kind: "object", className: name }, loc, - }; + }); const body: IrStmt[] = [ { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, ]; diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 120032bf5..16c83e9e9 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js"; import { BOOL, @@ -3158,13 +3159,13 @@ export class ClassDynamicDispatch { } if (!supported || !info.decl) continue; const cls = classValueRef(lowerer, info, info.decl); - const result: IrExpr = { + const result = constructedValue(lowerer, info, { kind: "new", className: name, args, type: { kind: "object", className: name }, loc, - }; + }); this.constructDispatch.body.unshift({ kind: "if", cond: { diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 925f59d9b..6a5e0058f 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -1,3 +1,4 @@ +import { constructedValue } from "../constructor-returns.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import type { FieldLift } from "./structural-plans.js"; import { buildArrayConversion } from "./builders.js"; @@ -524,7 +525,13 @@ export function recordClassWidthHelper( params: [{ localId: "r.0", name: "r", type: fromT }], returnType: toT, locals: [{ id: "r.0", name: "r", type: fromT, mutable: true }], - body: [{ kind: "return", value: { kind: "new", className, args, type: toT, loc }, loc }], + body: [ + { + kind: "return", + value: constructedValue(lowerer, info, { kind: "new", className, args, type: toT, loc }), + loc, + }, + ], loc, }); return name; diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts new file mode 100644 index 000000000..d054a6b49 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -0,0 +1,377 @@ +/** Constructors that `return` a value. + * + * JavaScript's `new C()` answers the constructor's returned value when it + * is an object (functions included) and the constructed `this` otherwise. + * A primitive or bare return therefore keeps the ordinary lowering, which + * evaluates the operand and drops it. + * + * A return whose value is an instance of the constructor's own class keeps + * the class representation (`if (Single.instance) return + * Single.instance;`). The return stores that instance in a per-class + * result slot and every direct `new C()` takes it from there. Any other + * object has no representation as a `C` instance and refuses at the return + * statement, deferred to run time in JavaScript like other statement + * fences. A JavaScript value that is an object only at run time (`any`) + * is tested when it returns: a primitive answers the constructed + * instance, an instance of the class takes the result slot, and any other + * object refuses. + * + * `super()` from a subclass refuses: Node would rebind the subclass's + * `this` to the returned instance. */ +import * as ts from "../ts7/adapter.js"; +import { varRef } from "../../ir/build.js"; +import { + BOOL, + DYN, + type IrExpr, + type IrGlobal, + type IrStmt, + type IrType, + STRING, + typeKey, +} from "../../ir/ir.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { ClassInfo } from "./lower-classes.js"; +import type { Lowerer } from "./lowerer.js"; + +type ReturnKind = "instance" | "object" | "mixed"; + +export interface ConstructorReturnPlan { + /** Returns whose value may be an object, by kind. Primitive and `this` + * returns are absent: they keep the ordinary lowering. */ + returns: Map; + /** The result slot (`C | undefined`), present when some return yields an + * instance of the class itself. */ + slot: IrGlobal | null; +} + +const PRIMITIVE_FLAGS = + ts.TypeFlags.Primitive | ts.TypeFlags.EnumLike | ts.TypeFlags.Void | ts.TypeFlags.Never; + +/** Types whose values may be primitives or objects: open types, type + * variables and intersections (a branded `string & { tag }` is a string). */ +const OPEN_FLAGS = + ts.TypeFlags.AnyOrUnknown | ts.TypeFlags.InstantiableNonPrimitive | ts.TypeFlags.Intersection; + +function unparenthesized(node: ts.Expression): ts.Expression { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** Whether a value of this checker type can be an object: "never", + * "always", or "maybe" (open types and primitive/object unions). */ +function objectness(type: ts.Type): "never" | "always" | "maybe" { + const parts = type.isUnionType() ? ts.constituentTypes(type) : [type]; + let primitive = false; + let object = false; + for (const part of parts) { + if ((part.flags & OPEN_FLAGS) !== 0) return "maybe"; + if ((part.flags & PRIMITIVE_FLAGS) !== 0) primitive = true; + else object = true; + } + if (!object) return "never"; + return primitive ? "maybe" : "always"; +} + +/** The constructor's own `return ` statements: nested functions, + * arrows and classes have their own returns. */ +function ownValueReturns(ctor: ts.ConstructorDeclaration): ts.ReturnStatement[] { + const out: ts.ReturnStatement[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) out.push(node); + node.forEachChild(visit); + }; + ctor.body?.forEachChild(visit); + return out; +} + +/** A return inside a `try` with a `finally` block: the finally block runs + * after the result is stored and may throw past it. */ +function insideFinallyTry(node: ts.Node, ctor: ts.ConstructorDeclaration): boolean { + for (let p = node.parent; p !== undefined && p !== ctor; p = p.parent) { + if (ts.isTryStatement(p) && p.finallyBlock !== undefined && !nodeWithin(node, p.finallyBlock)) + return true; + } + return false; +} + +function nodeWithin(node: ts.Node, ancestor: ts.Node): boolean { + return node.pos >= ancestor.pos && node.end <= ancestor.end; +} + +function instanceType(info: ClassInfo): IrType { + return { kind: "object", className: info.def.name }; +} + +function isInstanceOf(lowerer: Lowerer, info: ClassInfo, type: IrType | null): boolean { + if (type === null) return false; + if (type.kind === "object") return type.className === info.def.name; + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + return ( + arms.length === 2 && + arms.some((arm) => arm.kind === "object" && arm.className === info.def.name) && + arms.some((arm) => arm.kind === "undefinedT") + ); +} + +export function constructorReturnPlan( + lowerer: Lowerer, + info: ClassInfo, +): ConstructorReturnPlan | null { + const byClass = lowerer.constructorReturnPlans; + const known = byClass.get(info.def.name); + if (known !== undefined) return known; + const ctor = info.ctor; + let plan: ConstructorReturnPlan | null = null; + if (ctor?.body !== undefined && info.decl !== null) { + const returns = new Map(); + for (const stmt of ownValueReturns(ctor)) { + const value = unparenthesized(stmt.expression!); + if (value.kind === ts.SyntaxKind.ThisKeyword) continue; + const type = lowerer.typeOf(value); + const shape = objectness(type); + if (shape === "never") continue; + // Local classes construct through their own environment, which has + // no result slot. + const instance = + info.localClass === undefined && + !insideFinallyTry(stmt, ctor) && + isInstanceOf(lowerer, info, lowerer.mapTypeOf(type)); + returns.set(stmt, instance ? "instance" : shape === "always" ? "object" : "mixed"); + } + if (returns.size > 0) { + let slot: IrGlobal | null = null; + // A JavaScript value typed only at run time may hold an instance of + // the class too (`if (C.instance) return C.instance;`). + const runtimeInstance = + info.localClass === undefined && + isJsSourceFile(ctor.getSourceFile()) && + [...returns].some(([stmt, kind]) => kind === "mixed" && !insideFinallyTry(stmt, ctor)); + if ([...returns.values()].includes("instance") || runtimeInstance) { + slot = { + id: `%g.${info.def.name}.%constructorResult`, + name: `${info.def.name}.%constructorResult`, + type: lowerer.withUndefinedArm(instanceType(info)), + mutable: true, + }; + lowerer.globalsList.push(slot); + } + plan = { returns, slot }; + } + } + byClass.set(info.def.name, plan); + return plan; +} + +function foreignObjectFeature(info: ClassInfo): string { + const display = info.def.name.replace(/^%|^%m\d+\./, ""); + return `returning an object other than an instance of the class from a constructor (Node's 'new ${display}()' answers the returned object, which has no representation as a '${display}' instance)`; +} + +/** `return ` inside a class constructor whose value may be an + * object. Null when the statement keeps the ordinary lowering. */ +export function lowerConstructorReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + const info = lowerer.currentClass; + if (info === null || info.ctor === null || stmt.expression === undefined) return null; + let fn: ts.Node | undefined = stmt.parent; + while (fn !== undefined && !ts.isFunctionLike(fn) && !ts.isClassLikeDeclaration(fn)) + fn = fn.parent; + if (fn !== info.ctor) return null; + const plan = constructorReturnPlan(lowerer, info); + const kind = plan?.returns.get(stmt); + if (plan === null || kind === undefined) return null; + const loc = locOf(stmt); + if (kind === "instance") { + const value = lowerer.coerceInto( + stmt.expression, + lowerer.lowerExpr(stmt.expression), + plan.slot!.type, + ); + return { + kind: "block", + body: [ + { kind: "assign", localId: plan.slot!.id, value, loc }, + { kind: "return", value: null, loc }, + ], + loc, + }; + } + const feature = foreignObjectFeature(info); + if (kind === "object" || !isJsSourceFile(stmt.getSourceFile())) + lowerer.unsupported("SC1090", stmt, feature); + // A JavaScript value that is an object only sometimes: a primitive + // still answers the constructed instance, an object refuses. + const value = lowerer.coerceToExpected(lowerer.lowerExpr(stmt.expression), DYN); + if (value.type.kind !== "dyn") lowerer.unsupported("SC1090", stmt, feature); + const held = lowerer.declareHiddenLocal("%returned", DYN); + const read = varRef(held.id, DYN, loc); + const typeOf: IrExpr = { kind: "libCall", fn: "dyn.typeof", args: [read], type: STRING, loc }; + const typeIs = (name: string): IrExpr => ({ + kind: "strEq", + negated: false, + left: typeOf, + right: { kind: "strLit", value: name, type: STRING, loc }, + type: BOOL, + loc, + }); + const diag = unsupportedDiag("SC1090", loc, feature); + lowerer.runtimeFences.push(diag); + const sf = stmt.getSourceFile(); + const line = ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: held.id, init: value, loc }, + { + kind: "if", + // null answers "object" and is falsy; every object is truthy. + cond: { + kind: "logical", + op: "||", + left: typeIs("function"), + right: { + kind: "logical", + op: "&&", + left: typeIs("object"), + right: lowerer.ensureBool(read, stmt.expression), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + then: [ + ...(plan.slot !== null && !insideFinallyTry(stmt, info.ctor) + ? [ + { + kind: "if" as const, + cond: { + kind: "libCall" as const, + fn: "dyn.typedRefIs" as const, + args: [ + read, + { + kind: "strLit" as const, + value: typeKey(instanceType(info)), + type: STRING, + loc, + }, + ], + type: BOOL, + loc, + }, + then: [ + { + kind: "assign" as const, + localId: plan.slot.id, + value: lowerer.coerceToExpected( + lowerer.coerceToExpected(read, instanceType(info)), + plan.slot.type, + ), + loc, + }, + { kind: "return" as const, value: null, loc }, + ], + else_: null, + loc, + }, + ] + : []), + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }; +} + +/** The value of a construction of exactly class `C`: the constructed + * instance, or the instance the constructor returned. Unchanged for + * classes without a result slot. The slot is read and cleared by a + * per-class helper, so every construction site (direct `new`, class + * values, the checked dynamic dispatchers) shares one protocol. */ +export function constructedValue(lowerer: Lowerer, info: ClassInfo, constructed: IrExpr): IrExpr { + const slot = constructorReturnPlan(lowerer, info)?.slot; + if (slot === null || slot === undefined) return constructed; + const loc = constructed.loc; + const type = instanceType(info); + const name = `%${info.def.name}.%constructed`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const instance = { id: "instance.0", name: "instance", type, mutable: false }; + const returned = { id: "returned.0", name: "returned", type: slot.type, mutable: false }; + lowerer.liftedFns.push({ + name, + params: [{ localId: instance.id, name: instance.name, type }], + returnType: type, + locals: [instance, returned], + body: [ + { kind: "varDecl", localId: returned.id, init: varRef(slot.id, slot.type, loc), loc }, + { kind: "assign", localId: slot.id, value: lowerer.wrappedUndefined(slot.type, loc)!, loc }, + { + kind: "return", + value: { + kind: "orDefault", + left: varRef(returned.id, slot.type, loc), + right: varRef(instance.id, type, loc), + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [constructed], type, loc }; +} + +/** A construction through a class value whose static class is `info`: the + * value holds `info` itself or a descendant. Descendants of a class with a + * result slot refuse at their `super()`, so only `info`'s own slot can + * apply; a descendant with its own slot has no static home here. */ +export function constructedFromValue( + lowerer: Lowerer, + info: ClassInfo, + constructed: IrExpr, + blame: ts.Node, +): IrExpr { + const pending = [...info.subclasses]; + while (pending.length > 0) { + const sub = pending.pop()!; + pending.push(...sub.subclasses); + const plan = constructorReturnPlan(lowerer, sub); + if (plan === null || plan.slot === null) continue; + const display = sub.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `constructing through a class value that may hold '${display}', whose constructor can return an existing instance`, + ); + } + return constructedValue(lowerer, info, constructed); +} + +/** `super()` into a class with a result slot: Node rebinds the subclass's + * `this` to the returned instance, which has no representation. */ +export function refuseSuperIntoResultSlot(lowerer: Lowerer, base: ClassInfo, blame: ts.Node): void { + const plan = constructorReturnPlan(lowerer, base); + if (plan === null || plan.slot === null) return; + const display = base.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`, + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..8bb3fd8cb 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,5 +1,10 @@ import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { + constructedFromValue, + constructedValue, + refuseSuperIntoResultSlot, +} from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { lowerReadlineUiNew } from "./builtins/readline.js"; import { collectClassMethodMutations } from "./class-method-mutations.js"; @@ -8020,6 +8025,7 @@ export function superCallStmt( info.decl ?? info.ctor!, )[0]!; } + refuseSuperIntoResultSlot(lowerer, base, info.ctor ?? info.decl!); lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`); return { kind: "exprStmt", @@ -8429,21 +8435,21 @@ function lowerProgramClassNew( : lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); for (let current: ClassInfo | null = info; current; current = current.base) { if (current.def.baseValueGlobal) - return { + return constructedValue(lowerer, info, { kind: "newValue", callee: classValueRef(lowerer, info, expr), args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } - return { + return constructedValue(lowerer, info, { kind: "new", className: info.def.name, args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { @@ -8731,13 +8737,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { below(info); const callee: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } } } @@ -9638,13 +9649,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { }; below(info); const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } if (calleeT?.kind === "union") { const def = lowerer.unions.get(calleeT.unionId); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 12544979c..9ede6182b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -14,6 +14,7 @@ import { processDataMemberValue } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { bindingSource } from "../binding-source.js"; +import { lowerConstructorReturn } from "./constructor-returns.js"; import type { Lowerer } from "./lowerer.js"; import { arrayValueRead, @@ -1370,6 +1371,11 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt // incompatible boundary keeps the ordinary per-statement refusal. let value: IrExpr | null = null; if (stmt.expression) { + // A constructor's returned object replaces the constructed `this`. + if (lowerer.currentClass?.ctor) { + const constructorReturn = lowerConstructorReturn(lowerer, stmt); + if (constructorReturn !== null) return constructorReturn; + } return lowerer.lowerReturnStmt(stmt.expression, locOf(stmt)); } else if (lowerer.ctx.returnType.kind === "union") { value = lowerer.wrappedUndefined(lowerer.ctx.returnType, locOf(stmt)); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 145fb3ae6..cdc170364 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4,6 +4,7 @@ import { lowerLifetimeRegionCall, lowerLifetimeRegionNew, } from "./lifetime-regions.js"; +import type { ConstructorReturnPlan } from "./constructor-returns.js"; import { internTableType, lowerInternTableCall, lowerInternTableNew } from "./intern-tables.js"; import { isExperimentalDeclarationFile } from "../experimental-import.js"; import { @@ -2706,6 +2707,9 @@ export class Lowerer { * by class name (cycle-initialization.ts). */ readonly classInitFlags = new Map(); readonly cycleInitDeclarations = new Map(); + /** Each class's constructor return plan by class name + * (constructor-returns.ts); null when no return can yield an object. */ + readonly constructorReturnPlans = new Map(); /** CommonJS require declarations whose bindings code running before the * require can read, and those references (require-initialization.ts). */ readonly requireInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json new file mode 100644 index 000000000..a85c33c1a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json new file mode 100644 index 000000000..e01b6a426 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json new file mode 100644 index 000000000..86b15f680 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-instance-super.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json new file mode 100644 index 000000000..f3415a937 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-object.ts" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-instance.js b/tests/corpus/constructor-returns-instance.js new file mode 100644 index 000000000..ab7af8b2c --- /dev/null +++ b/tests/corpus/constructor-returns-instance.js @@ -0,0 +1,45 @@ +// JavaScript constructors whose returned value is typed only at run time: +// an instance of the class replaces `this`, a primitive keeps it. +class Connection { + constructor(url) { + if (Connection.open && Connection.open.url === url) return Connection.open; + this.url = url; + this.uses = 0; + Connection.open = this; + } + use() { + this.uses++; + return this; + } +} +const c1 = new Connection("db://a").use(); +const c2 = new Connection("db://a").use(); +const c3 = new Connection("db://b").use(); +console.log(c1 === c2, c2.uses, c3 === c1, c3.uses, c3.url); + +class Counter { + constructor(start, answer) { + this.count = start; + return answer; + } +} +console.log(new Counter(1, 5).count, new Counter(2, "x").count, new Counter(3, null).count); +console.log(new Counter(4, undefined).count, new Counter(5, true).count); +const kept = new Counter(6, 0); +console.log(new Counter(7, kept) === kept, kept.count); + +const memoCache = new Map(); +let memoCreated = 0; +class Memo { + constructor(key) { + const hit = memoCache.get(key); + if (hit !== undefined) return hit; + this.key = key; + this.created = ++memoCreated; + memoCache.set(key, this); + } +} +const m1 = new Memo("k"); +const m2 = new Memo("k"); +const m3 = new Memo("j"); +console.log(m1 === m2, m3 === m1, m2.created, m3.created, memoCreated); diff --git a/tests/corpus/constructor-returns-instance.ts b/tests/corpus/constructor-returns-instance.ts new file mode 100644 index 000000000..81e25a12d --- /dev/null +++ b/tests/corpus/constructor-returns-instance.ts @@ -0,0 +1,112 @@ +// A constructor's returned object replaces the constructed instance when it +// is an object; primitive, bare and `this` returns answer the instance. +class Registry { + static current: Registry | undefined = undefined; + readonly id: number = 0; + entries: string[] = []; + constructor(id: number) { + if (Registry.current) return Registry.current; + this.id = id; + Registry.current = this; + } + add(entry: string): this { + this.entries.push(entry); + return this; + } +} +const first = new Registry(1).add("a"); +const second = new Registry(2).add("b"); +console.log(first === second, second.id, second.entries.join(",")); + +// An instance returned only on some paths. +class Slot { + static previous: Slot | undefined = undefined; + label: string; + constructor(label: string, reuse: boolean) { + this.label = label; + const previous = Slot.previous; + Slot.previous = this; + if (reuse && previous) return previous; + } +} +const s1 = new Slot("one", true); +const s2 = new Slot("two", true); +const s3 = new Slot("three", false); +console.log(s1.label, s2.label, s3.label, s2 === s1, s3 === s2); + +// Constructing the class again inside its own constructor. +class Node2 { + static made = 0; + value: number; + child: Node2 | null = null; + constructor(depth: number, share: Node2 | null) { + Node2.made++; + this.value = depth; + if (share !== null) return share; + if (depth > 0) this.child = new Node2(depth - 1, null); + } +} +const root = new Node2(2, null); +const alias = new Node2(9, root); +console.log(alias === root, alias.value, root.child?.value, root.child?.child?.value, Node2.made); + +// A constructor that throws leaves later constructions unaffected. +class Guarded { + static kept: Guarded | undefined = undefined; + n: number; + constructor(n: number) { + if (n < 0) throw new RangeError(`negative: ${n}`); + this.n = n; + if (n === 0 && Guarded.kept) return Guarded.kept; + Guarded.kept = this; + } +} +try { + new Guarded(-1); +} catch (e) { + console.log((e as Error).message); +} +const g1 = new Guarded(5); +const g2 = new Guarded(0); +const g3 = new Guarded(7); +console.log(g1.n, g2.n, g3.n, g1 === g2, g3 === g1); + +// Primitive, bare and `this` returns keep the constructed instance. +class Plain { + v = 3; + constructor(mode: number) { + if (mode === 1) return; + if (mode === 2) return this; + } +} +const plain = new Plain(2); +console.log(new Plain(0).v, new Plain(1).v, plain.v, plain instanceof Plain); + +// The derived class's own constructor may return an instance of itself. +class Animal { + sound = "..."; +} +class Dog extends Animal { + static pack: Dog[] = []; + name: string; + constructor(name: string) { + super(); + this.name = name; + this.sound = "woof"; + const known = Dog.pack.find((d) => d.name === name); + if (known) return known; + Dog.pack.push(this); + } +} +const rex = new Dog("rex"); +const again = new Dog("rex"); +const fido = new Dog("fido"); +console.log(rex === again, fido === rex, Dog.pack.length, again.sound, again instanceof Animal); + +// Constructions through class values take the returned instance too. +const Ctor: typeof Registry = Registry; +console.log(new Ctor(3) === first); +function build(make: new (id: number) => T, id: number): T { + return new make(id); +} +console.log(build(Registry, 4) === first, build(Registry, 5).entries.length); diff --git a/tests/diagnostics/constructor-returns-instance-super.ts b/tests/diagnostics/constructor-returns-instance-super.ts new file mode 100644 index 000000000..c5e34c7a1 --- /dev/null +++ b/tests/diagnostics/constructor-returns-instance-super.ts @@ -0,0 +1,15 @@ +// `super()` into a constructor that can return an existing instance would +// rebind the subclass's `this` to that instance. +class Shared { + static one: Shared | undefined = undefined; + constructor() { + if (Shared.one) return Shared.one; + Shared.one = this; + } +} +class Derived extends Shared { + constructor() { + super(); + } +} +console.log(new Derived() instanceof Derived); diff --git a/tests/diagnostics/constructor-returns-object.ts b/tests/diagnostics/constructor-returns-object.ts new file mode 100644 index 000000000..63abf4c4d --- /dev/null +++ b/tests/diagnostics/constructor-returns-object.ts @@ -0,0 +1,10 @@ +// Node's `new Point()` answers the object a constructor returns. A plain +// object has no representation as a Point instance, so the return refuses +// instead of answering the constructed instance. +class Point { + x = 1; + constructor() { + return { x: 2 }; + } +} +console.log(new Point().x); diff --git a/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt new file mode 100644 index 000000000..53a01d431 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-instance-super.ts:11:3 - error SC1090: extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance 'Shared' returns) is not supported yet + + 10 | class Derived extends Shared { + 11 | constructor() { + | ^~~~~~~~~~~~~~~ + 12 | super(); \ No newline at end of file diff --git a/tests/harness/__snapshots__/constructor-returns-object.ts.txt b/tests/harness/__snapshots__/constructor-returns-object.ts.txt new file mode 100644 index 000000000..fe2220d56 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-object.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-object.ts:7:5 - error SC1090: returning an object other than an instance of the class from a constructor (Node's 'new Point()' answers the returned object, which has no representation as a 'Point' instance) is not supported yet + + 6 | constructor() { + 7 | return { x: 2 }; + | ^~~~~~~~~~~~~~~~ + 8 | } \ No newline at end of file diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 51f04d310..856c1ad7d 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1013,3 +1013,55 @@ console.log(typeof Derived); ); }); }); + +describe("constructors returning objects (JS lane)", () => { + test("a returned plain object is a named deferred fence", async () => { + // Node's `new Box(2)` answers `{ size: 99 }`; a Box instance cannot + // hold that object, so the return refuses instead of answering `this`. + const r = await compileAndRun( + "constructor-returns-object", + `'use strict'; +class Box { + constructor(size) { + this.size = size; + if (size > 1) return { size: 99 }; + } +} +console.log(new Box(1).size); +console.log(new Box(2)); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor \(Node's 'new Box\(\)' answers the returned object, .*\) is not supported yet \[SC1090 at .*constructor-returns-object\.cjs:5\]\n$/, + ); + }); + + test("an untyped returned value fences only when it holds a foreign object", async () => { + // Primitive results answer the constructed instance, as in Node; an + // object that is not a Wrap instance refuses. + const r = await compileAndRun( + "constructor-returns-untyped", + `'use strict'; +class Wrap { + constructor(value) { + this.value = value; + return value; + } +} +console.log(new Wrap(1).value, new Wrap('s').value, new Wrap(null).value); +const w = new Wrap(0); +console.log(new Wrap(w) === w); +console.log(new Wrap({ a: 1 })); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1 s null\ntrue\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor .* is not supported yet \[SC1090 at .*constructor-returns-untyped\.cjs:5\]\n$/, + ); + }); +});