diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-json-stringify-live-capsules.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-json-stringify-live-capsules.ts.json new file mode 100644 index 000000000..580e84ff0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-json-stringify-live-capsules.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7363-json-stringify-live-capsules.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index cf658313f..dfa81ace6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -10744,8 +10744,9 @@ static bool scr_json_callback_depth(size_t depth) { static ScrStr *scr_json_index_key(size_t index) { char bytes[32]; - int length = snprintf(bytes, sizeof bytes, "%zu", index); - return scr_str_new(bytes, (size_t)length); + /* array indexes are below 2^32: exact doubles, printed as integers */ + size_t length = scr_f64_to_str((double)index, bytes); + return scr_str_new(bytes, length); } /* Keys passed here come from our own index formatter or own-key walk. */ @@ -11021,12 +11022,22 @@ static bool scr_json_live_container(const ScrDyn *value, bool inspect) { * { type, data } view, and an object with a callable toJSON through that * method, called with the key. Returns the value to serialize (+1), or NULL * with an exception pending. */ -static ScrDyn *scr_json_to_json(ScrDyn *value, const ScrStr *key) { +/* The toJSON key argument: `key`, or the decimal spelling of array index + * `index` when `key` is NULL (formed only for a call that receives it). */ +static ScrDyn *scr_json_key_arg(const ScrStr *key, size_t index) { + if (key) return scr_dyn_new_str((ScrStr *)key); + ScrStr *text = scr_json_index_key(index); + ScrDyn *name = scr_dyn_new_str(text); + scr_str_release(text); + return name; +} + +static ScrDyn *scr_json_to_json_at(ScrDyn *value, const ScrStr *key, size_t index) { /* An instance of a class declared `extends Date` converts through its * toJSON, declared or inherited. */ double time; if (value->kind == SCR_DYN_TYPED_REF && scr_dyn_date_class_time(value, &time)) { - ScrDyn *name = scr_dyn_new_str((ScrStr *)key); + ScrDyn *name = scr_json_key_arg(key, index); ScrDyn *converted = scr_dyn_date_class_to_json(value, name); scr_dyn_release(name); scr_dyn_release(value); @@ -11054,7 +11065,7 @@ static ScrDyn *scr_json_to_json(ScrDyn *value, const ScrStr *key) { ScrDyn *method = scr_dyn_obj_read(inspected, "toJSON", 6); if (!method) { scr_dyn_release(view); scr_dyn_release(value); return NULL; } if (method->kind == SCR_DYN_FUNC) { - ScrDyn *name = scr_dyn_new_str((ScrStr *)key); + ScrDyn *name = scr_json_key_arg(key, index); ScrDyn *args[] = { name }; scr_dyn_this_push_dyn(value); ScrDyn *converted = scr_dyn_call(method, args, 1, "toJSON"); @@ -11069,6 +11080,10 @@ static ScrDyn *scr_json_to_json(ScrDyn *value, const ScrStr *key) { return value; } +static ScrDyn *scr_json_to_json(ScrDyn *value, const ScrStr *key) { + return scr_json_to_json_at(value, key, 0); +} + static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const ScrDyn *replacer) { ScrDyn *value = scr_json_member(holder, key); if (!value) return NULL; @@ -11203,6 +11218,81 @@ static ScrDyn *scr_json_property_list(const ScrDyn *replacer) { return list; } +/* The protocol walk over a live record or array capsule without a replacer + * (the caller entered it on the circular-structure stack). Members are read + * from the native value exactly as scr_json_member reads them, but the walk + * needs no key list: a record's present fields are snapshotted by index (its + * keys are the immortal field names) and an array's indexes are counted, so + * an index key string is formed only for a toJSON call that receives it. */ +static bool scr_json_live_write(ScrJsonBuf *buffer, const ScrDyn *value, bool array, + const ScrStr *gap, size_t depth) { + const ScrDynTypedRecordOps *record = array ? NULL : scr_typed_ref_record_ops(value); + /* EnumerableOwnProperties runs once, before any member's toJSON (the + * caller sends records of more than 64 fields down the key-list walk). */ + uint64_t present = 0; + size_t length; + if (array) { + length = ((ScrArr *)value->v.typed_ref.ptr)->len; + } else { + length = record->count; + for (size_t i = 0; i < length; i++) + if (record->fixed || record->has(value->v.typed_ref.ptr, i)) present |= (uint64_t)1 << i; + } + scr_jb_putc(buffer, array ? '[' : '{'); + bool first = true; + for (size_t i = 0; i < length; i++) { + if (!array && !(present & ((uint64_t)1 << i))) continue; + const ScrStr *key = array ? NULL : record->names[i]; + ScrDyn *child; + if (array) { + scr_jb_edge_idx(buffer, i); + /* scr_json_member's index read, minus the key: a present element + * answers directly; holes, a length a callback shrank, a capsule a + * callback materialized and inherited event members take the keyed + * read */ + child = !scr_event_ref_hooks && scr_dyn_typed_ref_array_ops(value) ? scr_dyn_typed_ref_element(value, i) + : NULL; + if (!child) { + ScrStr *index = scr_json_index_key(i); + child = scr_json_member(value, index); + scr_str_release(index); + } + } else { + scr_jb_edge_key(buffer, key); + child = scr_json_member(value, key); + } + if (child) child = scr_json_to_json_at(child, key, i); + if (!child) return false; + if (!array && scr_json_omitted(child)) { + scr_dyn_release(child); + continue; + } + if (!first) scr_jb_putc(buffer, ','); + first = false; + scr_json_gap(buffer, gap, depth + 1); + if (!array) { + scr_jb_put_json_str(buffer, key); + scr_jb_putc(buffer, ':'); + if (gap->len) scr_jb_putc(buffer, ' '); + } + bool ok = true; + if (scr_json_omitted(child)) scr_jb_puts(buffer, "null"); + else if (scr_json_plain_value(child)) { + /* the protocol walk's depth check, then the primitive itself */ + ok = scr_json_callback_depth(depth + 1); + if (ok) { + scr_dyn_json_write(buffer, child); + ok = !scr_exc_pending(); + } + } else ok = scr_json_replaced_write(buffer, child, scr_dyn_undefined(), NULL, gap, depth + 1); + scr_dyn_release(child); + if (!ok) return false; + } + if (!first) scr_json_gap(buffer, gap, depth); + scr_jb_putc(buffer, array ? ']' : '}'); + return true; +} + /* `properties`, when present, is an array replacer's PropertyList: objects * serialize exactly those keys, own or inherited, in that order; arrays * keep all their indexes. */ @@ -11232,6 +11322,12 @@ static bool scr_json_replaced_write(ScrJsonBuf *buffer, const ScrDyn *value, * after the current frame leaves the traversal stack. */ const void *identity = view || live ? value->v.typed_ref.ptr : (const void *)value; if (!scr_jb_enter(buffer, identity, array)) { scr_dyn_release(view); return false; } + if (live && replacer->kind == SCR_DYN_UNDEF && !properties && + (array || scr_typed_ref_record_ops(value)->count <= 64)) { + bool ok = scr_json_live_write(buffer, value, array, gap, depth); + scr_jb_leave(buffer); + return ok; + } scr_jb_putc(buffer, array ? '[' : '{'); ScrDyn *keys = array ? NULL : properties ? scr_dyn_retain((ScrDyn *)properties) : scr_dyn_obj_keys(observed); if (scr_exc_pending()) { scr_dyn_release(keys); scr_dyn_release(view); scr_jb_leave(buffer); return false; } diff --git a/tests/corpus/7363-json-stringify-live-capsules.ts b/tests/corpus/7363-json-stringify-live-capsules.ts new file mode 100644 index 000000000..5522ede5f --- /dev/null +++ b/tests/corpus/7363-json-stringify-live-capsules.ts @@ -0,0 +1,73 @@ +// JSON.stringify of typed records and arrays reached through `unknown`: +// members are read from the native values in key order, toJSON receives the +// member key (an index string inside arrays), undefined members drop out of +// objects and become null in arrays, and the key list is fixed before any +// toJSON runs. +interface Product { + id: string; + name: string; + priceCents: number; + tags: string[]; + note?: string; +} + +function stringify(value: unknown, space?: number): string { + return JSON.stringify(value, undefined, space); +} + +const products: Product[] = [ + { id: "p1", name: "Keyboard", priceCents: 12900, tags: ["hardware", "input"] }, + { id: "p2", name: "Quote \"and\" slash \\", priceCents: 0.5, tags: [], note: "café ☕" }, +]; +console.log(stringify({ products, count: products.length })); +console.log(stringify(products, 2)); +const second: Product = products[1]!; +delete second.note; +console.log(stringify(second)); + +class Money { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + toJSON(key: string): string { + return `${key}:${(this.cents / 100).toFixed(2)}`; + } +} +const prices: Money[] = [new Money(100), new Money(2550)]; +console.log(stringify(prices), stringify({ total: new Money(5) })); + +const numbers: number[] = [1, -0, NaN, Infinity, 2.5, 1e21]; +console.log(stringify(numbers)); +const maybe: (string | undefined)[] = ["a", undefined, "c"]; +console.log(stringify(maybe), stringify({ maybe })); +const nested: number[][] = [[1, 2], [], [3]]; +console.log(stringify(nested, 1)); + +// a toJSON that deletes a later optional field: the field was present when +// the key list was taken, so its (now undefined) value is dropped +interface Order { + first: { toJSON(): string }; + note?: string; +} +const order: Order = { first: { toJSON: () => "x" }, note: "kept?" }; +order.first = { + toJSON: () => { + delete order.note; + return "first"; + }, +}; +console.log(stringify(order)); + +// circular structures through capsules report the path +interface Node { + name: string; + children: Node[]; +} +const root: Node = { name: "root", children: [] }; +root.children.push({ name: "leaf", children: [root] }); +try { + stringify(root); +} catch (error) { + console.log((error as Error).message); +} diff --git a/tests/harness/corpus-costs/7363-json-stringify-live-capsules.ts.json b/tests/harness/corpus-costs/7363-json-stringify-live-capsules.ts.json new file mode 100644 index 000000000..27b641ffe --- /dev/null +++ b/tests/harness/corpus-costs/7363-json-stringify-live-capsules.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 3200 }