opencode/packages/codemode/src/interpreter/methods.ts

1165 lines
43 KiB
TypeScript

import { Effect } from "effect"
import {
type AstNode,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
IntrinsicReference,
InterpreterRuntimeError,
JsonMethodReference,
PromiseCapabilityFunction,
PromiseNamespace,
UriFunction,
} from "./model.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
isCodeModeValue,
} from "../values.js"
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
import { invokeObjectMethod } from "../stdlib/object.js"
import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly invokeCallable: (
callable: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
// constructors pass the gate and throw a TypeError on call, like JS.
export type SupportedCallback =
| CodeModeFunction
| CoercionFunction
| UriFunction
| PromiseCapabilityFunction
| GlobalMethodReference
| JsonMethodReference
| IntrinsicReference
| ErrorConstructorReference
| GlobalNamespace
| PromiseNamespace
export const isSupportedCallback = (value: unknown): value is SupportedCallback =>
value instanceof CodeModeFunction ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof GlobalMethodReference ||
value instanceof JsonMethodReference ||
value instanceof IntrinsicReference ||
value instanceof ErrorConstructorReference ||
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
// new-requiring constructors throw a TypeError. Math/JSON/console stay non-callable.
(value instanceof GlobalNamespace && typeofValue(value) === "function") ||
value instanceof PromiseNamespace
export const invokeIntrinsic = <R>(
runner: CallbackRunner<R>,
ref: IntrinsicReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (typeof ref.receiver === "string") {
if (ref.name === "replace" || ref.name === "replaceAll") {
if (isSupportedCallback(args[1])) return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
if (typeofValue(args[1]) === "function") {
throw new InterpreterRuntimeError(
`String.${ref.name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
node,
)
}
}
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
}
if (typeof ref.receiver === "number") {
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
}
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeDate) {
const target = ref.receiver
const argumentCount = dateSetterArgumentCount(ref.name)
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
const initialTime = target.time
return Effect.map(
Effect.forEach(args.slice(0, argumentCount), (arg) => coerceNumericArgument(runner, arg, node), {
concurrency: 1,
}),
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
)
}
if (ref.receiver instanceof CodeModeRegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof CodeModeMap) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeSet) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
}
const coerceNumericArgument = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<number, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
return Effect.succeed(coerceToNumber(value))
}
const object = value as Record<string, unknown>
return Effect.gen(function* () {
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 coerceToNumber(result)
}
}
if (!Object.hasOwn(object, "toString")) return coerceToNumber(value)
if (typeofValue(object.toString) === "function") {
const result = yield* runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
return coerceToNumber(result)
}
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
if (containsOpaqueReference(arg)) {
throw new InterpreterRuntimeError(
`String.${name} expects argument ${index + 1} to be a data value.`,
node,
"InvalidDataValue",
)
}
return arg
}
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
const rejectRegex = (): void => {
if (args[0] instanceof CodeModeRegExp) {
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
).as("TypeError")
}
}
let result: unknown
switch (name) {
case "toLowerCase":
result = value.toLowerCase()
break
case "toUpperCase":
result = value.toUpperCase()
break
case "trim":
result = value.trim()
break
case "trimStart":
result = value.trimStart()
break
case "trimEnd":
result = value.trimEnd()
break
// Locale/options are deliberately unsupported; comparison uses the host default locale.
case "localeCompare":
result = value.localeCompare(str(0))
break
case "normalize": {
const form = optStr(0)
try {
result = value.normalize(form)
} catch {
throw new InterpreterRuntimeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
node,
).as("RangeError")
}
break
}
case "split": {
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
if (args[0] === undefined) {
const requestedLimit = optNum(1)
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
break
}
if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
const requestedLimit = optNum(1)
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
break
}
case "slice":
result = value.slice(optNum(0), optNum(1))
break
case "includes":
rejectRegex()
result = value.includes(str(0), optNum(1))
break
case "startsWith":
rejectRegex()
result = value.startsWith(str(0), optNum(1))
break
case "endsWith":
rejectRegex()
result = value.endsWith(str(0), optNum(1))
break
case "indexOf":
result = value.indexOf(str(0), optNum(1))
break
case "lastIndexOf":
result = value.lastIndexOf(str(0), optNum(1))
break
case "replace":
case "replaceAll": {
if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
break
}
if (name === "replace") {
result = value.replace(str(0), str(1))
break
}
result = value.replaceAll(str(0), str(1))
break
}
case "match": {
const pattern = toHostRegex(args[0], name, node)
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return boundedData(matched, "String.match result")
return matchToValue(matched)
}
case "matchAll": {
const pattern = toHostRegex(args[0], name, node, "g")
if (!pattern.global) {
throw new InterpreterRuntimeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
node,
)
}
return Array.from(value.matchAll(pattern), matchToValue)
}
case "search": {
result = value.search(toHostRegex(args[0], name, node))
break
}
case "repeat": {
const count = num(0)
if (!Number.isFinite(count) || count < 0)
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
result = value.repeat(count)
break
}
case "padStart":
result = value.padStart(num(0), optStr(1))
break
case "padEnd":
result = value.padEnd(num(0), optStr(1))
break
case "charAt":
result = value.charAt(optNum(0) ?? 0)
break
case "at":
result = value.at(optNum(0) ?? 0)
break
case "substring":
result = value.substring(optNum(0) ?? 0, optNum(1))
break
case "charCodeAt":
result = value.charCodeAt(optNum(0) ?? 0)
break
case "codePointAt":
result = value.codePointAt(optNum(0) ?? 0)
break
case "toString":
result = value
break
case "concat": {
result = value.concat(...args.map((_, index) => str(index)))
break
}
default:
throw new InterpreterRuntimeError(`String method '${name}' is not available.`, node)
}
return boundedData(result, `String.${name} result`)
}
export const arrayStatics = new Set(["isArray", "of", "from"])
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
switch (name) {
case "isArray":
return Array.isArray(args[0])
case "of":
return [...args]
default:
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
}
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
const length = (source as { length: number }).length
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > 4_294_967_295) throw new RangeError("Invalid array length")
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
}
export const invokeArrayFrom = <R>(
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const source = args[0]
const apply =
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
if (source instanceof CodeModeGenerator) {
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node).as(
"TypeError",
)
}
const arrayLike = arrayLikeSource(source, node)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = Reflect.get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
}
return values
}
const values: Array<unknown> = []
let index = 0
while (true) {
const step = yield* cursor.next
if (step.done) return values
values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index])))
index += 1
}
})
}
export const invokeGroupBy = <R>(
runner: CallbackRunner<R> & SyncIteratorRunner<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)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
if (namespace === "Map") {
const result = new CodeModeMap()
let index = 0
while (true) {
const step = yield* cursor.next
if (step.done) return result
const item = step.value
const key = yield* preserveConsumerError(cursor, 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
}
}
const result: SafeObject = Object.create(null) as SafeObject
let index = 0
while (true) {
const step = yield* cursor.next
if (step.done) return result
const item = step.value
const key = yield* preserveConsumerError(
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
if (isBlockedMember(key)) {
return yield* preserveConsumerError(
cursor,
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
)
}
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
index += 1
}
})
}
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,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
const groups = callbackArgs[callbackArgs.length - 1]
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
matches.push({ match, offset, args: callbackArgs })
return match
}
const pattern = args[0]
if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
if (name === "replace") value.replace(search, collect)
else value.replaceAll(search, collect)
}
return Effect.gen(function* () {
const output: Array<string> = []
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof CodeModePromise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
output.push(value.slice(end))
return boundedData(output.join(""), `String.${name} result`)
})
}
export const applyCollectionCallback = <R>(
runner: CallbackRunner<R>,
callback: unknown,
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw new InterpreterRuntimeError(
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node).as("TypeError")
}
return (callbackArgs) => runner.invokeCallable(callback, callbackArgs, node)
}
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeMap,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
switch (name) {
case "get":
return Effect.succeed(target.map.get(args[0]))
case "has":
return Effect.succeed(target.map.has(args[0]))
case "set":
return Effect.sync(() => {
target.map.set(args[0], args[1])
return target
})
case "delete":
return Effect.sync(() => target.map.delete(args[0]))
case "clear":
return Effect.sync(() => {
target.map.clear()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.map.keys()))
case "values":
return Effect.sync(() => Array.from(target.map.values()))
case "entries":
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`Map method '${name}' is not available.`, node)
}
}
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeSet,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
switch (name) {
case "has":
return Effect.succeed(target.set.has(args[0]))
case "add":
return Effect.sync(() => {
target.set.add(args[0])
return target
})
case "delete":
return Effect.sync(() => target.set.delete(args[0]))
case "clear":
return Effect.sync(() => {
target.set.clear()
return undefined
})
case "keys":
case "values":
return Effect.sync(() => Array.from(target.set.values()))
case "entries":
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
return undefined
})
}
case "union":
case "intersection":
case "difference":
case "symmetricDifference":
case "isSubsetOf":
case "isSupersetOf":
case "isDisjointFrom":
return invokeSetOperation(runner, target, name, args[0], node)
default:
throw new InterpreterRuntimeError(`Set method '${name}' is not available.`, node)
}
}
const invokeSetOperation = <R>(
runner: CallbackRunner<R>,
target: CodeModeSet,
name: string,
source: unknown,
node: AstNode,
): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const other = yield* loadSetRecord(runner, source, name, node)
if (name === "union") {
const result = copySet(target)
for (const item of yield* other.keys()) result.set.add(item)
return result
}
if (name === "intersection") {
const result = new CodeModeSet()
if (target.set.size <= other.size) {
for (const item of target.set.values()) {
if (yield* other.has(item)) result.set.add(item)
}
return result
}
for (const item of yield* other.keys()) {
if (target.set.has(item)) result.set.add(item)
}
return result
}
if (name === "difference") {
const result = copySet(target)
if (target.set.size <= other.size) {
for (const item of result.set.values()) {
if (yield* other.has(item)) result.set.delete(item)
}
return result
}
for (const item of yield* other.keys()) result.set.delete(item)
return result
}
if (name === "symmetricDifference") {
const result = copySet(target)
for (const item of yield* other.keys()) {
if (target.set.has(item)) result.set.delete(item)
else result.set.add(item)
}
return result
}
if (name === "isSubsetOf") {
if (target.set.size > other.size) return false
for (const item of target.set.values()) {
if (!(yield* other.has(item))) return false
}
return true
}
if (name === "isSupersetOf") {
if (target.set.size < other.size) return false
for (const item of yield* other.keys()) {
if (!target.set.has(item)) return false
}
return true
}
if (target.set.size <= other.size) {
for (const item of target.set.values()) {
if (yield* other.has(item)) return false
}
return true
}
for (const item of yield* other.keys()) {
if (target.set.has(item)) return false
}
return true
})
const copySet = (source: CodeModeSet): CodeModeSet => {
const result = new CodeModeSet()
for (const item of source.set.values()) result.set.add(item)
return result
}
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
if (source instanceof CodeModeSet) {
return Effect.succeed({
size: source.set.size,
has: (item: unknown) => Effect.succeed(source.set.has(item)),
keys: () => Effect.succeed(source.set.values()),
})
}
if (source instanceof CodeModeMap) {
return Effect.succeed({
size: source.map.size,
has: (item: unknown) => Effect.succeed(source.map.has(item)),
keys: () => Effect.succeed(source.map.keys()),
})
}
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
}
const object = source as Record<string, unknown>
return Effect.gen(function* () {
const size = yield* coerceNumericArgument(runner, object.size, node)
if (Number.isNaN(size)) {
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node).as(
"TypeError",
)
}
if (!isSupportedCallback(object.has) || !isSupportedCallback(object.keys)) {
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node).as("TypeError")
}
const has = object.has
const keys = object.keys
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, [item], node), Boolean),
keys: () =>
Effect.flatMap(runner.invokeCallable(keys, [], node), (result) => {
if (Array.isArray(result)) return Effect.succeed(result)
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node).as("TypeError")
}),
}
})
}
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
).as("TypeError")
}
}
switch (name) {
case "append": {
requireArgs(2)
return Effect.sync(() => {
target.params.append(arg(0), arg(1))
return undefined
})
}
case "delete": {
requireArgs(1)
return Effect.sync(() => {
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
else target.params.delete(arg(0))
return undefined
})
}
case "get":
requireArgs(1)
return Effect.sync(() => target.params.get(arg(0)))
case "getAll":
requireArgs(1)
return Effect.sync(() => target.params.getAll(arg(0)))
case "has":
requireArgs(1)
return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0))))
case "set": {
requireArgs(2)
return Effect.sync(() => {
target.params.set(arg(0), arg(1))
return undefined
})
}
case "sort":
return Effect.sync(() => {
target.params.sort()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.params.keys()))
case "values":
return Effect.sync(() => Array.from(target.params.values()))
case "entries":
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
case "toString":
return Effect.sync(() => target.params.toString())
case "forEach": {
requireArgs(1)
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available.`, node)
}
}
const invokeArrayMethod = <R>(
runner: CallbackRunner<R>,
target: Array<unknown>,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const optNumber = (value: unknown, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number")
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
return value
}
switch (name) {
case "join": {
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
}
const input = boundedData(target, "Array.join input") as Array<unknown>
return Effect.succeed(
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
)
}
case "includes":
if (args.length === 0 || args.length > 2)
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
case "indexOf":
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
case "lastIndexOf":
return Effect.succeed(
args[1] === undefined
? target.lastIndexOf(args[0])
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
)
case "at":
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
case "slice":
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
case "concat":
return Effect.succeed(target.concat(...args))
case "flat":
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse":
return Effect.succeed(target.reverse())
case "sort": {
const length = target.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length
const itemCount = length - holeCount
return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => {
target[index] = item
})
Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => {
Reflect.deleteProperty(target, index)
})
return target
})
}
case "toSorted":
return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed":
return Effect.succeed([...target].reverse())
case "with": {
const index = optNumber(args[0], "index") ?? 0
const resolved = index < 0 ? target.length + index : index
if (resolved < 0 || resolved >= target.length) {
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
}
const copied = [...target]
copied[resolved] = args[1]
return Effect.succeed(copied)
}
case "push": {
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
target.push(...args)
return Effect.succeed(target.length)
}
case "unshift": {
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
target.unshift(...args)
return Effect.succeed(target.length)
}
case "pop":
return Effect.succeed(target.pop())
case "shift":
return Effect.succeed(target.shift())
case "splice": {
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
const start = optNumber(args[0], "start") ?? 0
if (args.length === 1) return Effect.succeed(target.splice(start))
const deleteCount = optNumber(args[1], "delete count") ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
}
case "toSpliced": {
if (args.length === 0) return Effect.succeed([...target])
const start = optNumber(args[0], "start") ?? 0
if (args.length === 1) {
const copied = [...target]
copied.splice(start)
return Effect.succeed(copied)
}
const deleteCount = optNumber(args[1], "delete count") ?? 0
const copied = [...target]
copied.splice(start, deleteCount, ...args.slice(2))
return Effect.succeed(copied)
}
case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
}
case "copyWithin":
return Effect.succeed(
target.copyWithin(
optNumber(args[0], "target index") ?? 0,
optNumber(args[1], "start") ?? 0,
optNumber(args[2], "end"),
),
)
case "keys":
return Effect.succeed(Array.from(target.keys()))
case "values":
return Effect.succeed([...target])
case "entries":
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
}
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
return Effect.gen(function* () {
// Fix iteration length while reading existing elements live.
const length = target.length
switch (name) {
case "map": {
const values: Array<unknown> = []
values.length = length
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
values[index] = yield* apply([target[index], index, target])
}
return values
}
case "flatMap": {
const values: Array<unknown> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const mapped = yield* apply([target[index], index, target])
if (Array.isArray(mapped)) values.push(...mapped)
else values.push(mapped)
}
return values
}
case "filter": {
const values: Array<unknown> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const item = target[index]
if (yield* apply([item, index, target])) values.push(item)
}
return values
}
case "find":
for (let index = 0; index < length; index += 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
}
return undefined
case "findIndex":
for (let index = 0; index < length; index += 1) {
if (yield* apply([target[index], index, target])) return index
}
return -1
case "some":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (yield* apply([target[index], index, target])) return true
}
return false
case "every":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (!(yield* apply([target[index], index, target]))) return false
}
return true
case "forEach":
for (let index = 0; index < length; index += 1) {
if (index in target) yield* apply([target[index], index, target])
}
return undefined
case "reduce": {
let start = 0
let accumulator = args[1]
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length)
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start += 1
}
for (let index = start; index < length; index += 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
}
return accumulator
}
case "reduceRight": {
let start = length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0)
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start -= 1
}
for (let index = start; index >= 0; index -= 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
}
return accumulator
}
case "findLast":
for (let index = length - 1; index >= 0; index -= 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
}
return undefined
case "findLastIndex":
for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([target[index], index, target])) return index
}
return -1
}
throw new InterpreterRuntimeError(`Array method '${name}' is not available.`, node)
})
}
const sortArray = <R>(
runner: CallbackRunner<R>,
target: Array<unknown>,
comparator: unknown,
name: string,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() =>
[...target].sort((a, b) => {
const left = coerceToString(a)
const right = coerceToString(b)
return left < right ? -1 : left > right ? 1 : 0
}),
)
}
const apply = applyCollectionCallback(runner, comparator, name, node)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
return Effect.gen(function* () {
const left = yield* mergeSort(items.slice(0, midpoint))
const right = yield* mergeSort(items.slice(midpoint))
const merged: Array<unknown> = []
let leftIndex = 0
let rightIndex = 0
while (leftIndex < left.length && rightIndex < right.length) {
// Treat a NaN comparator result as equal to preserve stable ordering.
const order = coerceToNumber(yield* apply([left[leftIndex], right[rightIndex]]))
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
else merged.push(right[rightIndex++])
}
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
})
}
const defined = target.filter((item) => item !== undefined)
const undefinedCount = target.length - defined.length
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
}