diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 0bbf31700..0701281a5 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -79,11 +79,13 @@ Some builtins can be stored and passed as functions: - Lowered `node:path` functions across the bare, POSIX, and win32 modules, including optional `basename` and variadic `join`/`resolve`. - Supported zero-argument `node:os` functions and `querystring.escape`/`unescape`. -- `util.isDeepStrictEqual`, `stripVTControlCharacters`, and `toUSVString`. +- `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, and `toUSVString`. - `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`. Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere. `util.promisify` supports compile-time conversions of `child_process.execFile` and UTF-8 `fs.readFile`. +`util.styleText` supports standard formats and aliases, arrays of formats, nested ANSI resets, and explicit color enabling with `{ validateStream: false }`. Native calls accept standard process streams; custom native streams and mutations to `util.inspect.colors` remain unsupported. Embedded calls use the stream and terminal metadata supplied by the embedded runtime. + `util.isDeepStrictEqual` supports primitives, ordinary records and arrays, cycles, enumerable string and symbol properties, and byte views in static code. Its optional third argument skips prototype and constructor comparisons. Structural comparisons of native Maps, Sets, class instances, Errors, proxies, accessor properties, and function-valued own `constructor` properties remain refused. Comparisons running inside the embedded JavaScript engine support a wider range of object and collection types. Synchronous and promise filesystem APIs with trailing arguments that affect behavior remain call-only. This includes writers, open, directory creation/removal, metadata readers, and directory readers. Passing them as values could discard modes, flags, encodings, or options during function-signature adaptation. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index e20506046..d821c0f0c 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "bb6a6ee53c88515cf85b", + "artifactVersion": "43c1ed516695c0676e51", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index fe3be66e1..d77e1bc38 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -1202,9 +1202,9 @@ "apiEntries": 105, "static": { "supported": 0, - "partial": 21, + "partial": 22, "refused": 0, - "not-implemented": 84, + "not-implemented": 83, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -85598,13 +85598,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Applies standard styles and aliases, nested ANSI resets, explicit color enabling, and standard process streams; custom native streams and inspect color-table mutation remain unsupported.", + "verification": "test-backed" }, "dynamic": { "status": "partial", - "detail": "Available through the embedded Node compatibility shim; exact member and overload coverage may be narrower.", + "detail": "Applies standard styles and aliases with nested ANSI resets, stream validation, color environment settings, and tested option and conversion hooks; terminal metadata and warnings follow the embedded runtime.", "verification": "test-backed" } }, diff --git a/internal/compatibility/dynamic-support.json b/internal/compatibility/dynamic-support.json index 19c2b0648..4fd9edc11 100644 --- a/internal/compatibility/dynamic-support.json +++ b/internal/compatibility/dynamic-support.json @@ -19,6 +19,7 @@ "wasi": "by-design" }, "features": [ + { "chapter": "util", "symbols": ["util.styleText"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-style-text/main.ts"] }, { "chapter": "util", "symbols": ["util.isDeepStrictEqual"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-deep-equality/main.ts"] }, { "chapter": "assert", "symbols": ["assert.doesNotThrow", "assert.doesNotReject"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-no-error-assertions/main.ts"] }, { "chapter": "util", "symbols": ["util.stripVTControlCharacters", "util.toUSVString"], "status": "supported", "evidence": ["tests/fixtures/npm/cases/node24-text-utilities/main.ts"] }, diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 45ef1a2dd..fe6772bb9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 42, - "verify-gap": 2509, - "audit-partial": 661, + "verify-gap": 2508, + "audit-partial": 662, "classify": 2, "implement": 81 }, @@ -95750,21 +95750,27 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.styleText", + "tests": [ + "tests/corpus/node24-style-text.ts", + "tests/corpus/node24-style-text-validation.js", + "tests/corpus/node24-style-text-environment.js", + "tests/corpus/node24-style-text.cjs", + "tests/fixtures/node-types/style-text.ts" + ] }, "dynamic": { "status": "partial", "action": "audit-partial", "verification": "test-backed", "confidence": "high", - "source": "island-export:node:util.styleText", + "source": "island-feature:util.util.styleText", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/fixtures/npm/cases/node24-style-text/main.ts" ] } } diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 1de5e6783..2be00b065 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -1323,9 +1323,9 @@ "apiEntries": 105, "static": { "supported": 0, - "partial": 21, + "partial": 22, "refused": 0, - "not-implemented": 84, + "not-implemented": 83, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -111285,6 +111285,10 @@ "tests/corpus/node24-env-parse-errors.ts", "tests/corpus/node24-env-parse.ts", "tests/corpus/node24-parseargs.js", + "tests/corpus/node24-style-text-environment.js", + "tests/corpus/node24-style-text-validation.js", + "tests/corpus/node24-style-text.cjs", + "tests/corpus/node24-style-text.ts", "tests/corpus/node24-system-error-validation.js", "tests/corpus/node24-system-errors.cjs", "tests/corpus/node24-system-errors.ts", @@ -111297,6 +111301,7 @@ "tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/deep-equality.ts", "tests/fixtures/node-types/env-files.ts", + "tests/fixtures/node-types/style-text.ts", "tests/fixtures/node-types/system-errors.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/fixtures/node-types/text-encoding.ts", @@ -111313,6 +111318,7 @@ "tests/fixtures/npm/cases/node24-mime-errors/main.ts", "tests/fixtures/npm/cases/node24-mime/main.ts", "tests/fixtures/npm/cases/node24-parseargs/main.ts", + "tests/fixtures/npm/cases/node24-style-text/main.ts", "tests/fixtures/npm/cases/node24-system-errors/main.ts", "tests/fixtures/npm/cases/node24-text-encoding/main.ts", "tests/fixtures/npm/cases/node24-text-utilities/main.ts", @@ -113321,14 +113327,21 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.styleText", + "tests": [ + "tests/corpus/node24-style-text.ts", + "tests/corpus/node24-style-text-validation.js", + "tests/corpus/node24-style-text-environment.js", + "tests/corpus/node24-style-text.cjs", + "tests/fixtures/node-types/style-text.ts" + ] }, "dynamic": { "status": "partial", - "evidence": "island-export:node:util.styleText", + "evidence": "island-feature:util.util.styleText", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/fixtures/npm/cases/node24-style-text/main.ts" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index eb7ec4464..e7ce7ce02 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -637,6 +637,9 @@ function publicDetail(tier) { if (source === "compiler-feature:util.util.toUSVString") { return "Converts native strings, primitives, and tested object conversion hooks to well-formed Unicode strings; native storage replaces unpaired surrogates at creation."; } + if (source === "compiler-feature:util.util.styleText") { + return "Applies standard styles and aliases, nested ANSI resets, explicit color enabling, and standard process streams; custom native streams and inspect color-table mutation remain unsupported."; + } if (source === "compiler-feature:util.util.isDeepStrictEqual") { return "Compares primitives, ordinary records and arrays, cycles, enumerable properties, and byte views with optional prototype skipping; opaque native objects and accessor properties remain refused."; } @@ -705,6 +708,7 @@ function publicDetail(tier) { if (/^island-feature:assert\.assert\.doesNot(?:Throw|Reject)$/.test(source)) { return "Supports no-error assertions over callbacks, promises, and promise-like objects, with Error classes, regular expressions, validation functions, and Node-compatible failure metadata for the tested forms."; } + if (source === "island-feature:util.util.styleText") return "Applies standard styles and aliases with nested ANSI resets, stream validation, color environment settings, and tested option and conversion hooks; terminal metadata and warnings follow the embedded runtime."; if (source === "island-feature:util.util.stripVTControlCharacters") return "Removes ANSI control sequences with the Node matcher and validates non-string arguments."; if (source === "island-feature:util.util.toUSVString") return "Replaces unpaired surrogate code units and preserves Node string coercion, custom conversion hooks, and conversion errors."; if (source.startsWith("island-feature:diagnostics_channel.")) return "Publishes synchronous, promise, and callback tracing lifecycle events with result/error identity, subscriber management, and store bindings. Bound stores follow the embedded engine's synchronous AsyncLocalStorage model and do not propagate across awaits."; diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index e664c2206..2c1281849 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -89,6 +89,7 @@ "node-builtin.url.fileURLToPath": ["tests/corpus/file-url-percent-decoding.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPathBuffer": ["tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js", "tests/fixtures/node-types/file-url-conversion.ts"], "node-builtin.util.parseArgs": ["tests/corpus/2678-util-parseargs.ts", "tests/corpus/node24-parseargs.js"], + "node-builtin.util.styleText": ["tests/corpus/node24-style-text.ts", "tests/corpus/node24-style-text-validation.js", "tests/corpus/node24-style-text-environment.js", "tests/corpus/node24-style-text.cjs", "tests/fixtures/node-types/style-text.ts"], "node-builtin.util.isDeepStrictEqual": ["tests/corpus/node24-deep-equality.ts", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality-properties.js", "tests/fixtures/node-types/deep-equality.ts", "packages/compiler/test/deep-equality.test.ts"], "node-builtin.util": ["tests/corpus/2440-console-inspect-args.ts", "tests/corpus/2912-promisify-fs-readfile.ts"], "node-builtin.worker_threads": ["tests/corpus/957-builtins-namespace.ts"], @@ -103,6 +104,7 @@ "stdlib.abort": ["tests/harness/fetch-conformance.test.ts"] }, "features": [ + { "chapter": "util", "symbols": ["util.styleText"], "status": "partial", "evidence": ["tests/corpus/node24-style-text.ts", "tests/corpus/node24-style-text-validation.js", "tests/corpus/node24-style-text-environment.js", "tests/corpus/node24-style-text.cjs", "tests/fixtures/node-types/style-text.ts"] }, { "chapter": "util", "symbols": ["util.isDeepStrictEqual"], "status": "partial", "evidence": ["tests/corpus/node24-deep-equality.ts", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality-properties.js", "tests/fixtures/node-types/deep-equality.ts", "packages/compiler/test/deep-equality.test.ts"] }, { "chapter": "deprecations", "symbols": ["util.toUSVString"], "status": "not-applicable" }, { "chapter": "util", "symbols": ["util.stripVTControlCharacters", "util.toUSVString"], "status": "partial", "evidence": ["tests/corpus/node24-text-utilities.ts", "tests/corpus/node24-text-utilities-validation.js", "tests/corpus/node24-text-utilities-coercion.js", "tests/corpus/node24-text-utilities.cjs", "tests/fixtures/node-types/text-utilities.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b1a5f4035..418d93891 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1890,6 +1890,7 @@ declare module "util" { export function stripVTControlCharacters(str: string): string; export function toUSVString(string: string): string; export function isDeepStrictEqual(val1: unknown, val2: unknown, skipPrototype?: boolean): boolean; + export function styleText(format: string | readonly string[], text: string, options?: { validateStream?: boolean; stream?: unknown }): string; export function parseEnv(content: string): { [key: string]: string | undefined }; export type TextEncoder = globalThis.TextEncoder; export type TextDecoder = globalThis.TextDecoder; diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 70fe63ffb..cfa61b60d 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -132,7 +132,7 @@ export class LlDyn { /** The value LLVM type of a dynCheck result / toDyn operand for `t`. */ private valTy(t: IrType): string { - return t.kind === "f64" || t.kind === "date" ? "double" : t.kind === "bool" ? "i1" : "ptr"; + return t.kind === "f64" || t.kind === "date" || t.kind === "procStream" ? "double" : t.kind === "bool" ? "i1" : "ptr"; } private collectionDynOps(t: IrType & { kind: "map" | "set" }): string { @@ -484,6 +484,13 @@ export class LlDyn { case "promise": kindIs(DYN_KIND.PROMISE); break; + case "procStream": { + this.host.declare(`declare zeroext i1 @scr_dyn_process_stdio_is(ptr)`); + const matched = B.tmp(); + B.line(`${matched} = call zeroext i1 @scr_dyn_process_stdio_is(ptr %d)`); + B.terminate(`ret i1 ${matched}`); + break; + } case "symbol": kindIs(DYN_KIND.SYMBOL); break; @@ -991,6 +998,13 @@ export class LlDyn { B.terminate(`ret ptr ${retained}`); break; } + case "procStream": { + host.declare(`declare double @scr_dyn_process_stdio_fd(ptr, ptr)`); + const r = B.tmp(); + B.line(`${r} = call double @scr_dyn_process_stdio_fd(ptr %d, ptr %path)`); + B.terminate(`ret double ${r}`); + break; + } case "bytes": { host.declare(`declare zeroext i1 @scr_dyn_bytes_is(ptr, i32)`); const matched = B.tmp(); @@ -1315,7 +1329,7 @@ export class LlDyn { // A matched unit arm builds nothing: THE interned immortal // instance (rc == SIZE_MAX — no retain owed). B.terminate(`ret ptr ${host.unitInstanceRef(t.unionId, i)}`); - } else if (arm.kind === "f64") { + } else if (arm.kind === "f64" || arm.kind === "procStream") { host.declare(`declare ptr @scr_union_new_f64(i32, double)`); const x = B.tmp(); const u = B.tmp(); @@ -1581,6 +1595,13 @@ export class LlDyn { B.terminate(`ret ptr ${r}`); break; } + case "procStream": { + host.declare(`declare ptr @scr_process_stdio(double)`); + const r = B.tmp(); + B.line(`${r} = call ptr @scr_process_stdio(double %v)`); + B.terminate(`ret ptr ${r}`); + break; + } case "string": { host.declare(`declare ptr @scr_dyn_new_str(ptr)`); const r = B.tmp(); @@ -1898,13 +1919,12 @@ export class LlDyn { const r = B.tmp(); B.line(`${r} = call ptr @scr_dyn_new_null()`); B.terminate(`ret ptr ${r}`); - } else if (arm.kind === "f64") { + } else if (arm.kind === "f64" || arm.kind === "procStream") { host.declare(`declare double @scr_union_get_f64(ptr)`); - host.declare(`declare ptr @scr_dyn_new_num(double)`); const x = B.tmp(); const r = B.tmp(); B.line(`${x} = call double @scr_union_get_f64(ptr %v)`); - B.line(`${r} = call ptr @scr_dyn_new_num(double ${x})`); + B.line(`${r} = call ptr @${this.toDynHelper(arm)}(double ${x})`); B.terminate(`ret ptr ${r}`); } else if (arm.kind === "bool") { host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`); @@ -2037,13 +2057,13 @@ export class LlDyn { fulfill(dv); break; } + case "procStream": case "f64": { host.declare(`declare double @scr_promise_payload_f64(ptr)`); - host.declare(`declare ptr @scr_dyn_new_num(double)`); const x = B.tmp(); const dv = B.tmp(); B.line(`${x} = call double @scr_promise_payload_f64(ptr %src)`); - B.line(`${dv} = call ptr @scr_dyn_new_num(double ${x})`); + B.line(`${dv} = call ptr @${this.toDynHelper(inner)}(double ${x})`); fulfill(dv); break; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index dad7ee58e..c85229829 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2802,7 +2802,7 @@ export class LlEmitter { * a retained peek. */ unionExtract(uName: string, arm: IrType): string { const B = this.B; - if (arm.kind === "f64") { + if (arm.kind === "f64" || arm.kind === "procStream") { const t = B.tmp(); this.declare(`declare double @scr_union_get_f64(ptr)`); B.line(`${t} = call double @scr_union_get_f64(ptr ${uName})`); @@ -2823,7 +2823,7 @@ export class LlEmitter { unionNewOwned(tag: number, v: LlValue): string { const B = this.B; const t = B.tmp(); - if (v.type.kind === "f64") { + if (v.type.kind === "f64" || v.type.kind === "procStream") { this.declare(`declare ptr @scr_union_new_f64(i32, double)`); B.line(`${t} = call ptr @scr_union_new_f64(i32 ${tag}, double ${v.name})`); return t; diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 1c5ed7557..b328744e7 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -72,7 +72,7 @@ export function emitDynamicExpr(host: LlvmEmitterContext, e: ExprOf<"dynFrom" | return host.own({ name: t, type: e.type }); } const conv = host.dyn.toDynHelper(v.type); - const valTy = v.type.kind === "f64" ? "double" : v.type.kind === "bool" ? "i1" : "ptr"; + const valTy = host.llType(v.type); const t = B.tmp(); B.line(`${t} = call ptr @${conv}(${valTy} ${v.name})`); return host.own({ name: t, type: e.type }); diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 639f82893..bb7d07d8c 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -192,7 +192,7 @@ export function keyedRecordReadInto(host: LlvmEmitterContext, B.line(`store ptr ${r}, ptr ${slot}`); } else { const conv = host.dyn.toDynHelper(type); - const vTy = type.kind === "f64" ? "double" : type.kind === "bool" ? "i1" : "ptr"; + const vTy = host.llType(type); const r = B.tmp(); B.line(`${r} = call ptr @${conv}(${vTy} ${v})`); B.line(`store ptr ${r}, ptr ${slot}`); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 29ac5addb..8e690b7e8 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -45,7 +45,7 @@ export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): stri if (inner.kind === "void") { host.declare(`declare void @scr_promise_fulfill_void(ptr)`); B.line(`call void @scr_promise_fulfill_void(ptr %dst)`); - } else if (inner.kind === "f64" || inner.kind === "date" || inner.kind === "bool") { + } else if (inner.kind === "f64" || inner.kind === "date" || inner.kind === "procStream" || inner.kind === "bool") { const fn = inner.kind !== "bool" ? "scr_promise_fulfill_f64" : "scr_promise_fulfill_bool"; host.declare(`declare void @${fn}(ptr, ${host.llType(inner)}${inner.kind === "bool" ? " zeroext" : ""})`); B.line(`call void @${fn}(ptr %dst, ${host.llType(inner)}${inner.kind === "bool" ? " zeroext" : ""} ${value})`); @@ -189,7 +189,7 @@ export function streamTypedRefCommitAdapter(host: LlvmEmitterContext, const after = `f${index}_after`; const { index: fieldIndex } = classFieldIndex(meta, field.name); const fieldTy = llFieldType(field.type); - const checkTy = field.type.kind === "f64" ? "double" : field.type.kind === "bool" ? "i1" : "ptr"; + const checkTy = host.llType(field.type); const symbolGlobal = symbols.get(field.name); if (symbolGlobal) { host.declare(`declare ptr @scr_dyn_symbol_key_get(ptr, ptr, i1 zeroext)`); @@ -458,13 +458,12 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext, const boxed = B.tmp(); B.line(`${boxed} = call ptr @scr_dyn_new_null()`); B.terminate(`ret ptr ${boxed}`); - } else if (arm.kind === "f64") { + } else if (arm.kind === "f64" || arm.kind === "procStream") { host.declare(`declare double @scr_union_get_f64(ptr)`); - host.declare(`declare ptr @scr_dyn_new_num(double)`); const value = B.tmp(); const boxed = B.tmp(); B.line(`${value} = call double @scr_union_get_f64(ptr %u)`); - B.line(`${boxed} = call ptr @scr_dyn_new_num(double ${value})`); + B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(arm)}(double ${value})`); B.terminate(`ret ptr ${boxed}`); } else if (arm.kind === "bool") { host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`); @@ -513,11 +512,7 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext, return boxed; } if (t.kind === "bytes" || !streamTypedRefEligible(t) && !isDynTypedRefType(t)) { - const valueTy = t.kind === "f64" - ? "double" - : t.kind === "bool" - ? "i1" - : "ptr"; + const valueTy = host.llType(t); B.line( `${boxed} = call ptr @${host.dyn.toDynHelper(t)}(${valueTy} ${value})`, ); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index b822d7ee7..82c0f0e10 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -63,6 +63,7 @@ export const LIB_FN_SYMS: Record = { "util.stripVTControlCharacters": "scr_util_strip_vt", "util.toUSVString": "scr_util_to_usv_string", "util.isDeepStrictEqual": "scr_util_is_deep_strict_equal", + "util.styleText": "scr_util_style_text", "math.maxArr": "scr_math_max_arr", "math.minArr": "scr_math_min_arr", "math.hypotArr": "scr_math_hypot_arr", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 5496425ab..d6d1fbcfc 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -271,6 +271,7 @@ export function llFieldType(t: IrType): "double" | "i8" | "ptr" { switch (t.kind) { case "f64": case "date": + case "procStream": return "double"; case "bool": return "i8"; diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index f5244eba8..54e2064d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -30,7 +30,7 @@ export function isNativeBuiltinValueInitializer(lowerer: Lowerer, expr: ts.Expre while (ts.isParenthesizedExpression(expr)) expr = expr.expression; const builtin = ts.isIdentifier(expr) ? lowerer.builtinImportOf(expr) : ts.isPropertyAccessExpression(expr) ? lowerer.builtinMemberOf(expr) : null; - if (builtin?.module === "util" && ["stripVTControlCharacters", "toUSVString", "isDeepStrictEqual"].includes(builtin.member)) return true; + if (builtin?.module === "util" && ["stripVTControlCharacters", "toUSVString", "isDeepStrictEqual", "styleText"].includes(builtin.member)) return true; // JS global snapshots may use an opaque builtin identity rather than // the ambient constructor's callable ABI. Store that actual value in // the checked representation, including optional capability probes. diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 3581049bd..9b1247be8 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -3955,7 +3955,7 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr // These checked native functions retain a runtime slot in JavaScript so // aliases can escape without narrowing their arguments to string. if (isJsSourceFile(nameNode.getSourceFile()) && target.module === "util" && - ["stripVTControlCharacters", "toUSVString", "isDeepStrictEqual"].includes(target.member)) return false; + ["stripVTControlCharacters", "toUSVString", "isDeepStrictEqual", "styleText"].includes(target.member)) return false; if (lowerer.checker.getCallSignatures(lowerer.typeOf(nameNode)).length === 0) return false; const symbol = lowerer.checker.getSymbolAtLocation(nameNode); if (!symbol) return false; diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index a16530448..058a767e9 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1383,8 +1383,9 @@ export function lowerUtilModuleCall( ): IrExpr | null { if (bi.module !== "util") return null; switch (bi.member) { + case "styleText": case "isDeepStrictEqual": { - if (expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering("util.isDeepStrictEqual with spread arguments", expr); + if (expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering(`util.${bi.member} with spread arguments`, expr); const stmts: IrStmt[] = []; const args: IrExpr[] = []; // Save all inputs before evaluating surplus arguments: comparison may @@ -1392,12 +1393,13 @@ export function lowerUtilModuleCall( for (let i = 0; i < 3; i++) { const input: IrExpr = expr.arguments[i] ? lowerer.lowerExprExpecting(expr.arguments[i]!, DYN) : { kind: "dynFrom", value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: DYN, loc }; - const saved = lowerer.declareHiddenLocal("%deepEqualInput", DYN); + const saved = lowerer.declareHiddenLocal("%utilInput", DYN); stmts.push({ kind: "varDecl", localId: saved.id, init: input, loc }); args.push(varRef(saved.id, DYN, loc)); } for (const arg of expr.arguments.slice(3)) stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc }); - return { kind: "seqExpr", stmts, result: { kind: "libCall", fn: "util.isDeepStrictEqual", args, type: BOOL, loc }, type: BOOL, loc }; + const type = bi.member === "styleText" ? STRING : BOOL; + return { kind: "seqExpr", stmts, result: { kind: "libCall", fn: `util.${bi.member}`, args, type, loc }, type, loc }; } case "getSystemErrorMap": { if (expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering("util.getSystemErrorMap with spread arguments", expr); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4567ec058..cd42d34a6 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -883,6 +883,7 @@ export const BUILTIN_MODULE_FNS: Record { }); }); +describe("native process stream checked storage", () => { + test("preserves stream fields and union arms without claiming scalar array storage", () => { + const stream: IrType = { kind: "procStream" }; + const optional: IrType = { kind: "union", unionId: "optionalStream" }; + const record = (id: string): IrRecordShape | undefined => id === "options" + ? { id, fields: [{ name: "stream", type: optional }] } : undefined; + const union = (id: string): IrUnionDef | undefined => id === "optionalStream" + ? { id, arms: [stream, { kind: "undefinedT" }] } : undefined; + for (const type of [stream, optional, { kind: "record", shapeId: "options" } as IrType, arrayOf(optional)]) { + expect(canConvertToDyn(type, record, union)).toBe(true); + expect(canDynCheckTo(type, record, union)).toBe(true); + expect(isJsonSafeType(type, record, union)).toBe(false); + expect(isIslandCallbackParamType(type, record, union)).toBe(false); + } + expect(canConvertToDyn(arrayOf(stream), record, union)).toBe(false); + expect(canDynCheckTo(arrayOf(stream), record, union)).toBe(false); + }); +}); + describe("checked conversion graph traversal", () => { test.each([ { label: "boxing", convert: canConvertToDyn }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index f476fcfe6..f3309f332 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2580,6 +2580,7 @@ export type IrLibFn = | "util.stripVTControlCharacters" | "util.toUSVString" | "util.isDeepStrictEqual" + | "util.styleText" /** ES Symbol values (scr_symbol.c — link-gated by moduleUsesSymbol). * sym.new: `Symbol(desc)` — a fresh runtime-unique identity (+1) whose * one arg is the description string (borrowed); sym.newAnon is the @@ -6492,6 +6493,7 @@ function canBoxDynCompositeAt( visiting: Set, ): boolean { switch (t.kind) { + case "procStream": case "f64": case "bigint": case "symbol": @@ -6517,7 +6519,9 @@ function canBoxDynCompositeAt( case "object": return RUNTIME_ERROR_CLASSES.has(t.className) || isDynTypedRefType(t); case "array": - return canBoxDynComposite(t.elem, getRecord, getUnion, visiting); + // Process streams have scalar storage only in fields, closures and + // unions. A direct procStream array has no native ScrArr layout. + return t.elem.kind !== "procStream" && canBoxDynComposite(t.elem, getRecord, getUnion, visiting); case "record": { const shape = getRecord(t.shapeId); if (!shape) return false; @@ -6587,6 +6591,7 @@ function canDynCheckToAt( getUnion: (unionId: string) => IrUnionDef | undefined, visiting: Set, ): boolean { + if (t.kind === "procStream") return true; // Unknown fields keep an owned dyn subtree; checking the surrounding // record/array still validates its layout. This is broader than the // stringify/island JSON domain, which cannot assume opaque slots are @@ -6604,6 +6609,7 @@ function canDynCheckToAt( // boundaries. Callable adapters use the same identity/brand check. if (isDynTypedRefType(t)) return true; if (t.kind === "array") { + if (t.elem.kind === "procStream") return false; const key = typeKey(t); if (visiting.has(key)) return false; visiting.add(key); @@ -6786,7 +6792,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.onUncaughtException", "process.offUncaughtException", "process.onUnhandledRejection", "process.offUnhandledRejection", "process.onRejectionHandled", "process.offRejectionHandled", - "process.onWarning", "process.offWarning", "process.emitWarning", + "process.onWarning", "process.offWarning", "process.emitWarning", "util.styleText", "als.new", "als.get", "als.run", "als.exitRun", "als.enterWith", "als.disable", "dc.chanBindStore", "dc.chanUnbindStore", "dc.chanRunStores", ]); @@ -6829,7 +6835,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run if (fn.startsWith("assert.")) features.assert = true; if (fn === "dyn.defineProps" || fn === "dyn.definePrototypeProps" || fn === "dyn.defineProperty" || fn === "dyn.objCreateWithProperties" || fn === "dyn.arrayProtoCall" || fn === "dyn.arrayPrototype" || fn === "dyn.functionApply" || fn === "dyn.builtinMethod") features.dynInvoke = true; if (DYN_ASYNC_LIB_FNS.has(fn)) features.dynAsync = true; - if (fn.startsWith("insp.") || fn === "console.native" || fn === "global.native") features.inspect = true; + if (fn.startsWith("insp.") || fn === "console.native" || fn === "global.native" || fn === "util.styleText") features.inspect = true; if (fn.startsWith("cp.") || fn.startsWith("child.") || fn.startsWith("writer.") || fn.startsWith("spawnRes.") || fn === "process.forkTarget" || fn === "process.connected" || fn === "process.send" || fn === "process.sendCb" || fn === "process.disconnect" || fn === "process.onMessage" || fn === "process.onDisconnect") features.childProcess = true; @@ -6844,7 +6850,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run if (fn.startsWith("bigint.")) features.bigint = true; if (fn.startsWith("sp.") || fn === "url.searchParams") features.searchParams = true; if (fn === "qs.parse" || fn === "qs.stringify" || fn === "qs.unescape") features.qs = true; - if (fn === "util.parseArgs" || fn === "util.isDeepStrictEqual") features.parseArgs = true; + if (fn === "util.parseArgs" || fn === "util.isDeepStrictEqual" || fn === "util.styleText") features.parseArgs = true; + if (fn === "util.styleText") features.processEvents = true; if (fn.startsWith("fs.watch") || fn.startsWith("watcher.")) features.fsWatch = true; if (fn.startsWith("test.")) features.nodeTest = true; if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; @@ -6881,6 +6888,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run switch (node.kind) { case "regex": features.regex = true; break; case "fileHandle": features.fileHandle = true; break; + case "procStream": features.processEvents = true; break; case "child": case "childStream": case "childWriter": case "spawnRes": features.childProcess = true; break; case "netServer": case "netSocket": features.net = true; break; case "http2Session": case "http2Stream": features.net = true; features.http2 = true; break; @@ -7790,6 +7798,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "util.getSystemErrorMessage", "util.stripVTControlCharacters", "util.isDeepStrictEqual", + "util.styleText", "util.toUSVString", // decodeURIComponent throws the spec's URIError on bad hex/invalid // UTF-8 octets (encodeURIComponent never throws — see the IrLibFn doc). diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index ddca13899..341eb833d 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -438,6 +438,7 @@ export const LIB_FN_SIGS: Record { + const dir = mkdtempSync(join(tmpdir(), "scriptc-style-text-")); + try { + const entry = join(dir, "main.cjs"); + writeFileSync(entry, ` +const { styleText } = require("node:util"); +const custom = { write() {}, on() {}, isTTY: true }; +try { styleText("red", "x", {stream: custom}); } +catch (error) { console.log(error.name, error.code, error.message); } +console.log(JSON.stringify(styleText("red", "x", {validateStream: false, stream: custom}))); +`); + const result = await compile(entry, { dynamic: false, sanitize: process.env["SCRIPTC_SAN"] === "1", outDir: dir, outPath: join(dir, "program") }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect(child.status, child.stdout + child.stderr).toBe(0); + expect(child.stderr).toBe(""); + expect(child.stdout).toBe("TypeError SC2020 util.styleText over native custom streams is not supported yet\n\"\\u001b[31mx\\u001b[39m\"\n"); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 12bbb36a0..1ac20dbdc 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -13648,6 +13648,42 @@ "/tests/diagnostics/no-error-assertion-fences.ts" ], "diags": [] + }, + "/tests/corpus/node24-style-text-validation.js": { + "order": [ + "/tests/corpus/node24-style-text-validation.js" + ], + "diags": [] + }, + "/tests/corpus/node24-style-text.cjs": { + "order": [ + "/tests/corpus/node24-style-text.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-style-text.ts": { + "order": [ + "/tests/corpus/node24-style-text.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/style-text.ts": { + "order": [ + "/tests/fixtures/node-types/style-text.ts" + ], + "diags": [] + }, + "/tests/fixtures/npm/cases/node24-style-text/main.ts": { + "order": [ + "/tests/fixtures/npm/cases/node24-style-text/main.ts" + ], + "diags": [] + }, + "/tests/corpus/node24-style-text-environment.js": { + "order": [ + "/tests/corpus/node24-style-text-environment.js" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 891cf494d..f4e976913 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1062,6 +1062,15 @@ ScrDyn *scr_process_stdio(double fd) { return scr_dyn_new_handle(&scr_stdio_streams[fd == 0 ? 0 : fd == 2 ? 2 : 1], SCR_DYNH_STDIO); } +bool scr_dyn_process_stdio_is(const ScrDyn *value) { + return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_STDIO; +} + +double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { + ScrStdio *stream = scr_dyn_handle_unbox(value, SCR_DYNH_STDIO, path, "ProcessStream"); + return stream ? stream->fd : 0; +} + /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a * normal run (the unit's atexit, registered by scr_events_install after diff --git a/packages/runtime/src/scr_island.c b/packages/runtime/src/scr_island.c index 4a3ec4383..132141618 100644 --- a/packages/runtime/src/scr_island.c +++ b/packages/runtime/src/scr_island.c @@ -8755,31 +8755,93 @@ static const char isl_modules_bootstrap[] = " if (typeof str !== \"string\") envArgumentError(\"str\", \"of type string\", str);\n" " return str.replace(ansiRe, \"\");\n" " };\n" - " const styleText = (fmt, text, options) => {\n" - " if (typeof text !== \"string\") {\n" - " const e = new TypeError('The \"text\" argument must be of type string. Received ' + (text === null ? \"null\" : typeof text === \"object\" ? \"an instance of Object\" : typeof text === \"undefined\" ? \"undefined\" : \"type \" + typeof text));\n" - " e.code = \"ERR_INVALID_ARG_TYPE\";\n" - " throw e;\n" - " }\n" - " const stream = options !== undefined && options !== null && options.stream !== undefined\n" - " ? options.stream : env.stdout;\n" - " let colorize = !!(stream && stream.isTTY);\n" - " if (env.env.NO_COLOR !== undefined || env.env.NODE_DISABLE_COLORS !== undefined) colorize = false;\n" - " if (env.env.FORCE_COLOR !== undefined && env.env.FORCE_COLOR !== \"0\") colorize = true;\n" - " const formats = Array.isArray(fmt) ? fmt : [fmt];\n" - " let left = \"\";\n" - " let right = \"\";\n" - " for (const f of formats) {\n" - " const pair = inspect.colors[f];\n" - " if (pair === undefined) {\n" - " const e = new TypeError(\"The value \\\"\" + String(f) + \"\\\" is invalid for argument 'format'. Reason: must be one of: \" + Object.keys(inspect.colors).join(\", \"));\n" - " e.code = \"ERR_INVALID_ARG_VALUE\";\n" - " throw e;\n" + " const styleAliases = { grey: 'gray', blackBright: 'gray', bgGrey: 'bgGray', bgBlackBright: 'bgGray', faint: 'dim', crossedout: 'strikethrough', strikeThrough: 'strikethrough', crossedOut: 'strikethrough', conceal: 'hidden', swapColors: 'inverse', swapcolors: 'inverse', doubleUnderline: 'doubleunderline' };\n" + " for (const [name, target] of Object.entries(styleAliases)) Object.defineProperty(inspect.colors, name, {\n" + " configurable: true, get: () => inspect.colors[target], set: (value) => { inspect.colors[target] = value; },\n" + " });\n" + " let styleCache;\n" + " const getStyleCache = () => {\n" + " if (styleCache === undefined) {\n" + " styleCache = Object.create(null);\n" + " for (const key of Object.getOwnPropertyNames(inspect.colors)) {\n" + " const pair = inspect.colors[key];\n" + " if (!pair) continue;\n" + " styleCache[key] = { open: '\\u001b[' + pair[0] + 'm', close: '\\u001b[' + pair[1] + 'm', keepClose: pair[0] === 1 || pair[0] === 2 };\n" " }\n" - " left += \"\\u001b[\" + pair[0] + \"m\";\n" - " right = \"\\u001b[\" + pair[1] + \"m\" + right;\n" " }\n" - " return colorize ? left + text + right : text;\n" + " return styleCache;\n" + " };\n" + " const replaceCloseCode = (text, style) => {\n" + " let at = 0, index = text.indexOf(style.close), result = '';\n" + " while (index !== -1 && index + style.close.length < text.length) {\n" + " result += text.slice(at, index) + (style.keepClose ? style.close : '') + style.open;\n" + " at = index + style.close.length;\n" + " index = text.indexOf(style.close, at);\n" + " }\n" + " return result + text.slice(at);\n" + " };\n" + " let warnedColors = false;\n" + " const terminalColors = () => {\n" + " const e = env.env, force = e.FORCE_COLOR;\n" + " if (force !== undefined) {\n" + " const enabled = ['', '1', '2', '3', 'true'].includes(force);\n" + " const disabled = e.NO_COLOR ? 'NO_COLOR' : e.NODE_DISABLE_COLORS ? 'NODE_DISABLE_COLORS' : undefined;\n" + " if (enabled && disabled && !warnedColors) {\n" + " env.emitWarning(\"The '\" + disabled + \"' env is ignored due to the 'FORCE_COLOR' env being set.\", 'Warning');\n" + " warnedColors = true;\n" + " }\n" + " return enabled;\n" + " }\n" + " if (e.NO_COLOR || e.NODE_DISABLE_COLORS || e.TERM === 'dumb') return false;\n" + " if (env.platform === 'win32' || e.TMUX || e.TF_BUILD !== undefined && e.AGENT_NAME !== undefined) return true;\n" + " if (e.CI !== undefined) return ['APPVEYOR', 'BUILDKITE', 'CIRCLECI', 'DRONE', 'GITEA_ACTIONS', 'GITHUB_ACTIONS', 'GITLAB_CI', 'TRAVIS'].some((name) => e[name] !== undefined) || e.CI_NAME === 'codeship';\n" + " if (e.TEAMCITY_VERSION !== undefined) return /^(9\\.(0*[1-9]\\d*)\\.|\\d{2,}\\.)/.test(e.TEAMCITY_VERSION);\n" + " if (['iTerm.app', 'HyperTerm', 'MacTerm', 'Apple_Terminal'].includes(e.TERM_PROGRAM)) return true;\n" + " if (e.COLORTERM === 'truecolor' || e.COLORTERM === '24bit') return true;\n" + " if (e.TERM) {\n" + " if (e.TERM.includes('truecolor') || e.TERM.startsWith('xterm-256')) return true;\n" + " const term = e.TERM.toLowerCase();\n" + " if (['eterm', 'cons25', 'console', 'cygwin', 'dtterm', 'gnome', 'hurd', 'jfbterm', 'konsole', 'kterm', 'mlterm', 'mosh', 'putty', 'st', 'rxvt-unicode-24bit', 'terminator', 'xterm-kitty'].includes(term) || /ansi|color|linux|direct|^con[0-9]*x[0-9]|^rxvt|^screen|^xterm|^vt100|^vt220/.test(term)) return true;\n" + " }\n" + " return !!e.COLORTERM;\n" + " };\n" + " const styleText = (format, text, options) => {\n" + " const validateStream = options?.validateStream ?? true;\n" + " const cache = getStyleCache();\n" + " if (!validateStream && typeof format === 'string' && typeof text === 'string') {\n" + " if (format === 'none') return text;\n" + " const style = cache[format];\n" + " if (style !== undefined) return style.open + replaceCloseCode(text, style) + style.close;\n" + " }\n" + " if (typeof text !== 'string') envArgumentError('text', 'of type string', text);\n" + " if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) envArgumentError('options', 'of type object', options);\n" + " if (typeof validateStream !== 'boolean') envArgumentError('options.validateStream', 'of type boolean', validateStream);\n" + " let skipColorize = false;\n" + " if (validateStream) {\n" + " const stream = options?.stream ?? env.stdout;\n" + " const node = stream && (stream._readableState || stream._writableState || typeof stream.write === 'function' && typeof stream.on === 'function' || typeof stream.pipe === 'function' && typeof stream.on === 'function');\n" + " const readable = stream && !node && typeof stream.pipeThrough === 'function' && typeof stream.getReader === 'function' && typeof stream.cancel === 'function';\n" + " const writable = stream && !node && typeof stream.getWriter === 'function' && typeof stream.abort === 'function';\n" + " if (!node && !readable && !writable) envArgumentError('stream', 'an instance of ReadableStream, WritableStream, or Stream', stream);\n" + " let colorize;\n" + " if (env.env.FORCE_COLOR !== undefined) colorize = terminalColors();\n" + " else colorize = stream?.isTTY && (typeof stream.getColorDepth === 'function' ? stream.getColorDepth() > 2 : stream === env.stdout || stream === env.stderr || stream === env.stdin ? terminalColors() : true);\n" + " skipColorize = !colorize;\n" + " }\n" + " const formats = Array.isArray(format) ? format : [format];\n" + " let open = '', close = '', processed = text;\n" + " for (const key of formats) {\n" + " if (key === 'none') continue;\n" + " const style = cache[key];\n" + " if (style === undefined && !Object.getOwnPropertyNames(inspect.colors).includes(key)) {\n" + " const error = new TypeError(\"The argument 'format' must be one of: \" + Object.getOwnPropertyNames(inspect.colors).map((name) => \"'\" + name + \"'\").join(', ') + '. Received ' + inspect(key));\n" + " error.code = 'ERR_INVALID_ARG_VALUE';\n" + " throw error;\n" + " }\n" + " open += style.open; close = style.close + close;\n" + " processed = replaceCloseCode(processed, style);\n" + " }\n" + " return skipColorize ? text : open + processed + close;\n" " };\n" " const parseArgs = (config = {}) => {\n" " if (config === null) throw new TypeError(\"Cannot convert undefined or null to object\");\n" diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 17cfef7b9..08b2ec28c 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -916,6 +916,7 @@ void scr_qs_parse_into(ScrMap *out, const ScrStr *qs, const ScrStr *sep, * a fresh ParsedResults object whose `values` child has null prototype. * Borrows config; returns +1, or NULL with a Node-coded TypeError pending. */ struct ScrDyn *scr_util_parse_args(const struct ScrDyn *config); +ScrStr *scr_util_style_text(const struct ScrDyn *format, const struct ScrDyn *text, const struct ScrDyn *options); /* Node/libuv system error utilities. Arguments are checked without coercion; * entries is a fresh array of [number, [name, message]] pairs (+1). */ @@ -4212,6 +4213,8 @@ ScrDyn *scr_global_native(ScrArr *known); ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)); ScrDyn *scr_console_native(void); ScrDyn *scr_process_stdio(double fd); +bool scr_dyn_process_stdio_is(const ScrDyn *value); +double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path); void scr_dyn_handle_install(ScrDynHandleTag tag, const ScrDynHandleOps *ops); void scr_file_handle_dyn_install(void); diff --git a/packages/runtime/src/scr_util.c b/packages/runtime/src/scr_util.c index d1c7a4663..c59fb7de7 100644 --- a/packages/runtime/src/scr_util.c +++ b/packages/runtime/src/scr_util.c @@ -1201,3 +1201,335 @@ fail: scr_dyn_release(owned_config); return NULL; } + +/* Standard Node 24 inspect colors, including its non-enumerable aliases. + * Native inspect.colors mutation has no static surface. */ +typedef struct ScrTextStyle { const char *name; unsigned open, close; } ScrTextStyle; +static const ScrTextStyle util_text_styles[] = { + { "reset", 0, 0 }, + { "bold", 1, 22 }, + { "dim", 2, 22 }, + { "italic", 3, 23 }, + { "underline", 4, 24 }, + { "blink", 5, 25 }, + { "inverse", 7, 27 }, + { "hidden", 8, 28 }, + { "strikethrough", 9, 29 }, + { "doubleunderline", 21, 24 }, + { "black", 30, 39 }, + { "red", 31, 39 }, + { "green", 32, 39 }, + { "yellow", 33, 39 }, + { "blue", 34, 39 }, + { "magenta", 35, 39 }, + { "cyan", 36, 39 }, + { "white", 37, 39 }, + { "bgBlack", 40, 49 }, + { "bgRed", 41, 49 }, + { "bgGreen", 42, 49 }, + { "bgYellow", 43, 49 }, + { "bgBlue", 44, 49 }, + { "bgMagenta", 45, 49 }, + { "bgCyan", 46, 49 }, + { "bgWhite", 47, 49 }, + { "framed", 51, 54 }, + { "overlined", 53, 55 }, + { "gray", 90, 39 }, + { "redBright", 91, 39 }, + { "greenBright", 92, 39 }, + { "yellowBright", 93, 39 }, + { "blueBright", 94, 39 }, + { "magentaBright", 95, 39 }, + { "cyanBright", 96, 39 }, + { "whiteBright", 97, 39 }, + { "bgGray", 100, 49 }, + { "bgRedBright", 101, 49 }, + { "bgGreenBright", 102, 49 }, + { "bgYellowBright", 103, 49 }, + { "bgBlueBright", 104, 49 }, + { "bgMagentaBright", 105, 49 }, + { "bgCyanBright", 106, 49 }, + { "bgWhiteBright", 107, 49 }, + { "grey", 90, 39 }, + { "blackBright", 90, 39 }, + { "bgGrey", 100, 49 }, + { "bgBlackBright", 100, 49 }, + { "faint", 2, 22 }, + { "crossedout", 9, 29 }, + { "strikeThrough", 9, 29 }, + { "crossedOut", 9, 29 }, + { "conceal", 8, 28 }, + { "swapColors", 7, 27 }, + { "swapcolors", 7, 27 }, + { "doubleUnderline", 21, 24 }, +}; + +static ScrDyn *util_style_view(const ScrDyn *value) { + return value->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(value) + : scr_dyn_retain((ScrDyn *)value); +} + +static ScrDyn *util_style_member(const ScrDyn *value, const char *key) { + if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL) + return scr_dyn_retain(scr_dyn_undefined()); + ScrDyn *view = util_style_view(value); + if (!view) return NULL; + ScrDyn *result = scr_dyn_obj_read(view, key, strlen(key)); + scr_dyn_release(view); + return result; +} + +static const ScrTextStyle *util_style_lookup(const ScrDyn *key) { + if (key->kind == SCR_DYN_SYMBOL) return NULL; + ScrStr *name = scr_dyn_string_constructor(key); + if (!name) return NULL; + const ScrTextStyle *result = NULL; + for (size_t i = 0; i < sizeof util_text_styles / sizeof util_text_styles[0]; i++) { + if (name->len == strlen(util_text_styles[i].name) && + !memcmp(name->data, util_text_styles[i].name, name->len)) { + result = &util_text_styles[i]; break; + } + } + scr_str_release(name); + return result; +} + +static bool util_style_none(const ScrDyn *key) { + return key->kind == SCR_DYN_STR && key->v.str->len == 4 && + !memcmp(key->v.str->data, "none", 4); +} + +static void util_style_invalid(const ScrDyn *key) { + ScrDyn *view = util_style_view(key); + if (!view) return; + ScrStr *shown = scr_insp_dyn(view, 0, 2); + scr_dyn_release(view); + if (!shown) return; + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, "The argument 'format' must be one of: "); + for (size_t i = 0; i < sizeof util_text_styles / sizeof util_text_styles[0]; i++) { + if (i) scr_jb_puts(&message, ", "); + scr_jb_putc(&message, '\''); + scr_jb_puts(&message, util_text_styles[i].name); + scr_jb_putc(&message, '\''); + } + scr_jb_puts(&message, ". Received "); + scr_jb_put_str(&message, shown); + ScrStr *text = scr_jb_finish(&message); + scr_throw_error_msg_code(SCR_ERR_TYPE, text->data, text->len, "ERR_INVALID_ARG_VALUE"); + scr_str_release(text); scr_str_release(shown); +} + +/* Reopen an outer style when a nested fragment closes it. Bold and dim + * share 22, so preserve that reset before restoring either attribute. + * A close at the very end is kept intact, matching Node's replacement. */ +static void util_style_putn(ScrJsonBuf *buffer, const char *data, size_t length) { + for (size_t i = 0; i < length; i++) scr_jb_putc(buffer, data[i]); +} + +static ScrStr *util_style_replace(const ScrStr *text, const char *close, + const char *open, bool keep_close) { + size_t close_len = strlen(close), at = 0; + ScrJsonBuf result; + scr_jb_init(&result); + for (size_t i = 0; i + close_len < text->len;) { + if (!memcmp(text->data + i, close, close_len)) { + util_style_putn(&result, text->data + at, i - at); + if (keep_close) scr_jb_puts(&result, close); + scr_jb_puts(&result, open); + i += close_len; at = i; + } else i++; + } + util_style_putn(&result, text->data + at, text->len - at); + return scr_jb_finish(&result); +} + +static bool util_style_env_nonempty(const char *name) { + const char *value = getenv(name); + return value && *value; +} + +/* Node recognizes structural stream implementations as well as branded + * instances. Keep that valid shape outside the native stdio boundary. */ +static bool util_style_custom_stream(const ScrDyn *stream) { + static const char *const keys[] = { "_readableState", "_writableState", "write", "on", "pipe", "pipeThrough", "getReader", "cancel", "getWriter", "abort" }; + ScrDyn *members[sizeof keys / sizeof keys[0]] = {0}; + bool result = false; + for (size_t i = 0; i < sizeof keys / sizeof keys[0]; i++) { + members[i] = util_style_member(stream, keys[i]); + if (!members[i] || scr_exc_pending()) goto done; + } + result = scr_dyn_truthy(members[0]) || scr_dyn_truthy(members[1]) || + (members[3]->kind == SCR_DYN_FUNC && (members[2]->kind == SCR_DYN_FUNC || members[4]->kind == SCR_DYN_FUNC)) || + (members[5]->kind == SCR_DYN_FUNC && members[6]->kind == SCR_DYN_FUNC && members[7]->kind == SCR_DYN_FUNC) || + (members[8]->kind == SCR_DYN_FUNC && members[9]->kind == SCR_DYN_FUNC); +done: + for (size_t i = 0; i < sizeof keys / sizeof keys[0]; i++) scr_dyn_release(members[i]); + return result; +} + +/* styleText only needs the color/no-color distinction, rather than the + * full 4/8/24-bit depth. Follow the ordering of Node 24 internal/tty. */ +static bool util_style_terminal_colors(void) { + const char *force = getenv("FORCE_COLOR"); + if (force) { + bool enabled = !strcmp(force, "") || !strcmp(force, "true") || + !strcmp(force, "1") || !strcmp(force, "2") || !strcmp(force, "3"); + static SCR_TL bool warned = false; + const char *disabled = util_style_env_nonempty("NO_COLOR") ? "NO_COLOR" : + util_style_env_nonempty("NODE_DISABLE_COLORS") ? "NODE_DISABLE_COLORS" : NULL; + if (enabled && disabled && !warned) { + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, "The '"); scr_jb_puts(&message, disabled); + scr_jb_puts(&message, "' env is ignored due to the 'FORCE_COLOR' env being set."); + ScrStr *text = scr_jb_finish(&message); + scr_emit_warning("Warning", NULL, text); + scr_str_release(text); warned = true; + } + return enabled; + } + const char *term = getenv("TERM"); + if (util_style_env_nonempty("NO_COLOR") || util_style_env_nonempty("NODE_DISABLE_COLORS") || + (term && !strcmp(term, "dumb"))) return false; +#ifdef _WIN32 + return true; +#endif + if (util_style_env_nonempty("TMUX")) return true; + if (getenv("TF_BUILD") && getenv("AGENT_NAME")) return true; + if (getenv("CI")) { + static const char *const ci[] = { "APPVEYOR", "BUILDKITE", "CIRCLECI", "DRONE", "GITEA_ACTIONS", "GITHUB_ACTIONS", "GITLAB_CI", "TRAVIS" }; + for (size_t i = 0; i < sizeof ci / sizeof ci[0]; i++) if (getenv(ci[i])) return true; + const char *name = getenv("CI_NAME"); + return name && !strcmp(name, "codeship"); + } + const char *teamcity = getenv("TEAMCITY_VERSION"); + if (teamcity) { + const char *p = teamcity; + if (!strncmp(p, "9.", 2)) { + p += 2; while (*p == '0') p++; + if (*p < '1' || *p > '9') return false; + while (*p >= '0' && *p <= '9') p++; + return *p == '.'; + } + while (*p >= '0' && *p <= '9') p++; + return p - teamcity >= 2 && *p == '.'; + } + const char *program = getenv("TERM_PROGRAM"); + if (program && (!strcmp(program, "iTerm.app") || !strcmp(program, "HyperTerm") || + !strcmp(program, "MacTerm") || !strcmp(program, "Apple_Terminal"))) return true; + const char *colorterm = getenv("COLORTERM"); + if (colorterm && (!strcmp(colorterm, "truecolor") || !strcmp(colorterm, "24bit"))) return true; + if (term) { + if (strstr(term, "truecolor") || !strncmp(term, "xterm-256", 9)) return true; + char *lower = malloc(strlen(term) + 1); + if (!lower) abort(); + for (size_t i = 0; i <= strlen(term); i++) lower[i] = term[i] >= 'A' && term[i] <= 'Z' ? term[i] + ('a' - 'A') : term[i]; + static const char *const terminals[] = { "eterm", "cons25", "console", "cygwin", "dtterm", "gnome", "hurd", "jfbterm", "konsole", "kterm", "mlterm", "mosh", "putty", "st", "rxvt-unicode-24bit", "terminator", "xterm-kitty" }; + bool found = false; + for (size_t i = 0; i < sizeof terminals / sizeof terminals[0]; i++) found |= !strcmp(lower, terminals[i]); + found |= strstr(lower, "ansi") || strstr(lower, "color") || strstr(lower, "linux") || strstr(lower, "direct") || + !strncmp(lower, "rxvt", 4) || !strncmp(lower, "screen", 6) || !strncmp(lower, "xterm", 5) || + !strncmp(lower, "vt100", 5) || !strncmp(lower, "vt220", 5); + if (!strncmp(lower, "con", 3)) { + const char *p = lower + 3; + while (*p >= '0' && *p <= '9') p++; + if (*p == 'x' && p[1] >= '0' && p[1] <= '9') found = true; + } + free(lower); + if (found) return true; + } + return colorterm && *colorterm; +} + +ScrStr *scr_util_style_text(const ScrDyn *format, const ScrDyn *text, const ScrDyn *options) { + ScrDyn *validate = util_style_member(options, "validateStream"); + if (!validate) return NULL; + if (validate->kind == SCR_DYN_UNDEF || validate->kind == SCR_DYN_NULL) { + scr_dyn_release(validate); validate = scr_dyn_new_bool(true); + } + const ScrTextStyle *fast = NULL; + if (!scr_dyn_truthy(validate) && format->kind == SCR_DYN_STR && text->kind == SCR_DYN_STR) { + if (util_style_none(format)) { scr_dyn_release(validate); return scr_str_retain(text->v.str); } + fast = util_style_lookup(format); + } + bool colorize = true; + ScrDyn *view = NULL, *stream = NULL; + ScrStr *result = NULL, *processed = NULL; + if (!fast) { + if (text->kind != SCR_DYN_STR) { scr_dyn_arg_type_fail("text", "of type string", text); goto done; } + view = util_style_view(options); + if (!view) goto done; + if (view->kind != SCR_DYN_UNDEF && view->kind != SCR_DYN_OBJ) { + scr_dyn_arg_type_fail("options", "of type object", view); goto done; + } + if (validate->kind != SCR_DYN_BOOL) { + scr_dyn_prop_type_fail("options.validateStream", "of type boolean", validate); goto done; + } + if (validate->v.b) { + stream = util_style_member(options, "stream"); + if (!stream) goto done; + if (stream->kind == SCR_DYN_TYPED_REF && !strncmp(stream->v.typed_ref.type_key, "record:", 7)) { + ScrDyn *native = util_style_view(stream); + scr_dyn_release(stream); stream = native; + if (!stream) goto done; + } + double fd = 1; + if (stream->kind != SCR_DYN_UNDEF && stream->kind != SCR_DYN_NULL) { + if (stream->kind == SCR_DYN_HANDLE && stream->v.handle.tag == SCR_DYNH_STDIO) { + ScrStr *key = scr_str_new("fd", 2); + ScrDyn *value = scr_dyn_handle_key_get(stream, key); + scr_str_release(key); + if (!value) goto done; + fd = value->v.num; scr_dyn_release(value); + } else if (stream->kind == SCR_DYN_HANDLE || stream->kind == SCR_DYN_TYPED_REF || + (stream->kind == SCR_DYN_OBJ && util_style_custom_stream(stream))) { + static const char message[] = "util.styleText over native custom streams is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "SC2020"); goto done; + } else { + if (scr_exc_pending()) goto done; + scr_dyn_arg_type_fail("stream", "an instance of ReadableStream, WritableStream, or Stream", stream); goto done; + } + } + colorize = getenv("FORCE_COLOR") ? util_style_terminal_colors() : + scr_process_is_tty(fd) && util_style_terminal_colors(); + } + } + scr_dyn_release(view); view = util_style_view(format); + if (!view) goto done; + processed = scr_str_retain(text->v.str); + ScrJsonBuf opens, closes; + scr_jb_init(&opens); scr_jb_init(&closes); + size_t count = view->kind == SCR_DYN_ARR ? view->v.arr.len : 1; + for (size_t i = 0; i < count; i++) { + const ScrDyn *key = view->kind == SCR_DYN_ARR ? view->v.arr.items[i] : view; + if (util_style_none(key)) continue; + const ScrTextStyle *style = util_style_lookup(key); + if (!style) { + if (!scr_exc_pending()) util_style_invalid(key); + scr_str_release(scr_jb_finish(&opens)); scr_str_release(scr_jb_finish(&closes)); goto done; + } + char open[16], close[16]; + snprintf(open, sizeof open, "\x1b[%um", style->open); + snprintf(close, sizeof close, "\x1b[%um", style->close); + scr_jb_puts(&opens, open); + size_t n = strlen(close); + scr_jb_puts(&closes, close); + /* Prepend each closing sequence so the outer formats close last. */ + memmove(closes.data + n, closes.data, closes.len - n); + memcpy(closes.data, close, n); + ScrStr *next = util_style_replace(processed, close, open, style->open == 1 || style->open == 2); + scr_str_release(processed); processed = next; + } + if (colorize) { + scr_jb_put_str(&opens, processed); + util_style_putn(&opens, closes.data, closes.len); + result = scr_jb_finish(&opens); + } else { scr_str_release(scr_jb_finish(&opens)); result = scr_str_retain(text->v.str); } + scr_str_release(scr_jb_finish(&closes)); +done: + scr_dyn_release(validate); scr_dyn_release(view); scr_dyn_release(stream); + scr_str_release(processed); + return result; +} diff --git a/tests/corpus/node24-style-text-environment.js b/tests/corpus/node24-style-text-environment.js new file mode 100644 index 000000000..8181f2441 --- /dev/null +++ b/tests/corpus/node24-style-text-environment.js @@ -0,0 +1,26 @@ +import { styleText } from "node:util"; +const originalForce = process.env.FORCE_COLOR; +const originalNoColor = process.env.NO_COLOR; +const originalDisable = process.env.NODE_DISABLE_COLORS; +delete process.env.FORCE_COLOR; +delete process.env.NO_COLOR; +delete process.env.NODE_DISABLE_COLORS; +for (const force of ["", "0", "1", "2", "3", "true", "false", "4", "01", "blue"]) { + process.env.FORCE_COLOR = String(force); + console.log("force " + force + " " + JSON.stringify(styleText(["bold", "red"], "x"))); + console.log("stderr " + force + " " + JSON.stringify(styleText("green", "x", {stream: process.stderr}))); + console.log("bypass " + force + " " + JSON.stringify(styleText("blue", "x", {validateStream: false}))); +} +delete process.env.FORCE_COLOR; +for (const disabled of ["", "1"]) { + process.env.NO_COLOR = String(disabled); + console.log("no-color " + disabled + " " + JSON.stringify(styleText("red", "x"))); + console.log("explicit " + disabled + " " + JSON.stringify(styleText("red", "x", {validateStream: false}))); + delete process.env.NO_COLOR; + process.env.NODE_DISABLE_COLORS = String(disabled); + console.log("disabled " + disabled + " " + JSON.stringify(styleText("green", "x", {stream: process.stdout}))); + delete process.env.NODE_DISABLE_COLORS; +} +if (originalForce === undefined) delete process.env.FORCE_COLOR; else process.env.FORCE_COLOR = String(originalForce); +if (originalNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = String(originalNoColor); +if (originalDisable === undefined) delete process.env.NODE_DISABLE_COLORS; else process.env.NODE_DISABLE_COLORS = String(originalDisable); diff --git a/tests/corpus/node24-style-text-validation.js b/tests/corpus/node24-style-text-validation.js new file mode 100644 index 000000000..9ca40e3d4 --- /dev/null +++ b/tests/corpus/node24-style-text-validation.js @@ -0,0 +1,31 @@ +import { styleText as style } from "node:util"; +const stored = style; +for (const value of [undefined, null, false, 0, -0, NaN, Infinity, 2n, Symbol("x"), {}, [1, 2]]) { + try { console.log("text", style("red", value, { validateStream: false })); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("format", style(value, "x", { validateStream: false })); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("options", style("red", "x", value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("flag", style(["red"], "x", { validateStream: value })); } + catch (error) { console.log(error.name, error.code, error.message); } +} +for (const name of ["bad", "toString", "constructor", "__proto__", "a\nb", "a'b", 'a"b', "a'b\"c"]) { + try { style(name, "x", { validateStream: false }); } + catch (error) { console.log("bad-style", error.name, error.code, error.message); } +} +for (const options of [null, [], 2, "x", {validateStream: 0}, {validateStream: ""}, {validateStream: null}, {validateStream: false, stream: 2}, {stream: 2}, {stream: {}}, {stream: {isTTY: true}}]) { + try { console.log("fast", JSON.stringify(stored("red", "x", options))); } + catch (error) { console.log("fast", error.name, error.code, error.message); } +} +try { style(); } catch (error) { console.log("missing", error.name, error.code, error.message); } +try { stored("red"); } catch (error) { console.log("missing-stored", error.name, error.code, error.message); } +let order = ""; +function input(value) { order += value; return value; } +function extra() { order += "extra"; return null; } +console.log("order", JSON.stringify(style(input("red"), input("text"), {validateStream: false}, extra())), order); +try { style("bad", "x", {validateStream: false}, extra()); } +catch (error) { console.log("order-error", order, error.code); } +const formats = ["red"]; +function mutate() { formats[0] = "green"; return null; } +console.log("live", JSON.stringify(style(formats, "x", {validateStream: false}, mutate()))); diff --git a/tests/corpus/node24-style-text.cjs b/tests/corpus/node24-style-text.cjs new file mode 100644 index 000000000..109290518 --- /dev/null +++ b/tests/corpus/node24-style-text.cjs @@ -0,0 +1,7 @@ +const {styleText} = require("node:util"); +const util = require("util"); +const stored = util.styleText; +console.log("identity", stored === styleText); +console.log(JSON.stringify(stored(["bold", "green"], "hello", {validateStream: false}))); +console.log(JSON.stringify(util.styleText("none", "plain"))); +console.log(JSON.stringify(styleText("red", "before\u001b[39mafter", {validateStream: false}))); diff --git a/tests/corpus/node24-style-text.ts b/tests/corpus/node24-style-text.ts new file mode 100644 index 000000000..854254ab2 --- /dev/null +++ b/tests/corpus/node24-style-text.ts @@ -0,0 +1,15 @@ +import { styleText } from "node:util"; +import * as util from "util"; +const stored = styleText; +const formats = ["reset", "bold", "dim", "italic", "underline", "blink", "inverse", "hidden", "strikethrough", "doubleunderline", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite", "framed", "overlined", "gray", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright", "bgGray", "bgRedBright", "bgGreenBright", "bgYellowBright", "bgBlueBright", "bgMagentaBright", "bgCyanBright", "bgWhiteBright", "grey", "blackBright", "bgGrey", "bgBlackBright", "faint", "crossedout", "strikeThrough", "crossedOut", "conceal", "swapColors", "swapcolors", "doubleUnderline", "none"]; +for (const format of formats) { + for (const text of ["", "café 😀", "first\nsecond", "a\u001b[39mb", "a\u001b[22mb", "a\u001b[24mb", "a\u001b[0mb", "a\u001b[39m", "embedded\u0000end"]) { + console.log(format, JSON.stringify(styleText(format, text, { validateStream: false }))); + } +} +for (const format of [[], ["none"], ["bold", "green"], ["red", "green"], ["dim", "bold", "dim"], ["none", "underline", "italic"], ["reset", "red"]]) { + console.log("array", JSON.stringify(stored(format, "before\u001b[39mmiddle\u001b[22mafter", { validateStream: false }))); +} +console.log("identity", styleText === stored, styleText === util.styleText); +console.log("nested", JSON.stringify(styleText("red", styleText("blue", "nested", { validateStream: false }) + " tail", { validateStream: false }))); +console.log("default", JSON.stringify(styleText("none", "plain"))); diff --git a/tests/fixtures/node-types/style-text.ts b/tests/fixtures/node-types/style-text.ts new file mode 100644 index 000000000..05b3212d8 --- /dev/null +++ b/tests/fixtures/node-types/style-text.ts @@ -0,0 +1,38 @@ +import { styleText } from "node:util"; +const stored: typeof styleText = styleText; +console.log(JSON.stringify(stored(["bold", "green"], "hello", {validateStream: false}))); +console.log(JSON.stringify(styleText("red", "pipe", {stream: process.stderr}))); +const out: NodeJS.WritableStream = process.stdout; +const options = {stream: out}; +console.log(JSON.stringify(styleText("blue", "captured", options))); +const boxed: unknown = out; +const restored = boxed as NodeJS.WritableStream; +console.log("round-trip", restored === out); +console.log(JSON.stringify(styleText("green", "restored", {stream: restored}))); +function optionalStream(enabled: boolean): NodeJS.WritableStream | undefined { + return enabled ? out : undefined; +} +for (const enabled of [true, false]) { + const optional: unknown = optionalStream(enabled); + const stream = optional as NodeJS.WritableStream | undefined; + console.log("optional", stream === (enabled ? out : undefined)); + console.log(JSON.stringify(styleText("yellow", "optional", {stream}))); +} +const records = [{stream: out}, {stream: restored}]; +const boxedRecords: unknown = records; +const restoredRecords = boxedRecords as {stream: NodeJS.WritableStream}[]; +for (const record of restoredRecords) { + console.log("record", record.stream === out); + console.log(JSON.stringify(styleText("cyan", "record", record))); +} +const identity = (stream: NodeJS.WritableStream): NodeJS.WritableStream => stream; +const boxedIdentity: unknown = identity; +const restoredIdentity = boxedIdentity as typeof identity; +console.log("callback", restoredIdentity(out) === out); +const optionPairs: (NodeJS.WritableStream | undefined)[] = [out, undefined]; +const boxedPairs: unknown = optionPairs; +const restoredPairs = boxedPairs as (NodeJS.WritableStream | undefined)[]; +for (const stream of restoredPairs) { + console.log("array", stream === out || stream === undefined); + console.log(JSON.stringify(styleText("magenta", "array", {stream}))); +} diff --git a/tests/fixtures/npm/cases/node24-style-text/main.ts b/tests/fixtures/npm/cases/node24-style-text/main.ts new file mode 100644 index 000000000..f8cfdf9ec --- /dev/null +++ b/tests/fixtures/npm/cases/node24-style-text/main.ts @@ -0,0 +1,4 @@ +// @dynamic +// @stderr +import { reportStyleText } from "node24-style-text-fixture"; +reportStyleText(); diff --git a/tests/fixtures/npm/cases/node24-style-text/package.json b/tests/fixtures/npm/cases/node24-style-text/package.json new file mode 100644 index 000000000..089153bcb --- /dev/null +++ b/tests/fixtures/npm/cases/node24-style-text/package.json @@ -0,0 +1 @@ +{"type":"module"} diff --git a/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.d.ts b/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.d.ts new file mode 100644 index 000000000..6d97d697c --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.d.ts @@ -0,0 +1 @@ +export function reportStyleText(): void; diff --git a/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.js b/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.js new file mode 100644 index 000000000..6f6a925dd --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-style-text-fixture/index.js @@ -0,0 +1,100 @@ +import { styleText } from "node:util"; +import * as util from "util"; +import { PassThrough } from "node:stream"; + +export function reportStyleText() { +const originalRed = util.inspect.colors.red; +util.inspect.colors.red = [33, 39]; +console.log("color-cache-before", JSON.stringify(styleText("red", "x", {validateStream: false}))); +util.inspect.colors.red = originalRed; +console.log("color-cache-after", JSON.stringify(styleText("red", "x", {validateStream: false}))); +console.log("aliases", Object.keys(util.inspect.colors).includes("grey"), util.inspect.colors.grey === util.inspect.colors.gray); +const stored = styleText; +const formats = ["reset", "bold", "dim", "italic", "underline", "blink", "inverse", "hidden", "strikethrough", "doubleunderline", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite", "framed", "overlined", "gray", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright", "bgGray", "bgRedBright", "bgGreenBright", "bgYellowBright", "bgBlueBright", "bgMagentaBright", "bgCyanBright", "bgWhiteBright", "grey", "blackBright", "bgGrey", "bgBlackBright", "faint", "crossedout", "strikeThrough", "crossedOut", "conceal", "swapColors", "swapcolors", "doubleUnderline", "none"]; +for (const format of formats) { + for (const text of ["", "café 😀", "first\nsecond", "a\u001b[39mb", "a\u001b[22mb", "a\u001b[24mb", "a\u001b[0mb", "a\u001b[39m", "embedded\u0000end"]) { + console.log(format, JSON.stringify(styleText(format, text, { validateStream: false }))); + } +} +for (const format of [[], ["none"], ["bold", "green"], ["red", "green"], ["dim", "bold", "dim"], ["none", "underline", "italic"], ["reset", "red"]]) { + console.log("array", JSON.stringify(stored(format, "before\u001b[39mmiddle\u001b[22mafter", { validateStream: false }))); +} +console.log("identity", styleText === stored, styleText === util.styleText); +console.log("nested", JSON.stringify(styleText("red", styleText("blue", "nested", { validateStream: false }) + " tail", { validateStream: false }))); +console.log("default", JSON.stringify(styleText("none", "plain"))); +const style = styleText; +for (const value of [undefined, null, false, 0, -0, NaN, Infinity, 2n, Symbol("x"), {}, [1, 2]]) { + try { console.log("text", style("red", value, { validateStream: false })); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("format", style(value, "x", { validateStream: false })); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("options", style("red", "x", value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("flag", style(["red"], "x", { validateStream: value })); } + catch (error) { console.log(error.name, error.code, error.message); } +} +for (const name of ["bad", "toString", "constructor", "__proto__", "a\nb", "a'b", 'a"b', "a'b\"c"]) { + try { style(name, "x", { validateStream: false }); } + catch (error) { console.log("bad-style", error.name, error.code, error.message); } +} +for (const options of [null, [], 2, "x", {validateStream: 0}, {validateStream: ""}, {validateStream: null}, {validateStream: false, stream: 2}, {stream: 2}, {stream: {}}, {stream: {isTTY: true}}]) { + try { console.log("fast", JSON.stringify(stored("red", "x", options))); } + catch (error) { console.log("fast", error.name, error.code, error.message); } +} +try { style(); } catch (error) { console.log("missing", error.name, error.code, error.message); } +try { stored("red"); } catch (error) { console.log("missing-stored", error.name, error.code, error.message); } +let order = ""; +function input(value) { order += value; return value; } +function extra() { order += "extra"; return null; } +console.log("order", JSON.stringify(style(input("red"), input("text"), {validateStream: false}, extra())), order); +try { style("bad", "x", {validateStream: false}, extra()); } +catch (error) { console.log("order-error", order, error.code); } +const mutableFormats = ["red"]; +function mutate() { mutableFormats[0] = "green"; return null; } +console.log("live", JSON.stringify(style(mutableFormats, "x", {validateStream: false}, mutate()))); + +const options = { get validateStream() { console.log("get-flag"); return false; }, get stream() { throw new Error("unused stream"); } }; +console.log("getter", JSON.stringify(styleText("red", "x", options))); +const inherited = Object.create({validateStream: false}); +console.log("inherited", JSON.stringify(styleText(["bold", "green"], "x", inherited))); +const converted = { toString() { console.log("format-toString"); return "red"; } }; +console.log("converted", JSON.stringify(styleText([converted], "x", {validateStream: false}))); +const sparse = []; sparse.length = 1; +try { styleText(sparse, "x", {validateStream: false}); } catch (error) { console.log("sparse", error.name, error.code, error.message); } +const stream = new PassThrough(); +console.log("custom-stream", JSON.stringify(styleText("red", "x", {stream}))); +stream.isTTY = true; +stream.getColorDepth = () => 1; +console.log("depth-one", JSON.stringify(styleText("red", "x", {stream}))); +stream.getColorDepth = () => 4; +console.log("depth-four", JSON.stringify(styleText("red", "x", {stream}))); +stream.destroy(); +const webReadable = new ReadableStream({start(controller) {controller.close();}}); +console.log("web-stream", JSON.stringify(styleText("red", "x", {stream: webReadable}))); +console.log("ignored-stream", JSON.stringify(styleText("red", "x", {validateStream: false, stream: Symbol("unused")}))); +const originalForce = process.env.FORCE_COLOR; +const originalNoColor = process.env.NO_COLOR; +const originalDisable = process.env.NODE_DISABLE_COLORS; +delete process.env.FORCE_COLOR; +delete process.env.NO_COLOR; +delete process.env.NODE_DISABLE_COLORS; +for (const force of ["", "0", "1", "2", "3", "true", "false", "4", "01", "blue"]) { + process.env.FORCE_COLOR = String(force); + console.log("force " + force + " " + JSON.stringify(styleText(["bold", "red"], "x"))); + console.log("stderr " + force + " " + JSON.stringify(styleText("green", "x", {stream: process.stderr}))); + console.log("bypass " + force + " " + JSON.stringify(styleText("blue", "x", {validateStream: false}))); +} +delete process.env.FORCE_COLOR; +for (const disabled of ["", "1"]) { + process.env.NO_COLOR = String(disabled); + console.log("no-color " + disabled + " " + JSON.stringify(styleText("red", "x"))); + console.log("explicit " + disabled + " " + JSON.stringify(styleText("red", "x", {validateStream: false}))); + delete process.env.NO_COLOR; + process.env.NODE_DISABLE_COLORS = String(disabled); + console.log("disabled " + disabled + " " + JSON.stringify(styleText("green", "x", {stream: process.stdout}))); + delete process.env.NODE_DISABLE_COLORS; +} +if (originalForce === undefined) delete process.env.FORCE_COLOR; else process.env.FORCE_COLOR = String(originalForce); +if (originalNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = String(originalNoColor); +if (originalDisable === undefined) delete process.env.NODE_DISABLE_COLORS; else process.env.NODE_DISABLE_COLORS = String(originalDisable); +} diff --git a/tests/fixtures/npm/node_modules/node24-style-text-fixture/package.json b/tests/fixtures/npm/node_modules/node24-style-text-fixture/package.json new file mode 100644 index 000000000..439843c80 --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-style-text-fixture/package.json @@ -0,0 +1 @@ +{"name":"node24-style-text-fixture","version":"1.0.0","type":"module","main":"index.js","types":"index.d.ts"} diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 26d9fcdbd..a1a9eb533 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -25,6 +25,17 @@ const fixture = (name: string) => join(repoRoot, "tests/fixtures/strictness", na const nodeTypesDir = join(repoRoot, "tests/fixtures/node-types"); const sanitize = process.env["SCRIPTC_SAN"] === "1"; +test("node-types: util.styleText supports stored calls and standard streams under @types/node", async () => { + const outDir = outDirFor("style-text"); + const entry = join(nodeTypesDir, "style-text.ts"); + const result = await compile(entry, { outPath: join(outDir, "style-text"), outDir, sanitize }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([execFileAsync(result.binaryPath), execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry])]); + expect(native.stdout).toBe(node.stdout); + expect(native.stderr).toBe(node.stderr); +}); + function outDirFor(name: string): string { return mkdtempSync(join(tmpdir(), `scriptc-config-${name}-`)); } diff --git a/tests/harness/self-hosting-runtime-features.test.ts b/tests/harness/self-hosting-runtime-features.test.ts index 48452a52d..45fce8d1b 100644 --- a/tests/harness/self-hosting-runtime-features.test.ts +++ b/tests/harness/self-hosting-runtime-features.test.ts @@ -41,6 +41,7 @@ const featureCalls: readonly [string, IrLibFn][] = [ ["qs", "qs.parse"], ["parseArgs", "util.parseArgs"], ["parseArgs", "util.isDeepStrictEqual"], + ["parseArgs", "util.styleText"], ["fsWatch", "fs.watch"], ["nodeTest", "test.register"], ["dgram", "dgram.createSocket"], @@ -96,7 +97,7 @@ for (const backend of ["llvm"] as const) { name: "callback", params: [], locals: [], returnType: VOID, loc, body: [{ kind: "block", body: [{ kind: "exprStmt", expr: lib(fn), loc }], loc }], }); - check(fn, module, fn === "util.isDeepStrictEqual" ? [feature, "symbol"] : [feature]); + check(fn, module, fn === "util.isDeepStrictEqual" ? [feature, "symbol"] : fn === "util.styleText" ? [feature, "inspect", "processEvents", "dynAsync"] : [feature]); } const regex = empty(); regex.functions[0]!.body.push({ kind: "exprStmt", expr: {