From 8abc6a1497a80ca620ad1f52d086930521966886 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 08:19:06 +0100 Subject: [PATCH] js: add Object.groupBy and Object.hasOwn/getPrototypeOf statics Implement Object.groupBy (sec-object.groupby): group an iterable's elements into a fresh null-prototype object keyed by ToPropertyKey(callback result), with each key's elements collected into an Array in encounter order. A non-callable callback throws a TypeError before any element is read (GroupBy step 2). Route the static Object.hasOwn(obj, key) (sec-object.hasown) to the existing own-property runtime op so it works with null-prototype receivers, and Object.getPrototypeOf(obj) to the shared prototype op (null in this prototype-less model). Adds spec-driven tests covering bucketing, numeric/toString key coercion, the null prototype, the non-callable TypeError, and Object.hasOwn. --- src/twocore/backend/emit_core.gleam | 1 + src/twocore/frontend/js/lower.gleam | 16 ++++++++ src/twocore/runtime/rt_js.gleam | 7 ++++ src/twocore_rt_js_ffi.erl | 31 +++++++++++++++ test/js_compiler_test.gleam | 61 +++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+) diff --git a/src/twocore/backend/emit_core.gleam b/src/twocore/backend/emit_core.gleam index 3905a1e..d8da638 100644 --- a/src/twocore/backend/emit_core.gleam +++ b/src/twocore/backend/emit_core.gleam @@ -4382,6 +4382,7 @@ fn resolve_js(op: String) -> Option(String) { "object_from_entries" -> Some("object_from_entries") "object_is" -> Some("object_is") "object_has_own" -> Some("object_has_own") + "object_group_by" -> Some("object_group_by") // Reflect namespace "reflect_has" -> Some("reflect_has") "reflect_get" -> Some("reflect_get") diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index be1f19d..45ecee4 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -4876,6 +4876,22 @@ fn lower_static_call( "Object", "is", [x, y, ..] -> host("object_is", [x, y]) "Object", "is", [x] -> host("object_is", [x, undefined()]) "Object", "is", [] -> host("object_is", [undefined(), undefined()]) + // Object.hasOwn(obj, key) (sec-object.hasown) — own-property test that, unlike + // `obj.hasOwnProperty(key)`, works with a null-prototype receiver. Reuses the + // same runtime op as `hasOwnProperty`. + "Object", "hasOwn", [o, k, ..] -> host("object_has_own", [o, k]) + "Object", "hasOwn", [o] -> host("object_has_own", [o, undefined()]) + // Object.getPrototypeOf(obj) (sec-object.getprototypeof) — this v1 model tracks + // no prototype chain, so an ordinary object reports `null` (shared with + // Reflect.getPrototypeOf); a primitive receiver throws a TypeError. + "Object", "getPrototypeOf", [o, ..] -> host("reflect_get_prototype_of", [o]) + // Object.groupBy(items, callbackfn) (sec-object.groupby) — group the iterable's + // elements into a new null-prototype object keyed by ToPropertyKey(callback + // result). A missing callback lowers to `undefined` so the runtime's + // IsCallable check throws a TypeError (GroupBy step 2). + "Object", "groupBy", [items, cb, ..] -> host("object_group_by", [items, cb]) + "Object", "groupBy", [items] -> + host("object_group_by", [items, undefined()]) "Object", "fromEntries", [e, ..] -> host("object_from_entries", [e]) "Object", "freeze", [o, ..] -> host("object_freeze", [o]) "Object", "isFrozen", [o, ..] -> host("object_is_frozen", [o]) diff --git a/src/twocore/runtime/rt_js.gleam b/src/twocore/runtime/rt_js.gleam index 8766c9d..c939bd9 100644 --- a/src/twocore/runtime/rt_js.gleam +++ b/src/twocore/runtime/rt_js.gleam @@ -1102,6 +1102,13 @@ pub fn object_is(x: Dynamic, y: Dynamic) -> Dynamic @external(erlang, "twocore_rt_js_ffi", "object_has_own") pub fn object_has_own(obj: Dynamic, key: Dynamic) -> Dynamic +/// `Object.groupBy(items, callbackfn)` → a fresh null-prototype object whose keys +/// are the ToPropertyKey-coerced callback results and whose values are Arrays of +/// the elements that produced each key (encounter order). A non-callable +/// `callbackfn` throws a TypeError before any element is read. +@external(erlang, "twocore_rt_js_ffi", "object_group_by") +pub fn object_group_by(items: Dynamic, callbackfn: Dynamic) -> Dynamic + /// `Reflect.has(target, key)` → JS boolean; whether `target` (which must be an /// object, else TypeError) has the property `key` (own-property only in v1). @external(erlang, "twocore_rt_js_ffi", "reflect_has") diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index 7873c10..7af5c8b 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -110,6 +110,7 @@ object_prevent_extensions/1, object_is_extensible/1, object_seal/1, object_is_sealed/1, object_from_entries/1, object_is/2, object_has_own/2, + object_group_by/2, reflect_has/2, reflect_get/2, reflect_set/4, reflect_delete_property/2, reflect_own_keys/1, reflect_get_prototype_of/1, reflect_is_extensible/1, reflect_prevent_extensions/1, reflect_apply/3, @@ -3450,6 +3451,36 @@ group_into_map(M, Fn, [E | Es], I) -> end, group_into_map(M, Fn, Es, I + 1). +%% Object.groupBy(items, callbackfn) (sec-object.groupby) — group the iterable's +%% elements into a fresh null-prototype plain object. GroupBy is driven with +%% coercion = PROPERTY, so each callback result is run through ToPropertyKey: for +%% every non-Symbol value that is ToPrimitive(hint string) then ToString, so a +%% number key `4` becomes "4" and an object with a `toString` contributes its +%% string form (mirroring `to_string_dispatch` then `to_string`). Elements sharing +%% a key are collected, in encounter order, into that key's Array. A non-callable +%% `callbackfn` throws a TypeError (GroupBy step 2) before any element is read. +%% NOTE: this v1 object model iterates own keys in the backing map's order, not the +%% spec's first-encounter insertion order, so `Object.keys` on the result follows +%% that documented deviation. +object_group_by(Items, Fn) -> + case is_function(Fn) of + true -> ok; + false -> not_callable(Fn) + end, + O = new_object(), + group_into_object(O, Fn, array_from_elems(Items, undefined), 0), + O. + +group_into_object(_O, _Fn, [], _I) -> + ok; +group_into_object(O, Fn, [E | Es], I) -> + K = to_string(to_string_dispatch(call_cb(Fn, [E, I]))), + case has_prop(O, K) =:= 1 of + true -> array_push(get_prop(O, K), [E]); + false -> set_prop(O, K, new_array([E])) + end, + group_into_object(O, 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 f455b5a..08a6d68 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -7328,3 +7328,64 @@ 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")) } + +// ==== Object (wave 15) ==== + +// Object.groupBy(items, cb) buckets elements into a new object whose values are +// Arrays of the elements that produced each key, in encounter order +// (sec-object.groupby / GroupBy). Keys 'odd' → [1, 3], 'even' → [2]. +pub fn wave15_object_groupby_buckets_test() { + let m = + compile( + "function f() { var o = Object.groupBy([1, 2, 3], function(i){ return i % 2 === 0 ? 'even' : 'odd'; }); return o['odd'].length * 100 + o['odd'][0] * 10 + o['odd'][1] + o['even'][0]; }", + ) + // odd.length=2 → 200, odd[0]=1 → 10, odd[1]=3 → 3, even[0]=2 → 2 = 215 + to_float(call(m, "f", [])) |> should.equal(215.0) +} + +// A numeric callback result is run through ToPropertyKey, so key `1` becomes the +// string property "1" (GroupBy with coercion = property). Lengths 1 → ['a','c']. +pub fn wave15_object_groupby_numeric_key_coerced_test() { + let m = + compile( + "function f() { var o = Object.groupBy(['a', 'bb', 'c'], function(s){ return s.length; }); return o['1'].length * 10 + o['2'].length; }", + ) + to_float(call(m, "f", [])) |> should.equal(21.0) +} + +// ToPropertyKey uses ToString, so 1, '1' and an object whose toString() returns 1 +// all collapse to the single key "1" (sec-object.groupby toPropertyKey case). +pub fn wave15_object_groupby_topropertykey_test() { + let m = + compile( + "function f() { var s = { toString: function(){ return 1; } }; var o = Object.groupBy([1, '1', s], function(v){ return v; }); return o['1'].length; }", + ) + to_float(call(m, "f", [])) |> should.equal(3.0) +} + +// A non-callable callback throws a TypeError before any element is read +// (GroupBy step 2 / IsCallable). +pub fn wave15_object_groupby_noncallable_throws_test() { + let m = + compile( + "function f() { try { Object.groupBy([], null); return 0; } catch (e) { return e.name === 'TypeError' ? 1 : 0; } }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// The result is a null-prototype object: it has no inherited hasOwnProperty and +// Object.getPrototypeOf reports null (sec-object.groupby step 2). +pub fn wave15_object_groupby_null_prototype_test() { + let m = + compile( + "function f() { var o = Object.groupBy([1], function(x){ return x; }); return (Object.getPrototypeOf(o) === null && o.hasOwnProperty === undefined) ? 1 : 0; }", + ) + to_float(call(m, "f", [])) |> should.equal(1.0) +} + +// Object.hasOwn(obj, key) (sec-object.hasown) reports own properties, true for a +// present key and false for an absent one. +pub fn wave15_object_hasown_test() { + val("Object.hasOwn({ a: 1 }, 'a')") |> should.equal(dyn(True)) + val("Object.hasOwn({ a: 1 }, 'b')") |> should.equal(dyn(False)) +}