diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 69789914cd..a9d2025bf5 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -70,7 +70,9 @@ ultimate source of truth. - [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `throw` with arbitrary values. - [x] Labeled statements, labeled `break`, and labeled `continue`. -- [ ] `for await...of` and async iteration. +- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain + value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain + outside the supported subset. ## Functions and callbacks diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 9a3b95024f..2c90a4beb0 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -635,10 +635,7 @@ export class Interpreter { node: AstNode, labels?: ReadonlySet, ): Effect.Effect { - if (getBoolean(node, "await")) { - throw new InterpreterRuntimeError("for await...of is not supported.", node) - } - + const awaiting = getBoolean(node, "await") const left = getNode(node, "left") const declared = loopDeclaration(left, "for...of") if (declared?.lexical) this.scopes.push() @@ -651,7 +648,10 @@ export class Interpreter { const iterable = spreadItems(right) if (iterable === undefined) { - throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) + throw new InterpreterRuntimeError( + `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value in CodeMode.`, + node, + ) } let assignment: AstNode | undefined @@ -669,13 +669,18 @@ export class Interpreter { } for (const value of iterable) { + const resolved = awaiting + ? value instanceof CodeModePromise + ? yield* self.settlePromise(value) + : yield* Effect.as(Effect.yieldNow, value) + : value const result = yield* Effect.gen(function* () { if (declared) { self.scopes.push() if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) - yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical) + yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical) } else if (assignment) { - yield* self.assignPattern(assignment, value, left) + yield* self.assignPattern(assignment, resolved, left) } return yield* self.evaluateStatement(body) }).pipe( diff --git a/packages/codemode/test/for-await-test262.test.ts b/packages/codemode/test/for-await-test262.test.ts new file mode 100644 index 0000000000..15ab876da5 --- /dev/null +++ b/packages/codemode/test/for-await-test262.test.ts @@ -0,0 +1,139 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js + * - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js + * - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js + * + * Copyright (C) 2019 André Bargull. 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("Test262 for-await-of adaptations", () => { + test("awaits promise and plain values from a synchronous array", async () => { + expect( + await value(` + const values = [] + for await (const item of [Promise.resolve(1), 2, new Promise((resolve) => resolve(3))]) { + values.push(item) + } + return values + `), + ).toEqual([1, 2, 3]) + }) + + test("defers the body even for an already-resolved or plain value", async () => { + expect( + await value(` + const events = [] + const before = Promise.resolve().then(() => events.push("before")) + for await (const item of [Promise.resolve(1), 2]) events.push("body " + item) + await before + return events + `), + ).toEqual(["before", "body 1", "body 2"]) + }) + + test("an awaited rejection exits through normal try/catch", async () => { + const result = await execute(` + const values = [] + try { + for await (const item of [Promise.resolve(1), Promise.reject("stop"), Promise.resolve(3)]) { + values.push(item) + } + } catch (error) { + return [values, error] + } + return "missed" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toEqual([[1], "stop"]) + expect(result.warnings ?? []).toEqual([]) + }) + + test("destructures after resolving each yielded value", async () => { + expect( + await value(` + const values = [] + for await (const [first, ...rest] of [Promise.resolve([1, 2, 3])]) { + values.push(first, rest) + } + return values + `), + ).toEqual([1, [2, 3]]) + }) + + test("supports assignment targets", async () => { + expect( + await value(` + let first + let rest + for await ([first, ...rest] of [Promise.resolve([1, 2, 3])]) {} + return [first, rest] + `), + ).toEqual([1, [2, 3]]) + }) + + test("preserves fresh lexical bindings per iteration", async () => { + expect( + await value(` + const reads = [] + for await (const item of [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]) { + reads.push(() => item) + } + return reads.map((read) => read()) + `), + ).toEqual([1, 2, 3]) + }) + + test("supports every existing collection iterable", async () => { + expect( + await value(` + const string = [] + for await (const item of "ab") string.push(item) + + const set = [] + for await (const item of new Set([Promise.resolve(1), 2])) set.push(item) + + const map = [] + for await (const [key, item] of new Map([["a", 1], ["b", 2]])) map.push(key, item) + + const params = [] + for await (const [key, item] of new URLSearchParams("a=1&b=2")) params.push(key, item) + return { string, set, map, params } + `), + ).toEqual({ string: ["a", "b"], set: [1, 2], map: ["a", 1, "b", 2], params: ["a", "1", "b", "2"] }) + }) + + test("preserves labeled break and continue behavior", async () => { + expect( + await value(` + const values = [] + outer: for await (const item of [1, 2, 3, 4]) { + if (item === 2) continue outer + if (item === 4) break outer + values.push(item) + } + return values + `), + ).toEqual([1, 3]) + }) + + test("keeps custom iterator objects outside the supported subset", async () => { + const result = await execute(`for await (const item of { values: [1, 2] }) {}`) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams") + }) +})