diff --git a/src/twocore/backend/emit_core.gleam b/src/twocore/backend/emit_core.gleam index 9fd2405..5b70a84 100644 --- a/src/twocore/backend/emit_core.gleam +++ b/src/twocore/backend/emit_core.gleam @@ -4292,6 +4292,7 @@ fn resolve_js(op: String) -> Option(String) { "js_m_get_or_insert" -> Some("js_m_get_or_insert") "js_m_get_or_insert_computed" -> Some("js_m_get_or_insert_computed") "js_m_foreach" -> Some("js_m_foreach") + "map_group_by" -> Some("map_group_by") "js_m_keys" -> Some("js_m_keys") "js_m_values" -> Some("js_m_values") "js_m_entries" -> Some("js_m_entries") diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index 6b71797..c7d292e 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -4206,6 +4206,7 @@ fn lower_call_fixed( || ns == "Symbol" || ns == "Reflect" || ns == "Error" + || ns == "Map" -> lower_static_call(ns, method, arguments, env, ctx, ctr) // `super.method(args)` — call the superclass's method on the current `this`. ast.MemberExpression( @@ -4802,6 +4803,12 @@ fn lower_static_call( "JSON", "parse", [s, ..] -> host("json_parse", [s]) "Array", "from", [x] -> host("array_from", [x]) "Array", "from", [x, mapfn, ..] -> host("array_from_map", [x, mapfn]) + // Map.groupBy(items, callbackfn) (sec-map.groupby) — group the iterable's + // elements into a new Map keyed by the callback result (SameValueZero, no + // ToPropertyKey). A missing callback lowers to `undefined` so the runtime's + // IsCallable check throws a TypeError (GroupBy step 2). + "Map", "groupBy", [items, cb, ..] -> host("map_group_by", [items, cb]) + "Map", "groupBy", [items] -> host("map_group_by", [items, undefined()]) "String", "fromCharCode", _ -> { let #(binds2, listv, ctr) = build_list(argvals, binds, ctr) Ok(bind_after( diff --git a/src/twocore/runtime/rt_js.gleam b/src/twocore/runtime/rt_js.gleam index bffd603..202dc19 100644 --- a/src/twocore/runtime/rt_js.gleam +++ b/src/twocore/runtime/rt_js.gleam @@ -622,6 +622,13 @@ pub fn new_map(init: Dynamic) -> Dynamic @external(erlang, "twocore_rt_js_ffi", "new_set") pub fn new_set(init: Dynamic) -> Dynamic +/// `Map.groupBy(items, callbackfn)` — group the elements of the iterable `items` +/// into a new Map keyed by `callbackfn(value, index)` (SameValueZero key equality, +/// no ToPropertyKey coercion). Each value is an Array of the grouped elements in +/// iteration order. A non-callable `callbackfn` throws a TypeError. +@external(erlang, "twocore_rt_js_ffi", "map_group_by") +pub fn map_group_by(items: Dynamic, callbackfn: Dynamic) -> Dynamic + /// `new WeakMap(init?)` — optionally seeded from an array of `[key, value]` pairs; /// a non-object key in the iterable throws a TypeError (§24.3.1.1). Keys are held /// strongly (no real weak references are modelled). diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index b102e66..60dc18b 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -80,7 +80,7 @@ regex_flags/1, str_match/2, str_search/2, new_map/1, new_set/1, js_m_set/2, js_m_get/2, js_m_add/2, js_m_has/2, - js_m_delete/2, js_m_clear/2, js_m_foreach/2, + js_m_delete/2, js_m_clear/2, js_m_foreach/2, map_group_by/2, js_m_get_or_insert/2, js_m_get_or_insert_computed/2, js_m_keys/2, js_m_values/2, js_m_entries/2, new_weakset/1, weakset_ctor/0, weakset_no_new/0, @@ -3360,6 +3360,34 @@ new_set(Init) -> end, S. +%% Map.groupBy(items, callbackfn) (sec-map.groupby / GroupBy AO with COLLECTION key +%% coercion). Iterates `items` through the iterator protocol (arrays, strings by code +%% point, Sets/Maps/generators — via array_from_elems), calls callbackfn(value, index) +%% for each element, and collects the elements into a fresh Map keyed by the callback's +%% result. Key equality is SameValueZero (mapkey_norm): -0 folds to +0 and integral +%% floats to the equal integer, but a number key and the equal string key stay distinct +%% (no ToPropertyKey conversion, unlike Object.groupBy). Each value in the returned Map +%% is an Array of the grouped elements in iteration order. A non-callable callbackfn is +%% a TypeError thrown before any element is visited (GroupBy step 2). +map_group_by(Items, Fn) -> + case is_function(Fn) of + true -> ok; + false -> not_callable(Fn) + end, + M = cell_new({js_map, 0, #{}}), + group_into_map(M, Fn, array_from_elems(Items, undefined), 0), + M. + +group_into_map(_M, _Fn, [], _I) -> + ok; +group_into_map(M, Fn, [E | Es], I) -> + K = call_cb(Fn, [E, I]), + case js_m_has(M, [K]) of + true -> array_push(js_m_get(M, [K]), [E]); + false -> js_m_set(M, [K, new_array([E])]) + end, + group_into_map(M, Fn, Es, I + 1). + %% A WeakMap is a cell `{js_weakmap, Next, Data}`, mirroring the ordinary Map cell so %% the shared collection-method dispatch (`js_m_set`/`get`/`has`/`delete`) can reuse %% `mapkey_norm` and the same `Key => {Seq, Value}` storage. No real weak references @@ -3750,12 +3778,19 @@ js_m_get_or_insert(Recv, Args) -> js_m_get_or_insert_computed(Recv, Args) -> case cell_tag(Recv) of {js_map, _, D} -> + %% Map.prototype.getOrInsertComputed step 3: a non-callable callbackfn is a + %% TypeError, thrown BEFORE the key lookup — so it fires even when the key + %% is already present (and thus the callback would never be invoked). + Fn = arg(Args, 1), + case is_function(Fn) of + true -> ok; + false -> not_callable(Fn) + end, K = mapkey_norm(arg(Args, 0)), case maps:get(K, D, undefined) of {_Seq, V} -> V; undefined -> - Fn = arg(Args, 1), Value = call_cb(Fn, [K]), {js_map, Next2, D2} = cell_tag(Recv), {Next3, Seq} = @@ -3777,11 +3812,17 @@ js_m_get_or_insert_computed(Recv, Args) -> false -> type_error(K); true -> + %% Step 4 (after CanBeHeldWeakly): a non-callable callbackfn is a + %% TypeError, thrown before the lookup so a present key still throws. + Fn = arg(Args, 1), + case is_function(Fn) of + true -> ok; + false -> not_callable(Fn) + end, case maps:get(K, D, undefined) of {_Seq, V} -> V; undefined -> - Fn = arg(Args, 1), Value = call_cb(Fn, [K]), {js_weakmap, Next2, D2} = cell_tag(Recv), {Next3, Seq} = diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index 7011cc1..2a84a4a 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -6851,3 +6851,86 @@ pub fn array_map_thisarg_arrow_unaffected_test() { val("[1, 2, 3].map((x) => x * 2, { k: 9 }).join(',')") |> should.equal(dyn("2,4,6")) } + +// ==== MapSet (wave 13) ==== + +// Map.groupBy(items, cb) (sec-map.groupby): elements are grouped into a new Map +// keyed by the callback result, each value an Array of elements in iteration order. +// Grouping [1,2,3] by parity → key "odd" holds [1,3] (length 2), "even" holds [2] +// (length 1). Encode both lengths in one number so a single assertion covers them. +pub fn map_group_by_basic_test() { + let m = + compile( + "function f() { var g = Map.groupBy([1, 2, 3], function (i) { return i % 2 === 0 ? 'even' : 'odd'; }); return g.get('odd').length * 10 + g.get('even').length; }", + ) + to_float(call(m, "f", [])) |> should.equal(21.0) +} + +// GroupBy key coercion is COLLECTION (SameValueZero), so -0 and +0 collapse to one +// key: Map.groupBy([-0, +0], i => i) has size 1 (sec-map.groupby → GroupBy). The +// grouped value keeps both original elements, so the group's length is 2. +pub fn map_group_by_negative_zero_test() { + let m = + compile( + "function f() { var g = Map.groupBy([-0, 0], function (i) { return i; }); return g.size * 10 + g.get(0).length; }", + ) + to_float(call(m, "f", [])) |> should.equal(12.0) +} + +// Map.groupBy iterates any iterable via the iterator protocol: a string is iterated +// by code point, so 'abc' yields three distinct single-char keys → size 3. +pub fn map_group_by_string_iterable_test() { + num("Map.groupBy('abc', function (c) { return c; }).size === 3 ? 1 : 0") + |> should.equal(1.0) +} + +// COLLECTION coercion does NOT apply ToPropertyKey, so a number key and the equal +// string key stay distinct: grouping [1, '1'] by identity yields two groups +// (sec-map.groupby, unlike Object.groupBy which coerces to property keys). +pub fn map_group_by_number_vs_string_key_test() { + num("Map.groupBy([1, '1'], function (v) { return v; }).size === 2 ? 1 : 0") + |> should.equal(1.0) +} + +// GroupBy step 2: a non-callable callbackfn is a TypeError, thrown before any element +// is visited. The throw is catchable, so the catch block runs and returns 1. +pub fn map_group_by_non_callable_throws_test() { + let m = + compile( + "function f() { try { Map.groupBy([1, 2], 5); } catch (e) { return 1; } return 0; }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// Map.prototype.getOrInsertComputed(key, callbackfn) step 3: a non-callable +// callbackfn is a TypeError, thrown for an ABSENT key (the callback would otherwise +// be invoked). Catch returns 1 (upsert proposal, sec-map.prototype.getorinsertcomputed). +pub fn map_get_or_insert_computed_non_callable_throws_test() { + let m = + compile( + "function f() { var x = new Map(); try { x.getOrInsertComputed(1, 1); } catch (e) { return 1; } return 0; }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// The IsCallable check precedes the key lookup, so getOrInsertComputed throws even +// when the key is ALREADY present (the callback is never consulted). Insert key 1, +// then call with a non-callable second argument → TypeError, catch returns 1. +pub fn map_get_or_insert_computed_present_key_still_throws_test() { + let m = + compile( + "function f() { var x = new Map(); x.set(1, 'foo'); try { x.getOrInsertComputed(1, 1); } catch (e) { return 1; } return 0; }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// With a callable callbackfn and an absent key, getOrInsertComputed calls +// callbackfn(key), stores the result, and returns it. Key 1 absent → callback +// returns key + 10 = 11, which is inserted and returned. +pub fn map_get_or_insert_computed_calls_callback_test() { + let m = + compile( + "function f() { var x = new Map(); return x.getOrInsertComputed(1, function (k) { return k + 10; }); }", + ) + to_float(call(m, "f", [])) |> should.equal(11.0) +}