From 1086efd647edf0055119f661a6939a5c81668739 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 05:48:19 +0100 Subject: [PATCH 1/2] js: implement Map.groupBy (sec-map.groupby) Add the Map.groupBy static method: iterate the items argument through the iterator protocol (arrays, strings by code point, Sets/Maps/generators) and collect each element into a fresh Map keyed by callbackfn(value, index). Key equality is SameValueZero via mapkey_norm (COLLECTION coercion) so -0 folds to +0, but no ToPropertyKey conversion is applied, keeping a number key distinct from the equal string key. A non-callable callbackfn raises a TypeError before any element is visited (GroupBy step 2). Clears the six Map/groupBy test262 runtime failures (evenOdd, groupLength, map-instance, negativeZero, string, toPropertyKey). --- src/twocore/backend/emit_core.gleam | 1 + src/twocore/frontend/js/lower.gleam | 7 ++++ src/twocore/runtime/rt_js.gleam | 7 ++++ src/twocore_rt_js_ffi.erl | 30 ++++++++++++++++- test/js_compiler_test.gleam | 50 +++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) 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..d6be78c 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 diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index 7011cc1..b0bbe4d 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -6851,3 +6851,53 @@ 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) +} From f198ba59f205866758e95256e708df667f825c56 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 06:01:25 +0100 Subject: [PATCH 2/2] js: getOrInsertComputed rejects a non-callable callbackfn (upsert step 3) Map.prototype.getOrInsertComputed (and the WeakMap form) must throw a TypeError when callbackfn is not callable, and the IsCallable check runs BEFORE the key lookup so it fires even when the key is already present (the callback would otherwise never be invoked). Previously the check was absent, so a present key silently skipped the throw and an absent key crashed with badarg when trying to call a non-function. Fixes Map/prototype/getOrInsertComputed/not-a-function-callbackfn-throws. --- src/twocore_rt_js_ffi.erl | 17 +++++++++++++++-- test/js_compiler_test.gleam | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index d6be78c..60dc18b 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -3778,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} = @@ -3805,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 b0bbe4d..2a84a4a 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -6901,3 +6901,36 @@ pub fn map_group_by_non_callable_throws_test() { ) 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) +}