diff --git a/packages/compiler/src/frontend/lowering/class-to-primitive.ts b/packages/compiler/src/frontend/lowering/class-to-primitive.ts index a260116f8..8807ac5a5 100644 --- a/packages/compiler/src/frontend/lowering/class-to-primitive.ts +++ b/packages/compiler/src/frontend/lowering/class-to-primitive.ts @@ -225,6 +225,11 @@ function classConversion( plan.method === "sym:toPrimitive" ? [{ kind: "strLit", value: hint, type: STRING, loc }] : []; return finish(accessorCall(lowerer, cls.def.name, plan.method, object, args, plan.ret, loc)); }; + // "[object Object]" reads nothing of the instance, but the receiver + // expression still runs, once. + const own = plans.get(info)!; + if (tops.length === 0 && own.method === null && !own.date && !own.tag && !isSafeToRepeat(value)) + prefix.push({ kind: "exprStmt", expr: value, loc }); let result = convert(info, receiver); for (let i = tops.length - 1; i >= 0; i--) { const top = tops[i]!; diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index eeb4c51da..e5e8b947c 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -18,6 +18,7 @@ import { jsFuncNameOf, type Lowerer } from "./lowerer.js"; import { isJsSourceFile } from "../program.js"; import { BOOL, + CAUGHT, DYN, F64, type IrExpr, @@ -1876,7 +1877,8 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { 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. + // nonempty string other than the name. A getter that throws shows + // no tag, as in Node. const tagGetter = info.fields.has(TO_STRING_TAG_FIELD) ? null : findMethodOn(lowerer, info, "get:sym:toStringTag"); @@ -1884,21 +1886,36 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { 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, + locals.push( + { id: "tag.0", name: "tag", type: STRING, mutable: true }, + { id: "tagError.0", name: "tagError", type: CAUGHT, mutable: false }, + ); + body.push( + { kind: "varDecl", localId: "tag.0", init: strLit("", loc), loc }, + { + kind: "tryCatch", + tryBody: [ + { + kind: "assign", + localId: "tag.0", + value: accessorCall( + lowerer, + owner, + "get:sym:toStringTag", + lowerer.upcastTo(v(), owner), + [], + STRING, + loc, + ), + loc, + }, + ], + catchBody: [], + catchLocalId: "tagError.0", + finallyBody: null, loc, - ), - loc, - }); + }, + ); const tag = (): IrExpr => varRef("tag.0", STRING, loc); const shown: IrExpr = { kind: "logical", diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index b76fe38d2..0dab6aaaa 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -144,6 +144,10 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d, bool to_string) return; case SCR_DYN_TYPED_REF: { + /* ToString of a class instance that converts through + * Object.prototype.toString shows its Symbol.toStringTag getter's + * string. */ + if (to_string && scr_dyn_typed_ref_put_tag(b, d)) return; ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); scr_dyn_display_buf(b, materialized, to_string); scr_dyn_release(materialized); diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 4a89fbca8..23aa2aebd 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1210,9 +1210,10 @@ static bool insp_tag_names_class(const char *tag, const char *cls) { /* 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. */ + * the fields instead. A getter that throws shows no tag, as in Node. */ static ScrStr *insp_inherited_tag(const ScrDyn *instance, const ScrStr *name) { ScrStr *tag = scr_dyn_typed_ref_class_tag(instance); + if (!tag && scr_exc_pending()) scr_exc_clear(); if (tag && (tag->len == 0 || (tag->len == name->len && memcmp(tag->data, name->data, name->len) == 0))) { scr_str_release(tag); return NULL; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 8c31f80d5..a4234f1b1 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4034,6 +4034,9 @@ ScrDyn *scr_dyn_index_get(const ScrDyn *d, double index) { return scr_dyn_typed_ref_element(d, i); } +static ScrStr *scr_dyn_typed_ref_conversion_tag(const ScrDyn *d); +static ScrStr *scr_dyn_tag_string(const ScrStr *tag); + ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) { /* An instance of a class declared `extends Date` converts through its * toString, declared or inherited. With an exception pending the view is @@ -4047,6 +4050,16 @@ ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) { scr_str_release(empty); return out; } + /* A class instance converting through Object.prototype.toString shows + * its Symbol.toStringTag getter's string. */ + ScrStr *class_tag = scr_dyn_typed_ref_conversion_tag(d); + if (class_tag || scr_exc_pending()) { + ScrStr *text = class_tag ? scr_dyn_tag_string(class_tag) : scr_str_new("", 0); + scr_str_release(class_tag); + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; + } ScrDyn *view = scr_dyn_typed_ref_materialize(d); const char *tag = scr_event_ref_hooks ? scr_event_ref_hooks->string_tag(d) : NULL; if (!tag || view->kind != SCR_DYN_OBJ || scr_dyn_property_owner(view, "toString", 8)) return view; @@ -8333,6 +8346,27 @@ static ScrStr *scr_dyn_tag_string(const ScrStr *tag) { return scr_jb_finish(&b); } +/* The Symbol.toStringTag getter's string of a class instance whose + * ToString is Object.prototype.toString (its class has no toString; the + * string hint never reaches valueOf, since that answers a string): +1, or + * NULL otherwise or with the getter's exception pending. */ +static ScrStr *scr_dyn_typed_ref_conversion_tag(const ScrDyn *d) { + ScrStr *to_string_key = scr_str_new("toString", 8); + bool own_conversion = scr_dyn_typed_ref_class_has(d, to_string_key); + scr_str_release(to_string_key); + return own_conversion ? NULL : scr_dyn_typed_ref_class_tag(d); +} + +bool scr_dyn_typed_ref_put_tag(ScrJsonBuf *b, const ScrDyn *d) { + ScrStr *tag = scr_dyn_typed_ref_conversion_tag(d); + if (!tag) return scr_exc_pending(); + scr_jb_puts(b, "[object "); + scr_jb_write(b, tag->data, tag->len); + scr_jb_putc(b, ']'); + scr_str_release(tag); + return true; +} + /* 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. */ @@ -9676,22 +9710,13 @@ 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; + ScrStr *tag = scr_dyn_typed_ref_conversion_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); diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index de4510264..8a4ecc62d 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -119,6 +119,11 @@ _Static_assert(offsetof(ScrJsonBuf, data) == 0 && offsetof(ScrJsonBuf, len) == s "LLVM backend expects ScrJsonBuf to start with { data, len, cap }"); void scr_jb_init(ScrJsonBuf *b); +/* String conversion of a class instance whose ToString is + * Object.prototype.toString and whose class has a Symbol.toStringTag + * getter: appends "[object Tag]" and answers true, also with the getter's + * exception pending; false when the ordinary conversion applies. */ +bool scr_dyn_typed_ref_put_tag(ScrJsonBuf *b, const ScrDyn *d); /* Release a buffer's text without finishing it (error paths). */ void scr_jb_abandon(ScrJsonBuf *b); /* Append a borrowed byte span verbatim, including embedded NUL bytes. */ diff --git a/tests/corpus/7751-class-well-known-symbol-fields.ts b/tests/corpus/7751-class-well-known-symbol-fields.ts index 445af40d6..d57fce3f8 100644 --- a/tests/corpus/7751-class-well-known-symbol-fields.ts +++ b/tests/corpus/7751-class-well-known-symbol-fields.ts @@ -74,3 +74,42 @@ console.log(values); const literal: unknown = { [Symbol.toStringTag]: "Literal", a: 1 }; console.log(Object.prototype.toString.call(literal), String(literal), `${literal}`); + +// util.inspect ignores a Symbol.toStringTag getter that throws, typed or +// held as an untyped value. +class Throwing { + get [Symbol.toStringTag](): string { + throw new Error("tag"); + } +} +const throwing: unknown = new Throwing(); +console.log(new Throwing(), [new Throwing()], throwing, [throwing]); + +// A conversion to "[object Object]" still evaluates its receiver. +let made = 0; +function make(): Plain { + made++; + return new Plain(); +} +function fail(): Plain { + throw new Error("receiver"); +} +console.log(Object.prototype.toString.call(make()), String(make()), made); +try { + console.log(Object.prototype.toString.call(fail())); +} catch (error) { + console.log((error as Error).message); +} + +// ToString of a class with valueOf and no toString reads its tag; the +// default hint calls valueOf. +class Valued { + valueOf(): number { + return 1; + } + get [Symbol.toStringTag](): string { + return "Valued"; + } +} +const valued: unknown = new Valued(); +console.log(String(valued), `${valued}`, [valued].join(), valued + "", Object.prototype.toString.call(valued));