Support Symbol.toStringTag class fields and getters

This commit is contained in:
Chris Tate
2026-10-23 15:47:49 -05:00
parent 24a89488f9
commit e0d0e03eed
21 changed files with 620 additions and 48 deletions
@@ -1,4 +1,5 @@
- **Symbol fields:** Instance fields require a stable module-level `Symbol()` or `Symbol.for()` binding with a literal description or constant string binding.
A `[Symbol.toStringTag]` field or getter with a string value sets the tag that `String()`, `Object.prototype.toString` and `util.inspect` print; fields keyed by other well-known symbols are unsupported.
Reassigned keys, runtime-computed descriptions, distinct symbols with the same description in one class, and copying symbol fields with rest bindings or `Object.assign` are unsupported.
JavaScript object spreads copy represented own symbol fields.
Equal registry keys share a field across modules.
@@ -20,6 +20,7 @@ import {
isRefCounted,
shapeHasAccessorSlots,
typeKey,
type IrClassDef,
} from "../../ir/ir.js";
import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
@@ -277,9 +278,7 @@ export function streamTypedRefCommitAdapter(
);
});
}
const symbols = new Map(
(meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol.globalId]),
);
const symbols = new Map((meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol]));
const fields = meta.def.fields.filter(
(field) =>
(isClassOwnEnumerableFieldName(field.name) || symbols.has(field.name)) &&
@@ -299,13 +298,15 @@ export function streamTypedRefCommitAdapter(
const inline = host.inlineUnions.classField(t.className, field.name);
const fieldTy = int32 ? "i32" : llFieldType(field.type, inline);
const checkTy = host.llType(field.type);
const symbolGlobal = symbols.get(field.name);
if (symbolGlobal) {
const symbolKey = symbols.get(field.name);
if (symbolKey) {
host.declare(`declare ptr @scr_dyn_symbol_key_get(ptr, ptr, i1 zeroext)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
const keyLines = symbolKeyLines(host, symbolKey, `f${index}_keyraw`);
lines.push(
` %f${index}_keyraw = load ptr, ptr @${mangleGlobal(symbolGlobal)}`,
...keyLines.load,
` %f${index}_key = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %f${index}_keyraw)`,
...keyLines.release,
);
if (field.type.kind === "symbol") {
// A base constructor can mutate the instance before this
@@ -333,7 +334,7 @@ export function streamTypedRefCommitAdapter(
lines.push(
` %${raw} = call ptr @scr_dyn_obj_get(ptr %d, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`,
);
if (meta.def.tracksOwnFields && !symbolGlobal)
if (meta.def.tracksOwnFields && !symbolKey)
lines.push(
` %f${index}_present = icmp ne ptr %${raw}, null`,
` br i1 %f${index}_present, label %f${index}_read, label %${after}`,
@@ -344,7 +345,7 @@ export function streamTypedRefCommitAdapter(
` %${undef} = call ptr @scr_dyn_undefined()`,
` %${input} = select i1 %${missing}, ptr %${undef}, ptr %${raw}`,
` %${next} = call ${field.type.kind === "bool" ? "zeroext " : ""}${checkTy} @${host.dyn.dynCheckHelper(field.type)}(ptr %${input}, ptr null)`,
...(symbolGlobal ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []),
...(symbolKey ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []),
...host.pendingTestLines(`%${pending}`),
` br i1 %${pending}, label %done, label %${store}`,
`${store}:`,
@@ -499,16 +500,18 @@ export function streamTypedRefCommitAdapter(
? `declare void @scr_dyn_symbol_key_placeholder(ptr, ptr)`
: `declare void @scr_dyn_key_delete_computed(ptr, ptr, i1 zeroext)`,
);
[...symbols.values()].forEach((globalId, index) =>
[...symbols.values()].forEach((symbol, index) => {
const keyLines = symbolKeyLines(host, symbol, `bag_symraw${index}`);
lines.push(
` %bag_symraw${index} = load ptr, ptr @${mangleGlobal(globalId)}`,
...keyLines.load,
` %bag_sym${index} = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %bag_symraw${index})`,
...keyLines.release,
meta.def.tracksOwnFields
? ` call void @scr_dyn_symbol_key_placeholder(ptr %bag, ptr %bag_sym${index})`
: ` call void @scr_dyn_key_delete_computed(ptr %bag, ptr %bag_sym${index}, i1 zeroext false)`,
` call void @scr_dyn_release_v(ptr %bag_sym${index})`,
),
);
);
});
const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES);
lines.push(
` %bag_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${index}`,
@@ -705,6 +708,27 @@ export function liveDynUnionRefAdapter(
return sym;
}
/** The raw ScrSym of a class's symbol field key into `%name`: a module
* key is borrowed from its global, a well-known one comes from the
* runtime's registry (+1, released by the `release` lines once the key
* value holds it). */
function symbolKeyLines(
host: LlvmEmitterContext,
symbol: NonNullable<IrClassDef["symbolFields"]>[number],
name: string,
): { load: string[]; release: string[] } {
if ("globalId" in symbol)
return { load: [` %${name} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`], release: [] };
host.declare("declare ptr @scr_sym_well_known(ptr)");
host.declare("declare void @scr_sym_release(ptr)");
return {
load: [
` %${name} = call ptr @scr_sym_well_known(ptr ${host.internLiteral(symbol.wellKnown)})`,
],
release: [` call void @scr_sym_release(ptr %${name})`],
};
}
export function streamTypedRefBoxValue(
host: LlvmEmitterContext,
B: BlockBuilder,
@@ -929,8 +953,10 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (inline) fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
B.line(`${rawKey} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`);
const keyLines = symbolKeyLines(host, symbol, rawKey.slice(1));
for (const line of keyLines.load) B.line(line.trim());
const key = host.streamTypedRefBoxValue(B, SYMBOL_T, rawKey);
for (const line of keyLines.release) B.line(line.trim());
const afterSymbol =
meta.def.tracksOwnFields || field.type.kind === "symbol"
? B.newLabel("symbol.after")
@@ -1473,9 +1499,26 @@ function liveClassOps(host: LlvmEmitterContext, snapshot: string, meta: LlClassM
// The constructor's name, as util.inspect prints before the fields.
const jsName = meta.def.jsName ?? meta.def.name.replace(/^%/, "");
const className = jsName === "" ? "null" : host.internLiteral(jsName);
// Its Symbol.toStringTag getter's string, for Object.prototype.toString
// and util.inspect.
let stringTag = "null";
if (meta.def.stringTagHelper) {
// The runtime lends the instance; the helper takes its own reference.
stringTag = `@${snapshot}_ctag`;
const retain = vAdapters(host.shapeHost, { kind: "object", className: meta.def.name }).retain;
host.resolveThunkDefs.push(
`define internal ptr ${stringTag}(ptr %p) ${FN_ATTRS} { ; Symbol.toStringTag of ${meta.def.name}`,
`entry:`,
` %owned = call ptr ${retain}(ptr %p)`,
` %tag = call ptr @${mangleFunction(meta.def.stringTagHelper)}(ptr %owned)`,
` ret ptr %tag`,
`}`,
``,
);
}
host.resolveThunkDefs.push(
`@${names} = internal constant [${members.length} x ptr] [${literals.join(", ")}]`,
`@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className} }`,
`@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className}, ptr ${stringTag} }`,
``,
);
}
@@ -1,4 +1,5 @@
import { primitiveConstructorOr } from "./lower-exprs.js";
import { TO_STRING_TAG_FIELD, wellKnownSymbolOfField } from "./symbol-fields.js";
import { InternalCompilerError } from "../../errors.js";
import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js";
import { constructedValue } from "./constructor-returns.js";
@@ -26,6 +27,7 @@ import {
isUnitType,
typeEquals,
typeKey,
type IrClassDef,
type IrExpr,
type IrFunction,
type IrParam,
@@ -153,6 +155,41 @@ const STREAM_NUM_PROPERTIES = new Set([
const streamProperty = (name: string): boolean =>
STREAM_BOOL_PROPERTIES.has(name) || STREAM_NUM_PROPERTIES.has(name);
/** `(instance) => string`, the string a class's inherited
* Symbol.toStringTag getter answers, which the runtime reads for
* Object.prototype.toString and util.inspect of an instance seen as
* `unknown`. None for a class without a string getter, or whose own tag
* field shadows it. */
function stringTagHelper(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): IrFunction | null {
if (info.fields.has(TO_STRING_TAG_FIELD)) return null;
const getter = findMethodOn(lowerer, info, "get:sym:toStringTag");
if (getter?.sig.ret.kind !== "string") return null;
const type: IrType = { kind: "object", className: info.def.name };
const owner = getter.declarer.def.name;
return {
name: `%class.stringTag:${info.def.name}`,
params: [{ localId: "this", name: "this", type }],
locals: [{ id: "this", name: "this", type, mutable: false }],
returnType: STRING,
body: [
{
kind: "return",
value: accessorCall(
lowerer,
owner,
"get:sym:toStringTag",
lowerer.upcastTo(varRef("this", type, loc), owner),
[],
STRING,
loc,
),
loc,
},
],
loc,
};
}
function symbolMemberKey(
lowerer: Lowerer,
info: ClassInfo,
@@ -1276,10 +1313,27 @@ export class ClassDynamicDispatch {
const info = lowerer.classes.get(className);
if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue;
if (info.symbolFields && info.def.symbolFields === undefined) {
info.def.symbolFields = [...info.symbolFields].flatMap(([symbol, field]) => {
const global = lowerer.globalsBySymbol.get(symbol);
return global?.type.kind === "symbol" ? [{ field, globalId: global.id }] : [];
});
info.def.symbolFields = [...info.symbolFields].flatMap(
([symbol, field]): NonNullable<IrClassDef["symbolFields"]> => {
const global = lowerer.globalsBySymbol.get(symbol);
if (global?.type.kind === "symbol") return [{ field, globalId: global.id }];
const wellKnown = wellKnownSymbolOfField(field);
return wellKnown === null ? [] : [{ field, wellKnown }];
},
);
}
const tagged = (current: ClassInfo): ClassInfo[] => [
current,
...current.subclasses.flatMap(tagged),
];
for (const current of tagged(info)) {
if (current.def.stringTagHelper !== undefined) continue;
const fn = stringTagHelper(lowerer, current, functions[0]!.loc);
if (!fn) continue;
current.def.stringTagHelper = fn.name;
this.generated.add(fn);
lowerer.liftedFns.push(fn);
changed = true;
}
if (this.prototypeHelper && !this.prototypeClasses.has(className)) {
this.prototypeClasses.add(className);
@@ -3554,10 +3608,29 @@ export class ClassDynamicDispatch {
name,
lowerer.coerceToExpected(varRef(global.id, global.type, loc), DYN),
);
} else if (wellKnownSymbolOfField(name) !== null) {
// A field keyed by a well-known symbol (Symbol.toStringTag).
symbols.set(name, {
kind: "dynFrom",
type: DYN,
loc,
value: {
kind: "libCall",
fn: "sym.wellKnown",
args: [
{ kind: "strLit", value: wellKnownSymbolOfField(name)!, type: STRING, loc },
],
type: SYMBOL_T,
loc,
},
});
}
}
for (let owner: ClassInfo | null = info; owner; owner = owner.base) {
for (const name of [...owner.methods.keys()])
for (const method of [...owner.methods.keys()]) {
// Accessors key by the property they define (`get [Symbol.toStringTag]`).
const name =
method.startsWith("get:") || method.startsWith("set:") ? method.slice(4) : method;
if (name.startsWith("sym:")) {
symbols.set(name, {
kind: "dynFrom",
@@ -3572,6 +3645,7 @@ export class ClassDynamicDispatch {
},
});
}
}
}
const branches: IrStmt[] = [];
if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) {
@@ -394,7 +394,19 @@ export function collectClassMethodMutations(
(ts.isElementAccessExpression(base) &&
ts.isStringLiteralLike(base.argumentExpression) &&
base.argumentExpression.text === "prototype");
if (prototype) {
// `Object.prototype.toString.call(value)` calls the builtin and
// keeps no reference to it, so it replaces nothing.
const builtinCall =
ts.isPropertyAccessExpression(node) &&
ts.isPropertyAccessExpression(base) &&
ts.isIdentifier(unwrap(base.expression)) &&
lowerer.isStdlibGlobal(unwrap(base.expression) as ts.Identifier, "Object") &&
ts.isPropertyAccessExpression(node.parent) &&
node.parent.expression === node &&
(node.parent.name.text === "call" || node.parent.name.text === "apply") &&
ts.isCallExpression(node.parent.parent) &&
node.parent.parent.expression === node.parent;
if (prototype && !builtinCall) {
if (ts.isPropertyAccessExpression(node)) lowerer.noteMethodAccess(node.name.text, node);
else if (ts.isStringLiteralLike(node.argumentExpression))
lowerer.noteMethodAccess(node.argumentExpression.text, node);
@@ -13,6 +13,7 @@ import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js";
import { isClassCallback } from "./class-callbacks.js";
import { TO_STRING_TAG_FIELD } from "./symbol-fields.js";
import { isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { DATE_TIME_FIELD, dateRootOf } from "./builtins/date-classes.js";
import { dateValueMethod } from "./builtins/dates.js";
@@ -29,7 +30,15 @@ type ConversionMethod = "sym:toPrimitive" | "valueOf" | "toString";
* Date`, or through Object.prototype.toString ("[object Object]"). */
type Plan =
| { method: ConversionMethod; ret: IrType }
| { method: null; date?: "toString" | "valueOf" };
| { method: null; date?: "toString" | "valueOf"; tag?: ToStringTag };
/** Where Object.prototype.toString finds a class's Symbol.toStringTag: a
* string field (`[Symbol.toStringTag] = "LRUCache"`) or a getter that
* returns a string, declared on `owner`. */
interface ToStringTag {
owner: ClassInfo;
getter: boolean;
}
const PRIMITIVE_KINDS = new Set(["f64", "string", "bool", "bigint", "undefinedT", "nullT"]);
@@ -52,6 +61,17 @@ export function classToStringConversion(
);
}
/** Object.prototype.toString of a program class instance: "[object " +
* its Symbol.toStringTag + "]" (an own tag field, else an inherited
* getter), or "[object Object]". The class's own conversion methods do not
* take part. Null as for classToStringConversion, and for Date subclasses. */
export function classObjectTag(lowerer: Lowerer, value: IrExpr, node: ts.Node): IrExpr | null {
if (value.type.kind !== "object") return null;
const info = lowerer.classes.get(value.type.className);
if (!info || dateRootOf(info) !== null) return null;
return classConversion(lowerer, value, "tag", node, STRING, (tag) => tag);
}
/** ToNumber(ToPrimitive(value, "number")) of a program class instance
* (`Number(x)`): `[Symbol.toPrimitive]("number")`, then `valueOf` and then
* `toString`, and NaN for "[object Object]". Null as for
@@ -122,7 +142,7 @@ function mayBeBigint(lowerer: Lowerer, type: IrType): boolean {
function classConversion(
lowerer: Lowerer,
value: IrExpr,
hint: ConversionHint,
hint: ConversionHint | "tag",
node: ts.Node,
resultType: IrType,
finish: (primitive: IrExpr) => IrExpr,
@@ -131,7 +151,7 @@ function classConversion(
const info = lowerer.classes.get(value.type.className);
if (!info || !plainProgramClass(info)) return null;
const classes = subtree(lowerer, info);
for (const cls of classes) refuseReplacedConversions(lowerer, cls, node);
if (hint !== "tag") for (const cls of classes) refuseReplacedConversions(lowerer, cls, node);
const plans = new Map(classes.map((cls) => [cls, planOf(lowerer, cls, hint, node)]));
// A class whose plan differs from its base's starts a group; the deepest
// groups are tested first, so each instance takes its own class's plan.
@@ -164,6 +184,41 @@ function classConversion(
};
return finish(dateValueMethod(plan.date, time, loc)!);
}
if (plan.method === null && plan.tag) {
// Object.prototype.toString: "[object " + the tag + "]".
const owner = plan.tag.owner.def.name;
const tag: IrExpr = plan.tag.getter
? accessorCall(
lowerer,
owner,
"get:sym:toStringTag",
lowerer.upcastTo(object, owner),
[],
STRING,
loc,
)
: {
kind: "fieldGet",
obj: lowerer.upcastTo(object, owner),
className: owner,
field: TO_STRING_TAG_FIELD,
type: STRING,
loc,
};
return finish({
kind: "strConcat",
left: {
kind: "strConcat",
left: { kind: "strLit", value: "[object ", type: STRING, loc },
right: tag,
type: STRING,
loc,
},
right: { kind: "strLit", value: "]", type: STRING, loc },
type: STRING,
loc,
});
}
if (plan.method === null)
return finish({ kind: "strLit", value: "[object Object]", type: STRING, loc });
const args: IrExpr[] =
@@ -228,10 +283,28 @@ function depth(info: ClassInfo): number {
}
function samePlan(a: Plan, b: Plan): boolean {
return (
a.method === b.method &&
(a.method !== null || b.method !== null || a.date === (b as { date?: string }).date)
);
if (a.method !== null || b.method !== null) return a.method === b.method;
return a.date === b.date && a.tag?.owner === b.tag?.owner && a.tag?.getter === b.tag?.getter;
}
/** The class's Symbol.toStringTag, when it has one: the class at the top
* of the chain whose layout holds the tag field (an own property, which
* shadows any getter), or its getter. Other tag types would make
* Object.prototype.toString answer "Object" or the value depending on the
* run, and are refused. */
function toStringTagOf(lowerer: Lowerer, info: ClassInfo, node: ts.Node): ToStringTag | null {
const type = info.fields.get(TO_STRING_TAG_FIELD);
if (type !== undefined) {
if (type.kind !== "string") refuse(lowerer, node, info, "a non-string Symbol.toStringTag");
let owner = info;
while (owner.base?.fields.has(TO_STRING_TAG_FIELD)) owner = owner.base;
return { owner, getter: false };
}
const getter = findMethodOn(lowerer, info, "get:sym:toStringTag");
if (!getter) return null;
if (getter.sig.ret.kind !== "string")
refuse(lowerer, node, info, "a non-string Symbol.toStringTag");
return { owner: getter.declarer, getter: true };
}
/** A conversion method the class declares or inherits (a field of that
@@ -246,7 +319,16 @@ function declared(
return found ? { ret: found.sig.ret } : null;
}
function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: ts.Node): Plan {
function planOf(
lowerer: Lowerer,
info: ClassInfo,
hint: ConversionHint | "tag",
node: ts.Node,
): Plan {
if (hint === "tag") {
const tag = toStringTagOf(lowerer, info, node);
return tag ? { method: null, tag } : { method: null };
}
const exotic = declared(lowerer, info, "sym:toPrimitive");
if (exotic) {
if (!primitive(lowerer, exotic.ret))
@@ -271,7 +353,8 @@ function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: t
refuse(lowerer, node, info, `a '${method}' method that can return an object`);
return { method, ret: found.ret };
}
return { method: null };
const tag = toStringTagOf(lowerer, info, node);
return tag ? { method: null, tag } : { method: null };
}
function primitive(lowerer: Lowerer, type: IrType): boolean {
@@ -292,8 +375,6 @@ function refuseReplacedConversions(lowerer: Lowerer, info: ClassInfo, node: ts.N
if (lowerer.prototypeMethodAccesses.has(method))
refuse(lowerer, node, info, `a '${method}' the program reaches through a prototype`);
}
if (findMethodOn(lowerer, info, "get:sym:toStringTag") || info.fields.has("sym:toStringTag"))
refuse(lowerer, node, info, "a Symbol.toStringTag");
}
function refuse(lowerer: Lowerer, node: ts.Node, info: ClassInfo, what: string): never {
@@ -707,11 +707,17 @@ function rewriteFunction(
* unchecked. */
export function refuseUncheckedCycleReads(
lowerer: Lowerer,
classes: readonly { baseValueGlobal?: string; symbolFields?: { globalId: string }[] }[],
classes: readonly {
baseValueGlobal?: string;
symbolFields?: readonly ({ globalId: string } | { wellKnown: string })[];
}[],
): void {
if (lowerer.cycleInitFlags.size === 0) return;
for (const cls of classes) {
const ids = [cls.baseValueGlobal, ...(cls.symbolFields ?? []).map((f) => f.globalId)];
const ids = [
cls.baseValueGlobal,
...(cls.symbolFields ?? []).map((f) => ("globalId" in f ? f.globalId : undefined)),
];
for (const id of ids) {
const flag = id === undefined ? undefined : lowerer.cycleInitFlags.get(id);
if (flag === undefined) continue;
@@ -183,7 +183,7 @@ import {
import { isSafeToDiscard } from "./expressions/evaluation-safety.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerChildMethodValue, lowerChildPropertyCall } from "./handle-properties.js";
import { classToNumberConversion } from "./class-to-primitive.js";
import { classObjectTag, classToNumberConversion } from "./class-to-primitive.js";
import { hasRuntimeStatics } from "./class-runtime-statics.js";
import { structuralReadType } from "./class-unions.js";
import {
@@ -3103,6 +3103,10 @@ function lowerObjectPrototypeCall(
const immediate = immediateObjectTagReceiver(lowerer, receiverNode);
const receiver = immediate?.effect ?? lowerer.lowerExpr(receiverNode);
const tag = immediate?.tag ?? objectToStringTag(lowerer, receiver.type);
if (tag === null && immediate === null && call.arguments.nodes.length === 1) {
const classTag = classObjectTag(lowerer, receiver, receiverNode);
if (classTag) return classTag;
}
// A generator object answers its tag through its checked capsule.
if (tag === null && receiver.type.kind !== "dyn" && receiver.type.kind !== "generator") {
lowerer.noLowering(
@@ -8156,7 +8156,10 @@ export function classMemberNameOf(lowerer: Lowerer, name: ts.PropertyName): stri
member === "asyncIterator" ||
member === "dispose" ||
member === "asyncDispose" ||
member === "toPrimitive"
member === "toPrimitive" ||
// A toStringTag field keeps its symbol field name; its accessor and
// method forms take the method slot name.
(member === "toStringTag" && !ts.isPropertyDeclaration(name.parent))
) {
return `sym:${member}`;
}
@@ -44,9 +44,9 @@ import {
shapeKeyOrders,
typeKey,
} from "../../ir/ir.js";
import type { ClassInfo } from "./lower-classes.js";
import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js";
import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { symbolFieldDisplayName } from "./symbol-fields.js";
import { symbolFieldDisplayName, TO_STRING_TAG_FIELD } from "./symbol-fields.js";
import { eventTargetRootOf } from "./lower-event-target.js";
import { dateRootOf } from "./builtins/date-classes.js";
import { lowerSystemErrorMap } from "./lower-system-errors.js";
@@ -1870,8 +1870,70 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
// observable way — Node's inspect omits them entirely (verified),
// so a class whose only fields are private prints as `C {}`.
const visible = info.def.fields.filter((f) => isClassOwnEnumerableFieldName(f.name));
// A Symbol.toStringTag getter (an own tag field is listed instead)
// brackets the tag after the name, `Name [tag] {`, when it is a
// nonempty string other than the name.
const tagGetter = info.fields.has(TO_STRING_TAG_FIELD)
? null
: findMethodOn(lowerer, info, "get:sym:toStringTag");
let named: (plain: string, make: (head: IrExpr) => IrExpr) => IrExpr = (plain) =>
strLit(plain, loc);
if (tagGetter?.sig.ret.kind === "string") {
const owner = tagGetter.declarer.def.name;
locals.push({ id: "tag.0", name: "tag", type: STRING, mutable: false });
body.push({
kind: "varDecl",
localId: "tag.0",
init: accessorCall(
lowerer,
owner,
"get:sym:toStringTag",
lowerer.upcastTo(v(), owner),
[],
STRING,
loc,
),
loc,
});
const tag = (): IrExpr => varRef("tag.0", STRING, loc);
const shown: IrExpr = {
kind: "logical",
op: "&&",
left: {
kind: "strEq",
negated: true,
left: tag(),
right: strLit("", loc),
type: BOOL,
loc,
},
right: {
kind: "strEq",
negated: true,
left: tag(),
right: strLit(display, loc),
type: BOOL,
loc,
},
type: BOOL,
loc,
};
const head = concatAll(
[strLit(`${display === "" ? "Object" : display} [`, loc), tag(), strLit("]", loc)],
loc,
);
named = (plain, make) => ({
kind: "ternary",
cond: shown,
then: make(head),
else_: strLit(plain, loc),
type: STRING,
loc,
});
}
const after = (text: string) => (head: IrExpr) => concatAll([head, strLit(text, loc)], loc);
if (visible.length === 0) {
body = [ret(strLit(display === "" ? "{}" : `${display} {}`, loc))];
body.push(ret(named(display === "" ? "{}" : `${display} {}`, after(" {}"))));
break;
}
const get = (field: string, type: IrType): IrExpr => ({
@@ -1882,7 +1944,22 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
type,
loc,
});
body = [depthGate(display === "" ? "[Object]" : `[${display}]`), ...begin()];
body.push(
{
kind: "if",
cond: { kind: "bin", op: ">", left: r(), right: d(), type: BOOL, loc },
then: [
ret(
named(display === "" ? "[Object]" : `[${display}]`, (head) =>
concatAll([strLit("[", loc), head, strLit("]", loc)], loc),
),
),
],
else_: null,
loc,
},
...begin(),
);
// def.fields carries layout order: the base chain first, then own —
// exactly the own-property insertion order of a constructor that
// assigns in declaration order (SEMANTICS.md 36's stance). SYMBOL-
@@ -1911,7 +1988,7 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
ret(
end(
strLit("", loc),
strLit(display === "" ? "{" : `${display} {`, loc),
named(display === "" ? "{" : `${display} {`, after(" {")),
strLit("}", loc),
false,
boolLit(false, loc),
@@ -77,6 +77,8 @@ export function fenceSymbolFieldCopy(
}
export function classSymbolKeyOf(lowerer: Lowerer, key: ts.Expression): ClassSymbolKey | null {
const wellKnown = wellKnownSymbolKeyOf(lowerer, key);
if (wellKnown !== undefined) return wellKnown;
if (
!ts.isIdentifier(key) &&
(!ts.isPropertyAccessExpression(key) || key.questionDotToken || !ts.isIdentifier(key.name))
@@ -90,6 +92,53 @@ export function classSymbolKeyOf(lowerer: Lowerer, key: ts.Expression): ClassSym
return sym ? classSymbolKeyOfSymbol(lowerer, sym) : null;
}
/** Well-known symbols a class field may be keyed by: those no protocol
* consults on an instance, or whose protocol the lowering reads from the
* field (Symbol.toStringTag, by the class string conversions). A field
* keyed by any other well-known symbol (Symbol.iterator, toPrimitive,
* hasInstance, ...) would be skipped by its protocol, so it keeps the
* computed-key refusal. */
const FIELD_WELL_KNOWN_SYMBOLS: ReadonlySet<string> = new Set(["toStringTag"]);
/** The key of `Symbol.<name>` on the standard global: undefined when the
* expression is not one, null when it is a well-known symbol a field may
* not be keyed by. The identity is the lib's declaration of the property,
* one symbol for every program reference. */
function wellKnownSymbolKeyOf(
lowerer: Lowerer,
key: ts.Expression,
): ClassSymbolKey | null | undefined {
while (ts.isParenthesizedExpression(key)) key = key.expression;
if (
!ts.isPropertyAccessExpression(key) ||
key.questionDotToken ||
!ts.isIdentifier(key.expression) ||
!ts.isIdentifier(key.name) ||
!lowerer.isStdlibGlobal(key.expression, "Symbol")
)
return undefined;
const name = key.name.text;
const sym = lowerer.checker.getSymbolAtLocation(key.name);
if (!sym || !(lowerer.typeOf(key).flags & ts.TypeFlags.UniqueESSymbol)) return undefined;
if (!FIELD_WELL_KNOWN_SYMBOLS.has(name)) return null;
return { sym, identity: sym, fieldName: wellKnownSymbolFieldName(name) };
}
/** The layout name of a field keyed by the well-known `Symbol.<name>`: a
* reserved symbol-field name, printed as Node spells the key. */
export function wellKnownSymbolFieldName(name: string): string {
return `${FIELD_PREFIX}Symbol(Symbol.${name})`;
}
/** The well-known symbol a field name is keyed by (`toStringTag`), or null. */
export function wellKnownSymbolOfField(field: string): string | null {
const match = /^%symbol:Symbol\(Symbol\.(\w+)\)$/.exec(field);
return match === null ? null : match[1]!;
}
/** The layout name of a `Symbol.toStringTag` field. */
export const TO_STRING_TAG_FIELD = wellKnownSymbolFieldName("toStringTag");
/** A literal Symbol()/Symbol.for() initialized once at module scope. The
* whole-file proof also covers var declarations emitted by bundlers. Keys
* cannot be used before initialization: a computed class field evaluates
@@ -157,10 +157,12 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, Role> = {
"classes[].errorNameAfter": "other",
"classes[].prototypeDataHelper": "reference",
"classes[].instancePrototypeHelper": "reference",
"classes[].stringTagHelper": "reference",
"classes[].localCaptures[].name": "other",
"classes[].localCaptures[].localId": "other",
"classes[].symbolFields[].field": "other",
"classes[].symbolFields[].globalId": "other",
"classes[].symbolFields[].wellKnown": "other",
"classes[].fields[].name": "other",
"classes[].methods[]": "method",
"classes[].prototypeNames[]": "other",
@@ -281,6 +283,10 @@ const REFERENCE_FIXTURES: Record<string, () => IrModule> = {
...module(),
classes: [{ name: "%m0.Cell", fields: [], instancePrototypeHelper: TARGET, loc }],
}),
"classes[].stringTagHelper": () => ({
...module(),
classes: [{ name: "%m0.Cell", fields: [], stringTagHelper: TARGET, loc }],
}),
"lib.exports[].fnName": () => ({
...module(),
lib: {
@@ -24,6 +24,7 @@ export function moduleFunctionReferences(mod: IrModule): string[] {
for (const cls of mod.classes ?? []) {
if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper);
if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper);
if (cls.stringTagHelper !== undefined) names.push(cls.stringTagHelper);
}
return names;
}
+7 -2
View File
@@ -1477,6 +1477,9 @@ export interface IrClassDef {
prototypeDataHelper?: string;
/** Native instance to its actual prototype, including factory captures and subclasses. */
instancePrototypeHelper?: string;
/** Native instance to the string its inherited Symbol.toStringTag getter
* answers, read by the runtime for an instance seen as `unknown`. */
stringTagHelper?: string;
/** Public JavaScript fields record their creation in the shared property
* bag. Its descriptors preserve presence and insertion order independently
* of native layout order; field values remain in their native slots. */
@@ -1486,8 +1489,10 @@ export interface IrClassDef {
* class of the chain sets `name` during construction, and then an own
* `name` follows every other own property. */
errorNameAfter?: string | null;
/** Stable module symbol identities for public instance layout fields. */
symbolFields?: { field: string; globalId: string }[];
/** Stable symbol identities for public instance layout fields: the
* module global holding the key, or the name of the well-known symbol
* (`toStringTag` for `Symbol.toStringTag`). */
symbolFields?: ({ field: string } & ({ globalId: string } | { wellKnown: string }))[];
/** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC
* helpers, and vtable live in the runtime (ScrError / scr_error_*), so
* backends emit no definitions for it — only the preorder-interval
+22 -3
View File
@@ -501,10 +501,29 @@ export function validateModule(mod: IrModule): IrValidationError[] {
});
}
}
for (const symbol of cls.symbolFields ?? []) {
const global = mod.globals?.find((global) => global.id === symbol.globalId);
if (cls.stringTagHelper !== undefined) {
const helper = functionsByName.get(cls.stringTagHelper);
const receiver = helper?.params[0]?.type;
if (
global?.type.kind !== "symbol" ||
!helper ||
helper.params.length !== 1 ||
receiver?.kind !== "object" ||
receiver.className !== cls.name ||
helper.returnType.kind !== "string" ||
helper.captures !== undefined ||
helper.classCaptures !== undefined
) {
errors.push({
message: `class ${cls.name}: string tag helper must be a noncapturing (instance) => string function`,
loc: cls.loc,
});
}
}
for (const symbol of cls.symbolFields ?? []) {
const global =
"globalId" in symbol ? mod.globals?.find((global) => global.id === symbol.globalId) : null;
if (
("globalId" in symbol ? global?.type.kind !== "symbol" : symbol.wellKnown === "") ||
!cls.fields.some((field) => field.name === symbol.field)
) {
errors.push({ message: `class ${cls.name}: invalid symbol field metadata`, loc: cls.loc });
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7751-class-well-known-symbol-fields.ts"
],
"diags": []
}
+8
View File
@@ -398,6 +398,10 @@ typedef struct ScrDynTypedClassOps {
size_t count;
ScrStr *const *names; /* immortal member-name strings */
ScrStr *class_name; /* immortal constructor name; NULL for an anonymous class */
/* The string an inherited Symbol.toStringTag getter answers for the
* instance (+1), or NULL with a pending exception from the getter;
* NULL when the class has no such getter. */
ScrStr *(*string_tag)(void *instance);
} ScrDynTypedClassOps;
/* A compiled JavaScript class whose instances record their own properties
@@ -915,6 +919,7 @@ void scr_dyn_install_iterator_symbol(ScrDyn *key);
void scr_dyn_install_has_instance_symbol(ScrDyn *key);
void scr_dyn_install_async_iterator_symbol(ScrDyn *key);
void scr_dyn_install_async_dispose_symbol(ScrDyn *key);
void scr_dyn_install_to_string_tag_symbol(ScrDyn *key);
/* True when key is Symbol.asyncDispose (registered by the symbol unit when
* the program names it); handle symbol_get ops outside that unit use it. */
bool scr_dyn_is_async_dispose_symbol(const ScrDyn *key);
@@ -1072,6 +1077,9 @@ ScrDyn *scr_record_source_apply(ScrDyn *snapshot, const void *rec);
/* Attach the prototype member names of a class instance capsule
* (idempotent), so `in` sees its methods and accessors. */
void scr_dyn_typed_ref_bind_class(ScrDyn *d, const ScrDynTypedClassOps *ops);
/* A class instance capsule's inherited Symbol.toStringTag string from its
* class's getter (+1); NULL when it has none or the getter threw. */
ScrStr *scr_dyn_typed_ref_class_tag(const ScrDyn *d);
/* Attach the bag and prototype hooks of a capsule of a bag-tracked class
* instance (idempotent). */
void scr_dyn_typed_ref_bind_bag_class(ScrDyn *d, const ScrDynTypedBagClassOps *ops);
+28 -2
View File
@@ -1207,6 +1207,19 @@ static bool insp_tag_names_class(const char *tag, const char *cls) {
memcmp(tag + tag_len - cls_len, cls, cls_len) == 0;
}
/* The Symbol.toStringTag a class instance inherits from its class's
* getter (+1), which Node brackets after the class name when it is a
* nonempty string other than the name. An own tag field is listed with
* the fields instead. */
static ScrStr *insp_inherited_tag(const ScrDyn *instance, const ScrStr *name) {
ScrStr *tag = scr_dyn_typed_ref_class_tag(instance);
if (tag && (tag->len == 0 || (tag->len == name->len && memcmp(tag->data, name->data, name->len) == 0))) {
scr_str_release(tag);
return NULL;
}
return tag;
}
/* A native Set or Map held as a checked-dynamic value: `Set(n) { a, b }`,
* `Map(n) { k => v }`, at most 100 entries like Node's maxArrayLength. */
/* Node's formatWeakCollection: the entries of a WeakMap or WeakSet are not
@@ -1725,10 +1738,23 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) {
free(cls);
} else if (name && materialized->kind == SCR_DYN_OBJ &&
scr_dyn_obj_get(materialized, "%error", 6) == NULL) {
char *cls = malloc(name->len + 1);
ScrStr *tag = insp_inherited_tag(d, name);
if (scr_exc_pending()) {
scr_dyn_release(materialized);
return scr_str_new("", 0);
}
size_t tag_len = tag ? tag->len + 3 : 0;
char *cls = malloc(name->len + tag_len + 1);
if (!cls) scr_trap("scriptc: out of memory\n");
memcpy(cls, name->data, name->len);
cls[name->len] = '\0';
if (tag) {
/* Node's `Name [tag]` prefix. */
memcpy(cls + name->len, " [", 2);
memcpy(cls + name->len + 2, tag->data, tag->len);
cls[name->len + tag_len - 1] = ']';
scr_str_release(tag);
}
cls[name->len + tag_len] = '\0';
size_t nseen = g_nseen;
g_pending_seen = instance;
out = scr_insp_obj(materialized, recurse, depth, cls);
+70 -1
View File
@@ -202,6 +202,19 @@ void scr_dyn_install_has_instance_symbol(ScrDyn *key) {
scr_atexit(scr_has_instance_symbol_cleanup);
}
/* Symbol.toStringTag, installed when the program first evaluates it: no
* object holds the key before then. */
static SCR_TL ScrDyn *scr_to_string_tag_symbol;
static void scr_to_string_tag_symbol_cleanup(void) {
scr_dyn_release(scr_to_string_tag_symbol);
scr_to_string_tag_symbol = NULL;
}
void scr_dyn_install_to_string_tag_symbol(ScrDyn *key) {
if (scr_to_string_tag_symbol) return;
scr_to_string_tag_symbol = scr_dyn_retain(key);
scr_atexit(scr_to_string_tag_symbol_cleanup);
}
void scr_dyn_install_iterator_symbol(ScrDyn *key) {
if (scr_iterator_symbol) return;
scr_iterator_symbol = scr_dyn_retain(key);
@@ -3884,6 +3897,12 @@ static bool scr_dyn_name_in_strs(const ScrStr *key, ScrStr *const *names, size_t
return false;
}
ScrStr *scr_dyn_typed_ref_class_tag(const ScrDyn *d) {
if (!d || d->kind != SCR_DYN_TYPED_REF || d->prototype) return NULL;
const ScrDynTypedClassOps *ops = scr_typed_ref_class_ops(d);
return ops && ops->string_tag ? ops->string_tag(d->v.typed_ref.ptr) : NULL;
}
bool scr_dyn_typed_ref_class_has(const ScrDyn *d, const ScrStr *key) {
if (!d || d->kind != SCR_DYN_TYPED_REF || d->prototype) return false;
const ScrDynTypedClassOps *ops = scr_typed_ref_class_ops(d);
@@ -7616,11 +7635,45 @@ void scr_dyn_throw(ScrDyn *d) {
scr_dyn_trace_v, scr_dyn_is_object(d));
}
static ScrStr *scr_dyn_tag_string(const ScrStr *tag) {
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "[object ");
scr_jb_write(&b, tag->data, tag->len);
scr_jb_putc(&b, ']');
return scr_jb_finish(&b);
}
/* Object.prototype.toString of an object whose own or inherited
* @@toStringTag is a string (+1), NULL otherwise or with a pending
* exception from its getter. */
static ScrStr *scr_dyn_string_tag(const ScrDyn *d) {
ScrStr *getter_tag = scr_dyn_typed_ref_class_tag(d);
if (getter_tag) {
ScrStr *out = scr_dyn_tag_string(getter_tag);
scr_str_release(getter_tag);
return out;
}
if (scr_exc_pending() || !scr_to_string_tag_symbol) return NULL;
ScrDyn *tag = scr_dyn_symbol_key_get(d, scr_to_string_tag_symbol, true);
if (!tag) return NULL;
ScrStr *out = tag->kind == SCR_DYN_STR ? scr_dyn_tag_string(tag->v.str) : NULL;
scr_dyn_release(tag);
return out;
}
ScrStr *scr_dyn_object_tag(const ScrDyn *d) {
if (d->kind == SCR_DYN_PROXY) {
scr_dyn_proxy_unsupported("Symbol.toStringTag lookup");
return NULL;
}
/* Object.prototype.toString answers a string @@toStringTag over the
* builtin tag of a plain object or a class instance. */
if ((d->kind == SCR_DYN_OBJ || (d->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_error(d) &&
!scr_dyn_generator(d)))) {
ScrStr *tag = scr_dyn_string_tag(d);
if (tag || scr_exc_pending()) return tag;
}
if (d->kind == SCR_DYN_TYPED_REF) {
if (scr_dyn_ref_is_error(d)) return scr_str_new("[object Error]", 14);
if (scr_dyn_generator(d)) {
@@ -8838,6 +8891,22 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) {
return NULL;
}
if (d->kind == SCR_DYN_TYPED_REF) {
/* A class instance without its own conversion methods converts with
* Object.prototype.toString, which reads its toStringTag getter. */
ScrStr *to_string_key = scr_str_new("toString", 8), *value_of_key = scr_str_new("valueOf", 7);
bool own_conversion = scr_dyn_typed_ref_class_has(d, to_string_key) ||
scr_dyn_typed_ref_class_has(d, value_of_key);
scr_str_release(to_string_key);
scr_str_release(value_of_key);
if (!own_conversion) {
ScrStr *tag = scr_dyn_typed_ref_class_tag(d);
if (tag) {
ScrStr *out = scr_dyn_tag_string(tag);
scr_str_release(tag);
return out;
}
if (scr_exc_pending()) return NULL;
}
ScrDyn *materialized = scr_dyn_typed_ref_string_view(d);
ScrStr *out = scr_dyn_string_coerce_js(materialized);
scr_dyn_release(materialized);
@@ -8858,7 +8927,7 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) {
scr_error_release(error);
return text;
}
return scr_str_new("[object Object]", 15);
return scr_dyn_object_tag(d);
}
if (!m || m->kind != SCR_DYN_FUNC) { scr_dyn_release(m); continue; }
/* OrdinaryToPrimitive performs a method call, not a bare function
+5
View File
@@ -90,6 +90,11 @@ ScrSym *scr_sym_well_known(ScrStr *name) {
scr_dyn_install_has_instance_symbol(key);
scr_dyn_release(key);
}
if (name->len == 11 && !memcmp(name->data, "toStringTag", 11)) {
ScrDyn *key = scr_dyn_new_symbol(symbol);
scr_dyn_install_to_string_tag_symbol(key);
scr_dyn_release(key);
}
if (name->len == 13 && !memcmp(name->data, "asyncIterator", 13)) {
ScrDyn *key = scr_dyn_new_symbol(symbol);
scr_dyn_install_async_iterator_symbol(key);
@@ -0,0 +1,76 @@
// Class fields keyed by well-known symbols, Symbol.toStringTag first (the
// lru-cache shape), beside toStringTag getters and module symbol keys.
// String conversion, Object.prototype.toString and util.inspect read the
// tag as Node does, through typed values and through `unknown`.
const limit = Symbol("limit");
class Cache {
size = 0;
[limit] = 10;
[Symbol.toStringTag] = "LRUCache";
}
class Sub extends Cache {
extra = 1;
}
class Retag extends Cache {
constructor() {
super();
this[Symbol.toStringTag] = "Retagged";
}
}
class Tagged {
x = 1;
get [Symbol.toStringTag](): string {
return "Custom";
}
}
class TaggedSub extends Tagged {
y = 2;
}
class Named {
get [Symbol.toStringTag](): string {
return "Named";
}
}
class Blank {
get [Symbol.toStringTag](): string {
return "";
}
}
class Plain {
n = 3;
}
const c = new Cache();
console.log(String(c), `${c}`, Object.prototype.toString.call(c), c[Symbol.toStringTag]);
console.log(c);
console.log(Object.keys(c), JSON.stringify(c));
console.log(new Sub(), String(new Sub()));
const r: Cache = new Retag();
console.log(String(r), r);
c[Symbol.toStringTag] = "Other";
console.log(`${c}`, c);
const t = new Tagged();
console.log(String(t), `${t}`, t, [t], { deep: { deeper: { t } } });
const ts: Tagged = new TaggedSub();
console.log(String(ts), ts);
console.log(new Named(), new Blank(), String(new Blank()));
console.log(String(new Plain()), new Plain());
const values: unknown[] = [new Cache(), new Tagged(), new TaggedSub(), new Plain()];
for (const v of values) {
console.log(Object.prototype.toString.call(v), String(v), (v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag]);
}
console.log(values);
const literal: unknown = { [Symbol.toStringTag]: "Literal", a: 1 };
console.log(Object.prototype.toString.call(literal), String(literal), `${literal}`);
@@ -0,0 +1 @@
{ "plain": 3800, "san": 4300 }