From 97e833a297b89989ae55784f93eca8e4f59b805f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:24 -0500 Subject: [PATCH] feat(codemode): add Iterator helpers and Iterator.from (#50410) --- packages/codemode/interpreter-support.md | 18 +- packages/codemode/src/interpreter/globals.ts | 2 +- .../codemode/src/interpreter/interpreter.ts | 59 ++- .../codemode/src/interpreter/intrinsics.ts | 2 + packages/codemode/src/interpreter/objects.ts | 31 +- packages/codemode/src/stdlib/array.ts | 10 +- packages/codemode/src/stdlib/bytes.ts | 10 +- packages/codemode/src/stdlib/collections.ts | 30 +- packages/codemode/src/stdlib/headers.ts | 17 +- packages/codemode/src/stdlib/iterator.ts | 343 +++++++++++++++++- packages/codemode/src/stdlib/string.ts | 9 +- packages/codemode/src/stdlib/url.ts | 8 +- packages/codemode/test/stdlib.test.ts | 141 +++++++ packages/codemode/test/test262/manifest.json | 6 +- packages/codemode/test/test262/skipped.txt | 5 + 15 files changed, 608 insertions(+), 83 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index d646da292bd..a4be9bb4be5 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -239,7 +239,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like any function. - [x] `Promise.withResolvers()`: the same promise and resolver callables as the constructor, as a `{ promise, resolve, - reject }` object. + reject }` object. - [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators, constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields @@ -416,6 +416,20 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results. - [x] `RegExp.escape`. +## Iterator + +- [x] `Iterator.prototype.map`, `filter`, `take`, `drop`, and `flatMap` on any iterator or generator: lazy, one source + step per result, closing the source when a callback throws, on early `return()`, or when `for...of` or + destructuring finishes with it early. Once done or closed a helper stays done, and a callback that re-enters its + own helper is a `TypeError`. `take`/`drop` coerce their count and reject `NaN` or negative counts with a + `RangeError`; `flatMap` callbacks must return an iterable or iterator, not a string. +- [x] `Iterator.prototype.reduce`, `toArray`, `forEach`, `some`, `every`, and `find`, closing the source on early exit. +- [x] `Iterator.from(value)` returns iterators and generators as they are, and wraps strings, iterables, and objects + with a `next` method. `Iterator` itself is abstract: calling or constructing it is a `TypeError`. +- [x] Helpers and `Iterator.from` wrappers have `return()`; collection iterators (`array.values()`) do not, as in JS, + so an early exit from them leaves them where they were. +- [ ] `Iterator.concat`, `Iterator.zip`, and `Iterator.zipKeyed` (stage 3 proposals). + ## Map and Set - [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity. @@ -424,7 +438,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `new Set()` from synchronous iterables. - [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`. - [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()` - may return a built-in iterator or an array. + may return any iterator or an array. - [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration. - [x] Map and Set values serialize to `{}` at host/JSON boundaries. - [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, diff --git a/packages/codemode/src/interpreter/globals.ts b/packages/codemode/src/interpreter/globals.ts index 47df3f55d21..20e388702ff 100644 --- a/packages/codemode/src/interpreter/globals.ts +++ b/packages/codemode/src/interpreter/globals.ts @@ -70,6 +70,7 @@ const table: Record = { console: (ctx) => consoleGlobal(ctx), Promise: (ctx) => promiseGlobal(ctx), Symbol: (ctx) => symbolGlobal(ctx), + Iterator: (ctx) => iteratorGlobals(ctx), Number: (ctx) => numberGlobal(ctx), String: (ctx) => stringGlobal(ctx), Boolean: (ctx) => booleanGlobal(ctx), @@ -103,6 +104,5 @@ export const globalNames: ReadonlySet = new Set(Object.keys(table)) /** The immutable global bindings of every program, in declaration order. */ export const globals = (ctx: Interpreter): ReadonlyArray => { generatorGlobals(ctx) - iteratorGlobals(ctx) return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const) } diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index 907316a1a52..1ce1be234e5 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -70,6 +70,9 @@ import { Callable, define, get, + hostCursor, + IteratorObj, + type Cursor, has, hasPrototype, keys, @@ -303,6 +306,10 @@ export class Interpreter { return this.root.iterate(value) } + iterateDirect(value: Value) { + return this.root.iterateDirect(value) + } + /** Runs one host tool: arguments cross as JSON and the result comes back as program values. */ tool( run: (args: Array) => Effect.Effect, @@ -657,7 +664,7 @@ class Frame { if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) const right = yield* self.evaluateExpression(node.right) - const cursor = self.hostCursor(right) + const cursor = self.builtinCursor(right) const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined if (iterator === undefined && cursor === undefined) { throw invalidData( @@ -752,35 +759,47 @@ class Frame { }) } - iterate(value: Value, node?: AstNode) { - const cursor = this.hostCursor(value) + iterate(value: Value, node?: AstNode): Effect.Effect | undefined, unknown, R> { + const cursor = this.builtinCursor(value) if (cursor !== undefined) return Effect.succeed(cursor) - 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)), - }, + iterator === undefined ? undefined : this.customCursor(iterator, node), ) } - private hostCursor(value: Value) { + /** GetIteratorDirect: drive an iterator by its own `next`, without asking for `[Symbol.iterator]`. */ + iterateDirect(value: Value, node?: AstNode): Cursor { + if (value instanceof IteratorObj) return value.cursor as Cursor + if (!(value instanceof Obj)) { + throw typeError(`An iterator must be an object, received ${describeValue(value)}.`, node) + } + return this.customCursor( + { + iterator: value, + next: this.requireIteratorMethod(get(value, "next"), "Iterator next", node), + asynchronous: false, + }, + node, + ) + } + + private customCursor(iterator: CustomIterator, node: AstNode | undefined): Cursor { + return { + next: this.nextIteratorResult(iterator, node, false), + close: Effect.suspend(() => this.closeIterator(iterator, node, false)), + } + } + + private builtinCursor(value: Value): Cursor | undefined { + // Natives build their cursors without knowing R, like `lift` in native.ts. + if (value instanceof IteratorObj) return value.cursor as Cursor const iterator = typeof value === "string" ? value[Symbol.iterator]() : value instanceof Obj ? value.iterator(this.ctx.builtins) : undefined - if (iterator === undefined) return undefined - return { - next: Effect.sync(() => { - const step = iterator.next() - return { done: Boolean(step.done), value: step.value } - }), - close: Effect.void, - } + return iterator === undefined ? undefined : hostCursor(iterator) } private customIterator(value: Value, node: AstNode | undefined, allowAsync = true) { @@ -1831,7 +1850,7 @@ class Frame { private delegateYield(value: Value, node: AstNode): Effect.Effect { const self = this return Effect.gen(function* () { - const cursor = self.hostCursor(value) + const cursor = self.builtinCursor(value) if (cursor !== undefined) { while (true) { const step = yield* cursor.next diff --git a/packages/codemode/src/interpreter/intrinsics.ts b/packages/codemode/src/interpreter/intrinsics.ts index 9b525046631..bb1ce831ab9 100644 --- a/packages/codemode/src/interpreter/intrinsics.ts +++ b/packages/codemode/src/interpreter/intrinsics.ts @@ -35,6 +35,7 @@ const builtins = [ "TextDecoder", "Promise", "Iterator", + "IteratorHelper", "AsyncIterator", "Generator", "AsyncGenerator", @@ -95,6 +96,7 @@ export const createBuiltins = (): Builtins => { TextDecoder: plain(), Promise: plain(), Iterator: iterator, + IteratorHelper: new Obj(iterator), AsyncIterator: asyncIterator, Generator: new Obj(iterator), AsyncGenerator: new Obj(asyncIterator), diff --git a/packages/codemode/src/interpreter/objects.ts b/packages/codemode/src/interpreter/objects.ts index 32e4c32e0c2..e845778a3df 100644 --- a/packages/codemode/src/interpreter/objects.ts +++ b/packages/codemode/src/interpreter/objects.ts @@ -1,5 +1,5 @@ import type { BlockStatement, Expression, Pattern } from "acorn" -import type { Effect, Fiber } from "effect" +import { Effect, type Fiber } from "effect" import { ToolReference } from "../tool-runtime.js" import type { Builtins } from "./intrinsics.js" import { checkArrayLength } from "./limits.js" @@ -224,23 +224,42 @@ export class GeneratorObj extends Opaque { } } -/** A built-in collection iterator: live over the host collection, yielding program values. */ +/** One pull from an iterator, as `for...of` sees it. */ +export type Step = { readonly done: boolean; readonly value: Value } + +/** How the interpreter drives any iterator: pull the next step, or close it early. */ +export type Cursor = { + readonly next: Effect.Effect + readonly close: Effect.Effect +} + +/** A cursor over a host iterator; there is nothing to close. */ +export const hostCursor = (iterator: Iterator): Cursor => ({ + next: Effect.sync(() => { + const step = iterator.next() + return { done: Boolean(step.done), value: step.value } + }), + close: Effect.void, +}) + +/** A built-in iterator: a live cursor over a host collection or an iterator helper, yielding program values. */ export class IteratorObj extends Opaque { override readonly tag = "Iterator" constructor( proto: Obj, - readonly source: IteratorObject, + readonly cursor: Cursor, ) { super(proto) } override get describe() { return "an iterator" } - override iterator() { - return this.source - } } +/** A built-in iterator over a host iterator, e.g. `array.values()`. */ +export const hostIterator = (builtins: Builtins, iterator: Iterator): IteratorObj => + new IteratorObj(builtins.Iterator, hostCursor(iterator)) + /** A built-in object around a host value: data-like, so it prints as itself and crosses to extensions as a copy. */ export abstract class Wrapper extends Obj { override inspect(item: (value: Value) => string) { diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index fed90d22cd6..658469740de 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -8,7 +8,7 @@ import { hidden, Arr, GeneratorObj, - IteratorObj, + hostIterator, Obj, coerceToNumber, coerceToString, @@ -363,14 +363,14 @@ export const arrayGlobal = (ctx: Interpreter) => { (parts) => parts.join(","), ), ], - ["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())], - ["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").items.keys())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").items.values())], [ "entries", 0, (thisValue) => - new IteratorObj( - builtins.Iterator, + hostIterator( + builtins, self(thisValue, "entries") .items.entries() .map(([index, item]) => wrap([index, item])), diff --git a/packages/codemode/src/stdlib/bytes.ts b/packages/codemode/src/stdlib/bytes.ts index 3994bdee1a5..a9d60ec9f53 100644 --- a/packages/codemode/src/stdlib/bytes.ts +++ b/packages/codemode/src/stdlib/bytes.ts @@ -9,7 +9,7 @@ import { hidden, Arr, Bytes, - IteratorObj, + hostIterator, Obj, coerceToNumber, coerceToString, @@ -184,14 +184,14 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { ["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")], ["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()], ["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()], - ["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").bytes.keys())], - ["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").bytes.values())], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").bytes.keys())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").bytes.values())], [ "entries", 0, (thisValue) => - new IteratorObj( - builtins.Iterator, + hostIterator( + builtins, self(thisValue, "entries") .bytes.entries() .map(([index, byte]) => wrapAll([index, byte])), diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index f5ff48e0a1a..5890fdf70a1 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -9,7 +9,8 @@ import { hidden, Arr, coerceToString, - IteratorObj, + hostCursor, + hostIterator, MapObj, Obj, PromiseObj, @@ -177,9 +178,9 @@ export const mapGlobal = (ctx: Interpreter) => { return undefined }, ], - ["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())], - ["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())], - ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").map.keys())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").map.values())], + ["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))], [ "forEach", 1, @@ -239,10 +240,15 @@ const loadSetRecord = ( size: Math.max(Math.trunc(size), 0), has: (item: Value) => Effect.map(ctx.call(has, source, [item]), Boolean), keys: () => - Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect> => { - if (result instanceof IteratorObj) return Effect.succeed(result.source) - if (result instanceof Arr) return Effect.succeed(result.items) - throw typeError(`Set.${name} expected 'keys' to return an iterator.`) + Effect.gen(function* () { + const result = yield* ctx.call(keys, source, []) + const cursor = result instanceof Arr ? hostCursor(result.items.values()) : ctx.iterateDirect(result) + const items: Array = [] + while (true) { + const step = yield* cursor.next + if (step.done) return items + items.push(step.value) + } }), } }) @@ -360,14 +366,14 @@ export const setGlobal = (ctx: Interpreter) => { return undefined }, ], - ["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").set.values())], - ["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").set.values())], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").set.values())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").set.values())], [ "entries", 0, (thisValue) => - new IteratorObj( - builtins.Iterator, + hostIterator( + builtins, self(thisValue, "entries") .set.values() .map((item) => wrap([item, item])), diff --git a/packages/codemode/src/stdlib/headers.ts b/packages/codemode/src/stdlib/headers.ts index 7509b281466..96ed8f5216c 100644 --- a/packages/codemode/src/stdlib/headers.ts +++ b/packages/codemode/src/stdlib/headers.ts @@ -8,7 +8,7 @@ import { hidden, Arr, HeadersObj, - IteratorObj, + hostIterator, Obj, coerceToString, isRuntimeReference, @@ -106,18 +106,9 @@ export const headersGlobal = (ctx: Interpreter) => { return undefined }, ], - // Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them. - [ - "keys", - 0, - (thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "keys").headers.keys())), - ], - [ - "values", - 0, - (thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())), - ], - ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").headers.keys())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").headers.values())], + ["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))], [ "forEach", 1, diff --git a/packages/codemode/src/stdlib/iterator.ts b/packages/codemode/src/stdlib/iterator.ts index 1d52779bc3c..a312e15ef4b 100644 --- a/packages/codemode/src/stdlib/iterator.ts +++ b/packages/codemode/src/stdlib/iterator.ts @@ -1,19 +1,348 @@ -import { methods, receiver } from "../interpreter/native.js" -import { IteratorObj, record } from "../interpreter/objects.js" +import { Cause, Effect, Exit } from "effect" +import { applyCollectionCallback } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" +import { rangeError, typeError } from "../interpreter/model.js" +import { constructor, fn, methods, receiver, requiresNew } from "../interpreter/native.js" +import { + Arr, + coerceToNumber, + type Cursor, + define, + hasPrototype, + hidden, + IteratorObj, + Obj, + record, + type Step, + type Value, +} from "../interpreter/objects.js" +import { describeValue } from "../interpreter/references.js" -// Every built-in collection iterator shares the Iterator prototype; JS gives each collection its own, which is only +const finished: Step = { done: true, value: undefined } + +// Every built-in iterator and helper shares the Iterator prototype; JS gives each collection its own, which is only // observable through getPrototypeOf. -export const iteratorGlobals = (ctx: Interpreter): void => { +export const iteratorGlobals = (ctx: Interpreter) => { const builtins = ctx.builtins - methods(builtins, builtins.Iterator, [ + const proto = builtins.Iterator + const method = (name: string) => `Iterator.prototype.${name}` + + // A step that runs program code: when it throws, close the source first, as IteratorClose does. + const guarded = (source: Cursor, body: Effect.Effect) => + Effect.flatMap(Effect.exit(body), (exit) => { + if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.andThen(Effect.exit(source.close), Effect.failCause(exit.cause)) + }) + + // A lazy helper: `pull` advances it once. Once it reports done, throws, or is closed, it stays done. A callback + // that re-enters its own helper is a TypeError, as for a running generator. + const helper = (source: Cursor, pull: Effect.Effect, close = source.close) => { + let done = false + let running = false + const enter = () => { + if (running) throw typeError("Iterator helper is already running.") + running = true + } + return new IteratorObj(builtins.IteratorHelper, { + next: Effect.suspend(() => { + if (done) return Effect.succeed(finished) + enter() + return Effect.flatMap(Effect.exit(pull), (exit) => { + running = false + done = Exit.isSuccess(exit) ? exit.value.done : true + return exit + }) + }), + close: Effect.suspend(() => { + if (done) return Effect.void + enter() + done = true + return Effect.ensuring( + close, + Effect.sync(() => { + running = false + }), + ) + }), + }) + } + + // GetIteratorFlattenable: an iterable by `[Symbol.iterator]`, otherwise the object itself as an iterator. + const flattenable = (value: Value, name: string, strings: boolean) => + Effect.gen(function* () { + if (typeof value === "string" ? !strings : !(value instanceof Obj)) { + throw typeError(`${name} expects an iterable or iterator, received ${describeValue(value)}.`) + } + return (yield* ctx.iterate(value)) ?? ctx.iterateDirect(value) + }) + + const limit = (value: Value, name: string) => { + const count = Math.trunc(coerceToNumber(value)) + if (Number.isNaN(count) || count < 0) { + throw rangeError(`${method(name)} expects a non-negative count, received ${count}.`) + } + return count + } + + methods(builtins, proto, [ [ "next", 0, + (thisValue) => + Effect.map(receiver(IteratorObj, thisValue, method("next")).cursor.next, (step) => + record(builtins.Object, { value: step.value, done: step.done }), + ), + ], + [ + "map", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("map")) + let counter = 0 + return helper( + source, + Effect.gen(function* () { + const step = yield* source.next + if (step.done) return finished + return { done: false, value: yield* guarded(source, apply([step.value, counter++])) } + }), + ) + }, + ], + [ + "filter", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("filter")) + let counter = 0 + return helper( + source, + Effect.gen(function* () { + while (true) { + const step = yield* source.next + if (step.done) return finished + if (yield* guarded(source, apply([step.value, counter++]))) return step + } + }), + ) + }, + ], + [ + "take", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + let remaining = limit(args[0], "take") + return helper( + source, + Effect.gen(function* () { + if (remaining === 0) { + yield* source.close + return finished + } + remaining -= 1 + return yield* source.next + }), + ) + }, + ], + [ + "drop", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + let remaining = limit(args[0], "drop") + return helper( + source, + Effect.gen(function* () { + while (remaining > 0) { + remaining -= 1 + const step = yield* source.next + if (step.done) return finished + } + return yield* source.next + }), + ) + }, + ], + [ + "flatMap", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("flatMap")) + let counter = 0 + let inner: Cursor | undefined + return helper( + source, + Effect.gen(function* () { + while (true) { + if (inner !== undefined) { + const step = yield* guarded(source, inner.next) + if (!step.done) return step + inner = undefined + } + const step = yield* source.next + if (step.done) return finished + inner = yield* guarded( + source, + Effect.flatMap(apply([step.value, counter++]), (mapped) => + flattenable(mapped, method("flatMap"), false), + ), + ) + } + }), + Effect.suspend(() => (inner === undefined ? source.close : Effect.andThen(inner.close, source.close))), + ) + }, + ], + [ + "reduce", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("reduce")) + return Effect.gen(function* () { + let counter = 0 + let accumulator = args[1] + if (args.length < 2) { + const first = yield* source.next + if (first.done) throw typeError("Iterator.prototype.reduce of an empty iterator with no initial value.") + accumulator = first.value + counter = 1 + } + while (true) { + const step = yield* source.next + if (step.done) return accumulator + accumulator = yield* guarded(source, apply([accumulator, step.value, counter++])) + } + }) + }, + ], + [ + "toArray", + 0, (thisValue) => { - const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").source.next() - return record(builtins.Object, { value: step.value, done: Boolean(step.done) }) + const source = ctx.iterateDirect(thisValue) + return Effect.gen(function* () { + const items: Array = [] + while (true) { + const step = yield* source.next + if (step.done) return new Arr(builtins.Array, items) + items.push(step.value) + } + }) + }, + ], + [ + "forEach", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("forEach")) + return Effect.gen(function* () { + let counter = 0 + while (true) { + const step = yield* source.next + if (step.done) return undefined + yield* guarded(source, apply([step.value, counter++])) + } + }) + }, + ], + [ + "some", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("some")) + return Effect.gen(function* () { + let counter = 0 + while (true) { + const step = yield* source.next + if (step.done) return false + if (yield* guarded(source, apply([step.value, counter++]))) { + yield* source.close + return true + } + } + }) + }, + ], + [ + "every", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("every")) + return Effect.gen(function* () { + let counter = 0 + while (true) { + const step = yield* source.next + if (step.done) return true + if (!(yield* guarded(source, apply([step.value, counter++])))) { + yield* source.close + return false + } + } + }) + }, + ], + [ + "find", + 1, + (thisValue, args) => { + const source = ctx.iterateDirect(thisValue) + const apply = applyCollectionCallback(ctx, args[0], method("find")) + return Effect.gen(function* () { + let counter = 0 + while (true) { + const step = yield* source.next + if (step.done) return undefined + if (yield* guarded(source, apply([step.value, counter++]))) { + yield* source.close + return step.value + } + } + }) }, ], ]) + + // Helpers and `Iterator.from` wrappers can be closed; collection iterators, as in JS, cannot. + methods(builtins, builtins.IteratorHelper, [ + [ + "return", + 0, + (thisValue) => + Effect.map(receiver(IteratorObj, thisValue, "Iterator.prototype.return").cursor.close, () => + record(builtins.Object, { value: undefined, done: true }), + ), + ], + ]) + + const iterator = constructor(builtins, proto, { + name: "Iterator", + length: 0, + call: requiresNew("Iterator"), + construct: () => + Effect.sync(() => { + throw typeError("Iterator is abstract; use Iterator.from(...) or a built-in iterator.") + }), + }) + define( + iterator, + "from", + fn(builtins, "from", 1, (_, args) => + Effect.gen(function* () { + if (args[0] instanceof Obj && hasPrototype(args[0], proto)) return args[0] + return new IteratorObj(builtins.IteratorHelper, yield* flattenable(args[0], "Iterator.from", true)) + }), + ), + hidden, + ) + return iterator } diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index 2e0f138c0f8..84f69da4e12 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -6,7 +6,7 @@ import { define, hidden, Arr, - IteratorObj, + hostIterator, RegExpObj, record, coerceToNumber, @@ -272,11 +272,8 @@ export const stringGlobal = (ctx: Interpreter) => { define( builtins.String, IteratorSymbol, - fn( - builtins, - "[Symbol.iterator]", - 0, - (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "[Symbol.iterator]")[Symbol.iterator]()), + fn(builtins, "[Symbol.iterator]", 0, (thisValue) => + hostIterator(builtins, self(thisValue, "[Symbol.iterator]")[Symbol.iterator]()), ), hidden, ) diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index 85fbd1574d5..b17494ecdef 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -8,7 +8,7 @@ import { get, hidden, Arr, - IteratorObj, + hostIterator, Obj, URLObj, URLSearchParamsObj, @@ -269,9 +269,9 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { return undefined }, ], - ["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").params.keys())], - ["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").params.values())], - ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], + ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").params.keys())], + ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").params.values())], + ["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))], ["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()], [ "forEach", diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index eba96d54c31..a09f606ca5e 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1165,6 +1165,147 @@ describe("TextEncoder and TextDecoder", () => { }) }) +describe("Iterator helpers", () => { + test("lazy helpers chain over collection iterators and generators, one source step per result", async () => { + expect( + await value(` + const pulled = [] + function* naturals() { let n = 0; while (true) { pulled.push(n); yield n++ } } + const squares = naturals().map((n) => n * n).filter((n) => n % 2 === 0).drop(1).take(3) + const first = squares.next() + return { + first, + rest: squares.toArray(), + after: squares.next(), + pulled, + values: new Map([["a", 1], ["b", 2]]).values().map((v, i) => v * 10 + i).toArray(), + flat: [1, 2].values().flatMap((n) => [n, [n]]).toArray(), + entries: [...new Map([[1, 2]]).entries().map(([k, v]) => k + v)], + } + `), + ).toEqual({ + first: { value: 4, done: false }, + rest: [16, 36], + after: { done: true }, + pulled: [0, 1, 2, 3, 4, 5, 6], + values: [10, 21], + flat: [1, [1], 2, [2]], + entries: [3], + }) + }) + + test("eager helpers consume the source and close it on early exit", async () => { + expect( + await value(` + const log = [] + function* g() { try { yield 1; yield 2; yield 3 } finally { log.push("closed") } } + const seen = [] + g().forEach((v, i) => seen.push([v, i])) + return { + seen, + sum: g().reduce((a, b) => a + b), + sumFrom: g().reduce((a, b) => a + b, 10), + some: g().some((v) => v === 2), + every: g().every((v) => v < 2), + find: g().find((v) => v > 1), + missing: g().find((v) => v > 5), + log, + } + `), + ).toEqual({ + seen: [ + [1, 0], + [2, 1], + [3, 2], + ], + sum: 6, + sumFrom: 16, + some: true, + every: false, + find: 2, + log: ["closed", "closed", "closed", "closed", "closed", "closed", "closed"], + }) + }) + + test("a helper closes its source when a callback throws, on return(), and on early for...of exit", async () => { + expect( + await value(` + const log = [] + function* g(name) { try { yield 1; yield 2 } finally { log.push(name) } } + const throwing = g("throw").map((v) => { if (v === 2) throw new Error("boom"); return v }) + throwing.next() + let message + try { throwing.next() } catch (error) { message = error.message } + const returned = g("return").map((v) => v) + returned.next() + const closed = returned.return() + for (const v of g("loop").filter((v) => true)) break + return { message, afterThrow: throwing.next(), closed, afterReturn: returned.next(), log } + `), + ).toEqual({ + message: "boom", + afterThrow: { done: true }, + closed: { done: true }, + afterReturn: { done: true }, + log: ["throw", "return", "loop"], + }) + }) + + test("collection iterators have no return() and continue after an early exit, as in JS", async () => { + expect( + await value(` + const it = [1, 2, 3].values() + const found = it.some((v) => v === 2) + return [typeof it.return, found, it.next().value, typeof it.map(x => x).return] + `), + ).toEqual(["undefined", true, 3, "function"]) + }) + + test("Iterator.from and the abstract Iterator constructor", async () => { + expect( + await value(` + const it = [1].values() + let n = 0 + const errors = [] + for (const attempt of [() => Iterator(), () => new Iterator(), () => Iterator.from(5)]) { + try { attempt() } catch (error) { errors.push(error.name) } + } + return [ + Iterator.from(it) === it, + Iterator.from("ab").toArray(), + Iterator.from([1, 2]).map((v) => v * 2).toArray(), + Iterator.from({ next: () => ({ done: n > 1, value: n++ }) }).toArray(), + it instanceof Iterator, + errors, + ] + `), + ).toEqual([true, ["a", "b"], [2, 4], [0, 1], true, ["TypeError", "TypeError", "TypeError"]]) + }) + + test("argument validation", async () => { + const cases: Array<[string, string]> = [ + ["[1].values().map(1)", "Iterator.prototype.map expects a function callback."], + ["[1].values().take(-1)", "Iterator.prototype.take expects a non-negative count, received -1."], + ["[1].values().drop()", "Iterator.prototype.drop expects a non-negative count, received NaN."], + [ + "[1].values().flatMap((v) => 'ab').toArray()", + "Iterator.prototype.flatMap expects an iterable or iterator, received a string.", + ], + ["[].values().reduce((a, b) => a)", "Iterator.prototype.reduce of an empty iterator with no initial value."], + ] + for (const [code, message] of cases) { + expect((await error(`return ${code}`)).message).toContain(message) + } + expect( + await error(` + function* g() { while (true) yield 1 } + const it = g().map(() => it.next()) + return it.next() + `), + ).toMatchObject({ message: expect.stringContaining("Iterator helper is already running.") }) + }) +}) + describe("built-in iterators", () => { test("keys/values/entries and [Symbol.iterator] step with next() and stay live", async () => { expect( diff --git a/packages/codemode/test/test262/manifest.json b/packages/codemode/test/test262/manifest.json index 5a2ae1055ea..3caff368cd9 100644 --- a/packages/codemode/test/test262/manifest.json +++ b/packages/codemode/test/test262/manifest.json @@ -1,6 +1,6 @@ { "revision": "250f204f23a9249ff204be2baec29600faae7b75", - "directories": ["built-ins/Array/prototype", "language/statements"], + "directories": ["built-ins/Array/prototype", "language/statements", "built-ins/Iterator"], "harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"], "flags": ["module", "raw", "noStrict"], "boundaries": { @@ -101,6 +101,8 @@ "legacy-regexp", "__proto__", "__getter__", - "__setter__" + "__setter__", + "iterator-sequencing", + "joint-iteration" ] } diff --git a/packages/codemode/test/test262/skipped.txt b/packages/codemode/test/test262/skipped.txt index b47513a29d4..a680ff1aca5 100644 --- a/packages/codemode/test/test262/skipped.txt +++ b/packages/codemode/test/test262/skipped.txt @@ -236,6 +236,11 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A2_T3.js # Array.prototype.unshift built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift called on incompatible receiver a data object. built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object. built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined +built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function. +built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError +built-ins/Iterator/prototype/drop/limit-tonumber.js # Iterator.prototype.drop expects a non-negative count, received NaN. +built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError +built-ins/Iterator/prototype/take/limit-tonumber.js # Iterator.prototype.take expects a non-negative count, received NaN. language/statements/async-generator/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all language/statements/async-generator/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all language/statements/async-generator/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all