Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 66 additions & 15 deletions src/twocore_rt_js_ffi.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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">>;
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions test/js_compiler_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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}]"))
}
Loading