From 1316576720be2e0e3bc3e7e38bf8d18e235129ff Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:03:16 -0500 Subject: [PATCH] refactor(codemode): type program values and let built-ins answer for themselves (#50191) --- packages/codemode/AGENTS.md | 2 + packages/codemode/interpreter-support.md | 3 +- packages/codemode/src/data.ts | 11 +- packages/codemode/src/interpreter/callback.ts | 21 +- packages/codemode/src/interpreter/errors.ts | 25 +- packages/codemode/src/interpreter/execute.ts | 3 +- .../codemode/src/interpreter/extensions.ts | 54 +--- .../codemode/src/interpreter/generators.ts | 6 +- packages/codemode/src/interpreter/globals.ts | 5 +- .../codemode/src/interpreter/interpreter.ts | 247 +++++++-------- .../codemode/src/interpreter/intrinsics.ts | 4 +- packages/codemode/src/interpreter/model.ts | 13 +- packages/codemode/src/interpreter/native.ts | 14 +- packages/codemode/src/interpreter/objects.ts | 286 +++++++++++++++--- packages/codemode/src/interpreter/promises.ts | 38 +-- .../codemode/src/interpreter/references.ts | 81 ++--- packages/codemode/src/interpreter/scope.ts | 9 +- packages/codemode/src/stdlib/array.ts | 56 ++-- packages/codemode/src/stdlib/bytes.ts | 30 +- packages/codemode/src/stdlib/collections.ts | 54 ++-- packages/codemode/src/stdlib/console.ts | 73 +---- packages/codemode/src/stdlib/date.ts | 7 +- packages/codemode/src/stdlib/headers.ts | 45 +-- packages/codemode/src/stdlib/iterator.ts | 2 +- packages/codemode/src/stdlib/json.ts | 8 +- packages/codemode/src/stdlib/math.ts | 4 +- packages/codemode/src/stdlib/number.ts | 9 +- packages/codemode/src/stdlib/object.ts | 27 +- packages/codemode/src/stdlib/regexp.ts | 19 +- packages/codemode/src/stdlib/string.ts | 65 ++-- packages/codemode/src/stdlib/url.ts | 39 +-- packages/codemode/src/stdlib/value.ts | 64 +--- packages/codemode/src/stdlib/web.ts | 3 +- .../codemode/test/for-await-test262.test.ts | 2 +- .../codemode/test/generator-test262.test.ts | 2 +- packages/codemode/test/stdlib.test.ts | 26 ++ packages/codemode/test/test262/run.ts | 25 +- 37 files changed, 711 insertions(+), 671 deletions(-) diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md index 368ed6bb788..5fbfecb96a2 100644 --- a/packages/codemode/AGENTS.md +++ b/packages/codemode/AGENTS.md @@ -6,6 +6,8 @@ - Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens. - State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary. - When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. +- Program values are `Value` (`src/interpreter/objects.ts`); host values are `unknown` and are copied in at the boundaries (`fromHost`, `fromJson`). Do not widen program-facing signatures back to `unknown`. +- A built-in kind of object is one `Obj` subclass (`Wrapper` for host-backed data such as Date or Map, `Opaque` for machinery such as functions and promises) that overrides `tag`, `toString`, `toPrimitive`, `inspect`, `toHost`, and `iterator` as needed. Do not add `instanceof` ladders over the built-in classes elsewhere; ask the object. ## OpenAPI diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index f339b2520f2..48fc15032cd 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -275,7 +275,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `Object.is` for supported data values. - [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion and plain-object results. -- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's +- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`, `"[object Map]"`, `"[object Promise]"`, and so + on for every built-in kind, as JS reports through `Symbol.toStringTag`), `toLocaleString` (calls the value's `toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`. ## Arrays diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 88ac2c61f05..e15e551fac2 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -15,6 +15,7 @@ import { Obj, PromiseObj, record, + type Value, SetObj, URLSearchParamsObj, HeadersObj, @@ -23,14 +24,14 @@ import { typeofValue } from "./interpreter/references.js" export type Json = Schema.Json -type Replacer = (args: Array) => Effect.Effect +type Replacer = (args: Array) => Effect.Effect /** * What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and * `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS * so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws. */ -export const toJson = (ctx: Interpreter, value: unknown, replacer?: Replacer) => +export const toJson = (ctx: Interpreter, value: Value, replacer?: Replacer) => walk(ctx, value, replacer, false) /** @@ -38,11 +39,11 @@ export const toJson = (ctx: Interpreter, value: unknown, replacer?: Replac * awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as * text first, and a `__proto__` key is dropped so host code can never receive one. */ -export const toBoundary = (ctx: Interpreter, value: unknown) => walk(ctx, value, undefined, true) +export const toBoundary = (ctx: Interpreter, value: Value) => walk(ctx, value, undefined, true) const walk = ( ctx: Interpreter, - value: unknown, + value: Value, replacer: Replacer | undefined, boundary: boolean, ): Effect.Effect => { @@ -104,7 +105,7 @@ const walk = ( } /** Host JSON as program values: objects and arrays are copied, primitives pass through. */ -export const fromJson = (ctx: Interpreter, value: unknown): unknown => { +export const fromJson = (ctx: Interpreter, value: Json | undefined): Value => { if (value === null || typeof value !== "object") return value if (Array.isArray(value)) return new Arr( diff --git a/packages/codemode/src/interpreter/callback.ts b/packages/codemode/src/interpreter/callback.ts index 15c149e5776..051dbfdf1a6 100644 --- a/packages/codemode/src/interpreter/callback.ts +++ b/packages/codemode/src/interpreter/callback.ts @@ -1,13 +1,12 @@ import { Effect, Exit } from "effect" -import { coerceToNumber, coerceToString } from "../stdlib/value.js" import type { Interpreter } from "./interpreter.js" import { primitivePrototype } from "./intrinsics.js" import { typeError } from "./model.js" -import { Callable, get, Native, DateObj, Obj } from "./objects.js" +import { Callable, get, Native, DateObj, Obj, coerceToNumber, coerceToString, type Value } from "./objects.js" import { typeofValue } from "./references.js" export type IteratorCursor = { - readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R> + readonly next: Effect.Effect<{ readonly done: boolean; readonly value: Value }, unknown, R> readonly close: Effect.Effect } @@ -27,9 +26,9 @@ export const preserveConsumerError = ( */ export const toPrimitive = ( ctx: Interpreter, - value: unknown, + value: Value, hint: "number" | "string" | "default", -): Effect.Effect => { +): Effect.Effect => { if (!(value instanceof Obj)) return Effect.succeed(value) const asString = hint === "string" || (hint === "default" && value instanceof DateObj) const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"] @@ -45,30 +44,30 @@ export const toPrimitive = ( } /** Invoke(value, name): calls the method the value would find through its prototype. */ -export const invoke = (ctx: Interpreter, value: unknown, name: string, label: string) => { +export const invoke = (ctx: Interpreter, value: Value, name: string, label: string) => { const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value) if (target === undefined) throw typeError(`${label} called on null or undefined.`) return ctx.call(get(target, name), value, []) } -export const toPrimitiveString = (ctx: Interpreter, value: unknown) => +export const toPrimitiveString = (ctx: Interpreter, value: Value) => Effect.map(toPrimitive(ctx, value, "string"), coerceToString) -export const toPrimitiveNumber = (ctx: Interpreter, value: unknown) => +export const toPrimitiveNumber = (ctx: Interpreter, value: Value) => Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber) // The single acceptance list for callbacks: collections, sort, string replacers, // Array.from mappers, and promise reactions all admit exactly these callables. // Admission means dispatchable, not necessarily invocable: new-requiring // constructors pass the gate and throw a TypeError on call, like JS. -export const isSupportedCallback = (value: unknown): value is Callable => +export const isSupportedCallback = (value: Value): value is Callable => value instanceof Callable && !(value instanceof Native && !value.callback) export const applyCollectionCallback = ( ctx: Interpreter, - callback: unknown, + callback: Value, name: string, -): ((args: Array) => Effect.Effect) => { +): ((args: Array) => Effect.Effect) => { if (!isSupportedCallback(callback)) { if (typeofValue(callback) === "function") { throw typeError( diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 85744e87e6d..075422b2edf 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -6,10 +6,21 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type import { containsRuntimeReference } from "./references.js" import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js" import { constructor, methods, prototypeFrom, receiver } from "./native.js" -import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js" +import { + type Callable, + define, + get, + has, + hidden, + type Native, + Arr, + ErrorObj, + Obj, + coerceToString, + type Value, +} from "./objects.js" import type { Interpreter } from "./interpreter.js" import { formatValue } from "../stdlib/console.js" -import { coerceToString } from "../stdlib/value.js" export const normalizeError = (error: unknown): Diagnostic => { if (error instanceof PendingThrow) { @@ -86,7 +97,7 @@ export const locate = (error: unknown, node?: AstNode): unknown => { } /** The program value a handler receives for a failure; one failure always yields the same value. */ -export const materialize = (ctx: Interpreter, thrown: unknown): unknown => { +export const materialize = (ctx: Interpreter, thrown: unknown): Value => { if (thrown instanceof Throw) return thrown.value const builtins = ctx.builtins if (thrown instanceof PendingThrow) { @@ -113,7 +124,7 @@ const errorToString = (self: Obj): string => { export const createAggregateErrorValue = ( ctx: Interpreter, - errors: Array, + errors: Array, message: string, proto: Obj = ctx.builtins.AggregateError, ) => { @@ -124,13 +135,13 @@ export const createAggregateErrorValue = ( const constructAggregateErrorValue = ( ctx: Interpreter, - args: Array, + args: Array, proto: Obj, ): Effect.Effect => Effect.gen(function* () { const cursor = yield* ctx.iterate(args[0]) if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.") - const errors: Array = [] + const errors: Array = [] while (true) { const step = yield* cursor.next if (step.done) { @@ -144,7 +155,7 @@ const constructAggregateErrorValue = ( export const errorGlobal = (type: ErrorType, ctx: Interpreter) => { const builtins = ctx.builtins const prototype = builtins[type] - const construct = (args: Array, newTarget: Callable) => { + const construct = (args: Array, newTarget: Callable) => { const proto = prototypeFrom(newTarget, prototype) const created = type === "AggregateError" diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index d08739cf664..c1fdab13c6c 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -4,6 +4,7 @@ import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../ import { toBoundary } from "../data.js" import { ToolRuntime } from "../tool-runtime.js" import { normalizeError } from "./errors.js" +import type { Value } from "./objects.js" import { createBuiltins } from "./intrinsics.js" import { Pending } from "./promises.js" import { Interpreter } from "./interpreter.js" @@ -13,7 +14,7 @@ export const executeProgram = ( prepared: ToolRuntime.Prepared, limits: ResolvedExecutionLimits, hooks: ToolRuntime.Hooks, - globals?: (ctx: Interpreter) => ReadonlyArray, + globals?: (ctx: Interpreter) => ReadonlyArray, ): Effect.Effect => { if (code.trim().length === 0) { return Effect.succeed({ diff --git a/packages/codemode/src/interpreter/extensions.ts b/packages/codemode/src/interpreter/extensions.ts index 2ab95f8f429..530b4575c86 100644 --- a/packages/codemode/src/interpreter/extensions.ts +++ b/packages/codemode/src/interpreter/extensions.ts @@ -1,6 +1,5 @@ import { Effect } from "effect" import type { Extension } from "../extension.js" -import { coerceToString } from "../stdlib/value.js" import { type ExtensionInvocation, hooked } from "../tool-runtime.js" import type { Interpreter } from "./interpreter.js" import { createErrorValue, isErrorType } from "./intrinsics.js" @@ -8,9 +7,7 @@ import { MAX_VALUE_DEPTH } from "./limits.js" import { PendingThrow, Throw, typeError } from "./model.js" import { fn } from "./native.js" import { - Callable, define, - entries, get, has, hidden, @@ -19,18 +16,17 @@ import { Bytes, DateObj, ErrorObj, - GeneratorObj, - IteratorObj, + HeadersObj, MapObj, Obj, - PromiseObj, RegExpObj, SetObj, URLObj, URLSearchParamsObj, - HeadersObj, + coerceToString, + type Value, } from "./objects.js" -import { describeValue } from "./references.js" +import { describeValue, isOpaque } from "./references.js" /** * The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and @@ -40,35 +36,18 @@ import { describeValue } from "./references.js" export const extensionGlobals = ( ctx: Interpreter, extensions: ReadonlyArray, -): ReadonlyArray => { +): ReadonlyArray => { const builtins = ctx.builtins - const toHost = (value: unknown, label: string, depth = 0, seen = new Set()): unknown => { + const toHost = (value: Value, label: string, depth = 0, seen = new Set()): unknown => { if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) - if (value === null || typeof value !== "object") { - if (isPrimitive(value)) return value - throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`) - } - if (value instanceof Bytes) return new Uint8Array(value.bytes) - if (value instanceof DateObj) return new Date(value.time) - if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags) - if (value instanceof URLObj) return new URL(value.url.href) - if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params) - if (value instanceof HeadersObj) return new Headers(value.headers) - const next = (item: unknown) => toHost(item, label, depth + 1, seen) - if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)])) - if (value instanceof SetObj) return new Set([...value.set].map(next)) - if ( - !(value instanceof Obj) || - value instanceof Callable || - value instanceof GeneratorObj || - value instanceof IteratorObj || - value instanceof PromiseObj - ) { + if (isPrimitive(value)) return value + if (!(value instanceof Obj) || isOpaque(value)) { throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`) } if (seen.has(value)) throw typeError(`${label} contains a circular value.`) seen.add(value) + const next = (item: Value) => toHost(item, label, depth + 1, seen) if (value instanceof ErrorObj) { const name = coerceToString(get(value, "name")) const message = get(value, "message") @@ -89,19 +68,12 @@ export const extensionGlobals = ( seen.delete(value) return copied } - const copied = - value instanceof Arr - ? value.items.map(next) - : Object.fromEntries( - entries(value) - .filter(([key]) => key !== "__proto__") - .map(([key, item]) => [key, next(item)]), - ) + const copied = value.toHost(next) seen.delete(value) return copied } - const fromHost = (value: unknown, label: string, depth = 0, seen = new Set()): unknown => { + const fromHost = (value: unknown, label: string, depth = 0, seen = new Set()): Value => { if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) if (isPrimitive(value)) return value if (typeof value === "function") return wrap(value, label) @@ -200,7 +172,7 @@ export const extensionGlobals = ( */ const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"]) const left = Symbol("left behind") -const crossing = (convert: () => unknown): unknown => { +const crossing = (convert: () => T): T | typeof left => { try { return convert() } catch (reason) { @@ -219,7 +191,7 @@ const hostErrors = new Map([ ]) // The primitives the interpreter operates on; symbols and BigInts are not among them. -const isPrimitive = (value: unknown): boolean => +const isPrimitive = (value: unknown): value is string | number | boolean | null | undefined => value === null || value === undefined || typeof value === "string" || diff --git a/packages/codemode/src/interpreter/generators.ts b/packages/codemode/src/interpreter/generators.ts index d33b164ee6a..fa6045fe110 100644 --- a/packages/codemode/src/interpreter/generators.ts +++ b/packages/codemode/src/interpreter/generators.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { fn, type Method, methods, receiver } from "./native.js" import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js" -import { define, hidden, GeneratorObj } from "./objects.js" +import { define, hidden, GeneratorObj, type Value } from "./objects.js" import type { Interpreter } from "./interpreter.js" /** `next`/`return`/`throw` on the generator prototypes; async generators answer with promises. */ @@ -13,9 +13,9 @@ export const generatorGlobals = (ctx: Interpreter): void => { const request = (kind: GeneratorRequestKind): Method => [ kind, 1, - (thisValue: unknown, args: Array) => { + (thisValue: Value, args: Array) => { const generator = receiver(GeneratorObj, thisValue, `${label}.prototype.${kind}`) - const requested = generator.request(kind, args[0]) as Effect.Effect + const requested = generator.request(kind, args[0]) as Effect.Effect return generator.asynchronous ? ctx.pending.create(requested) : requested }, ] diff --git a/packages/codemode/src/interpreter/globals.ts b/packages/codemode/src/interpreter/globals.ts index 85d7fa248bd..47df3f55d21 100644 --- a/packages/codemode/src/interpreter/globals.ts +++ b/packages/codemode/src/interpreter/globals.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import type { Value } from "./objects.js" import { arrayGlobal } from "../stdlib/array.js" import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js" import { mapGlobal, setGlobal } from "../stdlib/collections.js" @@ -51,7 +52,7 @@ const symbolGlobal = (ctx: Interpreter) => { return symbol } -type Factory = (ctx: Interpreter) => unknown +type Factory = (ctx: Interpreter) => Value // A table rather than a list so the names are known before any runtime exists. const table: Record = { @@ -100,7 +101,7 @@ const table: Record = { 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 => { +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 6eb7c99f318..907316a1a52 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -32,6 +32,7 @@ import type { Statement, Super, SwitchStatement, + Literal, TemplateLiteral, ThrowStatement, TryStatement, @@ -75,20 +76,16 @@ import { Native, parseArrayIndex, Arr, - Bytes, - DateObj, Fn, GeneratorObj, - IteratorObj, - MapObj, Obj, PromiseObj, - SetObj, - URLSearchParamsObj, - HeadersObj, record, remove, set, + coerceToNumber, + coerceToString, + type Value, } from "./objects.js" import { preserveConsumerError } from "./callback.js" import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js" @@ -96,7 +93,7 @@ import { containsOpaqueReference, describeValue, rejectCircularInsertion, typeof import { ScopeStack } from "./scope.js" import { constructRegExp } from "../stdlib/regexp.js" import { enumerableSource } from "../stdlib/object.js" -import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js" +import { compoundOperators } from "../stdlib/value.js" // What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating. // Unlabelled break ends this loop; a label the loop does not carry propagates outward. @@ -132,7 +129,14 @@ const calleeDescription = (node: Expression | Super | undefined): string | undef } // OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`. -const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { +// acorn types every literal as possibly a BigInt or RegExp; regex literals become RegExp objects before this is asked. +const literal = (node: Literal): Value => { + if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node) + if (node.value instanceof RegExp) throw unsupportedSyntax("RegExpLiteral", node) + return node.value +} + +const instanceofValue = (lhs: Value, rhs: Value, node: AstNode): boolean => { if (!(rhs instanceof Callable)) { throw typeError("The right-hand side of 'instanceof' is not callable.", node) } @@ -229,7 +233,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for... type CustomIterator = { iterator: Obj - next: unknown + next: Value asynchronous: boolean } @@ -237,13 +241,13 @@ type CustomIterator = { type MemberReference = { target: Obj key: PropertyKey - receiver: unknown + receiver: Value } type GeneratorRequest = { kind: GeneratorRequestKind - value: unknown - response: Deferred.Deferred + value: Value + response: Deferred.Deferred } type GeneratorState = { @@ -269,7 +273,7 @@ export class Interpreter { readonly pending: Pending readonly builtins: Builtins readonly logs?: Array - readonly globals?: (ctx: Interpreter) => ReadonlyArray + readonly globals?: (ctx: Interpreter) => ReadonlyArray }) { this.tools = options.tools this.pending = options.pending @@ -283,27 +287,27 @@ export class Interpreter { } } - run(program: Program): Effect.Effect { + run(program: Program): Effect.Effect { return this.root.run(program) } - call(callable: unknown, thisValue: unknown, args: Array): Effect.Effect { + call(callable: Value, thisValue: Value, args: Array): Effect.Effect { return this.root.call(callable, thisValue, args) } - await(promise: PromiseObj): Effect.Effect { + await(promise: PromiseObj): Effect.Effect { return this.root.await(promise) } - iterate(value: unknown) { + iterate(value: Value) { return this.root.iterate(value) } /** Runs one host tool: arguments cross as JSON and the result comes back as program values. */ tool( run: (args: Array) => Effect.Effect, - args: Array, - ): Effect.Effect { + args: Array, + ): Effect.Effect { const ctx = this return Effect.gen(function* () { const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg)) @@ -326,7 +330,7 @@ class Frame { private depth = 0, ) {} - run(program: Program): Effect.Effect { + run(program: Program): Effect.Effect { const self = this // Keep top-level declarations separate so they can shadow builtins. this.scopes.push() @@ -334,7 +338,7 @@ class Frame { self.predeclareLexical(program.body) self.hoistFunctions(program.body) self.hoistVars(program.body) - let value: unknown = undefined + let value: Value = undefined for (const [index, statement] of program.body.entries()) { if (index === program.body.length - 1 && statement.type === "ExpressionStatement") { value = yield* self.evaluateExpression(statement.expression) @@ -359,15 +363,12 @@ class Frame { } // Fork at the call site so admission and hooks occur when the call is made. - private createToolCallPromise( - path: ReadonlyArray, - args: Array, - ): Effect.Effect { + private createToolCallPromise(path: ReadonlyArray, args: Array): Effect.Effect { return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args)) } // Fiber exits make settlement idempotent; yielding prevents inline continuation. - await(promise: PromiseObj): Effect.Effect { + await(promise: PromiseObj): Effect.Effect { const pending = this.ctx.pending return Effect.suspend(() => { pending.markObserved(promise) @@ -458,7 +459,7 @@ class Frame { } // NamedEvaluation: an anonymous function definition takes the name of what it is assigned to. - private evaluateNamed(node: Expression, name: string): Effect.Effect { + private evaluateNamed(node: Expression, name: string): Effect.Effect { if (node.type === "ArrowFunctionExpression" || (node.type === "FunctionExpression" && !node.id)) { return Effect.sync(() => this.createFunction(node, name)) } @@ -660,7 +661,7 @@ class Frame { const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined if (iterator === undefined && cursor === undefined) { throw invalidData( - `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`, + `${awaiting ? "for await...of" : "for...of"} requires an iterable value, received ${describeValue(right)}.`, node, ) } @@ -676,7 +677,7 @@ class Frame { } const assignment = left.type === "VariableDeclaration" ? undefined : left - const evaluateBody = (value: unknown) => + const evaluateBody = (value: Value) => Effect.gen(function* () { if (declared?.lexical) { self.scopes.push() @@ -725,7 +726,7 @@ class Frame { ) } - private awaitValue(value: unknown): Effect.Effect { + private awaitValue(value: Value): Effect.Effect { return Effect.flatMap(resolvePromise(this.ctx, value), (promise) => Effect.ensuring( this.await(promise), @@ -736,10 +737,10 @@ class Frame { private awaitAsyncFromSyncValue( iterator: CustomIterator, - value: unknown, + value: Value, node: AstNode | undefined, closeOnRejection: boolean, - ): Effect.Effect { + ): Effect.Effect { const self = this return Effect.gen(function* () { const settled = yield* Effect.exit(self.awaitValue(value)) @@ -751,7 +752,7 @@ class Frame { }) } - iterate(value: unknown, node?: AstNode) { + iterate(value: Value, node?: AstNode) { const cursor = this.hostCursor(value) if (cursor !== undefined) return Effect.succeed(cursor) const self = this @@ -765,40 +766,24 @@ class Frame { ) } - private hostCursor(value: unknown) { + private hostCursor(value: Value) { const iterator = - value instanceof Arr - ? value.items[Symbol.iterator]() - : typeof value === "string" - ? value[Symbol.iterator]() - : value instanceof MapObj - ? value.map.entries() - : value instanceof SetObj - ? value.set.values() - : value instanceof URLSearchParamsObj - ? value.params.entries() - : value instanceof HeadersObj - ? value.headers.entries() - : value instanceof Bytes - ? value.bytes.values() - : value instanceof IteratorObj - ? value.iterator - : undefined + typeof value === "string" + ? value[Symbol.iterator]() + : value instanceof Obj + ? value.iterator(this.ctx.builtins) + : undefined if (iterator === undefined) return undefined - const proto = this.ctx.builtins.Array return { next: Effect.sync(() => { const step = iterator.next() - return { - done: Boolean(step.done), - value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value, - } + return { done: Boolean(step.done), value: step.value } }), close: Effect.void, } } - private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) { + private customIterator(value: Value, node: AstNode | undefined, allowAsync = true) { if (!(value instanceof Obj)) return Effect.undefined const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined const method = asyncMethod ?? get(value, IteratorSymbol) @@ -888,18 +873,18 @@ class Frame { }) } - private requireIteratorObject(value: unknown, context: string, node?: AstNode): Obj { + private requireIteratorObject(value: Value, context: string, node?: AstNode): Obj { if (value instanceof Obj) return value throw typeError(`${context} must be an object.`, node) } - private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown { + private requireIteratorMethod(value: Value, context: string, node?: AstNode): Value { if (typeofValue(value) === "function") return value throw typeError(`${context} must be a function.`, node) } // for...in over null/undefined iterates nothing, like JS. - private enumerableKeys(value: unknown, node: AstNode): Array { + private enumerableKeys(value: Value, node: AstNode): Array { if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)] if (value === null || value === undefined) return [] return keys(enumerableSource(this.ctx, "for...in", value, node)) @@ -1063,7 +1048,7 @@ class Frame { private declarePattern( pattern: Pattern, - value: unknown, + value: Value, mutable: boolean, node: AstNode, initialize = false, @@ -1123,7 +1108,7 @@ class Frame { }) } - private assignPattern(pattern: Pattern, value: unknown, node: AstNode): Effect.Effect { + private assignPattern(pattern: Pattern, value: Value, node: AstNode): Effect.Effect { const self = this return Effect.gen(function* () { if (pattern.type === "Identifier") { @@ -1175,7 +1160,7 @@ class Frame { }) } - private evaluateDefault(pattern: AssignmentPattern): Effect.Effect { + private evaluateDefault(pattern: AssignmentPattern): Effect.Effect { return pattern.left.type === "Identifier" ? this.evaluateNamed(pattern.right, pattern.left.name) : this.evaluateExpression(pattern.right) @@ -1183,8 +1168,8 @@ class Frame { private destructureArrayPattern( pattern: ArrayPattern, - value: unknown, - consume: (target: Pattern, value: unknown, context: AstNode) => Effect.Effect, + value: Value, + consume: (target: Pattern, value: Value, context: AstNode) => Effect.Effect, ): Effect.Effect { const self = this return Effect.gen(function* () { @@ -1208,7 +1193,7 @@ class Frame { done = step.done if (element === null) continue if (element.type === "RestElement") { - const rest: Array = [] + const rest: Array = [] if (!step.done) rest.push(step.value) while (!done) { const next = yield* cursor.next @@ -1238,13 +1223,12 @@ class Frame { throw unsupportedSyntax(keyNode.type, keyNode) } - private evaluateExpression(node: Expression): Effect.Effect { + private evaluateExpression(node: Expression): Effect.Effect { switch (node.type) { case "Literal": { const regex = node.regex if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags])) - if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node) - return Effect.succeed(node.value) + return Effect.succeed(literal(node)) } case "Identifier": return Effect.sync(() => this.scopes.get(node.name, node)) @@ -1259,7 +1243,7 @@ class Frame { case "SequenceExpression": { const self = this return Effect.gen(function* () { - let result: unknown + let result: Value for (const expression of node.expressions) { result = yield* self.evaluateExpression(expression) } @@ -1300,7 +1284,7 @@ class Frame { } } - private evaluateNewExpression(node: NewExpression): Effect.Effect { + private evaluateNewExpression(node: NewExpression): Effect.Effect { const self = this return Effect.gen(function* () { const callee = yield* self.evaluateExpression(node.callee) @@ -1324,7 +1308,7 @@ class Frame { }) } - private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect { + private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect { const operator = node.operator const left = node.left if (left.type === "PrivateIdentifier") throw unsupportedSyntax(left.type, left) @@ -1337,7 +1321,7 @@ class Frame { }) } - private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value { if (operator === "===") return lhs === rhs if (operator === "!==") return lhs !== rhs if (operator === "in" && rhs instanceof Obj && !containsOpaqueReference(lhs)) { @@ -1346,14 +1330,9 @@ class Frame { if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw invalidData("Binary operators require data values.", node) } - // Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects. - // Dates use their default string hint for addition and loose equality, and epoch time elsewhere. - const coerceOperand = (operand: unknown): unknown => { - if (operand instanceof DateObj) { - return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time - } - return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand - } + // Addition and loose equality use the default hint; every other operator asks for a number. + const hint = operator === "+" || operator === "==" || operator === "!=" ? "default" : "number" + const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand) const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" const l = coerceOperand(lhs) const r = coerceOperand(rhs) @@ -1407,7 +1386,7 @@ class Frame { } } - private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect { + private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect { const operator = node.operator return Effect.flatMap(this.evaluateExpression(node.left), (left) => { if (operator === "&&") return left ? this.evaluateExpression(node.right) : Effect.succeed(left) @@ -1418,7 +1397,7 @@ class Frame { }) } - private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect { + private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect { const operator = node.operator const argument = node.argument if (operator === "delete") return this.evaluateDeleteExpression(argument) @@ -1433,13 +1412,8 @@ class Frame { if (containsOpaqueReference(value)) { throw invalidData("Unary operators require data values.", node) } - const operand = - value instanceof DateObj - ? value.time - : value !== null && typeof value === "object" - ? coerceToString(value) - : value - let result: unknown + const operand = value instanceof Obj ? value.toPrimitive("number") : value + let result: Value switch (operator) { case "+": result = +(operand as number) @@ -1457,7 +1431,7 @@ class Frame { }) } - private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect { + private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect { const left = node.left const operator = node.operator const self = this @@ -1497,9 +1471,9 @@ class Frame { node: AssignmentExpression, left: Pattern, operator: string, - ): Effect.Effect { + ): Effect.Effect { const self = this - const shouldAssign = (current: unknown): boolean => + const shouldAssign = (current: Value): boolean => operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) if (left.type === "Identifier") { const name = left.name @@ -1524,7 +1498,7 @@ class Frame { throw typeError("Assignment target must be an Identifier or MemberExpression.", left) } - private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect { + private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect { const operator = node.operator const argument = node.argument const prefix = node.prefix @@ -1537,7 +1511,7 @@ class Frame { // CodeMode numeric coercion, not host Number(): null-prototype data objects would make // the host throw during ToPrimitive, and opaque runtime references must reject clearly. - const operand = (current: unknown): number => { + const operand = (current: Value): number => { if (containsOpaqueReference(current)) { throw invalidData(`'${operator}' requires a data value.`, argument) } @@ -1566,7 +1540,7 @@ class Frame { } // EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined. - private evaluateCallExpression(node: CallExpression): Effect.Effect { + private evaluateCallExpression(node: CallExpression): Effect.Effect { const callee = node.callee const self = this @@ -1584,7 +1558,7 @@ class Frame { }) } - private readMethod(node: MemberExpression): Effect.Effect<{ callable: unknown; thisValue: unknown }, unknown, R> { + private readMethod(node: MemberExpression): Effect.Effect<{ callable: Value; thisValue: Value }, unknown, R> { return Effect.map(this.getMemberReference(node), (reference) => { if (reference === OptionalShortCircuit) return { callable: OptionalShortCircuit, thisValue: undefined } if (reference instanceof ToolReference) return { callable: reference, thisValue: undefined } @@ -1595,12 +1569,12 @@ class Frame { // The single dispatch for every invocation: call expressions and callbacks share it. call( - callable: unknown, - thisValue: unknown, - args: Array, + callable: Value, + thisValue: Value, + args: Array, node?: AstNode, callee?: Expression, - ): Effect.Effect { + ): Effect.Effect { const self = this return Effect.gen(function* () { if (callable instanceof ToolReference) { @@ -1618,7 +1592,7 @@ class Frame { } // Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it. - private native(body: () => Effect.Effect, node?: AstNode): Effect.Effect { + private native(body: () => Effect.Effect, node?: AstNode): Effect.Effect { return Effect.provideService( Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))), CallSite, @@ -1628,10 +1602,10 @@ class Frame { private evaluateCallArguments( argNodes: ReadonlyArray, - ): Effect.Effect, unknown, R> { + ): Effect.Effect, unknown, R> { const self = this return Effect.gen(function* () { - const args: Array = [] + const args: Array = [] for (const argNode of argNodes) { if (argNode.type === "SpreadElement") { const spread = yield* self.evaluateExpression(argNode.argument) @@ -1651,7 +1625,7 @@ class Frame { } // A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts. - invokeFunction(fn: Fn, args: Array, node?: AstNode): Effect.Effect { + invokeFunction(fn: Fn, args: Array, node?: AstNode): Effect.Effect { const self = this return Effect.flatMap(CallSite, (site) => { const depth = Math.max(self.depth, site.depth) + 1 @@ -1698,16 +1672,16 @@ class Frame { private createGenerator( invocation: Frame, - run: Effect.Effect, + run: Effect.Effect, asynchronous: boolean, ): GeneratorObj { const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 } invocation.generatorState = state invocation.generatorAsync = asynchronous const builtins = this.ctx.builtins - const result = (value: unknown, done: boolean) => record(builtins.Object, { value, done }) - const request = (kind: GeneratorRequestKind, value: unknown) => { - const request = { kind, value, response: Deferred.makeUnsafe() } + const result = (value: Value, done: boolean) => record(builtins.Object, { value, done }) + const request = (kind: GeneratorRequestKind, value: Value) => { + const request = { kind, value, response: Deferred.makeUnsafe() } if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running.")) if (asynchronous && (state.completed || (!state.started && kind !== "next"))) { state.started = true @@ -1778,7 +1752,7 @@ class Frame { private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect { const self = this - const result = (value: unknown, done: boolean) => record(self.ctx.builtins.Object, { value, done }) + const result = (value: Value, done: boolean) => record(self.ctx.builtins.Object, { value, done }) return Effect.gen(function* () { while (true) { const pending = self.dequeueGeneratorRequest(state) @@ -1824,7 +1798,7 @@ class Frame { return request } - private evaluateYieldExpression(node: YieldExpression): Effect.Effect { + private evaluateYieldExpression(node: YieldExpression): Effect.Effect { const argument = node.argument const self = this return Effect.gen(function* () { @@ -1839,7 +1813,7 @@ class Frame { }) } - private suspendGenerator(value: unknown, node: AstNode): Effect.Effect { + private suspendGenerator(value: Value, node: AstNode): Effect.Effect { const state = this.generatorState if (!state?.active) throw typeError("Generator has no active request.", node) Deferred.doneUnsafe(state.active.response, Exit.succeed(record(this.ctx.builtins.Object, { value, done: false }))) @@ -1854,20 +1828,11 @@ class Frame { }) } - private delegateYield(value: unknown, node: AstNode): Effect.Effect { + private delegateYield(value: Value, node: AstNode): Effect.Effect { const self = this return Effect.gen(function* () { - if ( - value instanceof Arr || - typeof value === "string" || - value instanceof MapObj || - value instanceof SetObj || - value instanceof URLSearchParamsObj || - value instanceof HeadersObj || - value instanceof Bytes - ) { - const cursor = yield* self.iterate(value, node) - if (!cursor) throw typeError("Built-in iterator is unavailable.", node) + const cursor = self.hostCursor(value) + if (cursor !== undefined) { while (true) { const step = yield* cursor.next if (step.done) return undefined @@ -1891,7 +1856,7 @@ class Frame { const iterator = yield* self.customIterator(value, node, self.generatorAsync) if (!iterator) throw typeError("yield* requires a compatible iterable value.", node) let kind: GeneratorRequestKind = "next" - let input: unknown = undefined + let input: Value = undefined while (true) { const method = kind === "next" ? iterator.next : get(iterator.iterator, kind) if (method === undefined || method === null) { @@ -1911,7 +1876,7 @@ class Frame { node, ) const done = Boolean(get(result, "done")) - const resultValue: unknown = + const resultValue: Value = self.generatorAsync && !iterator.asynchronous ? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done) : get(result, "value") @@ -1920,7 +1885,7 @@ class Frame { return resultValue } - const resumed: Exit.Exit = yield* Effect.exit(self.suspendGenerator(resultValue, node)) + const resumed: Exit.Exit = yield* Effect.exit(self.suspendGenerator(resultValue, node)) if (Exit.isSuccess(resumed)) { kind = "next" input = resumed.value @@ -1961,7 +1926,7 @@ class Frame { } else if (keyNode.type === "Identifier") { key = keyNode.name } else if (keyNode.type === "Literal") { - key = self.toPropertyKey(keyNode.value, keyNode) + key = self.toPropertyKey(literal(keyNode), keyNode) } else { throw typeError("Unsupported object property key shape.", keyNode) } @@ -1980,7 +1945,7 @@ class Frame { } private evaluateArrayExpression(node: ArrayExpression): Effect.Effect { - const values: Array = [] + const values: Array = [] const self = this return Effect.gen(function* () { @@ -2035,13 +2000,13 @@ class Frame { }) } - private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect { + private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect { return Effect.flatMap(this.evaluateExpression(node.test), (test) => this.evaluateExpression(test ? node.consequent : node.alternate), ) } - private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { + private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value { if (!compoundOperators.has(operator)) { throw typeError(`Unsupported assignment operator '${operator}'.`, node) } @@ -2050,7 +2015,7 @@ class Frame { private getMemberReference( node: MemberExpression, - ): Effect.Effect { + ): Effect.Effect { const objectNode = node.object const propertyNode = node.property if (objectNode.type === "Super") throw unsupportedSyntax(objectNode.type, objectNode) @@ -2092,7 +2057,7 @@ class Frame { }) } - private readReference(reference: MemberReference, node: MemberExpression): unknown { + private readReference(reference: MemberReference, node: MemberExpression): Value { // Reject unknown promise properties so a missing await cannot hide. if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) { throw invalidData( @@ -2104,7 +2069,7 @@ class Frame { } // Accessors throw without a location; the member or pattern that read them supplies it. - private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown { + private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: Value = target): Value { try { return get(target, key, receiver) } catch (error) { @@ -2112,7 +2077,7 @@ class Frame { } } - private readMember(node: MemberExpression): Effect.Effect { + private readMember(node: MemberExpression): Effect.Effect { return Effect.map(this.getMemberReference(node), (reference) => { if (reference === OptionalShortCircuit) return OptionalShortCircuit if (reference instanceof ToolReference) return reference @@ -2121,7 +2086,7 @@ class Frame { }) } - private writeMember(node: MemberExpression, value: unknown): Effect.Effect { + private writeMember(node: MemberExpression, value: Value): Effect.Effect { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } @@ -2143,8 +2108,8 @@ class Frame { // Resolve side-effecting object and key expressions exactly once. private modifyMember( node: MemberExpression, - compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, - ): Effect.Effect { + compute: (current: Value) => Effect.Effect<{ write: boolean; next: Value; result: Value }, unknown, R>, + ): Effect.Effect { const self = this return Effect.gen(function* () { const reference = yield* self.getMemberReference(node) @@ -2164,7 +2129,7 @@ class Frame { }) } - private assignToReference(target: Obj, key: PropertyKey, next: unknown, node: AstNode): void { + private assignToReference(target: Obj, key: PropertyKey, next: Value, node: AstNode): void { const written = (() => { try { rejectCircularInsertion( @@ -2182,7 +2147,7 @@ class Frame { throw typeError(`Cannot assign to read only property '${String(key)}'.`, node) } - private toPropertyKey(value: unknown, node: AstNode): PropertyKey { + private toPropertyKey(value: Value, node: AstNode): PropertyKey { if (typeof value === "string" || typeof value === "number") { return value } diff --git a/packages/codemode/src/interpreter/intrinsics.ts b/packages/codemode/src/interpreter/intrinsics.ts index da3aa87b618..9b525046631 100644 --- a/packages/codemode/src/interpreter/intrinsics.ts +++ b/packages/codemode/src/interpreter/intrinsics.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define, hidden, Native, Arr, ErrorObj, Obj } from "./objects.js" +import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js" export const errorTypes = [ "Error", @@ -53,7 +53,7 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E } /** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */ -export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => { +export const primitivePrototype = (builtins: Builtins, value: Value): Obj | undefined => { if (typeof value === "string") return builtins.String if (typeof value === "number") return builtins.Number if (typeof value === "boolean") return builtins.Boolean diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index c9c36904908..60a974ed4e1 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -2,7 +2,7 @@ import type { Node } from "acorn" import { Context } from "effect" import type { ErrorType } from "./intrinsics.js" import type { DiagnosticKind } from "../codemode.js" -import type { ErrorObj } from "./objects.js" +import type { ErrorObj, Value } from "./objects.js" /** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */ export type AstNode = Node @@ -14,13 +14,13 @@ export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly de export type Binding = { mutable: boolean - value: unknown + value: Value initialized?: boolean } export type StatementResult = | { kind: "none" } - | { kind: "return"; value: unknown } + | { kind: "return"; value: Value } | { kind: "break"; label?: string } | { kind: "continue"; label?: string } @@ -31,11 +31,11 @@ export const IteratorSymbol: unique symbol = Symbol("codemode.iterator") export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const export class Throw { - constructor(readonly value: unknown) {} + constructor(readonly value: Value) {} } export class GeneratorReturn { - constructor(readonly value: unknown) {} + constructor(readonly value: Value) {} } export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") @@ -83,9 +83,6 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow => [supportedSyntaxMessage], ) -export const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - // Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source. export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ line: node.loc?.start.line ?? 1, diff --git a/packages/codemode/src/interpreter/native.ts b/packages/codemode/src/interpreter/native.ts index 5d11d6ceff5..6dbf15c0ad2 100644 --- a/packages/codemode/src/interpreter/native.ts +++ b/packages/codemode/src/interpreter/native.ts @@ -1,18 +1,18 @@ import { Effect } from "effect" import type { Builtins } from "./intrinsics.js" import { typeError } from "./model.js" -import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj } from "./objects.js" +import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj, type Value } from "./objects.js" import { describeValue } from "./references.js" -/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */ -export type Impl = (thisValue: unknown, args: Array) => unknown +/** A native function body: a value, a thrown `PendingThrow`, or an Effect of a value. */ +export type Impl = (thisValue: Value, args: Array) => Value | Effect.Effect // The dispatch in `Frame.call` suspends every native call, so a synchronous throw here is a defect. const lift = (impl: Impl) => - (thisValue: unknown, args: Array): Effect.Effect => { + (thisValue: Value, args: Array): Effect.Effect => { const result = impl(thisValue, args) - return Effect.isEffect(result) ? (result as Effect.Effect) : Effect.succeed(result) + return Effect.isEffect(result) ? (result as Effect.Effect) : Effect.succeed(result) } export const native = (builtins: Builtins, options: NativeOptions): Native => @@ -27,7 +27,7 @@ export const methods = (builtins: Builtins, target: Obj, table: ReadonlyArray): void => { +export const constants = (target: Obj, table: Record): void => { for (const [name, value] of Object.entries(table)) define(target, name, value, frozen) } @@ -54,7 +54,7 @@ export const prototypeFrom = (newTarget: Callable, fallback: Obj): Obj => { /** Narrows a method receiver to the built-in it belongs to, or throws the TypeError JS would. */ export const receiver = ( cls: abstract new (...args: never) => T, - thisValue: unknown, + thisValue: Value, method: string, ): T => { if (thisValue instanceof cls) return thisValue diff --git a/packages/codemode/src/interpreter/objects.ts b/packages/codemode/src/interpreter/objects.ts index 23a9a66dce8..d850f4583fb 100644 --- a/packages/codemode/src/interpreter/objects.ts +++ b/packages/codemode/src/interpreter/objects.ts @@ -1,5 +1,7 @@ import type { BlockStatement, Expression, Pattern } from "acorn" import type { Effect, Fiber } from "effect" +import { ToolReference } from "../tool-runtime.js" +import type { Builtins } from "./intrinsics.js" import { checkArrayLength } from "./limits.js" import { AsyncIteratorSymbol, @@ -16,12 +18,12 @@ export type Attributes = { readonly configurable: boolean } -export type Getter = (receiver: unknown) => unknown -export type Setter = (receiver: unknown, value: unknown) => void +export type Getter = (receiver: Value) => Value +export type Setter = (receiver: Value, value: Value) => void /** One own property: a data slot or a native accessor pair. */ export type Slot = - | { value: unknown; writable: boolean; enumerable: boolean; configurable: boolean } + | { value: Value; writable: boolean; enumerable: boolean; configurable: boolean } | { get: Getter | undefined; set: Setter | undefined; enumerable: boolean; configurable: boolean } /** Ordinary assignment: writable, enumerable, configurable. */ @@ -33,33 +35,119 @@ export const readonly: Attributes = { writable: false, enumerable: false, config /** Constants such as `Math.PI` and a constructor's `prototype`. */ export const frozen: Attributes = { writable: false, enumerable: false, configurable: false } -/** An object owned by the program: own properties plus a prototype link. */ +/** + * An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a + * built-in kind of object prints, coerces, iterates, and crosses to the host. + */ export class Obj { readonly props = new Map() constructor(public proto: Obj | null) {} + + /** The class name `Object.prototype.toString` reports: `[object Map]`. */ + readonly tag: string = "Object" + + /** How diagnostics refer to a value of this kind. */ + get describe(): string { + if (this.tag === "Object") return "a data object" + return `${/^[AEIO]/.test(this.tag) ? "an" : "a"} ${this.tag}` + } + + /** ToString without consulting program-defined methods. */ + toString(): string { + return `[object ${this.tag}]` + } + + /** ToPrimitive without consulting program-defined methods: only a Date answers a number hint differently. */ + toPrimitive(hint: "default" | "number" | "string"): string | number { + return this.toString() + } + + /** ToNumber without consulting program-defined methods. */ + toNumber(): number { + return Number(this.toPrimitive("number")) + } + + /** How `console.log` shows the value; `item` formats a child with cycle and depth tracking. */ + inspect(item: (value: Value) => string): string { + return `{${entries(this) + .map(([key, value]) => `${JSON.stringify(key)}:${item(value)}`) + .join(",")}}` + } + + /** A copy the host can hold; `item` converts a child. A `__proto__` key never reaches host code. */ + toHost(item: (value: Value) => unknown): unknown { + return Object.fromEntries( + entries(this) + .filter(([key]) => key !== "__proto__") + .map(([key, value]) => [key, item(value)]), + ) + } + + /** The built-in iteration `for...of` and spread use, when this kind of object has one. */ + iterator(builtins: Builtins): Iterator | undefined { + return undefined + } } export class Arr extends Obj { + override readonly tag = "Array" constructor( proto: Obj, - readonly items: Array = [], + readonly items: Array = [], ) { super(proto) } + override get describe() { + return "an array" + } + override toString() { + return this.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + } + override inspect(item: (value: Value) => string) { + return `[${this.items.map(item).join(",")}]` + } + override toHost(item: (value: Value) => unknown) { + return this.items.map(item) + } + override iterator() { + return this.items.values() + } } /** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */ export class ErrorObj extends Obj { + override readonly tag = "Error" /** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */ host?: PendingThrow + /** Error.prototype.toString: "name: message", or just one when the other is empty. */ + override toString() { + const name = get(this, "name") + const message = get(this, "message") + const shownName = typeof name === "string" ? name : "Error" + const shownMessage = typeof message === "string" ? message : "" + if (shownMessage === "") return shownName + if (shownName === "") return shownMessage + return `${shownName}: ${shownMessage}` + } } -export abstract class Callable extends Obj { +/** Interpreter machinery a program can hold but never inspect, serialize, or hand to the host. */ +export abstract class Opaque extends Obj { + override inspect() { + return "[opaque reference]" + } +} + +export abstract class Callable extends Opaque { + override readonly tag = "Function" constructor(proto: Obj, name: string, length: number) { super(proto) define(this, "length", length, readonly) define(this, "name", name, readonly) } + override get describe() { + return "a function" + } } export class Fn extends Callable { @@ -77,8 +165,8 @@ export class Fn extends Callable { } } -export type NativeCall = (thisValue: unknown, args: Array) => Effect.Effect -export type NativeConstruct = (args: Array, newTarget: Callable) => Effect.Effect +export type NativeCall = (thisValue: Value, args: Array) => Effect.Effect +export type NativeConstruct = (args: Array, newTarget: Callable) => Effect.Effect export type NativeOptions = { readonly name: string @@ -103,79 +191,163 @@ export class Native extends Callable { } } -export class PromiseObj extends Obj { +export class PromiseObj extends Opaque { + override readonly tag = "Promise" constructor( proto: Obj, - readonly fiber: Fiber.Fiber, + readonly fiber: Fiber.Fiber, ) { super(proto) } + override get describe() { + return "an un-awaited Promise" + } + override inspect() { + return "[Promise (await it to get its value)]" + } } -export class GeneratorObj extends Obj { +export class GeneratorObj extends Opaque { + override readonly tag = "Generator" constructor( proto: Obj, readonly asynchronous: boolean, - readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect, + readonly request: (kind: GeneratorRequestKind, value: Value) => Effect.Effect, ) { super(proto) } + override get describe() { + return "a generator" + } } /** A built-in collection iterator: live over the host collection, yielding program values. */ -export class IteratorObj extends Obj { +export class IteratorObj extends Opaque { + override readonly tag = "Iterator" constructor( proto: Obj, - readonly iterator: IteratorObject, + readonly source: IteratorObject, ) { super(proto) } + override get describe() { + return "an iterator" + } + override iterator() { + return this.source + } } -export class DateObj extends Obj { +/** 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) { + return this.toString() + } +} + +export class DateObj extends Wrapper { + override readonly tag = "Date" constructor( proto: Obj, public time: number, ) { super(proto) } + override toString() { + return Number.isFinite(this.time) ? new Date(this.time).toISOString() : "Invalid Date" + } + override toPrimitive(hint: "default" | "number" | "string") { + return hint === "number" ? this.time : this.toString() + } + override toHost() { + return new Date(this.time) + } } -export class RegExpObj extends Obj { +export class RegExpObj extends Wrapper { + override readonly tag = "RegExp" readonly regex: RegExp constructor(proto: Obj, pattern: string, flags: string) { super(proto) this.regex = new RegExp(pattern, flags) } + override toString() { + return `/${this.regex.source}/${this.regex.flags}` + } + override toHost() { + return new RegExp(this.regex.source, this.regex.flags) + } } -export class MapObj extends Obj { - readonly map = new Map() +export class MapObj extends Wrapper { + override readonly tag = "Map" + readonly map = new Map() + override inspect(item: (value: Value) => string) { + return `Map(${this.map.size}) [${[...this.map].map(([key, value]) => `[${item(key)},${item(value)}]`).join(",")}]` + } + override toHost(item: (value: Value) => unknown) { + return new Map([...this.map].map(([key, value]) => [item(key), item(value)])) + } + override iterator(builtins: Builtins) { + return this.map.entries().map((entry) => new Arr(builtins.Array, entry)) + } } -export class SetObj extends Obj { - readonly set = new Set() +export class SetObj extends Wrapper { + override readonly tag = "Set" + readonly set = new Set() + override inspect(item: (value: Value) => string) { + return `Set(${this.set.size}) [${[...this.set].map(item).join(",")}]` + } + override toHost(item: (value: Value) => unknown) { + return new Set([...this.set].map(item)) + } + override iterator() { + return this.set.values() + } } -export class URLSearchParamsObj extends Obj { +export class URLSearchParamsObj extends Wrapper { + override readonly tag = "URLSearchParams" constructor( proto: Obj, readonly params: URLSearchParams, ) { super(proto) } + override toString() { + return this.params.toString() + } + override toHost() { + return new URLSearchParams(this.params) + } + override iterator(builtins: Builtins) { + return this.params.entries().map((entry) => new Arr(builtins.Array, entry)) + } } -export class HeadersObj extends Obj { +export class HeadersObj extends Wrapper { + override readonly tag = "Headers" constructor( proto: Obj, readonly headers: Headers, ) { super(proto) } + override inspect() { + return `Headers ${JSON.stringify(Object.fromEntries(this.headers))}` + } + override toHost() { + return new Headers(this.headers) + } + override iterator(builtins: Builtins) { + // Bun's Headers typings lack the iterator helpers, so the host iterator is lifted first. + return Iterator.from(this.headers.entries()).map((entry) => new Arr(builtins.Array, entry)) + } } -export class URLObj extends Obj { +export class URLObj extends Wrapper { + override readonly tag = "URL" readonly searchParams: URLSearchParamsObj constructor( proto: Obj, @@ -185,30 +357,52 @@ export class URLObj extends Obj { super(proto) this.searchParams = new URLSearchParamsObj(searchParamsProto, url.searchParams) } + override toString() { + return this.url.href + } + override toHost() { + return new URL(this.url.href) + } } /** A `Uint8Array`: the host array does the byte clamping and ignores out-of-range writes, as JS does. */ -export class Bytes extends Obj { +export class Bytes extends Wrapper { + override readonly tag = "Uint8Array" constructor( proto: Obj, readonly bytes: Uint8Array, ) { super(proto) } + override toString() { + return this.bytes.join(",") + } + override inspect() { + return `Uint8Array(${this.bytes.length}) [${this.bytes.join(",")}]` + } + override toHost() { + return new Uint8Array(this.bytes) + } + override iterator() { + return this.bytes.values() + } } -/** Built-in objects that wrap a host value; data-like, but never plain data. */ -export const isWrapper = ( - value: unknown, -): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes => - value instanceof DateObj || - value instanceof RegExpObj || - value instanceof MapObj || - value instanceof SetObj || - value instanceof URLObj || - value instanceof URLSearchParamsObj || - value instanceof HeadersObj || - value instanceof Bytes +/** Every value a program can hold. Host values never appear here; they are copied in at the boundaries. */ +export type Value = string | number | boolean | null | undefined | symbol | Obj | ToolReference + +/** ToString without consulting program-defined methods. */ +export const coerceToString = (value: Value): string => (value instanceof Obj ? value.toString() : String(value)) + +/** ToNumber without consulting program-defined methods; tool references are not numbers. */ +export const coerceToNumber = (value: Value): number => { + if (value instanceof Obj) return value.toNumber() + return value instanceof ToolReference ? Number.NaN : Number(value) +} + +/** Values that cannot cross the data boundary: opaque machinery and host-backed wrappers. */ +export const isRuntimeReference = (value: Value): boolean => + value instanceof Opaque || value instanceof Wrapper || value instanceof ToolReference const MAX_ARRAY_INDEX = 4_294_967_295 @@ -226,7 +420,7 @@ type Indexed = Arr | Bytes const isIndexed = (target: Obj): target is Indexed => target instanceof Arr || target instanceof Bytes -const elements = (target: Indexed): Array | Uint8Array => (target instanceof Arr ? target.items : target.bytes) +const elements = (target: Indexed): Array | Uint8Array => (target instanceof Arr ? target.items : target.bytes) const index = (target: Obj, key: string | symbol): number | undefined => isIndexed(target) && typeof key === "string" ? parseArrayIndex(key) : undefined @@ -247,18 +441,18 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => { return target.props.get(name) } -const read = (slot: Slot, receiver: unknown): unknown => +const read = (slot: Slot, receiver: Value): Value => "value" in slot ? slot.value : slot.get === undefined ? undefined : slot.get(receiver) export const hasOwn = (target: Obj, key: PropertyKey): boolean => own(target, key) !== undefined -export const getOwn = (target: Obj, key: PropertyKey): unknown => { +export const getOwn = (target: Obj, key: PropertyKey): Value => { const slot = own(target, key) return slot === undefined ? undefined : read(slot, target) } /** [[Get]]: walks the prototype chain; accessors see `receiver`, which is the primitive for wrapper prototypes. */ -export const get = (target: Obj, key: PropertyKey, receiver: unknown = target): unknown => { +export const get = (target: Obj, key: PropertyKey, receiver: Value = target): Value => { for (let current: Obj | null = target; current !== null; current = current.proto) { const slot = own(current, key) if (slot !== undefined) return read(slot, receiver) @@ -273,14 +467,14 @@ export const has = (target: Obj, key: PropertyKey): boolean => { return false } -export const hasPrototype = (value: unknown, proto: Obj): boolean => { +export const hasPrototype = (value: Value, proto: Obj): boolean => { for (let current = value instanceof Obj ? value.proto : null; current !== null; current = current.proto) { if (current === proto) return true } return false } -const writeElement = (target: Indexed, name: string | symbol, value: unknown): boolean | undefined => { +const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => { const at = index(target, name) if (at !== undefined) { if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value) @@ -296,7 +490,7 @@ const writeElement = (target: Indexed, name: string | symbol, value: unknown): b } /** [[Set]]: an inherited setter or read-only property decides before an own data property is created. */ -export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => { +export const set = (target: Obj, key: PropertyKey, value: Value): boolean => { const name = canonical(key) for (let current: Obj | null = target; current !== null; current = current.proto) { const slot = own(current, name) @@ -324,7 +518,7 @@ export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => { } /** [[DefineOwnProperty]] for a data property, ignoring the chain. */ -export const define = (target: Obj, key: PropertyKey, value: unknown, attrs: Attributes = data): void => { +export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => { const name = canonical(key) if (isIndexed(target) && writeElement(target, name, value) !== undefined) return target.props.set(name, { value, ...attrs }) @@ -375,9 +569,9 @@ export const keys = (target: Obj): Array => ownKeys(target).filter((key): key is string => typeof key === "string" && enumerable(target, key)) /** Own enumerable string entries: `Object.entries` and serialization. */ -export const entries = (target: Obj): Array<[string, unknown]> => keys(target).map((key) => [key, getOwn(target, key)]) +export const entries = (target: Obj): Array<[string, Value]> => keys(target).map((key) => [key, getOwn(target, key)]) -export const record = (proto: Obj, fields: Record): Obj => { +export const record = (proto: Obj, fields: Record): Obj => { const target = new Obj(proto) for (const [key, value] of Object.entries(fields)) define(target, key, value) return target diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 4850af97fa9..88dd49cb4a4 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -2,7 +2,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" import type { Diagnostic } from "../codemode.js" import { MAX_PENDING_PROMISES } from "./limits.js" import { CallSite, Throw, rangeError, typeError } from "./model.js" -import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record } from "./objects.js" +import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record, type Value } from "./objects.js" import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js" import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js" import { typeofValue } from "./references.js" @@ -10,7 +10,7 @@ import { applyCollectionCallback, isSupportedCallback } from "./callback.js" import type { Interpreter } from "./interpreter.js" // A `resolve`/`reject` handed to an executor or thenable: calling it settles the capability. -const capability = (ctx: Interpreter, name: string, settle: (value: unknown) => void) => +const capability = (ctx: Interpreter, name: string, settle: (value: Value) => void) => fn(ctx.builtins, name, 1, (_, args) => { settle(args[0]) return undefined @@ -31,7 +31,7 @@ export class Pending { // Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution. createWithSelf( - body: (self: { promise?: PromiseObj }) => Effect.Effect, + body: (self: { promise?: PromiseObj }) => Effect.Effect, ): Effect.Effect { const self: { promise?: PromiseObj } = {} return Effect.map(this.create(body(self)), (promise) => { @@ -40,7 +40,7 @@ export class Pending { }) } - create(effect: Effect.Effect): Effect.Effect { + create(effect: Effect.Effect): Effect.Effect { return Effect.flatMap(CallSite, (site) => { if (this.active.size >= MAX_PENDING_PROMISES) { throw rangeError( @@ -79,7 +79,7 @@ export class Pending { if (id !== undefined) this.failures.delete(id) } - await(promise: PromiseObj): Effect.Effect> { + await(promise: PromiseObj): Effect.Effect> { return Fiber.await(promise.fiber) } @@ -105,9 +105,9 @@ export class Pending { export const resolvePromiseValue = ( ctx: Interpreter, - value: unknown, + value: Value, own?: { promise?: PromiseObj }, -): Effect.Effect => { +): Effect.Effect => { if (own?.promise !== undefined && value === own.promise) { return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself.")) } @@ -119,7 +119,7 @@ export const resolvePromiseValue = ( return Effect.gen(function* () { // Promise resolution invokes a thenable's method in a later job. yield* Effect.yieldNow - const deferred = Deferred.makeUnsafe() + const deferred = Deferred.makeUnsafe() const resolve = capability(ctx, "resolve", (result) => Deferred.doneUnsafe(deferred, Exit.succeed(result))) const reject = capability(ctx, "reject", (reason) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(reason)))) const executed = yield* Effect.exit(ctx.call(then, value, [resolve, reject])) @@ -131,7 +131,7 @@ export const resolvePromiseValue = ( }) } -export const resolvePromise = (ctx: Interpreter, value: unknown): Effect.Effect => { +export const resolvePromise = (ctx: Interpreter, value: Value): Effect.Effect => { if (value instanceof PromiseObj) return Effect.succeed(value) return ctx.pending.createWithSelf((self) => resolvePromiseValue(ctx, value, self)) } @@ -141,8 +141,8 @@ const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] const invokePromiseMethod = ( ctx: Interpreter, name: (typeof promiseStatics)[number], - args: Array, -): Effect.Effect => { + args: Array, +): Effect.Effect => { if (name === "resolve") { return resolvePromise(ctx, args[0]) } @@ -175,7 +175,7 @@ const invokePromiseMethod = ( ) } if (name === "allSettled") { - const outcomes: Array = [] + const outcomes: Array = [] for (const item of items) { const exit = yield* ctx.pending.await(item) if (Exit.isSuccess(exit)) { @@ -223,8 +223,8 @@ const invokePromiseMethod = ( const instanceMethod = ( ctx: Interpreter, name: "then" | "catch" | "finally", - thisValue: unknown, - args: Array, + thisValue: Value, + args: Array, ): Effect.Effect => { const method = `Promise.prototype.${name}` const promise = receiver(PromiseObj, thisValue, method) @@ -237,12 +237,12 @@ const instanceMethod = ( return chainReaction(ctx, promise, onFulfilled, onRejected, method) } -const constructPromise = (ctx: Interpreter, executor: unknown): Effect.Effect => { +const constructPromise = (ctx: Interpreter, executor: Value): Effect.Effect => { if (!(executor instanceof Fn)) { throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).") } return Effect.gen(function* () { - const deferred = Deferred.makeUnsafe() + const deferred = Deferred.makeUnsafe() const promise = yield* ctx.pending.createWithSelf((self) => Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(ctx, value, self)), ) @@ -262,10 +262,10 @@ const settleAfterTurn = (body: Effect.Effect): Effect.Effect Effect.andThen(Effect.yieldNow, exit)) class PromiseAnyFulfilled { - constructor(readonly value: unknown) {} + constructor(readonly value: Value) {} } -const reactionHandler = (value: unknown, method: string): Callable | undefined => { +const reactionHandler = (value: Value, method: string): Callable | undefined => { if (isSupportedCallback(value)) return value if (typeofValue(value) === "function") { throw typeError( @@ -279,7 +279,7 @@ const reactionHandler = (value: unknown, method: string): Callable | undefined = const reactionExit = ( ctx: Interpreter, source: PromiseObj, -): Effect.Effect, unknown, R> => +): Effect.Effect, unknown, R> => Effect.gen(function* () { const exit = yield* ctx.pending.await(source) if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index 5331035c99a..c2c4a25bcd5 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -1,47 +1,18 @@ import { ToolReference } from "../tool-runtime.js" import { invalidData } from "./model.js" -import { - Callable, - getOwn, - isWrapper, - ownKeys, - Arr, - Bytes, - DateObj, - GeneratorObj, - IteratorObj, - MapObj, - Obj, - PromiseObj, - RegExpObj, - SetObj, - URLObj, - URLSearchParamsObj, - HeadersObj, -} from "./objects.js" +import { Callable, getOwn, isRuntimeReference, Obj, Opaque, ownKeys, type Value } from "./objects.js" -/** Values that cannot cross the data boundary. */ -export const isRuntimeReference = (value: unknown): boolean => - value instanceof Callable || - value instanceof GeneratorObj || - value instanceof IteratorObj || - value instanceof ToolReference || - value instanceof PromiseObj || - isWrapper(value) - -function* childValues(value: object): Generator { - if (!(value instanceof Obj)) return - for (const key of ownKeys(value)) yield getOwn(value, key) -} +/** Interpreter machinery that is never data, unlike a Date or Map, which cross some boundaries as copies. */ +export const isOpaque = (value: Value): boolean => value instanceof Opaque || value instanceof ToolReference // Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it. const find = ( - value: unknown, - match: (current: unknown) => boolean, - skip: (current: unknown) => boolean, + value: Value, + match: (current: Value) => boolean, + skip: (current: Value) => boolean, seen: Set, ): boolean => { - const pending: Array> = [[value].values()] + const pending: Array> = [[value].values()] while (pending.length > 0) { const next = pending.at(-1)!.next() if (next.done) { @@ -50,25 +21,28 @@ const find = ( } const current = next.value if (match(current)) return true - if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue + if (!(current instanceof Obj) || skip(current) || seen.has(current)) continue seen.add(current) - pending.push(childValues(current)) + pending.push( + ownKeys(current) + .map((key) => getOwn(current, key)) + .values(), + ) } return false } const never = () => false -export const containsRuntimeReference = (value: unknown): boolean => find(value, isRuntimeReference, never, new Set()) +export const containsRuntimeReference = (value: Value): boolean => find(value, isRuntimeReference, never, new Set()) -// Wrapper values are data here, not opaque interpreter references. -export const containsOpaqueReference = (value: unknown): boolean => - find(value, (current) => !isWrapper(current) && isRuntimeReference(current), isWrapper, new Set()) +export const containsOpaqueReference = (value: Value): boolean => + find(value, isOpaque, (current) => isRuntimeReference(current) && !isOpaque(current), new Set()) // Reject cycles before mutation so later boundary walks remain safe. export const rejectCircularInsertion = ( - container: object, - value: unknown, + container: Obj, + value: Value, label: string, seen = new Set(), ): void => { @@ -77,27 +51,14 @@ export const rejectCircularInsertion = ( } } -export const describeValue = (value: unknown): string => { +export const describeValue = (value: Value): string => { if (value === null || value === undefined) return String(value) - if (value instanceof Arr) return "an array" - if (value instanceof PromiseObj) return "an un-awaited Promise" + if (value instanceof Obj) return value.describe if (value instanceof ToolReference) return "a tool reference" - if (value instanceof DateObj) return "a Date" - if (value instanceof RegExpObj) return "a RegExp" - if (value instanceof MapObj) return "a Map" - if (value instanceof SetObj) return "a Set" - if (value instanceof URLObj) return "a URL" - if (value instanceof URLSearchParamsObj) return "a URLSearchParams" - if (value instanceof HeadersObj) return "a Headers" - if (value instanceof Bytes) return "a Uint8Array" - if (value instanceof GeneratorObj) return "a generator" - if (value instanceof IteratorObj) return "an iterator" - if (isRuntimeReference(value)) return "a function" - if (typeof value === "object") return "a data object" return `a ${typeof value}` } -export const typeofValue = (value: unknown): string => { +export const typeofValue = (value: Value): string => { if (value instanceof Callable) return "function" if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" return typeof value diff --git a/packages/codemode/src/interpreter/scope.ts b/packages/codemode/src/interpreter/scope.ts index 8ae2861f670..f69fcf0adf4 100644 --- a/packages/codemode/src/interpreter/scope.ts +++ b/packages/codemode/src/interpreter/scope.ts @@ -1,4 +1,5 @@ import { type AstNode, type Binding, referenceError, typeError } from "./model.js" +import type { Value } from "./objects.js" export class ScopeStack { private readonly scopes: Array> @@ -15,7 +16,7 @@ export class ScopeStack { scope.set(name, { mutable, value: undefined, initialized: false }) } - initialize(name: string, value: unknown, node: AstNode): void { + initialize(name: string, value: Value, node: AstNode): void { const binding = this.current().get(name) if (!binding || binding.initialized !== false) { throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node) @@ -24,7 +25,7 @@ export class ScopeStack { binding.initialized = true } - declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + declare(name: string, value: Value, mutable: boolean, node: AstNode): void { const scope = this.current() if (scope.has(name)) { throw typeError(`Identifier '${name}' has already been declared.`, node) @@ -32,7 +33,7 @@ export class ScopeStack { scope.set(name, { mutable, value, initialized: true }) } - get(name: string, node: AstNode): unknown { + get(name: string, node: AstNode): Value { const binding = this.resolve(name) if (!binding) { @@ -46,7 +47,7 @@ export class ScopeStack { return binding.value } - set(name: string, value: unknown, node: AstNode): unknown { + set(name: string, value: Value, node: AstNode): Value { const binding = this.resolve(name) if (!binding) { diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index 02b1ed7a8f1..3c3ebcb81eb 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -2,14 +2,24 @@ import { Effect } from "effect" import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js" import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js" import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js" -import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js" +import { + define, + get, + hidden, + Arr, + GeneratorObj, + IteratorObj, + Obj, + coerceToNumber, + coerceToString, + type Value, +} from "../interpreter/objects.js" import { describeValue, rejectCircularInsertion } from "../interpreter/references.js" import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" import { compareText } from "../tool-runtime.js" -import { coerceToNumber, coerceToString } from "./value.js" -const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: Obj } => { +const arrayLikeSource = (source: Value): { readonly length: number; readonly source: Obj } => { if (source instanceof Obj && typeof get(source, "length") === "number") { const length = get(source, "length") as number const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length) @@ -21,7 +31,7 @@ const arrayLikeSource = (source: unknown): { readonly length: number; readonly s ) } -const arrayFrom = (ctx: Interpreter, args: Array): Effect.Effect => { +const arrayFrom = (ctx: Interpreter, args: Array): Effect.Effect => { const source = args[0] const proto = ctx.builtins.Array const apply = @@ -33,14 +43,14 @@ const arrayFrom = (ctx: Interpreter, args: Array): Effect.Effect< throw typeError("Array.from expects a synchronous iterable or array-like value.") } const arrayLike = arrayLikeSource(source) - const values: Array = [] + const values: Array = [] for (let index = 0; index < arrayLike.length; index += 1) { const item = get(arrayLike.source, index) values.push(apply === undefined ? item : yield* apply([item, index])) } return new Arr(proto, values) } - const values: Array = [] + const values: Array = [] let index = 0 while (true) { const step = yield* cursor.next @@ -53,21 +63,21 @@ const arrayFrom = (ctx: Interpreter, args: Array): Effect.Effect< export const sortArray = ( ctx: Interpreter, - target: Array, - comparator: unknown, + target: Array, + comparator: Value, name: string, -): Effect.Effect, unknown, R> => { +): Effect.Effect, unknown, R> => { if (comparator === undefined) { return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b)))) } const apply = applyCollectionCallback(ctx, comparator, name) - const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { if (items.length <= 1) return Effect.succeed(items) const midpoint = Math.floor(items.length / 2) return Effect.gen(function* () { const left = yield* mergeSort(items.slice(0, midpoint)) const right = yield* mergeSort(items.slice(midpoint)) - const merged: Array = [] + const merged: Array = [] let leftIndex = 0 let rightIndex = 0 while (leftIndex < left.length && rightIndex < right.length) { @@ -88,8 +98,8 @@ export const sortArray = ( export const arrayGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins const proto = builtins.Array - const wrap = (items: Array) => new Arr(proto, items) - const construct = (args: Array, into: Obj): Arr => { + const wrap = (items: Array) => new Arr(proto, items) + const construct = (args: Array, into: Obj): Arr => { if (args.length !== 1) return new Arr(into, [...args]) const first = args[0] if (typeof first !== "number") return new Arr(into, [first]) @@ -109,8 +119,8 @@ export const arrayGlobal = (ctx: Interpreter) => { ["from", 1, (_, args) => arrayFrom(ctx, args)], ]) - const self = (thisValue: unknown, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`) - const optNumber = (name: string, value: unknown, label: string): number | undefined => { + const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`) + const optNumber = (name: string, value: Value, label: string): number | undefined => { if (value === undefined) return undefined if (typeof value !== "number") { throw typeError(`Array.${name} expects ${label} to be a number.`) @@ -122,11 +132,11 @@ export const arrayGlobal = (ctx: Interpreter) => { name: string, length: number, body: ( - target: Array, + target: Array, receiver: Arr, - apply: (args: Array) => Effect.Effect, - args: Array, - ) => Effect.Effect, + apply: (args: Array) => Effect.Effect, + args: Array, + ) => Effect.Effect, ): Method => [ name, length, @@ -214,7 +224,7 @@ export const arrayGlobal = (ctx: Interpreter) => { "flat", 0, (thisValue, args) => { - const flatten = (items: Array, depth: number): Array => + const flatten = (items: Array, depth: number): Array => items.flatMap((item) => (item instanceof Arr && depth > 0 ? flatten(item.items, depth - 1) : [item])) const flattened = flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1) checkArrayLength(flattened.length) @@ -369,7 +379,7 @@ export const arrayGlobal = (ctx: Interpreter) => { iterate("map", 1, (target, receiver, apply) => Effect.gen(function* () { const length = target.length - const values: Array = [] + const values: Array = [] values.length = length for (let index = 0; index < length; index += 1) { if (!(index in target)) continue @@ -381,7 +391,7 @@ export const arrayGlobal = (ctx: Interpreter) => { iterate("flatMap", 1, (target, receiver, apply) => Effect.gen(function* () { const length = target.length - const values: Array = [] + const values: Array = [] for (let index = 0; index < length; index += 1) { if (!(index in target)) continue const mapped = yield* apply([target[index], index, receiver]) @@ -394,7 +404,7 @@ export const arrayGlobal = (ctx: Interpreter) => { iterate("filter", 1, (target, receiver, apply) => Effect.gen(function* () { const length = target.length - const values: Array = [] + const values: Array = [] for (let index = 0; index < length; index += 1) { if (!(index in target)) continue const item = target[index] diff --git a/packages/codemode/src/stdlib/bytes.ts b/packages/codemode/src/stdlib/bytes.ts index 4660f6303df..3994bdee1a5 100644 --- a/packages/codemode/src/stdlib/bytes.ts +++ b/packages/codemode/src/stdlib/bytes.ts @@ -2,13 +2,24 @@ import { Effect } from "effect" import { checkArrayLength, checkStringLength } from "../interpreter/limits.js" import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js" -import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js" +import { + define, + defineAccessor, + get, + hidden, + Arr, + Bytes, + IteratorObj, + Obj, + coerceToNumber, + coerceToString, + type Value, +} from "../interpreter/objects.js" import { describeValue } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToNumber, coerceToString } from "./value.js" /** The bytes a Uint8Array, array, or other iterable of numbers describes; the host array clamps each value. */ -const collectBytes = (ctx: Interpreter, source: unknown, name: string): Effect.Effect => { +const collectBytes = (ctx: Interpreter, source: Value, name: string): Effect.Effect => { if (source instanceof Bytes) return Effect.succeed(new Uint8Array(source.bytes)) return Effect.gen(function* () { const cursor = yield* ctx.iterate(source) @@ -27,7 +38,7 @@ const collectBytes = (ctx: Interpreter, source: unknown, name: string): Ef }) } -const constructBytes = (ctx: Interpreter, args: Array, proto: Obj) => { +const constructBytes = (ctx: Interpreter, args: Array, proto: Obj) => { const source = args[0] if (source !== null && typeof source === "object") { return Effect.map(collectBytes(ctx, source, "new Uint8Array(...)"), (bytes) => new Bytes(proto, bytes)) @@ -48,7 +59,7 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { call: requiresNew("Uint8Array"), construct: (args, newTarget) => constructBytes(ctx, args, prototypeFrom(newTarget, proto)), }) - const decode = (name: string, args: Array, from: (text: string) => Uint8Array) => { + const decode = (name: string, args: Array, from: (text: string) => Uint8Array) => { if (typeof args[0] !== "string") throw typeError(`Uint8Array.${name} expects a string.`) try { return wrap(from(args[0])) @@ -63,13 +74,13 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { ["fromHex", 1, (_, args) => decode("fromHex", args, (text) => Uint8Array.fromHex(text))], ]) - const self = (thisValue: unknown, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`) - const optNumber = (name: string, value: unknown, label: string): number | undefined => { + const self = (thisValue: Value, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`) + const optNumber = (name: string, value: Value, label: string): number | undefined => { if (value === undefined) return undefined if (typeof value !== "number") throw typeError(`Uint8Array.${name} expects ${label} to be a number.`) return value } - const wrapAll = (items: Array) => new Arr(builtins.Array, items) + const wrapAll = (items: Array) => new Arr(builtins.Array, items) defineAccessor(proto, "length", (thisValue) => self(thisValue, "length").bytes.length) methods(builtins, proto, [ ["at", 1, (thisValue, args) => self(thisValue, "at").bytes.at(optNumber("at", args[0], "index") ?? 0)], @@ -222,8 +233,7 @@ const utf8Labels = new Set(["unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8 export const textDecoderGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins const proto = builtins.TextDecoder - const self = (thisValue: unknown, name: string) => - receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`) + const self = (thisValue: Value, name: string) => receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`) defineAccessor(proto, "encoding", (thisValue) => self(thisValue, "encoding").decoder.encoding) defineAccessor(proto, "fatal", (thisValue) => self(thisValue, "fatal").decoder.fatal) defineAccessor(proto, "ignoreBOM", (thisValue) => self(thisValue, "ignoreBOM").decoder.ignoreBOM) diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index 34c8f383053..5234b4a9da3 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -7,15 +7,16 @@ import { get, getOwn, hidden, - isWrapper, Arr, + coerceToString, IteratorObj, MapObj, Obj, PromiseObj, SetObj, + type Value, } from "../interpreter/objects.js" -import { describeValue, isRuntimeReference } from "../interpreter/references.js" +import { describeValue, isOpaque } from "../interpreter/references.js" import { applyCollectionCallback, isSupportedCallback, @@ -25,9 +26,9 @@ import { } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" -const coerceGroupByPropertyKey = (ctx: Interpreter, value: unknown): Effect.Effect => { - if (value instanceof PromiseObj) return Effect.succeed("[object Promise]") - if (!isWrapper(value) && isRuntimeReference(value)) { +const coerceGroupByPropertyKey = (ctx: Interpreter, value: Value): Effect.Effect => { + if (value instanceof PromiseObj) return Effect.succeed(coerceToString(value)) + if (isOpaque(value)) { throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`) } return toPrimitiveString(ctx, value) @@ -81,7 +82,7 @@ export const groupBy = (ctx: Interpreter, namespace: "Map" | "Object") => }) }) -const constructMap = (ctx: Interpreter, init: unknown, proto: Obj) => { +const constructMap = (ctx: Interpreter, init: Value, proto: Obj) => { const target = new MapObj(proto) if (init === undefined || init === null) return Effect.succeed(target) return Effect.gen(function* () { @@ -105,7 +106,7 @@ const constructMap = (ctx: Interpreter, init: unknown, proto: Obj) => { }) } -const constructSet = (ctx: Interpreter, init: unknown, proto: Obj) => { +const constructSet = (ctx: Interpreter, init: Value, proto: Obj) => { const target = new SetObj(proto) if (init === undefined || init === null) return Effect.succeed(target) return Effect.gen(function* () { @@ -130,8 +131,7 @@ export const mapGlobal = (ctx: Interpreter) => { construct: (args, newTarget) => constructMap(ctx, args[0], prototypeFrom(newTarget, proto)), }) define(map, "groupBy", groupBy(ctx, "Map"), hidden) - const self = (thisValue: unknown, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`) - const wrap = (items: Array) => new Arr(builtins.Array, items) + const self = (thisValue: Value, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`) defineAccessor(proto, "size", (thisValue) => receiver(MapObj, thisValue, "Map.prototype.size").map.size) methods(builtins, proto, [ ["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])], @@ -179,17 +179,7 @@ export const mapGlobal = (ctx: Interpreter) => { ], ["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") - .map.entries() - .map(([key, item]) => wrap([key, item])), - ), - ], + ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], [ "forEach", 1, @@ -209,26 +199,26 @@ export const mapGlobal = (ctx: Interpreter) => { type SetRecord = { readonly size: number - readonly has: (item: unknown) => Effect.Effect - readonly keys: () => Effect.Effect, unknown, R> + readonly has: (item: Value) => Effect.Effect + readonly keys: () => Effect.Effect, unknown, R> } const loadSetRecord = ( ctx: Interpreter, - source: unknown, + source: Value, name: string, ): Effect.Effect, unknown, R> => { if (source instanceof SetObj) { return Effect.succeed({ size: source.set.size, - has: (item: unknown) => Effect.succeed(source.set.has(item)), + has: (item: Value) => Effect.succeed(source.set.has(item)), keys: () => Effect.succeed(source.set.values()), }) } if (source instanceof MapObj) { return Effect.succeed({ size: source.map.size, - has: (item: unknown) => Effect.succeed(source.map.has(item)), + has: (item: Value) => Effect.succeed(source.map.has(item)), keys: () => Effect.succeed(source.map.keys()), }) } @@ -247,10 +237,10 @@ const loadSetRecord = ( } return { size: Math.max(Math.trunc(size), 0), - has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean), + 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.iterator) + 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.`) }), @@ -262,8 +252,8 @@ const setOperation = ( ctx: Interpreter, target: SetObj, name: string, - source: unknown, -): Effect.Effect => + source: Value, +): Effect.Effect => Effect.gen(function* () { const other = yield* loadSetRecord(ctx, source, name) const copy = () => { @@ -342,8 +332,8 @@ export const setGlobal = (ctx: Interpreter) => { call: requiresNew("Set"), construct: (args, newTarget) => constructSet(ctx, args[0], prototypeFrom(newTarget, proto)), }) - const self = (thisValue: unknown, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`) - const wrap = (items: Array) => new Arr(builtins.Array, items) + const self = (thisValue: Value, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`) + const wrap = (items: Array) => new Arr(builtins.Array, items) const operation = (name: string): Method => [ name, 1, diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index 2cf9db6f1f3..f2cb8de3aa8 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -1,22 +1,8 @@ import { type Method, methods } from "../interpreter/native.js" -import { - entries, - get, - Arr, - Bytes, - DateObj, - MapObj, - Obj, - PromiseObj, - RegExpObj, - SetObj, - URLObj, - URLSearchParamsObj, - HeadersObj, -} from "../interpreter/objects.js" -import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js" +import { entries, get, Arr, Obj, type Value } from "../interpreter/objects.js" +import { ToolReference } from "../tool-runtime.js" +import { containsOpaqueReference } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToString } from "./value.js" const consoleMethods = ["log", "info", "debug", "warn", "error", "dir", "table"] @@ -43,7 +29,7 @@ export const consoleGlobal = (ctx: Interpreter) => { const MAX_CONSOLE_DEPTH = 32 -const formatConsoleMessage = (name: string, args: Array): string => { +const formatConsoleMessage = (name: string, args: Array): string => { if (name === "dir") return args.length === 0 ? "undefined" : formatValue(args[0]) if (name === "table") return formatConsoleTable(args[0], args[1]) const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" @@ -51,60 +37,27 @@ const formatConsoleMessage = (name: string, args: Array): string => { } /** One value as `console.log` shows it. */ -export const formatValue = (value: unknown): string => { +export const formatValue = (value: Value): string => { if (value === undefined) return "undefined" if (typeof value === "string") return value return formatConsoleValue(value, new Set(), 0) } -const formatConsoleValue = (value: unknown, seen: Set, depth: number): string => { +const formatConsoleValue = (value: Value, seen: Set, depth: number): string => { if (value === null || value === undefined) return "null" if (typeof value === "string") return JSON.stringify(value) - if (typeof value === "number" || typeof value === "boolean") return String(value) - if (typeof value !== "object") return String(value) - if (value instanceof PromiseObj) return "[Promise (await it to get its value)]" - if (value instanceof DateObj) return coerceToString(value) - if (value instanceof RegExpObj) return coerceToString(value) - if (value instanceof URLObj) return coerceToString(value) - if (value instanceof URLSearchParamsObj) return coerceToString(value) - if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}` - if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]` + if (!(value instanceof Obj)) return value instanceof ToolReference ? "[opaque reference]" : String(value) if (depth > MAX_CONSOLE_DEPTH) return "..." if (seen.has(value)) return "[Circular]" - if (value instanceof MapObj) { - seen.add(value) - try { - const items = Array.from(value.map.entries(), ([key, item]) => `[${formatItems([key, item], seen, depth + 1)}]`) - return `Map(${value.map.size}) [${items.join(",")}]` - } finally { - seen.delete(value) - } - } - if (value instanceof SetObj) { - seen.add(value) - try { - return `Set(${value.set.size}) [${formatItems([...value.set.values()], seen, depth + 1)}]` - } finally { - seen.delete(value) - } - } - if (isRuntimeReference(value)) return "[opaque reference]" seen.add(value) try { - if (value instanceof Arr) return `[${formatItems(value.items, seen, depth + 1)}]` - if (!(value instanceof Obj)) return "[object Object]" - return `{${entries(value) - .map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`) - .join(",")}}` + return value.inspect((item) => formatConsoleValue(item, seen, depth + 1)) } finally { seen.delete(value) } } -const formatItems = (items: Array, seen: Set, depth: number): string => - items.map((item) => formatConsoleValue(item, seen, depth)).join(",") - -const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => { +const formatConsoleTable = (value: Value, columnsArgument: Value): string => { if (value === undefined) return "undefined" if (containsOpaqueReference(value)) return "[opaque reference]" const columns = columnsArgument instanceof Arr ? columnsArgument.items.map(String) : undefined @@ -118,9 +71,9 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => } const consoleTableRows = ( - data: unknown, + data: Value, columns: ReadonlyArray | undefined, -): Array<{ readonly index: string; readonly values: Record }> => { +): Array<{ readonly index: string; readonly values: Record }> => { if (data instanceof Arr) { return data.items.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) } @@ -130,7 +83,7 @@ const consoleTableRows = ( return [{ index: "0", values: { Value: data } }] } -const consoleTableValues = (value: unknown, columns: ReadonlyArray | undefined): Record => { +const consoleTableValues = (value: Value, columns: ReadonlyArray | undefined): Record => { if (value instanceof Obj && !(value instanceof Arr)) { if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, get(value, column)])) return Object.fromEntries(entries(value)) @@ -138,7 +91,7 @@ const consoleTableValues = (value: unknown, columns: ReadonlyArray | und return { Value: value } } -const formatConsoleTableCell = (value: unknown): string => { +const formatConsoleTableCell = (value: Value): string => { if (value === undefined) return "" if (typeof value === "string") return value return formatConsoleValue(value, new Set(), 0) diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index 060529c06e4..57285ddf994 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -1,12 +1,11 @@ import { Effect } from "effect" import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js" import { rangeError } from "../interpreter/model.js" -import { DateObj, Obj } from "../interpreter/objects.js" +import { DateObj, Obj, coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js" import { toPrimitive, toPrimitiveNumber } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToNumber, coerceToString } from "./value.js" -const constructDate = (ctx: Interpreter, args: Array, proto: Obj) => { +const constructDate = (ctx: Interpreter, args: Array, proto: Obj) => { if (args.length === 0) return Effect.succeed(new DateObj(proto, Date.now())) if (args.length === 1) { const arg = args[0] @@ -83,7 +82,7 @@ export const dateGlobal = (ctx: Interpreter) => { ["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters))], ]) - const self = (thisValue: unknown, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`) + const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`) const iso = (value: DateObj) => { if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.") return new Date(value.time).toISOString() diff --git a/packages/codemode/src/stdlib/headers.ts b/packages/codemode/src/stdlib/headers.ts index 2ba3ec1c73c..7509b281466 100644 --- a/packages/codemode/src/stdlib/headers.ts +++ b/packages/codemode/src/stdlib/headers.ts @@ -1,11 +1,21 @@ import { Effect } from "effect" import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" import { IteratorSymbol, typeError } from "../interpreter/model.js" -import { define, entries, get, hidden, Arr, HeadersObj, IteratorObj, Obj } from "../interpreter/objects.js" +import { + define, + entries, + get, + hidden, + Arr, + HeadersObj, + IteratorObj, + Obj, + coerceToString, + isRuntimeReference, + type Value, +} from "../interpreter/objects.js" import { applyCollectionCallback } from "../interpreter/callback.js" -import { isRuntimeReference } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToString } from "./value.js" import { readPairs } from "./url.js" // The host validates header names and values and throws its own TypeError; the program gets one of its own. @@ -17,7 +27,7 @@ const attempt = (run: () => T): T => { } } -const constructHeaders = (ctx: Interpreter, init: unknown, proto: Obj): Effect.Effect => { +const constructHeaders = (ctx: Interpreter, init: Value, proto: Obj): Effect.Effect => { const wrap = (headers: Headers) => new HeadersObj(proto, headers) if (init === undefined) return Effect.succeed(wrap(new Headers())) return Effect.gen(function* () { @@ -40,10 +50,10 @@ export const headersGlobal = (ctx: Interpreter) => { call: requiresNew("Headers"), construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)), }) - const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`) - const wrap = (items: Array) => new Arr(builtins.Array, items) - const arg = (args: Array, index: number): string => coerceToString(args[index]) - const requireArgs = (name: string, args: Array, count: number): void => { + const self = (thisValue: Value, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`) + const wrap = (items: Array) => new Arr(builtins.Array, items) + const arg = (args: Array, index: number): string => coerceToString(args[index]) + const requireArgs = (name: string, args: Array, count: number): void => { if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`) } methods(builtins, proto, [ @@ -53,7 +63,8 @@ export const headersGlobal = (ctx: Interpreter) => { (thisValue, args) => { requireArgs("append", args, 2) const target = self(thisValue, "append").headers - return attempt(() => target.append(arg(args, 0), arg(args, 1))) + attempt(() => target.append(arg(args, 0), arg(args, 1))) + return undefined }, ], [ @@ -62,7 +73,8 @@ export const headersGlobal = (ctx: Interpreter) => { (thisValue, args) => { requireArgs("delete", args, 1) const target = self(thisValue, "delete").headers - return attempt(() => target.delete(arg(args, 0))) + attempt(() => target.delete(arg(args, 0))) + return undefined }, ], [ @@ -90,7 +102,8 @@ export const headersGlobal = (ctx: Interpreter) => { (thisValue, args) => { requireArgs("set", args, 2) const target = self(thisValue, "set").headers - return attempt(() => target.set(arg(args, 0), arg(args, 1))) + attempt(() => target.set(arg(args, 0), arg(args, 1))) + return undefined }, ], // Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them. @@ -104,15 +117,7 @@ export const headersGlobal = (ctx: Interpreter) => { 0, (thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())), ], - [ - "entries", - 0, - (thisValue) => - new IteratorObj( - builtins.Iterator, - Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])), - ), - ], + ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], [ "forEach", 1, diff --git a/packages/codemode/src/stdlib/iterator.ts b/packages/codemode/src/stdlib/iterator.ts index 87e3e1e9b84..1d52779bc3c 100644 --- a/packages/codemode/src/stdlib/iterator.ts +++ b/packages/codemode/src/stdlib/iterator.ts @@ -11,7 +11,7 @@ export const iteratorGlobals = (ctx: Interpreter): void => { "next", 0, (thisValue) => { - const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").iterator.next() + const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").source.next() return record(builtins.Object, { value: step.value, done: Boolean(step.done) }) }, ], diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 6f0daf92dd1..8a87463e513 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -6,7 +6,7 @@ import { checkStringLength } from "../interpreter/limits.js" import { syntaxError, typeError } from "../interpreter/model.js" import { typeofValue } from "../interpreter/references.js" import { fromJson, toJson } from "../data.js" -import { get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js" +import { get, keys, Arr, Obj, record, remove, set, type Value } from "../interpreter/objects.js" export const jsonGlobal = (ctx: Interpreter) => { const json = new Obj(ctx.builtins.Object) @@ -17,7 +17,7 @@ export const jsonGlobal = (ctx: Interpreter) => { return json } -const parse = (ctx: Interpreter, args: Array): Effect.Effect => { +const parse = (ctx: Interpreter, args: Array): Effect.Effect => { const text = args[0] if (typeof text !== "string") throw typeError("JSON.parse expects a string.") @@ -31,7 +31,7 @@ const parse = (ctx: Interpreter, args: Array): Effect.Effect => + const visit = (holder: Obj, key: string): Effect.Effect => Effect.gen(function* () { const value = get(holder, key) if (value instanceof Obj) { @@ -46,7 +46,7 @@ const parse = (ctx: Interpreter, args: Array): Effect.Effect(ctx: Interpreter, args: Array): Effect.Effect => { +const stringify = (ctx: Interpreter, args: Array): Effect.Effect => { const space = args[2] const indent = typeof space === "number" || typeof space === "string" ? space : undefined const replacer = args[1] diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index aad3190c37d..4e84936802c 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { constants, type Method, methods } from "../interpreter/native.js" import { typeError } from "../interpreter/model.js" -import { Obj } from "../interpreter/objects.js" +import { Obj, type Value } from "../interpreter/objects.js" import { preserveConsumerError } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" @@ -14,7 +14,7 @@ declare global { // Validate only the arguments a method consumes; like JS, extras are ignored // (so built-ins work as callbacks receiving (element, index, array)). -const number = (name: string, args: Array, index: number): number => { +const number = (name: string, args: Array, index: number): number => { if (index >= args.length) return Number.NaN const arg = args[index] if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`) diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index 498b5c436a3..369e66755de 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,7 +1,8 @@ import { constructor, constants, methods } from "../interpreter/native.js" +import { coerceToString, type Value } from "../interpreter/objects.js" import { rangeError, typeError } from "../interpreter/model.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coercion, coerceToString } from "./value.js" +import { coercion } from "./value.js" export const numberGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins @@ -39,11 +40,11 @@ export const numberGlobal = (ctx: Interpreter) => { ["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))], ]) - const self = (thisValue: unknown, name: string): number => { + const self = (thisValue: Value, name: string): number => { if (typeof thisValue === "number") return thisValue throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`) } - const optNum = (name: string, arg: unknown): number | undefined => { + const optNum = (name: string, arg: Value): number | undefined => { if (arg === undefined) return undefined if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`) return arg @@ -89,7 +90,7 @@ export const booleanGlobal = (ctx: Interpreter) => { length: 1, call: coercion(ctx, "Boolean").call, }) - const self = (thisValue: unknown, name: string): boolean => { + const self = (thisValue: Value, name: string): boolean => { if (typeof thisValue === "boolean") return thisValue throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`) } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 3f3960ebe19..201361b6c96 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -9,7 +9,6 @@ import { typeError, } from "../interpreter/model.js" import { - Callable, define, entries, enumerableKeys, @@ -20,23 +19,20 @@ import { keys, own, Arr, - Bytes, - DateObj, - ErrorObj, Obj, PromiseObj, - RegExpObj, set, + coerceToString, + type Value, } from "../interpreter/objects.js" import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js" import { invoke, preserveConsumerError } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" import { ToolReference } from "../tool-runtime.js" import { groupBy } from "./collections.js" -import { coerceToString } from "./value.js" // ToObject for enumeration. -export const enumerableSource = (ctx: Interpreter, label: string, value: unknown, node?: AstNode): Obj => { +export const enumerableSource = (ctx: Interpreter, label: string, value: Value, node?: AstNode): Obj => { if (value === null || value === undefined) { throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node) } @@ -54,7 +50,7 @@ export const enumerableSource = (ctx: Interpreter, label: string, value: u return new Obj(ctx.builtins.Object) } -export const objectAssign = (ctx: Interpreter, args: Array): unknown => { +export const objectAssign = (ctx: Interpreter, args: Array): Value => { const target = args[0] // JS would box a primitive target; wrappers and primitives cannot hold fields here. if (!(target instanceof Obj)) { @@ -75,7 +71,7 @@ export const objectAssign = (ctx: Interpreter, args: Array): unkn return target } -const objectFromEntries = (ctx: Interpreter, source: unknown): Effect.Effect => { +const objectFromEntries = (ctx: Interpreter, source: Value): Effect.Effect => { const out = new Obj(ctx.builtins.Object) return Effect.gen(function* () { const cursor = yield* ctx.iterate(source) @@ -98,29 +94,24 @@ const objectFromEntries = (ctx: Interpreter, source: unknown): Effect.Effe }) } -export const classTag = (value: unknown): string => { +const classTag = (value: Value): string => { if (value === null) return "Null" if (value === undefined) return "Undefined" - if (value instanceof Arr) return "Array" - if (value instanceof Callable) return "Function" - if (value instanceof ErrorObj) return "Error" - if (value instanceof DateObj) return "Date" - if (value instanceof RegExpObj) return "RegExp" - if (value instanceof Bytes) return "Uint8Array" + if (value instanceof Obj) return value.tag if (typeof value === "string") return "String" if (typeof value === "number") return "Number" if (typeof value === "boolean") return "Boolean" return "Object" } -const propertyKey = (value: unknown): PropertyKey => +const propertyKey = (value: Value): PropertyKey => value === AsyncIteratorSymbol || value === IteratorSymbol ? value : coerceToString(value) // Object constructs identically with or without new, like JS. Only `keys` copies its result into the // program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values. export const objectGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins - const construct = (args: Array): unknown => { + const construct = (args: Array): Value => { const first = args[0] if (first === null || first === undefined) return new Obj(builtins.Object) if (first instanceof Obj) return first diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts index 1791407942d..9f81ce5cc0b 100644 --- a/packages/codemode/src/stdlib/regexp.ts +++ b/packages/codemode/src/stdlib/regexp.ts @@ -2,9 +2,18 @@ import { Effect } from "effect" import type { Builtins } from "../interpreter/intrinsics.js" import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js" import { syntaxError, typeError } from "../interpreter/model.js" -import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js" +import { + define, + defineAccessor, + Arr, + Obj, + RegExpObj, + record, + coerceToNumber, + coerceToString, + type Value, +} from "../interpreter/objects.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToNumber, coerceToString } from "./value.js" const flagProperties = [ "hasIndices", @@ -23,7 +32,7 @@ const regexFailureReason = (error: unknown): string => const escapeRegexHint = 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' -export const toHostRegex = (arg: unknown, method: string, extraFlags = ""): RegExp => { +export const toHostRegex = (arg: Value, method: string, extraFlags = ""): RegExp => { // Native parity: an undefined pattern behaves as an empty pattern. if (arg === undefined) return new RegExp("", extraFlags) if (arg instanceof RegExpObj) return arg.regex @@ -53,7 +62,7 @@ export const matchToValue = (builtins: Builtins, match: RegExpMatchArray): Arr = return result } -export const constructRegExp = (builtins: Builtins, args: Array, proto: Obj = builtins.RegExp): RegExpObj => { +export const constructRegExp = (builtins: Builtins, args: Array, proto: Obj = builtins.RegExp): RegExpObj => { const first = args[0] const pattern = first instanceof RegExpObj ? first.regex.source : first === undefined ? "" : coerceToString(first) const flagsArg = args[1] @@ -96,7 +105,7 @@ export const regexpGlobal = (ctx: Interpreter) => { ], ]) - const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`) + const self = (thisValue: Value, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`) defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source) defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags) // The host regex holds the only lastIndex, so exec/test and the String methods share one counter. diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index 7caa0571b26..2e0f138c0f8 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -1,16 +1,26 @@ import { Effect } from "effect" -import { constructor, fn, type Method, methods } from "../interpreter/native.js" +import { constructor, fn, type Impl, type Method, methods } from "../interpreter/native.js" import { checkArrayLength, checkStringLength } from "../interpreter/limits.js" import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js" -import { define, hidden, Arr, IteratorObj, PromiseObj, RegExpObj, record } from "../interpreter/objects.js" +import { + define, + hidden, + Arr, + IteratorObj, + RegExpObj, + record, + coerceToNumber, + coerceToString, + type Value, +} from "../interpreter/objects.js" import { containsOpaqueReference, typeofValue } from "../interpreter/references.js" import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" import { matchToValue, toHostRegex } from "./regexp.js" -import { coerceToNumber, coerceToString, coercion } from "./value.js" +import { coercion } from "./value.js" // console is intercepted by the interpreter before reaching here. -const requireDataArgument = (name: string, index: number, arg: unknown): unknown => { +const requireDataArgument = (name: string, index: number, arg: Value): Value => { if (containsOpaqueReference(arg)) { throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`) } @@ -29,21 +39,23 @@ const replaceWithCallback = ( ctx: Interpreter, value: string, name: "replace" | "replaceAll", - args: Array, -): Effect.Effect => { + args: Array, +): Effect.Effect => { const builtins = ctx.builtins const apply = applyCollectionCallback(ctx, args[1], `String.${name}`) - const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] - const collect = (...callbackArgs: Array): string => { + const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] + // The host calls back with (match, ...captures, offset, string, groups?); only groups is not already a Value. + const collect = ( + ...callbackArgs: Array> + ): string => { const match = callbackArgs[0] - const groups = callbackArgs[callbackArgs.length - 1] - const hasGroups = groups !== null && typeof groups === "object" + const hasGroups = typeof callbackArgs.at(-1) === "object" const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] if (typeof match !== "string" || typeof offset !== "number") { throw typeError(`String.${name} produced an invalid replacement match.`) } - if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(builtins.Object, groups as Record) - matches.push({ match, offset, args: callbackArgs }) + const args = callbackArgs.map((arg) => (typeof arg === "object" ? record(builtins.Object, arg) : arg)) + matches.push({ match, offset, args }) return match } @@ -63,10 +75,7 @@ const replaceWithCallback = ( let end = 0 for (const match of matches) { const replacement = yield* apply(match.args) - output.push( - value.slice(end, match.offset), - replacement instanceof PromiseObj ? "[object Promise]" : coerceToString(replacement), - ) + output.push(value.slice(end, match.offset), coerceToString(replacement)) end = match.offset + match.match.length } output.push(value.slice(end)) @@ -99,7 +108,7 @@ export const stringGlobal = (ctx: Interpreter) => { codeUnits("fromCodePoint", String.fromCodePoint), ]) - const self = (thisValue: unknown, name: string): string => { + const self = (thisValue: Value, name: string): string => { if (typeof thisValue === "string") return thisValue if (thisValue === null || thisValue === undefined) { throw typeError(`String.prototype.${name} called on null or undefined.`) @@ -107,26 +116,26 @@ export const stringGlobal = (ctx: Interpreter) => { return coerceToString(thisValue) } // Coerce arguments like native JS; opaque runtime references still reject. - const str = (name: string, args: Array, index: number): string => + const str = (name: string, args: Array, index: number): string => coerceToString(requireDataArgument(name, index, args[index])) - const num = (name: string, args: Array, index: number): number => + const num = (name: string, args: Array, index: number): number => coerceToNumber(requireDataArgument(name, index, args[index])) - const optNum = (name: string, args: Array, index: number): number | undefined => + const optNum = (name: string, args: Array, index: number): number | undefined => args[index] === undefined ? undefined : num(name, args, index) - const optStr = (name: string, args: Array, index: number): string | undefined => + const optStr = (name: string, args: Array, index: number): string | undefined => args[index] === undefined ? undefined : str(name, args, index) - const rejectRegex = (name: string, args: Array): void => { + const rejectRegex = (name: string, args: Array): void => { if (args[0] instanceof RegExpObj) { throw typeError( `String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`, ) } } - const simple = (name: string, length: number, op: (value: string, args: Array) => unknown): Method => [ - name, - length, - (thisValue, args) => op(self(thisValue, name), args), - ] + const simple = ( + name: string, + length: number, + op: (value: string, args: Array) => ReturnType, + ): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)] const replace = (name: "replace" | "replaceAll") => simple(name, 2, (value, args) => { if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args) @@ -218,7 +227,7 @@ export const stringGlobal = (ctx: Interpreter) => { `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, ) } - const matches: Array = [] + const matches: Array = [] for (const match of value.matchAll(pattern)) { checkArrayLength(matches.length + 1) matches.push(matchToValue(builtins, match)) diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index 4e31489359f..85fbd1574d5 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -7,17 +7,17 @@ import { entries, get, hidden, - isWrapper, Arr, IteratorObj, Obj, URLObj, URLSearchParamsObj, + coerceToString, + isRuntimeReference, + type Value, } from "../interpreter/objects.js" -import { isRuntimeReference } from "../interpreter/references.js" import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToString } from "./value.js" const urlProperties = [ "href", @@ -52,12 +52,12 @@ export const uriGlobal = (ctx: Interpreter, name: UriFunction) => } }) -const urlArgument = (value: unknown): string => (value instanceof URLObj ? value.url.href : coerceToString(value)) +const urlArgument = (value: Value): string => (value instanceof URLObj ? value.url.href : coerceToString(value)) export const urlGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins const proto = builtins.URL - const construct = (args: Array, into: Obj): URLObj => { + const construct = (args: Array, into: Obj): URLObj => { if (args.length === 0) { throw typeError("new URL(...) requires a URL string and an optional base URL.") } @@ -92,7 +92,7 @@ export const urlGlobal = (ctx: Interpreter) => { ] methods(builtins, url, [parse("canParse"), parse("parse")]) - const self = (thisValue: unknown, name: string) => receiver(URLObj, thisValue, `URL.prototype.${name}`) + const self = (thisValue: Value, name: string) => receiver(URLObj, thisValue, `URL.prototype.${name}`) for (const name of urlProperties) { defineAccessor( proto, @@ -119,7 +119,7 @@ export const urlGlobal = (ctx: Interpreter) => { return url } -const readPair = (ctx: Interpreter, value: unknown, label: string): Effect.Effect, unknown, R> => +const readPair = (ctx: Interpreter, value: Value, label: string): Effect.Effect, unknown, R> => Effect.gen(function* () { const cursor = yield* ctx.iterate(value) if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`) @@ -142,7 +142,7 @@ const readPair = (ctx: Interpreter, value: unknown, label: string): Effect */ export const readPairs = ( ctx: Interpreter, - init: unknown, + init: Value, label: string, ): Effect.Effect | undefined, unknown, R> => Effect.gen(function* () { @@ -161,7 +161,7 @@ export const readPairs = ( const constructURLSearchParams = ( ctx: Interpreter, - init: unknown, + init: Value, proto: Obj, ): Effect.Effect => { const wrap = (params: URLSearchParams) => new URLSearchParamsObj(proto, params) @@ -177,7 +177,6 @@ const constructURLSearchParams = ( if (isRuntimeReference(init)) { throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.") } - if (isWrapper(init)) return wrap(new URLSearchParams()) if (!(init instanceof Obj)) { throw typeError( "new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.", @@ -197,11 +196,11 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { call: requiresNew("URLSearchParams"), construct: (args, newTarget) => constructURLSearchParams(ctx, args[0], prototypeFrom(newTarget, proto)), }) - const self = (thisValue: unknown, name: string) => + const self = (thisValue: Value, name: string) => receiver(URLSearchParamsObj, thisValue, `URLSearchParams.prototype.${name}`) - const wrap = (items: Array) => new Arr(builtins.Array, items) - const arg = (args: Array, index: number): string => coerceToString(args[index]) - const requireArgs = (name: string, args: Array, count: number): void => { + const wrap = (items: Array) => new Arr(builtins.Array, items) + const arg = (args: Array, index: number): string => coerceToString(args[index]) + const requireArgs = (name: string, args: Array, count: number): void => { if (args.length < count) { throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`) } @@ -272,17 +271,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { ], ["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") - .params.entries() - .map(([key, value]) => wrap([key, value])), - ), - ], + ["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))], ["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()], [ "forEach", diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 68f577acd9d..4308ad791da 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -1,64 +1,13 @@ import { fn } from "../interpreter/native.js" import { typeError } from "../interpreter/model.js" -import { - get, - isWrapper, - type Native, - Arr, - Bytes, - DateObj, - ErrorObj, - MapObj, - RegExpObj, - SetObj, - URLObj, - URLSearchParamsObj, - HeadersObj, -} from "../interpreter/objects.js" +import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js" import type { Interpreter } from "../interpreter/interpreter.js" export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) -/** The built-in string form of a value, without consulting program-defined `toString` methods. */ -export const coerceToString = (value: unknown): string => { - if (value === null) return "null" - if (value === undefined) return "undefined" - if (value instanceof DateObj) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" - if (value instanceof RegExpObj) return `/${value.regex.source}/${value.regex.flags}` - if (value instanceof MapObj) return "[object Map]" - if (value instanceof SetObj) return "[object Set]" - if (value instanceof URLObj) return value.url.href - if (value instanceof URLSearchParamsObj) return value.params.toString() - if (value instanceof HeadersObj) return "[object Headers]" - if (value instanceof Bytes) return value.bytes.join(",") - if (value instanceof ErrorObj) { - // Match Error.prototype.toString: "name: message", or just one when the other is empty. - const name = get(value, "name") - const message = get(value, "message") - const shownName = typeof name === "string" ? name : "Error" - const shownMessage = typeof message === "string" ? message : "" - if (shownMessage === "") return shownName - if (shownName === "") return shownMessage - return `${shownName}: ${shownMessage}` - } - if (value instanceof Arr) { - return value.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") - } - if (typeof value === "object") return "[object Object]" - return String(value) -} - -export const coerceToNumber = (value: unknown): number => { - if (value instanceof DateObj) return value.time - if (value instanceof Bytes) return Number(coerceToString(value)) - if (isWrapper(value)) return Number.NaN - if (value instanceof Arr) return Number(coerceToString(value)) - return value !== null && typeof value === "object" ? Number.NaN : Number(value) -} - export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN" -const coerce = (ctx: Interpreter, name: Coercion, args: Array): unknown => { +const coerce = (ctx: Interpreter, name: Coercion, args: Array): Value => { // Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the // other coercers match native through the undefined-argument path below. if (args.length === 0) { @@ -66,15 +15,6 @@ const coerce = (ctx: Interpreter, name: Coercion, args: Array): u if (name === "String") return "" } const raw = args[0] - if (isWrapper(raw)) { - if (name === "Boolean") return true - if (name === "Number") return coerceToNumber(raw) - if (name === "String") return coerceToString(raw) - if (name === "isFinite") return Number.isFinite(coerceToNumber(raw)) - if (name === "isNaN") return Number.isNaN(coerceToNumber(raw)) - if (name === "parseInt") return parseInt(coerceToString(raw)) - return parseFloat(coerceToString(raw)) - } if (name === "Number") return coerceToNumber(raw) if (name === "Boolean") return Boolean(raw) if (name === "isFinite") return Number.isFinite(coerceToNumber(raw)) diff --git a/packages/codemode/src/stdlib/web.ts b/packages/codemode/src/stdlib/web.ts index d549c77ba67..6bf6aa3b6f8 100644 --- a/packages/codemode/src/stdlib/web.ts +++ b/packages/codemode/src/stdlib/web.ts @@ -1,9 +1,8 @@ import { fn, methods } from "../interpreter/native.js" import { typeError } from "../interpreter/model.js" -import { Bytes, Obj } from "../interpreter/objects.js" +import { Bytes, Obj, coerceToString } from "../interpreter/objects.js" import { describeValue } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coerceToString } from "./value.js" // WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a // TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have. diff --git a/packages/codemode/test/for-await-test262.test.ts b/packages/codemode/test/for-await-test262.test.ts index 6b2e682794e..fcbbcb85ccb 100644 --- a/packages/codemode/test/for-await-test262.test.ts +++ b/packages/codemode/test/for-await-test262.test.ts @@ -529,6 +529,6 @@ describe("Test262 for-await-of adaptations", () => { 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("or custom iterator value") + expect(result.error.message).toContain("requires an iterable value, received a data object") }) }) diff --git a/packages/codemode/test/generator-test262.test.ts b/packages/codemode/test/generator-test262.test.ts index c79274f94d5..abf2b6ccad7 100644 --- a/packages/codemode/test/generator-test262.test.ts +++ b/packages/codemode/test/generator-test262.test.ts @@ -1030,7 +1030,7 @@ describe("confined generators", () => { const params = new URLSearchParams(entries()) return [events, params.toString()] `), - ).toEqual([["first", "second", "pair close", "outer close"], "%5Bobject+Object%5D=2"]) + ).toEqual([["first", "second", "pair close", "outer close"], "%5Bobject+Promise%5D=2"]) }) test("validates URLSearchParams pair lengths after converting the outer sequence", async () => { diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index f2394ab2414..9af41ca852d 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1267,6 +1267,32 @@ describe("built-in iterators", () => { }) }) +describe("Object.prototype.toString", () => { + test("reports the built-in kind it is inherited by, as JS does through Symbol.toStringTag", async () => { + expect( + await value(` + return [ + new Map().toString(), new Set().toString(), new Headers().toString(), Promise.resolve(1).toString(), + [1].values().toString(), ({}).toString(), String(new Map()), String(Promise.resolve(1)), + \`\${new Set([1])}\`, [new Map()] + "", new Map() == "[object Map]", + ] + `), + ).toEqual([ + "[object Map]", + "[object Set]", + "[object Headers]", + "[object Promise]", + "[object Iterator]", + "[object Object]", + "[object Map]", + "[object Promise]", + "[object Set]", + "[object Map]", + true, + ]) + }) +}) + describe("toLocaleString", () => { test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => { expect( diff --git a/packages/codemode/test/test262/run.ts b/packages/codemode/test/test262/run.ts index 4ec05a50cb9..d1894699100 100644 --- a/packages/codemode/test/test262/run.ts +++ b/packages/codemode/test/test262/run.ts @@ -9,7 +9,7 @@ import type { Interpreter } from "../../src/interpreter/interpreter.js" import { Throw } from "../../src/interpreter/model.js" import { createErrorValue } from "../../src/interpreter/intrinsics.js" import { constructor, fn, methods } from "../../src/interpreter/native.js" -import { Callable, define, get, hidden, Arr, Fn, Obj } from "../../src/interpreter/objects.js" +import { Callable, define, get, hidden, Arr, Fn, Obj, type Value } from "../../src/interpreter/objects.js" import { ToolRuntime } from "../../src/tool-runtime.js" export const root = import.meta.dir @@ -64,10 +64,7 @@ export const run = async (file: string): Promise => { return { status: "pass" } } -const harness = ( - ctx: Interpreter, - onDone: (error: unknown) => void, -): ReadonlyArray => { +const harness = (ctx: Interpreter, onDone: (error: Value) => void): ReadonlyArray => { const builtins = ctx.builtins const test262Prototype = new Obj(builtins.Object) define(test262Prototype, "name", "Test262Error", hidden) @@ -90,7 +87,7 @@ const harness = ( methods(builtins, compareArray, [["format", 1, (_, args) => show(args[0])]]) const assert = fn(builtins, "assert", 2, (_, args) => args[0] === true - ? Effect.void + ? Effect.undefined : fail(args[1] === undefined ? `Expected true but got ${show(args[0])}` : String(args[1])), ) methods(builtins, assert, [ @@ -99,7 +96,7 @@ const harness = ( 3, (_, args) => Object.is(args[0], args[1]) - ? Effect.void + ? Effect.undefined : fail(`${prefix(args[2])}Expected SameValue(«${show(args[0])}», «${show(args[1])}») to be true`), ], [ @@ -108,14 +105,14 @@ const harness = ( (_, args) => Object.is(args[0], args[1]) ? fail(`${prefix(args[2])}Expected SameValue(«${show(args[0])}», «${show(args[1])}») to be false`) - : Effect.void, + : Effect.undefined, ], [ "compareArray", 3, (_, args) => compare(args[0], args[1]) - ? Effect.void + ? Effect.undefined : fail( `Actual ${show(args[0])} and expected ${show(args[1])} should have the same contents. ${prefix(args[2])}`, ), @@ -132,7 +129,7 @@ const harness = ( const thrown = materialize(ctx, Cause.squash(cause)) if (!(thrown instanceof Obj)) return fail(`${prefix(args[2])}Thrown value was not an object!`) const actual = get(thrown, "constructor") - if (actual === args[0]) return Effect.void + if (actual === args[0]) return Effect.undefined return fail(`${prefix(args[2])}Expected a ${expected} but got a ${show(actual)}`) }, onSuccess: () => @@ -146,7 +143,13 @@ const harness = ( ["assert", assert], ["compareArray", compareArray], ["Test262Error", test262Error], - ["$DONE", fn(builtins, "$DONE", 1, (_, args) => onDone(args[0]))], + [ + "$DONE", + fn(builtins, "$DONE", 1, (_, args) => { + onDone(args[0]) + return undefined + }), + ], [ "$DONOTEVALUATE", fn(builtins, "$DONOTEVALUATE", 0, () =>