diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index f424b72b01..bacce12b44 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -2,3 +2,6 @@ `JSON.stringify` writes Date fields as their ISO text and Map and Set fields as `{}`, as Node.js does, and refuses classes with RegExp or Promise fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. + `value.constructor.name` names the class that constructed a class instance or an error, as Node.js does; for an error it is the error's class, not its `name` property. + A read of `value.constructor` alone in TypeScript raises `SC2020`. + `constructor.name` of a plain object, such as an object literal or a `JSON.parse` result, raises `SC2020` in TypeScript and throws a `TypeError` in JavaScript and on untyped values, where Node.js answers `Object`. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 91242b45c8..ca6240eaf0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -454,6 +454,7 @@ export const LIB_FN_SYMS: Record = { "dyn.toStringCoerce": "scr_dyn_string_coerce_js", "dyn.stringConstructor": "scr_dyn_string_constructor", "dyn.propertyKey": "scr_dyn_property_key_value", + "dyn.errorConstructorName": "scr_dyn_error_constructor_name", "dyn.toNumeric": "scr_dyn_to_numeric", "dyn.increment": "scr_dyn_increment", "dyn.numberConstructor": "scr_dyn_number_constructor", diff --git a/packages/compiler/src/frontend/lowering/constructor-name.ts b/packages/compiler/src/frontend/lowering/constructor-name.ts new file mode 100644 index 0000000000..594e42b9ea --- /dev/null +++ b/packages/compiler/src/frontend/lowering/constructor-name.ts @@ -0,0 +1,227 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, STRING, type IrExpr } from "../../ir/ir.js"; +import { dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { ClassInfo } from "./lower-classes.js"; +import { tryLowerExpression } from "./expressions/try-lower-expression.js"; + +/** `value.constructor.name`: the name of the class that constructed the + * value. + * + * - Program class instances in TypeScript read `constructor` and then + * `name` through the instance's checked-dynamic view, which JavaScript + * sources already use for `value.constructor`, so a `constructor` + * replaced through an untyped value answers the replacement's name. A + * class without subclasses whose instances have no checked-dynamic view + * answers its name at compile time, as `C.name` does. + * - An error-typed value tests the program's error classes that extend its + * static class, most derived first, and otherwise answers the native + * error's builtin class name. + * - An untyped value reads `constructor` and then `name`. A value that holds + * a native error answers the error's class name, since a native error has + * no constructor value to read. + * + * Answers null for other receivers, which keep their existing lowering. */ +export function lowerConstructorName( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (expr.name.text !== "name" || expr.questionDotToken) return null; + let access = expr.expression; + while (ts.isParenthesizedExpression(access)) access = access.expression; + if ( + !ts.isPropertyAccessExpression(access) || + access.name.text !== "constructor" || + access.questionDotToken || + access.expression.kind === ts.SyntaxKind.SuperKeyword + ) + return null; + const target = access.expression; + const js = isJsSourceFile(expr.getSourceFile()); + let declared = lowerer.mapTypeOf(lowerer.typeOf(target)); + // An unannotated JavaScript parameter has no mapped type; its lowered + // value says whether it is untyped. + let lowered: IrExpr | null = null; + if (declared === null) { + if (!js) return null; + lowered = tryLowerExpression(lowerer, target); + if (lowered?.type.kind !== "dyn") return null; + declared = DYN; + } + const loc = locOf(expr); + const asString = !js && lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "string"; + if (declared.kind === "dyn") { + const slot = lowerer.declareHiddenLocal("%constructorNameReceiver", DYN); + const constructorSlot = lowerer.declareHiddenLocal("%constructorNameConstructor", DYN); + const value = (): IrExpr => varRef(slot.id, DYN, loc); + const constructor = (): IrExpr => varRef(constructorSlot.id, DYN, loc); + const name: IrExpr = { + kind: "libCall", + fn: "dyn.errorConstructorName", + args: [ + value(), + constructor(), + { + kind: "dynKeyGet", + value: constructor(), + key: strLit("name", loc), + optional: true, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + const result = asString ? lowerer.coerceToExpected(name, STRING) : name; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: slot.id, init: lowered ?? lowerer.lowerExpr(target), loc }, + { + kind: "varDecl", + localId: constructorSlot.id, + init: { + kind: "dynKeyGet", + value: value(), + key: strLit("constructor", loc), + type: DYN, + loc, + }, + loc, + }, + ], + result, + type: result.type, + loc, + }; + } + if (declared.kind !== "object") return null; + const info = lowerer.classes.get(declared.className); + const programClass = info !== undefined && !info.def.runtime ? info : undefined; + if (programClass?.fields.has("constructor")) return null; + if (lowerer.errorHierarchyClassOf(declared.className)) { + if (programClass && js) return null; + return errorConstructorName(lowerer, target, declared.className, programClass, loc); + } + if (!programClass || js || !asString) return null; + // An instance that can become an untyped value can have its `constructor` + // replaced there (`untyped.constructor = Other`), so only a class whose + // instances never convert answers its name at compile time. + if ( + programClass.subclasses.length === 0 && + programClass.def.jsName !== undefined && + !lowerer.dynConvertible(declared) + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(target), loc }], + result: strLit(programClass.def.jsName, loc), + type: STRING, + loc, + }; + } + if (!lowerer.dynConvertible(declared)) return null; + const slot = lowerer.declareHiddenLocal("%constructorNameReceiver", DYN); + const value = varRef(slot.id, DYN, loc); + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: slot.id, + init: lowerer.coerceToExpected(lowerer.lowerExpr(target), DYN), + loc, + }, + ], + result: lowerer.coerceToExpected(constructorNameRead(value, loc), STRING), + type: STRING, + loc, + }; +} + +/** `value.constructor.name` read through the checked-dynamic tree. */ +function constructorNameRead(value: IrExpr, loc: IrExpr["loc"]): IrExpr { + return { + kind: "dynKeyGet", + value: { kind: "dynKeyGet", value, key: strLit("constructor", loc), type: DYN, loc }, + key: strLit("name", loc), + type: DYN, + loc, + }; +} + +/** The constructor name of an error-typed value. */ +function errorConstructorName( + lowerer: Lowerer, + target: ts.Expression, + className: string, + programClass: ClassInfo | undefined, + loc: IrExpr["loc"], +): IrExpr | null { + const receiver = lowerer.lowerExpr(target); + if (receiver.type.kind !== "object") return null; + const type = receiver.type; + const extendsStatic = (candidate: ClassInfo): boolean => { + for (let c: ClassInfo | null = candidate; c; c = c.base) { + if (c.def.name === className) return true; + } + return false; + }; + const depth = (candidate: ClassInfo): number => { + let n = 0; + for (let c = candidate.base; c; c = c.base) n++; + return n; + }; + const candidates = [...lowerer.classes.values()] + .filter( + (candidate) => + !candidate.def.runtime && + candidate.def.jsName !== undefined && + candidate.decl != null && + extendsStatic(candidate), + ) + .sort((a, b) => depth(b) - depth(a)); + if (candidates.some((candidate) => candidate.fields.has("constructor"))) return null; + const slot = lowerer.declareHiddenLocal("%constructorNameReceiver", type); + const value = (): IrExpr => varRef(slot.id, type, loc); + // A program class is the most general candidate of its own hierarchy, so + // it answers when no subclass matches. A builtin static class falls back + // to the native error's class. + let result: IrExpr = + programClass?.def.jsName !== undefined && candidates.at(-1) === programClass + ? strLit(programClass.def.jsName, loc) + : lowerer.coerceToExpected( + { + kind: "libCall", + fn: "dyn.errorConstructorName", + args: [ + lowerer.coerceToExpected(value(), DYN), + dynUndefinedExpr(loc), + dynUndefinedExpr(loc), + ], + type: DYN, + loc, + }, + STRING, + ); + const tested = result.kind === "strLit" ? candidates.slice(0, -1) : candidates; + for (const candidate of tested.toReversed()) { + result = { + kind: "ternary", + cond: { kind: "instanceOf", value: value(), className: candidate.def.name, type: BOOL, loc }, + then: strLit(candidate.def.jsName!, loc), + else_: result, + type: STRING, + loc, + }; + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: slot.id, init: receiver, loc }], + result, + type: STRING, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 22bcb66099..251e99fa8b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,5 +1,6 @@ /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import * as ts from "../../ts7/adapter.js"; +import { lowerConstructorName } from "../constructor-name.js"; import { lowerHistogramProperty } from "../builtins/performance.js"; import { staticFunctionNameRead } from "../static-function-names.js"; import { typedArgumentsArity } from "../typed-arguments.js"; @@ -313,6 +314,11 @@ export function lowerPropertyAccessExpr( if (table) return table; } lowerThreadSlotsProperty(lowerer, expr); + // `value.constructor.name` (constructor-name.ts). + { + const constructorName = lowerConstructorName(lowerer, expr); + if (constructorName) return constructorName; + } // `EventEmitter.name`, `zlib.Gzip.name`, `C.name` over a builtin // class alias. if (expr.name.text === "name" && !expr.questionDotToken) { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 142b1e25c1..d1985cc716 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -10,6 +10,9 @@ import type { IrLibFn } from "./builtin-signatures.js"; export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", + // value.constructor.name on a value whose constructor is undefined or + // null throws Node's TypeError for the name read. + "dyn.errorConstructorName", // node:test's { timeout } and { concurrency } range checks. "test.register", "test.registerEmpty", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 99ca04ae4c..9629183844 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3311,6 +3311,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.toStringCoerce": { argTypes: [DYN], result: STRING }, "dyn.stringConstructor": { argTypes: [DYN], result: STRING }, "dyn.propertyKey": { argTypes: [DYN], result: DYN }, + // value.constructor.name from the value, its constructor and the name + // read from it: that name, or the class name of the native error the + // value holds. + "dyn.errorConstructorName": { argTypes: [DYN, DYN, DYN], result: DYN }, "dyn.toNumeric": { argTypes: [DYN], result: DYN }, "dyn.increment": { argTypes: [DYN, BOOL], result: DYN }, /** JS ToNumber over a dyn value WITH the object protocol (number-hint diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index a086930137..a51fc933b2 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -359,6 +359,7 @@ const DYN_READ_ONLY = new Set([ "dyn.bagGet", "dyn.bitwise", "dyn.compare", + "dyn.errorConstructorName", "dyn.forInKeyStrings", "dyn.forInKeys", "dyn.forInStable", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6006-constructor-name.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6006-constructor-name.ts.json new file mode 100644 index 0000000000..20d7abcd59 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6006-constructor-name.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6006-constructor-name.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6007-constructor-name-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6007-constructor-name-js.js.json new file mode 100644 index 0000000000..93fb1b7a98 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6007-constructor-name-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6007-constructor-name-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 28d642501f..a87b7acace 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1112,6 +1112,11 @@ ScrError *scr_error_from_dyn(const ScrDyn *d); /* scr_async_dyn.c (gated) */ /* The cache's runtime-internal access pair (scr_json.c owns the storage; * the gated extraction reads/writes through these). */ ScrError *scr_errdyn_err_of(const ScrDyn *d); /* +1 or NULL */ +/* value.constructor.name, given the value's constructor and the `name` + * read from it (undefined when the constructor is undefined or null). + * Borrows all three; +1, or NULL after throwing Node's TypeError. */ +ScrDyn *scr_dyn_error_constructor_name(const ScrDyn *value, const ScrDyn *constructor, + const ScrDyn *name); void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */ /* Sets an own property on an error: its checked-dynamic node, which casts * and util.inspect read. Moves value. */ diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 4f514681b8..69d965a784 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -270,6 +270,15 @@ void scr_error_install_name_reader(ScrStr *(*reader)(ScrError *)) { scr_error_name_reader = reader; } +/* The builtin class that constructed the error, as `error.constructor.name` + * reads it: the vtable's class, not the error's assignable `name`. + * DOMException is its own class. +1. */ +ScrStr *scr_error_class_name(const ScrError *e) { + if (scr_error_vts[SCR_ERR_DOMEX].pre <= e->vt->pre && e->vt->pre <= scr_error_vts[SCR_ERR_DOMEX].post) + return scr_str_new("DOMException", 12); + return scr_error_default_name(e); +} + ScrStr *scr_error_default_name(const ScrError *e) { for (int i = 1; i < SCR_ERR_COUNT; i++) { if (i == SCR_ERR_DOMEX) continue; diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index a7c921f236..73a3b2c9db 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -251,6 +251,7 @@ ScrStr *scr_error_to_string(ScrError *e); void scr_error_install_message_reader(ScrStr *(*reader)(ScrError *)); void scr_error_install_name_reader(ScrStr *(*reader)(ScrError *)); ScrStr *scr_error_default_name(const ScrError *e); +ScrStr *scr_error_class_name(const ScrError *e); ScrStr *scr_error_stack(ScrError *e); /* Replaces the error's stack string, as an assignment to `stack` does. * Borrowed operands. */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9746e8eb11..7de07d9c19 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -7151,6 +7151,34 @@ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *va scr_dyn_release(view); } +/* `value.constructor.name` after the compiler read the value's + * constructor and its `name` (undefined when the constructor is + * undefined or null). A string name answers itself. A value that holds a + * native error has no constructor value to read, so it answers the error's + * class name, which an assigned `name` property does not change. Any + * other undefined or null constructor throws Node's TypeError for the + * `name` read. */ +ScrDyn *scr_dyn_error_constructor_name(const ScrDyn *value, const ScrDyn *constructor, + const ScrDyn *name) { + if (name->kind == SCR_DYN_STR) return scr_dyn_retain((ScrDyn *)name); + ScrError *error = scr_errdyn_err_of(value); + if (error) { + ScrStr *class_name = scr_error_class_name(error); + scr_error_release(error); + ScrDyn *out = scr_dyn_new_str(class_name); + scr_str_release(class_name); + return out; + } + if (constructor->kind == SCR_DYN_UNDEF || constructor->kind == SCR_DYN_NULL) { + char message[64]; + int n = snprintf(message, sizeof message, "Cannot read properties of %s (reading 'name')", + constructor->kind == SCR_DYN_NULL ? "null" : "undefined"); + scr_throw_error_msg(SCR_ERR_TYPE, message, (size_t)n); + return NULL; + } + return scr_dyn_retain((ScrDyn *)name); +} + /* The error's checked-dynamic node when it has one (borrowed), else NULL: * util.inspect reads its extra own properties. */ ScrDyn *scr_errdyn_view_of(const ScrError *e) { diff --git a/tests/corpus/6006-constructor-name.ts b/tests/corpus/6006-constructor-name.ts new file mode 100644 index 0000000000..22185129d1 --- /dev/null +++ b/tests/corpus/6006-constructor-name.ts @@ -0,0 +1,80 @@ +// value.constructor.name names the class that constructed the value: +// program classes (with and without subclasses), builtin errors, program +// error classes, DOMException, and errors whose `name` was reassigned. +class Shape { + describe(): string { + return `${this.constructor.name} shape`; + } +} +class Circle extends Shape {} +class Square extends Shape {} +class Lone {} + +const shapes: Shape[] = [new Shape(), new Circle(), new Square()]; +for (const s of shapes) console.log(s.constructor.name, s.describe()); +console.log(new Lone().constructor.name); + +const Anonymous = class {}; +console.log(new Anonymous().constructor.name); + +class AppError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} +class NotFound extends AppError {} +class Conflict extends AppError {} + +const failures: AppError[] = [new AppError("a"), new NotFound("b"), new Conflict("c")]; +for (const f of failures) console.log(f.constructor.name, f.name, String(f)); + +const errors: Error[] = [ + new Error("e"), + new TypeError("t"), + new RangeError("r"), + new SyntaxError("s"), + new URIError("u"), + new EvalError("v"), + new ReferenceError("f"), + new AggregateError([], "g"), + new NotFound("n"), +]; +for (const e of errors) console.log(e.constructor.name, e.name); + +const renamed = new RangeError("renamed"); +renamed.name = "Custom"; +console.log(renamed.constructor.name, renamed.name); + +const abort = new DOMException("stopped", "AbortError"); +console.log(abort.constructor.name, abort.name); + +function kind(error: unknown): string { + return error instanceof Error ? error.constructor.name : typeof error; +} +try { + JSON.parse("{"); +} catch (error) { + console.log(kind(error)); +} +try { + throw new Conflict("thrown"); +} catch (error) { + console.log(kind(error), (error as Error).constructor.name); +} +console.log(kind("text")); + +// A constructor replaced through an untyped value answers the +// replacement's name, for classes with and without subclasses. +class Replacement {} +const replaced = new Lone(); +const untyped: any = replaced; +untyped.constructor = { name: "Fake" }; +console.log(replaced.constructor.name); +const defined = new Lone(); +Object.defineProperty(defined, "constructor", { value: Replacement }); +console.log(defined.constructor.name); +const shape: Shape = new Circle(); +const untypedShape: any = shape; +untypedShape.constructor = Replacement; +console.log(shape.constructor.name, new Lone().constructor.name); diff --git a/tests/corpus/6007-constructor-name-js.js b/tests/corpus/6007-constructor-name-js.js new file mode 100644 index 0000000000..8e421344b5 --- /dev/null +++ b/tests/corpus/6007-constructor-name-js.js @@ -0,0 +1,45 @@ +// value.constructor.name in JavaScript: untyped caught errors answer the +// class that constructed them, including builtin errors whose `name` was +// reassigned, DOMException, and program error classes. +class Shape {} +class Circle extends Shape {} +class AppError extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + } +} +class NotFound extends AppError {} + +for (const s of [new Shape(), new Circle()]) console.log(s.constructor.name); + +function report(error) { + console.log(error.constructor.name, error.name, error.message); +} +try { + throw new RangeError("range"); +} catch (error) { + report(error); +} +try { + const e = new TypeError("type"); + e.name = "Renamed"; + throw e; +} catch (error) { + report(error); +} +try { + throw new DOMException("stopped", "AbortError"); +} catch (error) { + report(error); +} +try { + throw new NotFound("missing"); +} catch (error) { + report(error); +} +try { + JSON.parse("{"); +} catch (error) { + console.log(error.constructor.name); +} diff --git a/tests/harness/corpus-costs/6006-constructor-name.ts.json b/tests/harness/corpus-costs/6006-constructor-name.ts.json new file mode 100644 index 0000000000..5a4309d85c --- /dev/null +++ b/tests/harness/corpus-costs/6006-constructor-name.ts.json @@ -0,0 +1 @@ +{ "plain": 2094, "san": 2594 } diff --git a/tests/harness/corpus-costs/6007-constructor-name-js.js.json b/tests/harness/corpus-costs/6007-constructor-name-js.js.json new file mode 100644 index 0000000000..61dd852cba --- /dev/null +++ b/tests/harness/corpus-costs/6007-constructor-name-js.js.json @@ -0,0 +1 @@ +{ "plain": 1883, "san": 2430 }