From c8a40450e57bd5e9925396fb443497c68a2eb800 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:36:42 -0500 Subject: [PATCH] feat(codemode): support generator functions (#38172) --- packages/codemode/interpreter-support.md | 59 +- packages/codemode/src/interpreter/errors.ts | 39 +- packages/codemode/src/interpreter/iterator.ts | 21 + packages/codemode/src/interpreter/methods.ts | 93 +- packages/codemode/src/interpreter/model.ts | 26 + packages/codemode/src/interpreter/promises.ts | 151 +- .../codemode/src/interpreter/references.ts | 5 + packages/codemode/src/interpreter/runtime.ts | 838 ++++++++--- packages/codemode/src/stdlib/math.ts | 40 +- packages/codemode/src/stdlib/object.ts | 76 +- packages/codemode/src/tool-runtime.ts | 2 +- packages/codemode/test/codemode.test.ts | 3 +- .../codemode/test/generator-test262.test.ts | 1271 +++++++++++++++++ 13 files changed, 2232 insertions(+), 392 deletions(-) create mode 100644 packages/codemode/src/interpreter/iterator.ts create mode 100644 packages/codemode/test/generator-test262.test.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 467f6e0f5b7..7c2a9596652 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -29,7 +29,8 @@ ultimate source of truth. ## Values and literals - [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings. -- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams. +- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous + iterators, and synchronous generators. - [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and `undefined` are no-ops, while arrays are rejected. - [x] Template literals with interpolation. @@ -57,7 +58,9 @@ ultimate source of truth. - [ ] Hoist function declarations accepted directly in switch cases. - [x] Computed object destructuring keys such as `const { [field]: value } = record`. - [x] Object destructuring from arrays, such as `const { length } = values`. -- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams. +- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous + iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion + or binding/default failure. ## Statements and control flow @@ -65,7 +68,8 @@ ultimate source of truth. - [x] `if`/`else` and conditional expressions. - [x] `switch`, including default clauses and fallthrough. - [x] `for`, `while`, and `do...while`. -- [x] `for...of` over arrays, strings, Maps, Sets, and URLSearchParams. +- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined + synchronous generators. Abrupt completion invokes the iterator's optional `return()`. - [x] `for...in` over own keys of plain objects, arrays, and tool references. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. @@ -75,7 +79,7 @@ ultimate source of truth. `Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in - JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset. + JavaScript; only their `next()` results are awaited. Confined sync and async generators are iterable here. ## Functions and callbacks @@ -104,7 +108,27 @@ ultimate source of truth. - [ ] User-defined constructor calls. - [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions. - [ ] Classes and private fields. -- [ ] Generator functions and `yield`. +- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies, + `next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering, + `try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync + iterator but preserves values supplied by a manually implemented async iterator. Generator values are opaque + runtime references. +- [x] Synchronous generators and custom synchronous iterators are consumed stepwise by array/argument spread, array + destructuring, `Array.from`, Map/Set/URLSearchParams construction, `Object.fromEntries`, Object/Map `groupBy`, + Promise combinators, `AggregateError`, and `Math.sumPrecise`. Mapper/grouping callbacks interleave with iterator + steps; synchronous consumers preserve yielded promise objects rather than awaiting them. Async generators are + rejected by every synchronous consumer. +- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and + intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error + wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`. +- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield, + and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected + yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results; + and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite + deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions, + proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests + asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These + are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged. ## Expressions and operators @@ -130,8 +154,8 @@ ultimate source of truth. - [x] Tool calls start eagerly and return supervised, run-once CodeMode promises. - [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. - [x] `Promise.resolve` and `Promise.reject`. -- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing - promises and plain values. +- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous + iterators, and synchronous generators containing promises and plain values. - [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings. - [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records. - [x] `Promise.race` settles from the first result without cancelling losers at settlement time. @@ -175,14 +199,16 @@ ultimate source of truth. - [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged; primitive wrapper objects (`Object(1)`) are rejected explicitly. - [x] Computed property names and object spread. -- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. +- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with + synchronous iterator support for `fromEntries`. - [x] `Object.keys` over arrays and tool references. - [x] Object identity is preserved by in-CodeMode Object helpers. - [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked. - [ ] 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. -- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results. +- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion + and null-prototype results. ## Arrays @@ -190,7 +216,7 @@ ultimate source of truth. array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like JavaScript, and host results normalize holes to `null`. - [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with - `(value, index)` arguments. + `(value, index)` arguments and stepwise synchronous iterator consumption. - [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. - [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and `lastIndexOf`. @@ -245,7 +271,8 @@ ultimate source of truth. for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw, and unknown `Promise` statics keep their descriptive error. -- [x] `Math.sumPrecise` over supported collection iterables, rejecting non-number elements without coercion. +- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number + elements without coercion. - [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`. ## JSON and console @@ -297,10 +324,10 @@ ultimate source of truth. ## Map and Set -- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity. -- [x] `new Map()` from entry arrays or another Map. +- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity. +- [x] `new Map()` from synchronous iterables of entries. - [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`. -- [x] `new Set()` from arrays, strings, or another Set. +- [x] `new Set()` from synchronous iterables. - [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`. - [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set. - [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration. @@ -316,7 +343,7 @@ ultimate source of truth. - [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash`. - [x] Writable URL fields except `origin`. -- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams. +- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams. - [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`, `entries`, `toString`, and `size`. - [x] URL values serialize to their href; URLSearchParams serialize to `{}`. @@ -326,7 +353,7 @@ ultimate source of truth. - [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with or without `new`. - [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by - an all-rejected `Promise.any`. + an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators. - [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. - [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index ed621dc7f7a..14962ce3848 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -1,9 +1,10 @@ +import { Effect } from "effect" import type { Diagnostic } from "../codemode.js" import { ToolError } from "../tool-error.js" import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js" import { containsRuntimeReference } from "./references.js" -import { spreadItems } from "../stdlib/collections.js" +import { type SyncIteratorRunner } from "./iterator.js" import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js" export const normalizeError = (error: unknown): Diagnostic => { @@ -79,15 +80,27 @@ export const caughtErrorValue = (thrown: unknown): unknown => { return createErrorValue(name, normalizeError(thrown).message) } -export const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { - if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) - const errors = spreadItems(args[0]) - if (errors === undefined) { - throw new InterpreterRuntimeError( - "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", - node, - ).as("TypeError") - } - // Error values must not alias caller-owned arrays. - return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) -} +export const constructErrorValue = (name: string, args: Array): SafeObject => + createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) + +export const constructAggregateErrorValue = ( + runner: SyncIteratorRunner, + args: Array, + node: AstNode, +): Effect.Effect => + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(args[0], node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node).as( + "TypeError", + ) + } + const errors: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) { + return createAggregateErrorValue(errors, args[1] === undefined ? "" : coerceToString(args[1])) + } + errors.push(step.value) + } + }) diff --git a/packages/codemode/src/interpreter/iterator.ts b/packages/codemode/src/interpreter/iterator.ts new file mode 100644 index 00000000000..f352ed159de --- /dev/null +++ b/packages/codemode/src/interpreter/iterator.ts @@ -0,0 +1,21 @@ +import { Effect, Exit } from "effect" +import type { AstNode } from "./model.js" + +export type IteratorCursor = { + readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R> + readonly close: Effect.Effect +} + +export type SyncIteratorRunner = { + readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect | undefined, unknown, R> +} + +export const preserveConsumerError = ( + cursor: IteratorCursor, + effect: Effect.Effect, +): Effect.Effect => + Effect.flatMap(Effect.exit(effect), (exit) => + Exit.isSuccess(exit) + ? Effect.succeed(exit.value) + : Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)), + ) diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 79a014ba4fe..cae4951d9a8 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { type AstNode, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ErrorConstructorReference, GlobalMethodReference, @@ -33,6 +34,7 @@ import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } fro 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 = { readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect @@ -360,19 +362,12 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u return Array.isArray(args[0]) case "of": return [...args] - case "from": - return arrayFromItems(args[0], node) default: throw new InterpreterRuntimeError(`Array.${name} is not available.`, node) } } -const arrayFromItems = (source: unknown, node: AstNode): Array => { - if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item]) - if (source instanceof CodeModeSet) return Array.from(source.set.values()) - if (source instanceof CodeModeURLSearchParams) { - return Array.from(source.params.entries(), ([key, value]) => [key, value]) - } +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.", @@ -380,15 +375,16 @@ const arrayFromItems = (source: unknown, node: AstNode): Array => { "InvalidDataValue", ) } - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] if ( source !== null && typeof source === "object" && (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && typeof (source as { length?: unknown }).length === "number" ) { - return Array.from(source as ArrayLike) + 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.", @@ -398,24 +394,42 @@ const arrayFromItems = (source: unknown, node: AstNode): Array => { } export const invokeArrayFrom = ( - runner: CallbackRunner, + runner: CallbackRunner & SyncIteratorRunner, args: Array, node: AstNode, ): Effect.Effect => { - const items = arrayFromItems(args[0], node) - if (args.length < 2 || args[1] === undefined) return Effect.succeed(items) - const apply = applyCollectionCallback(runner, args[1], "Array.from", node) + 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 values: Array = [] - for (let index = 0; index < items.length; index += 1) { - values.push(yield* apply([items[index], index])) + 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 = [] + 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 = [] + 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 } - return values }) } export const invokeGroupBy = ( - runner: CallbackRunner, + runner: CallbackRunner & SyncIteratorRunner, namespace: "Map" | "Object", args: Array, node: AstNode, @@ -425,47 +439,50 @@ export const invokeGroupBy = ( throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") } const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node) - const items = supportedIterableItems(source) - if (items === undefined) { - throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError") - } 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 - for (const item of items) { - const key = yield* apply([item, index]) + 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).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) + 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)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) + 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).push(item) index += 1 } - return result }) } -const supportedIterableItems = (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, diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index ef66d8876cb..b3a82e68867 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -1,3 +1,4 @@ +import type { Effect } from "effect" import type { SafeObject } from "../tool-runtime.js" import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js" @@ -45,6 +46,27 @@ export class CodeModeFunction { readonly body: AstNode, readonly capturedScopes: ReadonlyArray>, readonly async: boolean, + readonly generator: boolean, + ) {} +} + +export type GeneratorRequestKind = "next" | "return" | "throw" + +export class CodeModeGenerator { + constructor( + readonly asynchronous: boolean, + readonly request: ( + kind: GeneratorRequestKind, + value: unknown, + node: AstNode, + ) => Effect.Effect, + ) {} +} + +export class GeneratorMethodReference { + constructor( + readonly generator: CodeModeGenerator, + readonly kind: GeneratorRequestKind | "iterator", ) {} } @@ -128,6 +150,10 @@ export class ProgramThrow { constructor(readonly value: unknown) {} } +export class GeneratorReturn { + constructor(readonly value: unknown) {} +} + export class ErrorConstructorReference { constructor(readonly name: string) {} } diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 6f7c07c5b8b..8f5fe2bdd42 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -13,9 +13,9 @@ import { import { caughtErrorValue, normalizeError } from "./errors.js" import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js" import { typeofValue } from "./references.js" -import { spreadItems } from "../stdlib/collections.js" import { createAggregateErrorValue } from "../stdlib/value.js" import { CodeModePromise } from "../values.js" +import type { SyncIteratorRunner } from "./iterator.js" // Observation only controls rejection reporting; program completion interrupts all promise work. export class PromiseRuntime { @@ -64,6 +64,10 @@ export class PromiseRuntime { return Fiber.await(promise.fiber) } + fork(effect: Effect.Effect): Effect.Effect { + return Effect.asVoid(Effect.forkIn(effect, this.scope, { startImmediately: true })) + } + diagnostics(): Array { return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) } @@ -84,7 +88,7 @@ export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") export const invokePromiseMethod = ( - runner: CallbackRunner, + runner: CallbackRunner & SyncIteratorRunner, promises: PromiseRuntime, ref: PromiseMethodReference, args: Array, @@ -98,79 +102,69 @@ export const invokePromiseMethod = ( return promises.create(Effect.fail(new ProgramThrow(args[0]))) } - const spread = spreadItems(args[0]) - if (spread === undefined) { - return promises.create( - Effect.fail( - new InterpreterRuntimeError( - `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + return promises.create( + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(args[0], node) + if (cursor === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array or other synchronous iterable.`, node, - ).as("TypeError"), - ), - ) - } - const items = Array.from(spread) + ).as("TypeError") + } + const items: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) break + items.push(step.value) + if (step.value instanceof CodeModePromise) promises.markObserved(step.value) + } - for (const item of items) { - if (item instanceof CodeModePromise) promises.markObserved(item) - } - - switch (ref.name) { - case "all": { - const observations = items.map((item) => - item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), - ) - return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) - } - case "allSettled": { - const observations = items.map((item) => - item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - return promises.create( - settleAfterTurn( - Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), - ) - continue - } - if (Cause.hasInterruptsOnly(exit.cause)) { - // Teardown interruption is not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) - } - return outcomes - }), - ), - ) - } - case "race": { - if (items.length === 0) { - return promises.create( - Effect.fail( - new InterpreterRuntimeError( - "Promise.race([]) would never settle; provide at least one promise or value.", - node, + if (ref.name === "all") { + return yield* settleAfterTurn( + Effect.all( + items.map((item) => + item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), + ), + { concurrency: "unbounded" }, + ), + ) + } + if (ref.name === "allSettled") { + const outcomes: Array = [] + for (const item of items) { + const exit = item instanceof CodeModePromise ? yield* promises.await(item) : Exit.succeed(item) + if (Exit.isSuccess(exit)) { + outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) + continue + } + if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(Cause.squash(exit.cause)), + }), + ) + } + yield* Effect.yieldNow + return outcomes + } + if (ref.name === "race") { + if (items.length === 0) { + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) + } + return yield* settleAfterTurn( + Effect.flatten( + Effect.raceAll( + items.map((item) => + item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), + ), ), ), ) } - const observations = items.map((item) => - item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), - ) - return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) - } - case "any": { const flipped = items.map((item) => item instanceof CodeModePromise ? Effect.flatMap(promises.await(item), (exit) => { @@ -180,17 +174,18 @@ export const invokePromiseMethod = ( }) : Effect.fail(new PromiseAnyFulfilled(item)), ) - const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( - Effect.flatMap((reasons) => - Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), - ), - Effect.catch((error) => - error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + return yield* settleAfterTurn( + Effect.all(flipped, { concurrency: "unbounded" }).pipe( + Effect.flatMap((reasons) => + Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), + ), + Effect.catch((error) => + error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + ), ), ) - return promises.create(settleAfterTurn(body)) - } - } + }), + ) } export const invokePromiseInstanceMethod = ( diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index ae3a9afa46e..bfc7663a7e3 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -1,10 +1,12 @@ import { type AstNode, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, + GeneratorMethodReference, InterpreterRuntimeError, IntrinsicReference, JsonMethodReference, @@ -21,6 +23,8 @@ import { isCodeModeValue, CodeModePromise } from "../values.js" export const isRuntimeReference = (value: unknown): boolean => value instanceof CodeModeFunction || + value instanceof CodeModeGenerator || + value instanceof GeneratorMethodReference || value instanceof ToolReference || value instanceof IntrinsicReference || value instanceof GlobalNamespace || @@ -107,6 +111,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label export const typeofValue = (value: unknown): string => { if ( value instanceof CodeModeFunction || + value instanceof GeneratorMethodReference || value instanceof CoercionFunction || value instanceof IntrinsicReference || value instanceof GlobalMethodReference || diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 765b97ee5ba..1d4e0306651 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit } from "effect" +import { Cause, Deferred, Effect, Exit } from "effect" import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, @@ -6,11 +6,15 @@ import { asNode, type Binding, CodeModeFunction, + CodeModeGenerator, CoercionFunction, ComputedValue, ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, + GeneratorMethodReference, + GeneratorReturn, + type GeneratorRequestKind, type GlobalNamespaceName, getArray, getBoolean, @@ -35,11 +39,10 @@ import { SearchFunction, SymbolNamespace, type StatementResult, - supportedSyntaxMessage, unsupportedSyntax, UriFunction, } from "./model.js" -import { caughtErrorValue, constructErrorValue } from "./errors.js" +import { caughtErrorValue, constructAggregateErrorValue, constructErrorValue } from "./errors.js" import { arrayStatics, type CallbackRunner, @@ -48,6 +51,7 @@ import { invokeGroupBy, invokeIntrinsic, } from "./methods.js" +import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js" import { constructPromise, invokePromiseInstanceMethod, @@ -57,13 +61,13 @@ import { } from "./promises.js" import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" import { ScopeStack } from "./scope.js" -import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js" +import { arrayMethods, mapMethods, mapStatics, setMethods } from "../stdlib/collections.js" import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" import { dateMethods, dateStatics } from "../stdlib/date.js" import { invokeJsonMethod, jsonStatics, type JsonMethodName } from "../stdlib/json.js" -import { mathConstants, mathMethods } from "../stdlib/math.js" +import { invokeMathSumPrecise, mathConstants, mathMethods } from "../stdlib/math.js" import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js" -import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js" +import { invokeObjectFromEntries, objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js" import { promiseStatics } from "../stdlib/promise.js" import { escapeRegexHint, @@ -216,11 +220,52 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => { } type CustomIterator = { - iterator: SafeObject + iterator: SafeObject | CodeModeGenerator next: unknown asynchronous: boolean } +type OpaqueMemberReference = + | ToolReference + | PromiseMethodReference + | PromiseInstanceMethodReference + | IntrinsicReference + | GlobalMethodReference + | JsonMethodReference + | GeneratorMethodReference + +const isOpaqueMemberReference = (value: unknown): value is OpaqueMemberReference => + value instanceof ToolReference || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof JsonMethodReference || + value instanceof GeneratorMethodReference + +const copyIteratorSymbols = (source: object, target: object, consumed?: ReadonlySet): void => { + for (const symbol of IteratorSymbols) { + if (!consumed?.has(symbol) && Object.hasOwn(source, symbol)) + Reflect.set(target, symbol, Reflect.get(source, symbol)) + } +} + +type GeneratorRequest = { + kind: GeneratorRequestKind + value: unknown + response: Deferred.Deferred +} + +type GeneratorState = { + started: boolean + completed: boolean + draining: boolean + active?: GeneratorRequest + pending: Array + pendingIndex: number + available?: Deferred.Deferred +} + export class Interpreter { private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -228,10 +273,13 @@ export class Interpreter { private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array private readonly promises: PromiseRuntime - private readonly runner: CallbackRunner = { + private generatorState?: GeneratorState + private generatorAsync = false + private readonly runner: CallbackRunner & SyncIteratorRunner = { invokeFunction: (fn, args) => this.invokeFunction(fn, args), invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node), settlePromise: (promise) => this.settlePromise(promise), + syncIterator: (value, node) => this.syncIterator(value, node), } constructor( @@ -403,16 +451,12 @@ export class Interpreter { } private createFunction(node: AstNode): CodeModeFunction { - if (node.generator === true) { - throw new InterpreterRuntimeError("Generator functions are not supported.", node, "UnsupportedSyntax", [ - supportedSyntaxMessage, - ]) - } return new CodeModeFunction( getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), this.scopes.capture(), node.async === true, + node.generator === true, ) } @@ -646,14 +690,20 @@ export class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - const iterable = spreadItems(right) - const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined - if (iterable === undefined && iterator === undefined) { + const iterator = yield* self.customIterator(right, node, awaiting) + const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined + if (iterator === undefined && cursor === undefined) { throw new InterpreterRuntimeError( - `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`, + `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`, node, - ) + ).as("TypeError") } + const close = () => + iterator + ? self.closeIterator(iterator, node, awaiting) + : awaiting + ? Effect.andThen(cursor?.close ?? Effect.void, Effect.yieldNow) + : (cursor?.close ?? Effect.void) let assignment: AstNode | undefined @@ -687,45 +737,35 @@ export class Interpreter { ), ) - if (iterable !== undefined) { - for (const value of iterable) { - const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value) - - if (result.kind === "return") return result - if (result.kind === "break") { - if (result.label !== undefined && !labels?.has(result.label)) return result - return { kind: "none" } satisfies StatementResult - } - if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result - } - return { kind: "none" } satisfies StatementResult - } - if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node) - while (true) { - const step = yield* self.nextIteratorResult(iterator, node) + const current = iterator + ? yield* self.nextIteratorResult(iterator, node, awaiting) + : yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node)) + const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current if (step.done) return { kind: "none" } satisfies StatementResult const bodyExit = yield* Effect.exit(evaluateBody(step.value)) if (!Exit.isSuccess(bodyExit)) { // Process interruption must remain prompt; user cleanup cannot extend a timeout. - if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node)) + if (!Cause.hasInterruptsOnly(bodyExit.cause)) { + yield* Effect.exit(close()) + } return yield* Effect.failCause(bodyExit.cause) } const result = bodyExit.value if (result.kind === "return") { - yield* self.closeIterator(iterator, node) + yield* close() return result } if (result.kind === "break") { - yield* self.closeIterator(iterator, node) + yield* close() if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) { - yield* self.closeIterator(iterator, node) + yield* close() return result } } @@ -742,26 +782,86 @@ export class Interpreter { return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value) } - private customIterator(value: unknown, node: AstNode) { + private awaitAsyncFromSyncValue( + iterator: CustomIterator, + value: unknown, + node: AstNode, + closeOnRejection: boolean, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const settled = yield* Effect.exit(self.awaitValue(value)) + if (Exit.isSuccess(settled)) return settled.value + if (closeOnRejection && !Cause.hasInterruptsOnly(settled.cause)) { + yield* Effect.exit(self.closeIterator(iterator, node, false)) + } + return yield* Effect.failCause(settled.cause) + }) + } + + private syncIterator(value: unknown, node: AstNode) { + const iterator = Array.isArray(value) + ? value[Symbol.iterator]() + : typeof value === "string" + ? value[Symbol.iterator]() + : value instanceof CodeModeMap + ? value.map.entries() + : value instanceof CodeModeSet + ? value.set.values() + : value instanceof CodeModeURLSearchParams + ? value.params.entries() + : undefined + if (iterator !== undefined) { + return Effect.succeed({ + next: Effect.sync(() => { + const step = iterator.next() + return { done: Boolean(step.done), value: step.value } + }), + close: Effect.void, + }) + } + const self = this + return Effect.map(this.customIterator(value, node, false), (iterator) => + iterator === undefined + ? undefined + : { + next: self.nextIteratorResult(iterator, node, false), + close: Effect.suspend(() => self.closeIterator(iterator, node, false)), + }, + ) + } + + private customIterator(value: unknown, node: AstNode, allowAsync = true) { + if (value instanceof CodeModeGenerator) { + if (value.asynchronous && !allowAsync) return Effect.succeed(undefined) + return Effect.succeed({ + iterator: value, + next: new GeneratorMethodReference(value, "next"), + asynchronous: value.asynchronous, + }) + } if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined) - const asyncMethod = Reflect.get(value, AsyncIteratorSymbol) + const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined const method = asyncMethod ?? Reflect.get(value, IteratorSymbol) if (method === undefined || method === null) return Effect.succeed(undefined) const self = this return Effect.map( this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node), (iterator) => { - const object = self.requireIteratorObject(iterator, "Iterator method result", node) + const object = self.requireIterator(iterator, node) return { iterator: object, - next: self.requireIteratorMethod(object.next, "Iterator next", node), + next: + object instanceof CodeModeGenerator + ? new GeneratorMethodReference(object, "next") + : self.requireIteratorMethod(object.next, "Iterator next", node), asynchronous: asyncMethod !== undefined && asyncMethod !== null, } }, ) } - private nextIteratorResult(iterator: CustomIterator, node: AstNode) { + private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) { const self = this return Effect.gen(function* () { if (iterator.asynchronous) { @@ -775,7 +875,7 @@ export class Interpreter { const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node)) if (!Exit.isSuccess(called)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(called.cause) } const captured = yield* Effect.exit( @@ -785,16 +885,24 @@ export class Interpreter { }), ) if (!Exit.isSuccess(captured)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(captured.cause) } - return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) } + return { + done: captured.value.done, + value: awaiting + ? yield* self.awaitAsyncFromSyncValue(iterator, captured.value.value, node, !captured.value.done) + : captured.value.value, + } }) } - private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect { - const close = iterator.iterator.return - if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow + private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect { + const close = + iterator.iterator instanceof CodeModeGenerator + ? new GeneratorMethodReference(iterator.iterator, "return") + : iterator.iterator.return + if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow const self = this return Effect.gen(function* () { const method = self.requireIteratorMethod(close, "Iterator return", node) @@ -809,17 +917,17 @@ export class Interpreter { const called = yield* Effect.exit(self.invokeCallable(method, [], node)) if (!Exit.isSuccess(called)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(called.cause) } const captured = yield* Effect.exit( Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value), ) if (!Exit.isSuccess(captured)) { - yield* Effect.yieldNow + if (awaiting) yield* Effect.yieldNow return yield* Effect.failCause(captured.cause) } - yield* self.awaitValue(captured.value) + if (awaiting) yield* self.awaitValue(captured.value) }) } @@ -828,6 +936,12 @@ export class Interpreter { throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError") } + private requireIterator(value: unknown, node: AstNode): SafeObject | CodeModeGenerator { + return value instanceof CodeModeGenerator + ? value + : this.requireIteratorObject(value, "Iterator method result", node) + } + private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown { if (typeofValue(value) === "function") return value throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError") @@ -966,7 +1080,7 @@ export class Interpreter { const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { onFailure: (cause) => { - if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + if (cause.reasons.some(Cause.isInterruptReason) || Cause.squash(cause) instanceof GeneratorReturn || !handler) { return Effect.failCause(cause) } @@ -1059,10 +1173,7 @@ export class Interpreter { for (const [key, item] of Object.entries(value as SafeObject)) { if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item } - for (const symbol of IteratorSymbols) { - if (!consumed.has(symbol) && Object.hasOwn(value, symbol)) - Reflect.set(rest, symbol, Reflect.get(value, symbol)) - } + copyIteratorSymbols(value, rest, consumed) yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize) continue } @@ -1084,21 +1195,9 @@ export class Interpreter { } if (pattern.type === "ArrayPattern") { - const items = spreadItems(value) - if (items === undefined) { - throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) - } - - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - if (element.type === "RestElement") { - yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize) - break - } - yield* self.declarePattern(element, items[index], mutable, pattern, initialize) - } - return + return yield* self.destructureArrayPattern(pattern, value, (target, item, context) => + self.declarePattern(target, item, mutable, context, initialize), + ) } throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) @@ -1142,10 +1241,7 @@ export class Interpreter { for (const [key, item] of Object.entries(source)) { if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item } - for (const symbol of IteratorSymbols) { - if (!consumed.has(symbol) && Object.hasOwn(source, symbol)) - Reflect.set(rest, symbol, Reflect.get(source, symbol)) - } + copyIteratorSymbols(source, rest, consumed) yield* self.assignPattern(getNode(property, "argument"), rest, property) continue } @@ -1160,26 +1256,63 @@ export class Interpreter { } if (pattern.type === "ArrayPattern") { - const items = spreadItems(value) - if (items === undefined) { - throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) - } - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - if (element.type === "RestElement") { - yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element) - break - } - yield* self.assignPattern(element, items[index], pattern) - } - return + return yield* self.destructureArrayPattern(pattern, value, (target, item, context) => + self.assignPattern(target, item, context), + ) } throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node) }) } + private destructureArrayPattern( + pattern: AstNode, + value: unknown, + consume: (target: AstNode, value: unknown, context: AstNode) => Effect.Effect, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(value, pattern) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern).as( + "TypeError", + ) + } + let done = false + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (done) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + yield* consume( + element.type === "RestElement" ? getNode(element, "argument") : element, + element.type === "RestElement" ? [] : undefined, + element, + ) + if (element.type === "RestElement") return + continue + } + const step = yield* cursor.next + done = step.done + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + if (element.type === "RestElement") { + const rest: Array = [] + if (!step.done) rest.push(step.value) + while (!done) { + const next = yield* cursor.next + done = next.done + if (!done) rest.push(next.value) + } + yield* consume(getNode(element, "argument"), rest, element) + return + } + const consumed = consume(element, step.done ? undefined : step.value, pattern) + yield* step.done ? consumed : preserveConsumerError(cursor, consumed) + } + if (!done) yield* cursor.close + }) + } + private destructuringPropertyKey(property: AstNode): Effect.Effect { if (property.type !== "Property" || getString(property, "kind") !== "init") { throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) @@ -1259,6 +1392,8 @@ export class Interpreter { value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), ) } + case "YieldExpression": + return this.evaluateYieldExpression(node) case "NewExpression": return this.evaluateNewExpression(node) default: @@ -1280,7 +1415,11 @@ export class Interpreter { ) } if (errorConstructors.has(name)) { - return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) + return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => + name === "AggregateError" + ? constructAggregateErrorValue(self.runner, args, node) + : Effect.succeed(constructErrorValue(name, args)), + ) } // Array and Object construct identically with or without new, like JS. if (name === "Array") { @@ -1298,13 +1437,13 @@ export class Interpreter { case "RegExp": return self.constructRegExp(args, node) case "Map": - return self.constructMap(args[0], node) + return yield* self.constructMap(args[0], node) case "Set": - return self.constructSet(args[0], node) + return yield* self.constructSet(args[0], node) case "URL": return self.constructURL(args, node) default: - return self.constructURLSearchParams(args[0], node) + return yield* self.constructURLSearchParams(args[0], node) } }) } @@ -1390,44 +1529,53 @@ export class Interpreter { } } - private constructMap(init: unknown, node: AstNode): CodeModeMap { + private constructMap(init: unknown, node: AstNode): Effect.Effect { const target = new CodeModeMap() - if (init === undefined || init === null) return target - const entries = Array.isArray(init) - ? init - : init instanceof CodeModeMap - ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) - : undefined - if (entries === undefined) { - throw new InterpreterRuntimeError( - "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", - node, - ) - } - for (const pair of entries) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + if (init === undefined || init === null) return Effect.succeed(target) + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError( + "new Map(...) expects an iterable of [key, value] pairs or no argument.", + node, + ).as("TypeError") } - target.map.set(pair[0], pair[1]) - } - return target + while (true) { + const step = yield* cursor.next + if (step.done) return target + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if (!isRecord(step.value) || isRuntimeReference(step.value)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node).as( + "TypeError", + ) + } + target.map.set(step.value[0], step.value[1]) + }), + ) + } + }) } - private constructSet(init: unknown, node: AstNode): CodeModeSet { + private constructSet(init: unknown, node: AstNode): Effect.Effect { const target = new CodeModeSet() - if (init === undefined || init === null) return target - const items = Array.isArray(init) - ? init - : init instanceof CodeModeSet - ? Array.from(init.set.values()) - : typeof init === "string" - ? Array.from(init) - : undefined - if (items === undefined) { - throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) - } - for (const item of items) target.set.add(item) - return target + if (init === undefined || init === null) return Effect.succeed(target) + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node).as( + "TypeError", + ) + } + while (true) { + const step = yield* cursor.next + if (step.done) return target + target.set.add(step.value) + } + }) } private constructURL(args: Array, node: AstNode): CodeModeURL { @@ -1448,47 +1596,79 @@ export class Interpreter { } } - private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams { - if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams()) + private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect { + if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams())) if (init instanceof CodeModeURLSearchParams) { - return new CodeModeURLSearchParams(new URLSearchParams(init.params)) + return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params))) } - if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init)) + if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init))) if (init === null || typeof init === "number" || typeof init === "boolean") { - return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))) + return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))) } - if (init instanceof CodeModeMap) { - return this.constructURLSearchParams( - Array.from(init.map.entries(), ([key, value]) => [key, value]), - node, - ) - } - if (Array.isArray(init)) { - const entries = init.map((pair) => { - if (!Array.isArray(pair) || pair.length !== 2) { - throw new InterpreterRuntimeError( - "new URLSearchParams(...) expects an array of [name, value] pairs.", - node, - ).as("TypeError") + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(init, node) + if (cursor !== undefined) { + const entries: Array> = [] + while (true) { + const step = yield* cursor.next + if (step.done) { + if (entries.some((entry) => entry.length !== 2)) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects iterable [name, value] pairs.", + node, + ).as("TypeError") + } + return new CodeModeURLSearchParams( + new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])), + ) + } + entries.push(yield* preserveConsumerError(cursor, self.readURLSearchParamsPair(step.value, node))) } - return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [ - string, - string, - ] - }) - return new CodeModeURLSearchParams(new URLSearchParams(entries)) - } - if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams()) - const data = boundedData(init, "new URLSearchParams input") - if (data === null || typeof data !== "object") { - throw new InterpreterRuntimeError( - "new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.", - node, - ).as("TypeError") - } - return new CodeModeURLSearchParams( - new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), - ) + } + if (isRuntimeReference(init)) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.", + node, + ).as("TypeError") + } + if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams()) + const data = boundedData(init, "new URLSearchParams input") + if (data === null || typeof data !== "object") { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.", + node, + ).as("TypeError") + } + return new CodeModeURLSearchParams( + new URLSearchParams( + Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])), + ), + ) + }) + } + + private readURLSearchParamsPair(value: unknown, node: AstNode): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function* () { + const cursor = yield* self.syncIterator(value, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node).as( + "TypeError", + ) + } + const items: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) return items + items.push( + yield* preserveConsumerError( + cursor, + Effect.sync(() => uriArgument(step.value, "URLSearchParams pair value")), + ), + ) + } + }) } private evaluateBinaryExpression(node: AstNode): Effect.Effect { @@ -1503,6 +1683,8 @@ export class Interpreter { } private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + if (operator === "===") return lhs === rhs + if (operator === "!==") return lhs !== rhs if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue") } @@ -1532,12 +1714,8 @@ export class Interpreter { return (l as number) ** (r as number) case "==": return bothObjects ? lhs === rhs : l == r - case "===": - return lhs === rhs case "!=": return bothObjects ? lhs !== rhs : l != r - case "!==": - return lhs !== rhs case "<": return (l as string) < (r as string) case "<=": @@ -1773,6 +1951,11 @@ export class Interpreter { if (callable instanceof CodeModeFunction) { return yield* self.invokeFunction(callable, args) } + if (callable instanceof GeneratorMethodReference) { + if (callable.kind === "iterator") return callable.generator + const requested = callable.generator.request(callable.kind, args[0], node) as Effect.Effect + return callable.generator.asynchronous ? yield* self.createPromise(requested) : yield* requested + } if (callable instanceof IntrinsicReference) { return yield* invokeIntrinsic(self.runner, callable, args, node) } @@ -1782,6 +1965,7 @@ export class Interpreter { return self.invokeObjectMethodOnTools(callable.name, args[0], node) } if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { + if (callable.name === "fromEntries") return yield* invokeObjectFromEntries(self.runner, args[0], node) return invokeGlobalMethod(callable, args, node) } if (callable.namespace === "Array" && callable.name === "from") { @@ -1790,6 +1974,9 @@ export class Interpreter { if ((callable.namespace === "Object" || callable.namespace === "Map") && callable.name === "groupBy") { return yield* invokeGroupBy(self.runner, callable.namespace, args, node) } + if (callable.namespace === "Math" && callable.name === "sumPrecise") { + return yield* invokeMathSumPrecise(self.runner, args[0], node) + } if (callable.namespace === "Array" && callable.name === "of") { return invokeGlobalMethod(callable, args, node) } @@ -1808,7 +1995,8 @@ export class Interpreter { return yield* self.invokeSearch(args) } if (callable instanceof ErrorConstructorReference) { - return constructErrorValue(callable.name, args, node) + if (callable.name === "AggregateError") return yield* constructAggregateErrorValue(self.runner, args, node) + return constructErrorValue(callable.name, args) } if (callable instanceof GlobalNamespace) { // Real JS permits calling Array, Object, Date, and RegExp without new. @@ -1868,10 +2056,16 @@ export class Interpreter { const argNode = asNode(arg, `arguments[${index}]`) if (argNode.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set.", argNode) - args.push(...items) + const cursor = yield* self.syncIterator(spread, argNode) + if (cursor === undefined) + throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode).as( + "TypeError", + ) + while (true) { + const step = yield* cursor.next + if (step.done) break + args.push(step.value) + } } else { args.push(yield* self.evaluateExpression(argNode)) } @@ -1906,6 +2100,7 @@ export class Interpreter { return yield* invocation.evaluateExpression(fn.body) }) + if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async)) if (!fn.async) return run // The initial yield assigns `box.own` before the body can self-resolve. const box: { own?: CodeModePromise } = {} @@ -1924,6 +2119,250 @@ export class Interpreter { ) } + private createGenerator( + invocation: Interpreter, + run: Effect.Effect, + asynchronous: boolean, + ): CodeModeGenerator { + const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 } + invocation.generatorState = state + invocation.generatorAsync = asynchronous + const generator = new CodeModeGenerator(asynchronous, (kind, value, node) => { + const request = { kind, value, response: Deferred.makeUnsafe() } + if (!asynchronous && state.active) { + return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node).as("TypeError")) + } + if (asynchronous && (state.completed || (!state.started && kind !== "next"))) { + state.started = true + state.completed = true + state.pending.push(request) + if (state.draining) return Deferred.await(request.response) + state.draining = true + return Effect.andThen( + this.promises.fork( + invocation + .completeGeneratorRequests(state, true) + .pipe(Effect.ensuring(Effect.sync(() => (state.draining = false)))), + ), + Deferred.await(request.response), + ) + } + if (state.completed) { + if (kind === "throw") return Effect.fail(new ProgramThrow(value)) + return Effect.succeed({ value: kind === "return" ? value : undefined, done: true }) + } + if (!state.started && kind !== "next") { + state.completed = true + if (kind === "throw") return Effect.fail(new ProgramThrow(value)) + return Effect.succeed({ value, done: true }) + } + + state.pending.push(request) + if (state.available) { + const available = state.available + state.available = undefined + Deferred.doneUnsafe(available, Exit.succeed(undefined)) + } + if (!state.started) { + state.started = true + const body = Effect.gen(function* () { + state.active = yield* invocation.takeGeneratorRequest(state) + const exit = yield* Effect.exit( + run.pipe( + Effect.flatMap((result) => (asynchronous ? invocation.awaitValue(result) : Effect.succeed(result))), + Effect.catch((error) => + error instanceof GeneratorReturn + ? asynchronous + ? invocation.awaitValue(error.value) + : Effect.succeed(error.value) + : Effect.fail(error), + ), + ), + ) + const active = state.active + state.active = undefined + if (active) { + Deferred.doneUnsafe( + active.response, + Exit.isSuccess(exit) ? Exit.succeed({ value: exit.value, done: true }) : exit, + ) + } + yield* invocation.completeGeneratorRequests(state, asynchronous) + state.completed = true + }) + return Effect.andThen(this.promises.fork(body), Deferred.await(request.response)) + } + return Deferred.await(request.response) + }) + return generator + } + + private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect { + const self = this + return Effect.gen(function* () { + while (true) { + const pending = self.dequeueGeneratorRequest(state) + if (!pending) return + if (pending.kind === "throw") { + Deferred.doneUnsafe(pending.response, Exit.fail(new ProgramThrow(pending.value))) + continue + } + if (asynchronous && pending.kind === "return") { + const resolved = yield* Effect.exit(self.awaitValue(pending.value)) + Deferred.doneUnsafe( + pending.response, + Exit.isSuccess(resolved) ? Exit.succeed({ value: resolved.value, done: true }) : resolved, + ) + continue + } + Deferred.doneUnsafe( + pending.response, + Exit.succeed({ value: pending.kind === "return" ? pending.value : undefined, done: true }), + ) + } + }) + } + + private takeGeneratorRequest(state: GeneratorState): Effect.Effect { + const next = this.dequeueGeneratorRequest(state) + if (next) return Effect.succeed(next) + state.available = Deferred.makeUnsafe() + return Effect.andThen( + Deferred.await(state.available), + Effect.sync(() => this.dequeueGeneratorRequest(state)!), + ) + } + + private dequeueGeneratorRequest(state: GeneratorState): GeneratorRequest | undefined { + const request = state.pending[state.pendingIndex] + if (!request) return undefined + state.pendingIndex += 1 + if (state.pendingIndex === state.pending.length) { + state.pending = [] + state.pendingIndex = 0 + } + return request + } + + private evaluateYieldExpression(node: AstNode): Effect.Effect { + const argument = getOptionalNode(node, "argument") + const self = this + return Effect.gen(function* () { + if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node) + if (node.delegate === true) { + const value = argument ? yield* self.evaluateExpression(argument) : undefined + return yield* self.delegateYield(value, node) + } + const value = argument ? yield* self.evaluateExpression(argument) : undefined + const yielded = self.generatorAsync ? yield* self.awaitValue(value) : value + return yield* self.suspendGenerator(yielded, node) + }) + } + + private suspendGenerator(value: unknown, node: AstNode): Effect.Effect { + const state = this.generatorState + if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node) + Deferred.doneUnsafe(state.active.response, Exit.succeed({ value, done: false })) + state.active = undefined + return Effect.flatMap(this.takeGeneratorRequest(state), (request) => { + state.active = request + if (request.kind === "next") return Effect.succeed(request.value) + if (request.kind === "throw") return Effect.fail(new ProgramThrow(request.value)) + return this.generatorAsync + ? Effect.flatMap(this.awaitValue(request.value), (value) => Effect.fail(new GeneratorReturn(value))) + : Effect.fail(new GeneratorReturn(request.value)) + }) + } + + private delegateYield(value: unknown, node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + if ( + Array.isArray(value) || + typeof value === "string" || + value instanceof CodeModeMap || + value instanceof CodeModeSet || + value instanceof CodeModeURLSearchParams + ) { + const cursor = yield* self.syncIterator(value, node) + if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node) + while (true) { + const step = yield* cursor.next + if (step.done) return undefined + const resumed = yield* Effect.exit( + self.suspendGenerator(self.generatorAsync ? yield* self.awaitValue(step.value) : step.value, node), + ) + if (Exit.isSuccess(resumed)) continue + const error = Cause.squash(resumed.cause) + if (error instanceof GeneratorReturn) { + yield* cursor.close + return yield* Effect.fail(error) + } + if (error instanceof ProgramThrow) { + yield* cursor.close + throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as( + "TypeError", + ) + } + return yield* Effect.failCause(resumed.cause) + } + } + + const iterator = yield* self.customIterator(value, node, self.generatorAsync) + if (!iterator) + throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node).as("TypeError") + let kind: GeneratorRequestKind = "next" + let input: unknown = undefined + while (true) { + const method = + kind === "next" + ? iterator.next + : iterator.iterator instanceof CodeModeGenerator + ? new GeneratorMethodReference(iterator.iterator, kind) + : iterator.iterator[kind] + if (method === undefined || method === null) { + if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input)) + yield* self.closeIterator(iterator, node, self.generatorAsync) + throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as( + "TypeError", + ) + } + const called = yield* self.invokeCallable( + self.requireIteratorMethod(method, `Iterator ${kind}`, node), + [input], + node, + ) + const result = self.requireIteratorObject( + iterator.asynchronous ? yield* self.awaitValue(called) : called, + `Iterator ${kind}() result`, + node, + ) + const done = Boolean(result.done) + const resultValue: unknown = + self.generatorAsync && !iterator.asynchronous + ? yield* self.awaitAsyncFromSyncValue(iterator, result.value, node, kind !== "return" && !done) + : result.value + if (done) { + if (kind === "return") return yield* Effect.fail(new GeneratorReturn(resultValue)) + return resultValue + } + + const resumed: Exit.Exit = yield* Effect.exit(self.suspendGenerator(resultValue, node)) + if (Exit.isSuccess(resumed)) { + kind = "next" + input = resumed.value + continue + } + const error: unknown = Cause.squash(resumed.cause) + if (!(error instanceof GeneratorReturn) && !(error instanceof ProgramThrow)) { + return yield* Effect.failCause(resumed.cause) + } + kind = error instanceof GeneratorReturn ? "return" : "throw" + input = error.value + } + }) + } + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { const objectValue: Record = Object.create(null) as Record const properties = getArray(node, "properties") @@ -1942,9 +2381,7 @@ export class Interpreter { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property) objectValue[key] = value } - for (const symbol of IteratorSymbols) { - if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol)) - } + copyIteratorSymbols(spread, objectValue) continue } @@ -1997,10 +2434,14 @@ export class Interpreter { const element = asNode(elementValue, "elements") if (element.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(element, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set.", element) - values.push(...items) + const cursor = yield* self.syncIterator(spread, element) + if (cursor === undefined) + throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element).as("TypeError") + while (true) { + const step = yield* cursor.next + if (step.done) break + values.push(step.value) + } } else { values.push(yield* self.evaluateExpression(element)) } @@ -2061,6 +2502,7 @@ export class Interpreter { | IntrinsicReference | GlobalMethodReference | JsonMethodReference + | GeneratorMethodReference | ComputedValue | typeof OptionalShortCircuit | undefined, @@ -2205,6 +2647,19 @@ export class Interpreter { ) } + if (objectValue instanceof CodeModeGenerator) { + if (key === "next" || key === "return" || key === "throw") { + return new GeneratorMethodReference(objectValue, key) + } + if ( + (key === IteratorSymbol && !objectValue.asynchronous) || + (key === AsyncIteratorSymbol && objectValue.asynchronous) + ) { + return new GeneratorMethodReference(objectValue, "iterator") + } + return new ComputedValue(undefined) + } + if (isRuntimeReference(objectValue)) { throw new InterpreterRuntimeError( "Runtime references are opaque and do not expose properties.", @@ -2241,16 +2696,7 @@ export class Interpreter { return Effect.map(this.getMemberReference(node), (reference) => { if (reference === OptionalShortCircuit) return OptionalShortCircuit if (reference instanceof ComputedValue) return reference.value - if ( - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference - ) - return reference + if (reference === undefined || isOpaqueMemberReference(reference)) return reference if (Array.isArray(reference.target)) { if (reference.key === "length") return reference.target.length if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) @@ -2278,12 +2724,7 @@ export class Interpreter { if ( reference instanceof ComputedValue || reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference || + isOpaqueMemberReference(reference) || reference.target instanceof CodeModeURL ) { throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue") @@ -2307,12 +2748,7 @@ export class Interpreter { reference === OptionalShortCircuit || reference instanceof ComputedValue || reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof PromiseInstanceMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof JsonMethodReference + isOpaqueMemberReference(reference) ) { throw new InterpreterRuntimeError("Only data fields may be assigned.", node) } diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index 040153b97ec..211d307cac2 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,5 +1,6 @@ +import { Effect } from "effect" +import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" -import { spreadItems } from "./collections.js" // Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types. declare global { @@ -53,17 +54,6 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node) if (name === "random") return Math.random() - if (name === "sumPrecise") { - const items = spreadItems(args[0]) - if (items === undefined) { - throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable collection.", node).as("TypeError") - } - const numbers = Array.from(items) - if (!numbers.every((item): item is number => typeof item === "number")) { - throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError") - } - return Math.sumPrecise(numbers) - } // Validate only the arguments the method consumes; like JS, extras are ignored // (so built-ins work as callbacks receiving (element, index, array)). const num = (index: number): number => { @@ -153,3 +143,29 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo } throw new InterpreterRuntimeError(`Math.${name} is not available.`, node) } + +export const invokeMathSumPrecise = ( + runner: SyncIteratorRunner, + source: unknown, + node: AstNode, +): Effect.Effect => + Effect.gen(function* () { + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node).as("TypeError") + } + const numbers: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) return Math.sumPrecise(numbers) + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if (typeof step.value !== "number") { + throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError") + } + numbers.push(step.value) + }), + ) + } + }) diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index b4182904d61..583a4b4a42b 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import { type AstNode, AsyncIteratorSymbol, @@ -7,8 +8,9 @@ import { } from "../interpreter/model.js" import { containsOpaqueReference } from "../interpreter/references.js" import { isBlockedMember } from "../tool-runtime.js" -import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js" +import { isCodeModeValue, CodeModePromise } from "../values.js" import { boundedData, coerceToString } from "./value.js" +import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js" export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) @@ -39,11 +41,6 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) out[key] = item } - const addEntry = (out: Record, key: unknown, item: unknown): void => { - boundedData(key, "Object.fromEntries key") - boundedData(item, "Object.fromEntries value") - guardedSet(out, coerceToString(key), item) - } switch (name) { case "keys": return Object.keys(requireObject()) @@ -79,32 +76,47 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast } return out } - case "fromEntries": { - if (args[0] instanceof CodeModeMap) { - const out: Record = Object.create(null) - for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) - return out - } - if (args[0] instanceof CodeModeURLSearchParams) { - const out: Record = Object.create(null) - for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) - return out - } - const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0] - if (!Array.isArray(pairs)) { - boundedData(args[0], "Object.fromEntries input") - throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) - } - const out: Record = Object.create(null) - for (const pair of pairs) { - const validated = boundedData(pair, "Object.fromEntries entry") - if (validated === null || typeof validated !== "object" || isCodeModeValue(validated)) - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node) - const entry = pair as Record - addEntry(out, entry[0], entry[1]) - } - return out - } } throw new InterpreterRuntimeError(`Object.${name} is not available.`, node) } + +export const invokeObjectFromEntries = ( + runner: SyncIteratorRunner, + source: unknown, + node: AstNode, +): Effect.Effect, unknown, R> => { + const out: Record = Object.create(null) + return Effect.gen(function* () { + const cursor = yield* runner.syncIterator(source, node) + if (cursor === undefined) { + throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node).as( + "TypeError", + ) + } + while (true) { + const step = yield* cursor.next + if (step.done) return out + yield* preserveConsumerError( + cursor, + Effect.sync(() => { + if ( + step.value === null || + typeof step.value !== "object" || + isCodeModeValue(step.value) || + containsOpaqueReference(step.value) + ) { + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as( + "TypeError", + ) + } + const entry = step.value as Record + boundedData(entry[0], "Object.fromEntries key") + boundedData(entry[1], "Object.fromEntries value") + const key = coerceToString(entry[0]) + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node) + out[key] = entry[1] + }), + ) + } + }) +} diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f64858aa355..febc2ba5860 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -553,7 +553,7 @@ export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.", + "Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index c017a0b9719..8699daf07da 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -732,9 +732,10 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("not a general-purpose runtime") expect(instructions).not.toContain("Standard modern JavaScript works") expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) { + for (const missing of ["Modules/imports", "classes", "fetch"]) { expect(instructions).toContain(missing) } + expect(instructions).not.toContain("generators") expect(instructions).not.toContain("new Promise(...) are unavailable") expect(instructions).not.toContain("promise chaining") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") diff --git a/packages/codemode/test/generator-test262.test.ts b/packages/codemode/test/generator-test262.test.ts new file mode 100644 index 00000000000..70528f51fcd --- /dev/null +++ b/packages/codemode/test/generator-test262.test.ts @@ -0,0 +1,1271 @@ +/* + * Portable portions adapted from Test262 at revision + * 250f204f23a9249ff204be2baec29600faae7b75. Exact source paths are cited + * beside the corresponding tests below. + * + * Copyright (C) 2013-2017 the V8 project authors. All rights reserved. + * Copyright (C) 2018 Valerie Young. All rights reserved. + * Copyright (C) 2020 Alexey Shvayka. All rights reserved. + * Copyright (C) 2022 Kevin Gibbons. All rights reserved. + * Copyright Ecma International. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +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("confined generators", () => { + // test/built-ins/GeneratorPrototype/next/return-yield-expr.js + test("is lazy and preserves next(value), nested suspension, return, and exhaustion", async () => { + expect( + await value(` + const events = [] + function* generate() { + events.push("start") + const received = yield 1 + (yield 2) + return received + } + const iterator = generate() + const before = events.slice() + const first = iterator.next(99) + const second = iterator.next(3) + const third = iterator.next(7) + const fourth = iterator.next(8) + return [before, events, first, second, third, fourth] + `), + ).toEqual([ + [], + ["start"], + { value: 2, done: false }, + { value: 4, done: false }, + { value: 7, done: true }, + { value: null, done: true }, + ]) + }) + + test("routes throw and return through catch and finally", async () => { + expect( + await value(` + function* generate() { + try { + try { yield "try" } catch (error) { yield "caught " + error } + } finally { + yield "finally" + } + } + const iterator = generate() + return [iterator.next(), iterator.throw("boom"), iterator.return("done"), iterator.next()] + `), + ).toEqual([ + { value: "try", done: false }, + { value: "caught boom", done: false }, + { value: "finally", done: false }, + { value: "done", done: true }, + ]) + }) + + test("throws into a suspended generator and after exhaustion", async () => { + expect( + await value(` + function* generate() { yield 1 } + const iterator = generate() + iterator.next() + let suspended + let exhausted + try { iterator.throw("first") } catch (error) { suspended = error } + try { iterator.throw("second") } catch (error) { exhausted = error } + return [suspended, exhausted, iterator.next()] + `), + ).toEqual(["first", "second", { value: null, done: true }]) + }) + + test("rejects synchronous generator reentry", async () => { + expect( + await value(` + let iterator + function* generate() { + try { iterator.next() } catch (error) { return error.name } + } + iterator = generate() + return iterator.next() + `), + ).toEqual({ value: "TypeError", done: true }) + }) + + test("delegates yield*, forwards next values, and receives the delegate return value", async () => { + expect( + await value(` + function* inner() { + const input = yield 1 + return input * 2 + } + function* outer() { + const result = yield* inner() + return result + 1 + } + const iterator = outer() + return [iterator.next(), iterator.next(4)] + `), + ).toEqual([ + { value: 1, done: false }, + { value: 9, done: true }, + ]) + }) + + test("delegates throw and return to custom iterators", async () => { + expect( + await value(` + const calls = [] + let step = 0 + const delegate = { + [Symbol.iterator]: () => delegate, + next(...args) { + calls.push(["next", args.length, args[0]]) + step += 1 + return step === 1 ? { value: "one", done: false } : { value: "end", done: true } + }, + throw(value) { + calls.push(["throw", value]) + return { value: "recovered", done: false } + }, + return(value) { + calls.push(["return", value]) + return { value: value + "!", done: true } + }, + } + function* generate() { return yield* delegate } + const iterator = generate() + const first = iterator.next() + const second = iterator.throw("x") + const third = iterator.return("stop") + return [first, second, third, calls] + `), + ).toEqual([ + { value: "one", done: false }, + { value: "recovered", done: false }, + { value: "stop!", done: true }, + [ + ["next", 1, null], + ["throw", "x"], + ["return", "stop"], + ], + ]) + }) + + test("uses the missing-throw delegation path for built-in iterables", async () => { + expect( + await value(` + function* generate() { yield* [1, 2] } + const iterator = generate() + iterator.next() + try { iterator.throw("boom") } catch (error) { return error.name } + `), + ).toBe("TypeError") + }) + + test("exposes only the appropriate iterator symbol and works in for...of", async () => { + expect( + await value(` + function* generate() { yield 1; yield 2 } + const iterator = generate() + const symbols = [iterator[Symbol.iterator]() === iterator, iterator[Symbol.asyncIterator]] + const values = [] + for (const item of iterator) values.push(item) + return [symbols, values] + `), + ).toEqual([ + [true, null], + [1, 2], + ]) + }) + + test("accepts generator methods as iterator acquisition results", async () => { + expect( + await value(` + const sync = { + *[Symbol.iterator]() { yield 1; yield 2 }, + } + const asynchronous = { + async *[Symbol.asyncIterator]() { yield 3; yield 4 }, + } + const values = [] + for (const item of sync) values.push(item) + for await (const item of asynchronous) values.push(item) + return values + `), + ).toEqual([1, 2, 3, 4]) + }) + + test("closes a generator when for...of exits abruptly", async () => { + expect( + await value(` + const events = [] + function* generate() { + try { yield 1; yield 2 } finally { events.push("closed") } + } + for (const item of generate()) break + return events + `), + ).toEqual(["closed"]) + }) + + test("async generator requests are promises and execute in request order", async () => { + expect( + await value(` + const events = [] + async function* generate() { + events.push("start") + const input = yield Promise.resolve(1) + events.push("received " + input) + return Promise.resolve(3) + } + const iterator = generate() + const first = iterator.next() + const second = iterator.next(2) + const third = iterator.next(4) + const promiseFlags = [first instanceof Promise, second instanceof Promise, third instanceof Promise] + return [promiseFlags, await Promise.all([first, second, third]), events] + `), + ).toEqual([ + [true, true, true], + [ + { value: 1, done: false }, + { value: 3, done: true }, + { value: null, done: true }, + ], + ["start", "received 2"], + ]) + }) + + test("keeps requests queued while a completed generator adopts return values", async () => { + expect( + await value(` + const events = [] + let resolve + const pending = new Promise((done) => { resolve = done }) + async function* generate() { return 1 } + const iterator = generate() + const first = iterator.next() + const returned = iterator.return(pending) + const later = first.then(() => iterator.next()).then(() => events.push("later")) + returned.then(() => events.push("returned")) + await first + await Promise.resolve() + const before = events.slice() + resolve(9) + await Promise.all([returned, later]) + return [before, events] + `), + ).toEqual([[], ["returned", "later"]]) + }) + + test("serializes requests made after async generator exhaustion", async () => { + expect( + await value(` + const events = [] + let resolve + const pending = new Promise((done) => { resolve = done }) + async function* generate() { return 1 } + const iterator = generate() + await iterator.next() + const returned = iterator.return(pending).then(() => events.push("returned")) + const later = iterator.next().then(() => events.push("later")) + await Promise.resolve() + const before = events.slice() + resolve(9) + await Promise.all([returned, later]) + return [before, events] + `), + ).toEqual([[], ["returned", "later"]]) + }) + + test("async generators adopt yielded, returned, and return-request promises", async () => { + expect( + await value(` + async function* yielded() { yield Promise.resolve(1) } + async function* returned() { return Promise.resolve(2) } + async function* pending() { yield 0 } + const first = yielded() + const second = returned() + const third = pending() + const exhausted = returned() + await third.next() + await exhausted.next() + return await Promise.all([ + first.next(), + second.next(), + third.return(Promise.resolve(3)), + exhausted.return(Promise.resolve(4)), + ]) + `), + ).toEqual([ + { value: 1, done: false }, + { value: 2, done: true }, + { value: 3, done: true }, + { value: 4, done: true }, + ]) + }) + + test("awaits return-request promises before injecting completion", async () => { + expect( + await value(` + const events = [] + async function* generate() { + try { + yield 1 + } catch (error) { + events.push("caught " + error) + yield "recovered" + } finally { + events.push("finally") + } + } + const iterator = generate() + const first = await iterator.next() + const returned = await iterator.return(Promise.reject("bad")) + const beforeNext = events.slice() + const last = await iterator.next() + return [first, returned, beforeNext, last, events] + `), + ).toEqual([ + { value: 1, done: false }, + { value: "recovered", done: false }, + ["caught bad"], + { value: null, done: true }, + ["caught bad", "finally"], + ]) + }) + + test("loop consumers call iterator next with no arguments", async () => { + expect( + await value(` + const calls = [] + let syncStep = 0 + const sync = { + [Symbol.iterator]: () => sync, + next(...args) { + calls.push(["sync", args.length]) + syncStep += 1 + return { value: syncStep, done: syncStep > 1 } + }, + } + let asyncStep = 0 + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + async next(...args) { + calls.push(["async", args.length]) + asyncStep += 1 + return { value: asyncStep, done: asyncStep > 1 } + }, + } + for (const item of sync) {} + for await (const item of asynchronous) {} + return calls + `), + ).toEqual([ + ["sync", 0], + ["sync", 0], + ["async", 0], + ["async", 0], + ]) + }) + + test("supports async generators in for await...of and keeps them out of for...of", async () => { + expect( + await value(` + async function* generate() { yield 1; yield await Promise.resolve(2) } + const values = [] + for await (const item of generate()) values.push(item) + let name + try { for (const item of generate()) {} } catch (error) { name = error.name } + return [values, name] + `), + ).toEqual([[1, 2], "TypeError"]) + }) + + test("keeps generator references opaque at the data boundary", async () => { + const result = await execute(`function* generate() { yield 1 } return generate()`) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("InvalidDataValue") + }) + + // test/built-ins/GeneratorPrototype/return/from-state-suspended-start.js + // test/built-ins/GeneratorPrototype/throw/from-state-suspended-start.js + // test/built-ins/GeneratorPrototype/return/from-state-completed.js + // test/built-ins/GeneratorPrototype/throw/from-state-completed.js + test("honors sync return and throw in suspended-start and completed states", async () => { + expect( + await value(` + const events = [] + function* generate() { events.push("body"); yield 1 } + + const returned = generate() + const startReturn = returned.return(7) + const afterReturn = returned.next() + + const thrown = generate() + let startThrow + try { thrown.throw("start") } catch (error) { startThrow = error } + const afterThrow = thrown.next() + let completedThrow + try { thrown.throw("completed") } catch (error) { completedThrow = error } + + return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] + `), + ).toEqual([ + { value: 7, done: true }, + { value: null, done: true }, + "start", + { value: null, done: true }, + "completed", + [], + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-promise.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedStart.js + // test/built-ins/AsyncGeneratorPrototype/return/return-state-completed.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-state-completed.js + test("honors async return and throw in suspended-start and completed states", async () => { + expect( + await value(` + const events = [] + async function* generate() { events.push("body"); yield 1 } + + const returned = generate() + const startReturn = await returned.return(Promise.resolve(7)) + const afterReturn = await returned.next() + + const thrown = generate() + let startThrow + try { await thrown.throw("start") } catch (error) { startThrow = error } + const afterThrow = await thrown.next() + let completedThrow + try { await thrown.throw("completed") } catch (error) { completedThrow = error } + + return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] + `), + ).toEqual([ + { value: 7, done: true }, + { value: null, done: true }, + "start", + { value: null, done: true }, + "completed", + [], + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally.js + // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally-return.js + // test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedYield-try-finally-throw.js + test("runs finally yields and lets finally completions override requests", async () => { + expect( + await value(` + async function* yielding() { + try { yield 1 } finally { yield 2 } + } + async function* returning() { + try { yield 1 } finally { return "override" } + } + async function* throwing() { + try { yield 1 } finally { throw "override" } + } + + const first = yielding() + await first.next() + const finallyYield = await first.return("sent") + const preservedReturn = await first.next() + + const second = returning() + await second.next() + const overriddenReturn = await second.return("sent") + + const third = throwing() + await third.next() + let overriddenThrow + try { await third.throw("sent") } catch (error) { overriddenThrow = error } + return [finallyYield, preservedReturn, overriddenReturn, overriddenThrow] + `), + ).toEqual([{ value: 2, done: false }, { value: "sent", done: true }, { value: "override", done: true }, "override"]) + }) + + // test/language/statements/async-generator/yield-promise-reject-next-catch.js + // test/language/statements/async-generator/yield-promise-reject-next-yield-star-sync-iterator.js + test("rejects yielded promises and closes direct and sync-delegating async generators", async () => { + expect( + await value(` + async function* direct() { yield Promise.reject("direct") } + async function* delegated() { yield* [Promise.reject("delegated"), "unreachable"] } + const results = [] + for (const iterator of [direct(), delegated()]) { + try { await iterator.next() } catch (error) { results.push(error) } + results.push(await iterator.next()) + } + return results + `), + ).toEqual(["direct", { value: null, done: true }, "delegated", { value: null, done: true }]) + }) + + // test/built-ins/AsyncFromSyncIteratorPrototype/next/for-await-iterator-next-rejected-promise-close.js + // test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-iterator-next-rejected-promise-close.js + // test/built-ins/AsyncFromSyncIteratorPrototype/throw/iterator-result-rejected-promise-close.js + test("closes sync iterators when async-from-sync values reject", async () => { + expect( + await value(` + const events = [] + function* loopSource() { + try { yield Promise.reject("loop") } finally { events.push("loop close") } + } + let loopError + try { for await (const item of loopSource()) {} } catch (error) { loopError = error } + + function* yieldSource() { + try { yield Promise.reject("yield") } finally { events.push("yield close") } + } + async function* delegate() { yield* yieldSource() } + let yieldError + try { await delegate().next() } catch (error) { yieldError = error } + + const throwing = { + [Symbol.iterator]: () => throwing, + next: () => ({ value: 1, done: false }), + throw: () => ({ value: Promise.reject("throw"), done: false }), + return: () => { events.push("throw close"); return {} }, + } + async function* throwDelegate() { yield* throwing } + const iterator = throwDelegate() + await iterator.next() + let throwError + try { await iterator.throw("sent") } catch (error) { throwError = error } + return [loopError, yieldError, throwError, events] + `), + ).toEqual(["loop", "yield", "throw", ["loop close", "yield close", "throw close"]]) + }) + + test("does not close rejected terminal or delegated return values", async () => { + expect( + await value(` + const events = [] + const terminal = { + [Symbol.iterator]: () => terminal, + next: () => ({ value: Promise.reject("terminal"), done: true }), + return: () => { events.push("terminal close"); return {} }, + } + let terminalError + try { for await (const item of terminal) {} } catch (error) { terminalError = error } + + let returnCount = 0 + const returned = { + [Symbol.iterator]: () => returned, + next: () => ({ value: 1, done: false }), + return: () => { + returnCount += 1 + return { value: Promise.reject("return"), done: false } + }, + } + async function* delegate() { yield* returned } + const iterator = delegate() + await iterator.next() + let returnError + try { await iterator.return("sent") } catch (error) { returnError = error } + return [terminalError, returnError, returnCount, events] + `), + ).toEqual(["terminal", "return", 1, []]) + }) + + test("serializes a mixed async next, throw, return, and next request queue", async () => { + expect( + await value(` + async function* generate() { + try { + try { yield 1; yield 2 } catch (error) { yield "caught " + error } + } finally { + yield "finally" + } + } + const iterator = generate() + const first = iterator.next() + const thrown = iterator.throw("x") + const returned = iterator.return("done") + const last = iterator.next() + return await Promise.all([first, thrown, returned, last]) + `), + ).toEqual([ + { value: 1, done: false }, + { value: "caught x", done: false }, + { value: "finally", done: false }, + { value: "done", done: true }, + ]) + }) + + // test/built-ins/AsyncGeneratorPrototype/next/request-queue-order.js + // test/built-ins/AsyncGeneratorPrototype/throw/request-queue-order-state-executing.js + // test/built-ins/AsyncGeneratorPrototype/return/request-queue-order-state-executing.js + test("orders async requests enqueued while generators are executing", async () => { + expect( + await value(` + const events = [] + let returned, returnRequest + async function* returnWhileExecuting() { + returnRequest = returned.return(42).then((result) => events.push(["return", result])) + yield 1 + } + returned = returnWhileExecuting() + const firstReturn = returned.next().then((result) => events.push(["first return", result])) + await Promise.all([firstReturn, returnRequest]) + + let thrown, throwRequest + async function* throwWhileExecuting() { + throwRequest = thrown.throw("boom").catch((error) => events.push(["throw", error])) + yield 2 + } + thrown = throwWhileExecuting() + const firstThrow = thrown.next().then((result) => events.push(["first throw", result])) + await Promise.all([firstThrow, throwRequest]) + + async function* queued() { yield "first"; yield "second" } + const iterator = queued() + const first = iterator.next() + const second = iterator.next() + const third = iterator.next() + await Promise.all([ + third.then(() => events.push("third")), + second.then(() => events.push("second")), + first.then(() => events.push("first")), + ]) + return events + `), + ).toEqual([ + ["first return", { value: 1, done: false }], + ["return", { value: 42, done: true }], + ["first throw", { value: 2, done: false }], + ["throw", "boom"], + "first", + "second", + "third", + ]) + }) + + // test/language/statements/async-generator/yield-star-promise-not-unwrapped.js + // test/language/statements/async-generator/yield-star-sync-next.js + test("preserves async iterator promise values but unwraps async-from-sync values", async () => { + expect( + await value(` + const asyncValue = Promise.resolve("async") + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + next: () => ({ value: asyncValue, done: false }), + } + const syncValue = Promise.resolve("sync") + const synchronous = { + [Symbol.iterator]: () => synchronous, + next: () => ({ value: syncValue, done: false }), + } + async function* delegate(value) { yield* value } + const asyncResult = await delegate(asynchronous).next() + const syncResult = await delegate(synchronous).next() + return [asyncResult.value === asyncValue, await asyncResult.value, syncResult] + `), + ).toEqual([true, "async", { value: "sync", done: false }]) + }) + + test("captures delegated result done before awaiting async-from-sync values", async () => { + expect( + await value(` + const result = { value: Promise.resolve(1), done: false } + result.value.then(() => { result.done = true }) + const source = { + [Symbol.iterator]: () => source, + next: () => result, + } + async function* delegate() { yield* source } + return await delegate().next() + `), + ).toEqual({ value: 1, done: false }) + }) + + // test/language/statements/async-generator/yield-star-async-next.js + // test/language/statements/async-generator/yield-star-sync-return.js + // test/language/statements/async-generator/yield-star-async-throw.js + test("forwards next, return, and throw through sync and async yield delegates", async () => { + expect( + await value(` + const calls = [] + const make = (symbol, label) => { + let step = 0 + const iterator = { + [symbol]: () => iterator, + next(...args) { + calls.push([label, "next", args.length, args[0]]) + step += 1 + return { value: step, done: false } + }, + return(value) { + calls.push([label, "return", value]) + return { value: value + "!", done: true } + }, + throw(value) { + calls.push([label, "throw", value]) + return { value: "caught " + value, done: false } + }, + } + return iterator + } + async function* delegate(iterator) { return yield* iterator } + const sync = delegate(make(Symbol.iterator, "sync")) + const asynchronous = delegate(make(Symbol.asyncIterator, "async")) + const results = [await sync.next(9), await sync.next(2), await sync.throw("x"), await sync.return("stop")] + results.push(await asynchronous.next(9), await asynchronous.next(3), await asynchronous.throw("y")) + return [results, calls] + `), + ).toEqual([ + [ + { value: 1, done: false }, + { value: 2, done: false }, + { value: "caught x", done: false }, + { value: "stop!", done: true }, + { value: 1, done: false }, + { value: 2, done: false }, + { value: "caught y", done: false }, + ], + [ + ["sync", "next", 1, null], + ["sync", "next", 1, 2], + ["sync", "throw", "x"], + ["sync", "return", "stop"], + ["async", "next", 1, null], + ["async", "next", 1, 3], + ["async", "throw", "y"], + ], + ]) + }) + + // test/language/statements/async-generator/yield-star-sync-return.js + // test/language/statements/async-generator/yield-star-async-throw.js + test("continues delegation when return and throw report done false", async () => { + expect( + await value(` + let returns = 0 + const synchronous = { + [Symbol.iterator]: () => synchronous, + next: () => ({ value: "next", done: false }), + return(value) { + returns += 1 + return { value: returns === 1 ? "return pending" : value, done: returns > 1 } + }, + } + let throws = 0 + const asynchronous = { + [Symbol.asyncIterator]: () => asynchronous, + next: () => ({ value: "next", done: false }), + throw(value) { + throws += 1 + return { value: throws === 1 ? "throw pending" : value, done: throws > 1 } + }, + } + async function* delegate(iterator) { return yield* iterator } + const returned = delegate(synchronous) + const thrown = delegate(asynchronous) + return [ + await returned.next(), + await returned.return("first"), + await returned.return("second"), + await thrown.next(), + await thrown.throw("first"), + await thrown.throw("second"), + ] + `), + ).toEqual([ + { value: "next", done: false }, + { value: "return pending", done: false }, + { value: "second", done: true }, + { value: "next", done: false }, + { value: "throw pending", done: false }, + { value: "second", done: true }, + ]) + }) + + // test/language/expressions/yield/star-rhs-iter-nrml-next-call-non-obj.js + // test/language/expressions/yield/star-rhs-iter-thrw-thrw-call-non-obj.js + // test/language/expressions/yield/star-rhs-iter-rtrn-rtrn-call-non-obj.js + // test/language/statements/async-generator/yield-star-next-not-callable-number-throw.js + test("rejects malformed yield delegate methods and iterator results", async () => { + expect( + await value(` + const run = (iterator, operation) => { + function* generate() { + try { yield* iterator } catch (error) { return error.name } + } + const value = generate() + const first = value.next() + return operation === "next" ? first : value[operation]() + } + const iterable = (fields) => ({ [Symbol.iterator]: () => fields }) + const nextResult = run(iterable({ next: () => 1 }), "next") + const throwResult = run(iterable({ next: () => ({ done: false }), throw: () => 1 }), "throw") + const returnResult = run(iterable({ next: () => ({ done: false }), return: () => 1 }), "return") + + const badAsync = { + [Symbol.asyncIterator]: () => ({ next: 1 }), + } + async function* asynchronous() { + try { yield* badAsync } catch (error) { return error.name } + } + return [nextResult, throwResult, returnResult, await asynchronous().next()] + `), + ).toEqual([ + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + { value: "TypeError", done: true }, + ]) + }) + + // test/language/expressions/yield/captured-free-vars.js + // test/language/statements/generators/dflt-params-ref-prior.js + // test/language/expressions/generators/dflt-params-ref-prior.js + // test/language/expressions/object/method-definition/generator-no-yield.js + test("supports declaration, expression, and method forms with closures and parameters", async () => { + expect( + await value(` + const captured = 4 + function* declaration(x, y = x, ...rest) { yield captured + y + rest[0] } + const expression = function* (x, y = x) { yield captured + y } + const object = { *method({ value }, extra = 1) { return captured + value + extra } } + return [declaration(2, undefined, 3).next(), expression(5).next(), object.method({ value: 6 }).next()] + `), + ).toEqual([ + { value: 9, done: false }, + { value: 9, done: false }, + { value: 11, done: true }, + ]) + }) + + // test/language/expressions/assignment/dstr/array-elem-iter-nrml-close.js + test("steps holes and rest and closes array binding and assignment patterns early", async () => { + expect( + await value(` + const events = [] + function* binding() { + try { events.push("b1"); yield 1; events.push("b2"); yield 2; events.push("b3"); yield 3; yield 4 } + finally { events.push("binding close") } + } + const [first, , third] = binding() + function* assignment() { + try { yield 5; yield 6; yield 7 } + finally { events.push("assignment close") } + } + let head, rest + ;[head, ...rest] = assignment() + return [first, third, head, rest, events] + `), + ).toEqual([1, 3, 5, [6, 7], ["b1", "b2", "b3", "binding close", "assignment close"]]) + }) + + // test/language/statements/variable/dstr/ary-ptrn-elem-id-init-throws.js + // test/language/expressions/assignment/dstr/array-elem-iter-thrw-close-err.js + test("closes on destructuring defaults and preserves the binding error over return failure", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => ({ value: undefined, done: false }), + return: () => { events.push("close"); throw "close error" }, + } + let caught + try { + const [value = (() => { throw "binding error" })()] = iterator + } catch (error) { caught = error } + return [caught, events] + `), + ).toEqual(["binding error", ["close"]]) + }) + + test("does not close an exhausted iterator when a destructuring default fails", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => ({ done: true }), + return: () => { events.push("close"); return {} }, + } + try { const [item = (() => { throw "default" })()] = iterator } catch {} + return events + `), + ).toEqual([]) + }) + + test("consumes generators in array and argument spread without awaiting yielded promises", async () => { + expect( + await value(` + function* values() { yield 1; yield Promise.resolve(2); yield 3 } + const array = [...values()] + const args = ((...items) => items)(...values()) + return [array[0], array[1] instanceof Promise, await array[1], args[2]] + `), + ).toEqual([1, true, 2, 3]) + }) + + test("constructs Map, Set, and URLSearchParams from generators lazily", async () => { + expect( + await value(` + const events = [] + function* pairs() { events.push(1); yield ["a", 1]; events.push(2); yield ["b", 2] } + function* values() { yield 1; yield 1; yield 2 } + const map = new Map(pairs()) + const set = new Set(values()) + const params = new URLSearchParams(pairs()) + return [map.get("b"), [...set], params.toString(), events] + `), + ).toEqual([2, [1, 2], "a=1&b=2", [1, 2, 1, 2]]) + }) + + test("keeps built-in collection iteration live during callbacks", async () => { + expect( + await value(` + const map = new Map([[1, 1], [2, 2]]) + const mapped = Array.from(map, (entry, index) => { + if (index === 0) map.set(3, 3) + return entry[0] + }) + const set = new Set([1, 2]) + const grouped = Map.groupBy(set, (item, index) => { + if (index === 0) set.add(3) + return "items" + }) + return [mapped, grouped.get("items")] + `), + ).toEqual([ + [1, 2, 3], + [1, 2, 3], + ]) + }) + + test("keeps built-in collection iteration live in loops and yield delegation", async () => { + expect( + await value(` + const map = new Map([[1, 1], [2, 2]]) + const mapValues = [] + for (const [key] of map) { + mapValues.push(key) + if (key === 1) map.set(3, 3) + } + const set = new Set([1, 2]) + const setValues = [] + for await (const item of set) { + setValues.push(item) + if (item === 1) set.add(3) + } + const params = new URLSearchParams("a=1&b=2") + function* delegate() { yield* params } + const iterator = delegate() + const first = iterator.next() + params.append("c", "3") + return [mapValues, setValues, first, iterator.next(), iterator.next()] + `), + ).toEqual([ + [1, 2, 3], + [1, 2, 3], + { value: ["a", "1"], done: false }, + { value: ["b", "2"], done: false }, + { value: ["c", "3"], done: false }, + ]) + }) + + test("preserves async-from-sync turns for built-in loop completion and close", async () => { + expect( + await value(` + const completed = [] + Promise.resolve().then(() => completed.push("reaction")) + for await (const item of []) {} + completed.push("after") + + const closed = [] + for await (const item of [1]) { + Promise.resolve().then(() => closed.push("reaction")) + break + } + closed.push("after") + return [completed, closed] + `), + ).toEqual([ + ["reaction", "after"], + ["reaction", "after"], + ]) + }) + + test("reads iterator return only when closing", async () => { + expect( + await value(` + const events = [] + const iterator = { + [Symbol.iterator]: () => iterator, + next() { + iterator.return = () => { events.push("new"); return {} } + return { value: 1, done: false } + }, + return: () => { events.push("old"); return {} }, + } + const [item] = iterator + return [item, events] + `), + ).toEqual([1, ["new"]]) + }) + + test("accepts iterable URLSearchParams entry pairs", async () => { + expect( + await value(` + function* pair() { yield "a"; yield 1 } + function* entries() { yield pair() } + return new URLSearchParams(entries()).toString() + `), + ).toBe("a=1") + }) + + test("converts URLSearchParams pair elements before requesting the next", async () => { + expect( + await value(` + const events = [] + function* pair() { + try { + events.push("first") + yield (function* () {})() + events.push("second") + yield 2 + } finally { events.push("pair close") } + } + function* entries() { + try { yield pair() } finally { events.push("outer close") } + } + let name + try { new URLSearchParams(entries()) } catch (error) { name = error.name } + return [events, name] + `), + ).toEqual([["first", "pair close", "outer close"], "Error"]) + }) + + test("validates URLSearchParams pair lengths after converting the outer sequence", async () => { + expect( + await value(` + const events = [] + function* entries() { + try { + events.push("one") + yield ["a"] + events.push("two") + yield ["b", 2] + } finally { events.push("outer close") } + } + let name + try { new URLSearchParams(entries()) } catch (error) { name = error.name } + return [events, name] + `), + ).toEqual([["one", "two", "outer close"], "TypeError"]) + }) + + test("closes entry constructors when a generator yields a malformed entry", async () => { + expect( + await value(` + const events = [] + function* mapEntries() { try { yield ["a", 1]; yield null } finally { events.push("map close") } } + function* parameterEntries() { try { yield ["a"]; yield ["b", 2] } finally { events.push("params close") } } + const names = [] + try { new Map(mapEntries()) } catch (error) { names.push(error.name) } + try { new URLSearchParams(parameterEntries()) } catch (error) { names.push(error.name) } + return [names, events] + `), + ).toEqual([ + ["TypeError", "TypeError"], + ["map close", "params close"], + ]) + }) + + // test/built-ins/Array/from/iter-map-fn-args.js + // test/built-ins/Array/from/iter-map-fn-err.js + test("interleaves Array.from mapping and closes when its mapper fails", async () => { + expect( + await value(` + const events = [] + function* source() { + try { events.push("next 1"); yield 1; events.push("next 2"); yield 2 } + finally { events.push("close") } + } + const mapped = Array.from(source(), (item) => { events.push("map " + item); return item * 2 }) + let caught + try { Array.from(source(), (item) => { throw "mapper " + item }) } catch (error) { caught = error } + return [mapped, caught, events] + `), + ).toEqual([[2, 4], "mapper 1", ["next 1", "map 1", "next 2", "map 2", "close", "next 1", "close"]]) + }) + + test("reads array-like Array.from values immediately before mapping", async () => { + expect( + await value(` + const source = { 0: 1, 1: 2, length: 2 } + return Array.from(source, (item, index) => { + if (index === 0) source[1] = 9 + return item + }) + `), + ).toEqual([1, 9]) + }) + + test("interleaves Object.groupBy and Map.groupBy callbacks and closes on callback failure", async () => { + expect( + await value(` + const events = [] + function* source() { try { events.push("next"); yield 1; events.push("next"); yield 2 } finally { events.push("close") } } + const object = Object.groupBy(source(), (item) => { events.push("object " + item); return item % 2 }) + const map = Map.groupBy(source(), (item) => { events.push("map " + item); return item % 2 }) + let caught + try { Object.groupBy(source(), () => { throw "callback" }) } catch (error) { caught = error } + return [object, Object.fromEntries(map), caught, events] + `), + ).toEqual([ + { 0: [2], 1: [1] }, + { 0: [2], 1: [1] }, + "callback", + ["next", "object 1", "next", "object 2", "close", "next", "map 1", "next", "map 2", "close", "next", "close"], + ]) + }) + + test("consumes Promise combinator generators in order and observes rejections", async () => { + expect( + await value(` + function* items() { yield Promise.resolve(1); yield Promise.reject("bad"); yield 3 } + const settled = await Promise.allSettled(items()) + let all, any + try { await Promise.all(items()) } catch (error) { all = error } + try { await Promise.any((function* () { yield Promise.reject("a"); yield Promise.reject("b") })()) } + catch (error) { any = error.errors } + const race = await Promise.race((function* () { yield 4; yield Promise.resolve(5) })()) + return [settled, all, any, race] + `), + ).toEqual([ + [ + { status: "fulfilled", value: 1 }, + { status: "rejected", reason: "bad" }, + { status: "fulfilled", value: 3 }, + ], + "bad", + ["a", "b"], + 4, + ]) + }) + + test("finishes Promise combinator iterator consumption before returning the promise", async () => { + expect( + await value(` + const events = [] + function* items() { events.push("first"); yield 1; events.push("second"); yield 2 } + const promise = Promise.all(items()) + events.push("after call") + await promise + return events + `), + ).toEqual(["first", "second", "after call"]) + }) + + // test/built-ins/Object/fromEntries/iterator-closed-for-null-entry.js + test("closes Object.fromEntries on malformed entries and consumes valid generators", async () => { + expect( + await value(` + const events = [] + function* valid() { yield ["a", 1]; yield ["b", 2] } + function* invalid() { try { yield ["a", 1]; yield null; yield ["c", 3] } finally { events.push("close") } } + let name + try { Object.fromEntries(invalid()) } catch (error) { name = error.name } + return [Object.fromEntries(valid()), name, events] + `), + ).toEqual([{ a: 1, b: 2 }, "TypeError", ["close"]]) + }) + + test("consumes AggregateError and Math.sumPrecise generators and closes on invalid numbers", async () => { + expect( + await value(` + const events = [] + function* errors() { yield "a"; yield "b" } + function* numbers() { yield 1e30; yield 0.1; yield -1e30 } + function* invalid() { try { yield 1; yield "bad"; yield 2 } finally { events.push("close") } } + const aggregate = new AggregateError(errors(), "message") + let name + try { Math.sumPrecise(invalid()) } catch (error) { name = error.name } + return [aggregate.errors, aggregate.message, Math.sumPrecise(numbers()), name, events] + `), + ).toEqual([["a", "b"], "message", 0.1, "TypeError", ["close"]]) + }) + + test("rejects async generators in every synchronous iterable consumer", async () => { + expect( + await value(` + async function* source() { yield ["a", 1] } + const checks = [ + () => [...source()], + () => ((...items) => items)(...source()), + () => { const [item] = source(); return item }, + () => Array.from(source()), + () => new Map(source()), + () => new Set(source()), + () => new URLSearchParams(source()), + () => Object.fromEntries(source()), + () => Object.groupBy(source(), (item) => item), + () => Math.sumPrecise(source()), + () => new AggregateError(source()), + ] + const names = [] + for (const check of checks) { + try { check() } catch (error) { names.push(error.name) } + } + try { await Promise.all(source()) } catch (error) { names.push(error.name) } + return names + `), + ).toEqual(Array(12).fill("TypeError")) + }) + + // test/built-ins/Array/from/iter-get-iter-err.js + // test/built-ins/Array/from/iter-adv-err.js + test("does not close when iterator acquisition or next-result validation fails", async () => { + expect( + await value(` + const events = [] + const acquisition = { [Symbol.iterator]: () => { events.push("acquire"); throw "acquisition" } } + const malformed = { + [Symbol.iterator]: () => malformed, + next: () => { events.push("next"); return 1 }, + return: () => { events.push("close"); return {} }, + } + for (const source of [acquisition, malformed]) { + try { Array.from(source) } catch {} + } + return events + `), + ).toEqual(["acquire", "next"]) + }) + + test("reports synchronous iterator failures before queued promise reactions", async () => { + expect( + await value(` + const events = [] + Promise.resolve().then(() => events.push("reaction")) + const iterator = { + [Symbol.iterator]: () => iterator, + next: () => { throw "next" }, + } + try { Array.from(iterator) } catch { events.push("catch") } + await Promise.resolve() + return events + `), + ).toEqual(["catch", "reaction"]) + }) +})