diff --git a/docs/src/interop/jco-std.md b/docs/src/interop/jco-std.md index 2c81fbdef..93ef6d106 100644 --- a/docs/src/interop/jco-std.md +++ b/docs/src/interop/jco-std.md @@ -136,7 +136,10 @@ used by the Hono adapter; assert and Buffer do not add further capabilities. whose semantics an individual-request interface cannot preserve; - `node:readline` and `node:readline/promises`, ported from Node 24.20 for line parsing, questions, async iteration and terminal editing over supplied streams, - with no additional WIT capability; and + with no additional WIT capability; +- `node:repl`, ported from Node 24.20 over that readline port for global-scope + evaluation, keyword commands, completion and top-level `await`, with no + additional WIT capability and acorn bundled only when the REPL is imported; and - `node:stream/consumers`, implemented as portable iterable collection over the engine's Blob, typed-array, and text-codec globals; and - the experimental Node 24.20 `node:stream/iter` API, including portable sources, diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index a206c0d17..31a75b11b 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -112,6 +112,7 @@ is planned. | `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | | `node:perf_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/perf-hooks` | Portable timing and observers; native telemetry throws. Runtime requirements are described below. | | `node:readline`, `node:readline/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/readline` and `/readline/promises` | Node 24.20 line parsing, questions, terminal editing and cursor actions over supplied streams. No WIT capability. | +| `node:repl` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl` | Node 24.20 REPL over the readline port and supplied streams; `useGlobal: true` only, bundles acorn -- see below. No WIT capability. | | `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | | `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | | `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | @@ -389,6 +390,73 @@ throw an explicit `ERR_JCO_UNSUPPORTED_NODE_API` for that operation. Cancellatio accepts supplied AbortSignals; readline does not install missing Abort globals. QuickJS async entry functions must be declared `async func` in WIT. +### REPL + +`node:repl` ports [Node v24.20.0's REPL](https://github.com/nodejs/node/blob/v24.20.0/lib/repl.js) +on top of the readline port: `repl.start()`, `REPLServer`, the `.break`, `.clear`, +`.exit`, `.help`, `.editor` keywords and `defineCommand()`, tab completion, +in-memory history and reverse search, top-level `await`, recoverable multi-line +input, `_` and `_error`, and the `'exit'` and `'reset'` events. The pinned unenv +repl module is stubs and is not used. + +Applications keep the ordinary import and supply the streams: + +```js +import repl from "node:repl"; + +export function attach(input, output) { + const server = repl.start({ prompt: "app> ", input, output, useGlobal: true }); + server.context.app = { version: "1.0.0" }; + server.on("exit", () => output.write("bye\n")); + return server; +} +``` + +Bundle with `jco componentize app.js --bundle --wit wit -o app.wasm`. The REPL +requires no WIT imports; `input` and `output` decide where the session goes. +Without a `process` global they are required, since there is no stdin or stdout +to fall back to. + +#### Global scope only + +Node's default `useGlobal: false` runs each line in a separate `vm` context, a +second realm with its own globals. No component engine can create one, so that +option -- given explicitly or omitted -- is refused at construction with +`ERR_JCO_UNSUPPORTED_NODE_API`. With `useGlobal: true` evaluation is an indirect +`eval`, which is exactly `vm.runInThisContext`: `replServer.context` is +`globalThis`, `.clear` is an alias for `.break`, and assigning to the context +exposes values as documented. Node's script scope keeps top-level `let`, `const` +and `class` bindings across lines; an `eval` does not, so the REPL rewrites those +declarations to persist them. The trade is that `const` is not enforced between +lines and a later redeclaration is accepted -- the same trade Node documents for +lines containing `await`. `REPL_MODE_STRICT` is refused for the same reason: a +strict-mode eval cannot bind declarations in the global scope at all. + +#### Why acorn + +Node's REPL vendors [acorn](https://github.com/acornjs/acorn); this port depends +on the same versions from npm (`acorn@8.17.0`, `acorn-walk@8.3.5`). A parser is +needed for what the engine cannot answer: whether a line is *incomplete* (show the +`...` prompt) or *wrong* (print the error) -- engine `SyntaxError` messages differ +between SpiderMonkey and QuickJS and cannot drive that decision -- rewriting +top-level `await` into an async wrapper, and locating the expression to +tab-complete. acorn also serves as the compile step Node performs with +`vm.Script`, so syntax errors read the same on every engine. It is bundled only +when `node:repl` is imported; a component without the REPL does not carry it. +Bundle size grows by roughly 1.2 MB (QuickJS) to 1.4 MB (StarlingMonkey). + +#### Boundaries + +| Surface | Behavior | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useGlobal: false` or omitted, `REPL_MODE_STRICT`, `breakEvalOnSigint` | Refused at construction with `ERR_JCO_UNSUPPORTED_NODE_API`. Ctrl+C still arrives as a keypress and emits `'SIGINT'`. | +| `REPLServer()` without `new` (DEP0185) | Throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API` before reading any argument. | +| `preview` | Accepted and ignored, as in a Node built without an inspector. | +| `.save`, `.load`, `setupHistory(filePath)` | Print Node's own failure text (`Failed to save: …`, `Could not open history file`) and continue; a component has no filesystem unless the application supplies one. | +| Core modules in the context | Not auto-loaded: `fs` is a `ReferenceError` unless the application put it on the context. `require` throws `ERR_JCO_UNSUPPORTED_NODE_API`; `require.resolve` answers as Node does. | +| Errors | Synchronous errors print as `Uncaught …` with the evaluated frames only; errors thrown later by asynchronous work are not routed back, since there is no `node:domain`. | +| `writer` | Jco's portable inspector shared with `node:console`: the same values as `util.inspect`, without line breaking, `showProxy`, `showHidden`, `getters` or `sorted`. | + ### Child processes and host capabilities A WebAssembly guest cannot spawn a process itself. When bundled source imports diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 38f3d7646..fba58dcd3 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -59,6 +59,7 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/stream` and `/stream/promises` | Classic Node streams over readable-stream 4.7.0, with Node 24 adapters | | `wasi/0.2.x/node/24.x.x/stream/consumers` | Portable `node:stream/consumers`, Node 24 | | `wasi/0.2.x/node/24.x.x/stream/iter` | Experimental iterable streams from Node 24.20 | +| `wasi/0.2.x/node/24.x.x/repl` | `node:repl` over the readline port; global-scope evaluation only | | `wasi/0.2.x/node/24.x.x/child-process/host` | Deny-by-default host for `jco:node/child-process` | | `wasi/0.2.x/node/24.x.x/child-process/host/node` | Opt-in host over the runtime's real `node:child_process` | | `wasi/0.2.x/node/24.x.x/cluster/host` | Deny-by-default host for `jco:node/cluster` | @@ -171,6 +172,10 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components module-level functions unenv leaves unimplemented; - `node:string_decoder`, implemented guest-locally by `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder`; +- `node:repl`, implemented by + `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl` over the readline port. + Evaluation is global-scope only (`useGlobal: true`); the module needs no WIT + capability and is the only jco-std module that bundles `acorn`; - `node:module`, implemented by `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module`. Classification, source maps and `require.resolve` are exact; everything that loads throws, @@ -285,6 +290,47 @@ algorithms. It also retains the legacy `text`, `lastChar`, `lastNeed`, and `lastTotal` prototype members that remain present in Node 24, although new code should use the documented constructor, `write()`, and `end()` API. +### REPL + +The versioned repl module ports Node 24.20.0's `node:repl` on top of the readline +port: `repl.start()`, `REPLServer`, keyword commands, `defineCommand()`, tab +completion, in-memory history, reverse search, editor mode, top-level `await`, +recoverable multi-line input and the `_`/`_error` conventions. The application +supplies the streams; no WIT capability is required. + +```js +import repl from "node:repl"; + +export function attach(input, output) { + const server = repl.start({ prompt: "app> ", input, output, useGlobal: true }); + server.context.app = { version: "1.0.0" }; + server.defineCommand("ping", { + help: "Answer pong", + action() { + this.output.write("pong\n"); + this.displayPrompt(); + }, + }); + return server; +} +``` + +Only `useGlobal: true` is supported. No component engine can create a second +realm, so Node's default of a separate context is refused at construction with +`ERR_JCO_UNSUPPORTED_NODE_API`; with the global scope, evaluation is an indirect +`eval`, which is what `vm.runInThisContext` is. Top-level `let`, `const` and +`class` are rewritten so they persist between lines as they do in Node, at the +cost that `const` is not enforced across lines. Strict mode, `breakEvalOnSigint`, +history files, `.save` and `.load` are refused or report Node's own failure text, +and core modules are not auto-loaded into the context. + +`acorn` 8.17.0 and `acorn-walk` 8.3.5, the versions Node vendors, are bundled by +this module alone. The REPL needs a parser the engine cannot replace: deciding +whether input is incomplete or wrong (engine `SyntaxError` messages differ +between SpiderMonkey and QuickJS), rewriting top-level `await`, and locating the +expression to tab-complete. A component that does not import `node:repl` does +not carry acorn. + ### Errors globals Node's [Errors API](https://nodejs.org/docs/latest-v24.x/api/errors.html) is not an diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 9c8692aaf..12aafc295 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -439,6 +439,11 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.js", "default": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.js" + }, + "./wasi/0.2.x/node/24.x.x/repl": { + "types": "./dist/wasi/0.2.x/node/24.x.x/repl.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/repl.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/repl.js" } }, "scripts": { @@ -458,6 +463,8 @@ "build:bindings:wasi:tls": "node scripts/generate-tls-bindings.mjs" }, "dependencies": { + "acorn": "8.17.0", + "acorn-walk": "8.3.5", "minimatch": "10.2.6", "readable-stream": "4.7.0" }, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/console/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/console/core.ts index 37d871c7e..13534c220 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/console/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/console/core.ts @@ -3,8 +3,10 @@ // Node.js is distributed under the MIT license. See https://github.com/nodejs/node. import { unsupportedNodeApi } from "../errors/core.js"; +import { inspect as inspectValue, type InspectOptions } from "../internal/inspect.js"; + +export type { InspectOptions }; -const customInspect = Symbol.for("nodejs.util.inspect.custom"); const clocks = new WeakMap number>(); const consoleMethods = [ "log", @@ -37,21 +39,6 @@ export interface WritableStream { getColorDepth?(): number; } -export interface InspectOptions { - showHidden?: boolean; - colors?: boolean; - depth?: number | null; - maxArrayLength?: number | null; - maxStringLength?: number | null; - breakLength?: number; - compact?: boolean | number; - customInspect?: boolean; - showProxy?: boolean; - sorted?: boolean | ((left: string, right: string) => number); - getters?: boolean | "get" | "set"; - numericSeparator?: boolean; -} - export interface ConsoleOptions { stdout: WritableStream; stderr?: WritableStream; @@ -84,128 +71,6 @@ function validateStream(value: unknown, name: string): asserts value is Writable } } -function quote(value: string): string { - return `'${value - .replaceAll("\\", "\\\\") - .replaceAll("'", "\\'") - .replaceAll("\n", "\\n") - .replaceAll("\r", "\\r") - .replaceAll("\t", "\\t")}'`; -} - -function color(code: number, value: string, enabled: boolean): string { - return enabled ? `\u001b[${code}m${value}\u001b[39m` : value; -} - -function primitive(value: unknown, colors: boolean): string | undefined { - if (value === undefined) { - return color(90, "undefined", colors); - } - if (value === null) { - return colors ? "\u001b[1mnull\u001b[22m" : "null"; - } - if (typeof value === "string") { - return color(32, quote(value), colors); - } - if (typeof value === "number") { - return color(33, Object.is(value, -0) ? "-0" : String(value), colors); - } - if (typeof value === "bigint") { - return color(33, `${value}n`, colors); - } - if (typeof value === "boolean") { - return color(33, String(value), colors); - } - if (typeof value === "symbol") { - return color(32, String(value), colors); - } - if (typeof value === "function") { - return color(36, `[Function${value.name ? `: ${value.name}` : ""}]`, colors); - } - return undefined; -} - -function inspectValue( - value: unknown, - options: InspectOptions = {}, - seen = new Set(), - level = 0, -): string { - const simple = primitive(value, options.colors === true); - if (simple !== undefined) { - return simple; - } - - const object = value as object; - if (seen.has(object)) { - return color(36, "[Circular]", options.colors === true); - } - const depth = options.depth === undefined ? 2 : options.depth; - if (depth !== null && level > depth) { - const name = object.constructor?.name ?? "Object"; - return color(36, `[${name}]`, options.colors === true); - } - - if (options.customInspect !== false) { - const hook = (object as { [customInspect]?: unknown })[customInspect]; - if (typeof hook === "function") { - return String( - hook.call(object, depth === null ? null : depth - level, options, inspectValue), - ); - } - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); - } - if (value instanceof RegExp) { - return String(value); - } - if (value instanceof Error) { - return value.stack ?? `${value.name}: ${value.message}`; - } - - seen.add(object); - let result: string; - if (Array.isArray(value)) { - const limit = options.maxArrayLength === null ? value.length : (options.maxArrayLength ?? 100); - const entries = value - .slice(0, limit) - .map((item) => inspectValue(item, options, seen, level + 1)); - if (value.length > limit) { - entries.push(`... ${value.length - limit} more item${value.length - limit === 1 ? "" : "s"}`); - } - result = `[ ${entries.join(", ")} ]`; - } else if (value instanceof Map) { - const entries = Array.from( - value, - ([key, item]) => - `${inspectValue(key, options, seen, level + 1)} => ${inspectValue(item, options, seen, level + 1)}`, - ); - result = `Map(${value.size}) { ${entries.join(", ")} }`; - } else if (value instanceof Set) { - const entries = Array.from(value, (item) => inspectValue(item, options, seen, level + 1)); - result = `Set(${value.size}) { ${entries.join(", ")} }`; - } else if (ArrayBuffer.isView(value)) { - const typed = value as unknown as { readonly length?: number; [index: number]: unknown }; - const length = typed.length ?? 0; - const entries = Array.from({ length }, (_, index) => - inspectValue(typed[index], options, seen, level + 1), - ); - result = `${object.constructor?.name ?? "TypedArray"}(${length}) [ ${entries.join(", ")} ]`; - } else { - const entries = Object.keys(object).map((key) => { - const displayKey = /^[A-Za-z_$][\w$]*$/.test(key) ? key : quote(key); - const item = (object as Record)[key]; - return `${displayKey}: ${inspectValue(item, options, seen, level + 1)}`; - }); - const prefix = - object.constructor && object.constructor !== Object ? `${object.constructor.name} ` : ""; - result = `${prefix}{ ${entries.join(", ")} }`; - } - seen.delete(object); - return result; -} - function json(value: unknown): string { try { return JSON.stringify(value) ?? "undefined"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/inspect.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/inspect.ts new file mode 100644 index 000000000..86a4335d6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/inspect.ts @@ -0,0 +1,195 @@ +/** + * Portable value inspection shared by `node:console` and `node:repl`. + * + * Locally written, guided by Node.js v24.20.0's `lib/internal/util/inspect.js` behavior; no Node + * implementation code is copied. It covers the presentation the console and REPL shims need -- + * primitives, colors, depth, `[Circular]`, custom inspection hooks, arrays, Map, Set, typed arrays, + * dates, regular expressions, errors and plain objects -- and deliberately not Node's line + * breaking (`breakLength`/`compact`), `showProxy`, `showHidden`, getters or sorting. + */ + +const customInspect = Symbol.for("nodejs.util.inspect.custom"); + +/** + * An error's stack, led by its `name: message` header. + * + * V8 and SpiderMonkey put the header on the stack's first line; QuickJS stores frames only, and a + * stack whose frames have been trimmed away may be empty. Node's inspector rebuilds the header in + * the same situations. + */ +function errorText(error: Error): string { + const name = typeof error.name === "string" && error.name ? error.name : "Error"; + const message = typeof error.message === "string" ? error.message : ""; + const header = message ? `${name}: ${message}` : name; + const stack = typeof error.stack === "string" ? error.stack : ""; + if (stack === "") { + return header; + } + // Only a stack made purely of frames is missing its header; anything else -- a normal V8 or + // SpiderMonkey stack, or one decorated with source context -- is returned untouched. + return stack.split("\n").every(isFrameLine) ? `${header}\n${stack}` : stack; +} + +/** A V8/QuickJS ` at …` frame or a SpiderMonkey `name@file:line:col` frame. */ +function isFrameLine(line: string): boolean { + return /^\s+at\s/.test(line) || /^[^\s@]*@\S+:\d+:\d+$/.test(line); +} + +/** `{ a, b }` with entries, `{}` without: Node prints empty containers without inner padding. */ +function wrap(open: string, entries: string[], close: string): string { + return entries.length === 0 ? `${open}${close}` : `${open} ${entries.join(", ")} ${close}`; +} + +export interface InspectOptions { + showHidden?: boolean; + colors?: boolean; + depth?: number | null; + maxArrayLength?: number | null; + maxStringLength?: number | null; + breakLength?: number; + compact?: boolean | number; + customInspect?: boolean; + showProxy?: boolean; + sorted?: boolean | ((left: string, right: string) => number); + getters?: boolean | "get" | "set"; + numericSeparator?: boolean; +} + +function quote(value: string): string { + return `'${value + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t")}'`; +} + +function color(code: number, value: string, enabled: boolean): string { + return enabled ? `\u001b[${code}m${value}\u001b[39m` : value; +} + +function primitive(value: unknown, colors: boolean): string | undefined { + if (value === undefined) { + return color(90, "undefined", colors); + } + if (value === null) { + return colors ? "\u001b[1mnull\u001b[22m" : "null"; + } + if (typeof value === "string") { + return color(32, quote(value), colors); + } + if (typeof value === "number") { + return color(33, Object.is(value, -0) ? "-0" : String(value), colors); + } + if (typeof value === "bigint") { + return color(33, `${value}n`, colors); + } + if (typeof value === "boolean") { + return color(33, String(value), colors); + } + if (typeof value === "symbol") { + return color(32, String(value), colors); + } + if (typeof value === "function") { + return color(36, `[Function${value.name ? `: ${value.name}` : ""}]`, colors); + } + return undefined; +} + +export function inspect( + value: unknown, + options: InspectOptions = {}, + seen = new Set(), + level = 0, +): string { + const simple = primitive(value, options.colors === true); + if (simple !== undefined) { + return simple; + } + + const object = value as object; + if (seen.has(object)) { + return color(36, "[Circular]", options.colors === true); + } + const depth = options.depth === undefined ? 2 : options.depth; + if (depth !== null && level > depth) { + const name = object.constructor?.name ?? "Object"; + return color(36, `[${name}]`, options.colors === true); + } + + if (options.customInspect !== false) { + const hook = (object as { [customInspect]?: unknown })[customInspect]; + if (typeof hook === "function") { + return String(hook.call(object, depth === null ? null : depth - level, options, inspect)); + } + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + } + if (value instanceof RegExp) { + return String(value); + } + if (value instanceof Error) { + return errorText(value); + } + + seen.add(object); + let result: string; + if (Array.isArray(value)) { + const limit = options.maxArrayLength === null ? value.length : (options.maxArrayLength ?? 100); + const entries = value.slice(0, limit).map((item) => inspect(item, options, seen, level + 1)); + if (value.length > limit) { + entries.push(`... ${value.length - limit} more item${value.length - limit === 1 ? "" : "s"}`); + } + result = wrap("[", entries, "]"); + } else if (value instanceof Map) { + const entries = Array.from( + value, + ([key, item]) => + `${inspect(key, options, seen, level + 1)} => ${inspect(item, options, seen, level + 1)}`, + ); + result = wrap(`Map(${value.size}) {`, entries, "}"); + } else if (value instanceof Set) { + const entries = Array.from(value, (item) => inspect(item, options, seen, level + 1)); + result = wrap(`Set(${value.size}) {`, entries, "}"); + } else if (ArrayBuffer.isView(value)) { + const typed = value as unknown as { readonly length?: number; [index: number]: unknown }; + const length = typed.length ?? 0; + const entries = Array.from({ length }, (_, index) => + inspect(typed[index], options, seen, level + 1), + ); + result = wrap(`${object.constructor?.name ?? "TypedArray"}(${length}) [`, entries, "]"); + } else { + const entries = Object.keys(object).map((key) => { + const displayKey = /^[A-Za-z_$][\w$]*$/.test(key) ? key : quote(key); + const item = (object as Record)[key]; + return `${displayKey}: ${inspect(item, options, seen, level + 1)}`; + }); + const prefix = + object.constructor && object.constructor !== Object ? `${object.constructor.name} ` : ""; + result = wrap(`${prefix}{`, entries, "}"); + } + seen.delete(object); + return result; +} + +/** + * Node's `util.inspect.defaultOptions`, key for key. + * + * Carried in full so option objects derived from it keep Node's shape even though the portable + * inspector honors only a subset. + */ +export const inspectDefaultOptions: Readonly> = Object.freeze({ + showHidden: false, + depth: 2, + colors: false, + customInspect: true, + showProxy: false, + maxArrayLength: 100, + maxStringLength: 10000, + breakLength: 80, + compact: 3, + sorted: false, + getters: false, + numericSeparator: false, +}); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl.ts new file mode 100644 index 000000000..cc26bc689 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl.ts @@ -0,0 +1,2 @@ +export { default } from "./repl/index.js"; +export * from "./repl/index.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/README.md b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/README.md new file mode 100644 index 000000000..e70b15bdd --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/README.md @@ -0,0 +1,72 @@ +# REPL source provenance + +The TypeScript port targets **Node v24.20.0**, commit +[`71b8b174857e25106d39b61a9e6f30d927da8b01`](https://github.com/nodejs/node/tree/71b8b174857e25106d39b61a9e6f30d927da8b01), +the same pin as the readline port it extends. The upstream MIT notice is retained +in each ported source file. + +| Local file | Upstream source | Local adaptations | +| ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `index.ts` | `lib/repl.js` (module shape) | ESM namespace; deprecated `builtinModules`/`_builtinLibs` accessors on the default only | +| `server.ts` | `lib/repl.js` | Class over Jco's readline `Interface`; indirect `eval` for `vm`; refusals listed below | +| `recoverable.ts` | `lib/repl.js`, `lib/internal/repl.js` | Own module so the evaluator and the await rewriter share one identity | +| `utils.ts` | `lib/internal/repl/utils.js`, `lib/internal/util/colors.js` | acorn from npm; `process` as an optional global; no inspector preview | +| `await.ts` | `lib/internal/repl/await.js` | Types only | +| `completion.ts` | `lib/internal/repl/completion.js` | No filesystem listings, inspector scope names, or proxy detection | +| `types.ts` | `@types/node` 24 repl declarations | Structural stream types shared with the readline port | + +`unenv@2.0.0-rc.24` was inspected at `node/repl`: `start`, `REPLServer`, +`Recoverable` and `writer` are all `notImplemented` stubs. None of it is reused. + +## Why acorn + +Node's REPL vendors [acorn](https://github.com/acornjs/acorn) and this port depends +on the same versions from npm (`acorn@8.17.0`, `acorn-walk@8.3.5`). Three things need +a parser rather than the engine: deciding whether input is _incomplete_ (show the +`...` prompt) or _wrong_ (print the error), which Node does by subclassing acorn's +tokenizer -- engine `SyntaxError` messages differ between SpiderMonkey and QuickJS +and cannot drive it; rewriting top-level `await` into an async wrapper while +hoisting declarations; and finding the expression to tab-complete. acorn also serves +as the compile step: Node compiles a `vm.Script` to surface syntax errors before +running, and acorn's parse plays that role identically on every engine. + +Only files under `repl/` import acorn. A component that does not import `node:repl` +does not carry it; the jco-std tests assert both. + +## Runtime differences + +- `useGlobal: false` -- Node's default -- is refused with `ERR_JCO_UNSUPPORTED_NODE_API` + at construction. No component engine can create a second realm for a separate + context. `useGlobal: true` is exact: evaluation is an indirect `eval`, which is what + `vm.runInThisContext` is, so `context === globalThis`, `.clear` is an alias for + `.break`, and `repl.start(...).context.name = value` works as documented. +- `input`/`output` default to `globalThis.process`'s streams when a `process` global + exists and are required otherwise. `node:process` is never imported. +- `breakEvalOnSigint: true` is refused: there is no signal watchdog. Ctrl+C in a + terminal still arrives as a keypress and emits `'SIGINT'`. +- `preview` is accepted and has no effect, as in a Node built without an inspector. +- `.save`, `.load`, and a `setupHistory()` file path take Node's own "could not open" + path and print its message; a component has no filesystem unless the application + supplies one, and the REPL will not request that capability on every user's behalf. +- Core modules are not auto-loaded into the context: there is no module loader, so + `fs` is a `ReferenceError` unless the application put it there. `require` on the + context throws `ERR_JCO_UNSUPPORTED_NODE_API` pointing at static imports; + `require.resolve` answers as Node does. +- `node:domain` is replaced by a local error sink. Synchronous errors from evaluation + are reported as `Uncaught ...` exactly as in Node; errors thrown later by + asynchronous work started from evaluated code are not routed back to the REPL. +- `writer` uses Jco's portable inspector shared with `node:console`. It prints the + same values as `util.inspect` but never breaks long objects across lines and + ignores `showProxy`, `showHidden`, `getters` and `sorted`. `writer.options` keeps + Node's key set. +- Stack traces in `Uncaught` output are the engine's; SyntaxError output is rebuilt + from name and message rather than stripped with V8's frame pattern. +- The legacy `RegExp.$1`..`$9` statics are saved and restored around evaluation on + engines that have them (SpiderMonkey) and ignored where they do not exist (QuickJS). +- Deferred work (`close()`, paused-input replay) uses the microtask queue rather than + Node's `process.nextTick` queue. + +Deprecated at the pin: calling `REPLServer()` without `new` (DEP0185, runtime) +throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. `inputStream`/`outputStream` +(DEP0141), `builtinModules` (DEP0191) and `_builtinLibs` (DEP0142) are +documentation-only deprecations and stay functional. diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/await.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/await.ts new file mode 100644 index 000000000..8b5aaa05c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/await.ts @@ -0,0 +1,300 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/internal/repl/await.js. +// Local changes: TypeScript types, ES intrinsics, acorn from npm rather than Node's vendored copy. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +import { Parser } from "acorn"; +import type { + AnyNode, + ArrowFunctionExpression, + BlockStatement, + CallExpression, + ExpressionStatement, + Node, + Pattern, +} from "acorn"; +import * as walk from "acorn-walk"; +import { Recoverable } from "./recoverable.js"; + +interface AwaitState { + body: BlockStatement; + ancestors: AnyNode[]; + hoistedDeclarationStatements: string[]; + replace(from: number, to: number, str: string): void; + prepend(node: Node, str: string): void; + append(node: Node, str: string): void; + containsAwait: boolean; + containsReturn: boolean; +} + +type Walker = walk.WalkerCallback; +type Visitor = (node: AnyNode, state: AwaitState, c: Walker) => void; +type BaseVisitors = Record; + +const base = walk.base as unknown as BaseVisitors; + +function isTopLevelDeclaration(state: AwaitState): boolean { + return state.ancestors[state.ancestors.length - 2] === state.body; +} + +const noop: Visitor = () => {}; +const visitorsWithoutAncestors: BaseVisitors = { + ClassDeclaration(node, state, c) { + const declaration = node as Extract; + if (isTopLevelDeclaration(state)) { + state.prepend(declaration, `${declaration.id!.name}=`); + state.hoistedDeclarationStatements.push(`let ${declaration.id!.name}; `); + } + + base.ClassDeclaration(node, state, c); + }, + ForOfStatement(node, state, c) { + if ((node as Extract).await === true) { + state.containsAwait = true; + } + base.ForOfStatement(node, state, c); + }, + FunctionDeclaration(node, state) { + const declaration = node as Extract; + state.prepend(declaration, `this.${declaration.id!.name} = ${declaration.id!.name}; `); + state.hoistedDeclarationStatements.push(`var ${declaration.id!.name}; `); + }, + FunctionExpression: noop, + ArrowFunctionExpression: noop, + MethodDefinition: noop, + AwaitExpression(node, state, c) { + state.containsAwait = true; + base.AwaitExpression(node, state, c); + }, + ReturnStatement(node, state, c) { + state.containsReturn = true; + base.ReturnStatement(node, state, c); + }, + VariableDeclaration(node, state, c) { + const declaration = node as Extract; + const variableKind = declaration.kind; + const isIterableForDeclaration = ["ForOfStatement", "ForInStatement"].includes( + state.ancestors[state.ancestors.length - 2].type, + ); + + if (variableKind === "var" || isTopLevelDeclaration(state)) { + state.replace( + declaration.start, + declaration.start + variableKind.length + (isIterableForDeclaration ? 1 : 0), + variableKind === "var" && isIterableForDeclaration + ? "" + : "void" + (declaration.declarations.length === 1 ? "" : " ("), + ); + + if (!isIterableForDeclaration) { + for (const decl of declaration.declarations) { + state.prepend(decl, "("); + state.append(decl, decl.init ? ")" : "=undefined)"); + } + + if (declaration.declarations.length !== 1) { + state.append(declaration.declarations[declaration.declarations.length - 1], ")"); + } + } + + const variableIdentifiersToHoist: [kind: string, identifiers: string[]][] = [ + ["var", []], + ["let", []], + ]; + function registerVariableDeclarationIdentifiers(node: Pattern | null) { + switch (node?.type) { + case "Identifier": + variableIdentifiersToHoist[variableKind === "var" ? 0 : 1][1].push(node.name); + break; + case "ObjectPattern": + for (const property of node.properties) { + registerVariableDeclarationIdentifiers( + property.type === "RestElement" ? property.argument : property.value, + ); + } + break; + case "ArrayPattern": + for (const element of node.elements) { + registerVariableDeclarationIdentifiers(element); + } + break; + } + } + + for (const decl of declaration.declarations) { + registerVariableDeclarationIdentifiers(decl.id); + } + + for (const [kind, identifiers] of variableIdentifiersToHoist) { + if (identifiers.length > 0) { + state.hoistedDeclarationStatements.push(`${kind} ${identifiers.join(", ")}; `); + } + } + } + + base.VariableDeclaration(node, state, c); + }, +}; + +const visitors: BaseVisitors = {}; +for (const nodeType of Object.keys(base)) { + const callback = visitorsWithoutAncestors[nodeType] || base[nodeType]; + visitors[nodeType] = (node, state, c) => { + const isNew = node !== state.ancestors[state.ancestors.length - 1]; + if (isNew) { + state.ancestors.push(node); + } + callback(node, state, c); + if (isNew) { + state.ancestors.pop(); + } + }; +} + +interface AcornSyntaxError extends SyntaxError { + pos: number; + loc: { line: number; column: number }; +} + +/** + * Rewrite a top-level `await` script into an async wrapper returning `{ value }`. + * + * Returns `null` when the source needs no rewriting, throws `Recoverable` for incomplete input, + * and re-throws parse failures as a `SyntaxError` in Node's message shape. + */ +export function processTopLevelAwait(src: string): string | null { + const wrapPrefix = "(async () => { "; + const wrapped = `${wrapPrefix}${src} })()`; + const wrappedArray = wrapped.split(""); + let root; + try { + root = Parser.parse(wrapped, { ecmaVersion: "latest" }); + } catch (error) { + const e = error as AcornSyntaxError; + if (e.message.startsWith("Unterminated ")) { + throw new Recoverable(e); + } + // If the parse error is before the first "await", then use the execution + // error. Otherwise we must emit this parse error, making it look like a + // proper syntax error. + const awaitPos = src.indexOf("await"); + const errPos = e.pos - wrapPrefix.length; + if (awaitPos > errPos) { + return null; + } + // Convert keyword parse errors on await into their original errors when + // possible. + if (errPos === awaitPos + 6 && e.message.includes("Expecting Unicode escape sequence")) { + return null; + } + if (errPos === awaitPos + 7 && e.message.includes("Unexpected token")) { + return null; + } + const line = e.loc.line; + const column = line === 1 ? e.loc.column - wrapPrefix.length : e.loc.column; + let message = + "\n" + + src.split("\n", line)[line - 1] + + "\n" + + " ".repeat(column) + + "^\n\n" + + e.message.replace(/ \([^)]+\)/, ""); + // V8 unexpected token errors include the token string. + if (message.endsWith("Unexpected token")) { + message += + " '" + + // Wrapper end may cause acorn to report error position after the source + (src[e.pos - wrapPrefix.length] ?? src[src.length - 1]) + + "'"; + } + throw new SyntaxError(message); + } + const call = (root.body[0] as ExpressionStatement).expression as CallExpression; + const body = (call.callee as ArrowFunctionExpression).body as BlockStatement; + const state: AwaitState = { + body, + ancestors: [], + hoistedDeclarationStatements: [], + replace(from, to, str) { + for (let i = from; i < to; i++) { + wrappedArray[i] = ""; + } + if (from === to) { + str += wrappedArray[from]; + } + wrappedArray[from] = str; + }, + prepend(node, str) { + wrappedArray[node.start] = str + wrappedArray[node.start]; + }, + append(node, str) { + wrappedArray[node.end - 1] += str; + }, + containsAwait: false, + containsReturn: false, + }; + + walk.recursive(body, state, visitors as unknown as walk.RecursiveVisitors); + + // Do not transform if + // 1. False alarm: there isn't actually an await expression. + // 2. There is a top-level return, which is not allowed. + if (!state.containsAwait || state.containsReturn) { + return null; + } + + for (let i = body.body.length - 1; i >= 0; i--) { + const node = body.body[i]; + if (node.type === "EmptyStatement") { + continue; + } + if (node.type === "ExpressionStatement") { + // For an expression statement of the form + // ( expr ) ; + // ^^^^^^^^^^ // node + // ^^^^ // node.expression + // + // We do not want the left parenthesis before the `return` keyword; + // therefore we prepend the `return (` to `node`. + // + // On the other hand, we do not want the right parenthesis after the + // semicolon. Since there can only be more right parentheses between + // node.expression.end and the semicolon, appending one more to + // node.expression should be fine. + // + // We also create a wrapper object around the result of the expression. + // Consider an expression of the form `(await x).y`. If we just return + // this expression from an async function, the caller will await `y`, too, + // if it evaluates to a Promise. Instead, we return + // `{ value: ((await x).y) }`, which allows the caller to retrieve the + // awaited value correctly. + state.prepend(node.expression, "{ value: ("); + state.prepend(node, "return "); + state.append(node.expression, ") }"); + } + break; + } + + return state.hoistedDeclarationStatements.join("") + wrappedArray.join(""); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/completion.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/completion.ts new file mode 100644 index 000000000..b240d3203 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/completion.ts @@ -0,0 +1,655 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/internal/repl/completion.js. +// Local changes: TypeScript types, ES intrinsics, acorn from npm rather than Node's vendored copy. +// A component has no filesystem or inspector, so file-path completion (`allowBlockingCompletions`) +// yields no entries, global lexical scope names are unavailable, and `isProxy` cannot be observed +// -- a proxy that hides its own property names is treated as an ordinary object. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +import * as acorn from "acorn"; +import { Parser } from "acorn"; +import type { AnyNode, Expression, MemberExpression, Program } from "acorn"; +import * as walk from "acorn-walk"; +import { builtinModules } from "../module/builtins.js"; +import type { CompleterResult } from "../readline/types.js"; +import { getREPLResourceName, getReplBuiltinLibs } from "./utils.js"; +import type { EvalCallback, ReplCommand } from "./types.js"; + +// acorn exports these at runtime but omits them from its type declarations. +const { isIdentifierChar, isIdentifierStart } = acorn as unknown as { + isIdentifierChar(code: number): boolean; + isIdentifierStart(code: number): boolean; +}; + +const importRE = /\bimport\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/; +const requireRE = /\brequire\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/; +const fsAutoCompleteRE = /fs(?:\.promises)?\.\s*[a-z][a-zA-Z]+\(\s*["'](.*)/; + +/** Node's `node:`-scheme builtin names: every lib plus the scheme-only modules. */ +const nodeSchemeBuiltinLibs = (): string[] => [ + ...getReplBuiltinLibs().map((lib) => `node:${lib}`), + ...builtinModules.filter((name) => name.startsWith("node:")), +]; + +/** The evaluator surface completion drives; a `REPLServer` satisfies it structurally. */ +export interface Completable { + commands: Record; + context: object; + useGlobal: boolean; + allowBlockingCompletions: boolean; + eval(code: string, context: object, file: string, callback: EvalCallback): void; +} + +export type CompletionCallback = (error: Error | null, result?: CompleterResult) => void; + +function isIdentifier(str: string): boolean { + if (str === "") { + return false; + } + const first = str.codePointAt(0)!; + if (!isIdentifierStart(first)) { + return false; + } + const firstLen = first > 0xffff ? 2 : 1; + for (let i = firstLen; i < str.length; i += 1) { + const cp = str.codePointAt(i)!; + if (!isIdentifierChar(cp)) { + return false; + } + if (cp > 0xffff) { + i += 1; + } + } + return true; +} + +function isNotLegacyObjectPrototypeMethod(str: string): boolean { + return ( + isIdentifier(str) && + str !== "__defineGetter__" && + str !== "__defineSetter__" && + str !== "__lookupGetter__" && + str !== "__lookupSetter__" + ); +} + +/** Node's `getOwnNonIndexProperties(obj, ALL_PROPERTIES | SKIP_SYMBOLS)`: string keys that are not array indices. */ +function getOwnNonIndexProperties(obj: object): string[] { + return Object.getOwnPropertyNames(obj).filter( + (name) => !(/^(?:0|[1-9]\d*)$/.test(name) && Number(name) < 4294967295), + ); +} + +function filteredOwnPropertyNames(obj: unknown): string[] { + if (!obj || (typeof obj !== "object" && typeof obj !== "function")) { + return []; + } + // `Object.prototype` is the only non-contrived object that fulfills + // `Object.getPrototypeOf(X) === null && + // Object.getPrototypeOf(Object.getPrototypeOf(X.constructor)) === X`. + let isObjectPrototype = false; + if (Object.getPrototypeOf(obj) === null) { + const ctorDescriptor = Object.getOwnPropertyDescriptor(obj, "constructor"); + if (ctorDescriptor?.value) { + const ctorProto = Object.getPrototypeOf(ctorDescriptor.value) as object | null; + isObjectPrototype = ctorProto !== null && Object.getPrototypeOf(ctorProto) === obj; + } + } + return getOwnNonIndexProperties(obj).filter( + isObjectPrototype ? isNotLegacyObjectPrototypeMethod : isIdentifier, + ); +} + +function addCommonWords(completionGroups: string[][]) { + // Only words which do not yet exist as global property should be added to + // this list. + completionGroups.push([ + "async", + "await", + "break", + "case", + "catch", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "null", + "return", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + ]); +} + +// Provide a list of completions for the given leading text. This is +// given to the readline interface for handling tab completion. +// +// Example: +// complete('let foo = util.') +// -> [['util.print', 'util.debug', 'util.log', 'util.inspect'], +// 'util.' ] +// +// Warning: This evals code like "foo.bar.baz", so it could run property +// getter code. To avoid potential triggering side-effectful behaviors with getters the completion +// logic is skipped when getters or proxies are involved in the expression. +// (see: https://github.com/nodejs/node/issues/57829). +export function complete(this: Completable, line: string, callback: CompletionCallback): void { + // List of completion lists, one for each inheritance "level" + let completionGroups: string[][] = []; + let completeOn: string | undefined; + + // Ignore right whitespace. It could change the outcome. + line = line.trimStart(); + + let filter = ""; + + let match; + // REPL commands (e.g. ".break"). + if ((match = /^\s*\.(\w*)$/.exec(line)) !== null) { + completionGroups.push(Object.keys(this.commands)); + completeOn = match[1]; + if (completeOn.length) { + filter = completeOn; + } + } else if ((match = requireRE.exec(line)) !== null) { + // require('...') + completeOn = match[1]; + filter = completeOn; + // Filesystem groups need a directory listing, which a component does not have. + completionGroups.push(getReplBuiltinLibs(), nodeSchemeBuiltinLibs()); + } else if ((match = importRE.exec(line)) !== null) { + // import('...') + completeOn = match[1]; + filter = completeOn; + completionGroups.push(getReplBuiltinLibs(), nodeSchemeBuiltinLibs()); + } else if ((match = fsAutoCompleteRE.exec(line)) !== null && this.allowBlockingCompletions) { + // Node lists the directory here; without a filesystem the answer is "nothing", with Node's shape. + const filePath = match[1]; + completeOn = filePath.slice(filePath.lastIndexOf("/") + 1); + completionGroups = [[]]; + } else if (line.length === 0 || /\w|\.|\$/.test(line[line.length - 1])) { + const completeTarget = line.length === 0 ? line : findExpressionCompleteTarget(line); + + if (line.length !== 0 && !completeTarget) { + completionGroupsLoaded(); + return; + } + let expr = ""; + completeOn = completeTarget ?? ""; + if (line.endsWith(".")) { + expr = completeOn.slice(0, -1); + } else if (line.length !== 0) { + const bits = completeOn.split("."); + filter = bits.pop()!; + expr = bits.join("."); + } + + // Resolve expr and get its completions. + if (!expr) { + // Node asks the inspector for global lexical scope names here; a component has none to ask. + let contextProto: object | null = this.context; + while ((contextProto = Object.getPrototypeOf(contextProto) as object | null) !== null) { + completionGroups.push(filteredOwnPropertyNames(contextProto)); + } + const contextOwnNames = filteredOwnPropertyNames(this.context); + completionGroups.push(contextOwnNames); + if (filter !== "") { + addCommonWords(completionGroups); + } + completionGroupsLoaded(); + return; + } + + // If the target ends with a dot (e.g. `obj.foo.`) such code won't be valid for AST parsing + // so in order to make it correct we add an identifier to its end (e.g. `obj.foo.x`) + const parsableCompleteTarget = completeOn.endsWith(".") ? `${completeOn}x` : completeOn; + + let completeTargetAst: Program | undefined; + try { + completeTargetAst = Parser.parse(parsableCompleteTarget, { + sourceType: "module", + ecmaVersion: "latest", + }); + } catch { + /* No need to specifically handle parse errors */ + } + + if (!completeTargetAst) { + return completionGroupsLoaded(); + } + + const statement = completeTargetAst.body[0]; + return includesProxiesOrGetters( + statement.type === "ExpressionStatement" ? statement.expression : undefined, + parsableCompleteTarget, + this.eval.bind(this), + this.context, + (includes) => { + if (includes) { + // The expression involves proxies or getters, meaning that it + // can trigger side-effectful behaviors, so bail out + return completionGroupsLoaded(); + } + + let chaining = "."; + if (expr.endsWith("?")) { + expr = expr.slice(0, -1); + chaining = "?."; + } + + const memberGroups: string[][] = []; + const evalExpr = `try { ${expr} } catch {}`; + this.eval(evalExpr, this.context, getREPLResourceName(), (_e, obj) => { + try { + let p: object | null; + if ((typeof obj === "object" && obj !== null) || typeof obj === "function") { + memberGroups.push(filteredOwnPropertyNames(obj)); + p = Object.getPrototypeOf(obj) as object | null; + } else { + const constructor = (obj as { constructor?: { prototype?: object } })?.constructor; + p = constructor?.prototype ?? null; + } + // Circular refs possible? Let's guard against that. + let sentinel = 5; + while (p !== null && sentinel-- !== 0) { + memberGroups.push(filteredOwnPropertyNames(p)); + p = Object.getPrototypeOf(p) as object | null; + } + } catch { + // Maybe a Proxy object without `getOwnPropertyNames` trap. + // We simply ignore it here, as we don't want to break the + // autocompletion. Fixes the bug + // https://github.com/nodejs/node/issues/2119 + } + + if (memberGroups.length) { + expr += chaining; + for (const group of memberGroups) { + completionGroups.push(group.map((member) => `${expr}${member}`)); + } + filter &&= `${expr}${filter}`; + } + + completionGroupsLoaded(); + }); + }, + ); + } + + return completionGroupsLoaded(); + + // Will be called when all completionGroups are in place + // Useful for async autocompletion + function completionGroupsLoaded() { + // Filter, sort (within each group), uniq and merge the completion groups. + if (completionGroups.length && filter) { + const newCompletionGroups: string[][] = []; + const lowerCaseFilter = filter.toLocaleLowerCase(); + for (const group of completionGroups) { + const filteredGroup = group.filter((str) => { + // Filter is always case-insensitive following chromium autocomplete + // behavior. + return str.toLocaleLowerCase().startsWith(lowerCaseFilter); + }); + if (filteredGroup.length) { + newCompletionGroups.push(filteredGroup); + } + } + completionGroups = newCompletionGroups; + } + + const completions: string[] = []; + // Unique completions across all groups. + const uniqueSet = new Set(); + uniqueSet.add(""); + // Completion group 0 is the "closest" (least far up the inheritance + // chain) so we put its completions last: to be closest in the REPL. + for (const group of completionGroups) { + group.sort((a, b) => (b > a ? 1 : -1)); + const setSize = uniqueSet.size; + for (const entry of group) { + if (!uniqueSet.has(entry)) { + completions.unshift(entry); + uniqueSet.add(entry); + } + } + // Add a separator between groups. + if (uniqueSet.size !== setSize) { + completions.unshift(""); + } + } + + // Remove obsolete group entry, if present. + if (completions[0] === "") { + completions.shift(); + } + + callback(null, [completions, completeOn ?? ""]); + } +} + +/** + * This function tries to extract a target for tab completion from code representing an expression. + * + * Such target is basically the last piece of the expression that can be evaluated for the potential + * tab completion. + * + * Some examples: + * - The complete target for `const a = obj.b` is `obj.b` + * (because tab completion will evaluate and check the `obj.b` object) + * - The complete target for `tru` is `tru` + * (since we'd ideally want to complete that to `true`) + * - The complete target for `{ a: tru` is `tru` + * (like the last example, we'd ideally want that to complete to true) + * - There is no complete target for `{ a: true }` + * (there is nothing to complete) + * @param {string} code the code representing the expression to analyze + * @returns {string|null} a substring of the code representing the complete target is there was one, `null` otherwise + */ +function findExpressionCompleteTarget(code: string): string | null { + if (!code) { + return null; + } + + if (code.at(-1) === ".") { + if (code.at(-2) === "?") { + // The code ends with the optional chaining operator (`?.`), + // such code can't generate a valid AST so we need to strip + // the suffix, run this function's logic and add back the + // optional chaining operator to the result if present + const result = findExpressionCompleteTarget(code.slice(0, -2)); + return !result ? result : `${result}?.`; + } + + // The code ends with a dot, such code can't generate a valid AST + // so we need to strip the suffix, run this function's logic and + // add back the dot to the result if present + const result = findExpressionCompleteTarget(code.slice(0, -1)); + return !result ? result : `${result}.`; + } + + let ast: Program; + try { + ast = Parser.parse(code, { sourceType: "module", ecmaVersion: "latest" }); + } catch { + const keywords = code.split(" "); + + if (keywords.length > 1) { + // Something went wrong with the parsing, however this can be due to incomplete code + // (that is for example missing a closing bracket, as for example `{ a: obj.te`), in + // this case we take the last code keyword and try again + // TODO(dario-piotrowicz): make this more robust, right now we only split by spaces + // but that's not always enough, for example it doesn't handle + // this code: `{ a: obj['hello world'].te` + return findExpressionCompleteTarget(keywords.at(-1)!); + } + + // The ast parsing has legitimately failed so we return null + return null; + } + + const lastBodyStatement = ast.body[ast.body.length - 1]; + + if (!lastBodyStatement) { + return null; + } + + // If the last statement is a block we know there is not going to be a potential + // completion target (e.g. in `{ a: true }` there is no completion to be done) + if (lastBodyStatement.type === "BlockStatement") { + return null; + } + + // If the last statement is an expression and it has a right side, that's what we + // want to potentially complete on, so let's re-run the function's logic on that + if (lastBodyStatement.type === "ExpressionStatement" && "right" in lastBodyStatement.expression) { + const exprRight = lastBodyStatement.expression.right as Expression; + const exprRightCode = code.slice(exprRight.start, exprRight.end); + return findExpressionCompleteTarget(exprRightCode); + } + + // If the last statement is a variable declaration statement the last declaration is + // what we can potentially complete on, so let's re-run the function's logic on that + if (lastBodyStatement.type === "VariableDeclaration") { + const lastDeclarationInit = lastBodyStatement.declarations.at(-1)!.init; + if (!lastDeclarationInit) { + // If there is no initialization we can simply return + return null; + } + const lastDeclarationInitCode = code.slice(lastDeclarationInit.start, lastDeclarationInit.end); + return findExpressionCompleteTarget(lastDeclarationInitCode); + } + + // If the last statement is an expression statement with a unary operator (delete, typeof, etc.) + // we want to extract the argument for completion (e.g. for `delete obj.prop` we want `obj.prop`) + if ( + lastBodyStatement.type === "ExpressionStatement" && + lastBodyStatement.expression.type === "UnaryExpression" && + lastBodyStatement.expression.argument + ) { + const argument = lastBodyStatement.expression.argument; + const argumentCode = code.slice(argument.start, argument.end); + return findExpressionCompleteTarget(argumentCode); + } + + // If the last statement is an expression statement with "new" syntax + // we want to extract the callee for completion (e.g. for `new Sample` we want `Sample`) + if ( + lastBodyStatement.type === "ExpressionStatement" && + lastBodyStatement.expression.type === "NewExpression" && + lastBodyStatement.expression.callee + ) { + const callee = lastBodyStatement.expression.callee; + const calleeCode = code.slice(callee.start, callee.end); + return findExpressionCompleteTarget(calleeCode); + } + + // Walk the AST for the current block of code, and check whether it contains any + // statement or expression type that would potentially have side effects if evaluated. + let isAllowed = true; + const disallow = () => { + isAllowed = false; + }; + walk.simple(lastBodyStatement, { + ForInStatement: disallow, + ForOfStatement: disallow, + CallExpression: disallow, + AssignmentExpression: disallow, + UpdateExpression: disallow, + }); + if (!isAllowed) { + return null; + } + + // If any of the above early returns haven't activated then it means that + // the potential complete target is the full code (e.g. the code represents + // a simple partial identifier, a member expression, etc...) + return code.slice(lastBodyStatement.start, lastBodyStatement.end); +} + +type EvalFunction = Completable["eval"]; +type ProxyOrGetterCallback = (includes: boolean, lastEvaledObj?: unknown) => void; + +/** + * Utility used to determine if an expression includes object getters or proxies. + * + * Example: given `obj.foo`, the function lets you know if `foo` has a getter function + * associated to it, or if `obj` is a proxy + * @param {any} expr The expression, in AST format to analyze + * @param {string} exprStr The string representation of the expression + * @param {(str: string, ctx: any, resourceName: string, cb: (error, evaled) => void) => void} evalFn + * Eval function to use + * @param {any} ctx The context to use for any code evaluation + * @param {(includes: boolean) => void} callback Callback that will be called with the result of the operation + * @returns {void} + */ +function includesProxiesOrGetters( + expr: AnyNode | undefined, + exprStr: string, + evalFn: EvalFunction, + ctx: object, + callback: ProxyOrGetterCallback, +): void { + if (expr?.type !== "MemberExpression") { + // If the expression is not a member one for obvious reasons no getters are involved + return callback(false); + } + + if (expr.object.type === "MemberExpression") { + // The object itself is a member expression, so we need to recurse (e.g. the expression is `obj.foo.bar`) + return includesProxiesOrGetters( + expr.object, + exprStr.slice(0, expr.object.end), + evalFn, + ctx, + (includes, lastEvaledObj) => { + if (includes) { + // If the recurred call found a getter we can also terminate + return callback(includes); + } + + // If a getter/proxy hasn't been found by the recursion call we need to check if maybe a getter/proxy + // is present here (e.g. in `obj.foo.bar` we found that `obj.foo` doesn't involve any getters so we now + // need to check if `bar` on `obj.foo` (i.e. `lastEvaledObj`) has a getter or if `obj.foo.bar` is a proxy) + return hasGetterOrIsProxy(lastEvaledObj, expr.property, (doesHaveGetterOrIsProxy) => { + return callback(doesHaveGetterOrIsProxy); + }); + }, + ); + } + + // This is the base of the recursion we have an identifier for the object and an identifier or literal + // for the property (e.g. we have `obj.foo` or `obj['foo']`, `obj` is the object identifier and `foo` + // is the property identifier/literal) + if (expr.object.type === "Identifier") { + return evalFn( + `try { ${expr.object.name} } catch {}`, + ctx, + getREPLResourceName(), + (err, obj) => { + if (err) { + return callback(false); + } + + return hasGetterOrIsProxy(obj, expr.property, (doesHaveGetterOrIsProxy) => { + if (doesHaveGetterOrIsProxy) { + return callback(true); + } + + return evalFn(`try { ${exprStr} } catch {} `, ctx, getREPLResourceName(), (err, obj) => { + if (err) { + return callback(false); + } + return callback(false, obj); + }); + }); + }, + ); + } + + /** + * Utility to see if a property has a getter associated to it or if + * the property itself is a proxy object. + * @returns {void} + */ + function hasGetterOrIsProxy( + obj: unknown, + astProp: MemberExpression["property"], + cb: (includes: boolean) => void, + ): void { + if (!obj || !astProp) { + return cb(false); + } + + if (astProp.type === "Literal") { + // We have something like `obj['foo'].x` where `x` is the literal + return propHasGetter(obj, astProp.value as PropertyKey, cb); + } + + if (astProp.type === "Identifier" && exprStr.at(astProp.start - 1) === ".") { + // We have something like `obj.foo.x` where `foo` is the identifier + return propHasGetter(obj, astProp.name, cb); + } + + return evalFn( + // Note: this eval runs the property expression, which might be side-effectful, for example + // the user could be running `obj[getKey()].` where `getKey()` has some side effects. + // Arguably this behavior should not be too surprising, but if it turns out that it is, + // then we can revisit this behavior and add logic to analyze the property expression + // and eval it only if we can confidently say that it can't have any side effects + `try { ${exprStr.slice(astProp.start, astProp.end)} } catch {} `, + ctx, + getREPLResourceName(), + (err, evaledProp) => { + if (err) { + return cb(false); + } + + if (typeof evaledProp === "string") { + return propHasGetter(obj, evaledProp, cb); + } + + return cb(false); + }, + ); + } + + return callback(false); +} + +/** + * Given an object and a property name, checks whether the property has a getter. + * + * Node also checks whether the value is a proxy, which needs a V8 binding; an engine offers no + * portable way to tell a proxy from its target, so that half of the check is absent here. + */ +function propHasGetter(obj: unknown, prop: PropertyKey, cb: (includes: boolean) => void): void { + if (obj === null || (typeof obj !== "object" && typeof obj !== "function")) { + return cb(false); + } + const propDescriptor = Object.getOwnPropertyDescriptor(obj, prop); + cb(typeof propDescriptor?.get === "function"); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/index.ts new file mode 100644 index 000000000..1042faf82 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/index.ts @@ -0,0 +1,89 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/repl.js (module shape). +// Local changes: ESM namespace; the deprecated `builtinModules`/`_builtinLibs` accessors live on +// the default export only, since an ES module binding cannot be an accessor. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +import { + REPLServer, + REPL_MODE_SLOPPY, + REPL_MODE_STRICT, + Recoverable, + isValidSyntax, + start, + writer, +} from "./server.js"; +import { getReplBuiltinLibs, setReplBuiltinLibs } from "./utils.js"; + +export { + REPLServer, + REPL_MODE_SLOPPY, + REPL_MODE_STRICT, + Recoverable, + isValidSyntax, + start, + writer, +}; +export type { + REPLServer as REPLServerInstance, + REPLServerConstructor, + ReplWriter, +} from "./server.js"; +export type * from "./types.js"; + +/** The module object, matching what `require("node:repl")` yields. */ +export interface ReplModule { + start: typeof start; + writer: typeof writer; + REPLServer: typeof REPLServer; + REPL_MODE_SLOPPY: typeof REPL_MODE_SLOPPY; + REPL_MODE_STRICT: typeof REPL_MODE_STRICT; + Recoverable: typeof Recoverable; + isValidSyntax: typeof isValidSyntax; + /** Deprecated upstream (DEP0191, documentation-only); `module.builtinModules` is the replacement. */ + builtinModules: string[]; + /** Deprecated upstream (DEP0142, documentation-only); an alias of `builtinModules`. */ + _builtinLibs: string[]; +} + +const repl = { + start, + writer, + REPLServer, + REPL_MODE_SLOPPY, + REPL_MODE_STRICT, + Recoverable, + isValidSyntax, +} as ReplModule; + +for (const name of ["builtinModules", "_builtinLibs"] as const) { + Object.defineProperty(repl, name, { + get: () => getReplBuiltinLibs(), + set: (value: string[]) => setReplBuiltinLibs(value), + enumerable: false, + configurable: true, + }); +} + +export default repl; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/recoverable.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/recoverable.ts new file mode 100644 index 000000000..b175c2911 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/recoverable.ts @@ -0,0 +1,50 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/repl.js (`Recoverable`) and +// lib/internal/repl.js. Local changes: TypeScript types; split into its own module so the +// evaluator, the await rewriter and the module entry share one identity without a cycle. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +/** + * A syntax error the REPL can recover from by reading more input. + * + * Node's class is a plain function whose prototype chain is grafted onto `SyntaxError`; instances + * therefore have no own `message` or `stack`, only `err`. That shape is preserved exactly. + */ +export interface Recoverable extends SyntaxError { + err: Error; +} + +export interface RecoverableConstructor { + new (err: Error): Recoverable; + prototype: Recoverable; +} + +export const Recoverable: RecoverableConstructor = function Recoverable( + this: Recoverable, + err: Error, +) { + this.err = err; +} as unknown as RecoverableConstructor; +Object.setPrototypeOf(Recoverable.prototype, SyntaxError.prototype); +Object.setPrototypeOf(Recoverable, SyntaxError); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/server.ts new file mode 100644 index 000000000..62ea83ae1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/server.ts @@ -0,0 +1,1240 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/repl.js. +// Local changes: TypeScript class over Jco's readline port; evaluation through indirect `eval` +// (Node's `runInThisContext`) with acorn as the compile step; `useGlobal: false`, +// `breakEvalOnSigint`, `preview`, `.save`, `.load` and history files have no platform support and +// say so; `node:domain` is replaced by a local error sink; `process` is an optional global. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +import { EventEmitter as NodeEventEmitter } from "node:events"; +import { + codedError, + deprecatedNodeApi, + missingArgs, + unsupportedNodeApi, + validateFunction, +} from "../errors/core.js"; +import { inspect, inspectDefaultOptions, type InspectOptions } from "../internal/inspect.js"; +import { Module } from "../module/module-class.js"; +import { createRequire } from "../module/require.js"; +import { defer } from "../readline/compat.js"; +import { History } from "../readline/history.js"; +import { Interface, type Interface as ReadlineInterface } from "../readline/index.js"; +import { kAddNewLineOnTTY, kLastCommandErrored, kMultilinePrompt } from "../readline/interface.js"; +import { commonPrefix } from "../readline/utils.js"; +import type { + CompleterResult, + Emitter, + Key, + ReadableInput, + WritableOutput, +} from "../readline/types.js"; +import { processTopLevelAwait } from "./await.js"; +import { complete, type CompletionCallback } from "./completion.js"; +import { Recoverable } from "./recoverable.js"; +import type { + EvalCallback, + HistoryLoadedCallback, + ReplCommand, + ReplCommandDefinition, + ReplDomainLike, + ReplEvalFunction, + ReplHistoryConfig, + ReplLines, + ReplOptions, + ReplWriterFunction, +} from "./types.js"; +import { + REPL_MODE_SLOPPY, + REPL_MODE_STRICT, + getREPLResourceName, + isAcornSyntaxError, + isObjectLiteral, + isRecoverableError, + isValidSyntax, + kContextId, + parseScript, + persistLexicalDeclarations, + processGlobal, + setupReverseSearch, + shouldColorize, + normalizeAcornError, +} from "./utils.js"; + +// Keep the runtime EventEmitter identity without leaking @types/node into declarations. +const EventEmitter: new () => Emitter = NodeEventEmitter as unknown as new () => Emitter; + +const kBufferedCommandSymbol = Symbol("bufferedCommand"); +const kLoadingSymbol = Symbol("loading"); + +/** Node's error for `breakEvalOnSigint` combined with a custom evaluator. */ +function invalidReplEvalConfig() { + return codedError( + new TypeError('Cannot specify both "breakEvalOnSigint" and "eval" for REPL'), + "ERR_INVALID_REPL_EVAL_CONFIG", + ); +} + +const indirectEval: (code: string) => unknown = eval; + +/** + * Run a script in the global scope, exactly as `vm.runInThisContext` does. + * + * An indirect `eval` is the engine-neutral equivalent. The `sourceURL` comment names the script + * the way Node names it (`REPL1`, `REPL2`, ...) on engines that honour it, and the function's own + * name marks where evaluated frames end so `trimEvalFrames` can cut the REPL's internals out. + */ +function runInThisContext(code: string, file: string): unknown { + return indirectEval(`${code}\n//# sourceURL=${file}`); +} + +/** + * Keep only the stack frames that belong to evaluated code. + * + * Node trims everything below the REPL frame through V8's stack hook; here the boundary is the + * `runInThisContext` frame, which every engine names, plus the anonymous eval trampoline V8 and + * QuickJS put directly above it. + */ +function trimEvalFrames(stack: string): string { + const lines = stack.split("\n"); + const boundary = lines.findIndex((line, index) => index > 0 && line.includes("runInThisContext")); + if (boundary === -1) { + return stack; + } + const kept = lines.slice(0, boundary); + if (/^\s*at eval \((?:|native)\)\s*$/.test(kept[kept.length - 1] ?? "")) { + kept.pop(); + } + // Node also drops the script's own top-level frame (its function name is null); engines name + // that frame `eval`, ``, or nothing at all. + if (/^(?:\s*at (?:eval|) \(|@)/.test(kept[kept.length - 1] ?? "")) { + kept.pop(); + } + return kept.join("\n"); +} + +/** + * Node decorates a compile error with the offending source line and a caret under the failing + * column. acorn reports the position, so the same decoration is built here and kept beside the + * error until it is printed. + */ +const arrowMessages = new WeakMap(); +function decorateSyntaxError(error: Error, code: string): void { + if (!isAcornSyntaxError(error) || arrowMessages.has(error)) { + return; + } + const line = code.split("\n")[error.loc.line - 1]; + if (line === undefined) { + return; + } + arrowMessages.set(error, `${line}\n${" ".repeat(error.loc.column)}^\n\n`); +} + +/** + * Whether the engine keeps the legacy `RegExp.$1`..`$9` statics the REPL saves and restores + * around each evaluation. SpiderMonkey has them; QuickJS does not. + */ +const legacyRegExpStatics = Object.prototype.hasOwnProperty.call(RegExp, "$1"); +type RegExpStatics = Record; + +/** + * Stands in for the `node:domain` instance Node's REPL binds evaluation through. + * + * Synchronous exceptions from the evaluator are routed to `'error'`, which is what the REPL relies + * on. Errors thrown later by asynchronous work started from evaluated code are not: a domain + * captured those through async hooks, which a component does not have. + */ +class ReplDomain extends EventEmitter implements ReplDomainLike { + bind unknown>(fn: T): T { + // oxlint-disable-next-line typescript/no-this-alias -- The wrapper keeps its own `this`. + const domain = this; + return function (this: unknown, ...args: never[]) { + try { + return fn.apply(this, args); + } catch (error) { + domain.emit("error", error); + return undefined; + } + } as T; + } + exit(): void {} +} + +/** The default `writer`, `util.inspect` with `writer.options`, which callers may edit in place. */ +export interface ReplWriter extends ReplWriterFunction { + options: InspectOptions; +} + +// This is the default "writer" value, if none is passed in the REPL options, +// and it can be overridden by custom print functions, such as `probe` or +// `eyes.js`. +export const writer: ReplWriter = Object.assign( + (obj: unknown): string => inspect(obj, writer.options), + { options: { ...inspectDefaultOptions, showProxy: true } as InspectOptions }, +); + +function isError(value: unknown): value is Error { + return value instanceof Error; +} + +/** acorn's message for a module-only statement in a script, standing in for V8's. */ +const acornImportErrorStr = "'import' and 'export' may appear only with 'sourceType: module'"; +const importErrorStr = "Cannot use import statement outside a module"; + +// Converts static import statement to dynamic import statement +function toDynamicImport(codeLine: string): string { + let dynamicImportStatement = ""; + const ast = parseScript(codeLine, { sourceType: "module" }); + for (const node of ast.body) { + if (node.type !== "ImportDeclaration") { + continue; + } + const awaitDynamicImport = `await import(${JSON.stringify(node.source.value)});`; + if (node.specifiers.length === 0) { + dynamicImportStatement += awaitDynamicImport; + } else if ( + node.specifiers.length === 1 && + node.specifiers[0].type === "ImportNamespaceSpecifier" + ) { + dynamicImportStatement += `const ${node.specifiers[0].local.name} = ${awaitDynamicImport}`; + } else { + const importNames = node.specifiers + .map((specifier) => { + const local = specifier.local.name; + const imported = + specifier.type === "ImportSpecifier" + ? specifier.imported.type === "Identifier" + ? specifier.imported.name + : String(specifier.imported.value) + : undefined; + return local === imported ? local : `${imported ?? "default"}: ${local}`; + }) + .join(", "); + dynamicImportStatement += `const { ${importNames} } = ${awaitDynamicImport}`; + } + } + return dynamicImportStatement; +} + +type LegacyArgs = [ + prompt?: string | ReplOptions, + stream?: ReplOptions["stream"], + eval_?: ReplEvalFunction, + useGlobal?: boolean, + ignoreUndefined?: boolean, + replMode?: symbol, +]; + +export class ReplServerCore extends Interface { + declare context: object; + declare commands: Record; + declare lines: ReplLines; + declare last: unknown; + declare lastError: unknown; + declare underscoreAssigned: boolean; + declare underscoreErrAssigned: boolean; + declare useGlobal: boolean; + declare useColors: boolean; + declare ignoreUndefined: boolean; + declare replMode: symbol; + declare editorMode: boolean; + declare breakEvalOnSigint: boolean; + declare allowBlockingCompletions: boolean; + declare _domain: ReplDomainLike; + declare _initialPrompt: string; + declare _closingOnFlush?: boolean; + declare eval: ReplEvalFunction; + declare writer: ReplWriterFunction; + declare inputStream: ReadableInput; + declare outputStream: WritableOutput | null | undefined; + declare [kContextId]: undefined; + /** Readline's underscore aliases, installed on `Interface.prototype` at runtime. */ + declare _ttyWrite: (d: string | ArrayBufferView | null, key?: Key) => void; + declare _sawKeyPress: boolean; + declare _previousKey: Key | null; + declare _getDisplayPos: (str: string) => { rows: number; cols: number }; + declare [kBufferedCommandSymbol]: string; + declare [kLoadingSymbol]: boolean; + + constructor(...args: LegacyArgs) { + let [prompt, stream, eval_, useGlobal, ignoreUndefined, replMode] = args; + let options: ReplOptions; + if (prompt !== null && typeof prompt === "object") { + // An options object was given. + options = { ...prompt }; + stream = options.stream || options.socket; + eval_ = options.eval; + useGlobal = options.useGlobal; + ignoreUndefined = options.ignoreUndefined; + prompt = options.prompt; + replMode = options.replMode; + } else { + options = {}; + } + + if (!options.input && !options.output) { + // Legacy API, passing a 'stream'/'socket' option. + // Use stdin and stdout as the default streams if none were given. + const source = stream ?? processGlobal(); + if (source === undefined) { + throw unsupportedNodeApi( + "repl.start() without input and output streams", + "there is no `process` global to supply stdin and stdout; pass the `input` and `output` " + + "options (or a `stream`) explicitly", + ); + } + + // We're given a duplex readable/writable Stream, like a `net.Socket` + // or a custom object with 2 streams, or the `process` object. + options.input = (source.stdin ?? source) as ReadableInput; + options.output = (source.stdout ?? source) as WritableOutput; + } + if (!options.input || !options.output) { + throw unsupportedNodeApi( + "repl.start() with only one of input and output", + "a component REPL has no default stream to fill in the other side; pass both", + ); + } + + if (options.terminal === undefined) { + options.terminal = options.output.isTTY; + } + options.terminal = !!options.terminal; + + if (options.terminal && options.useColors === undefined) { + // If possible, check if stdout supports colors or not. + options.useColors = shouldColorize(options.output); + } + + // Node routes previews through the inspector and disables them when it has none. A component + // has none, so `preview` is accepted and has no effect. + + if (options.breakEvalOnSigint && eval_) { + // Allowing this would not reflect user expectations. + // breakEvalOnSigint affects only the behavior of the default eval(). + throw invalidReplEvalConfig(); + } + if (options.breakEvalOnSigint) { + throw unsupportedNodeApi( + "repl option breakEvalOnSigint", + "a component has no signal watchdog that could interrupt a running evaluation", + ); + } + if (!useGlobal) { + throw unsupportedNodeApi( + "repl option useGlobal: false (Node's default)", + "the platform cannot create a second realm for a separate REPL context; pass " + + "`useGlobal: true` to evaluate against the global scope, which is exact", + ); + } + if (replMode === REPL_MODE_STRICT) { + throw unsupportedNodeApi( + "repl option replMode: REPL_MODE_STRICT", + "a strict-mode script evaluated through the engine's eval cannot bind declarations in " + + "the global scope, so nothing would persist between lines; use REPL_MODE_SLOPPY", + ); + } + + // The readline completer is only ever called after construction, so it may look `this` up + // through a box that is filled in once `super()` has returned. + const box: { repl?: ReplServerCore } = {}; + function completer(text: string, cb: CompletionCallback) { + const repl = box.repl!; + complete.call(repl, text, repl.editorMode ? repl.completeOnEditorMode(cb) : cb); + } + + // All the parameters in the object are defining the "input" param of the + // InterfaceConstructor. + super({ + input: options.input, + output: options.output, + completer: options.completer || completer, + terminal: options.terminal, + historySize: options.historySize, + prompt: prompt, + }); + box.repl = this; + // oxlint-disable-next-line typescript/no-this-alias -- Preserve upstream listener closures. + const self = this; + + Object.defineProperty(this, "inputStream", { + get: () => this.input, + set: (val: ReadableInput) => { + this.input = val; + }, + enumerable: false, + configurable: true, + }); + Object.defineProperty(this, "outputStream", { + get: () => this.output, + set: (val: WritableOutput | null | undefined) => { + this.output = val; + }, + enumerable: false, + configurable: true, + }); + + this.allowBlockingCompletions = !!options.allowBlockingCompletions; + this.useColors = !!options.useColors; + this._domain = options.domain || new ReplDomain(); + this.useGlobal = !!useGlobal; + this.ignoreUndefined = !!ignoreUndefined; + this.replMode = replMode || REPL_MODE_SLOPPY; + this.underscoreAssigned = false; + this.last = undefined; + this.underscoreErrAssigned = false; + this.lastError = undefined; + this.breakEvalOnSigint = !!options.breakEvalOnSigint; + this.editorMode = false; + // Context id for use with the inspector protocol. + this[kContextId] = undefined; + this[kLastCommandErrored] = false; + + const savedRegExMatches = ["", "", "", "", "", "", "", "", "", ""]; + const sep = "\u0000\u0000\u0000"; + const regExMatcher = new RegExp( + `^${sep}(.*)${sep}(.*)${sep}(.*)${sep}(.*)` + + `${sep}(.*)${sep}(.*)${sep}(.*)${sep}(.*)` + + `${sep}(.*)$`, + ); + + eval_ ||= defaultEval; + + // Pause taking in new input, and store the keys in a buffer. + const pausedBuffer: ( + | [type: "key", payload: [string | ArrayBufferView | null, Key], isCompletionEnabled: boolean] + | [type: "close"] + )[] = []; + let paused = false; + function pause() { + paused = true; + } + + function unpause() { + if (!paused) { + return; + } + paused = false; + let entry; + const tmpCompletionEnabled = self.isCompletionEnabled; + while ((entry = pausedBuffer.shift()) !== undefined) { + switch (entry[0]) { + case "key": { + const [d, key] = entry[1]; + self.isCompletionEnabled = entry[2]; + self._ttyWrite(d, key); + break; + } + case "close": + self.emit("exit"); + break; + } + if (paused) { + break; + } + } + self.isCompletionEnabled = tmpCompletionEnabled; + } + + function defaultEval(code: string, context: object, file: string, cb: EvalCallback) { + let result: unknown; + let wrappedErr: Error | undefined; + let err: Error | null = null; + let wrappedCmd = false; + let awaitPromise = false; + const input = code; + + if (isObjectLiteral(code) && isValidSyntax(code)) { + // Add parentheses to make sure `code` is parsed as an expression + code = `(${code.trim()})\n`; + wrappedCmd = true; + } + + // Top-level await is always enabled, as it is by default in Node. + if (code.includes("await")) { + try { + const potentialWrappedCode = processTopLevelAwait(code); + if (potentialWrappedCode !== null) { + code = potentialWrappedCode; + wrappedCmd = true; + awaitPromise = true; + } + } catch (error) { + const e = error as Error; + let recoverableError = false; + if (e.name === "SyntaxError") { + // Remove all "await"s and attempt running the script + // in order to detect if error is truly non recoverable + const fallbackCode = code.replace(/\bawait\b/g, ""); + try { + parseScript(fallbackCode); + } catch (fallbackError) { + if (isRecoverableError(fallbackError, fallbackCode)) { + recoverableError = true; + err = new Recoverable(e); + } + } + } + if (!recoverableError) { + err = e; + } + } + } + + // First, create the Script object to check the syntax + if (code === "\n") { + return cb(null); + } + + if (err === null) { + while (true) { + try { + // Node compiles a `vm.Script` here; acorn is the engine-neutral compile step, and its + // syntax tree drives the script-scope emulation (see `persistLexicalDeclarations`). + code = persistLexicalDeclarations(code, parseScript(code)); + } catch (error) { + if (wrappedCmd) { + // Unwrap and try again + wrappedCmd = false; + awaitPromise = false; + code = input; + wrappedErr = error as Error; + continue; + } + // Preserve original error for wrapped command + const parseError = wrappedErr || (error as Error); + const parsed = wrappedErr ? `(${input.trim()})\n` : code; + decorateSyntaxError(parseError, parsed); + normalizeAcornError(parseError, parsed); + if (isRecoverableError(parseError, code)) { + err = new Recoverable(parseError); + } else { + err = parseError; + } + } + break; + } + } + + // This will set the values from `savedRegExMatches` to corresponding + // predefined RegExp properties `RegExp.$1`, `RegExp.$2` ... `RegExp.$9` + if (legacyRegExpStatics) { + regExMatcher.exec(savedRegExMatches.join(sep)); + } + + let finished = false; + function finishExecution(err: Error | null, result?: unknown) { + if (finished) { + return; + } + finished = true; + + // After executing the current expression, store the values of RegExp + // predefined properties back in `savedRegExMatches` + if (legacyRegExpStatics) { + for (let idx = 1; idx < savedRegExMatches.length; idx += 1) { + savedRegExMatches[idx] = (RegExp as unknown as RegExpStatics)[`$${idx}`]; + } + } + + if (err) { + cb(err); + } else { + cb(null, result); + } + } + + if (!err) { + try { + result = runInThisContext(code, file); + } catch (error) { + // Node hands a runtime error straight to the active domain and never reaches the + // callback, so the line is not recorded in `lines`; the local sink takes the same path. + self._domain.emit("error", error); + self._domain.exit(); + return; + } + + if (awaitPromise) { + pause(); + const promise = result as Promise<{ value?: unknown } | undefined>; + + (async () => { + try { + const result = (await promise)?.value; + finishExecution(null, result); + } catch (error) { + if (error) { + self._domain.emit("error", error); + self._domain.exit(); + return; + } + finishExecution(error as Error); + } finally { + unpause(); + } + })(); + } + } + + if (!awaitPromise || err) { + finishExecution(err, result); + } + } + + self.eval = self._domain.bind(eval_); + + self._domain.on("error", function debugDomainError(e: unknown) { + let errStack = ""; + + if (typeof e === "object" && e !== null) { + if (isError(e)) { + if (e.stack) { + if (e.name === "SyntaxError") { + // Remove stack trace. Engines format frames differently, so rebuild the first line + // rather than pattern-matching V8's ` at` prefix, and lead with the source + // caret Node's own compile errors carry. + e.stack = `${arrowMessages.get(e) ?? ""}${e.name}: ${e.message}\n`; + if (e.message.includes(importErrorStr) || e.message.includes(acornImportErrorStr)) { + e.message = + "Cannot use import statement inside the Node.js " + + "REPL, alternatively use dynamic import: " + + toDynamicImport(self.lines.at(-1) ?? ""); + e.stack = e.stack.replace(/SyntaxError:.*\n/, `SyntaxError: ${e.message}\n`); + } + } else { + e.stack = trimEvalFrames(e.stack); + } + } + errStack = self.writer(e); + + // Remove one line error braces to keep the old style in place. + if (errStack[0] === "[" && errStack[errStack.length - 1] === "]") { + errStack = errStack.slice(1, -1); + } + } + } + + if (!self.underscoreErrAssigned) { + self.lastError = e; + } + + if (errStack === "") { + errStack = self.writer(e); + } + const lines = errStack.split(/(?<=\n)/); + let matched = false; + + errStack = ""; + for (const line of lines) { + if (!matched && /^\[?([A-Z][a-z0-9_]*)*Error/.test(line)) { + errStack += + (writer.options.breakLength ?? 0) >= line.length + ? `Uncaught ${line}` + : `Uncaught:\n${line}`; + matched = true; + } else { + errStack += line; + } + } + if (!matched) { + const ln = lines.length === 1 ? " " : ":\n"; + errStack = `Uncaught${ln}${errStack}`; + } + // Normalize line endings. + errStack += errStack.endsWith("\n") ? "" : "\n"; + self.output!.write(errStack); + self.clearBufferedCommand(); + self.lines.level = []; + if (!self.closed) { + self.displayPrompt(); + } + }); + + self.clearBufferedCommand(); + + self.resetContext(); + + this.commands = Object.create(null) as Record; + defineDefaultCommands(this); + + // Figure out which "writer" function to use + self.writer = options.writer || writer; + + if (self.writer === writer) { + // Conditionally turn on ANSI coloring. + writer.options.colors = self.useColors; + } + + function _parseREPLKeyword(this: ReplServerCore, keyword: string, rest: string): boolean { + const cmd = this.commands[keyword]; + if (cmd) { + cmd.action.call(this, rest); + return true; + } + return false; + } + + self.on("close", function emitExit() { + if (paused) { + pausedBuffer.push(["close"]); + return; + } + self.emit("exit"); + }); + + let sawSIGINT = false; + let sawCtrlD = false; + self.on("SIGINT", function onSigInt() { + const empty = self.line.length === 0; + self.clearLine(); + _turnOffEditorMode(self); + + const cmd = self[kBufferedCommandSymbol]; + if (!(cmd && cmd.length > 0) && empty) { + if (sawSIGINT) { + self.close(); + sawSIGINT = false; + return; + } + self.output!.write("(To exit, press Ctrl+C again or Ctrl+D or type .exit)\n"); + sawSIGINT = true; + } else { + sawSIGINT = false; + } + + self.clearBufferedCommand(); + self.lines.level = []; + self.displayPrompt(); + }); + + self.on("line", function onLine(cmd: string) { + cmd ||= ""; + sawSIGINT = false; + + if (self.editorMode) { + self[kBufferedCommandSymbol] += cmd + "\n"; + + // code alignment + const matches = self._sawKeyPress && !self[kLoadingSymbol] ? /^\s+/.exec(cmd) : null; + if (matches) { + const prefix = matches[0]; + self.write(prefix); + self.line = prefix; + self.cursor = prefix.length; + } + _memory.call(self, cmd); + return; + } + + // Check REPL keywords and empty lines against a trimmed line input. + const trimmedCmd = cmd.trim(); + + // Check to see if a REPL keyword was used. If it returns true, + // display next prompt and return. + if (trimmedCmd) { + if ( + trimmedCmd.charAt(0) === "." && + trimmedCmd.charAt(1) !== "." && + Number.isNaN(Number.parseFloat(trimmedCmd)) + ) { + const matches = /^\.([^\s]+)\s*(.*)$/.exec(trimmedCmd); + const keyword = matches?.[1] ?? ""; + const rest = matches?.[2] ?? ""; + if (_parseREPLKeyword.call(self, keyword, rest) === true) { + return; + } + if (!self[kBufferedCommandSymbol]) { + self.output!.write("Invalid REPL keyword\n"); + finish(null); + return; + } + } + } + + const evalCmd = self[kBufferedCommandSymbol] + cmd + "\n"; + + self.eval(evalCmd, self.context, getREPLResourceName(), finish); + + function finish(e: Error | null, ...rest: [result?: unknown]) { + _memory.call(self, cmd); + + if ( + e && + !self[kBufferedCommandSymbol] && + cmd.trim().startsWith("npm ") && + !(e instanceof Recoverable) + ) { + self.output!.write( + "npm should be run outside of the " + + "Node.js REPL, in your normal shell.\n" + + "(Press Ctrl+D to exit.)\n", + ); + self.displayPrompt(); + return; + } + + // If error was SyntaxError and not JSON.parse error + // We can start a multiline command + if (e instanceof Recoverable && !sawCtrlD) { + if (self.terminal) { + self[kAddNewLineOnTTY](); + } else { + self[kBufferedCommandSymbol] += cmd + "\n"; + self.displayPrompt(); + } + return; + } + + if (e) { + self._domain.emit("error", (e as Recoverable).err || e); + self[kLastCommandErrored] = true; + } + + // Clear buffer if no SyntaxErrors + self.clearBufferedCommand(); + sawCtrlD = false; + + // If we got any output - print it (if no error) + const ret = rest[0]; + if ( + !e && + // When an invalid REPL command is used, error message is printed + // immediately. We don't have to print anything else. So, only when + // the second argument to this function is there, print it. + rest.length === 1 && + (!self.ignoreUndefined || ret !== undefined) + ) { + if (!self.underscoreAssigned) { + self.last = ret; + } + self.output!.write(self.writer(ret) + "\n"); + } + + // If the REPL sever hasn't closed display prompt again (unless we already + // did by emitting the 'error' event on the domain instance). + if (!self.closed && !e) { + self[kLastCommandErrored] = false; + self.displayPrompt(); + } + } + }); + + self.on("SIGCONT", function onSigCont() { + if (self.editorMode) { + self.output!.write(`${self._initialPrompt}.editor\n`); + self.output!.write("// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)\n"); + self.output!.write(`${self[kBufferedCommandSymbol]}\n`); + self.prompt(true); + } else { + self.displayPrompt(true); + } + }); + + const { reverseSearch } = setupReverseSearch(this); + + // Wrap readline tty to enable editor mode and pausing. + const ttyWrite = self._ttyWrite.bind(self); + self._ttyWrite = (d: string | ArrayBufferView | null, key?: Key) => { + key ||= {}; + if (paused) { + pausedBuffer.push(["key", [d, key], self.isCompletionEnabled]); + return; + } + if (!self.editorMode || !self.terminal) { + // Before exiting, make sure to clear the line. + if (key.ctrl && key.name === "d" && self.cursor === 0 && self.line.length === 0) { + self.clearLine(); + } + if (!reverseSearch(d, key)) { + ttyWrite(d, key); + } + return; + } + + // Editor mode + if (key.ctrl && !key.shift) { + switch (key.name) { + // TODO(BridgeAR): There should not be a special mode necessary for full + // multiline support. + case "d": // End editor mode + _turnOffEditorMode(self); + sawCtrlD = true; + ttyWrite(d, { name: "return" }); + break; + case "n": // Override next history item + case "p": // Override previous history item + break; + default: + ttyWrite(d, key); + } + } else { + switch (key.name) { + case "up": // Override previous history item + case "down": // Override next history item + break; + case "tab": + // Prevent double tab behavior + self._previousKey = null; + ttyWrite(d, key); + break; + default: + ttyWrite(d, key); + } + } + }; + + self.displayPrompt(); + } + + setupHistory(historyConfig: ReplHistoryConfig | string = {}, cb?: HistoryLoadedCallback): void { + // TODO(puskin94): necessary because historyConfig can be a string for backwards compatibility + const options: ReplHistoryConfig = + typeof historyConfig === "string" ? { filePath: historyConfig } : historyConfig; + + if (typeof cb === "function") { + options.onHistoryFileLoaded = cb; + } + + // Node's history manager keeps the in-memory list and, given a path, mirrors it to a file. A + // component has no filesystem to mirror to, so the path takes Node's own "could not open" + // route and the session keeps its in-memory history. + this.historyManager = new History(this, { + history: [...this.history], + size: options.size ?? this.historySize, + removeHistoryDuplicates: options.removeHistoryDuplicates, + }); + if (options.filePath) { + this.output!.write( + "\nError: Could not open history file.\nREPL session history will not be persisted.\n", + ); + } + options.onHistoryFileLoaded?.(null, this); + } + + clearBufferedCommand(): void { + this[kBufferedCommandSymbol] = ""; + } + + override close(): void { + if (this.terminal && this.historyManager.isFlushing && !this._closingOnFlush) { + this._closingOnFlush = true; + this.once("flushHistory", () => super.close()); + + return; + } + defer(() => super.close()); + } + + createContext(): object { + // Only the global context is supported (see the constructor), and Node defines the module + // scaffolding on it just the same. + const context = globalThis; + + const replModule = new Module(""); + + Object.defineProperty(context, "module", { + configurable: true, + writable: true, + value: replModule, + }); + Object.defineProperty(context, "require", { + configurable: true, + writable: true, + value: createRequire(""), + }); + + // Node also installs lazy getters here that `require()` every core module on first use. A + // component has no loader, so nothing is installed: an unimported builtin name is a + // ReferenceError, not a getter that throws. + + return context; + } + + resetContext(): void { + this.context = this.createContext(); + this.underscoreAssigned = false; + this.underscoreErrAssigned = false; + // TODO(BridgeAR): Deprecate the lines. + this.lines = Object.assign([] as string[], { level: [] }) as ReplLines; + + Object.defineProperty(this.context, "_", { + configurable: true, + get: () => this.last, + set: (value: unknown) => { + this.last = value; + if (!this.underscoreAssigned) { + this.underscoreAssigned = true; + this.output!.write("Expression assignment to _ now disabled.\n"); + } + }, + }); + + Object.defineProperty(this.context, "_error", { + configurable: true, + get: () => this.lastError, + set: (value: unknown) => { + this.lastError = value; + if (!this.underscoreErrAssigned) { + this.underscoreErrAssigned = true; + this.output!.write("Expression assignment to _error now disabled.\n"); + } + }, + }); + + // Allow REPL extensions to extend the new context + this.emit("reset", this.context); + } + + displayPrompt(preserveCursor?: boolean): void { + let prompt = this._initialPrompt; + if (this[kBufferedCommandSymbol].length) { + prompt = kMultilinePrompt.description!; + } + + // Do not overwrite `_initialPrompt` here + super.setPrompt(prompt); + this.prompt(preserveCursor); + } + + // When invoked as an API method, overwrite _initialPrompt + override setPrompt(prompt: string): void { + this._initialPrompt = prompt; + super.setPrompt(prompt); + } + + complete(line: string, callback: CompletionCallback): void { + (this.completer as (line: string, callback: CompletionCallback) => void).call( + this, + line, + callback, + ); + } + + completeOnEditorMode(callback: CompletionCallback): CompletionCallback { + return (err, results) => { + if (err) { + return callback(err); + } + + const [completions, completeOn = ""] = results as CompleterResult; + let result = completions.filter(Boolean); + + if (completeOn && result.length !== 0) { + result = [commonPrefix(result)]; + } + + callback(null, [result, completeOn]); + }; + } + + defineCommand(keyword: string, cmd: ReplCommandDefinition): void { + let command: ReplCommand; + if (typeof cmd === "function") { + command = { action: cmd }; + } else { + validateFunction(cmd.action, "cmd.action"); + command = cmd; + } + this.commands[keyword] = command; + } +} + +// Node's `_memory` records every line and tries to track brace depth for tab completion inside +// function bodies. Its depth arithmetic (`dw.length - up.length` on two numbers) is NaN in Node +// 24, so `lines.level` never gains an entry there; that observable behaviour is kept, minus the +// dead arithmetic. +function _memory(this: ReplServerCore, cmd: string | undefined) { + this.lines ||= Object.assign([] as string[], { level: [] }) as ReplLines; + this.lines.level ||= []; + + // Save the line so I can do magic later + if (cmd) { + const len = this.lines.level.length ? this.lines.level.length - 1 : 0; + this.lines.push(" ".repeat(len) + cmd); + } else { + // I don't want to not change the format too much... + this.lines.push(""); + } + + if (!cmd) { + this.lines.level = []; + } +} + +function _turnOnEditorMode(repl: ReplServerCore) { + repl.editorMode = true; + Interface.prototype.setPrompt.call(repl, ""); +} + +function _turnOffEditorMode(repl: ReplServerCore) { + repl.editorMode = false; + repl.setPrompt(repl._initialPrompt); +} + +function defineDefaultCommands(repl: ReplServerCore) { + repl.defineCommand("break", { + help: "Sometimes you get stuck, this gets you out", + action: function (this: ReplServerCore) { + this.clearBufferedCommand(); + this.displayPrompt(); + }, + }); + + repl.defineCommand("clear", { + help: "Alias for .break", + action: function (this: ReplServerCore) { + this.clearBufferedCommand(); + this.displayPrompt(); + }, + }); + + repl.defineCommand("exit", { + help: "Exit the REPL", + action: function (this: ReplServerCore) { + this.close(); + }, + }); + + repl.defineCommand("help", { + help: "Print this help message", + action: function (this: ReplServerCore) { + const names = Object.keys(this.commands).sort(); + const longestNameLength = Math.max(...names.map((name) => name.length)); + for (const name of names) { + const cmd = this.commands[name]; + const spaces = " ".repeat(longestNameLength - name.length + 3); + const line = `.${name}${cmd.help ? spaces + cmd.help : ""}\n`; + this.output!.write(line); + } + this.output!.write("\nPress Ctrl+C to abort current expression, Ctrl+D to exit the REPL\n"); + this.displayPrompt(); + }, + }); + + // `.save` and `.load` need a filesystem, which a component does not have by default. Node's own + // response to an I/O failure is a one-line message and a fresh prompt; that is what they do here. + repl.defineCommand("save", { + help: "Save all evaluated commands in this REPL session to a file", + action: function (this: ReplServerCore, file: string) { + if (file === "") { + this.output!.write(`${missingArgs("file").message}\n`); + } else { + this.output!.write(`Failed to save: ${file}\n`); + } + this.displayPrompt(); + }, + }); + + repl.defineCommand("load", { + help: "Load JS from a file into the REPL session", + action: function (this: ReplServerCore, file: string) { + if (file === "") { + this.output!.write(`${missingArgs("file").message}\n`); + } else { + this.output!.write(`Failed to load: ${file}\n`); + } + this.displayPrompt(); + }, + }); + if (repl.terminal) { + repl.defineCommand("editor", { + help: "Enter editor mode", + action(this: ReplServerCore) { + _turnOnEditorMode(this); + this.output!.write("// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)\n"); + }, + }); + } +} + +export type REPLServer = ReplServerCore; + +export interface REPLServerConstructor { + new (options?: ReplOptions): REPLServer; + new ( + prompt?: string, + stream?: ReplOptions["stream"], + eval_?: ReplEvalFunction, + useGlobal?: boolean, + ignoreUndefined?: boolean, + replMode?: symbol, + ): REPLServer; + prototype: REPLServer; +} + +/** + * Node's `REPLServer` is a plain function whose prototype chain is grafted onto `Interface`. + * Calling it without `new` is a runtime deprecation there (DEP0185) and a refusal here. + */ +export const REPLServer: REPLServerConstructor = function REPLServer( + this: unknown, + prompt?: string | ReplOptions, + stream?: ReplOptions["stream"], + eval_?: ReplEvalFunction, + useGlobal?: boolean, + ignoreUndefined?: boolean, + replMode?: symbol, +): REPLServer { + if (!new.target) { + throw deprecatedNodeApi("REPLServer() called without new (DEP0185)", "new REPLServer()"); + } + return Reflect.construct( + ReplServerCore, + [prompt, stream, eval_, useGlobal, ignoreUndefined, replMode], + new.target, + ) as REPLServer; +} as unknown as REPLServerConstructor; +REPLServer.prototype = ReplServerCore.prototype; +Object.defineProperty(REPLServer.prototype, "constructor", { + value: REPLServer, + writable: true, + configurable: true, +}); +Object.setPrototypeOf(REPLServer, Interface); + +// Prompt is a string to print on each line for the prompt, +// source is a stream to use for I/O, defaulting to stdin/stdout. +export function start(options?: ReplOptions): REPLServer; +export function start( + prompt?: string, + source?: ReplOptions["stream"], + eval_?: ReplEvalFunction, + useGlobal?: boolean, + ignoreUndefined?: boolean, + replMode?: symbol, +): REPLServer; +export function start( + prompt?: string | ReplOptions, + source?: ReplOptions["stream"], + eval_?: ReplEvalFunction, + useGlobal?: boolean, + ignoreUndefined?: boolean, + replMode?: symbol, +): REPLServer { + return new REPLServer(prompt as string, source, eval_, useGlobal, ignoreUndefined, replMode); +} + +export { REPL_MODE_SLOPPY, REPL_MODE_STRICT, Recoverable, isValidSyntax }; +export type { ReadlineInterface }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/types.ts new file mode 100644 index 000000000..4728d1898 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/types.ts @@ -0,0 +1,93 @@ +// Signatures follow the MIT-licensed @types/node 24 repl declarations, adapted to the structural +// stream contracts of Jco's readline port so consumers do not need @types/node. + +import type { REPLServer } from "./server.js"; +import type { + AsyncCompleter, + Completer, + PromiseCompleter, + ReadableInput, + WritableOutput, +} from "../readline/types.js"; + +/** The callback the evaluator reports through; `result` is present only on success. */ +export type EvalCallback = (error: Error | null, result?: unknown) => void; + +/** + * A custom evaluator, as documented for `repl.start({ eval })`. + * + * `context` is always `globalThis` here: Jco supports only `useGlobal: true` (see the README). + */ +export type ReplEvalFunction = ( + code: string, + context: object, + file: string, + callback: EvalCallback, +) => void; + +/** What `writer` receives and returns. */ +export type ReplWriterFunction = (value: unknown) => string; + +/** A legacy `stream`/`socket` option, or the `process` object: something with both ends. */ +export interface ReplDuplexLike { + stdin?: ReadableInput; + stdout?: WritableOutput; +} + +/** The subset of Node's `process` the REPL reads when it is present as a global. */ +export interface ProcessLike extends ReplDuplexLike { + env?: Record; +} + +/** Node's `options.domain`, which the REPL binds evaluation through. */ +export interface ReplDomainLike { + on(event: "error", listener: (error: unknown) => void): unknown; + emit(event: "error", error: unknown): boolean; + bind unknown>(fn: T): T; + exit(): void; +} + +export interface ReplOptions { + prompt?: string; + input?: ReadableInput; + output?: WritableOutput; + /** Legacy duplex stream option, replaced by `input`/`output`. */ + stream?: ReadableInput & WritableOutput & ReplDuplexLike; + /** Legacy alias of `stream`. */ + socket?: ReadableInput & WritableOutput & ReplDuplexLike; + terminal?: boolean; + eval?: ReplEvalFunction; + useColors?: boolean; + useGlobal?: boolean; + ignoreUndefined?: boolean; + writer?: ReplWriterFunction; + completer?: Completer | AsyncCompleter | PromiseCompleter; + replMode?: symbol; + breakEvalOnSigint?: boolean; + preview?: boolean; + historySize?: number; + allowBlockingCompletions?: boolean; + domain?: ReplDomainLike; +} + +/** A REPL keyword command, as registered through `defineCommand`. */ +export interface ReplCommand { + help?: string; + action: (this: REPLServer, rest: string) => void; +} + +export type ReplCommandDefinition = ReplCommand | ((this: REPLServer, rest: string) => void); + +export type HistoryLoadedCallback = (error: Error | null, repl: unknown) => void; + +export interface ReplHistoryConfig { + filePath?: string; + size?: number; + removeHistoryDuplicates?: boolean; + onHistoryFileLoaded?: HistoryLoadedCallback; +} + +/** `replServer.lines`: the evaluated lines, plus the nesting bookkeeping Node keeps on it. */ +export interface ReplLines extends Array { + level: { line: number; depth: number }[]; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/utils.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/utils.ts new file mode 100644 index 000000000..4435de0a5 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/repl/utils.ts @@ -0,0 +1,567 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/internal/repl/utils.js and +// lib/internal/util/colors.js. Local changes: TypeScript types, ES intrinsics, acorn from npm +// rather than Node's vendored copy, `process` read as an optional global, and no inspector: +// `setupPreview` is omitted because Node itself disables previews without one. +// See ./README.md for runtime boundaries and the upstream dependency audit. + +import { Parser, tokTypes, type Options, type Program, type TokenType } from "acorn"; +import { builtinModules } from "../module/builtins.js"; +import { clearScreenDown, cursorTo, moveCursor } from "../readline/callbacks.js"; +import { kSetLine } from "../readline/interface.js"; +import { kSubstringSearch } from "../readline/utils.js"; +import type { Key, WritableOutput } from "../readline/types.js"; +import type { ProcessLike } from "./types.js"; + +export const REPL_MODE_SLOPPY: unique symbol = Symbol("repl-sloppy"); +export const REPL_MODE_STRICT: unique symbol = Symbol("repl-strict"); +export const kContextId = Symbol("contextId"); + +/** The `process` global when one exists; the REPL never imports `node:process`. */ +export function processGlobal(): ProcessLike | undefined { + const candidate = (globalThis as { process?: unknown }).process; + return candidate !== null && typeof candidate === "object" + ? (candidate as ProcessLike) + : undefined; +} + +function env(name: string): string | undefined { + return processGlobal()?.env?.[name]; +} + +/** Parse `code` as Node's REPL compiles it: a classic script, latest ECMAScript. */ +export function parseScript(code: string, extra: Partial = {}): Program { + return Parser.parse(code, { ecmaVersion: "latest", ...extra }); +} + +/** acorn's parse errors carry `pos`/`loc` and a ` (line:column)` message suffix Node strips. */ +export interface AcornSyntaxError extends SyntaxError { + pos: number; + loc: { line: number; column: number }; +} + +export function isAcornSyntaxError(error: unknown): error is AcornSyntaxError { + return error instanceof SyntaxError && typeof (error as AcornSyntaxError).pos === "number"; +} + +const KEYWORDS = new Set( + ( + "break case catch class const continue debugger default delete do else enum export extends " + + "false finally for function if import in instanceof new null return super switch this throw " + + "true try typeof var void while with yield let static await async of" + ).split(" "), +); + +/** V8's wording for an unexpected token at `pos`: identifier, number, string, keyword, or char. */ +function describeUnexpectedToken(code: string, pos: number): string { + const char = code[pos]; + if (char === undefined) { + return "Unexpected end of input"; + } + if (/\d/.test(char)) { + return "Unexpected number"; + } + if (char === "'" || char === '"') { + return "Unexpected string"; + } + if (char === "`") { + return "Unexpected template string"; + } + const word = /^[A-Za-z_$][\w$]*/.exec(code.slice(pos))?.[0]; + if (word === undefined) { + return `Unexpected token '${char}'`; + } + return KEYWORDS.has(word) ? `Unexpected token '${word}'` : `Unexpected identifier '${word}'`; +} + +/** + * Make an acorn parse error read like V8's: drop the trailing ` (line:column)`, and name the + * offending token the way V8's `Unexpected token '.'` and `Unexpected end of input` do. + * + * Mutates and returns the same error, so identity and the `Recoverable.err` link are kept. + */ +export function normalizeAcornError(error: T, code: string): T { + if (isAcornSyntaxError(error)) { + error.message = error.message.replace(/ \(\d+:\d+\)$/, ""); + if (error.message === "Unexpected token") { + error.message = describeUnexpectedToken(code, error.pos); + } + } + return error; +} + +/** + * Rewrite top-level lexical declarations so they outlive the line. + * + * Node runs each line as a `vm.Script`, whose top-level `let`, `const` and `class` bindings live + * in the realm's script scope and stay visible to later lines. An indirect `eval` -- the only + * engine-neutral way to run a script here -- scopes those bindings to the eval itself, so + * `let x = 1` would be gone by the next prompt. Rewriting them to `var` (a `class` to a `var` + * initialised with the class expression) makes them global properties, which is exactly where + * an eval'd `var` lands. Only direct children of the program are touched; blocks, loops and + * function bodies keep their own scoping. The cost is that `const` is not enforced across lines + * and a redeclaration on a later line is silently accepted -- the same trade Node documents for + * lines containing `await`. + */ +export function persistLexicalDeclarations(code: string, ast: Program): string { + const edits: { start: number; end: number; text: string }[] = []; + for (const node of ast.body) { + if (node.type === "VariableDeclaration" && node.kind !== "var") { + edits.push({ start: node.start, end: node.start + node.kind.length, text: "var" }); + } else if (node.type === "ClassDeclaration") { + edits.push({ start: node.start, end: node.start, text: `var ${node.id!.name} = ` }); + } + } + if (edits.length === 0) { + return code; + } + let result = ""; + let cursor = 0; + for (const edit of edits) { + result += code.slice(cursor, edit.start) + edit.text; + cursor = edit.end; + } + return result + code.slice(cursor); +} + +/** The parts of acorn's parser the recoverable-error subclass overrides; not in its public types. */ +interface ParserInternals { + type: TokenType; + input: string; + lastTokStart: number; + pos: number; + nextToken(): void; + raise(pos: number, message: string): never; +} + +type ParserClass = typeof Parser; +type InternalParserClass = new (...args: never[]) => ParserInternals; + +// If the error is that we've unexpectedly ended the input, +// then let the user try to recover by adding more input. +// Note: `e` (the original exception) is not used by the current implementation, +// but may be needed in the future. +export function isRecoverableError(e: unknown, code: string): boolean { + // For similar reasons as `defaultEval`, wrap expressions starting with a + // curly brace with parenthesis. Note: only the open parenthesis is added + // here as the point is to test for potentially valid but incomplete + // expressions. + if (/^\s*\{/.test(code) && isRecoverableError(e, `(${code}`)) { + return true; + } + + let recoverable = false; + + // Determine if the point of any error raised is at the end of the input. + // There are two cases to consider: + // + // 1. Any error raised after we have encountered the 'eof' token. + // This prevents us from declaring partial tokens (like '2e') as + // recoverable. + // + // 2. Three cases where tokens can legally span lines. This is + // template, comment, and strings with a backslash at the end of + // the line, indicating a continuation. Note that we need to look + // for the specific errors of 'unterminated' kind (not, for example, + // a syntax error in a ${} expression in a template), and the only + // way to do that currently is to look at the message. Should Acorn + // change these messages in the future, this will lead to a test + // failure, indicating that this code needs to be updated. + // + const RecoverableParser = Parser.extend((Base: ParserClass) => { + const Internal = Base as unknown as InternalParserClass; + return class extends Internal { + override nextToken(): void { + super.nextToken(); + if (this.type === tokTypes.eof) { + recoverable = true; + } + } + override raise(pos: number, message: string): never { + switch (message) { + case "Unterminated template": + case "Unterminated comment": + recoverable = true; + break; + + case "Unterminated string constant": { + const token = this.input.slice(this.lastTokStart, this.pos); + // See https://www.ecma-international.org/ecma-262/#sec-line-terminators + if (/\\(?:\r\n?|\n|\u2028|\u2029)$/.test(token)) { + recoverable = true; + } + } + } + return super.raise(pos, message); + } + } as unknown as ParserClass; + }); + + // Try to parse the code with acorn. If the parse fails, ignore the acorn + // error and return the recoverable status. + try { + RecoverableParser.parse(code, { ecmaVersion: "latest" }); + + // Odd case: the underlying JS engine (V8, Chakra) rejected this input + // but Acorn detected no issue. Presume that additional text won't + // address this issue. + return false; + } catch { + return recoverable; + } +} + +const startsWithBraceRegExp = /^\s*{/; +const endsWithSemicolonRegExp = /;\s*$/; +export function isValidSyntax(input: string): boolean { + try { + Parser.parse(input, { + ecmaVersion: "latest", + allowAwaitOutsideFunction: true, + }); + return true; + } catch { + try { + Parser.parse(`_=${input}`, { + ecmaVersion: "latest", + allowAwaitOutsideFunction: true, + }); + return true; + } catch { + return false; + } + } +} + +/** + * Checks if some provided code represents an object literal. + * This is helpful to prevent confusing repl code evaluations where + * strings such as `{ a : 1 }` would get interpreted as block statements + * rather than object literals. + * @param {string} code the code to check + * @returns {boolean} true if the code represents an object literal, false otherwise + */ +export function isObjectLiteral(code: string): boolean { + return startsWithBraceRegExp.test(code) && !endsWithSemicolonRegExp.test(code); +} + +let nextREPLResourceNumber = 1; +// This prevents v8 code cache from getting confused and using a different +// cache from a resource of the same name +export function getREPLResourceName(): string { + return `REPL${nextREPLResourceNumber++}`; +} + +let _builtinLibs: string[] = builtinModules.filter((e) => e[0] !== "_" && !e.startsWith("node:")); + +// Note: the `getReplBuiltinLibs` and `setReplBuiltinLibs` are functions used to provide getters and +// setters for the `builtinModules` and `_builtinLibs` properties of the repl module and for making +// sure that all internal repl modules share the same value, which can potentially be updated by users. +// Also note that both `repl.builtinModules` and `repl._builtinLibs` are deprecated, once such properties +// are removed these two functions should also be removed as no longer necessary. + +export function getReplBuiltinLibs(): string[] { + return _builtinLibs; +} + +export function setReplBuiltinLibs(value: string[]): void { + _builtinLibs = value; +} + +/** + * Node's `shouldColorize`, minus the `internal/tty` color-depth probe: `FORCE_COLOR` follows + * Node's own parsing (only `0` and `false` disable), otherwise the stream decides. + */ +export function shouldColorize(stream: WritableOutput | null | undefined): boolean { + const forced = env("FORCE_COLOR"); + if (forced !== undefined) { + const normalized = forced.trim().toLowerCase(); + return normalized !== "0" && normalized !== "false"; + } + const candidate = stream as + | (WritableOutput & { getColorDepth?: () => number }) + | null + | undefined; + return ( + !!candidate?.isTTY && + (typeof candidate.getColorDepth === "function" ? candidate.getColorDepth() > 2 : true) + ); +} + +/** The readline surface reverse search drives; a `REPLServer` satisfies it structurally. */ +export interface ReverseSearchable { + history: string[]; + historyIndex: number; + line: string; + cursor: number; + useColors: boolean; + output: WritableOutput | null | undefined; + getPrompt(): string; + getCursorPos(): { rows: number; cols: number }; + _getDisplayPos(str: string): { rows: number; cols: number }; + [kSetLine](line?: string): void; + [kSubstringSearch]: string | null; +} + +export function setupReverseSearch(repl: ReverseSearchable): { + reverseSearch(string: string | ArrayBufferView | null, key: Key): boolean; +} { + // Simple terminals can't use reverse search. + if (env("TERM") === "dumb") { + return { + reverseSearch() { + return false; + }, + }; + } + + const alreadyMatched = new Set(); + const labels: Record = { + r: "bck-i-search: ", + s: "fwd-i-search: ", + }; + let isInReverseSearch = false; + let historyIndex = -1; + let input = ""; + let cursor = -1; + let dir: "r" | "s" = "r"; + let lastMatch = -1; + let lastCursor = -1; + let promptPos: { rows: number; cols: number }; + + function checkAndSetDirectionKey(keyName: string | undefined): keyName is "r" | "s" { + if (keyName === undefined || !labels[keyName]) { + return false; + } + if (dir !== keyName) { + // Reset the already matched set in case the direction is changed. That + // way it's possible to find those entries again. + alreadyMatched.clear(); + dir = keyName as "r" | "s"; + } + return true; + } + + function goToNextHistoryIndex() { + // Ignore this entry for further searches and continue to the next + // history entry. + alreadyMatched.add(repl.history[historyIndex]); + historyIndex += dir === "r" ? 1 : -1; + cursor = -1; + } + + function search() { + // Just print an empty line in case the user removed the search parameter. + if (input === "") { + print(repl.line, `${labels[dir]}_`); + return; + } + // Fix the bounds in case the direction has changed in the meanwhile. + if (dir === "r") { + if (historyIndex < 0) { + historyIndex = 0; + } + } else if (historyIndex >= repl.history.length) { + historyIndex = repl.history.length - 1; + } + // Check the history entries until a match is found. + while (historyIndex >= 0 && historyIndex < repl.history.length) { + let entry = repl.history[historyIndex]; + // Visualize all potential matches only once. + if (alreadyMatched.has(entry)) { + historyIndex += dir === "r" ? 1 : -1; + continue; + } + // Match the next entry either from the start or from the end, depending + // on the current direction. + if (dir === "r") { + // Update the cursor in case it's necessary. + if (cursor === -1) { + cursor = entry.length; + } + cursor = entry.lastIndexOf(input, cursor - 1); + } else { + cursor = entry.indexOf(input, cursor + 1); + } + // Match not found. + if (cursor === -1) { + goToNextHistoryIndex(); + // Match found. + } else { + if (repl.useColors) { + const start = entry.slice(0, cursor); + const end = entry.slice(cursor + input.length); + entry = `${start}\x1B[4m${input}\x1B[24m${end}`; + } + print(entry, `${labels[dir]}${input}_`, cursor); + lastMatch = historyIndex; + lastCursor = cursor; + // Explicitly go to the next history item in case no further matches are + // possible with the current entry. + if ( + (dir === "r" && cursor === 0) || + (dir === "s" && entry.length === cursor + input.length) + ) { + goToNextHistoryIndex(); + } + return; + } + } + print(repl.line, `failed-${labels[dir]}${input}_`); + } + + function print(outputLine: string, inputLine: string, cursor = repl.cursor) { + // TODO(BridgeAR): Resizing the terminal window hides the overlay. To fix + // that, readline must be aware of this information. It's probably best to + // add a couple of properties to readline that allow to do the following: + // 1. Add arbitrary data to the end of the current line while not counting + // towards the line. This would be useful for the completion previews. + // 2. Add arbitrary extra lines that do not count towards the regular line. + // This would be useful for both, the input preview and the reverse + // search. It might be combined with the first part? + // 3. Add arbitrary input that is "on top" of the current line. That is + // useful for the reverse search. + // 4. To trigger the line refresh, functions should be used to pass through + // the information. Alternatively, getters and setters could be used. + // That might even be more elegant. + // The data would then be accounted for when calling `_refreshLine()`. + // This function would then look similar to: + // repl.overlay(outputLine); + // repl.addTrailingLine(inputLine); + // repl.setCursor(cursor); + // More potential improvements: use something similar to stream.cork(). + // Multiple cursor moves on the same tick could be prevented in case all + // writes from the same tick are combined and the cursor is moved at the + // tick end instead of after each operation. + let rows = 0; + if (lastMatch !== -1) { + const line = repl.history[lastMatch].slice(0, lastCursor); + rows = repl._getDisplayPos(`${repl.getPrompt()}${line}`).rows; + cursorTo(repl.output, promptPos.cols); + } else if (isInReverseSearch && repl.line !== "") { + rows = repl.getCursorPos().rows; + cursorTo(repl.output, promptPos.cols); + } + if (rows !== 0) { + moveCursor(repl.output, 0, -rows); + } + + if (isInReverseSearch) { + clearScreenDown(repl.output); + repl.output?.write(`${outputLine}\n${inputLine}`); + } else { + repl.output?.write(`\n${inputLine}`); + } + + lastMatch = -1; + + // To know exactly how many rows we have to move the cursor back we need the + // cursor rows, the output rows and the input rows. + const prompt = repl.getPrompt(); + const cursorLine = prompt + outputLine.slice(0, cursor); + const cursorPos = repl._getDisplayPos(cursorLine); + const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`); + const inputPos = repl._getDisplayPos(inputLine); + const inputRows = inputPos.rows - (inputPos.cols === 0 ? 1 : 0); + + rows = -1 - inputRows - (outputPos.rows - cursorPos.rows); + + moveCursor(repl.output, 0, rows); + cursorTo(repl.output, cursorPos.cols); + } + + function reset(string?: string) { + isInReverseSearch = string !== undefined; + + // In case the reverse search ends and a history entry is found, reset the + // line to the found entry. + if (!isInReverseSearch) { + if (lastMatch !== -1) { + repl[kSetLine](repl.history[lastMatch]); + repl.cursor = lastCursor; + repl.historyIndex = lastMatch; + } + + lastMatch = -1; + + // Clear screen and write the current repl.line before exiting. + cursorTo(repl.output, promptPos.cols); + moveCursor(repl.output, 0, promptPos.rows); + clearScreenDown(repl.output); + if (repl.line !== "") { + repl.output?.write(repl.line); + if (repl.line.length !== repl.cursor) { + const { cols, rows } = repl.getCursorPos(); + cursorTo(repl.output, cols); + moveCursor(repl.output, 0, rows); + } + } + } + + input = string || ""; + cursor = -1; + historyIndex = repl.historyIndex; + alreadyMatched.clear(); + } + + function reverseSearch(string: string | ArrayBufferView | null, key: Key): boolean { + if (!isInReverseSearch) { + if (key.ctrl && checkAndSetDirectionKey(key.name)) { + historyIndex = repl.historyIndex; + promptPos = repl._getDisplayPos(`${repl.getPrompt()}`); + print(repl.line, `${labels[dir]}_`); + isInReverseSearch = true; + } + } else if (key.ctrl && checkAndSetDirectionKey(key.name)) { + search(); + } else if (key.name === "backspace" || (key.ctrl && (key.name === "h" || key.name === "w"))) { + reset(input.slice(0, input.length - 1)); + search(); + // Special handle + c and escape. Those should only cancel the + // reverse search. The original line is visible afterwards again. + } else if ((key.ctrl && key.name === "c") || key.name === "escape") { + lastMatch = -1; + reset(); + return true; + // End search in case either enter is pressed or if any non-reverse-search + // key (combination) is pressed. + } else if ( + key.ctrl || + key.meta || + key.name === "return" || + key.name === "enter" || + typeof string !== "string" || + string === "" + ) { + reset(); + repl[kSubstringSearch] = ""; + } else { + reset(`${input}${string}`); + search(); + } + return isInReverseSearch; + } + + return { reverseSearch }; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/repl.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/repl.ts new file mode 100644 index 000000000..b16eee28c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/repl.ts @@ -0,0 +1,172 @@ +// Drives a scripted REPL session through either Jco's `node:repl` port or Node's own module, the +// way upstream's tests do with `test/common/arraystream.js`, and normalizes the two engine-specific +// things in the output: stack frame lines and the trailing prompt left by an open session. +import { EventEmitter } from "node:events"; +import nativeRepl from "node:repl"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; + +/** Upstream's ArrayStream: an emitter that looks enough like a duplex stream for the REPL. */ +export class ArrayStream extends EventEmitter { + readable = true; + writable = true; + text = ""; + isTTY?: boolean; + columns?: number; + rows?: number; + isRaw?: boolean; + pause(): this { + return this; + } + resume(): this { + return this; + } + setRawMode(mode: boolean): this { + this.isRaw = mode; + return this; + } + write(chunk: unknown): boolean { + this.text += String(chunk); + return true; + } + run(lines: readonly string[]): void { + for (const line of lines) { + this.emit("data", `${line}\n`); + } + } +} + +/** The surface both REPL implementations expose that the sessions below read. */ +export interface SessionRepl { + on(event: string, listener: (...args: never[]) => unknown): unknown; + once(event: string, listener: (...args: never[]) => unknown): unknown; + close(): void; + closed?: boolean; + lines: string[]; + context: object; + last: unknown; + lastError: unknown; + commands: Record void }>; + line: string; + cursor: number; + history: string[]; + input: unknown; + output: unknown; + editorMode: boolean; + terminal: boolean; + useColors: boolean; + write(data: string | null, key?: object): void; + setPrompt(prompt: string): void; + getPrompt(): string; + displayPrompt(preserveCursor?: boolean): void; + clearBufferedCommand(): void; + defineCommand(keyword: string, cmd: unknown): void; + setupHistory(config: unknown, cb?: unknown): void; + complete(line: string, cb: (err: Error | null, result?: [string[], string]) => void): void; +} + +export type ReplApi = { + start(options: Record): SessionRepl; +}; + +export const portable = repl as unknown as ReplApi; +export const native = nativeRepl as unknown as ReplApi; + +export interface SessionOptions extends Record { + terminal?: boolean; + prompt?: string; +} + +export interface Session { + repl: SessionRepl; + input: ArrayStream; + output: ArrayStream; + events: string[]; + /** Everything written to `output`, with frames normalized. */ + text(): string; + /** Let queued microtasks and timers run, so awaited lines settle. */ + settle(): Promise; + /** Close the session if it is still open and wait for `'exit'`. */ + finish(): Promise; +} + +export function normalizeFrames(text: string): string { + return text + .replace(/^\s+at .*\n?/gm, " at \n") + .replace(/^\S*@\S+:\d+:\d+\n?/gm, " at \n"); +} + +export async function settle(): Promise { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** Start a REPL over array streams; `useGlobal: true` is what the port supports. */ +export function open(api: ReplApi, options: SessionOptions = {}): Session { + const input = new ArrayStream(); + const output = new ArrayStream(); + if (options.terminal) { + output.isTTY = true; + output.columns = 80; + output.rows = 24; + } + const events: string[] = []; + const instance = api.start({ + prompt: "> ", + input, + output, + useGlobal: true, + terminal: false, + useColors: false, + ...options, + }); + for (const event of ["exit", "reset", "close", "SIGINT"]) { + instance.on(event, () => events.push(event)); + } + return { + repl: instance, + input, + output, + events, + text: () => normalizeFrames(output.text), + settle, + async finish() { + await settle(); + if (!instance.closed) { + instance.close(); + } + await settle(); + return normalizeFrames(output.text); + }, + }; +} + +/** Run `lines` through a fresh REPL and return the normalized transcript. */ +export async function transcript( + api: ReplApi, + lines: readonly string[], + options: SessionOptions = {}, +): Promise { + const session = open(api, options); + session.input.run(lines); + return session.finish(); +} + +/** Run the same lines through both implementations and return both transcripts. */ +export async function both( + lines: readonly string[], + options: SessionOptions = {}, +): Promise<{ actual: string; expected: string }> { + const actual = await transcript(portable, lines, options); + const expected = await transcript(native, lines, options); + return { actual, expected }; +} + +export function errorCode(action: () => unknown): string | undefined { + try { + action(); + } catch (error) { + return (error as { code?: string }).code; + } + return undefined; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/acorn-isolation.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/acorn-isolation.ts new file mode 100644 index 000000000..700ad761d --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/acorn-isolation.ts @@ -0,0 +1,33 @@ +// acorn is a dependency of `node:repl` alone. Nothing else in jco-std may import it, or every +// component would carry a parser it never uses. +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +const root = fileURLToPath(new URL("../../../../../../src/wasi/0.2.x/node/", import.meta.url)); + +function* sources(dir: string): Generator { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + yield* sources(path); + } else if (/\.(?:ts|js|mjs)$/.test(entry) && !entry.endsWith(".d.ts")) { + yield path; + } + } +} + +test.concurrent("only the repl directory imports acorn", () => { + const importers: string[] = []; + for (const file of sources(root)) { + const text = readFileSync(file, "utf8"); + if ( + /from\s+["'](?:acorn|acorn-walk)["']|import\s*\(\s*["'](?:acorn|acorn-walk)["']/.test(text) + ) { + importers.push(relative(root, file)); + } + } + expect(importers.length).toBeGreaterThan(0); + expect(importers.every((file) => file.startsWith(join("24.x.x", "repl") + "/"))).toBe(true); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/await.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/await.ts new file mode 100644 index 000000000..76a706a0a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/await.ts @@ -0,0 +1,95 @@ +// Top-level await: the rewriter itself and sessions using it. +// Adapted from Node v24.20.0 test/parallel/test-repl-top-level-await.js. +import { expect, test } from "vitest"; +import { processTopLevelAwait } from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl/await.js"; +import { Recoverable } from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl/recoverable.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { both, open, portable, transcript } from "../helpers/repl.js"; + +const differential = test.skipIf(!hostIsTargetNode); + +test.concurrent("processTopLevelAwait rewrites declarations and returns { value }", () => { + expect(processTopLevelAwait("await 1")).toBe("(async () => { return { value: (await 1) } })()"); + expect(processTopLevelAwait("const x = await p")).toBe( + "let x; (async () => { void (x = await p) })()", + ); + expect(processTopLevelAwait("var a = 1, b = await p")).toBe( + "var a, b; (async () => { void ( (a = 1), (b = await p)) })()", + ); + expect(processTopLevelAwait("let { c, d } = await p")).toBe( + "let c, d; (async () => { void ({ c, d } = await p) })()", + ); + expect(processTopLevelAwait("let [e, ...rest] = await p")).toBe( + "let e; (async () => { void ([e, ...rest] = await p) })()", + ); + expect(processTopLevelAwait("function f() { return 1 }; await f()")).toBe( + "var f; (async () => { this.f = f; function f() { return 1 }; return { value: (await f()) } })()", + ); + expect(processTopLevelAwait("class K {}; await 1")).toBe( + "let K; (async () => { K=class K {}; return { value: (await 1) } })()", + ); + expect(processTopLevelAwait("for await (const x of y) {}")).toBe( + "(async () => { for await (const x of y) {} })()", + ); + expect(processTopLevelAwait("await 1; 2")).toBe( + "(async () => { await 1; return { value: (2) } })()", + ); +}); + +test.concurrent("processTopLevelAwait leaves code without await or with a return alone", () => { + expect(processTopLevelAwait("1 + 1")).toBeNull(); + expect(processTopLevelAwait("return await 1")).toBeNull(); + expect(processTopLevelAwait("const await_ = 1")).toBeNull(); + expect(processTopLevelAwait("foo bar await 1")).toBeNull(); +}); + +test.concurrent("processTopLevelAwait reports incomplete and malformed input like Node", () => { + expect(() => processTopLevelAwait("await `x")).toThrow(Recoverable); + const message = (source: string) => { + try { + processTopLevelAwait(source); + } catch (error) { + expect(error).toBeInstanceOf(SyntaxError); + return (error as Error).message; + } + throw new Error("expected a SyntaxError"); + }; + expect(message("await 1 +")).toBe("\nawait 1 +\n ^\n\nUnexpected token '+'"); + expect(message("function f() { await 1 }")).toBe( + "\nfunction f() { await 1 }\n ^\n\nUnexpected token '1'", + ); +}); + +differential.concurrent("awaited values, declarations and rejections match Node", async () => { + const { actual, expected } = await both([ + "await Promise.resolve(123)", + "const awaitedConst = await Promise.resolve('c')", + "awaitedConst", + "let awaitedLet = 1; awaitedLet = await Promise.resolve(2); awaitedLet", + "(await Promise.resolve({ y: 'inner' })).y", + "await Promise.reject(new Error('REPL await'))", + "for await (const awaitedItem of [Promise.resolve(1), 2]) {}", + "await 1; await 2; 3", + ]); + expect(actual).toBe(expected); + expect(actual).toContain("Uncaught Error: REPL await\n"); +}); + +test.concurrent("input arriving during an await is replayed afterwards in terminal mode", async () => { + const session = open(portable, { terminal: true }); + session.repl.write("await new Promise((resolve) => setTimeout(() => resolve('slow'), 20))"); + session.repl.write(null, { name: "return" }); + session.repl.write("'after'"); + session.repl.write(null, { name: "return" }); + await session.settle(); + await session.settle(); + const text = await session.finish(); + expect(text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")).toContain("'slow'"); + expect(text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")).toContain("'after'"); + expect(text.indexOf("'slow'")).toBeLessThan(text.indexOf("'after'\r\n")); +}); + +test.concurrent("a plain string containing the word await is not rewritten", async () => { + const text = await transcript(portable, ["'no await here'", "({ await: 1 }).await"]); + expect(text).toBe("> 'no await here'\n> 1\n> "); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/commands.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/commands.ts new file mode 100644 index 000000000..c21efc6ff --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/commands.ts @@ -0,0 +1,102 @@ +// The built-in keyword commands: .break, .clear, .exit, .help, .save, .load and .editor. +import { expect, test } from "vitest"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { both, native, open, portable, transcript } from "../helpers/repl.js"; + +const differential = test.skipIf(!hostIsTargetNode); + +differential.concurrent(".help lists the commands in Node's layout", async () => { + const { actual, expected } = await both([".help"]); + expect(actual).toBe(expected); +}); + +differential.concurrent( + ".break and .clear discard buffered input; .clear is an alias in global mode", + async () => { + const { actual, expected } = await both([ + "function commandsBuffered() {", + ".clear", + "1", + "[", + ".break", + "2", + ]); + expect(actual).toBe(expected); + expect(actual).toBe("> | > 1\n> | > 2\n> "); + }, +); + +differential.concurrent(".exit closes the session and emits exit", async () => { + const run = async (api: typeof portable) => { + const session = open(api); + session.input.run(["1", ".exit", "2"]); + await session.settle(); + return { text: session.text(), events: session.events, closed: session.repl.closed }; + }; + const actual = await run(portable); + const expected = await run(native); + expect(actual).toEqual(expected); + expect(actual.closed).toBe(true); + expect(actual.events).toEqual(["exit", "close"]); + // close() is deferred, so a line already queued behind .exit still evaluates, as in Node. + expect(actual.text).toBe("> 1\n> 2\n> "); +}); + +differential.concurrent( + ".save and .load without a file name report the missing argument", + async () => { + const { actual, expected } = await both([".save", ".load"]); + expect(actual).toBe(expected); + expect(actual).toBe( + '> The "file" argument must be specified\n> The "file" argument must be specified\n> ', + ); + }, +); + +test.concurrent(".save and .load with a file name report Node's I/O failure text", async () => { + const text = await transcript(portable, [ + ".save /nowhere/session.js", + ".load /nowhere/session.js", + ]); + expect(text).toBe( + "> Failed to save: /nowhere/session.js\n> Failed to load: /nowhere/session.js\n> ", + ); +}); + +test.concurrent(".editor exists only on terminals and evaluates the buffered text on Ctrl+D", async () => { + const plain = open(portable); + expect(plain.repl.commands.editor).toBeUndefined(); + plain.repl.close(); + + const session = open(portable, { terminal: true }); + expect(session.repl.commands.editor).toBeDefined(); + session.repl.write(".editor"); + session.repl.write(null, { name: "return" }); + expect(session.repl.editorMode).toBe(true); + session.repl.write("var commandsEditor = 6 *"); + session.repl.write(null, { name: "return" }); + session.repl.write("7; commandsEditor"); + session.repl.write(null, { name: "d", ctrl: true }); + expect(session.repl.editorMode).toBe(false); + const text = await session.finish(); + const visible = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + expect(visible).toContain("// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)"); + expect(visible).toContain("42"); +}); + +test.concurrent("Ctrl+C in editor mode cancels and Ctrl+C twice on an empty line exits", async () => { + const session = open(portable, { terminal: true }); + session.repl.write(".editor"); + session.repl.write(null, { name: "return" }); + session.repl.write("unfinished("); + session.repl.write(null, { name: "c", ctrl: true }); + expect(session.repl.editorMode).toBe(false); + expect(session.events.filter((event) => event === "SIGINT")).toHaveLength(1); + session.repl.write(null, { name: "c", ctrl: true }); + session.repl.write(null, { name: "c", ctrl: true }); + await session.settle(); + const text = session.text().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + expect(text).toContain("(To exit, press Ctrl+C again or Ctrl+D or type .exit)"); + expect(session.repl.closed).toBe(true); + expect(session.events).toContain("exit"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/complete.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/complete.ts new file mode 100644 index 000000000..0c3fde4a3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/complete.ts @@ -0,0 +1,119 @@ +// Tab completion through `replServer.complete()` and the completer readline calls. +// Adapted from Node v24.20.0 test/parallel/test-repl-tab-complete.js. +import { expect, test } from "vitest"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { open, portable, native, type ReplApi, type SessionRepl } from "../helpers/repl.js"; + +function completions(repl: SessionRepl, line: string): Promise<[string[], string]> { + return new Promise((resolve, reject) => { + repl.complete(line, (error, result) => (error ? reject(error) : resolve(result!))); + }); +} + +async function withRepl(api: ReplApi, run: (repl: SessionRepl) => Promise): Promise { + const session = open(api); + try { + return await run(session.repl); + } finally { + session.repl.close(); + } +} + +const differential = test.skipIf(!hostIsTargetNode); + +differential.concurrent("member, keyword and command completion match Node", async () => { + const lines = [ + "Math.", + "Math.ma", + "JSON.stri", + "'str'.toUpper", + "[].len", + "({ completeProp: 1 }).comp", + ".he", + ".", + "require('f", + "import('node:f", + "let completeLocal = 1; completeL", + "typeof Math.P", + "new Ma", + "delete Math.P", + ]; + for (const line of lines) { + const actual = await withRepl(portable, (repl) => completions(repl, line)); + const expected = await withRepl(native, (repl) => completions(repl, line)); + expect(actual, line).toEqual(expected); + } +}); + +test.concurrent("global completion lists the context's own names and adds keywords when filtering", async () => { + await withRepl(portable, async (repl) => { + const [all, on] = await completions(repl, ""); + expect(on).toBe(""); + expect(all).toContain("Math"); + expect(all).toContain("globalThis"); + expect(all).not.toContain("await"); + const [filtered, filteredOn] = await completions(repl, "aw"); + expect(filteredOn).toBe("aw"); + expect(filtered).toContain("await"); + }); +}); + +test.concurrent("completion refuses to evaluate getters and side effects", async () => { + await withRepl(portable, async (repl) => { + Object.defineProperty(globalThis, "completeGetter", { + configurable: true, + get() { + throw new Error("must not run"); + }, + }); + try { + const [none] = await completions(repl, "completeGetter."); + expect(none).toEqual([]); + const [call] = await completions(repl, "completeCall()."); + expect(call).toEqual([]); + } finally { + Reflect.deleteProperty(globalThis, "completeGetter"); + } + }); +}); + +test.concurrent("optional chaining and array indices are handled", async () => { + await withRepl(portable, async (repl) => { + const [chained, chainedOn] = await completions(repl, "Math?.ab"); + expect(chainedOn).toBe("Math?.ab"); + expect(chained).toEqual(["Math?.abs"]); + (globalThis as { completeArray?: unknown }).completeArray = ["x"]; + try { + const [members] = await completions(repl, "completeArray."); + expect(members).not.toContain("completeArray.0"); + expect(members).toContain("completeArray.length"); + } finally { + Reflect.deleteProperty(globalThis, "completeArray"); + } + }); +}); + +test.concurrent("the readline completer expands a common prefix in terminal mode", async () => { + const session = open(portable, { terminal: true }); + session.repl.write("Math.PI.toFi"); + session.repl.write(null, { name: "tab" }); + await session.settle(); + expect(session.repl.line).toBe("Math.PI.toFixed"); + session.repl.close(); +}); + +test.concurrent("editor mode completion keeps only the common prefix", async () => { + await withRepl(portable, async (repl) => { + type EditorCallback = (err: Error | null, result?: [string[], string]) => void; + const editor = repl as SessionRepl & { + completeOnEditorMode(cb: EditorCallback): EditorCallback; + }; + const editorCompletions = await new Promise<[string[], string]>((resolve) => { + repl.complete( + "Math.ma", + editor.completeOnEditorMode((_err, result) => resolve(result!)), + ); + }); + expect(editorCompletions).toEqual([["Math.max"], "Math.ma"]); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/context.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/context.ts new file mode 100644 index 000000000..332c02c40 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/context.ts @@ -0,0 +1,40 @@ +// The evaluation context: `globalThis` with Node's module scaffolding, and nothing auto-loaded. +// Kept apart from the differential files: Node's own REPL installs lazy getters for every core +// module on the global it runs against, which would mask what this port deliberately omits. +import { expect, test } from "vitest"; +import { Module } from "../../../../../../src/wasi/0.2.x/node/24.x.x/module/module-class.js"; +import { open, portable } from "../helpers/repl.js"; + +test("the context is the global object with module and require defined", async () => { + const session = open(portable); + expect(session.repl.context).toBe(globalThis); + const context = session.repl.context as { module?: unknown; require?: unknown }; + expect(context.module).toBeInstanceOf(Module); + expect((context.module as Module).id).toBe(""); + expect(typeof context.require).toBe("function"); + for (const name of ["module", "require", "_", "_error"]) { + expect(Object.getOwnPropertyDescriptor(globalThis, name)?.enumerable).toBe(false); + } + session.input.run(["typeof require", "module.id", "typeof fs", "typeof string_decoder"]); + const text = await session.finish(); + expect(text).toBe("> 'function'\n> ''\n> 'undefined'\n> 'undefined'\n> "); +}); + +test("core modules are not auto-loaded and get no getters on the global", async () => { + const session = open(portable); + for (const name of ["fs", "path", "os", "util"]) { + expect(Object.getOwnPropertyDescriptor(globalThis, name)).toBeUndefined(); + } + session.input.run(["fs"]); + const text = await session.finish(); + expect(text).toBe("> Uncaught ReferenceError: fs is not defined\n> "); +}); + +test("values placed on the context are visible to evaluated code", async () => { + const session = open(portable); + (session.repl.context as { contextInjected?: string }).contextInjected = "message"; + session.input.run(["contextInjected"]); + const text = await session.finish(); + expect(text).toBe("> 'message'\n> "); + Reflect.deleteProperty(globalThis, "contextInjected"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/define-command.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/define-command.ts new file mode 100644 index 000000000..d5dd37800 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/define-command.ts @@ -0,0 +1,77 @@ +// `replServer.defineCommand()` and keyword dispatch. +// Adapted from Node v24.20.0 test/parallel/test-repl-definecommand.js. +import { expect, test } from "vitest"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { both, errorCode, native, open, portable } from "../helpers/repl.js"; + +const differential = test.skipIf(!hostIsTargetNode); + +differential.concurrent( + "object and function commands receive the rest of the line and this", + async () => { + const run = async (api: typeof portable) => { + const session = open(api); + const seen: unknown[] = []; + session.repl.defineCommand("say1", { + help: "help for say1", + action(this: unknown, thing: string) { + seen.push(["say1", thing, this === session.repl]); + (this as { output: { write(s: string): void }; displayPrompt(): void }).output.write( + `hello ${thing}\n`, + ); + (this as { displayPrompt(): void }).displayPrompt(); + }, + }); + session.repl.defineCommand("say2", function (this: unknown, thing: string) { + seen.push(["say2", thing]); + (this as { output: { write(s: string): void }; displayPrompt(): void }).output.write( + `hello ${thing}\n`, + ); + (this as { displayPrompt(): void }).displayPrompt(); + }); + session.input.run([".say1 node developer", ".say2 node developer", ".say1", ".help"]); + return { text: await session.finish(), seen }; + }; + const actual = await run(portable); + const expected = await run(native); + expect(actual.text).toBe(expected.text); + expect(actual.seen).toEqual(expected.seen); + expect(actual.seen).toEqual([ + ["say1", "node developer", true], + ["say2", "node developer"], + ["say1", "", true], + ]); + expect(actual.text).toContain("hello node developer\n> hello node developer\n> hello \n> "); + expect(actual.text).toMatch(/\.say1 {4}help for say1\n\.say2\n/); + }, +); + +differential.concurrent("keyword lines, unknown keywords and decimals match Node", async () => { + const { actual, expected } = await both([".nope", ".5 + 1", "..", ".help"]); + expect(actual).toBe(expected); + expect(actual.startsWith("> Invalid REPL keyword\n> 1.5\n")).toBe(true); +}); + +test.concurrent("a command without an action function is rejected", () => { + const session = open(portable); + expect(errorCode(() => session.repl.defineCommand("bad", { action: 1 }))).toBe( + "ERR_INVALID_ARG_TYPE", + ); + expect(errorCode(() => session.repl.defineCommand("bad", {}))).toBe("ERR_INVALID_ARG_TYPE"); + expect(session.repl.commands.bad).toBeUndefined(); + session.repl.close(); +}); + +test.concurrent("commands live on a null-prototype object", () => { + const session = open(portable); + expect(Object.getPrototypeOf(session.repl.commands)).toBeNull(); + expect(Object.keys(session.repl.commands).sort()).toEqual([ + "break", + "clear", + "exit", + "help", + "load", + "save", + ]); + session.repl.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/deprecated.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/deprecated.ts new file mode 100644 index 000000000..b9fd283b2 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/deprecated.ts @@ -0,0 +1,36 @@ +// Deprecated surface at the pin: the throwing stub and the documentation-only ones kept working. +import { expect, test } from "vitest"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import { DEPRECATED_CODE } from "../../../../../../src/wasi/0.2.x/node/24.x.x/errors/core.js"; +import { ArrayStream, errorCode } from "../helpers/repl.js"; + +test.concurrent("REPLServer() without new throws before touching its arguments (DEP0185)", () => { + const input = new ArrayStream(); + const output = new ArrayStream(); + let touched = 0; + input.on("newListener", () => touched++); + const options = { + get input() { + touched++; + return input; + }, + output, + useGlobal: true, + }; + const call = () => (repl.REPLServer as unknown as (options: unknown) => unknown)(options); + expect(errorCode(call)).toBe(DEPRECATED_CODE); + expect(call).toThrow(/new REPLServer\(\)/); + expect(touched).toBe(0); + expect(output.text).toBe(""); +}); + +test.concurrent("inputStream, outputStream, builtinModules and _builtinLibs stay functional", () => { + const input = new ArrayStream(); + const output = new ArrayStream(); + const instance = new repl.REPLServer({ input, output, useGlobal: true, terminal: false }); + expect(instance.inputStream).toBe(input); + expect(instance.outputStream).toBe(output); + expect(Array.isArray(repl.builtinModules)).toBe(true); + expect(repl._builtinLibs).toBe(repl.builtinModules); + instance.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/eval.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/eval.ts new file mode 100644 index 000000000..d5fc2c742 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/eval.ts @@ -0,0 +1,168 @@ +// The default evaluator: results, errors, `_`/`_error`, ignoreUndefined, and script-scope +// persistence. Differential sessions compare transcripts with Node's REPL over the same streams. +import { expect, test } from "vitest"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { both, open, portable, transcript } from "../helpers/repl.js"; + +const differential = test.skipIf(!hostIsTargetNode); + +differential.concurrent( + "expressions, declarations, objects and function results match Node", + async () => { + const { actual, expected } = await both([ + "1 + 1", + "var evalVarA = 40", + "evalVarA + 2", + "function evalFnA() {", + " return 7", + "}", + "evalFnA()", + "{ a: 1, b: 'two' }", + "[1, 2, 3].map((n) => n * 2)", + "new Map([['k', 1]])", + "null", + "undefined", + "'string'", + "`template ${1 + 1}`", + "typeof evalFnA", + ]); + expect(actual).toBe(expected); + }, +); + +differential.concurrent( + "runtime errors print as Uncaught and are not recorded in lines", + async () => { + const lines = [ + "throw new Error('top')", + "function evalThrower() { throw new TypeError('deep') }", + "evalThrower()", + "1", + ]; + const { actual, expected } = await both(lines); + expect(actual).toBe(expected); + const session = open(portable); + session.input.run(lines); + await session.finish(); + expect([...session.repl.lines]).toEqual([ + "function evalThrower() { throw new TypeError('deep') }", + "1", + ]); + }, +); + +differential.concurrent("syntax errors carry Node's caret decoration", async () => { + const { actual, expected } = await both(["let evalDup = 1; let evalDup = 2", "foo bar baz"]); + expect(actual.split("\n")[0]).toBe(expected.split("\n")[0]); + expect(actual.split("\n")[1]).toBe(expected.split("\n")[1]); + expect(actual).toContain( + "Uncaught SyntaxError: Identifier 'evalDup' has already been declared\n", + ); + expect(actual).toContain( + "foo bar baz\n ^\n\nUncaught SyntaxError: Unexpected identifier 'bar'\n", + ); +}); + +differential.concurrent("_ and _error follow Node's assignment rules", async () => { + const { actual, expected } = await both([ + "40 + 2", + "_", + "_ + 1", + "throw new Error('kept')", + "_error.message", + "_ = 'mine'", + "5", + "_", + "_error = null", + "throw new Error('ignored')", + "_error", + ]); + expect(actual).toBe(expected); + expect(actual).toContain("Expression assignment to _ now disabled.\n"); + expect(actual).toContain("Expression assignment to _error now disabled.\n"); +}); + +differential.concurrent("ignoreUndefined suppresses undefined results", async () => { + const { actual, expected } = await both(["var evalIgnored = 1", "undefined", "evalIgnored"], { + ignoreUndefined: true, + }); + expect(actual).toBe(expected); + expect(actual).toBe("> > > 1\n> "); +}); + +differential.concurrent( + "the result of an unfinished object literal is the object, not a block", + async () => { + const { actual, expected } = await both(["{ a: 1,", "b: 2 }", "{ evalLabel: 1 };", "({})"]); + expect(actual).toBe(expected); + }, +); + +differential.concurrent("top-level let, const and class persist across lines", async () => { + const { actual, expected } = await both([ + "let evalLet = 5", + "evalLet", + "const evalConst = { n: 1 }", + "evalConst.n", + "class EvalKlass { m() { return 3 } }", + "new EvalKlass().m()", + "let { evalDestructured } = { evalDestructured: 'd' }", + "evalDestructured", + "for (let evalLoop = 0; evalLoop < 2; evalLoop++) {}", + "typeof evalLoop", + ]); + expect(actual).toBe(expected); +}); + +test.concurrent("persisted let and const become global properties, and const is not enforced across lines", async () => { + const text = await transcript(portable, [ + "let evalGlobalLet = 1", + "Object.prototype.hasOwnProperty.call(globalThis, 'evalGlobalLet')", + "const evalRelaxed = 1", + "evalRelaxed = 2", + "evalRelaxed", + "let evalRelaxed = 3", + "evalRelaxed", + ]); + expect(text).toBe("> undefined\n> true\n> undefined\n> 2\n> 2\n> undefined\n> 3\n> "); +}); + +test.concurrent("function declarations reach the global object as in Node", async () => { + const text = await transcript(portable, [ + "function evalGlobalFn() { return 1 }", + "typeof globalThis.evalGlobalFn", + ]); + expect(text).toBe("> undefined\n> 'function'\n> "); +}); + +test.concurrent("errors thrown asynchronously after evaluation are not routed to the REPL", async () => { + const session = open(portable); + let unhandled: unknown; + const onUnhandled = (reason: unknown) => { + unhandled = reason; + }; + process.once("unhandledRejection", onUnhandled); + session.input.run(["Promise.reject(new Error('later'))"]); + const text = await session.finish(); + process.removeListener("unhandledRejection", onUnhandled); + expect(text).toBe("> Promise {}\n> "); + expect((unhandled as Error)?.message).toBe("later"); +}); + +test.concurrent("a REPL source name reaches the stack on engines that honour sourceURL", async () => { + const session = open(portable); + session.input.run(["function evalNamed() { throw new Error('named') }", "evalNamed()"]); + await session.finish(); + const error = session.repl.lastError as Error; + expect(error.stack).toMatch(/at evalNamed \(REPL\d+:1:\d+\)/); + expect(error.stack).not.toContain("runInThisContext"); +}); + +test.concurrent("require in the context refuses with a hint at static imports", async () => { + const text = await transcript(portable, ["require('node:fs')", "require.resolve('node:fs')"]); + expect(text).toContain( + 'Uncaught:\nError [ERR_JCO_UNSUPPORTED_NODE_API]: require("node:fs") is not supported', + ); + expect(text).toContain("static `import`"); + expect(text).toContain("'node:fs'\n> "); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/history.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/history.ts new file mode 100644 index 000000000..6ef4cecd8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/history.ts @@ -0,0 +1,81 @@ +// `setupHistory()`, in-memory history navigation, and reverse search over the terminal. +import { expect, test } from "vitest"; +import { open, portable } from "../helpers/repl.js"; + +const strip = (text: string) => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + +test.concurrent("setupHistory keeps in-memory history and calls back with the server", async () => { + const session = open(portable, { terminal: true }); + const ready = new Promise((resolve) => + session.repl.setupHistory("", (_e: unknown, r: unknown) => resolve(r)), + ); + expect(await ready).toBe(session.repl); + session.repl.write("1 + 1"); + session.repl.write(null, { name: "return" }); + session.repl.write("2 + 2"); + session.repl.write(null, { name: "return" }); + expect(session.repl.history).toEqual(["2 + 2", "1 + 1"]); + session.repl.write(null, { name: "up" }); + expect(session.repl.line).toBe("2 + 2"); + session.repl.write(null, { name: "up" }); + expect(session.repl.line).toBe("1 + 1"); + await session.finish(); + expect(strip(session.output.text)).not.toContain("Could not open history file"); +}); + +test.concurrent("a history file path reports Node's could-not-open message and continues", async () => { + const session = open(portable, { terminal: true }); + let loaded: unknown = null; + session.repl.setupHistory( + { filePath: "/nowhere/.history", size: 2 }, + (_e: unknown, r: unknown) => { + loaded = r; + }, + ); + expect(loaded).toBe(session.repl); + expect(session.output.text).toContain( + "\nError: Could not open history file.\nREPL session history will not be persisted.\n", + ); + for (const line of ["1", "2", "3"]) { + session.repl.write(line); + session.repl.write(null, { name: "return" }); + } + expect(session.repl.history).toEqual(["3", "2"]); + await session.finish(); +}); + +test.concurrent("setupHistory accepts the legacy string form and validates options", () => { + const session = open(portable, { terminal: true }); + session.repl.setupHistory(""); + expect(() => session.repl.setupHistory({ size: -1 })).toThrow(/out of range/); + expect(() => session.repl.setupHistory({ size: "x" })).toThrow(/must be of type number/); + session.repl.close(); +}); + +test.concurrent("reverse search finds and accepts a history entry", async () => { + const session = open(portable, { terminal: true }); + for (const line of ["var historyAlpha = 1", "var historyBeta = 2"]) { + session.repl.write(line); + session.repl.write(null, { name: "return" }); + } + session.repl.write(null, { name: "r", ctrl: true }); + expect(strip(session.output.text)).toContain("bck-i-search: _"); + session.repl.write("Alpha", { name: "A" }); + expect(strip(session.output.text)).toContain("bck-i-search: Alpha_"); + session.repl.write(null, { name: "return" }); + expect(session.repl.line).toBe(""); + await session.finish(); + expect(strip(session.output.text)).toContain("var historyAlpha = 1"); +}); + +test.concurrent("reverse search is cancelled by escape, restoring the line", () => { + const session = open(portable, { terminal: true }); + session.repl.write("historyGamma"); + session.repl.write(null, { name: "return" }); + session.repl.write("partial"); + session.repl.write(null, { name: "r", ctrl: true }); + session.repl.write("Gam", { name: "G" }); + session.repl.write(null, { name: "escape" }); + expect(session.repl.line).toBe("partial"); + session.repl.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/is-valid-syntax.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/is-valid-syntax.ts new file mode 100644 index 000000000..f5d48edab --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/is-valid-syntax.ts @@ -0,0 +1,51 @@ +// `repl.isValidSyntax`, differential against Node's, plus the object-literal detection it pairs with. +import native from "node:repl"; +import { expect, test } from "vitest"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import { isObjectLiteral } from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl/utils.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; + +const cases = [ + "1 + 1", + "{ a: 1 }", + "{ a: 1 };", + "{ a: 1, b: 2 }", + "{ 'quoted': 1 }", + "await 1", + "const x = await y", + "function f() {", + "foo bar", + "", + " ", + "() => {}", + "class A { #p = 1 }", + "import('x')", + "import x from 'y'", + "yield 1", + "for (;;) {}", + "label: { break label }", + "1 +", + "/regex/u", + "a?.b ?? c", +]; + +test.skipIf(!hostIsTargetNode).concurrent("isValidSyntax agrees with Node on every case", () => { + for (const code of cases) { + expect(repl.isValidSyntax(code), JSON.stringify(code)).toBe(native.isValidSyntax(code)); + } +}); + +test.concurrent("isValidSyntax accepts expressions that only parse as assignments", () => { + expect(repl.isValidSyntax("{ a: 1, b: 2 }")).toBe(true); + expect(repl.isValidSyntax("await 1")).toBe(true); + expect(repl.isValidSyntax("function f() {")).toBe(false); + expect(repl.isValidSyntax("1 +")).toBe(false); +}); + +test.concurrent("isObjectLiteral wants a leading brace and no trailing semicolon", () => { + expect(isObjectLiteral("{ a: 1 }")).toBe(true); + expect(isObjectLiteral(" { a: 1 }")).toBe(true); + expect(isObjectLiteral("{ a: 1 };")).toBe(false); + expect(isObjectLiteral("{ a: 1 }; ")).toBe(false); + expect(isObjectLiteral("x = { a: 1 }")).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/module.ts new file mode 100644 index 000000000..369cb0ae9 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/module.ts @@ -0,0 +1,102 @@ +// Differential cases require the pinned Node 24 major; portable fixtures run on every major. +import native from "node:repl"; +import nativeReadline from "node:readline"; +import { describe, expect, test } from "vitest"; +import repl, * as namespace from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import readline from "../../../../../../src/wasi/0.2.x/node/24.x.x/readline.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { ArrayStream } from "../helpers/repl.js"; + +describe("node:repl module contract (Node 24)", () => { + test.skipIf(!hostIsTargetNode).concurrent("matches exports, accessors and namespace", () => { + expect(Object.keys(repl).sort()).toEqual(Object.keys(native).sort()); + expect(Reflect.ownKeys(repl).map(String).sort()).toEqual( + Reflect.ownKeys(native).map(String).sort(), + ); + expect(Object.keys(namespace).sort()).toEqual([...Object.keys(native), "default"].sort()); + for (const name of ["builtinModules", "_builtinLibs"] as const) { + const actual = Object.getOwnPropertyDescriptor(repl, name)!; + const expected = Object.getOwnPropertyDescriptor(native, name)!; + expect([ + actual.enumerable, + actual.configurable, + typeof actual.get, + typeof actual.set, + ]).toEqual([ + expected.enumerable, + expected.configurable, + typeof expected.get, + typeof expected.set, + ]); + } + expect(repl.builtinModules).toEqual(native.builtinModules); + expect(repl._builtinLibs).toEqual(native._builtinLibs); + expect(repl.REPL_MODE_SLOPPY.toString()).toBe(native.REPL_MODE_SLOPPY.toString()); + expect(repl.REPL_MODE_STRICT.toString()).toBe(native.REPL_MODE_STRICT.toString()); + expect(repl.REPLServer.length).toBe(native.REPLServer.length); + expect(repl.start.length).toBe(native.start.length); + expect(Object.getOwnPropertyNames(repl.REPLServer.prototype).sort()).toEqual( + Object.getOwnPropertyNames(native.REPLServer.prototype).sort(), + ); + expect(Object.getOwnPropertyNames(repl.Recoverable.prototype)).toEqual( + Object.getOwnPropertyNames(native.Recoverable.prototype), + ); + }); + + test.concurrent("named exports are the default object's members", () => { + expect(namespace.start).toBe(repl.start); + expect(namespace.writer).toBe(repl.writer); + expect(namespace.REPLServer).toBe(repl.REPLServer); + expect(namespace.Recoverable).toBe(repl.Recoverable); + expect(namespace.isValidSyntax).toBe(repl.isValidSyntax); + expect(namespace.REPL_MODE_SLOPPY).toBe(repl.REPL_MODE_SLOPPY); + expect(namespace.REPL_MODE_STRICT).toBe(repl.REPL_MODE_STRICT); + }); + + test.concurrent("REPLServer sits on readline's Interface like Node's", () => { + expect(Object.getPrototypeOf(repl.REPLServer.prototype)).toBe(readline.Interface.prototype); + expect(Object.getPrototypeOf(repl.REPLServer)).toBe(readline.Interface); + expect(Object.getPrototypeOf(native.REPLServer.prototype)).toBe( + nativeReadline.Interface.prototype, + ); + const instance = repl.start({ + input: new ArrayStream(), + output: new ArrayStream(), + useGlobal: true, + terminal: false, + }); + expect(instance).toBeInstanceOf(repl.REPLServer); + expect(instance).toBeInstanceOf(readline.Interface); + expect(instance.constructor).toBe(repl.REPLServer); + instance.close(); + }); + + test.concurrent("Recoverable is grafted onto SyntaxError with only an err field", () => { + const cause = new SyntaxError("incomplete"); + const recoverable = new repl.Recoverable(cause); + expect(recoverable).toBeInstanceOf(SyntaxError); + expect(recoverable).toBeInstanceOf(repl.Recoverable); + expect(Object.getPrototypeOf(repl.Recoverable)).toBe(SyntaxError); + expect(Object.keys(recoverable)).toEqual(["err"]); + expect(recoverable.err).toBe(cause); + expect(Object.prototype.hasOwnProperty.call(recoverable, "message")).toBe(false); + }); + + test.concurrent("builtinModules accessors share one list and accept replacement", () => { + const original = repl.builtinModules; + expect(original).toContain("fs"); + expect(original.some((name) => name.startsWith("_") || name.startsWith("node:"))).toBe(false); + expect(repl._builtinLibs).toBe(original); + try { + repl.builtinModules = ["only"]; + expect(repl._builtinLibs).toEqual(["only"]); + } finally { + repl.builtinModules = original; + } + }); + + test.concurrent("importing the module touches no stream and defines no global", () => { + expect(Object.prototype.hasOwnProperty.call(globalThis, "repl")).toBe(false); + expect(typeof repl.start).toBe("function"); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/recoverable.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/recoverable.ts new file mode 100644 index 000000000..1ee19c9a8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/recoverable.ts @@ -0,0 +1,92 @@ +// Incomplete input: which lines wait for more, which fail, and the `Recoverable` error itself. +// Adapted from Node v24.20.0 test/parallel/test-repl-recoverable.js and test-repl-multiline.js. +import { expect, test } from "vitest"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import { isRecoverableError } from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl/utils.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { both, open, portable, transcript } from "../helpers/repl.js"; + +const differential = test.skipIf(!hostIsTargetNode); + +test.concurrent("isRecoverableError follows Node's acorn rules", () => { + const error = new SyntaxError("engine"); + for (const code of [ + "function f() {", + "{ a: 1,", + "[1, 2,", + "`template", + "/* comment", + "'line \\\n", + "if (true) {", + "(async () => {", + "class C {", + "x = {", + ]) { + expect(isRecoverableError(error, code), code).toBe(true); + } + for (const code of [ + "2e", + "foo bar baz", + "'unterminated", + "let x = ;", + "function (", + "}", + "1 +* 2", + ]) { + expect(isRecoverableError(error, code), code).toBe(false); + } + expect(isRecoverableError(error, "1 + 1")).toBe(false); +}); + +differential.concurrent( + "multi-line input shows the continuation prompt and evaluates once complete", + async () => { + const { actual, expected } = await both([ + "function recoverableFn(a,", + " b) {", + " return a + b", + "}", + "recoverableFn(1, 2)", + "[1,", + "2,", + "3].length", + "`multi", + "line`", + "recoverableFn(", + ")", + ]); + expect(actual).toBe(expected); + expect(actual).toContain("| | 3\n"); + }, +); + +differential.concurrent("a buffered command is discarded by .break", async () => { + const { actual, expected } = await both(["function recoverableStuck() {", ".break", "1"]); + expect(actual).toBe(expected); +}); + +test.concurrent("unterminated strings and bad tokens are errors, not continuations", async () => { + const text = await transcript(portable, ["'abc", "2e", "1"]); + expect(text).toBe( + "> 'abc\n^\n\nUncaught SyntaxError: Unterminated string constant\n> 2e\n^\n\nUncaught SyntaxError: Invalid number\n> 1\n> ", + ); +}); + +test.concurrent("Recoverable wraps the parse error the evaluator saw", async () => { + const seen: unknown[] = []; + const session = open(portable, { + eval(code: string, _c: object, _f: string, cb: (e: Error | null, r?: unknown) => void) { + if (code.includes("}")) { + cb(null, "done"); + } else { + const error = new SyntaxError("Unexpected end of input"); + seen.push(error); + cb(new repl.Recoverable(error)); + } + }, + }); + session.input.run(["function () {", "}"]); + const text = await session.finish(); + expect(text).toBe("> | 'done'\n> "); + expect(seen).toHaveLength(1); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/start.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/start.ts new file mode 100644 index 000000000..df9d92cad --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/start.ts @@ -0,0 +1,147 @@ +// `repl.start()` and `new REPLServer()`: option handling, legacy positional arguments, and the +// construction-time refusals for what the platform cannot provide. +import native from "node:repl"; +import { expect, test } from "vitest"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { ArrayStream, errorCode, open, portable, transcript } from "../helpers/repl.js"; + +const UNSUPPORTED = "ERR_JCO_UNSUPPORTED_NODE_API"; + +function streams() { + return { input: new ArrayStream(), output: new ArrayStream() }; +} + +test.concurrent("useGlobal false or omitted is refused before any stream is touched", () => { + for (const options of [{}, { useGlobal: false }, { useGlobal: undefined }]) { + const { input, output } = streams(); + let touched = 0; + input.on("newListener", () => touched++); + expect(errorCode(() => repl.start({ input, output, ...options }))).toBe(UNSUPPORTED); + expect(() => repl.start({ input, output, ...options })).toThrow(/useGlobal: true/); + expect(touched).toBe(0); + expect(output.text).toBe(""); + } +}); + +test.concurrent("strict mode and breakEvalOnSigint are refused with Node's own conflict first", () => { + const { input, output } = streams(); + expect( + errorCode(() => + repl.start({ input, output, useGlobal: true, replMode: repl.REPL_MODE_STRICT }), + ), + ).toBe(UNSUPPORTED); + expect( + errorCode(() => repl.start({ input, output, useGlobal: true, breakEvalOnSigint: true })), + ).toBe(UNSUPPORTED); + expect( + errorCode(() => + repl.start({ input, output, useGlobal: true, breakEvalOnSigint: true, eval: () => {} }), + ), + ).toBe("ERR_INVALID_REPL_EVAL_CONFIG"); + expect(output.text).toBe(""); +}); + +test.concurrent("requires both streams when only one is given", () => { + const { input, output } = streams(); + expect(errorCode(() => repl.start({ input, useGlobal: true }))).toBe(UNSUPPORTED); + expect(errorCode(() => repl.start({ output, useGlobal: true }))).toBe(UNSUPPORTED); +}); + +test.concurrent("accepts a legacy duplex stream and positional arguments", async () => { + const duplex = new ArrayStream(); + const instance = repl.start("legacy> ", duplex, undefined, true, undefined, undefined); + expect(instance.input).toBe(duplex); + expect(instance.output).toBe(duplex); + expect(instance.getPrompt()).toBe("legacy> "); + duplex.run(["21 * 2"]); + instance.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(duplex.text).toBe("legacy> 42\nlegacy> "); + + const socket = new ArrayStream(); + const viaOption = repl.start({ socket, useGlobal: true, prompt: "" }); + expect(viaOption.input).toBe(socket); + viaOption.close(); +}); + +test.concurrent("terminal, colors and writer options resolve like Node", () => { + const { input, output } = streams(); + output.isTTY = true; + const custom = (value: unknown) => `<${String(value)}>`; + const instance = repl.start({ input, output, useGlobal: true, writer: custom }); + expect(instance.terminal).toBe(true); + // A TTY output without getColorDepth colorizes, as Node's shouldColorize decides. + expect(instance.useColors).toBe(true); + expect(instance.writer).toBe(custom); + expect(instance.useGlobal).toBe(true); + expect(instance.ignoreUndefined).toBe(false); + expect(instance.replMode).toBe(repl.REPL_MODE_SLOPPY); + expect(instance.commands.editor).toBeDefined(); + instance.close(); + + const plain = repl.start({ ...streams(), useGlobal: true, terminal: true, useColors: true }); + expect(plain.useColors).toBe(true); + expect(repl.writer.options.colors).toBe(true); + plain.close(); + const uncolored = repl.start({ ...streams(), useGlobal: true, terminal: false }); + expect(repl.writer.options.colors).toBe(false); + expect(uncolored.commands.editor).toBeUndefined(); + uncolored.close(); +}); + +test.concurrent("inputStream and outputStream alias input and output", () => { + const { input, output } = streams(); + const instance = repl.start({ input, output, useGlobal: true }); + expect(instance.inputStream).toBe(input); + expect(instance.outputStream).toBe(output); + expect(Object.getOwnPropertyDescriptor(instance, "inputStream")?.enumerable).toBe(false); + const other = new ArrayStream(); + instance.outputStream = other; + expect(instance.output).toBe(other); + instance.close(); +}); + +test.concurrent("a custom evaluator receives code, the global context, a REPL name and a callback", async () => { + const calls: unknown[] = []; + const session = open(portable, { + eval(code: string, context: object, file: string, cb: (e: Error | null, r?: unknown) => void) { + calls.push([code, context === globalThis, /^REPL\d+$/.test(file)]); + cb(null, code.trim().toUpperCase()); + }, + }); + session.input.run(["hello", "world"]); + const text = await session.finish(); + expect(calls).toEqual([ + ["hello\n", true, true], + ["world\n", true, true], + ]); + expect(text).toBe("> 'HELLO'\n> 'WORLD'\n> "); +}); + +test.concurrent("a custom evaluator's error is reported as Uncaught", async () => { + const text = await transcript(portable, ["anything"], { + eval(_code: string, _context: object, _file: string, cb: (e: Error | null) => void) { + const error = new RangeError("custom"); + error.stack = "RangeError: custom\n at somewhere (file.js:1:1)"; + cb(error); + }, + }); + expect(text).toBe("> Uncaught RangeError: custom\n at \n> "); +}); + +test + .skipIf(!hostIsTargetNode) + .concurrent("reset and exit events fire in Node's order", async () => { + for (const api of [repl, native]) { + const { input, output } = streams(); + const events: string[] = []; + const instance = api.start({ input, output, useGlobal: true, terminal: false }); + instance.on("exit", () => events.push("exit")); + instance.on("close", () => events.push("close")); + instance.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(events).toEqual(["exit", "close"]); + expect(instance.closed).toBe(true); + } + }); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/terminal.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/terminal.ts new file mode 100644 index 000000000..8f8c6ddeb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/terminal.ts @@ -0,0 +1,82 @@ +// Terminal-mode behaviour: raw mode, prompts, multi-line continuation on a TTY, Ctrl+D, and +// SIGCONT redraw. +import { expect, test } from "vitest"; +import { open, portable } from "../helpers/repl.js"; + +const strip = (text: string) => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + +test.concurrent("a terminal session takes raw mode, echoes, and releases it on close", async () => { + const session = open(portable, { terminal: true }); + expect(session.input.isRaw).toBe(true); + session.repl.write("6 * 7"); + session.repl.write(null, { name: "return" }); + await session.finish(); + expect(session.input.isRaw).toBe(false); + expect(strip(session.output.text)).toContain("> 6 * 7\r\n42\n> "); +}); + +test.concurrent("an incomplete line on a TTY continues in place instead of buffering", async () => { + const session = open(portable, { terminal: true }); + session.repl.write("function terminalFn() {"); + session.repl.write(null, { name: "return" }); + session.repl.write("return 5 }"); + session.repl.write(null, { name: "return" }); + session.repl.write("terminalFn()"); + session.repl.write(null, { name: "return" }); + const text = strip(await session.finish()); + expect(text).toContain("> terminalFn()\r\n5\n> "); + // Node records the first line before learning it is incomplete, then the whole command. + expect([...session.repl.lines]).toEqual([ + "function terminalFn() {", + "function terminalFn() {\rreturn 5 }", + "terminalFn()", + ]); +}); + +test.concurrent("Ctrl+D on an empty line closes; on a non-empty line deletes", async () => { + const session = open(portable, { terminal: true }); + session.repl.write("ab"); + session.repl.write(null, { name: "left" }); + session.repl.write(null, { name: "d", ctrl: true }); + expect(session.repl.line).toBe("a"); + session.repl.write(null, { name: "u", ctrl: true }); + session.repl.write(null, { name: "d", ctrl: true }); + await session.settle(); + expect(session.repl.closed).toBe(true); + expect(session.events).toContain("exit"); +}); + +test.concurrent("SIGCONT redraws the prompt, or the editor banner in editor mode", async () => { + const session = open(portable, { terminal: true }); + session.repl.output.text = ""; + (session.repl as unknown as { emit(event: string): boolean }).emit("SIGCONT"); + expect(strip(session.output.text)).toContain("> "); + session.repl.write(".editor"); + session.repl.write(null, { name: "return" }); + session.repl.write("partial"); + session.repl.write(null, { name: "return" }); + session.output.text = ""; + (session.repl as unknown as { emit(event: string): boolean }).emit("SIGCONT"); + expect(strip(session.output.text)).toContain( + "> .editor\n// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)\npartial\n", + ); + session.repl.close(); +}); + +test.concurrent("displayPrompt shows the continuation prompt while a command is buffered", () => { + const session = open(portable); + session.output.text = ""; + session.repl.displayPrompt(); + expect(session.output.text).toBe("> "); + session.input.run(["function terminalBuffered() {"]); + session.output.text = ""; + session.repl.displayPrompt(); + expect(session.output.text).toBe("| "); + session.repl.setPrompt("$ "); + session.repl.clearBufferedCommand(); + session.output.text = ""; + session.repl.displayPrompt(); + expect(session.output.text).toBe("$ "); + expect(session.repl.getPrompt()).toBe("$ "); + session.repl.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/writer.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/writer.ts new file mode 100644 index 000000000..32ce1b228 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/repl/writer.ts @@ -0,0 +1,107 @@ +// The default `writer`, its options object, and the shared inspector's defaults. +import { inspect as nativeInspect } from "node:util"; +import { expect, test } from "vitest"; +import repl from "../../../../../../src/wasi/0.2.x/node/24.x.x/repl.js"; +import { inspectDefaultOptions } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/inspect.js"; +import { hostIsTargetNode } from "../helpers/assert.js"; +import { open, portable, transcript } from "../helpers/repl.js"; + +test + .skipIf(!hostIsTargetNode) + .concurrent("writer.options carries Node's default inspect keys plus showProxy", () => { + expect(inspectDefaultOptions).toEqual({ ...nativeInspect.defaultOptions }); + expect(Object.keys(repl.writer.options).sort()).toEqual( + Object.keys({ ...nativeInspect.defaultOptions, showProxy: true }).sort(), + ); + expect(repl.writer.options.showProxy).toBe(true); + expect(repl.writer.options.depth).toBe(2); + }); + +test.concurrent("writer formats values the way the REPL prints them", () => { + repl.writer.options.colors = false; + expect(repl.writer(42)).toBe("42"); + expect(repl.writer("s")).toBe("'s'"); + expect(repl.writer(undefined)).toBe("undefined"); + expect(repl.writer(null)).toBe("null"); + expect(repl.writer([1, "a"])).toBe("[ 1, 'a' ]"); + expect(repl.writer({ a: { b: { c: { d: 1 } } } })).toBe("{ a: { b: { c: [Object] } } }"); + expect(repl.writer(new Map([["k", 1]]))).toBe("Map(1) { 'k' => 1 }"); + expect(repl.writer(Symbol("sym"))).toBe("Symbol(sym)"); + expect(repl.writer(() => {})).toMatch(/^\[Function/); + expect(repl.writer(10n)).toBe("10n"); +}); + +test.concurrent("writer honours edits to writer.options", () => { + const depth = repl.writer.options.depth; + try { + repl.writer.options.depth = 0; + expect(repl.writer({ a: { b: 1 } })).toBe("{ a: [Object] }"); + repl.writer.options.colors = true; + expect(repl.writer(1)).toBe("1"); + } finally { + repl.writer.options.depth = depth; + repl.writer.options.colors = false; + } +}); + +test.concurrent("errors print through the writer with Uncaught and a trimmed stack", async () => { + const text = await transcript(portable, [ + "new RangeError('range')", + "throw new RangeError('range')", + ]); + // A value prints with its whole stack, as util.inspect does; an uncaught one is trimmed. + expect(text.startsWith("> RangeError: range\n at \n")).toBe(true); + expect(text.endsWith("\n> Uncaught RangeError: range\n> ")).toBe(true); +}); + +test.concurrent("an error whose stack lacks the header line, as on QuickJS, still prints it", () => { + const framesOnly = new TypeError("no header"); + framesOnly.stack = " at run (file.js:1:1)"; + expect(repl.writer(framesOnly)).toBe("TypeError: no header\n at run (file.js:1:1)"); + const empty = new RangeError("bare"); + empty.stack = ""; + expect(repl.writer(empty)).toBe("RangeError: bare"); + const headed = new Error("headed"); + headed.stack = "Error: headed\n at run (file.js:1:1)"; + expect(repl.writer(headed)).toBe("Error: headed\n at run (file.js:1:1)"); +}); + +test.concurrent("a custom writer replaces the default for results and errors", async () => { + const session = open(portable, { writer: (value: unknown) => `[${typeof value}]` }); + session.input.run(["1", "'a'", "throw new Error('e')"]); + const text = await session.finish(); + // Node strips a writer result's outer brackets on the Uncaught line. + expect(text).toBe("> [number]\n> [string]\n> Uncaught object\n> "); +}); + +test.concurrent("useColors switches the shared writer's colors", () => { + const colored = repl.start({ + input: new (class extends EventTarget { + on() { + return this; + } + removeListener() { + return this; + } + emit() { + return false; + } + listenerCount() { + return 0; + } + resume() { + return this; + } + pause() { + return this; + } + })() as never, + output: { write: () => true } as never, + useGlobal: true, + terminal: false, + useColors: true, + }); + expect(repl.writer.options.colors).toBe(true); + colored.close(); + repl.writer.options.colors = false; +}); diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 37bb3452a..693442abc 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -14,6 +14,7 @@ import { createProcessBuiltin } from "./process.js"; import { createOsBuiltin } from "./os.js"; import { createSqliteBuiltin } from "./sqlite.js"; import { createReadlineBuiltin } from "./readline.js"; +import { createReplBuiltin } from "./repl.js"; import { createStringDecoderBuiltin } from "./string-decoder.js"; import { createStreamBuiltin } from "./stream.js"; import { createClusterBuiltin } from "./cluster.js"; @@ -65,6 +66,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createOsBuiltin, createSqliteBuiltin, createReadlineBuiltin, + createReplBuiltin, createStringDecoderBuiltin, createStreamBuiltin, createClusterBuiltin, diff --git a/packages/jco/src/node-builtins/repl.ts b/packages/jco/src/node-builtins/repl.ts new file mode 100644 index 000000000..053063a4f --- /dev/null +++ b/packages/jco/src/node-builtins/repl.ts @@ -0,0 +1,13 @@ +import { starReexportAdapter, type BuiltinContext, type BuiltinAdapter, builtin, stdModule } from "./shared.js"; + +const REPL_SPECIFIER = "node:repl"; + +/** + * `node:repl` is entirely guest-side -- evaluation, line editing and completion over the streams + * the application supplies -- so it reports no WIT requirement. The jco-std module is the only one + * that bundles acorn; resolving it here, and nowhere else, keeps the parser out of every other + * component. + */ +export function createReplBuiltin({ options }: BuiltinContext): BuiltinAdapter { + return builtin(REPL_SPECIFIER, () => starReexportAdapter(stdModule(options.replModule, "repl"), "repl")); +} diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index 9df7f061e..87283e7c9 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -62,6 +62,8 @@ export interface NodeBuiltinOptions { /** Paths to jco-std's capability-free readline modules (overridable for tests). */ readlineModule?: string; readlinePromisesModule?: string; + /** Path to jco-std's capability-free `node:repl` module (overridable for tests). */ + replModule?: string; /** Paths to jco-std's versioned stream modules (overridable for tests) */ streamModule?: string; streamPromisesModule?: string; diff --git a/packages/jco/test/fixtures/componentize/node-repl/quickjs.wit b/packages/jco/test/fixtures/componentize/node-repl/quickjs.wit new file mode 100644 index 000000000..f85cf7a10 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-repl/quickjs.wit @@ -0,0 +1,4 @@ +package local:node-repl; +world test { + export run: async func() -> string; +} diff --git a/packages/jco/test/fixtures/componentize/node-repl/source.js b/packages/jco/test/fixtures/componentize/node-repl/source.js new file mode 100644 index 000000000..44a8b38b9 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-repl/source.js @@ -0,0 +1,95 @@ +import repl, { REPLServer, Recoverable, start, isValidSyntax } from "node:repl"; +import { Interface } from "node:readline"; +import { EventEmitter } from "node:events"; + +// The application supplies streams; no host or node:process capability is needed. +class ArrayStream extends EventEmitter { + text = ""; + resume() { + return this; + } + pause() { + return this; + } + write(chunk) { + this.text += String(chunk); + return true; + } + run(lines) { + for (const line of lines) { + this.emit("data", `${line}\n`); + } + } +} + +function settle() { + return Promise.resolve() + .then(() => Promise.resolve()) + .then(() => Promise.resolve()); +} + +export async function run() { + const report = {}; + report.moduleIdentity = repl.start === start && repl.REPLServer === REPLServer && repl.Recoverable === Recoverable; + report.prototype = Object.getPrototypeOf(REPLServer.prototype) === Interface.prototype; + report.builtinModules = repl.builtinModules.includes("fs") && !repl.builtinModules.some((m) => m.startsWith("_")); + report.validSyntax = [isValidSyntax("{ a: 1 }"), isValidSyntax("function (")]; + + const input = new ArrayStream(); + const output = new ArrayStream(); + const server = start({ prompt: "> ", input, output, useGlobal: true, terminal: false, useColors: false }); + const events = []; + server.on("exit", () => events.push("exit")); + server.context.injected = "from the host program"; + server.defineCommand("shout", { + help: "Shout the rest", + action(rest) { + this.output.write(`${rest.toUpperCase()}\n`); + this.displayPrompt(); + }, + }); + input.run([ + "1 + 1", + "let replLet = 40", + "replLet + 2", + "function replFn(a,", + " b) {", + " return a * b", + "}", + "replFn(6, 7)", + "{ a: 1, b: 'two' }", + "injected", + "_", + "throw new Error('boom')", + "_error.message", + "foo bar", + ".shout hello there", + "const replAwaited = await Promise.resolve('awaited')", + ]); + await settle(); + await settle(); + input.run(["replAwaited", ".help", ".exit"]); + await settle(); + report.closed = server.closed === true; + report.events = events; + report.lines = server.lines.length; + report.output = output.text.replace(/^\s+at .*\n?/gm, "").replace(/^\S*@\S+:\d+:\d+\n?/gm, ""); + + // The refusal for Node's default context mode fails fast, before any stream is touched. + const untouched = new ArrayStream(); + let listeners = 0; + untouched.on("newListener", () => listeners++); + try { + start({ input: untouched, output: untouched }); + report.refusal = "no error"; + } catch (error) { + report.refusal = [error.code, listeners, untouched.text]; + } + try { + REPLServer({ input: untouched, output: untouched, useGlobal: true }); + report.deprecated = "no error"; + } catch (error) { + report.deprecated = error.code; + } + return JSON.stringify(report); +} diff --git a/packages/jco/test/fixtures/componentize/node-repl/source.wit b/packages/jco/test/fixtures/componentize/node-repl/source.wit new file mode 100644 index 000000000..ac45fc513 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-repl/source.wit @@ -0,0 +1,4 @@ +package local:node-repl; +world test { + export run: func() -> string; +} diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 747575421..5eab2838a 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -238,6 +238,22 @@ describe("Node builtin adapters", () => { expect(plugin.resolveId("readline/promises")).toBeNull(); }); + test.concurrent("generates a capability-free adapter for node:repl", () => { + const requirements = []; + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { replModule: "test:repl", onWitRequirement: (requirement) => requirements.push(requirement) }, + ); + const id = plugin.resolveId("node:repl"); + expect(id).toBe("\0jco-node-builtin:node:repl"); + const source = plugin.load(id); + expect(source).toContain('from "test:repl"'); + expect(source).toContain("export default repl"); + expect(source).toContain('export * from "test:repl"'); + expect(requirements).toEqual([]); + expect(plugin.resolveId("repl")).toBeNull(); + }); + test.concurrent("does not intercept the legacy bare string_decoder specifier", () => { const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }); expect(plugin.resolveId("string_decoder")).toBeNull(); diff --git a/packages/jco/test/node/readline.js b/packages/jco/test/node/readline.js index c1ae0f46f..9132676da 100644 --- a/packages/jco/test/node/readline.js +++ b/packages/jco/test/node/readline.js @@ -26,8 +26,8 @@ function runSimple(path) { } suite("node:readline", () => { - // TODO(unskip): publish and depend on a jco-std release with the readline, events, errors and - // abort-globals exports; the builtin plugin resolves them from the installed package. + // TODO(unskip): publish and depend on a jco-std release exporting readline, readline/promises, + // events, errors and abort-globals. The builtin plugin resolves the installed package in CI. test.concurrent.skip("the documentation simple example reads real stdin and writes stdout", async () => { const dir = await getTmpDir(); const bundle = await rolldown({ @@ -48,7 +48,8 @@ suite("node:readline", () => { }); for (const backend of ["quickjs", "starlingmonkey"]) { - // TODO(unskip): same blocker as above. + // TODO(unskip): publish and depend on a jco-std release exporting readline, readline/promises, + // events, errors and abort-globals before bundling this fixture with either component engine. test.concurrent.skip( `questions, line parsing and terminal APIs execute in ${backend}`, async () => { diff --git a/packages/jco/test/node/repl.js b/packages/jco/test/node/repl.js new file mode 100644 index 000000000..95c38eda2 --- /dev/null +++ b/packages/jco/test/node/repl.js @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { rolldown } from "rolldown"; +import { suite, test } from "vitest"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { componentizeFixture, transpileComponent } from "../helpers.js"; + +const fixtures = fileURLToPath(new URL("../fixtures/componentize/", import.meta.url)); + +async function bundle(entry) { + const build = await rolldown({ input: entry, plugins: [nodeBuiltinPlugin({ imports: [], exports: [] })] }); + try { + const { output } = await build.generate({ format: "esm" }); + return output[0].code; + } finally { + await build.close(); + } +} + +// Strings that only acorn's tokenizer contains, so their absence proves the parser was not bundled. +const acornMarkers = ["Unterminated template", "Unterminated string constant", "acorn"]; + +suite("node:repl", () => { + // TODO(unskip): use the published jco-std node:repl export once a release containing it ships and + // jco's dependency range is bumped; the workspace copy has it, the published 0.3.x does not. + test.skip("acorn is bundled only when node:repl is imported", async () => { + const withoutRepl = await bundle(join(fixtures, "node-string-decoder/source.js")); + for (const marker of acornMarkers) { + assert.equal(withoutRepl.includes(marker), false, `unexpected ${marker} in a bundle without node:repl`); + } + const withRepl = await bundle(join(fixtures, "node-repl/source.js")); + for (const marker of acornMarkers) { + assert.equal(withRepl.includes(marker), true, `expected ${marker} in a bundle with node:repl`); + } + }); + + for (const backend of ["quickjs", "starlingmonkey"]) { + // TODO(unskip): publish the jco-std node:repl export and update jco's dependency range; + // componentize resolves the installed package, which does not yet export this module. + test.skip(`a scripted session evaluates, recovers, errors and exits in ${backend}`, async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-repl", + entry: "source.js", + wit: backend === "quickjs" ? "quickjs.wit" : "source.wit", + world: "test", + bundle: true, + extraArgs: ["--backend", backend], + }); + assert.equal(stderr, ""); + const { modulePath } = await transpileComponent({ componentPath, name: `node-repl-${backend}` }); + const component = await import(modulePath); + const report = JSON.parse(await component.run()); + assert.deepEqual( + { ...report, output: undefined }, + { + moduleIdentity: true, + prototype: true, + builtinModules: true, + validSyntax: [true, false], + closed: true, + events: ["exit"], + lines: 15, + refusal: ["ERR_JCO_UNSUPPORTED_NODE_API", 0, ""], + deprecated: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + output: undefined, + }, + ); + assert.equal( + report.output, + [ + "> 2", + "> undefined", + "> 42", + "> | | | undefined", + "> 42", + "> { a: 1, b: 'two' }", + "> 'from the host program'", + "> 'from the host program'", + "> Uncaught Error: boom", + "> 'boom'", + "> foo bar", + " ^", + "", + "Uncaught SyntaxError: Unexpected identifier 'bar'", + "> HELLO THERE", + "> undefined", + "> 'awaited'", + "> .break Sometimes you get stuck, this gets you out", + ".clear Alias for .break", + ".exit Exit the REPL", + ".help Print this help message", + ".load Load JS from a file into the REPL session", + ".save Save all evaluated commands in this REPL session to a file", + ".shout Shout the rest", + "", + "Press Ctrl+C to abort current expression, Ctrl+D to exit the REPL", + "> ", + ].join("\n"), + ); + }, 180_000); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa8cb978..b3202fe21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,12 @@ importers: packages/jco-std: dependencies: + acorn: + specifier: 8.17.0 + version: 8.17.0 + acorn-walk: + specifier: 8.3.5 + version: 8.3.5 minimatch: specifier: 10.2.6 version: 10.2.6 @@ -463,7 +469,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)) + version: 4.1.11(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1)) which: specifier: ^5.0.0 version: 5.0.0 @@ -543,7 +549,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)) + version: 4.1.11(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1)) packages/preview3-shim: dependencies: @@ -596,7 +602,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)) + version: 4.1.11(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1)) packages: @@ -3064,6 +3070,15 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -6620,14 +6635,6 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.33.0) - '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1))': - dependencies: - '@vitest/spy': 4.1.11 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.6(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.33.0) - '@vitest/mocker@4.1.11(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1))': dependencies: '@vitest/spy': 4.1.11 @@ -6671,6 +6678,12 @@ snapshots: dependencies: event-target-shim: 5.0.1 + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + agent-base@7.1.4: {} ajv@8.20.0: @@ -8313,33 +8326,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.33.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.3 - transitivePeerDependencies: - - msw - vitest@4.1.11(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1)): dependencies: '@vitest/expect': 4.1.11