feat(codemode): add groupBy parity (#37992)

This commit is contained in:
Aiden Cline 2026-07-20 14:58:09 -05:00 committed by GitHub
commit 0c18d06c9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 289 additions and 9 deletions

View file

@ -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 = <R>(
})
}
export const invokeGroupBy = <R>(
runner: CallbackRunner<R>,
namespace: "Map" | "Object",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
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<unknown>).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<unknown>).push(item)
index += 1
}
return result
})
}
const groupByItems = (source: unknown): Iterable<unknown> | 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 = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
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<string, unknown>
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 = <R>(
runner: CallbackRunner<R>,
value: string,
@ -491,7 +574,7 @@ export const applyCollectionCallback = <R>(
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)
}

View file

@ -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<Record<GlobalNamespaceName, Set<string>>> = {
console: consoleMethods,
Date: dateStatics,
RegExp: regexpStatics,
Map: mapStatics,
URL: urlStatics,
}
@ -1608,6 +1616,9 @@ export class Interpreter<R> {
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)
}

View file

@ -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<unknown> | undefined => {

View file

@ -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<unknown>, node: AstNode): unknown => {
const requireObject = (): Record<string, unknown> => {