Browse Source

feat(codemode): support bounded async iteration (#38040)

Aiden Cline 3 tuần trước cách đây
mục cha
commit
7111f93836

+ 3 - 1
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
 

+ 12 - 7
packages/codemode/src/interpreter/runtime.ts

@@ -635,10 +635,7 @@ export class Interpreter<R> {
     node: AstNode,
     labels?: ReadonlySet<string>,
   ): Effect.Effect<StatementResult, unknown, R> {
-    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<R> {
 
       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<R> {
       }
 
       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(

+ 139 - 0
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")
+  })
+})