feat(codemode): support custom async iterators (#38141)

This commit is contained in:
Aiden Cline 2026-07-21 12:18:46 -05:00 committed by GitHub
commit 6f4b9504e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 636 additions and 51 deletions

View file

@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Regular-expression literals. - [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals. - [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries. - [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties. - [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls. - [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals. - [ ] Getter and setter definitions in object literals.
@ -70,9 +71,11 @@ ultimate source of truth.
- [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values. - [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`. - [x] Labeled statements, labeled `break`, and labeled `continue`.
- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain - [x] `for await...of` over the supported synchronous collections and custom iterator objects using
value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain `Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
outside the supported subset. 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.
## Functions and callbacks ## Functions and callbacks

View file

@ -36,7 +36,7 @@ export type StatementResult =
export type MemberReference = { export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number key: PropertyKey
} }
export class CodeModeFunction { export class CodeModeFunction {
@ -61,6 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {} export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject" export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference { export class PromiseMethodReference {

View file

@ -13,6 +13,7 @@ import {
PromiseMethodReference, PromiseMethodReference,
PromiseNamespace, PromiseNamespace,
SearchFunction, SearchFunction,
SymbolNamespace,
UriFunction, UriFunction,
} from "./model.js" } from "./model.js"
import { ToolReference } from "../tool-runtime.js" import { ToolReference } from "../tool-runtime.js"
@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction || value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction || value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference || value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value) isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> { function* childValues(value: object): Generator<unknown> {
@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference || value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace || value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction || value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
) )
return "function" return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function" if (value instanceof UriFunction || value instanceof SearchFunction) return "function"

View file

@ -1,7 +1,8 @@
import { Cause, Effect } from "effect" import { Cause, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import { import {
type AstNode, type AstNode,
AsyncIteratorSymbol,
asNode, asNode,
type Binding, type Binding,
CodeModeFunction, CodeModeFunction,
@ -19,6 +20,8 @@ import {
IntrinsicReference, IntrinsicReference,
InterpreterRuntimeError, InterpreterRuntimeError,
isRecord, isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference, JsonMethodReference,
type MemberReference, type MemberReference,
OptionalShortCircuit, OptionalShortCircuit,
@ -30,6 +33,7 @@ import {
ProgramThrow, ProgramThrow,
type ProgramNode, type ProgramNode,
SearchFunction, SearchFunction,
SymbolNamespace,
type StatementResult, type StatementResult,
supportedSyntaxMessage, supportedSyntaxMessage,
unsupportedSyntax, unsupportedSyntax,
@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
} }
} }
type CustomIterator = {
iterator: SafeObject
next: unknown
asynchronous: boolean
}
export class Interpreter<R> { export class Interpreter<R> {
private scopes: ScopeStack private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R> private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@ -241,6 +251,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() }) globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined }) globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@ -636,9 +647,10 @@ export class Interpreter<R> {
const body = getNode(node, "body") const body = getNode(node, "body")
const iterable = spreadItems(right) const iterable = spreadItems(right)
if (iterable === undefined) { const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
if (iterable === undefined && iterator === undefined) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`, `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
node, node,
) )
} }
@ -657,19 +669,14 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left) throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
} }
for (const value of iterable) { const evaluateBody = (value: unknown) =>
const resolved = awaiting Effect.gen(function* () {
? value instanceof CodeModePromise
? yield* self.settlePromise(value)
: yield* Effect.as(Effect.yieldNow, value)
: value
const result = yield* Effect.gen(function* () {
if (declared) { if (declared) {
self.scopes.push() self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical) yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) { } else if (assignment) {
yield* self.assignPattern(assignment, resolved, left) yield* self.assignPattern(assignment, value, left)
} }
return yield* self.evaluateStatement(body) return yield* self.evaluateStatement(body)
}).pipe( }).pipe(
@ -680,22 +687,48 @@ export class Interpreter<R> {
), ),
) )
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)
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))
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") { if (result.kind === "return") {
yield* self.closeIterator(iterator, node)
return result return result
} }
if (result.kind === "break") { if (result.kind === "break") {
yield* self.closeIterator(iterator, node)
if (result.label !== undefined && !labels?.has(result.label)) return result if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
} }
if (result.kind === "continue") { if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
if (result.label !== undefined && !labels?.has(result.label)) return result yield* self.closeIterator(iterator, node)
continue return result
} }
} }
return { kind: "none" } satisfies StatementResult
}).pipe( }).pipe(
Effect.ensuring( Effect.ensuring(
Effect.sync(() => { Effect.sync(() => {
@ -705,6 +738,101 @@ export class Interpreter<R> {
) )
} }
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private customIterator(value: unknown, node: AstNode) {
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
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)
return {
iterator: object,
next: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
})
}
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
const close = iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
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
return yield* Effect.failCause(captured.cause)
}
yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
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")
}
private enumerableKeys(value: unknown): Array<string> | undefined { private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) { if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)] return [...this.toolKeys(value.path)]
@ -922,7 +1050,7 @@ export class Interpreter<R> {
) )
} }
const consumed = new Set<string>() const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) { for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties") const property = asNode(propertyValue, "properties")
@ -931,6 +1059,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) { for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item 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))
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize) yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue continue
} }
@ -939,7 +1071,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
} }
consumed.add(String(key)) consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern( yield* self.declarePattern(
getNode(property, "value"), getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key), self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@ -1002,7 +1134,7 @@ export class Interpreter<R> {
} }
const source = value as SafeObject | Array<unknown> const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>() const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) { for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties") const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") { if (property.type === "RestElement") {
@ -1010,6 +1142,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) { for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item 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))
}
yield* self.assignPattern(getNode(property, "argument"), rest, property) yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue continue
} }
@ -1017,7 +1153,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
} }
consumed.add(String(key)) consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property) yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
} }
return return
@ -1044,7 +1180,7 @@ export class Interpreter<R> {
}) })
} }
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> { private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") { if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
} }
@ -1055,12 +1191,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)) return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
} }
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown { private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return source[String(key)] if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length if (key === "length") return source.length
if (typeof key === "number") return source[key] if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key] if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (arrayMethods.has(key)) return new IntrinsicReference(source, key) if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined return undefined
} }
@ -1690,6 +1826,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) { if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError") throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
} }
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) { if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0]) callable.settle(args[0])
return undefined return undefined
@ -1800,6 +1942,9 @@ export class Interpreter<R> {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property) if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value objectValue[key] = value
} }
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
}
continue continue
} }
@ -1830,7 +1975,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
} }
objectValue[String(key)] = yield* self.evaluateExpression(valueNode) Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
} }
return objectValue return objectValue
@ -1955,6 +2100,12 @@ export class Interpreter<R> {
) )
} }
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) { if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) { if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode) throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
@ -1976,7 +2127,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") { if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length) if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key) const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index]) if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
@ -2072,7 +2223,7 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) { if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key } if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key) const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) { if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) { if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key]) return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@ -2103,13 +2254,13 @@ export class Interpreter<R> {
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key] return Reflect.get(reference.target, reference.key)
} }
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) { if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)] return Reflect.get(reference.target.url, reference.key)
} }
return reference.target[String(reference.key)] return Reflect.get(reference.target, reference.key)
}) })
} }
@ -2171,22 +2322,22 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node) throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
} }
} }
const key = Array.isArray(reference.target) ? reference.key : String(reference.key) const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key)) const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node) if (write) self.assignToReference(reference, key, next, node)
return result return result
}) })
} }
private readReferenceValue(reference: MemberReference, key: number | string): unknown { private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) { if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key] return Reflect.get(reference.target.url, key)
} }
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key] return Reflect.get(reference.target, key)
} }
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
const target = reference.target const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) { if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@ -2219,16 +2370,19 @@ export class Interpreter<R> {
return return
} }
const target = reference.target as SafeObject const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node) rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next Reflect.set(target, key, next)
} }
private toPropertyKey(value: unknown, node: AstNode): string | number { private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") { if (typeof value === "string" || typeof value === "number") {
return value return value
} }
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node) throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
} }
} }

View file

@ -1,4 +1,10 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js" import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js" import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js" import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@ -46,7 +52,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries": case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item]) return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn": case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1])) return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is": case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) { if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue") throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node) throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
} }
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item) for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
} }
return out return out
} }

View file

@ -1,10 +1,15 @@
/* /*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: * 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/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-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-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js * - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
* *
* Copyright (C) 2019 André Bargull. All rights reserved. * Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262. * Test262 portions are governed by the BSD license in LICENSE.test262.
*/ */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@ -130,10 +135,400 @@ describe("Test262 for-await-of adaptations", () => {
).toEqual([1, 3]) ).toEqual([1, 3])
}) })
test("keeps custom iterator objects outside the supported subset", async () => { test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`) const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false) expect(result.ok).toBe(false)
if (result.ok) return if (result.ok) return
expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams") expect(result.error.message).toContain("or custom iterator value")
}) })
}) })

View file

@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" }) ).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
}) })
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => { test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`) const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number") expect(err.message).toContain("Property key must be a string or number")