diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 5317cebe43..9cdeb1176a 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -177,7 +177,7 @@ ultimate source of truth. - [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and cannot be created, read, or written in CodeMode; tool path segments with those names remain supported. - [x] `Object.is` for supported data values. -- [ ] `Object.groupBy`. +- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results. ## Arrays @@ -287,7 +287,7 @@ ultimate source of truth. ## Map and Set -- [ ] Static `Map.groupBy`. +- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity. - [x] `new Map()` from entry arrays or another Map. - [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`. - [x] `new Set()` from arrays, strings, or another Set. diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index cde9de3e7a..895cb737bf 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -12,7 +12,7 @@ import { PromiseNamespace, UriFunction, } from "./model.js" -import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js" +import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" import { isBlockedMember, type SafeObject } from "../tool-runtime.js" import { CodeModeDate, @@ -413,6 +413,89 @@ export const invokeArrayFrom = ( }) } +export const invokeGroupBy = ( + runner: CallbackRunner, + namespace: "Map" | "Object", + args: Array, + node: AstNode, +): Effect.Effect => { + const source = args[0] + if (source === null || source === undefined) { + throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") + } + const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node) + const items = groupByItems(source) + if (items === undefined) { + throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") + } + return Effect.gen(function* () { + if (namespace === "Map") { + const result = new CodeModeMap() + let index = 0 + for (const item of items) { + const key = yield* apply([item, index]) + const group = result.map.get(key) + if (group === undefined) result.map.set(key, [item]) + else (group as Array).push(item) + index += 1 + } + return result + } + + const result: SafeObject = Object.create(null) as SafeObject + let index = 0 + for (const item of items) { + const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) + } + const group = result[key] + if (group === undefined) result[key] = [item] + else (group as Array).push(item) + index += 1 + } + return result + }) +} + +const groupByItems = (source: unknown): Iterable | undefined => { + if (Array.isArray(source) || typeof source === "string") return source + if (source instanceof CodeModeMap) return source.map.entries() + if (source instanceof CodeModeSet) return source.set.values() + if (source instanceof CodeModeURLSearchParams) return source.params.entries() +} + +const coerceGroupByPropertyKey = ( + runner: CallbackRunner, + value: unknown, + node: AstNode, +): Effect.Effect => { + if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) { + return Effect.succeed(coerceToString(value)) + } + if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]") + if (isRuntimeReference(value)) { + throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue") + } + const object = value as Record + if (!Object.hasOwn(object, "toString")) return Effect.succeed(coerceToString(value)) + return Effect.gen(function* () { + if (typeofValue(object.toString) === "function") { + const result = yield* runner.invokeCallable(object.toString, [], node) + if (result === null || (typeof result !== "object" && typeof result !== "function")) { + return coerceToString(result) + } + } + if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") { + const result = yield* runner.invokeCallable(object.valueOf, [], node) + if (result === null || (typeof result !== "object" && typeof result !== "function")) { + return coerceToString(result) + } + } + throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError") + }) +} + const invokeStringReplacer = ( runner: CallbackRunner, value: string, @@ -491,7 +574,7 @@ export const applyCollectionCallback = ( node, ) } - throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node).as("TypeError") } return (callbackArgs) => runner.invokeCallable(callback, callbackArgs, node) } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 79fbfac6b4..d1052e8430 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -35,7 +35,14 @@ import { UriFunction, } from "./model.js" import { caughtErrorValue, constructErrorValue } from "./errors.js" -import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js" +import { + arrayStatics, + type CallbackRunner, + invokeArrayFrom, + invokeGlobalMethod, + invokeGroupBy, + invokeIntrinsic, +} from "./methods.js" import { constructPromise, invokePromiseInstanceMethod, @@ -45,7 +52,7 @@ import { } from "./promises.js" import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" import { ScopeStack } from "./scope.js" -import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" +import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js" import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" import { dateMethods, dateStatics } from "../stdlib/date.js" import { jsonStatics } from "../stdlib/json.js" @@ -100,6 +107,7 @@ const globalStaticMembers: Partial>> = { console: consoleMethods, Date: dateStatics, RegExp: regexpStatics, + Map: mapStatics, URL: urlStatics, } @@ -1608,6 +1616,9 @@ export class Interpreter { if (callable.namespace === "Array" && callable.name === "from") { return yield* invokeArrayFrom(self.runner, args, node) } + if ((callable.namespace === "Object" || callable.namespace === "Map") && callable.name === "groupBy") { + return yield* invokeGroupBy(self.runner, callable.namespace, args, node) + } if (callable.namespace === "Array" && callable.name === "of") { return invokeGlobalMethod(callable, args, node) } diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index 226a1c1e40..4811c24d81 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -39,6 +39,8 @@ export const arrayMethods = new Set([ export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) +export const mapStatics = new Set(["groupBy"]) + export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) export const spreadItems = (value: unknown): Array | undefined => { diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 361c0aad96..d2930d81c7 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -6,7 +6,7 @@ import { boundedData, coerceToString } from "./value.js" export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) -export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"]) +export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries", "groupBy"]) export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { const requireObject = (): Record => { diff --git a/packages/codemode/test/groupby-test262.test.ts b/packages/codemode/test/groupby-test262.test.ts new file mode 100644 index 0000000000..f44a4f56fe --- /dev/null +++ b/packages/codemode/test/groupby-test262.test.ts @@ -0,0 +1,184 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/Object/groupBy/evenOdd.js + * - test/built-ins/Object/groupBy/groupLength.js + * - test/built-ins/Object/groupBy/callback-arg.js + * - test/built-ins/Object/groupBy/string.js + * - test/built-ins/Object/groupBy/emptyList.js + * - test/built-ins/Object/groupBy/toPropertyKey.js + * - test/built-ins/Object/groupBy/invalid-property-key.js + * - test/built-ins/Object/groupBy/invalid-iterable.js + * - test/built-ins/Object/groupBy/invalid-callback.js + * - test/built-ins/Object/groupBy/callback-throws.js + * - test/built-ins/Object/groupBy/null-prototype.js + * - test/built-ins/Map/groupBy/evenOdd.js + * - test/built-ins/Map/groupBy/groupLength.js + * - test/built-ins/Map/groupBy/callback-arg.js + * - test/built-ins/Map/groupBy/string.js + * - test/built-ins/Map/groupBy/emptyList.js + * - test/built-ins/Map/groupBy/toPropertyKey.js + * - test/built-ins/Map/groupBy/negativeZero.js + * - test/built-ins/Map/groupBy/invalid-iterable.js + * - test/built-ins/Map/groupBy/invalid-callback.js + * - test/built-ins/Map/groupBy/callback-throws.js + * - test/built-ins/Map/groupBy/map-instance.js + * + * Copyright (c) 2023 Ecma International. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + * Invalid-iterable cases use a plain object, equivalent to the upstream object's absent + * Symbol.iterator; CodeMode does not support symbol-keyed properties. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) + +const value = async (code: string) => { + const result = await execute(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("Object.groupBy Test262 parity", () => { + test("groups values by coerced property keys", async () => { + expect( + await value(` + const values = [1, 2, 3] + const grouped = Object.groupBy(values, (value) => value % 2 === 0 ? "even" : "odd") + const stringable = { toString: () => 1 } + const coerced = Object.groupBy([1, "1", stringable], (value) => value) + const lengths = Object.groupBy(["hello", "test", "world"], (value) => value.length) + return [ + Object.keys(grouped), grouped.even, grouped.odd, grouped.toString === undefined, + Object.keys(coerced), coerced[1].length, + Object.keys(lengths), lengths[5], lengths[4], + ] + `), + ).toEqual([["odd", "even"], [2], [1, 3], true, ["1"], 3, ["4", "5"], ["hello", "world"], ["test"]]) + }) + + test("passes each value and index to the callback", async () => { + expect( + await value(` + const values = [-0, 0, 1, 2, 3] + const seen = [] + Object.groupBy(values, (value, index, ...extra) => { + seen.push([value === values[index], index, extra.length]) + return null + }) + return seen + `), + ).toEqual([ + [true, 0, 0], + [true, 1, 0], + [true, 2, 0], + [true, 3, 0], + [true, 4, 0], + ]) + }) + + test("supports strings and empty collections", async () => { + expect( + await value(` + const grouped = Object.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after") + const empty = Object.groupBy([], () => { throw new Error("not called") }) + return [Object.keys(grouped), grouped.before, grouped.after, Object.keys(empty)] + `), + ).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], []]) + }) + + test("rejects invalid inputs and propagates callback and key-coercion failures", async () => { + expect( + await value(` + const messages = [] + try { Object.groupBy({}, () => { throw new Error("not called") }) } catch (error) { messages.push(error.name) } + for (const callback of [null, undefined, {}]) { + try { Object.groupBy([], callback) } catch (error) { messages.push(error.name) } + } + try { Object.groupBy([1], () => { throw new Error("callback") }) } catch (error) { messages.push(error.message) } + try { + Object.groupBy([1], () => ({ + toString: () => { throw new Error("property key") }, + })) + } catch (error) { messages.push(error.message) } + return messages + `), + ).toEqual(["TypeError", "TypeError", "TypeError", "TypeError", "callback", "property key"]) + }) +}) + +describe("Map.groupBy Test262 parity", () => { + test("returns a Map with identity-preserving groups", async () => { + expect( + await value(` + const stringable = { toString: () => 1 } + const grouped = Map.groupBy([1, "1", stringable], (value) => value) + const parity = Map.groupBy([1, 2, 3], (value) => value % 2 === 0 ? "even" : "odd") + const lengths = Map.groupBy(["hello", "test", "world"], (value) => value.length) + return [ + grouped instanceof Map, + grouped.size, + grouped.get(1), + grouped.get("1"), + grouped.has(stringable), + grouped.keys().length, + parity.get("even"), + parity.get("odd"), + lengths.keys(), + lengths.get(5), + lengths.get(4), + ] + `), + ).toEqual([true, 3, [1], ["1"], true, 3, [2], [1, 3], [5, 4], ["hello", "world"], ["test"]]) + }) + + test("normalizes negative zero and passes each value and index", async () => { + expect( + await value(` + const values = [-0, 0, 1, 2, 3] + const seen = [] + const grouped = Map.groupBy(values, (value, index, ...extra) => { + seen.push([value === values[index], index, extra.length]) + return value + }) + return [grouped.size, grouped.get(0), seen] + `), + ).toEqual([ + 4, + [-0, 0], + [ + [true, 0, 0], + [true, 1, 0], + [true, 2, 0], + [true, 3, 0], + [true, 4, 0], + ], + ]) + }) + + test("supports strings and empty collections", async () => { + expect( + await value(` + const grouped = Map.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after") + const empty = Map.groupBy([], () => { throw new Error("not called") }) + return [grouped.keys(), grouped.get("before"), grouped.get("after"), empty.size] + `), + ).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], 0]) + }) + + test("rejects invalid inputs and propagates callback failures", async () => { + expect( + await value(` + const results = [] + try { Map.groupBy({}, () => { throw new Error("not called") }) } catch (error) { results.push(error.name) } + for (const callback of [null, undefined, {}]) { + try { Map.groupBy([], callback) } catch (error) { results.push(error.name) } + } + try { Map.groupBy([1], () => { throw new Error("callback") }) } + catch (error) { results.push(error.message) } + return results + `), + ).toEqual(["TypeError", "TypeError", "TypeError", "TypeError", "callback"]) + }) +}) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 26c942e23d..632d7b309b 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -857,7 +857,6 @@ describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => { describe("coercion parity: unknown static members read as undefined", () => { test("feature detection on missing statics works like native JS", async () => { expect(await value(`return typeof Math.sum`)).toBe("undefined") - expect(await value(`return Object.groupBy === undefined`)).toBe(true) expect(await value(`return RegExp.quote === undefined`)).toBe(true) expect(await value(`return Number.range === undefined`)).toBe(true) expect(await value(`return String.raw === undefined`)).toBe(true) @@ -866,7 +865,6 @@ describe("coercion parity: unknown static members read as undefined", () => { expect(await value(`return Date.moment === undefined`)).toBe(true) expect(await value(`return JSON.rawJSON === undefined`)).toBe(true) expect(await value(`return URL.createObjectURL === undefined`)).toBe(true) - expect(await value(`return Map.groupBy === undefined`)).toBe(true) expect(await value(`return Math.sum?.([1]) ?? "fallback"`)).toBe("fallback") }) @@ -876,6 +874,8 @@ describe("coercion parity: unknown static members read as undefined", () => { expect(await value(`return typeof Date.now`)).toBe("function") expect(await value(`return typeof Math.sumPrecise`)).toBe("function") expect(await value(`return typeof RegExp.escape`)).toBe("function") + expect(await value(`return typeof Object.groupBy`)).toBe("function") + expect(await value(`return typeof Map.groupBy`)).toBe("function") expect(await value(`return Math.max(1, 2)`)).toBe(2) expect(await value(`return Math.sumPrecise([1, 2])`)).toBe(3) expect(await value(`return RegExp.escape("a.b")`)).toBe("\\x61\\.b")