diff --git a/Userland/Libraries/LibJS/Runtime/ArrayPrototype.cpp b/Userland/Libraries/LibJS/Runtime/ArrayPrototype.cpp index 15672a5873..f0c953434e 100644 --- a/Userland/Libraries/LibJS/Runtime/ArrayPrototype.cpp +++ b/Userland/Libraries/LibJS/Runtime/ArrayPrototype.cpp @@ -72,6 +72,7 @@ void ArrayPrototype::initialize(GlobalObject& global_object) define_native_function(vm.names.keys, keys, 0, attr); define_native_function(vm.names.entries, entries, 0, attr); define_native_function(vm.names.copyWithin, copy_within, 2, attr); + define_native_function(vm.names.groupBy, group_by, 1, attr); // Use define_direct_property here instead of define_native_function so that // Object.is(Array.prototype[Symbol.iterator], Array.prototype.values) @@ -80,7 +81,8 @@ void ArrayPrototype::initialize(GlobalObject& global_object) define_direct_property(*vm.well_known_symbol_iterator(), get_without_side_effects(vm.names.values), attr); // 23.1.3.35 Array.prototype [ @@unscopables ], https://tc39.es/ecma262/#sec-array.prototype-@@unscopables - // With proposal, https://tc39.es/proposal-array-find-from-last/#sec-array.prototype-@@unscopables + // With find from last proposal, https://tc39.es/proposal-array-find-from-last/#sec-array.prototype-@@unscopables + // With array grouping proposal, https://tc39.es/proposal-array-grouping/#sec-array.prototype-@@unscopables auto* unscopable_list = Object::create(global_object, nullptr); MUST(unscopable_list->create_data_property_or_throw(vm.names.at, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.copyWithin, Value(true))); @@ -92,6 +94,7 @@ void ArrayPrototype::initialize(GlobalObject& global_object) MUST(unscopable_list->create_data_property_or_throw(vm.names.findLastIndex, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.flat, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.flatMap, Value(true))); + MUST(unscopable_list->create_data_property_or_throw(vm.names.groupBy, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.includes, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.keys, Value(true))); MUST(unscopable_list->create_data_property_or_throw(vm.names.values, Value(true))); @@ -1665,4 +1668,86 @@ JS_DEFINE_NATIVE_FUNCTION(ArrayPrototype::at) return TRY(this_object->get(index.value())); } +// 2.3 AddValueToKeyedGroup ( groups, key, value ), https://tc39.es/proposal-array-grouping/#sec-add-value-to-keyed-group +template +static void add_value_to_keyed_group(GlobalObject& global_object, GroupsType& groups, KeyType key, Value value) +{ + // 1. For each Record { [[Key]], [[Elements]] } g of groups, do + // a. If ! SameValue(g.[[Key]], key) is true, then + // NOTE: This is performed in KeyedGroupTraits::equals for groupByToMap and Traits::equals for groupBy. + auto existing_elements_iterator = groups.find(key); + if (existing_elements_iterator != groups.end()) { + // i. Assert: exactly one element of groups meets this criteria. + // NOTE: This is done on insertion into the hash map, as only `set` tells us if we overrode an entry. + + // ii. Append value as the last element of g.[[Elements]]. + existing_elements_iterator->value.append(value); + + // iii. Return. + return; + } + + // 2. Let group be the Record { [[Key]]: key, [[Elements]]: « value » }. + MarkedValueList new_elements { global_object.heap() }; + new_elements.append(value); + + // 3. Append group as the last element of groups. + auto result = groups.set(key, move(new_elements)); + VERIFY(result == AK::HashSetResult::InsertedNewEntry); +} + +// 2.1 Array.prototype.groupBy ( callbackfn [ , thisArg ] ), https://tc39.es/proposal-array-grouping/#sec-array.prototype.groupby +JS_DEFINE_NATIVE_FUNCTION(ArrayPrototype::group_by) +{ + auto callback_function = vm.argument(0); + auto this_arg = vm.argument(1); + + // 1. Let O be ? ToObject(this value). + auto* this_object = TRY(vm.this_value(global_object).to_object(global_object)); + + // 2. Let len be ? LengthOfArrayLike(O). + auto length = TRY(length_of_array_like(global_object, *this_object)); + + // 3. If IsCallable(callbackfn) is false, throw a TypeError exception. + if (!callback_function.is_function()) + return vm.throw_completion(global_object, ErrorType::NotAFunction, callback_function.to_string_without_side_effects()); + + // 5. Let groups be a new empty List. + OrderedHashMap groups; + + // 4. Let k be 0. + // 6. Repeat, while k < len + for (size_t index = 0; index < length; ++index) { + // a. Let Pk be ! ToString(𝔽(k)). + auto index_property = PropertyKey { index }; + + // b. Let kValue be ? Get(O, Pk). + auto k_value = TRY(this_object->get(index_property)); + + // c. Let propertyKey be ? ToPropertyKey(? Call(callbackfn, thisArg, « kValue, 𝔽(k), O »)). + auto property_key_value = TRY(vm.call(callback_function.as_function(), this_arg, k_value, Value(index), this_object)); + auto property_key = TRY(property_key_value.to_property_key(global_object)); + + // d. Perform ! AddValueToKeyedGroup(groups, propertyKey, kValue). + add_value_to_keyed_group(global_object, groups, property_key, k_value); + + // e. Set k to k + 1. + } + + // 7. Let obj be ! OrdinaryObjectCreate(null). + auto* object = Object::create(global_object, nullptr); + + // 8. For each Record { [[Key]], [[Elements]] } g of groups, do + for (auto& group : groups) { + // a. Let elements be ! CreateArrayFromList(g.[[Elements]]). + auto* elements = Array::create_from(global_object, group.value); + + // b. Perform ! CreateDataPropertyOrThrow(obj, g.[[Key]], elements). + MUST(object->create_data_property_or_throw(group.key, elements)); + } + + // 9. Return obj. + return object; +} + } diff --git a/Userland/Libraries/LibJS/Runtime/ArrayPrototype.h b/Userland/Libraries/LibJS/Runtime/ArrayPrototype.h index f58557cd2c..3dd2d07fb5 100644 --- a/Userland/Libraries/LibJS/Runtime/ArrayPrototype.h +++ b/Userland/Libraries/LibJS/Runtime/ArrayPrototype.h @@ -54,6 +54,7 @@ private: JS_DECLARE_NATIVE_FUNCTION(keys); JS_DECLARE_NATIVE_FUNCTION(entries); JS_DECLARE_NATIVE_FUNCTION(copy_within); + JS_DECLARE_NATIVE_FUNCTION(group_by); }; } diff --git a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h index 56aca802e0..64d53d6c0d 100644 --- a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h +++ b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h @@ -241,6 +241,7 @@ namespace JS { P(global) \ P(globalThis) \ P(group) \ + P(groupBy) \ P(groupCollapsed) \ P(groupEnd) \ P(groups) \ diff --git a/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype-generic-functions.js b/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype-generic-functions.js index 07b0bf610b..587d5d841c 100644 --- a/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype-generic-functions.js +++ b/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype-generic-functions.js @@ -304,4 +304,17 @@ describe("ability to work with generic non-array objects", () => { 2, ]); }); + + test("groupBy", () => { + const visited = []; + const o = { length: 5, 0: "foo", 1: "bar", 3: "baz" }; + const result = Array.prototype.groupBy.call(o, (value, _, object) => { + expect(object).toBe(o); + visited.push(value); + return value !== undefined ? value.startsWith("b") : false; + }); + expect(visited).toEqual(["foo", "bar", undefined, "baz", undefined]); + expect(result.false).toEqual(["foo", undefined, undefined]); + expect(result.true).toEqual(["bar", "baz"]); + }); }); diff --git a/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype.groupBy.js b/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype.groupBy.js new file mode 100644 index 0000000000..dd8955a295 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/builtins/Array/Array.prototype.groupBy.js @@ -0,0 +1,101 @@ +test("length is 1", () => { + expect(Array.prototype.groupBy).toHaveLength(1); +}); + +describe("errors", () => { + test("callback must be a function", () => { + expect(() => { + [].groupBy(undefined); + }).toThrowWithMessage(TypeError, "undefined is not a function"); + }); + + test("null or undefined this value", () => { + expect(() => { + Array.prototype.groupBy.call(); + }).toThrowWithMessage(TypeError, "ToObject on null or undefined"); + + expect(() => { + Array.prototype.groupBy.call(undefined); + }).toThrowWithMessage(TypeError, "ToObject on null or undefined"); + + expect(() => { + Array.prototype.groupBy.call(null); + }).toThrowWithMessage(TypeError, "ToObject on null or undefined"); + }); +}); + +describe("normal behavior", () => { + test("basic functionality", () => { + const array = [1, 2, 3, 4, 5, 6]; + const visited = []; + + const firstResult = array.groupBy(value => { + visited.push(value); + return value % 2 === 0; + }); + + expect(visited).toEqual([1, 2, 3, 4, 5, 6]); + expect(firstResult.true).toEqual([2, 4, 6]); + expect(firstResult.false).toEqual([1, 3, 5]); + + const firstKeys = Object.keys(firstResult); + expect(firstKeys).toHaveLength(2); + expect(firstKeys[0]).toBe("false"); + expect(firstKeys[1]).toBe("true"); + + const secondResult = array.groupBy((_, index) => { + return index < array.length / 2; + }); + + expect(secondResult.true).toEqual([1, 2, 3]); + expect(secondResult.false).toEqual([4, 5, 6]); + + const secondKeys = Object.keys(secondResult); + expect(secondKeys).toHaveLength(2); + expect(secondKeys[0]).toBe("true"); + expect(secondKeys[1]).toBe("false"); + + const thisArg = [7, 8, 9, 10, 11, 12]; + const thirdResult = array.groupBy(function (_, __, arrayVisited) { + expect(arrayVisited).toBe(array); + expect(this).toBe(thisArg); + }, thisArg); + + expect(thirdResult.undefined).not.toBe(array); + expect(thirdResult.undefined).not.toBe(thisArg); + expect(thirdResult.undefined).toEqual(array); + + const thirdKeys = Object.keys(thirdResult); + expect(thirdKeys).toHaveLength(1); + expect(thirdKeys[0]).toBe("undefined"); + }); + + test("is unscopable", () => { + expect(Array.prototype[Symbol.unscopables].groupBy).toBeTrue(); + const array = []; + with (array) { + expect(() => { + groupBy; + }).toThrowWithMessage(ReferenceError, "'groupBy' is not defined"); + } + }); + + test("never calls callback with empty array", () => { + var callbackCalled = 0; + expect( + [].groupBy(() => { + callbackCalled++; + }) + ).toEqual({}); + expect(callbackCalled).toBe(0); + }); + + test("calls callback once for every item", () => { + var callbackCalled = 0; + const result = [1, 2, 3].groupBy(() => { + callbackCalled++; + }); + expect(result.undefined).toEqual([1, 2, 3]); + expect(callbackCalled).toBe(3); + }); +});