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
1 change: 1 addition & 0 deletions src/twocore/backend/emit_core.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions src/twocore/frontend/js/lower.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/twocore/runtime/rt_js.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
47 changes: 44 additions & 3 deletions src/twocore_rt_js_ffi.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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} =
Expand All @@ -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} =
Expand Down
83 changes: 83 additions & 0 deletions test/js_compiler_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading