1165 lines
43 KiB
TypeScript
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)])
|
|
}
|