diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index 7873c10..db8adc3 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -7004,9 +7004,12 @@ excluded_keys(_) -> %% * anything else is treated as no replacer. %% `space` sets the indentation gap: a Number N yields min(10, floor(N)) spaces %% (0/negative → none); a String yields its first 10 code units; else none. -%% Deviation: `this`-bound `toJSON`/replacer receivers and String/Number/Boolean -%% wrapper objects are not modelled (this runtime has no such wrappers). +%% String/Number/Boolean wrapper objects ARE unwrapped to their primitive per +%% SerializeJSONProperty step 4 (see `json_unwrap_primitive`). A cyclical +%% structure raises a TypeError (see `json_serialize_cell`). Deviation: +%% `this`-bound `toJSON`/replacer receivers are still not modelled. json_stringify(V, Replacer, Space) -> + erlang:erase(json_stack), {RepFn, PropList} = json_replacer(Replacer), Gap = json_gap(Space), St = {RepFn, PropList, Gap}, @@ -7123,17 +7126,37 @@ json_call_tojson(Key, V, Stored) -> end. json_serialize_value(V, St, Indent) -> - case js_type(V) of - number -> json_num(V); - boolean -> to_string(V); - string -> json_str(V); - null -> <<"null">>; - undefined -> skip; - function -> skip; - object -> json_serialize_cell(V, St, Indent); - other -> skip + case json_unwrap_primitive(V) of + {primitive, Prim} -> + json_serialize_value(Prim, St, Indent); + no -> + case js_type(V) of + number -> json_num(V); + boolean -> to_string(V); + string -> json_str(V); + null -> <<"null">>; + undefined -> skip; + function -> skip; + object -> json_serialize_cell(V, St, Indent); + other -> skip + end end. +%% SerializeJSONProperty steps 4.a–4.c: a Number/String/Boolean wrapper OBJECT +%% (`new Number(n)` / `new String(s)` / `new Boolean(b)`) is serialized as its +%% wrapped primitive [[NumberData]]/[[StringData]]/[[BooleanData]], NOT as an +%% object — so `JSON.stringify(new Boolean(true))` yields `"true"`, not `"{}"`, +%% and a wrapper nested inside an object/array or returned from a replacer/toJSON +%% is serialized by its primitive too. Returns `{primitive, Prim}` for a wrapper +%% cell, `no` for any other value. +json_unwrap_primitive(V) when is_reference(V) -> + case erlang:get(?CELL_KEY(V)) of + {js_wrapper, _Kind, Prim} -> {primitive, Prim}; + _ -> no + end; +json_unwrap_primitive(_) -> + no. + json_num(js_nan) -> <<"null">>; json_num(js_inf) -> <<"null">>; json_num(js_neg_inf) -> <<"null">>; @@ -7165,11 +7188,39 @@ unicode_escape(B) -> %% Serialize an object/array cell. Arrays and plain objects recurse; every other %% cell kind (regex, Map, Set, …) has no enumerable own properties and renders as %% an empty object `{}`, matching JSON.stringify on an ordinary object. +%% +%% SerializeJSONObject/SerializeJSONArray step 1: "If stack contains value, throw +%% a TypeError exception because the structure is cyclical." The stack is the set +%% of object/array cells currently being serialized (an ancestor chain), tracked +%% in the process dictionary under `json_stack`. A cell is pushed on entry and +%% popped on exit (via `after`, so it is restored on a thrown TypeError too), so a +%% value that merely appears twice in DIFFERENT branches (a diamond, not a cycle) +%% is NOT rejected. A truly cyclical reference recurses back to an ancestor still +%% on the stack and raises `type_error`, which a JS `try`/`catch` observes as a +%% TypeError — without this, a self-referential object looped forever. json_serialize_cell(Ref, St, Indent) -> - case erlang:get(?CELL_KEY(Ref)) of - {js_array, Len, _Map} -> json_serialize_array(Ref, Len, St, Indent); - M when is_map(M) -> json_serialize_object(Ref, M, St, Indent); - _ -> <<"{}">> + Stack = + case erlang:get(json_stack) of + undefined -> []; + L -> L + end, + case lists:member(Ref, Stack) of + true -> + type_error(Ref); + false -> + erlang:put(json_stack, [Ref | Stack]), + try + case erlang:get(?CELL_KEY(Ref)) of + {js_array, Len, _Map} -> + json_serialize_array(Ref, Len, St, Indent); + M when is_map(M) -> + json_serialize_object(Ref, M, St, Indent); + _ -> + <<"{}">> + end + after + erlang:put(json_stack, Stack) + end end. %% SerializeJSONArray: each index is serialized via SerializeJSONProperty (so the diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index f455b5a..5f182a2 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -7328,3 +7328,76 @@ pub fn wave14_arrow_callback_ignores_thisarg_test() { val("[1, 2, 3].map((x) => x * 2, { k: 9 }).join(',')") |> should.equal(dyn("2,4,6")) } + +// ==== JSON (wave 15) ==== + +// SerializeJSONProperty step 4.c (sec-serializejsonproperty): a Boolean wrapper +// object is serialized as its primitive [[BooleanData]], so `new Boolean(true)` +// stringifies to "true", not "{}". Mirrors JSON/stringify/value-boolean-object.js. +pub fn json_wrapper_boolean_test() { + val("JSON.stringify(new Boolean(true))") |> should.equal(dyn("true")) + val("JSON.stringify(new Boolean(false))") |> should.equal(dyn("false")) +} + +// A Boolean wrapper nested inside an object (returned via toJSON) is unwrapped to +// its primitive per step 4.c — `{"key":false}`, not `{"key":{}}`. +pub fn json_wrapper_boolean_nested_test() { + val( + "JSON.stringify({toJSON: function() { return {key: new Boolean(false)}; }})", + ) + |> should.equal(dyn("{\"key\":false}")) +} + +// A Boolean wrapper produced by a replacer function is unwrapped (step 4 runs +// after the replacer) — `[true]`. Mirrors the third value-boolean-object case. +pub fn json_wrapper_boolean_from_replacer_test() { + val( + "JSON.stringify([1], function(_k, v) { return v === 1 ? new Boolean(true) : v; })", + ) + |> should.equal(dyn("[true]")) +} + +// SerializeJSONProperty step 4.a/4.b: Number and String wrapper objects serialize +// as their primitive [[NumberData]]/[[StringData]]. +pub fn json_wrapper_number_string_test() { + val("JSON.stringify(new Number(14))") |> should.equal(dyn("14")) + val("JSON.stringify(new String('hi'))") |> should.equal(dyn("\"hi\"")) +} + +// SerializeJSONObject step 1: a directly self-referential object is cyclical and +// MUST throw a TypeError (not loop forever). Mirrors value-object-circular.js. +pub fn json_circular_object_throws_test() { + let m = + compile( + "function f() { var d = {}; d.prop = d; try { JSON.stringify(d); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// SerializeJSONArray step 1: a self-referential array is cyclical and MUST throw a +// TypeError. Mirrors value-array-circular.js. +pub fn json_circular_array_throws_test() { + let m = + compile( + "function f() { var a = []; a.push(a); try { JSON.stringify(a); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// An indirectly cyclical structure (a getter/reference chain back to an ancestor) +// also throws. Mirrors the `indirect` case of value-object-circular.js. +pub fn json_circular_indirect_throws_test() { + let m = + compile( + "function f() { var root = {}; root.a = { b: root }; try { JSON.stringify(root); return 0; } catch (e) { return e instanceof TypeError ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// The cycle check is a STACK of ancestors, not a global seen-set: the SAME object +// referenced twice in sibling positions (a diamond, not a cycle) serializes fine. +pub fn json_shared_ref_no_false_cycle_test() { + let m = + compile("function f() { var x = { a: 1 }; return JSON.stringify([x, x]); }") + call(m, "f", []) |> should.equal(dyn("[{\"a\":1},{\"a\":1}]")) +}