diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c91edf4b..9a9ad9fb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,6 +354,37 @@ jobs: - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" - run: pnpm test tests/harness/effect.test.ts tests/harness/effect4.test.ts + # The sanitized three.js nodes build alone: beside the san corpus's other + # workers it took 860 to more than 1200 seconds of its shard + # (SCRIPTC_THREE_SANITIZED_NODES_JOB in scripts/ci-test-plan.mjs). It keeps + # the corpus's LeakSanitizer settings. + three_nodes_san: + needs: [validation_scope, lanes, native_linux] + if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }} + name: test (Linux, san, three.js nodes) + runs-on: ubuntu-24.04 + timeout-minutes: 40 + env: + SCRIPTC_SAN: "1" + ASAN_OPTIONS: detect_leaks=1 + LSAN_OPTIONS: suppressions=${{ github.workspace }}/tests/harness/lsan.supp:print_suppressions=0 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: native-linux-x64-gnu + path: ${{ runner.temp }} + - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" + - run: pnpm test tests/harness/three.test.ts -t "'nodes' runs statically through LLVM on 'native'" + # Build each instrumented seed once. Downstream contracts retain the same # compiler and source artifacts without repeating seed construction. bootstrap_seed: @@ -958,6 +989,7 @@ jobs: tests_macos, native_linux, effects, + three_nodes_san, bootstrap_seed, bootstrap, linux_host_clang, @@ -990,6 +1022,7 @@ jobs: lane tests_macos "${{ needs.tests_macos.result }}" "${{ needs.lanes.outputs.macos }}" lane native_linux "${{ needs.native_linux.result }}" "${{ needs.lanes.outputs.linux }}" lane effects "${{ needs.effects.result }}" "${{ needs.lanes.outputs.effects }}" + lane three_nodes_san "${{ needs.three_nodes_san.result }}" "${{ needs.lanes.outputs.linux }}" lane bootstrap_seed "${{ needs.bootstrap_seed.result }}" "${{ needs.lanes.outputs.bootstrap }}" lane bootstrap "${{ needs.bootstrap.result }}" "${{ needs.lanes.outputs.bootstrap }}" lane linux_host_clang "${{ needs.linux_host_clang.result }}" "${{ needs.lanes.outputs.linux }}" diff --git a/AGENTS.md b/AGENTS.md index 2350d7337..55ccb2eab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ SCRIPTC_TEST_WORKERS=4 SCRIPTC_SAN=1 pnpm test # sanitized lane Corpus programs are differential tests against Node: every program runs under Node and as a compiled native binary, and stdout, stderr, and exit codes must match byte-for-byte. A new feature lands with corpus programs that pin its behavior both ways. +Name a new corpus program after what it tests, without a number: lowercase words joined by hyphens, such as `tests/corpus/intersection-restated-members.ts`, or a directory such as `tests/corpus//main.ts` for a multi-file program. Parallel changes kept taking the same next number; with names, two changes collide only when they add the same name, which git reports as a conflict. Leave existing numbered programs as they are: never rename or renumber them, and name a new program even when it extends a numbered family. + Record a new or remeasured program's CI cost (used to balance the test shards) in `tests/harness/corpus-costs/.json`, not in `tests/harness/corpus-costs.json`; see the README there. Before opening a PR that adds or changes corpus, diagnostic, or frontend fixtures, run `pnpm test:ts7 --baselines-only`. This fast completeness check uses the same fixture discovery as the parity suite; it does not type-check or replace behavioral parity. Generate missing preflight/order baselines, review their diagnostics and module order, and run the affected `order-parity.test.ts` batches before pushing. Keep existing baseline entries unchanged unless the corresponding behavior intentionally changes. The full `pnpm test:ts7` command also checks completeness before starting its sweep. diff --git a/docs/content/docs/dependencies.mdx b/docs/content/docs/dependencies.mdx index ec1eb0601..9b660c76f 100644 --- a/docs/content/docs/dependencies.mdx +++ b/docs/content/docs/dependencies.mdx @@ -172,6 +172,25 @@ Each key names a package and a semver range of the installed versions the patch A declaration is named by its path: `Options.name` for a member of a type, `parse()` for a function's return type, `parse(text)` for a parameter, `list()[]` for an array's elements, and `Env[string]` for an index signature. `export=()` names the return type of the function the module exports, `default` its default export, and a file prefix such as `index.js:parse()` names a declaration inside one of the package's files. One patch applies to the package's own declarations, its JSDoc, and its `@types/` package. The patched type then holds everywhere, so your code has to handle the `undefined` that a widened declaration allows. An optional `"note"` string can link the upstream issue. +For larger fixes, replace a whole declaration file, or give a package without declarations its own: + +```json:package.json +{ + "scriptc": { + "typePatches": { + "some-client@^3.2.0": { + "files": { "dist/types/options.d.ts": "./type-patches/options.d.ts" } + }, + "untyped-helper@1.x": { + "types": "./type-patches/untyped-helper.d.ts" + } + } + } +} +``` + +`files` maps a declaration file inside the package, or inside its `@types/` package written as `@types//`, to a local `.d.ts` file. `types` supplies the package's declaration entry. Paths of local files are relative to the `package.json`, and a replaced file can import the package's other declaration files with the same relative paths as the file it replaces. + Patches change only declarations, never the package's code, and values are still checked against the patched types. A build warns when a patch doesn't apply as written: when no range covers the installed version, when a declaration path matches nothing, when the declaration already has the type, or when a path is ambiguous. Report wrong declarations to the package too, so the patch can be removed later. ## Provenance sources (experimental) diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 0fa62e558..efd45af2f 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -14,7 +14,7 @@ A class can extend `require("events")` directly. 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`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), `stream.finished` and `stream.pipeline` (to the `stream/promises` functions, as in Node), the `zlib` callback one-shots `deflate`, `inflate`, `deflateRaw`, `inflateRaw`, `gzip`, `gunzip` and `unzip` (with or without options), the `crypto` callback functions `randomBytes`, `pbkdf2`, `scrypt`, `hkdf`, `randomFill`, `randomInt` and `generateKey`, `dns.lookup` (settling with `{ address, family }`, or the address array with `all: true`) and `dns.lookupService` (settling with `{ hostname, service }`), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call. -`util.promisify` over an untyped (`any`) function returns a native wrapper that follows Node's callback rules and carries the original's `name` and `length`. +`util.promisify` over an untyped function (a value typed `any`, a function whose parameters are untyped, or any other target in JavaScript) returns a native wrapper that follows Node's callback rules and carries the original's `name` and `length`, and throws Node's `ERR_INVALID_ARG_TYPE` for values that are not functions. Such a function with other own properties, `util.promisify.custom` among them, throws `SC2020` where `util.promisify` runs, and the wrapper does not report Node's DEP0174 warning when the original returns a promise. `util.styleText` supports standard formats and aliases, `#RGB` and `#RRGGBB` hex colors, arrays of formats, nested ANSI resets, and explicit color enabling with `{ validateStream: false }`. diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index c62b82f0c..946cb8061 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -1,6 +1,13 @@ `console.log`, `info`, and `debug` write to stdout; `error` and `warn` write to stderr. They accept `unknown`, printing strings verbatim and other values through native `util.inspect`. Direct calls support global `console` and `node:console` imports. +Console output, `util.format`, and `util.formatWithOptions` with a format string known only at run time, spread arguments, or an options object format their arguments at run time. +There, `%s` of a function or of a class instance whose class defines `toString` and `%o` of an object throw `SC2020`. +`util.inspect` with `colors`, with option values known only at run time, or in the `inspect(value, showHidden, depth, colors)` form renders the value's untyped view in the runtime with Node's styles. +Colored output of errors, promises, regular expressions, `DataView`, `ArrayBuffer`, weak collections, iterators, and other native objects throws `SC2020`. +Inspect options other than `depth`, `colors`, `customInspect`, `compact` (a number or `false`), `breakLength`, and `sorted` (a boolean) throw `SC2020` when they are set to a value other than Node's default. +`util.inspect` calls `[util.inspect.custom]` methods of classes and objects with Node's depth, options, and `util.inspect` arguments, and `console.dir` and `customInspect: false` skip them. +A class instance with such a method that is held as an untyped value throws `SC2020` when it is inspected, and a class whose subclasses declare the method without declaring it itself is refused. Stored global console values and detached output methods retain identity and accept ordinary arguments. Stored methods do not support format specifiers with substitution arguments. Other stored console APIs, custom `Console` instances, and console mutation are unsupported. diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index dd180cb7b..fc0b2be92 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -25,3 +25,10 @@ Index-signature records list declared fields before the other keys. Optional fields and properties added through a record's `unknown` view can be deleted. A required field has no absent state, so deleting it through the view throws `TypeError: Cannot delete property 'x' of #` and leaves the record unchanged, where Node.js removes the property. + +Records inherit `toString`, `toLocaleString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf` and `propertyIsEnumerable` from `Object.prototype`. +A call of one of these methods by name runs the built-in with the record as its receiver, unless the record holds an own function under that name. +A keyed read such as `handlers[name]` of a record whose values are functions answers the built-in when the record lacks the key, so truthiness, `typeof` and calls match Node.js. +A keyed read of a record whose values are not functions, such as `counts["toString"]` on a `Record`, answers `undefined` where Node.js answers the inherited function, so `counts[word] ?? 0` counts such a word as new. +Calling a built-in read from a record of functions throws a `TypeError` when its answer does not fit the record's function type, for example `valueOf` read from a `Record number>`. +Other inherited names, such as `constructor`, read `undefined` from typed records. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 130da748c..2f90447ad 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -21382,9 +21382,9 @@ "anchor": "err_invalid_mime_syntax", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -23520,9 +23520,9 @@ "anchor": "err_throttled", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -71946,9 +71946,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -71969,9 +71969,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -71992,9 +71992,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72015,9 +72015,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72038,9 +72038,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72061,9 +72061,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72176,9 +72176,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72199,9 +72199,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72222,9 +72222,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72245,9 +72245,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72268,9 +72268,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72291,9 +72291,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72314,9 +72314,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72337,9 +72337,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72360,9 +72360,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -72636,9 +72636,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -72751,9 +72751,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -72774,9 +72774,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -72797,9 +72797,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -73027,9 +73027,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -73050,9 +73050,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -73395,9 +73395,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index c3a0f90b3..8060af796 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -25806,8 +25806,12 @@ "anchor": "dep0044-utilisarray", "nodeStability": null, "static": { - "status": "not-applicable", - "evidence": "compiler-chapter-policy:deprecations" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.isArray", + "tests": [ + "tests/corpus/7700-util-deprecated-helpers.cjs", + "tests/corpus/7701-util-deprecated-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -26094,8 +26098,12 @@ "anchor": "dep0060-util_extend", "nodeStability": null, "static": { - "status": "not-applicable", - "evidence": "compiler-chapter-policy:deprecations" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util._extend", + "tests": [ + "tests/corpus/7700-util-deprecated-helpers.cjs", + "tests/corpus/7701-util-deprecated-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -32763,6 +32771,8 @@ "tests/corpus/6702-ecdh-errors.js", "tests/corpus/6708-dh-errors.js", "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7711-util-throttle.cjs", "tests/corpus/7714-zlib-brotli-compress.cjs", "tests/corpus/caught-system-error-fields.ts", "tests/corpus/error-stack-frames.ts", @@ -36688,8 +36698,11 @@ "anchor": "err_invalid_mime_syntax", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.ERR_INVALID_MIME_SYNTAX", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs" + ] }, "anchorSource": "exact" }, @@ -38826,8 +38839,11 @@ "anchor": "err_throttled", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.ERR_THROTTLED", + "tests": [ + "tests/corpus/7711-util-throttle.cjs" + ] }, "anchorSource": "exact" }, @@ -101568,6 +101584,25 @@ "tests/corpus/5835-util-format-integer-specifiers.ts", "tests/corpus/5884-inspect-layout-options.ts", "tests/corpus/6428-promisify-callback-functions.ts", + "tests/corpus/7520-util-format-runtime.ts", + "tests/corpus/7521-util-format-runtime.cjs", + "tests/corpus/7522-promisify-untyped.cjs", + "tests/corpus/7523-promisify-untyped.ts", + "tests/corpus/7524-inspect-custom.ts", + "tests/corpus/7525-inspect-custom.cjs", + "tests/corpus/7526-inspect-options-runtime.ts", + "tests/corpus/7700-util-deprecated-helpers.cjs", + "tests/corpus/7701-util-deprecated-helpers.ts", + "tests/corpus/7702-util-signal-exit-code.cjs", + "tests/corpus/7703-util-aborted.cjs", + "tests/corpus/7704-util-aborted.ts", + "tests/corpus/7705-util-diff.cjs", + "tests/corpus/7706-util-diff.ts", + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts", + "tests/corpus/7710-util-debounce.cjs", + "tests/corpus/7711-util-throttle.cjs", + "tests/corpus/7712-util-debounce-throttle.ts", "tests/corpus/7747-text-codec-untyped-slot.js", "tests/corpus/node24-deep-equality-properties.js", "tests/corpus/node24-deep-equality.cjs", @@ -101654,8 +101689,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util._extend", + "tests": [ + "tests/corpus/7700-util-deprecated-helpers.cjs", + "tests/corpus/7701-util-deprecated-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -101677,8 +101716,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.isArray", + "tests": [ + "tests/corpus/7700-util-deprecated-helpers.cjs", + "tests/corpus/7701-util-deprecated-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -101700,8 +101743,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.MIMEType", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101723,8 +101770,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mime.toString", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101746,8 +101797,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mime.toJSON", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101769,8 +101824,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.MIMEType.parse", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101884,8 +101943,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.MIMEParams", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101907,8 +101970,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.delete", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101930,8 +101997,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.entries", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101953,8 +102024,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.get", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101976,8 +102051,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.has", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -101999,8 +102078,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.keys", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -102022,8 +102105,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.set", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -102045,8 +102132,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams.values", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -102068,8 +102159,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.mimeParams", + "tests": [ + "tests/corpus/7707-util-mime-type.cjs", + "tests/corpus/7708-util-mime-type.ts" + ] }, "anchorSource": "exact" }, @@ -102468,8 +102563,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.convertProcessSignalToExitCode", + "tests": [ + "tests/corpus/7702-util-signal-exit-code.cjs" + ] }, "anchorSource": "exact" }, @@ -102603,8 +102701,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.debounce", + "tests": [ + "tests/corpus/7710-util-debounce.cjs", + "tests/corpus/7712-util-debounce-throttle.ts" + ] }, "anchorSource": "exact" }, @@ -102626,8 +102728,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.throttle", + "tests": [ + "tests/corpus/7711-util-throttle.cjs", + "tests/corpus/7712-util-debounce-throttle.ts" + ] }, "anchorSource": "exact" }, @@ -102649,8 +102755,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.diff", + "tests": [ + "tests/corpus/7705-util-diff.cjs", + "tests/corpus/7706-util-diff.ts" + ] }, "anchorSource": "exact" }, @@ -102677,7 +102787,9 @@ "tests": [ "tests/corpus/1636-util-format.ts", "tests/corpus/2440-console-inspect-args.ts", - "tests/corpus/5835-util-format-integer-specifiers.ts" + "tests/corpus/5835-util-format-integer-specifiers.ts", + "tests/corpus/7520-util-format-runtime.ts", + "tests/corpus/7521-util-format-runtime.cjs" ] }, "anchorSource": "exact" @@ -102705,7 +102817,9 @@ "tests": [ "tests/corpus/1636-util-format.ts", "tests/corpus/2440-console-inspect-args.ts", - "tests/corpus/5835-util-format-integer-specifiers.ts" + "tests/corpus/5835-util-format-integer-specifiers.ts", + "tests/corpus/7520-util-format-runtime.ts", + "tests/corpus/7521-util-format-runtime.cjs" ] }, "anchorSource": "exact" @@ -102896,7 +103010,8 @@ "tests/corpus/1632-inspect-records.ts", "tests/corpus/1637-inspect-dyn.ts", "tests/corpus/2485-inspect-circular-refs.ts", - "tests/corpus/5884-inspect-layout-options.ts" + "tests/corpus/5884-inspect-layout-options.ts", + "tests/corpus/7526-inspect-options-runtime.ts" ] }, "anchorSource": "exact" @@ -102919,8 +103034,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.inspect", + "tests": [ + "tests/corpus/7526-inspect-options-runtime.ts" + ] }, "anchorSource": "exact" }, @@ -102942,8 +103060,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.custom", + "tests": [ + "tests/corpus/7524-inspect-custom.ts", + "tests/corpus/7525-inspect-custom.cjs" + ] }, "anchorSource": "ancestor" }, @@ -103260,6 +103382,8 @@ "tests/fixtures/node-types/promisify-dns.ts", "tests/corpus/6428-promisify-callback-functions.ts", "tests/fixtures/node-types/promisify-callback-functions.ts", + "tests/corpus/7522-promisify-untyped.cjs", + "tests/corpus/7523-promisify-untyped.ts", "tests/harness/project-config.test.ts" ] }, @@ -103475,8 +103599,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "surface-manifest:node-builtin.util.aborted", + "tests": [ + "tests/corpus/7703-util-aborted.cjs", + "tests/corpus/7704-util-aborted.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index ab626fe21..5eb4c1b11 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -187,6 +187,13 @@ "node-builtin.util.callbackify": ["tests/corpus/5190-util-callbackify.cjs", "tests/corpus/5191-util-callbackify.ts", "tests/fixtures/node-types/util-callables.ts"], "node-builtin.util.debuglog": ["tests/corpus/5192-util-debuglog.cjs", "tests/corpus/5193-util-debuglog.ts", "tests/fixtures/node-types/util-callables.ts"], "node-builtin.util.debug": ["tests/corpus/5192-util-debuglog.cjs", "tests/corpus/5193-util-debuglog.ts"], + "node-builtin.util.isArray": ["tests/corpus/7700-util-deprecated-helpers.cjs", "tests/corpus/7701-util-deprecated-helpers.ts"], + "node-builtin.util._extend": ["tests/corpus/7700-util-deprecated-helpers.cjs", "tests/corpus/7701-util-deprecated-helpers.ts"], + "node-builtin.util.convertProcessSignalToExitCode": ["tests/corpus/7702-util-signal-exit-code.cjs"], + "node-builtin.util.aborted": ["tests/corpus/7703-util-aborted.cjs", "tests/corpus/7704-util-aborted.ts"], + "node-builtin.util.diff": ["tests/corpus/7705-util-diff.cjs", "tests/corpus/7706-util-diff.ts"], + "node-builtin.util.debounce": ["tests/corpus/7710-util-debounce.cjs", "tests/corpus/7712-util-debounce-throttle.ts"], + "node-builtin.util.throttle": ["tests/corpus/7711-util-throttle.cjs", "tests/corpus/7712-util-debounce-throttle.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", "tests/corpus/native-worker-messages.ts", "tests/corpus/native-worker-references.ts"], "node-builtin.zlib": ["tests/corpus/1404-zlib-crypto-bytes.ts", "tests/corpus/2921-zlib-callbacks.ts", "tests/corpus/3115-zlib-static-compression-levels.ts", "tests/corpus/5370-zlib-stream-roundtrip.ts", "tests/corpus/5371-zlib-stream-chunking.cjs", "tests/corpus/5373-zlib-stream-options.cjs", "tests/corpus/5378-zlib-sync-options.cjs", "tests/corpus/5379-zlib-callback-options.ts", "tests/corpus/5415-zlib-dynamic-inputs.cjs"], @@ -253,9 +260,11 @@ {"chapter": "worker_threads", "symbols": ["resourceLimits"], "status": "partial", "evidence": ["tests/corpus/5806-worker-resource-limits.ts"]}, {"chapter": "worker_threads", "symbols": ["BroadcastChannel extends EventTarget", "broadcastChannel.close", "broadcastChannel.postMessage", "broadcastChannel.ref", "broadcastChannel.unref", "onmessage"], "status": "partial", "evidence": ["tests/corpus/5800-broadcast-channel.ts", "tests/corpus/5801-broadcast-channel.cjs", "tests/corpus/5802-broadcast-channel-workers.ts"]}, {"chapter": "util", "symbols": ["util.types.isAnyArrayBuffer", "util.types.isArrayBuffer", "util.types.isArrayBufferView", "util.types.isDataView", "util.types.isTypedArray", "util.types.isUint8Array", "util.types.isUint8ClampedArray", "util.types.isUint16Array", "util.types.isUint32Array", "util.types.isInt8Array", "util.types.isInt16Array", "util.types.isInt32Array", "util.types.isFloat32Array", "util.types.isFloat64Array", "util.types.isMap", "util.types.isSet", "util.types.isWeakMap", "util.types.isWeakSet", "util.types.isRegExp", "util.types.isNativeError", "util.types.isPromise", "util.types.isProxy", "util.types.isAsyncFunction", "util.types.isGeneratorFunction", "util.types.isGeneratorObject"], "status": "partial", "evidence": ["tests/corpus/node24-util-types.ts", "tests/corpus/node24-util-types.js", "tests/corpus/node24-util-types.cjs", "tests/fixtures/node-types/util-types.ts"]}, - {"chapter": "util", "symbols": ["util.format", "util.formatWithOptions"], "status": "partial", "evidence": ["tests/corpus/1636-util-format.ts", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/5835-util-format-integer-specifiers.ts"]}, + {"chapter": "util", "symbols": ["util.format", "util.formatWithOptions"], "status": "partial", "evidence": ["tests/corpus/1636-util-format.ts", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/5835-util-format-integer-specifiers.ts", "tests/corpus/7520-util-format-runtime.ts", "tests/corpus/7521-util-format-runtime.cjs"]}, {"chapter": "util", "symbols": ["util.types.isAnyArrayBuffer", "util.types.isArrayBuffer", "util.types.isArrayBufferView", "util.types.isDataView", "util.types.isTypedArray", "util.types.isUint8Array", "util.types.isUint8ClampedArray", "util.types.isUint16Array", "util.types.isUint32Array", "util.types.isInt8Array", "util.types.isInt16Array", "util.types.isInt32Array", "util.types.isFloat32Array", "util.types.isFloat64Array", "util.types.isMap", "util.types.isSet", "util.types.isWeakMap", "util.types.isWeakSet", "util.types.isRegExp", "util.types.isNativeError", "util.types.isPromise", "util.types.isProxy", "util.types.isAsyncFunction", "util.types.isGeneratorFunction", "util.types.isGeneratorObject", "util.types.isDate", "util.types.isSharedArrayBuffer", "util.types.isMapIterator", "util.types.isSetIterator", "util.types.isKeyObject", "util.types.isNumberObject", "util.types.isStringObject", "util.types.isBooleanObject", "util.types.isBigIntObject", "util.types.isSymbolObject", "util.types.isBoxedPrimitive", "util.types.isBigInt64Array", "util.types.isBigUint64Array", "util.types.isFloat16Array", "util.types.isCryptoKey", "util.types.isExternal"], "status": "partial", "evidence": ["tests/corpus/5831-util-types-brands.ts", "tests/corpus/5832-util-types-brands-dynamic.js", "tests/corpus/node24-util-types.ts", "tests/corpus/node24-util-types.js", "tests/corpus/node24-util-types.cjs", "tests/fixtures/node-types/util-types.ts"]}, - { "chapter": "util", "signatures": ["`util.inspect(object[, options])`"], "status": "partial", "evidence": ["tests/corpus/1630-inspect-scalars.ts", "tests/corpus/1632-inspect-records.ts", "tests/corpus/1637-inspect-dyn.ts", "tests/corpus/2485-inspect-circular-refs.ts", "tests/corpus/5884-inspect-layout-options.ts"] }, + { "chapter": "util", "signatures": ["`util.inspect(object[, options])`"], "status": "partial", "evidence": ["tests/corpus/1630-inspect-scalars.ts", "tests/corpus/1632-inspect-records.ts", "tests/corpus/1637-inspect-dyn.ts", "tests/corpus/2485-inspect-circular-refs.ts", "tests/corpus/5884-inspect-layout-options.ts", "tests/corpus/7526-inspect-options-runtime.ts"] }, + { "chapter": "util", "signatures": ["`util.inspect(object[, showHidden[, depth[, colors]]])`"], "status": "partial", "evidence": ["tests/corpus/7526-inspect-options-runtime.ts"] }, + { "chapter": "util", "signatures": ["`custom` Type: {symbol} that can be used to declare custom inspect functions."], "status": "partial", "evidence": ["tests/corpus/7524-inspect-custom.ts", "tests/corpus/7525-inspect-custom.cjs"] }, { "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" }, @@ -265,6 +274,10 @@ { "chapter": "buffer", "symbols": ["buffer.isAscii", "buffer.isUtf8", "buffer.transcode"], "status": "partial", "evidence": ["tests/corpus/node24-buffer-encoding.ts", "tests/corpus/node24-buffer-encoding-errors.ts", "tests/fixtures/node-types/text-encoding.ts"] }, { "chapter": "util", "parent": "Class: `util.TextEncoder`", "signatures": ["`textEncoder.encodeInto(src, dest)`", "`encoding` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/node24-text-encode-into.ts", "tests/corpus/node24-text-codec-properties.ts", "tests/fixtures/node-types/text-encoding.ts"] }, { "chapter": "util", "parent": "Class: `util.TextDecoder`", "signatures": ["`encoding` Type: {string}", "`fatal` Type: {boolean}", "`ignoreBOM` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/node24-text-codec-properties.ts", "tests/fixtures/node-types/text-encoding.ts"] }, + { "chapter": "util", "symbols": ["util.MIMEType", "util.MIMEParams", "MIMEType.parse", "mime.toString", "mime.toJSON", "mimeParams.delete", "mimeParams.entries", "mimeParams.get", "mimeParams.has", "mimeParams.keys", "mimeParams.set", "mimeParams.values"], "status": "partial", "evidence": ["tests/corpus/7707-util-mime-type.cjs", "tests/corpus/7708-util-mime-type.ts"] }, + { "chapter": "util", "signatures": ["`mimeParams[Symbol.iterator]()`"], "status": "partial", "evidence": ["tests/corpus/7707-util-mime-type.cjs", "tests/corpus/7708-util-mime-type.ts"] }, + { "chapter": "errors", "symbols": ["ERR_INVALID_MIME_SYNTAX"], "status": "partial", "evidence": ["tests/corpus/7707-util-mime-type.cjs"] }, + { "chapter": "errors", "symbols": ["ERR_THROTTLED"], "status": "partial", "evidence": ["tests/corpus/7711-util-throttle.cjs"] }, { "chapter": "util", "symbols": ["util.parseEnv"], "status": "partial", "evidence": ["tests/corpus/node24-env-parse.ts", "tests/corpus/node24-env-parse-errors.ts", "tests/corpus/node24-env-imports.cjs", "tests/fixtures/node-types/env-files.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "process", "symbols": ["process.memoryUsage.rss"], "status": "partial", "evidence": ["tests/corpus/5210-process-memory-usage.ts"] }, { "chapter": "perf_hooks", "symbols": ["performance.now"], "status": "partial", "evidence": ["tests/corpus/2427-perf-hooks-now.ts", "tests/corpus/2446-performance-global.ts"] }, @@ -409,7 +422,7 @@ { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.before", "context.after", "context.beforeEach", "context.afterEach", "context.plan", "context.fullName"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["getTestContext"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/get-test-context.ts", "tests/fixtures/node-test/cases/get-test-context-cancelled.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["describe.skip", "describe.todo", "describe.only", "suite.skip", "suite.todo", "suite.only", "it.skip", "it.todo", "it.only"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts"] }, - { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/corpus/5630-promisify-zlib.ts", "tests/fixtures/node-types/promisify-zlib.ts", "tests/corpus/5631-promisify-crypto.ts", "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", "tests/corpus/6428-promisify-callback-functions.ts", "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/harness/project-config.test.ts"] }, + { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/corpus/5630-promisify-zlib.ts", "tests/fixtures/node-types/promisify-zlib.ts", "tests/corpus/5631-promisify-crypto.ts", "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", "tests/corpus/6428-promisify-callback-functions.ts", "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/corpus/7522-promisify-untyped.cjs", "tests/corpus/7523-promisify-untyped.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] }, { "chapter": "zlib", "signatures": ["Class: `zlib.Deflate`", "Class: `zlib.Inflate`", "Class: `zlib.Gzip`", "Class: `zlib.Gunzip`", "Class: `zlib.DeflateRaw`", "Class: `zlib.InflateRaw`", "Class: `zlib.Unzip`", "Class: `zlib.ZlibBase`", "`zlib.close([callback])`", "`zlib.flush([kind, ]callback)`", "`zlib.params(level, strategy, callback)`", "`zlib.reset()`", "`bytesWritten` Type: {number}", "Class: `Options`"], "status": "partial", "evidence": ["tests/corpus/5370-zlib-stream-roundtrip.ts", "tests/corpus/5371-zlib-stream-chunking.cjs", "tests/corpus/5372-zlib-stream-errors.cjs", "tests/corpus/5373-zlib-stream-options.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts", "tests/corpus/5375-zlib-stream-promises.ts", "tests/corpus/5377-zlib-stream-backpressure.cjs"] }, { "chapter": "zlib", "signatures": ["Class: `zlib.BrotliCompress`", "Class: `BrotliOptions`"], "status": "partial", "evidence": ["tests/corpus/7714-zlib-brotli-compress.cjs"] }, diff --git a/packages/cli/test/target-cpu.test.ts b/packages/cli/test/target-cpu.test.ts index ca5243564..5bc6717c3 100644 --- a/packages/cli/test/target-cpu.test.ts +++ b/packages/cli/test/target-cpu.test.ts @@ -50,9 +50,11 @@ test.runIf(process.platform === "linux" && process.arch === "x64")( const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`; const baseline = await build([]); expect(baseline).not.toContain('"target-cpu"'); + // Only --sanitize builds mark the functions for ASan instrumentation. + expect(baseline).not.toContain("sanitize_address"); expect(await build(["--target-cpu=baseline"])).toBe(baseline); const v3 = await build(["--target-cpu=x86-64-v3"]); - expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m); + expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m); if (runsV3) expect((await execFileAsync(outPath)).stdout).toBe(expected); // The bootstrap's cached route builds without a level. expect(await build([], true)).toBe(baseline); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 058b262ea..ee2208bd9 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3961,6 +3961,85 @@ declare module "util" { ): string; export function parseEnv(content: string): { [key: string]: string | undefined }; export function inherits(constructor: unknown, superConstructor: unknown): void; + export class MIMEType { + constructor(input: string | { toString: () => string }); + static parse(input: string | { toString: () => string }): MIMEType | null; + type: string; + subtype: string; + readonly essence: string; + readonly params: MIMEParams; + toString(): string; + toJSON(): string; + } + export class MIMEParams { + delete(name: string): void; + entries(): IterableIterator<[name: string, value: string]>; + get(name: string): string | null; + has(name: string): boolean; + keys(): IterableIterator; + set(name: string, value: string): void; + values(): IterableIterator; + toString(): string; + toJSON(): string; + [Symbol.iterator](): IterableIterator<[name: string, value: string]>; + } + export type DiffEntry = [operation: -1 | 0 | 1, value: string]; + export function diff( + actual: string | readonly string[], + expected: string | readonly string[], + ): DiffEntry[]; + export interface DebounceOptions { + leading?: boolean; + rejectOnCancel?: boolean; + signal?: AbortSignal; + } + /** Every call answers a promise of the result of the call it waits for. */ + export interface DebouncedFunction any> { + (...args: Parameters): Promise>>; + cancel(reason?: unknown): void; + flush(): void; + ref(): this; + unref(): this; + readonly pending: Promise>> | null; + readonly pendingCount: number; + } + export function debounce any>( + fn: T, + wait: number, + options?: DebounceOptions, + ): DebouncedFunction; + export interface ThrottleOptions { + concurrency?: number; + maxPending?: number; + overflow?: "queue" | "drop"; + signal?: AbortSignal; + strict?: boolean; + } + export interface ThrottledFunction any> { + (...args: Parameters): Promise>>; + cancel(reason?: unknown): void; + hasImmediateCapacity(): boolean; + ref(): this; + unref(): this; + readonly pending: Promise>> | null; + readonly pendingCount: number; + readonly activeCount: number; + } + export function throttle any>( + fn: T, + limit: number, + interval: number, + options?: ThrottleOptions, + ): ThrottledFunction; + /** Resolves with the abort event once `signal` aborts, or with undefined + * when it already has. */ + export function aborted(signal: AbortSignal, resource: any): Promise; + /** 128 plus the number of the signal, the exit code of a process it terminates. */ + export function convertProcessSignalToExitCode(signalCode: string): number; + /** @deprecated Since v4.0.0 - Use `Array.isArray()` instead. */ + export function isArray(object: unknown): object is unknown[]; + /** @deprecated Since v6.0.0 - Use `Object.assign()` instead. Answers target itself. */ + export function _extend(target: T, source: object | null | undefined): T; export interface DeprecateOptions { modifyPrototype?: boolean; } @@ -4018,7 +4097,13 @@ declare module "util" { export type TextDecoder = globalThis.TextDecoder; export var TextEncoder: typeof globalThis.TextEncoder; export var TextDecoder: typeof globalThis.TextDecoder; - /* stream.pipeline's overload comes first: its __promisify__ member keeps + /* An untyped function: an `any` value, or a function whose parameters + * are untyped (JavaScript's `(value, callback) => ...`), the only + * parameters that accept this object type. The promisified function is + * untyped too. It comes first because such functions match every other + * overload. */ + export function promisify(fn: (...args: { readonly "scriptc:untyped": true }[]) => unknown): any; + /* stream.pipeline's overload comes next: its __promisify__ member keeps * every other target out of it. */ export function promisify( fn: typeof import("node:stream").pipeline, @@ -4139,15 +4224,56 @@ declare module "util" { ): (address: string, port: number) => Promise<{ hostname: string; service: string }>; /* Declared surface without full lowering (the type-level tolerance * story: guarded/diagnostic-path code must TYPECHECK; a reached use - * without a lowering fences at its site). inspect's options carry the - * documented knobs through an index signature. */ + * without a lowering fences at its site). */ + export interface InspectOptions { + showHidden?: boolean | undefined; + depth?: number | null | undefined; + colors?: boolean | undefined; + customInspect?: boolean | undefined; + showProxy?: boolean | undefined; + maxArrayLength?: number | null | undefined; + maxStringLength?: number | null | undefined; + breakLength?: number | undefined; + compact?: boolean | number | undefined; + sorted?: boolean | ((a: string, b: string) => number) | undefined; + getters?: "get" | "set" | boolean | undefined; + numericSeparator?: boolean | undefined; + } + export type Style = + | "special" + | "number" + | "bigint" + | "boolean" + | "undefined" + | "null" + | "string" + | "symbol" + | "date" + | "regexp" + | "module"; + export interface InspectOptionsStylized extends InspectOptions { + stylize(text: string, styleType: Style): string; + } + export type CustomInspectFunction = ( + depth: number, + options: InspectOptionsStylized, + inspect: typeof import("node:util").inspect, + ) => any; export function inspect( object: unknown, - options?: { depth?: number | null; colors?: boolean; [k: string]: unknown }, + showHidden?: boolean, + depth?: number | null, + color?: boolean, ): string; + export function inspect(object: unknown, options?: InspectOptions): string; + export namespace inspect { + /* The key of an object's custom inspect function: + * Symbol.for('nodejs.util.inspect.custom'). */ + const custom: unique symbol; + } export function format(format?: unknown, ...args: unknown[]): string; export function formatWithOptions( - inspectOptions: { depth?: number | null; colors?: boolean; [k: string]: unknown }, + inspectOptions: InspectOptions, format?: unknown, ...args: unknown[] ): string; diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index a46f005ee..c58ef8af8 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -34,6 +34,8 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { ...(features.declAdopt ? { declAdopt: true } : {}), ...(features.dynForAwait ? { dynForAwait: true } : {}), ...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}), + ...(features.mime ? { mime: true } : {}), + ...(features.utilTiming ? { utilTiming: true } : {}), ...(features.dynBuffer ? { dynBuffer: true } : {}), ...(features.builtinValues ? { builtinValues: true } : {}), // Island code's signal and exit listeners join the events unit's diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 33bae2043..3418f9bb1 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -198,15 +198,17 @@ function readRange(dir: string, range: number): { result: ChunkResult; texts: st /** The attribute groups of the emitted functions: #0 for every function, * #1 for coroutine bodies and #2 for out-of-line record clones. Each carries - * the build's `--target-cpu` level. */ + * the build's `--target-cpu` level, and `sanitize_address` in sanitized + * builds. A group needs one attribute: without either, #0 holds a string + * attribute that LLVM ignores. */ function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] { const cpu = targetCpuAttribute(emitter.targetCpu); + const asan = emitter.sanitizeAttributes ? " sanitize_address" : ""; + const base = `${asan}${cpu}` || ` "scriptc-function"`; return [ - `attributes #0 = { sanitize_address${cpu} }`, - ...(emitter.usesCoroutines - ? [`attributes #1 = { sanitize_address presplitcoroutine${cpu} }`] - : []), - ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address${cpu} }`] : []), + `attributes #0 = {${base} }`, + ...(emitter.usesCoroutines ? [`attributes #1 = {${asan} presplitcoroutine${cpu} }`] : []), + ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline${asan}${cpu} }`] : []), ]; } @@ -1204,9 +1206,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] { ); for (const line of emitter.inlineRcTail(flushedDecls)) out.push(line); out.push( - // sanitize_address is inert under the plain pipeline; the sanitized - // lane's -fsanitize=address link activates instrumentation over the - // emitted functions too (the runtime TUs get theirs from clang). + // The sanitized lane's -fsanitize=address link instruments the + // functions marked sanitize_address (the runtime TUs get theirs from + // clang). ...functionAttributes(emitter, hasNoInlineRecordClone), ``, ); @@ -1235,6 +1237,126 @@ export function emitModuleParts(emitter: LlEmitter): string[] { return out; } +/** The out-of-line hashed probe of a number-key map (scr_map_find_f64): + * `keyLines` define the normalized key word %kb from the parameter, and + * `runtimeKey` is the double passed to the runtime lookup. A map with at + * most SCR_MAP_LINEAR_LIMIT entries keeps no bucket table: the absent-key + * filter is tested before its entries are scanned. A bucket table is + * probed without the filter: the filter keeps two of its 64 bits per key, + * so past a few dozen keys it rejects few absent keys, and the probe's + * first bucket read answers those. Compact tables tag each 24-bit entry + * index (+1) with the hash's top byte; wider tables take the runtime + * lookup. */ +function hashedNumberProbe( + name: string, + param: string, + sz: string, + keyLines: string[], + runtimeKey: string, +): string[] { + return [ + `define internal ptr @${name}(ptr %m, ${param}) noinline ${FN_ATTRS} {`, + `entry:`, + ...keyLines, + ` %x1s = lshr i64 %kb, 30`, + ` %x1 = xor i64 %kb, %x1s`, + ` %x2 = mul i64 %x1, -4658895280553007687`, + ` %x3s = lshr i64 %x2, 27`, + ` %x3 = xor i64 %x2, %x3s`, + ` %x4 = mul i64 %x3, -7723592293110705685`, + ` %x5s = lshr i64 %x4, 31`, + ` %x5 = xor i64 %x4, %x5s`, + ` %hz = icmp eq i64 %x5, 0`, + ` %h = select i1 %hz, i64 1, i64 %x5`, + ` %nbp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 13`, + ...(sz === "i64" + ? [` %nb32 = load i32, ptr %nbp`, ` %nb = zext i32 %nb32 to i64`] + : [` %nb = load i32, ptr %nbp`]), + ` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %ents = load ptr, ptr %eps`, + ` %linear = icmp eq ${sz} %nb, 0`, + ` br i1 %linear, label %filter, label %buckets`, + `filter:`, + ` %f1s = lshr i64 %h, 44`, + ` %f1a = and i64 %f1s, 63`, + ` %f1 = shl i64 1, %f1a`, + ` %f2s = lshr i64 %h, 50`, + ` %f2a = and i64 %f2s, 63`, + ` %f2 = shl i64 1, %f2a`, + ` %fb = or i64 %f1, %f2`, + ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, + ` %filter.bits = load i64, ptr %fp`, + ` %fm = and i64 %filter.bits, %fb`, + ` %fin = icmp eq i64 %fm, %fb`, + ` br i1 %fin, label %scan, label %miss`, + // A number-key entry has no cached hash; a dead entry's key matches no key. + `scan:`, + ` %nep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, + ...(sz === "i64" + ? [` %ne32 = load i32, ptr %nep`, ` %ne = zext i32 %ne32 to i64`] + : [` %ne = load i32, ptr %nep`]), + ` br label %scan.loop`, + `scan.loop:`, + ` %si = phi ${sz} [ 0, %scan ], [ %si.next, %scan.next ]`, + ` %sdone = icmp eq ${sz} %si, %ne`, + ` br i1 %sdone, label %miss, label %scan.entry`, + `scan.entry:`, + ` %se = getelementptr inbounds %ScrMapNumEntry, ptr %ents, ${sz} %si`, + ` %sk = load i64, ptr %se`, + ` %skq = icmp eq i64 %sk, %kb`, + ` br i1 %skq, label %scan.found, label %scan.next`, + `scan.found:`, + ` ret ptr %se`, + `scan.next:`, + ` %si.next = add ${sz} %si, 1`, + ` br label %scan.loop`, + `buckets:`, + ` %wide = icmp ugt ${sz} %nb, 16777216`, + ` br i1 %wide, label %runtime, label %probe`, + `probe:`, + ` %bp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 14`, + ` %bk = load ptr, ptr %bp`, + ` %mask = sub ${sz} %nb, 1`, + ` %ht = lshr i64 %h, 32`, + ` %ht32 = trunc i64 %ht to i32`, + ` %tag = and i32 %ht32, -16777216`, + sz === "i64" ? ` %hi = add i64 %h, 0` : ` %hi = trunc i64 %h to ${sz}`, + ` %i0 = and ${sz} %hi, %mask`, + ` br label %probe.loop`, + `probe.loop:`, + ` %pi = phi ${sz} [ %i0, %probe ], [ %pi.next, %probe.next ]`, + ` %bep = getelementptr inbounds i32, ptr %bk, ${sz} %pi`, + ` %b = load i32, ptr %bep`, + ` %bempty = icmp eq i32 %b, 0`, + ` br i1 %bempty, label %miss, label %probe.tag`, + `probe.tag:`, + ` %bt = and i32 %b, -16777216`, + ` %btq = icmp eq i32 %bt, %tag`, + ` br i1 %btq, label %probe.entry, label %probe.next`, + `probe.entry:`, + ` %bix = and i32 %b, 16777215`, + ` %bix1 = sub i32 %bix, 1`, + sz === "i32" ? ` %bi = add i32 %bix1, 0` : ` %bi = zext i32 %bix1 to ${sz}`, + ` %pe = getelementptr inbounds %ScrMapNumEntry, ptr %ents, ${sz} %bi`, + ` %pk = load i64, ptr %pe`, + ` %pkq = icmp eq i64 %pk, %kb`, + ` br i1 %pkq, label %probe.found, label %probe.next`, + `probe.found:`, + ` ret ptr %pe`, + `probe.next:`, + ` %pi.inc = add ${sz} %pi, 1`, + ` %pi.next = and ${sz} %pi.inc, %mask`, + ` br label %probe.loop`, + `miss:`, + ` ret ptr null`, + `runtime:`, + ` %r = call ptr @scr_map_entry_f64(ptr %m, double ${runtimeKey}) memory(read) nounwind willreturn`, + ` ret ptr %r`, + `}`, + ``, + ]; +} + /** The shared abort helpers (emitted only when referenced): the OOM * abort of untraced shape allocation and the invalid-union-tag abort — * both print the runtime ABI’s exact message on fd 2 and abort. */ @@ -1401,118 +1523,34 @@ export function helperDefs(emitter: LlEmitter): string[] { ` %dense.any = icmp ne i64 %nd, 0`, ` br i1 %dense.any, label %miss, label %slow`, `slow:`, - ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`, + ` %hashed = call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)`, ` ret ptr %hashed`, `}`, ``, - // Hashing (scr_map_find_f64): the SameValueZero key word, its - // scr_key_mix hash (zero remapped to one) and the absent-key filter. - `define internal ptr @sc_map_entry_f64_hashed(ptr %m, double %k) noinline ${FN_ATTRS} {`, - `entry:`, - ` %nan = fcmp uno double %k, %k`, - ` %zero = fcmp oeq double %k, 0.0`, - ` %kz = select i1 %zero, double 0.0, double %k`, - ` %kzb = bitcast double %kz to i64`, - ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, - ` %x1s = lshr i64 %kb, 30`, - ` %x1 = xor i64 %kb, %x1s`, - ` %x2 = mul i64 %x1, -4658895280553007687`, - ` %x3s = lshr i64 %x2, 27`, - ` %x3 = xor i64 %x2, %x3s`, - ` %x4 = mul i64 %x3, -7723592293110705685`, - ` %x5s = lshr i64 %x4, 31`, - ` %x5 = xor i64 %x4, %x5s`, - ` %hz = icmp eq i64 %x5, 0`, - ` %h = select i1 %hz, i64 1, i64 %x5`, - ` %f1s = lshr i64 %h, 44`, - ` %f1a = and i64 %f1s, 63`, - ` %f1 = shl i64 1, %f1a`, - ` %f2s = lshr i64 %h, 50`, - ` %f2a = and i64 %f2s, 63`, - ` %f2 = shl i64 1, %f2a`, - ` %fb = or i64 %f1, %f2`, - ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, - ` %filter = load i64, ptr %fp`, - ` %fm = and i64 %filter, %fb`, - ` %fin = icmp eq i64 %fm, %fb`, - ` br i1 %fin, label %table, label %miss`, - `table:`, - ` %nbp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 13`, - ...(sz === "i64" - ? [` %nb32 = load i32, ptr %nbp`, ` %nb = zext i32 %nb32 to i64`] - : [` %nb = load i32, ptr %nbp`]), - ` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %ents = load ptr, ptr %eps`, - ` %linear = icmp eq ${sz} %nb, 0`, - ` br i1 %linear, label %scan, label %buckets`, - // Small maps keep no bucket table: scan the entries' keys. A number-key - // entry has no cached hash; a dead entry's key matches no key. - `scan:`, - ` %nep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, - ...(sz === "i64" - ? [` %ne32 = load i32, ptr %nep`, ` %ne = zext i32 %ne32 to i64`] - : [` %ne = load i32, ptr %nep`]), - ` br label %scan.loop`, - `scan.loop:`, - ` %si = phi ${sz} [ 0, %scan ], [ %si.next, %scan.next ]`, - ` %sdone = icmp eq ${sz} %si, %ne`, - ` br i1 %sdone, label %miss, label %scan.entry`, - `scan.entry:`, - ` %se = getelementptr inbounds %ScrMapNumEntry, ptr %ents, ${sz} %si`, - ` %sk = load i64, ptr %se`, - ` %skq = icmp eq i64 %sk, %kb`, - ` br i1 %skq, label %scan.found, label %scan.next`, - `scan.found:`, - ` ret ptr %se`, - `scan.next:`, - ` %si.next = add ${sz} %si, 1`, - ` br label %scan.loop`, - // Compact tables tag each 24-bit entry index (+1) with the hash's - // top byte; wider tables take the runtime lookup. - `buckets:`, - ` %wide = icmp ugt ${sz} %nb, 16777216`, - ` br i1 %wide, label %runtime, label %probe`, - `probe:`, - ` %bp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 14`, - ` %bk = load ptr, ptr %bp`, - ` %mask = sub ${sz} %nb, 1`, - ` %ht = lshr i64 %h, 32`, - ` %ht32 = trunc i64 %ht to i32`, - ` %tag = and i32 %ht32, -16777216`, - sz === "i64" ? ` %hi = add i64 %h, 0` : ` %hi = trunc i64 %h to ${sz}`, - ` %i0 = and ${sz} %hi, %mask`, - ` br label %probe.loop`, - `probe.loop:`, - ` %pi = phi ${sz} [ %i0, %probe ], [ %pi.next, %probe.next ]`, - ` %bep = getelementptr inbounds i32, ptr %bk, ${sz} %pi`, - ` %b = load i32, ptr %bep`, - ` %bempty = icmp eq i32 %b, 0`, - ` br i1 %bempty, label %miss, label %probe.tag`, - `probe.tag:`, - ` %bt = and i32 %b, -16777216`, - ` %btq = icmp eq i32 %bt, %tag`, - ` br i1 %btq, label %probe.entry, label %probe.next`, - `probe.entry:`, - ` %bix = and i32 %b, 16777215`, - ` %bix1 = sub i32 %bix, 1`, - sz === "i32" ? ` %bi = add i32 %bix1, 0` : ` %bi = zext i32 %bix1 to ${sz}`, - ` %pe = getelementptr inbounds %ScrMapNumEntry, ptr %ents, ${sz} %bi`, - ` %pk = load i64, ptr %pe`, - ` %pkq = icmp eq i64 %pk, %kb`, - ` br i1 %pkq, label %probe.found, label %probe.next`, - `probe.found:`, - ` ret ptr %pe`, - `probe.next:`, - ` %pi.inc = add ${sz} %pi, 1`, - ` %pi.next = and ${sz} %pi.inc, %mask`, - ` br label %probe.loop`, - `miss:`, - ` ret ptr null`, - `runtime:`, - ` %r = call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn`, - ` ret ptr %r`, - `}`, - ``, + // Hashing (scr_map_find_f64): the SameValueZero key word and its + // scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer + // view to the key word, which is never NaN or -0, and so skips the + // normalization. + ...hashedNumberProbe( + "sc_map_entry_f64_hashed", + "double %k", + sz, + [ + ` %nan = fcmp uno double %k, %k`, + ` %zero = fcmp oeq double %k, 0.0`, + ` %kz = select i1 %zero, double 0.0, double %k`, + ` %kzb = bitcast double %kz to i64`, + ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, + ], + "%k", + ), + ...hashedNumberProbe( + "sc_map_entry_f64_hashed_int", + "i64 %ki", + sz, + [` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`], + "%kf", + ), ); } // Map/Set iteration steps read the entries array inline: an index is diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index a43cf3534..cc0af8ebb 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -50,6 +50,7 @@ import { matchIntegerBytesForLoop, matchIntegerCountedForLoop, matchIntegerPairedForLoop, + steppedLoopCounter, } from "../../ir/integer-loops.js"; import { emitByteWindowGuard, matchByteWindow } from "./byte-windows.js"; import { @@ -1218,7 +1219,24 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { // has no exit besides its own bound, which LLVM can vectorize. A typed // array's length is fixed, so each block can take it as its limit when // the loop does not assign the array's binding. + // + // Inside another loop of this function, which polls at least once + // every WORKER_POLL_BLOCK of its own iterations, a loop needs less: + // - a loop whose counters' ranges bound its runs, and those of the + // loops in its body, to WORKER_POLL_BLOCK^2 needs no poll; + // - an innermost counted loop is strip-mined, typed arrays or not, + // and does not poll after its last block. A run of at most + // WORKER_POLL_BLOCK iterations then never polls, and the countdown + // leaves its body. A run of at most WORKER_UNPOLLED_RUN iterations + // is one block, polled once as it leaves. + const enclosed = + emitter.mod.workers === true && + emitter.jumpTargets.some((target) => target.kind === "loop"); + const bounded = + enclosed && boundedLoopRuns(emitter, s) <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK; + const innermost = enclosed && !containsLoop(s.body); const stripMined = + !bounded && capturedLets.length === 0 && integerSlot !== null && ((countedLoop !== null && @@ -1226,16 +1244,21 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { Math.abs(countedLoop.step) * WORKER_POLL_BLOCK <= 2 ** 52) || (integerLoop !== null && integerArrayLoop === null && integerLoop.stableReceiver)) && emitter.workerLoopPolls(s) && - loopAccessesTypedArrays(s); - const workerBudget = stripMined ? null : emitter.workerLoopBudget(s); + (loopAccessesTypedArrays(s) || (countedLoop !== null && innermost)); + const workerBudget = stripMined || bounded ? null : emitter.workerLoopBudget(s); // One slot for every copy of the loop: a speculative copy resumes an // iteration in another copy's body, which then reads this bound. let blockBound: string | null = null; + let runSlot: string | null = null; if (stripMined) { blockBound = B.slot(); B.entryAllocas.push( `${blockBound} = alloca ${countedLoop ? "i64" : emitter.sizeType} ; cancellation poll block bound`, ); + if (innermost) { + runSlot = B.slot(); + B.entryAllocas.push(`${runSlot} = alloca i1 ; cancellation poll after the run`); + } } freshenBindings(); emitter.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => { @@ -1271,7 +1294,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { return len; }, }; - emitStripMinedCountedLoop(emitter, s, counter, blockBound!, exit); + emitStripMinedCountedLoop(emitter, s, counter, blockBound!, exit, runSlot); return; } const lc = B.newLabel("loop.c"); @@ -2628,6 +2651,60 @@ function emitInt32KindLoop( /** Iterations between cancellation polls of a strip-mined counted loop. */ const WORKER_POLL_BLOCK = 64; +/** The longest run of a strip-mined loop inside another loop that polls + * only as it leaves. */ +const WORKER_UNPOLLED_RUN = 16384; + +/** Whether a step of `step` from every value in `range` gives a different + * value. Beyond 2^53 a double can stop changing (`j + 1 === j` from 2^53), + * so a counter there can run without end inside a range of a few values. */ +function exactSteps(range: { min: number; max: number }, step: number): boolean { + const size = Math.abs(step); + return range.min >= size - 2 ** 53 && range.max <= 2 ** 53 - size; +} + +/** At most how many times a `for` loop and the loops in its body run their + * bodies per entry, from the counters' proven ranges: its iterations times + * one plus the nested loops' runs. Infinity when a count has no bound, or + * depends on a run-time guard (the guard's slow copy steps a double). */ +function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number { + if (loop.kind !== "for") return Infinity; + const counted = matchIntegerCountedForLoop( + loop, + emitter.numericLocals, + emitter.integerRanges, + LOOPS.of(emitter).unaliasedArrays, + ); + let trips = Infinity; + if (counted && !counted.guarded && exactSteps(counted.range, counted.step)) + trips = Math.floor((counted.range.max - counted.range.min) / Math.abs(counted.step)) + 1; + else if (!counted) { + // Each iteration closes the gap between the two counters by at least two. + const paired = matchIntegerPairedForLoop(loop, emitter.numericLocals, emitter.integerRanges); + if (paired && !paired.guarded && exactSteps(paired.range, 1)) + trips = Math.floor((paired.range.max - paired.range.min) / 2) + 1; + } + if (trips === Infinity) { + // A counter with a proven range where the condition reads it, such as + // one compared with a module constant. + const stepped = steppedLoopCounter(loop, emitter.currentLocals); + const range = stepped && emitter.integerRanges.get(stepped.ref); + if (stepped && range && exactSteps(range, stepped.step)) + trips = Math.floor((range.max - range.min) / Math.abs(stepped.step)) + 1; + } + if (!(trips <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK)) return Infinity; + let nested = 0; + const visit = (s: IrStmt): boolean => { + if (s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf") { + nested += boundedLoopRuns(emitter, s); + return nested !== Infinity; + } + return everyStmtChild(s, () => true, visit); + }; + if (!loop.body.every(visit)) return Infinity; + return trips * (1 + nested); +} + /** The counter of a strip-mined loop: its slot, the LLVM integer type and * signedness of its values, its step, the counters stepped beside it, and * the limit, emitted in the outer header before each block. */ @@ -2645,13 +2722,20 @@ interface StripMinedCounter { * tests the counter against the loop's limit, each block runs the body up * to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll * follows the block. It polls as often as the budget it replaces, and the - * inner loop's only exit besides `break` and `return` is its bound. */ + * inner loop's only exit besides `break` and `return` is its bound. + * + * With `runSlot` (for a loop that another loop of the function encloses), a + * block that ends at the limit leaves the loop without polling, and a run + * of at most WORKER_UNPOLLED_RUN iterations is one block, polled once as + * it leaves when it took more than WORKER_POLL_BLOCK iterations. `runSlot` + * holds whether the current block is such a run. */ function emitStripMinedCountedLoop( emitter: LlEmitter, s: Extract, counter: StripMinedCounter, boundSlot: string, exit: string, + runSlot: string | null, ): void { const B = emitter.B; const { slot, type, signed, step } = counter; @@ -2684,7 +2768,27 @@ function emitStripMinedCountedLoop( `${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * WORKER_POLL_BLOCK})`, ); B.line(`${bound} = call ${type} @${nearer}(${type} ${stepped}, ${type} ${limit})`); - B.line(`store ${type} ${bound}, ptr ${boundSlot}`); + if (runSlot === null) B.line(`store ${type} ${bound}, ptr ${boundSlot}`); + else { + // The steps left to the limit: positive, as the header tested. + const left = B.tmp(), + whole = B.tmp(), + long = B.tmp(), + polled = B.tmp(), + end = B.tmp(); + const stride = Math.abs(step); + B.line( + ascending + ? `${left} = sub ${wrap} ${type} ${limit}, ${start}` + : `${left} = sub ${wrap} ${type} ${start}, ${limit}`, + ); + B.line(`${whole} = icmp ule ${type} ${left}, ${stride * WORKER_UNPOLLED_RUN}`); + B.line(`${long} = icmp ugt ${type} ${left}, ${stride * WORKER_POLL_BLOCK}`); + B.line(`${polled} = and i1 ${whole}, ${long}`); + B.line(`${end} = select i1 ${whole}, ${type} ${limit}, ${type} ${bound}`); + B.line(`store ${type} ${end}, ptr ${boundSlot}`); + B.line(`store i1 ${polled}, ptr ${runSlot}`); + } B.br(bc); B.startBlock(bc); const index = B.tmp(); @@ -2693,7 +2797,8 @@ function emitStripMinedCountedLoop( B.line(`${index} = load ${type}, ptr ${slot}`); B.line(`${end} = load ${type}, ptr ${boundSlot}`); B.line(`${inBlock} = icmp ${compare} ${type} ${index}, ${end}`); - B.condBr(inBlock, lb, poll); + const after = runSlot === null ? poll : B.newLabel("loop.end"); + B.condBr(inBlock, lb, after); B.startBlock(lb); emitter.jumpTargets.push({ kind: "loop", @@ -2716,11 +2821,45 @@ function emitStripMinedCountedLoop( B.line(`store ${type} ${next}, ptr ${stepSlot}`); } B.br(bc); + if (runSlot !== null) { + // A speculative copy may enter the body without passing the header, so + // the limit is read again here. + B.startBlock(after); + const at = B.tmp(); + const more = B.tmp(); + const run = B.tmp(); + const done = B.newLabel("loop.done"); + const last = B.newLabel("loop.poll.run"); + B.line(`${at} = load ${type}, ptr ${slot}`); + B.line(`${more} = icmp ${compare} ${type} ${at}, ${counter.limit()}`); + B.condBr(more, poll, done); + B.startBlock(done); + B.line(`${run} = load i1, ptr ${runSlot}`); + B.condBr(run, last, exit); + B.startBlock(last); + emitter.emitWorkerPoll(); + B.br(exit); + } B.startBlock(poll); emitter.emitWorkerPoll(); B.br(lc); } +/** Whether `body` holds a loop outside its closures. */ +function containsLoop(body: IrStmt[]): boolean { + const inStmt = (s: IrStmt): boolean => + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "for" || + s.kind === "forOf" || + !everyStmtChild( + s, + () => true, + (child) => !inStmt(child), + ); + return body.some(inStmt); +} + function enterLoopHeader(emitter: LlEmitter, header: string): void { const dispatch = emitter.speculationHeader; emitter.speculationHeader = null; diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 876476cc1..880685bf2 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -211,6 +211,19 @@ test("a target CPU level reaches every function attribute group and nothing else expect(emitLlvmModule(module, { targetCpu: "baseline" })).toBe(plain); }); +test("only sanitized builds mark the emitted functions sanitize_address", () => { + const module = moduleFor([call()]); + const sanitized = emitLlvmModule(module); + expect(emitLlvmModule(module, { sanitizeAttributes: true })).toBe(sanitized); + expect(sanitized).toMatch(/^attributes #0 = \{ sanitize_address \}$/m); + const plain = emitLlvmModule(module, { sanitizeAttributes: false }); + expect(plain).not.toContain("sanitize_address"); + // A group needs an attribute: an ignored string attribute stands in. + expect(plain).toMatch(/^attributes #0 = \{ "scriptc-function" \}$/m); + const v3 = emitLlvmModule(module, { sanitizeAttributes: false, targetCpu: "x86-64-v3" }); + expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m); +}); + test("inline RC inlines every fast path at speed and keeps box releases as calls in release", () => { for (const boxed of [false, true]) { const module = moduleFor(Array.from({ length: 4 }, call), { boxed }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 56411120a..bac841f3a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -511,6 +511,12 @@ export interface LlvmTargetOptions { inlineRc?: InlineRcMode; /** Define the inline RC fast-path helpers `noinline` (sanitized builds). */ rcHelpersNoinline?: boolean; + /** Mark every emitted function `sanitize_address`, which the sanitized + * lane's `-fsanitize=address` link needs to instrument it. Without a + * sanitizer pass the attribute still turns off shrink-wrapping and + * speculative loads, so plain builds pass false. Defaults to true for + * direct emission. */ + sanitizeAttributes?: boolean; /** Define `scr_detached_exit` so the Linux runtime hands the address-space * teardown after `process.exit` to a helper process (`--detached-exit`). * Executables only; the runtime's weak default keeps it off. */ @@ -1273,6 +1279,8 @@ export class LlEmitter { readonly detachedExit: boolean; readonly nodeIslandModuleData: Uint8Array | null; readonly targetCpu: TargetCpu | undefined; + /** Emitted functions carry `sanitize_address` (LlvmTargetOptions). */ + readonly sanitizeAttributes: boolean; readonly wasmBoundsChecks: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the runtime ABI’s determinism discipline. */ @@ -1976,6 +1984,7 @@ export class LlEmitter { this.detachedExit = options.detachedExit === true; this.nodeIslandModuleData = options.nodeIslandModuleData ?? null; this.targetCpu = options.targetCpu; + this.sanitizeAttributes = options.sanitizeAttributes !== false; this.wasmBoundsChecks = options.wasmBoundsChecks === true || wasmBoundsChecks({ diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 2f7c31813..5ffa54eb5 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -2,11 +2,12 @@ import { emitBorrowedInput } from "./borrowed-inputs.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { isRefCounted, isUnitType, typeEquals, type IrExpr } from "../../ir/ir.js"; +import { isRefCounted, isUnitType, typeEquals, type IrExpr, type IrType } from "../../ir/ir.js"; import { DYN_KIND } from "./dyn.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; +import { emitRecordKeyGetOr } from "./expr-records.js"; import { NULLABLE_NULL, SHAPE_WORD_INDEX, shapeWordHeader } from "./nullable-unions.js"; import { emitUnionTag } from "./union-repr.js"; @@ -397,6 +398,13 @@ export function emitControlExpr( throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms"); const fused = emitNullishOptionalChain(host, e); if (fused) return fused; + if ( + e.left.kind === "recordKeyGet" && + e.left.missNaN !== true && + typeEquals(e.type, e.left.type) && + storesNoUnit(host, e.left) + ) + return emitRecordKeyGetOr(host, e.left, e.right); if ( !typeEquals(e.type, e.left.type) && (e.left.kind === "varRef" || @@ -700,3 +708,15 @@ function emitUnionTagSet(host: LlvmEmitterContext, set: TagSet): string { B.line(`${t} = phi i1 [ true, %${yes} ], [ false, %${no} ]`); return t; } + +/** Every value a keyed read can hit is present (no stored undefined or + * null), so only a missing key yields the read's unit arm. */ +function storesNoUnit(host: LlvmEmitterContext, e: ExprOf<"recordKeyGet">): boolean { + const shape = host.recordShape(e.shapeId); + const holdsUnit = (t: IrType): boolean => + isUnitType(t) || + t.kind === "dyn" || + (t.kind === "union" && (host.unionsById.get(t.unionId)?.arms ?? []).some(isUnitType)); + if (shape.indexValue !== undefined && holdsUnit(shape.indexValue)) return false; + return e.overflowOnly === true || !shape.fields.some((f) => holdsUnit(f.type)); +} diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 51688eafa..161d1eccc 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -379,6 +379,39 @@ export function emitRegexIntrinsic( } } +/** `r[k] ?? d` in its pass-through shape: a missing key evaluates `d` + * where the read would store undefined, so a hit costs nothing extra. */ +export function emitRecordKeyGetOr( + host: LlvmEmitterContext, + e: IrExpr & { kind: "recordKeyGet" }, + fallback: IrExpr, +): LlValue { + const B = host.B; + const obj = host.emitExpr(e.obj); + const k = host.emitExpr(e.key); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const join = B.newLabel("rkg.j"); + keyedRecordReadInto( + host, + slot, + join, + obj.name, + k.name, + e.shapeId, + e.type, + e.overflowOnly === true, + e.loc, + false, + () => host.emitBranchInto(slot, fallback), + ); + B.startBlock(join); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); +} + export function emitRecordKeyGet( host: LlvmEmitterContext, e: IrExpr & { kind: "recordKeyGet" }, @@ -526,6 +559,7 @@ export function keyedRecordReadInto( overflowOnly: boolean, loc?: SrcLoc, missNaN = false, + missInto: (() => void) | null = null, ): void { const B = host.B; const shape = host.recordShape(shapeId); @@ -728,8 +762,10 @@ export function keyedRecordReadInto( } } if (def !== null && resultType.kind === "union" && undefTag >= 0) { - // The miss path: the result union's undefined arm. - B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`); + // The miss path: the result union's undefined arm, or the caller's + // own value for a miss (missInto stores it into the slot). + if (missInto !== null) missInto(); + else B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`); B.br(join); return; } diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index b80c5151d..e300228a6 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -1151,6 +1151,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "punycode": case "constants": case "promise": + case "mime": return host.emitGenericLibCall(e); case "nodeIsland": return host.emitGenericLibCall(e); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 49f88c125..4e2d0c60c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -86,6 +86,17 @@ export const LIB_FN_SYMS: Record = { "util.inherits": "scr_util_inherits", "util.inheritsPrototype": "scr_util_inherits_prototype", "util.deprecate": "scr_util_deprecate", + "util.isArrayDeprecated": "scr_util_is_array_deprecated", + "util.extend": "scr_util_extend", + "util.signalExitCode": "scr_util_signal_exit_code", + "util.aborted": "scr_util_aborted", + "util.diff": "scr_util_diff", + "util.debounce": "scr_util_debounce", + "util.throttle": "scr_util_throttle", + "mime.typeNew": "scr_mime_type_new", + "mime.typeParse": "scr_mime_type_parse", + "mime.paramsNew": "scr_mime_params_new", + "mime.is": "scr_mime_is", "util.callbackify": "scr_util_callbackify", "util.promisifyDyn": "scr_util_promisify", "util.debuglog": "scr_util_debuglog", @@ -1084,6 +1095,26 @@ export const LIB_FN_SYMS: Record = { "dyn.generatorThis": "scr_gen_receiver", "insp.dyn": "scr_insp_dyn", "insp.dynS": "scr_insp_dyn_s", + "insp.format": "scr_insp_format_pack", + "insp.formatPrimitives": "scr_insp_format_primitives", + "insp.formatList": "scr_insp_format_list", + "insp.formatWithOptions": "scr_insp_format_with_options", + "insp.inspect": "scr_insp_inspect", + "insp.customEnabled": "scr_insp_custom_enabled", + "insp.registerCustom": "scr_insp_register_custom", + "insp.customInspect": "scr_insp_custom_inspect", + "insp.depthKind": "scr_insp_depth_kind", + "insp.customDepth": "scr_insp_custom_depth", + "insp.customOptions": "scr_insp_custom_options", + "insp.inspectFunction": "scr_insp_inspect_function", + "insp.customText": "scr_insp_custom_text", + "insp.customIsSelf": "scr_insp_custom_is_self", + "insp.customResult": "scr_insp_custom_result", + "insp.customCallBegin": "scr_insp_custom_call_begin", + "insp.customCallEnd": "scr_insp_custom_call_end", + "insp.formatSpecifies": "scr_insp_format_specifies", + "insp.formatSpecifiesDyn": "scr_insp_format_specifies_dyn", + "insp.dynFormatS": "scr_insp_dyn_format_s", "insp.dynHidden": "scr_insp_dyn_hidden", "fs.readFileSyncDyn": "scr_fs_read_file_sync_dyn", "fs.readFileEncoded": "scr_fs_read_file_encoded", @@ -1744,9 +1775,14 @@ export const USES_TIMERS_LIB_FNS = new Set([ "buffer.deprecation", // The wrappers schedule warnings and callbacks on the tick queue. "util.deprecate", + "util.isArrayDeprecated", + "util.extend", "util.callbackify", "util.promisifyDyn", "util.debuglog", + // The wrapped calls arm timers and settle promises. + "util.debounce", + "util.throttle", "worker.new", "worker.parentPort", "worker.channel", diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts index 7457db42d..f7bd87af1 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts @@ -168,8 +168,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide expect(hashed).toContain("select i1 %nan, i64 9221120237041090560, i64 %kzb"); expect(hashed).toContain("mul i64 %x1, -4658895280553007687"); expect(hashed).toContain("mul i64 %x3, -7723592293110705685"); - // Filter (field 10), small-map scan of the entry count (field 8), and - // compact buckets (fields 13 and 14) tagged with the hash's top byte. + // Filter (field 10) before the small-map scan of the entry count (field + // 8) only, and compact buckets (fields 13 and 14) tagged with the hash's + // top byte. + expect(hashed).toContain("br i1 %linear, label %filter, label %buckets"); expect(hashed).toContain("getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10"); expect(hashed).toContain(`load i32, ptr %nep`); expect(hashed).toContain(`load i32, ptr %nbp`); @@ -192,11 +194,23 @@ test("a key with an integer view tests the direct index on the integer", () => { integral.module, )![0]; // An unsigned compare also rejects negative keys; the hashed probe takes - // the double. + // the integer. expect(helper).toContain("icmp ult i64 %ki, %nd"); expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`); - expect(helper).toContain("call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)"); + expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)"); expect(helper).not.toContain("fptosi"); + // Its key word is the integer's double, which needs no NaN or -0 + // normalization; the runtime lookup gets that double. + const hashed = + /^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki\) noinline [^]*?^}/m.exec( + integral.module, + )![0]; + expect(hashed).toContain("sitofp i64 %ki to double"); + expect(hashed).not.toContain("fcmp"); + expect(hashed).toContain("mul i64 %x1, -4658895280553007687"); + expect(hashed).toContain( + "call ptr @scr_map_entry_f64(ptr %m, double %kf) memory(read) nounwind willreturn", + ); // A fractional key and a key without a proof keep the double lookup. for (const key of [{ kind: "numLit", value: 0.5, type: F64, loc } as IrExpr, ref("k", F64)]) { const { work } = emitted(lookupFixture(key), bits); diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts index afc8ec606..17a1563c3 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -4,9 +4,10 @@ * direct index in the runtime (scr_map.c). Reads go through the internal * sc_map_entry_f64 helper (emitter.ts helperDefs), which LLVM inlines: it * resolves directly indexed maps without a call and other maps through - * the internal sc_map_entry_f64_hashed probe, which hashes the key as the - * runtime does and defers to scr_map_entry_f64 only for wide bucket - * tables. Both only read memory. The generated code then reads the entry's + * the internal sc_map_entry_f64_hashed probe (sc_map_entry_f64_hashed_int + * for a key with an integer view), which hashes the key as the runtime + * does and defers to scr_map_entry_f64 only for wide bucket tables. Both + * only read memory. The generated code then reads the entry's * value slot itself and retains a reference value with the value type's own * retain, which is the adapter the collection was constructed with. * String-keyed reads use the same entry contract through the internal diff --git a/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts index 6d2457c57..1d35caa05 100644 --- a/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts +++ b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts @@ -44,6 +44,7 @@ test("sort-heavy programs emit equal LLVM from memory and from serialized IR", a // compile() emits Linux textual LLVM with the unwinding ABI. unwindAbi: process.platform === "linux", objectAudit: sanitize, + sanitizeAttributes: sanitize, ...inlineRcOption(options.optimization, sanitize), }); const text = typeof emitted === "string" ? emitted : emitted.join(""); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 7498235b0..6d03db222 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -216,8 +216,9 @@ export interface RegionHooks { classDefinitions(cls: IrClassDef, sizeOf: string): string[]; } -/** Every emitted function/helper carries #0 = { sanitize_address } — see - * the emitter header. */ +/** Every emitted function/helper carries attribute group #0: the build's + * target CPU level, and `sanitize_address` in sanitized builds (see + * emit-module.ts functionAttributes). */ export const FN_ATTRS = "#0"; export { computeTraced } from "../cycle-analysis.js"; diff --git a/packages/compiler/src/backend/loop-polls.ts b/packages/compiler/src/backend/loop-polls.ts index c582a4974..866711fb7 100644 --- a/packages/compiler/src/backend/loop-polls.ts +++ b/packages/compiler/src/backend/loop-polls.ts @@ -110,8 +110,8 @@ export function loopTestsEveryIteration(loop: Loop, tested: (call: IrExpr) => bo /** True when the body of `loop`, outside its nested loops and closures, * reads or writes a typed-array element: the loops whose cancellation poll * the emitter moves between blocks of iterations, so that LLVM can - * vectorize them. Other loops keep the budget in their body, which costs - * less in a loop of a few iterations. */ + * vectorize them. Outside another loop, other loops keep the budget in + * their body, which costs less in a loop of a few iterations. */ export function loopAccessesTypedArrays(loop: Loop): boolean { const inExpr = (e: IrExpr): boolean => { if (e.kind === "bytesIntrinsic" && e.method === "get") return true; diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 56109b022..846a1f508 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -64,6 +64,10 @@ export interface NativeLinkFeatures { /** events.once with options: scr_events_once_signal.c. Absent (not * false) for programs without it. */ eventsOnceSignal?: boolean; + /** util.MIMEType and util.MIMEParams: scr_mime.c. */ + mime?: boolean; + /** util.debounce and util.throttle: scr_util_timing.c. */ + utilTiming?: boolean; /** Buffer.prototype methods called on checked-dynamic values link * scr_bytes_dyn.c; absent (false) for programs without them. */ dynBuffer?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 7e0d31625..11debe002 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -131,6 +131,10 @@ export interface CcOptions { /** events.once with options (events.onceSignal): compiles * scr_events_once_signal.c. */ eventsOnceSignal?: boolean; + /** util.MIMEType and util.MIMEParams: compiles scr_mime.c. */ + mime?: boolean; + /** util.debounce and util.throttle: compiles scr_util_timing.c. */ + utilTiming?: boolean; /** Buffer.prototype methods called on checked-dynamic values: * compiles scr_bytes_dyn.c. */ dynBuffer?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 2793f72fd..eff8c66fb 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -249,6 +249,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ // events.once with options registers through the emitter and // event-target units. unit((u) => u.opts.eventsOnceSignal === true, "scr_events_once_signal.c"), + unit((u) => u.opts.mime === true, "scr_mime.c"), + unit((u) => u.opts.utilTiming === true, "scr_util_timing.c"), // The checked-dynamic HANDLE support unit (listener gate + runtime // adapter closures): every referencing unit is one of the emitter or // net families (http implies net), so handle-free binaries keep diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 6fae3b040..6590f6491 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -89,6 +89,8 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { declAdopt: boolean; dynForAwait: boolean; eventsOnceSignal: boolean; + mime: boolean; + utilTiming: boolean; dynBuffer: boolean; builtinValues: boolean; warningReplace: boolean; @@ -290,6 +292,8 @@ export function effectiveRuntimeFeatures( declAdopt: features.declAdopt === true, dynForAwait: features.dynForAwait === true, eventsOnceSignal: features.eventsOnceSignal === true, + mime: features.mime === true, + utilTiming: features.utilTiming === true, dynBuffer: features.dynBuffer === true, builtinValues: features.builtinValues === true, warningReplace: features.warningReplace === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 7f4727809..6509a071e 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -77,6 +77,8 @@ export interface EarlyExecutableNativeFeatures { declAdopt?: boolean; dynForAwait?: boolean; eventsOnceSignal?: boolean; + mime?: boolean; + utilTiming?: boolean; dynBuffer?: boolean; builtinValues?: boolean; events: boolean; diff --git a/packages/compiler/src/frontend/decl-projection.ts b/packages/compiler/src/frontend/decl-projection.ts index 02a300996..a999559c2 100644 --- a/packages/compiler/src/frontend/decl-projection.ts +++ b/packages/compiler/src/frontend/decl-projection.ts @@ -519,6 +519,41 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection | return undefined; } }; + const stringIndex = (checker: ts.TypeChecker, type: ts.Type): boolean => { + try { + return checker + .getIndexInfosOfType(type) + .some((info) => (info.keyType.flags & ts.TypeFlags.String) !== 0); + } catch { + return false; + } + }; + /** Whether the JavaScript world types a package value as an object + * literal that lacks a member its declaration promises: a property (an + * expando `out.max = ...` on a non-empty literal, which the JavaScript + * checker does not add to the literal's type) or a string index + * signature (`Record` over a literal). Lowering the value + * at the narrower literal type would drop the member or fix the key set, + * so it crosses at its declared type instead. */ + const missesDeclaredMembers = (jsType: ts.Type, declType: ts.Type): boolean => { + if (!(jsType.flags & ts.TypeFlags.Object) || !(declType.flags & ts.TypeFlags.Object)) + return false; + const literal = jsType.getSymbol(); + if (literal === undefined || (literal.flags & ts.SymbolFlags.ObjectLiteral) === 0) return false; + try { + if (declChecker.isArrayType(declType) || declChecker.isTupleType(declType)) return false; + if (stringIndex(declChecker, declType) && !stringIndex(jsChecker, jsType)) return true; + return declChecker + .getPropertiesOfType(declType) + .some( + (prop) => + !String(prop.escapedName).startsWith("__@") && + jsChecker.getPropertyOfType(jsType, String(prop.escapedName)) === undefined, + ); + } catch { + return false; + } + }; const namesPackageSymbol = (node: ts.Node): boolean => { let found = false; const visit = (n: ts.Node): void => { @@ -540,7 +575,7 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection | const fileEdits: Edit[] = []; /** Candidate expressions (by js node) with their declared type, named * only for the casts that survive suppression. */ - const exprCandidates = new Map(); + const exprCandidates = new Map(); const exprOrder: ts.Node[] = []; /** Reads whose projectable receiver the JavaScript world types `any`: * kept only when nothing types the receiver. */ @@ -610,7 +645,14 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection | return; } const jsType = typeAt(jsChecker, jsNode); - if (!isAnyish(jsType)) return; + if (jsType === null) return; + // A typed JavaScript value crosses only where its object type lacks a + // member the declaration promises (missesDeclaredMembers). + const typedJs = !isAnyish(jsType); + if (typedJs) { + const declType = typeAt(declChecker, declNode); + if (declType === null || !missesDeclaredMembers(jsType, declType)) return; + } // Package-produced? const d = declNode as ts.Expression; let produced = false; @@ -686,7 +728,18 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection | // no contextual type } const declType = typeAt(declChecker, d); - if (declType === null || !printer.projectable(declType)) return; + if (declType === null) return; + if (!printer.projectable(declType)) { + // An index signature over values that do not project (package + // interfaces): the literal's fixed key set would still answer for + // keys it lacks, so the value stays untyped and every read is the + // JavaScript read. + if (typedJs && stringIndex(declChecker, declType)) { + exprCandidates.set(jsNode, "any"); + exprOrder.push(jsNode); + } + return; + } exprCandidates.set(jsNode, declType); exprOrder.push(jsNode); }; @@ -890,7 +943,8 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection | ) continue; } - const alias = printer.project(exprCandidates.get(e)!); + const candidate = exprCandidates.get(e)!; + const alias = candidate === "any" ? "any" : printer.project(candidate); if (alias === null) continue; const start = ts.tokenStartOfNode(e); const stmt = e.parent; diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index c1cdac334..2528554ff 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -1,3 +1,4 @@ +import { widensReducedRecords } from "../function-returns.js"; import { keyedHelperName } from "../stable-names.js"; import { isDateJsonSource } from "./dates.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; @@ -96,7 +97,14 @@ export function lowerJsonMethodCall( !isDateJsonSource(lowerer, argNode) ) { const narrowed = lowerer.mapTypeOf(lowerer.typeOf(argNode)); - if (narrowed && !isUnitType(narrowed) && narrowed.kind !== "void") { + // A union of record types the checker's subtype reduction spelled as + // one of them serializes each value by its own layout. + if ( + narrowed && + !isUnitType(narrowed) && + narrowed.kind !== "void" && + !widensReducedRecords(lowerer, narrowed, value.type) + ) { const helper = narrowed.kind === "union" ? lowerer.narrowedRetagHelper(argNode, value.type.unionId, narrowed.unionId, loc) diff --git a/packages/compiler/src/frontend/lowering/builtins/mime.ts b/packages/compiler/src/frontend/lowering/builtins/mime.ts new file mode 100644 index 000000000..4c36d6cc5 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/mime.ts @@ -0,0 +1,180 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { locOf } from "../../program.js"; +import { dynUndefinedExpr, numLit } from "../../../ir/build.js"; +import { BOOL, DYN, type IrExpr, type IrLibFn } from "../../../ir/ir.js"; +import { builtinImportOf } from "./module-bindings.js"; + +/* node:util's MIMEType and MIMEParams are native checked-dynamic handles + * (scr_mime.c): both types map to dyn, their getters and setters are + * handle property reads and writes, their methods dispatch through the + * handle, and these helpers lower the uses a dyn value cannot answer by + * itself (construction, MIMEType.parse and instanceof). */ + +type MimeClass = "MIMEType" | "MIMEParams"; + +/** The MIMEType or MIMEParams class in any node:util spelling. */ +export function mimeClassOf(lowerer: Lowerer, node: ts.Expression): MimeClass | null { + const builtin = ts.isIdentifier(node) + ? builtinImportOf(lowerer, node) + : ts.isPropertyAccessExpression(node) && !node.questionDotToken + ? lowerer.builtinMemberOf(node) + : null; + if (builtin?.module !== "util") return null; + return builtin.member === "MIMEType" || builtin.member === "MIMEParams" ? builtin.member : null; +} + +/** The class of a stdlib MIMEType- or MIMEParams-typed value. */ +function mimeValueClass(lowerer: Lowerer, node: ts.Expression): MimeClass | null { + const type = lowerer.typeOf(node); + const sym = type.getAliasSymbol() ?? type.getSymbol(); + if (!sym) return null; + const name = ts.symbolName(sym); + if (name !== "MIMEType" && name !== "MIMEParams") return null; + return lowerer.isStdlibSymbol(sym) ? name : null; +} + +/** The call of `fn` over the first `count` arguments, as checked-dynamic + * values (missing ones as undefined). Every argument evaluates in order + * before the call, as in JavaScript: with surplus arguments, the used ones + * are held in hidden locals while the others evaluate. */ +function mimeCall( + lowerer: Lowerer, + fn: IrLibFn, + nodes: readonly ts.Expression[], + count: number, + loc: ReturnType, +): IrExpr { + const values = nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); + if (values.length <= count) { + const args = [...values]; + while (args.length < count) args.push(dynUndefinedExpr(loc)); + return { kind: "libCall", fn, args, type: DYN, loc }; + } + const locals = values.slice(0, count).map((value) => ({ + value, + local: lowerer.declareHiddenLocal("%mimeArg", DYN), + })); + return { + kind: "seqExpr", + stmts: [ + ...locals.map(({ value, local }) => ({ + kind: "varDecl" as const, + localId: local.id, + init: value, + loc, + })), + ...values.slice(count).map((arg) => ({ kind: "exprStmt" as const, expr: arg, loc: arg.loc })), + ], + result: { + kind: "libCall", + fn, + args: locals.map(({ local }): IrExpr => ({ + kind: "varRef", + localId: local.id, + type: DYN, + loc, + })), + type: DYN, + loc, + }, + type: DYN, + loc, + }; +} + +/** `new MIMEType(string[, noThrow])` and `new MIMEParams()`. */ +export function lowerMimeNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + const cls = mimeClassOf(lowerer, expr.expression); + if (cls === null) return null; + const nodes = expr.arguments?.nodes ?? []; + if (nodes.some(ts.isSpreadElement)) return null; + const loc = locOf(expr); + const fn: IrLibFn = cls === "MIMEType" ? "mime.typeNew" : "mime.paramsNew"; + return mimeCall(lowerer, fn, nodes, cls === "MIMEType" ? 2 : 0, loc); +} + +const INSTANCE_METHODS: Record> = { + MIMEType: new Set(["toString", "toJSON"]), + MIMEParams: new Set([ + "get", + "has", + "set", + "delete", + "entries", + "keys", + "values", + "toString", + "toJSON", + ]), +}; + +/** MIMEType.parse(), and the instance methods, which the handle answers + * as methods, so they dispatch through dynInvoke rather than as a member + * read and a call. */ +export function lowerMimeCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + if (call.questionDotToken || !ts.isPropertyAccessExpression(call.expression)) return null; + const access = call.expression; + if (access.questionDotToken || call.arguments.nodes.some(ts.isSpreadElement)) return null; + const name = access.name.text; + const loc = locOf(call); + if (name === "parse" && mimeClassOf(lowerer, access.expression) === "MIMEType") { + return mimeCall(lowerer, "mime.typeParse", call.arguments.nodes, 1, loc); + } + const cls = mimeValueClass(lowerer, access.expression); + if (cls === null || !INSTANCE_METHODS[cls].has(name)) return null; + return { + kind: "dynInvoke", + recv: lowerer.lowerExprExpecting(access.expression, DYN), + method: name, + calleeName: ts.getTextOfNode(access), + args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; +} + +/** Reports SC2020 when `access` reads a static member of MIMEType or + * MIMEParams (`MIMEType.prototype`, `MIMEType.name`); null otherwise. + * JavaScript sources hold either class as an identity token, which has no + * members, so the read would answer undefined. MIMEType.parse calls lower + * in lowerMimeCall and never reach the member read. */ +export function fenceMimeStaticRead(lowerer: Lowerer, access: ts.PropertyAccessExpression): null { + const cls = mimeClassOf(lowerer, access.expression); + if (cls !== null) lowerer.noLowering(`util.${cls}.${access.name.text}`, access); + return null; +} + +/** `v instanceof MIMEType` / `v instanceof MIMEParams`. */ +export function lowerMimeInstanceof(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { + const cls = mimeClassOf(lowerer, expr.right); + if (cls === null) return null; + const loc = locOf(expr); + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { + kind: "libCall", + fn: "mime.is", + args: [value, numLit(cls === "MIMEType" ? 0 : 1, loc)], + type: BOOL, + loc, + }; +} + +/** `params`, `params.keys()`, `params.values()` or `params.entries()` over a + * MIMEParams: its checked-dynamic view iterates the live entry list, like + * URLSearchParams (isSearchParamsIterable). */ +export function isMimeParamsIterable(lowerer: Lowerer, node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (mimeValueClass(lowerer, node) === "MIMEParams") return true; + if ( + !ts.isCallExpression(node) || + node.arguments.nodes.length !== 0 || + !ts.isPropertyAccessExpression(node.expression) + ) + return false; + const name = node.expression.name.text; + return ( + (name === "keys" || name === "values" || name === "entries") && + mimeValueClass(lowerer, node.expression.expression) === "MIMEParams" + ); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index 2aaa2f80f..b21c964f9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -29,11 +29,13 @@ import { checkFsUrlPaths, isFsPathParam, lowerFallibleFsUrlCall, lowerFsPath } f import { lowerBufferEncodingCall } from "./text-codecs.js"; import { lowerUtilTypeCall } from "./util-types.js"; import { InternalCompilerError } from "../../../errors.js"; +import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { type BuiltinModuleFn } from "../surfaces.js"; import { type ParamShape } from "../call-signatures.js"; -import { BYTES_U8, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js"; +import { BYTES_U8, DYN, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js"; +import { typeKey } from "../../type-mapper.js"; import { lowerProcessLoadEnvFile } from "./environment.js"; import { lowerFsWatchCall, lowerFsReaddirTypesCall, lowerFsStatOptionsCall } from "./filesystem.js"; import { lowerZlibModuleCall } from "./compression.js"; @@ -386,7 +388,65 @@ export function lowerBuiltinModuleCall( const lowered = lowerFsReadPathCall(lowerer, expr, loc); if (lowered) return lowered; } - const required = fn.params.length - (fn.defaults?.length ?? 0); + // util._extend over a typed target: the writes go through its + // checked-dynamic view, and the call answers the target itself, as Node + // does, when the result type is the target's. + if ( + bi.module === "util" && + bi.member === "_extend" && + expr.arguments.nodes.length >= 1 && + !expr.arguments.nodes.some(ts.isSpreadElement) + ) { + const target = lowerer.lowerExpr(expr.arguments.nodes[0]!); + const resultType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if ( + target.type.kind !== "dyn" && + resultType !== null && + typeKey(resultType) === typeKey(target.type) + ) { + const held = lowerer.declareHiddenLocal("%extendTarget", target.type); + const ref = varRef(held.id, target.type, loc); + const source = expr.arguments.nodes[1] + ? lowerer.lowerExprExpecting(expr.arguments.nodes[1], DYN) + : dynUndefinedExpr(loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: held.id, init: target, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "util.extend", + args: [lowerer.coerceToExpected(ref, DYN), source], + type: DYN, + loc, + }, + loc, + }, + ], + result: ref, + type: target.type, + loc, + }; + } + } + // Trailing checked-dynamic parameters whose value adapter defaults to + // undefined may be omitted: the call passes undefined, as Node sees it. + let undefinedTail = 0; + if (!fn.defaults && fn.valueParams) { + for (let i = fn.valueParams.length - 1; i >= 0; i--) { + const param = fn.valueParams[i]!; + if ( + param.mode !== "optional" || + param.defaultValue !== undefined || + fn.params[i]?.kind !== "dyn" + ) + break; + undefinedTail++; + } + } + const required = fn.params.length - (fn.defaults?.length ?? undefinedTail); const hasSpread = expr.arguments.nodes.some(ts.isSpreadElement); if ( bi.module === "url" && @@ -466,12 +526,9 @@ export function lowerBuiltinModuleCall( : { type, mode: "omittable", - callDefault: { - kind: "strLit", - value: fn.defaults![i - required]!, - type: STRING, - loc, - }, + callDefault: fn.defaults + ? { kind: "strLit", value: fn.defaults[i - required]!, type: STRING, loc } + : dynUndefinedExpr(loc), }, ); args = lowerer.completeArgs(expr.arguments.nodes, shapes, loc, expr); @@ -482,7 +539,11 @@ export function lowerBuiltinModuleCall( : lowerer.lowerExprExpecting(a, fn.params[i]), ); for (let i = args.length; i < fn.params.length; i++) { - const dflt = fn.defaults![i - required]!; + if (!fn.defaults) { + args.push(dynUndefinedExpr(loc)); + continue; + } + const dflt = fn.defaults[i - required]!; args.push({ kind: "strLit", value: dflt, type: STRING, loc }); } } diff --git a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts index b4dbbf513..d3378c8ec 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts @@ -1,7 +1,8 @@ import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; -import { varRef } from "../../../ir/build.js"; +import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; +import { isJsSourceFile } from "../../program.js"; import { BOOL, DYN, @@ -92,12 +93,22 @@ function callbackShape( return { fnT, resultT, args, callbackT, callbackParamT: callbackParamT!, inner, error }; } -/** Whether `promisify(fn)`'s one argument is typed `any`: the function is - * only known at run time, so the promisified function is built there. */ +/** Whether `promisify(fn)`'s one argument is only known at run time, so + * the promisified function is built there: a value typed `any`, a function + * with untyped parameters (the declarations type that call `any`), or any + * argument in JavaScript, whose inferred types do not describe the + * function Node receives (it validates the argument and reads its own + * properties). */ export function untypedPromisifyArgument(lowerer: Lowerer, call: ts.CallExpression): boolean { - if (call.questionDotToken || call.arguments.nodes.length !== 1) return false; - const argNode = call.arguments.nodes[0]!; - return !ts.isSpreadElement(argNode) && (lowerer.typeOf(argNode).flags & ts.TypeFlags.Any) !== 0; + if (call.questionDotToken || call.arguments.nodes.length > 1) return false; + const argNode = call.arguments.nodes[0]; + if (argNode !== undefined && ts.isSpreadElement(argNode)) return false; + if (isJsSourceFile(ts.getSourceFileOfNode(call))) return true; + if (argNode === undefined) return false; + return ( + (lowerer.typeOf(argNode).flags & ts.TypeFlags.Any) !== 0 || + (lowerer.typeOf(call).flags & ts.TypeFlags.Any) !== 0 + ); } /** The callback shape of a `promisify(fn)` call, or null. */ @@ -144,10 +155,11 @@ export function lowerCallbackPromisify( if (shape === null) { // An untyped (`any`) function: Node's promisify at run time. if (!untypedPromisifyArgument(lowerer, call)) return null; + const argNode = call.arguments.nodes[0]; return { kind: "libCall", fn: "util.promisifyDyn", - args: [lowerer.lowerExprExpecting(call.arguments.nodes[0]!, DYN)], + args: [argNode ? lowerer.lowerExprExpecting(argNode, DYN) : dynUndefinedExpr(loc)], type: DYN, loc, }; diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index 79e891d4f..797bb1775 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -57,6 +57,95 @@ function emptyTupleSpread(lowerer: Lowerer, node: ts.Expression): boolean { return restTupleElementTypes(lowerer.typeOf(operand), lowerer.typeCtx)?.length === 0; } +/** A call's arguments as one checked-dynamic array, built in source order + * into a fresh hidden local: each argument converts to dyn, and a spread + * contributes the values its iteration yields (holes as undefined). + * `lowerSpread` lowers a spread's operand; the default lowers it here. */ +export function dynArgumentPack( + lowerer: Lowerer, + argNodes: readonly ts.Expression[], + loc: SrcLoc, + options: { + /** Pre-lowered values pushed before the arguments. */ + leading?: readonly IrExpr[] | undefined; + blame?: ts.Node | undefined; + lowerSpread?: (arg: ts.SpreadElement) => IrExpr; + } = {}, +): { stmts: IrStmt[]; pack: () => IrExpr } { + const blame = options.blame ?? argNodes[0]!; + const lowerSpread = + options.lowerSpread ?? ((arg: ts.SpreadElement) => lowerer.lowerExpr(arg.expression)); + const pack = lowerer.declareHiddenLocal("%spreadArgs", DYN); + const ref = (): IrExpr => ({ kind: "varRef", localId: pack.id, type: DYN, loc }); + const stmts: IrStmt[] = [ + { + kind: "varDecl", + localId: pack.id, + init: { kind: "dynArrLit", elems: [], type: DYN, loc }, + loc, + }, + ]; + const push = (value: IrExpr, fn: "dyn.packPush" | "dyn.packPushSpreadIter") => { + stmts.push({ + kind: "exprStmt", + expr: { kind: "libCall", fn, args: [ref(), value], type: VOID, loc: value.loc }, + loc: value.loc, + }); + }; + for (const value of options.leading ?? []) + push(lowerer.coerceInto(blame, value, DYN), "dyn.packPush"); + const spreads = argNodes.filter(ts.isSpreadElement).length; + argNodes.forEach((arg, index) => { + if (!ts.isSpreadElement(arg)) { + push(lowerer.lowerExprExpecting(arg, DYN), "dyn.packPush"); + return; + } + let value = lowerSpread(arg); + if (value.type.kind === "set") { + value = { + kind: "setIntrinsic", + method: "toArray", + receiver: value, + args: [], + type: arrayOf(value.type.elem), + loc: value.loc, + }; + } else if (value.type.kind === "object") { + value = lowerer.classIteratorDrainCall(value, locOf(arg)) ?? value; + } + if (value.type.kind === "array") { + // Argument iteration materializes holes as present undefined + // before conversion to the dense checked-dynamic argument pack. + value = { + kind: "arrayLit", + elems: [value], + spreads: [0], + type: value.type, + loc: value.loc, + }; + } + value = checkedIterableSpread(lowerer, value, ts.getTextOfNode(arg.expression), loc); + if (spreads === 1 && index === argNodes.length - 1) { + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.packPushSpread", + args: [ + ref(), + value, + { kind: "strLit", value: ts.getTextOfNode(arg.expression), type: STRING, loc }, + ], + type: VOID, + loc, + }, + loc, + }); + } else push(value, "dyn.packPushSpreadIter"); + }); + return { stmts, pack: ref }; +} + export function completeArgs( lowerer: Lowerer, argNodes: readonly ts.Expression[], @@ -97,72 +186,10 @@ export function completeArgs( // Runtime-length spreads determine the complete argument list before // any parameter default runs. Build it once in source order, then // extract the fixed native ABI slots; missing elements are undefined. - const pack = lowerer.declareHiddenLocal("%spreadArgs", DYN); - const ref = (): IrExpr => ({ kind: "varRef", localId: pack.id, type: DYN, loc }); - const stmts: IrStmt[] = [ - { - kind: "varDecl", - localId: pack.id, - init: { kind: "dynArrLit", elems: [], type: DYN, loc }, - loc, - }, - ]; - const push = (value: IrExpr, fn: "dyn.packPush" | "dyn.packPushSpreadIter") => { - stmts.push({ - kind: "exprStmt", - expr: { kind: "libCall", fn, args: [ref(), value], type: VOID, loc: value.loc }, - loc: value.loc, - }); - }; - for (const value of leading ?? []) push(lowerer.coerceInto(blame, value, DYN), "dyn.packPush"); - const spreads = argNodes.filter(ts.isSpreadElement).length; - argNodes.forEach((arg, index) => { - if (!ts.isSpreadElement(arg)) { - push(lowerer.lowerExprExpecting(arg, DYN), "dyn.packPush"); - return; - } - let value = lowerSpread(arg); - if (value.type.kind === "set") { - value = { - kind: "setIntrinsic", - method: "toArray", - receiver: value, - args: [], - type: arrayOf(value.type.elem), - loc: value.loc, - }; - } else if (value.type.kind === "object") { - value = lowerer.classIteratorDrainCall(value, locOf(arg)) ?? value; - } - if (value.type.kind === "array") { - // Argument iteration materializes holes as present undefined - // before conversion to the dense checked-dynamic argument pack. - value = { - kind: "arrayLit", - elems: [value], - spreads: [0], - type: value.type, - loc: value.loc, - }; - } - value = checkedIterableSpread(lowerer, value, ts.getTextOfNode(arg.expression), loc); - if (spreads === 1 && index === argNodes.length - 1) { - stmts.push({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.packPushSpread", - args: [ - ref(), - value, - { kind: "strLit", value: ts.getTextOfNode(arg.expression), type: STRING, loc }, - ], - type: VOID, - loc, - }, - loc, - }); - } else push(value, "dyn.packPushSpreadIter"); + const { stmts, pack: ref } = dynArgumentPack(lowerer, argNodes, loc, { + leading, + blame, + lowerSpread, }); const args = shapes.map((shape, index): IrExpr => { if (shape.mode === "arguments") return ref(); diff --git a/packages/compiler/src/frontend/lowering/coercions/state.ts b/packages/compiler/src/frontend/lowering/coercions/state.ts index db9bb53ad..76e5ac434 100644 --- a/packages/compiler/src/frontend/lowering/coercions/state.ts +++ b/packages/compiler/src/frontend/lowering/coercions/state.ts @@ -1,4 +1,6 @@ import type { IrType } from "../../../ir/ir.js"; +import type * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; /** Per-compilation conversion state. Recursive plans are scoped to the * active planning stack; only fully validated copy routes are memoized. */ @@ -14,8 +16,28 @@ export class CoercionState { // The extracted arm of each checked (non-defaulting) extraction helper. readonly checkedNarrowArms = new Map< string, - { unionId: string; tag: number; target: IrType; message?: string } + { unionId: string; tag: number; target: IrType; widens?: true; message?: string } >(); readonly retags = new Map(); readonly narrows = new Map(); + readonly widthNarrows = new Map(); + /** Unions widenReducedReturn produced: only these read through the + * width-converting extraction (widthNarrowHelper). */ + readonly widenedReturns = new Set(); + /** widenReducedReturn's results by declaration and inferred return type, + * for the results computed without cutting a widening cycle through + * another function. Each keeps the type-parameter bindings it was mapped + * under and answers only under the same bindings. */ + readonly widenedReturnTypes = new Map>(); +} + +/** A widened inferred return type and the lowering state that mapped it + * (Lowerer.typeParamBindings and its checker-type and implicit-any twins, + * and whether an erased generic body is lowering). */ +export interface WidenedReturn { + type: IrType; + bindings: Lowerer["typeParamBindings"]; + tsBindings: Lowerer["typeParamTsBindings"]; + implicit: Lowerer["implicitParamTypes"]; + erasing: boolean; } diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index f42663a13..bff419d48 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -128,6 +128,25 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width const route = presenceRoute(lowerer, src.shapeId, selected[0]!, selected[1]!); if (route) return route; } + // A record whose fields some arm keeps EVERY one of, each lifting + // (`{ id; limit; opts?: A | B }` into `{ id; limit; opts?: A | B | C } | + // { id; opts?: C }`): that arm is the value's own layout, widened; the + // others hold it only by dropping fields. Prefer the unique lossless arm. + if (selected.length > 1 && src.kind === "record") { + const source = lowerer.shapes.get(src.shapeId); + const lossless = selected.filter(({ arm }) => { + const target = arm.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; + return ( + source !== undefined && + target !== undefined && + !source.tuple && + !target.tuple && + target.fields.length === source.fields.length && + source.fields.every((field) => target.fields.some((kept) => kept.name === field.name)) + ); + }); + if (lossless.length === 1) selected = lossless; + } if (selected.length !== 1) return null; return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm }; } diff --git a/packages/compiler/src/frontend/lowering/coercions/unions.ts b/packages/compiler/src/frontend/lowering/coercions/unions.ts index 53c87e429..6bd72e989 100644 --- a/packages/compiler/src/frontend/lowering/coercions/unions.ts +++ b/packages/compiler/src/frontend/lowering/coercions/unions.ts @@ -437,6 +437,47 @@ export function narrowedArmHelper( return name; } +/** Interned `%union.narrow.width.(u)`: the checked extraction of a + * record arm from a union that also holds records whose fields strictly + * include the arm's (a function's widened inferred return, see + * widenReducedReturn). Those arms convert like any record width coercion; + * every other arm throws like narrowedArmHelper's. */ +export function widthNarrowHelper( + lowerer: Lowerer, + fromId: string, + target: IrType, + loc: SrcLoc, +): string | null { + const from = lowerer.unions.get(fromId); + if (!from || target.kind !== "record") return null; + const tag = lowerer.armTag(fromId, target); + if (tag < 0) return null; + const key = `${fromId}:${tag}`; + const existing = lowerer.coercions.widthNarrows.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%union.narrow.width", key); + lowerer.coercions.widthNarrows.set(key, name); + const fn = buildUnionNarrow( + name, + from, + target, + loc, + (type) => lowerer.fmt(type), + null, + null, + (armTag, payload) => widthSubtypeConversion(lowerer, from.arms[armTag]!, target, payload, loc), + ); + if (!fn) throw new InternalCompilerError("lowerer bug: invalid checked union extraction"); + lowerer.coercions.checkedNarrowArms.set(name, { + unionId: fromId, + tag, + target: from.arms[tag]!, + widens: true, + }); + lowerer.liftedFns.push(fn); + return name; +} + /** A checked extraction at its use. A plain read tests the stored tag in * place and projects the payload directly, so member reads can borrow it; * every other arm throws the helper's TypeError with the same message. @@ -458,7 +499,8 @@ export function checkedNarrowCall( value.type.kind !== "union" || value.type.unionId !== arm.unionId || !typeEquals(type, arm.target) || - !isSafeToRepeat(value) + !isSafeToRepeat(value) || + arm.widens === true ) return call; const unionId = arm.unionId; @@ -616,3 +658,31 @@ export function deferredReadHelper( lowerer.liftedFns.push(fn); return name; } + +/** Whether `arm` is a record whose fields strictly include those of the + * record `target`: a value of the target type under TypeScript's structural + * typing, which the checker's subtype reduction may have spelled as the + * target (`{ status } | { status, value }` infers `{ status }`). */ +export function isWidthSubtypeRecord(lowerer: Lowerer, arm: IrType, target: IrType): boolean { + if (arm.kind !== "record" || target.kind !== "record") return false; + const own = lowerer.shapes.get(arm.shapeId); + const want = lowerer.shapes.get(target.shapeId); + if (!own || !want || own.tuple || want.tuple || own.indexValue || want.indexValue) return false; + if (want.fields.length >= own.fields.length) return false; + const names = new Set(own.fields.map((f) => f.name)); + return want.fields.every((f) => names.has(f.name)); +} + +/** A checked narrow that meets a width-subtype record arm converts it like + * any record width coercion instead of reporting a lying narrowing. */ +function widthSubtypeConversion( + lowerer: Lowerer, + arm: IrType, + target: IrType, + payload: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (!isWidthSubtypeRecord(lowerer, arm, target)) return null; + const lift = lowerer.widthLiftPlan(arm, target); + return lift === null ? null : lowerer.applyWidthLift(lift, payload, target, loc); +} diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index bfaca6f96..21cc36016 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -44,6 +44,7 @@ import { } from "../collection-ingestion.js"; import { strCharsCall } from "./array-construction.js"; import { arrayElementRead } from "./array-iteration.js"; +import { isMimeParamsIterable } from "../builtins/mime.js"; import { isSearchParamsIterable } from "../builtins/url.js"; import { classGeneratorIterable } from "../class-iterables.js"; import { generatorDrain, lowerCollectionIteratorValue } from "../iterator-adapters.js"; @@ -1271,8 +1272,8 @@ export function lowerMapSeedNew( ); } // new Map(sp) / new Map(sp.entries()): the checked-dynamic view's live - // pairs seed the map in order. - if (isSearchParamsIterable(lowerer, argNode)) { + // pairs seed the map in order, and so do a MIMEParams's entries. + if (isSearchParamsIterable(lowerer, argNode) || isMimeParamsIterable(lowerer, argNode)) { return mapFromSeedValue(lowerer, lowerer.lowerExprExpecting(argNode, DYN), mapT); } const input = collectionInput(lowerer, argNode); diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 77c16594f..5854a0ae8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -29,6 +29,7 @@ import { lowerTernary } from "./narrowing.js"; import { lowerCollectionSpread } from "../containers/collection-methods.js"; import { strCharsCall } from "../containers/array-construction.js"; import { generatorDrain } from "../iterator-adapters.js"; +import { isMimeParamsIterable } from "../builtins/mime.js"; import { isSearchParamsIterable } from "../builtins/url.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import { arrayValueStore, primitiveElement } from "../array-values.js"; @@ -543,7 +544,9 @@ export function lowerArrayLiteral( // `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its // checked-dynamic view iterates the live pair list, and the drained // [name, value] pairs convert to the literal's tuple elements. - const searchParamsSource = isSearchParamsIterable(lowerer, el.expression); + const searchParamsSource = + isSearchParamsIterable(lowerer, el.expression) || + isMimeParamsIterable(lowerer, el.expression); if (src.type.kind === "searchParams") src = lowerer.coerceInto(el.expression, src, DYN); // A native checked iterable can supply scalar elements even when // the checker inferred a typed array from its producer. Drain once diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index c172fc662..b0dcef332 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1551,6 +1551,9 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr reference.kind === "map" || reference.kind === "set" || reference.kind === "union" || + // A promise of dyn boxes its own promise, so identity holds; + // other promise types box a fresh adapter. + (reference.kind === "promise" && reference.inner.kind === "dyn") || DYN_HANDLE_KINDS.has(reference.kind)) && lowerer.dynConvertible(reference) ) { @@ -2388,6 +2391,34 @@ export function lowerStrictEquality( loc, }; } + // A promise against a union with a promise arm, or two such unions: + // converting a promise into a union's promise arm of another payload + // (`chains.get(id) === tail` with a `Map>`, or + // `a.pending === b.pending` after both were set to one promise) builds a + // new promise, so either side may hold a conversion of the other's + // promise, and a value narrowed out of the union may be one too. + // Promises are objects to ===, so compare both sides as untyped values, + // where a converted promise answers with its origin's identity (the plain + // promise-vs-promise rule below). + const holdsPromise = (type: IrType): boolean => + type.kind === "promise" || + (type.kind === "union" && + (lowerer.unions.get(type.unionId)?.arms ?? []).some((arm) => arm.kind === "promise")); + if ( + holdsPromise(left.type) && + holdsPromise(right.type) && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { + return { + kind: "dynScalarEq", + left: lowerer.coerceToExpected(left, DYN), + right: lowerer.coerceToExpected(right, DYN), + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { aliasRefusal(ut); return { diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 59b6574e6..29f249282 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -70,6 +70,7 @@ import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js"; import { npmStaticPackageOfPath } from "../../npm-static.js"; import { isSafeToRepeat } from "./evaluation-safety.js"; +import { withInheritedMethods } from "../record-inherited.js"; import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { isClassCallback } from "../class-callbacks.js"; import { isKeyedFieldAccess } from "./keyed-field-targets.js"; @@ -1445,7 +1446,17 @@ export function lowerRecordKeyRead( loc, }; if (nanCoded) return read; - return includeUndefined || optionalRead ? read : lowerer.maybeNarrow(read, expr); + const answered = withInheritedMethods(lowerer, read, litKey, isCalledAccess(expr)); + return includeUndefined || optionalRead ? answered : lowerer.maybeNarrow(answered, expr); +} + +/** The access is the callee of a call (`o[k](…)`, `o[k]?.(…)`, `o[k]!(…)`), + * which passes the object as `this`. */ +function isCalledAccess(node: ts.Expression): boolean { + let child: ts.Expression = node; + while (ts.isParenthesizedExpression(child.parent) || ts.isNonNullExpression(child.parent)) + child = child.parent; + return ts.isCallExpression(child.parent) && child.parent.expression === child; } /** ToPropertyKey for represented record keys. Missing array-derived string diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 70766949a..f57e52159 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -34,7 +34,11 @@ import { } from "../../type-mapper.js"; import { isSafeToRepeat } from "./evaluation-safety.js"; import { tryLowerExpression } from "./try-lower-expression.js"; -import { fenceSymbolFieldCopy } from "../symbol-fields.js"; +import { + classSymbolKeyOf, + fenceSymbolFieldCopy, + symbolFieldDisplayName, +} from "../symbol-fields.js"; import { planUnionRetag } from "../union-retag.js"; import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; import { builtinGlobalValue } from "./property-access.js"; @@ -197,7 +201,19 @@ function propNameText(lowerer: Lowerer, name: ts.PropertyName): string { * (NamedEvaluation): an identifier, string or numeric key, or a computed * key that folds to one string. Null for other keys. */ function propertyFunctionName(lowerer: Lowerer, name: ts.PropertyName): string | null { - if (ts.isComputedPropertyName(name)) return literalComputedKey(lowerer, name); + if (ts.isComputedPropertyName(name)) { + const literal = literalComputedKey(lowerer, name); + if (literal !== null) return literal; + // A symbol key names the function `[description]`. + let key = name.expression; + while (ts.isParenthesizedExpression(key)) key = key.expression; + const symbol = classSymbolKeyOf(lowerer, key); + if (symbol) return `[${symbolFieldDisplayName(symbol.fieldName).slice("Symbol(".length, -1)}]`; + const wellKnown = ts.isPropertyAccessExpression(key) + ? lowerer.stdlibGlobalMember(key, "Symbol") + : null; + return wellKnown ? `[Symbol.${wellKnown}]` : null; + } if (ts.isNumericLiteral(name)) return String(Number(name.text)); if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text; return null; diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 35bb16cd3..6466857a5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,3 +1,5 @@ +import { widensReducedRecords } from "../function-returns.js"; +import { widthNarrowHelper } from "../coercions/unions.js"; /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import { indexedUnionArms, lowerIndexedUnionMember } from "../indexed-unions.js"; import { keyedHelperName } from "../stable-names.js"; @@ -7,6 +9,7 @@ import * as ts from "../../ts7/adapter.js"; import { lowerHistogramProperty } from "../builtins/performance.js"; import { staticFunctionNameRead } from "../static-function-names.js"; import { typedArgumentsArity } from "../typed-arguments.js"; +import { INSPECT_CUSTOM_DESCRIPTION, isInspectCustomAccess } from "../symbol-fields.js"; import { isThisParameter } from "../call-signatures.js"; import { argumentCountSlot, contextualCallCount, usedAsValue } from "../argument-counts.js"; import { @@ -62,6 +65,7 @@ import { } from "../../../ir/ir.js"; import { lowerNavigatorMember } from "../builtins/navigator.js"; import { lowerX509PublicKey } from "../builtins/x509.js"; +import { fenceMimeStaticRead } from "../builtins/mime.js"; import { NODE_COMPAT_VERSION } from "../builtins/process.js"; import { inlineNamespaceRequireOf, @@ -115,6 +119,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../. import { typedArrayLacksMember } from "../builtins/typed-array-members.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { lowerRecordInheritedMember } from "../record-inherited.js"; import { DATE_METHOD_HINT } from "../builtins/dates.js"; import { cjsClassExprWholeExportOf, @@ -335,6 +340,16 @@ export function lowerPropertyAccessExpr( // Cloudflare probe) folds like `navigator.userAgent`. const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION); if (navigator) return navigator; + // util.inspect.custom: Symbol.for('nodejs.util.inspect.custom'). + if (isInspectCustomAccess(lowerer, expr)) { + return { + kind: "libCall", + fn: "sym.for", + args: [{ kind: "strLit", value: INSPECT_CUSTOM_DESCRIPTION, type: STRING, loc }], + type: SYMBOL_T, + loc, + }; + } { const member = lowerBufferPrototypeMember(lowerer, expr); if (member) return member; @@ -931,6 +946,7 @@ export function lowerPropertyAccessExpr( // prototype that no lowering above claimed keeps the SC2020 fence // rather than reading undefined through the field and dynamic reads. fenceBuiltinConstructorStaticRead(lowerer, expr) ?? + fenceMimeStaticRead(lowerer, expr) ?? lowerer.lowerFieldRead(expr); // A union-typed field read narrows like an identifier when the // checker has narrowed this use to one arm. @@ -1364,7 +1380,23 @@ export function lowerPropertyAccessExpr( // honest one, not a generic recitation about the outer dot. A // receiver that lowers cleanly means the MEMBER is the gap — name // the property and the receiver's type instead of "property access". - const recvLowered = lowerer.lowerExpr(expr.expression); + let recvLowered = lowerer.lowerExpr(expr.expression); + // A receiver whose value union holds record types the checker's subtype + // reduction folded into its type (`{ status } | { status, value }` typed + // `{ status }`, a call of a function with an inferred return type): read + // through the checker's type, converting the wider arms. + if (recvLowered.type.kind === "union") { + const declared = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if ( + declared !== null && + declared.kind === "record" && + widensReducedRecords(lowerer, declared, recvLowered.type) + ) { + const helper = widthNarrowHelper(lowerer, recvLowered.type.unionId, declared, loc); + if (helper !== null) + recvLowered = lowerer.checkedNarrowCall(helper, recvLowered, declared, loc); + } + } // A dyn receiver — JSON.parse's `any`, or `unknown` the checker // narrowed to `object` — reads through the dyn keyed read: member // or undefined (JS's own-property answer), throwing JS's TypeError @@ -3188,6 +3220,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi loc: locOf(expr), }; } + // Object.prototype's methods read as values: an own entry of the + // member's function type, else the built-in. + if (actual?.type.kind === "record") { + const inherited = lowerRecordInheritedMember(lowerer, actual, expr); + if (inherited) return inherited; + } // A member the checker declares on the receiver, stored in a pure // index-signature record (an object literal that spread one, see // dictSpreadLiteralShape): the key is always present, so the keyed diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 389abb521..b323162c0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -39,6 +39,7 @@ import { lowerReadlineInstanceof } from "../builtins/readline.js"; import { lowerKeyObjectInstanceof } from "../builtins/key-object.js"; import { lowerCryptoKeyInstanceof } from "../builtins/webcrypto.js"; import { lowerNetAddressInstanceof } from "../builtins/net-address.js"; +import { lowerMimeInstanceof } from "../builtins/mime.js"; import { typedArrayHasMember } from "../builtins/typed-array-members.js"; import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js"; import { maybePromiseInstanceOf } from "../maybe-promise.js"; @@ -246,6 +247,8 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc if (cryptoKey) return cryptoKey; const netAddress = lowerNetAddressInstanceof(lowerer, expr); if (netAddress) return netAddress; + const mime = lowerMimeInstanceof(lowerer, expr); + if (mime) return mime; const readlineInterface = lowerReadlineInstanceof(lowerer, expr); if (readlineInterface) return readlineInterface; } diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index c4563a234..c692742a8 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -11,12 +11,14 @@ import { VOID, isUnitType, typeEquals, + typeKey, } from "../../ir/ir.js"; import { isJsSourceFile } from "../program.js"; import { PoisonError, dynFallbackType } from "./lowerer.js"; import { functionCanReturnUndefined } from "../function-completion.js"; import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js"; import type { FnCtx } from "./function-context.js"; +import { constituentTypes } from "../ts7/checker.js"; import { returnsGuessedOverloadValue } from "./overload-choice.js"; /** Keep the conservative completion rule shared by return construction @@ -193,6 +195,8 @@ export function declaredReturnType( nextT: DYN, }; let mappedReturn = lowerer.mapTypeOf(retTsType); + if (mappedReturn !== null) + mappedReturn = widenReducedReturn(lowerer, decl, retTsType, mappedReturn); // A generator declared to return Iterable returns a generator over T. if (mappedReturn && decl.asteriskToken) mappedReturn = iterableGeneratorArm(lowerer, mappedReturn); @@ -556,3 +560,194 @@ export function blockBodyOf(decl: ts.FunctionLikeDeclaration): ts.Block | null { if (ts.isBlock(body)) return body; return ts.forEachChild(decl, (c) => (ts.isBlock(c) ? c : undefined)) ?? null; } + +const widening = new Set(); +/** How many times widenReducedReturn answered a declaration it was still + * widening with its unwidened type. A result computed while that happened + * may lack arms, so it is not kept. */ +let wideningCuts = 0; + +/** An inferred return type is the checker's SUBTYPE-REDUCED union of the + * returned values' types: `return aborted` (`{ status }`) beside `return + * { status, value }` infers just `{ status }`, because the second is a + * subtype of the first. Records have fixed layouts, so that type cannot + * hold the second value: the return would drop `value` or fail the union + * wrap. A function whose return type is inferred therefore returns the + * union of the record types it actually returns. Declared return types keep + * their meaning. */ +function widenReducedReturn( + lowerer: Lowerer, + decl: ts.SignatureDeclaration, + retTsType: ts.Type, + mapped: IrType, +): IrType { + if (decl.type !== undefined || hasExplicitJsDocReturn(decl)) return mapped; + const body = (decl as { body?: ts.Node }).body; + if (body === undefined || decl.asteriskToken !== undefined) return mapped; + if (decl.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return mapped; + // A function expression with a contextual signature returns that + // signature's type; only free-standing inference is widened. + if ( + (ts.isFunctionExpression(decl) || ts.isArrowFunction(decl)) && + lowerer.checker.getContextualType(decl) !== undefined + ) + return mapped; + const arms = + mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; + const recordArms = arms.filter((a) => a.kind === "record"); + if (recordArms.length === 0) return mapped; + if (widening.has(decl)) { + wideningCuts++; + return mapped; + } + // Functions that forward to the same callees along several return + // branches read each callee's widened type once. A nested function + // lowered under other type-parameter bindings maps its returns again. + const known = lowerer.coercions.widenedReturnTypes.get(decl)?.get(retTsType); + const erasing = lowerer.erasingDepth > 0; + if ( + known !== undefined && + known.bindings === lowerer.typeParamBindings && + known.tsBindings === lowerer.typeParamTsBindings && + known.implicit === lowerer.implicitParamTypes && + known.erasing === erasing + ) + return known.type; + const cutsBefore = wideningCuts; + const widened = widenReturnArms(lowerer, decl, retTsType, mapped, arms, recordArms); + if (wideningCuts === cutsBefore) { + let byType = lowerer.coercions.widenedReturnTypes.get(decl); + if (byType === undefined) lowerer.coercions.widenedReturnTypes.set(decl, (byType = new Map())); + byType.set(retTsType, { + type: widened, + bindings: lowerer.typeParamBindings, + tsBindings: lowerer.typeParamTsBindings, + implicit: lowerer.implicitParamTypes, + erasing, + }); + } + return widened; +} + +/** widenReducedReturn's scan of the returned values, for a declaration not + * already being widened. */ +function widenReturnArms( + lowerer: Lowerer, + decl: ts.SignatureDeclaration, + retTsType: ts.Type, + mapped: IrType, + arms: readonly IrType[], + recordArms: readonly IrType[], +): IrType { + const body = (decl as { body?: ts.Node }).body!; + const checkerArms = new Set( + retTsType.flags & ts.TypeFlags.Union ? constituentTypes(retTsType) : [retTsType], + ); + const fieldsOf = (t: IrType): readonly string[] | null => { + if (t.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + return shape === undefined || shape.tuple || shape.indexValue !== undefined + ? null + : shape.fields.map((f) => f.name); + }; + const narrower = (part: IrType): boolean => { + const own = fieldsOf(part); + if (own === null || arms.some((a) => typeKey(a) === typeKey(part))) return false; + return recordArms.some((arm) => { + const fields = fieldsOf(arm); + return fields !== null && fields.length < own.length && fields.every((f) => own.includes(f)); + }); + }; + const extra = new Map(); + const returned = (expr: ts.Expression): void => { + // A call of another inferred function returns ITS widened type. + const callee = ts.isCallExpression(expr) + ? lowerer.checker.getResolvedSignature(expr)?.declaration + : undefined; + if ( + callee !== undefined && + ts.isFunctionLike(callee) && + (callee as { body?: ts.Node }).body !== undefined && + callee.type === undefined + ) { + // A call of the function itself returns its own type, whose arms + // add nothing. Reading it would cut the widening and keep this + // result from being reused. + if (callee === decl) return; + let t: IrType | null = null; + widening.add(decl); + try { + t = declaredReturnType(lowerer, callee, expr); + } catch { + t = null; + } finally { + widening.delete(decl); + } + if (t === null) return; + const parts = t.kind === "union" ? (lowerer.unions.get(t.unionId)?.arms ?? []) : [t]; + for (const part of parts) if (narrower(part)) extra.set(typeKey(part), part); + return; + } + // Only a value type the checker's reduction dropped from the inferred + // union is a candidate; a constituent of the inferred type is already + // represented by it. + const valueType = lowerer.typeOf(expr); + const valueArms = + valueType.flags & ts.TypeFlags.Union ? constituentTypes(valueType) : [valueType]; + for (const part of valueArms) { + if (checkerArms.has(part) || !(part.flags & ts.TypeFlags.Object)) continue; + if (!lowerer.checker.isTypeAssignableTo(part, retTsType)) continue; + const t = lowerer.mapTypeOf(part); + if (t !== null && narrower(t)) extra.set(typeKey(t), t); + } + }; + const scan = (node: ts.Node): void => { + if (node !== decl && ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) returned(node.expression); + ts.forEachChild(node, scan); + }; + if (ts.isArrowFunction(decl) && !ts.isBlock(body)) returned(body as ts.Expression); + else scan(body); + if (extra.size === 0) return mapped; + const joined = new Map(); + for (const a of [...arms, ...extra.values()]) joined.set(typeKey(a), a); + const sorted = [...joined.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const unionId = lowerer.unions.intern(sorted); + lowerer.coercions.widenedReturns.add(unionId); + return { kind: "union", unionId }; +} + +/** Whether `wide` is a widened inferred return that holds `narrow`'s arms plus records that are width + * subtypes of `narrow`'s record arms: the shape a subtype-reduced inferred + * type takes next to the values it was inferred from. */ +export function widensReducedRecords(lowerer: Lowerer, narrow: IrType, wide: IrType): boolean { + if (wide.kind !== "union" || typeKey(narrow) === typeKey(wide)) return false; + if (!lowerer.coercions.widenedReturns.has(wide.unionId)) return false; + const narrowArms = + narrow.kind === "union" ? (lowerer.unions.get(narrow.unionId)?.arms ?? []) : [narrow]; + const wideArms = lowerer.unions.get(wide.unionId)?.arms ?? []; + const keys = new Set(wideArms.map(typeKey)); + if (!narrowArms.every((a) => keys.has(typeKey(a)))) return false; + const fieldsOf = (t: IrType): readonly string[] | null => { + if (t.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + return shape === undefined || shape.tuple || shape.indexValue !== undefined + ? null + : shape.fields.map((f) => f.name); + }; + const narrowKeys = new Set(narrowArms.map(typeKey)); + const extras = wideArms.filter((a) => !narrowKeys.has(typeKey(a))); + return ( + extras.length > 0 && + extras.every((extra) => { + const own = fieldsOf(extra); + return ( + own !== null && + narrowArms.some((arm) => { + const f = fieldsOf(arm); + return f !== null && f.length < own.length && f.every((x) => own.includes(x)); + }) + ); + }) + ); +} diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 0f9e41723..1769e8562 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -1040,6 +1040,14 @@ function unifySignatureBindings( dAliasArgs.length && iAliasArgs.length === dAliasArgs.length ) { + if (tsBindings && declared !== inst) { + let entries = lowerer.boundAliasInstances.get(declared); + if (!entries) lowerer.boundAliasInstances.set(declared, (entries = [])); + // A body forwarding its own alias (`inner(g)` returning `Catalog` inside an instance) resolves through the enclosing one. + if (!entries.some((entry) => entry.tsBindings === tsBindings)) + entries.push({ tsBindings, instance: lowerer.boundAliasTsResolver(inst) ?? inst }); + } dAliasArgs.forEach((da, i) => { const ia = iAliasArgs[i]; if (ia) unify(da, ia, depth + 1); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 793ee79b4..7ef0f0577 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -282,9 +282,32 @@ import { } from "./generic-functions.js"; import { codeGenerationCalleeOf, lowerCodeGenerationCall } from "./code-generation.js"; +import { lowerRecordInheritedCall } from "./record-inherited.js"; const ZLIB_STREAM_METHODS: ReadonlySet = new Set(["flush", "params", "close", "reset"]); +/** The node:util member a `(0, member)` callee names, or null. */ +function indirectUtilCalleeOf( + lowerer: Lowerer, + callee: ts.Expression, +): { module: string; member: string } | null { + let e = callee; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if ( + !ts.isBinaryExpression(e) || + e.operatorToken.kind !== ts.SyntaxKind.CommaToken || + !ts.isNumericLiteral(e.left) + ) + return null; + const target = e.right; + const bi = ts.isIdentifier(target) + ? lowerer.builtinImportOf(target) + : ts.isPropertyAccessExpression(target) && !target.questionDotToken + ? lowerer.builtinMemberOf(target) + : null; + return bi?.module === "util" ? bi : null; +} + /** `Object.defineProperty(target, ...)`, `Object.defineProperties`, * `Reflect.defineProperty`, and `target.__defineGetter__/__defineSetter__` * over a target that may hold an Error can install an accessor over an @@ -384,6 +407,14 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const nodeApi = nodeApiCalleeOf(lowerer.program, expr.expression); if (nodeApi !== null) return lowerNodeApiCall(lowerer, expr, nodeApi); } + // `(0, util_1.inspect)(value)`: TypeScript's CommonJS output calls a + // named import through a comma expression, which leaves the call's + // receiver undefined. node:util's functions do not read their receiver, + // so the call lowers as the direct one. + { + const util = indirectUtilCalleeOf(lowerer, expr.expression); + if (util) return lowerBuiltinCall(lowerer, expr, util, loc); + } // The deprecated Buffer(...) call (containers/bytes.ts). The checker // types it `any`, so the bytes cross as a checked-dynamic value, which // keeps the Buffer brand for inspection. @@ -1651,6 +1682,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // Union receivers whose every arm has a text — the ngrok // `(chunk: Buffer | string) => chunk.toString()` idiom. lowerUnionToStringCall(lowerer, expr, expr.expression) ?? + // Object.prototype's methods called by name on records: an own + // callable entry, else the built-in with the record as receiver. + lowerRecordInheritedCall(lowerer, expr, expr.expression) ?? // Object.prototype.toString's default answer on records and // override-free program classes — "[object Object]", folded. lowerDefaultToStringCall(lowerer, expr, expr.expression) ?? diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 5f1f6a03d..d941d7b60 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -163,6 +163,7 @@ import { } from "./lower-event-emitter.js"; import { declSymbolOf } from "./lower-modules.js"; import { + INSPECT_CUSTOM_MEMBER, classSymbolKeyOf, classSymbolKeyOfSymbol, symbolFieldDisplayName, @@ -4454,6 +4455,9 @@ export function collectClassShapeInner( ...(methods.size > 0 ? { methods: [...methods.keys()] } : {}), ...((): { prototypeNames?: string[] } => { const names = classPrototypeNames(decl); + // A [util.inspect.custom] method, by its reserved slot name: the + // checked-dynamic inspect walk refuses instances that have one. + if (methods.has(INSPECT_CUSTOM_MEMBER)) names.push(INSPECT_CUSTOM_MEMBER); return names.length > 0 ? { prototypeNames: names } : {}; })(), ...(abstractClass ? { abstract: true as const } : {}), diff --git a/packages/compiler/src/frontend/lowering/lower-console.ts b/packages/compiler/src/frontend/lowering/lower-console.ts index b45218bd0..5632f7402 100644 --- a/packages/compiler/src/frontend/lowering/lower-console.ts +++ b/packages/compiler/src/frontend/lowering/lower-console.ts @@ -14,7 +14,15 @@ import { boolLit, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/buil import { locOf } from "../program.js"; import { type ConsoleMethodMember } from "./builtins/console.js"; import { jsFuncNameOf, type Lowerer } from "./lowerer.js"; -import { lowerConsoleInspectArg, lowerFormatCall, lowerInspectValue } from "./lower-inspect.js"; +import { + heldFormatArgs, + lowerConsoleInspectArg, + lowerFormatCall, + lowerInspectValue, + lowerRuntimeFormat, + runtimeFormatString, + formatStringConverts, +} from "./lower-inspect.js"; import { streamSidesOf } from "./lower-stream.js"; /** node:console Console instances: a native handle (the checked-dynamic @@ -358,9 +366,32 @@ export function lowerConsoleOutput( ) { return consoleWrite(stdout, [lowerFormatCall(lowerer, expr, loc, false)], loc); } - const args = expr.arguments.nodes.map((a) => - lowerConsoleArg(lowerer, a, lowerer.lowerExpr(a), surface, loc), - ); + const nodes = expr.arguments.nodes; + // Spread arguments and format strings that may hold a substitution at + // run time go through the runtime formatter. A string first argument + // without a '%' keeps the space-joined write below. + if (nodes.some(ts.isSpreadElement)) + return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc); + const runtime = + nodes.length > 1 && !isStringLiteralLike(first) ? runtimeFormatString(lowerer, first!) : null; + if (runtime === "other" && !formatStringConverts(lowerer, first!)) + return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc); + if (runtime !== null) { + const held = heldFormatArgs(lowerer, nodes, loc); + if (!held.joinable) return seq(held.stmts, consoleWrite(stdout, [held.format()], loc), loc); + const joined = held.args.map((arg) => + lowerConsoleArg(lowerer, arg.node, arg.ref(), surface, loc), + ); + const write: IrStmt = { + kind: "if", + cond: held.specifies, + then: [{ kind: "exprStmt", expr: consoleWrite(stdout, [held.format()], loc), loc }], + else_: [{ kind: "exprStmt", expr: consoleWrite(stdout, joined, loc), loc }], + loc, + }; + return seq([...held.stmts, write], noop(loc), loc); + } + const args = nodes.map((a) => lowerConsoleArg(lowerer, a, lowerer.lowerExpr(a), surface, loc)); return consoleWrite(stdout, args, loc); } diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index bd9b5f55d..7f4c8dfb7 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -167,6 +167,23 @@ function declareFunctionArguments( } } +/** The type parameters a lambda without its own takes from a generic + * contextual signature, unbound in the current scope. */ +function contextualTypeParameterSymbols( + lowerer: Lowerer, + node: ts.ArrowFunction | ts.FunctionExpression, +): ts.Symbol[] { + const signatures = lowerer.checker.getCallSignatures(lowerer.typeOf(node)); + if (signatures.length !== 1) return []; + const symbols: ts.Symbol[] = []; + for (const parameter of signatures[0]!.getTypeParameters()) { + const symbol = parameter.getSymbol(); + if (!symbol || lowerer.typeParamBindings?.has(symbol)) return []; + symbols.push(symbol); + } + return symbols; +} + /** Lifts an arrow function / function expression / nested declaration / * object-literal shorthand method to a module-level function and yields * the `closure` expression creating it. */ @@ -202,6 +219,31 @@ export function lowerLambda( lowerer.typeParamBindings = previous; } } + // A lambda contextually typed by a GENERIC signature (`send: (m) => …` + // for `send(m: Msg): string`) takes that signature's type + // parameters as its own; it lowers once, erased, like a generic lambda. + if ( + (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && + !node.typeParameters?.nodes.length && + !lowerer.erasingLambdas.has(node) + ) { + const contextual = contextualTypeParameterSymbols(lowerer, node); + if (contextual.length > 0) { + const previous = lowerer.typeParamBindings; + const bindings = new Map(previous ?? []); + for (const symbol of contextual) bindings.set(symbol, DYN); + lowerer.typeParamBindings = bindings; + lowerer.erasingLambdas.add(node); + lowerer.erasingDepth++; + try { + return lowerLambda(lowerer, node, checkedReceiver); + } finally { + lowerer.erasingDepth--; + lowerer.erasingLambdas.delete(node); + lowerer.typeParamBindings = previous; + } + } + } const prepared = ts.isArrowFunction(node) || ts.isFunctionExpression(node) ? lowerer.preparedClassFactories.get(node) diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 1ff19782c..e14c3efca 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -17,7 +17,8 @@ import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; import { unsignedHex } from "../../format-integer.js"; import { jsFuncNameOf, type Lowerer } from "./lowerer.js"; -import { isJsSourceFile } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { noLoweringDiag } from "../../diagnostics/diagnostic.js"; import { BOOL, CAUGHT, @@ -25,6 +26,7 @@ import { F64, type IrExpr, type IrFunction, + type IrLocal, type IrStmt, type IrStrIntrinsicMethod, type IrType, @@ -36,6 +38,8 @@ import { type SrcLoc, canConvertToDyn, canDynCheckTo, + funcOf, + arrayOf, CLASS_FIELD_ATTRIBUTES_PREFIX, classDynViewSupported, DYN_CLASS_PROPERTIES, @@ -49,13 +53,18 @@ import { } from "../../ir/ir.js"; import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js"; import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js"; -import { symbolFieldDisplayName, TO_STRING_TAG_FIELD } from "./symbol-fields.js"; +import { + INSPECT_CUSTOM_MEMBER, + symbolFieldDisplayName, + TO_STRING_TAG_FIELD, +} from "./symbol-fields.js"; import { eventTargetRootOf } from "./lower-event-target.js"; import { dateRootOf } from "./builtins/date-classes.js"; import { lowerSystemErrorMap } from "./lower-system-errors.js"; import { isPerfEntryType, perfEntryJson } from "./builtins/performance.js"; import { recordMayKeepSource, recordSourceTest } from "./record-sources.js"; import { nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js"; +import { dynArgumentPack } from "./call-arguments.js"; /* ── IR construction shorthand ───────────────────────────────────────── */ function concatAll(parts: IrExpr[], loc: SrcLoc): IrExpr { @@ -806,6 +815,8 @@ function inspectSupport( case "object": { const info = lowerer.classes.get(t.className); if (!info) return `class '${t.className}' has no inspect lowering`; + const custom = customInspectReason(lowerer, info); + if (custom !== null) return custom; // EventTarget and Event subclasses render through Node's inspectors // for those classes (the runtime's capsule view), and so do classes // declared `extends Date`. @@ -904,8 +915,10 @@ export function inspectExpr( recurse: IrExpr, depth: IrExpr, loc: SrcLoc, + /** The instance's own rendering, without its custom inspect method. */ + ownRendering = false, ): IrExpr { - const render = inspectExprBody(lowerer, t, value, recurse, depth, loc); + const render = inspectExprBody(lowerer, t, value, recurse, depth, loc, ownRendering); // A top-level render through emitted helpers that can reach a refusal // (the 'not supported yet' Error of insp.dyn) unwinds the inspector to // its state before the render on every exit: the Error propagates out of @@ -1015,6 +1028,7 @@ function inspectExprBody( recurse: IrExpr, depth: IrExpr, loc: SrcLoc, + ownRendering: boolean, ): IrExpr { // A Dirent row renders through its dyn view: Node's `Dirent { ... }` // with the Symbol(type) key. @@ -1095,6 +1109,15 @@ function inspectExprBody( loc, }; case "object": + if (!ownRendering && customInspectMethodOf(lowerer, t.className)) { + return { + kind: "call", + callee: customInspectHelper(lowerer, t, loc), + args: [value, recurse, depth], + type: STRING, + loc, + }; + } if ( eventTargetRootOf(lowerer.classes.get(t.className)) !== null || dateRootOf(lowerer.classes.get(t.className)) !== null @@ -1274,6 +1297,297 @@ function classInspectsAddedProperties(lowerer: Lowerer, info: ClassInfo): boolea ); } +/* ── custom inspect methods ([util.inspect.custom]) ──────────────────── */ + +/** The `[util.inspect.custom]` method a class's instances have, declared + * on the class or a base; null when they have none. */ +function customInspectMethodOf(lowerer: Lowerer, className: string) { + const info = lowerer.classes.get(className); + return info ? (lowerer.findMethodOn(info, INSPECT_CUSTOM_MEMBER) ?? null) : null; +} + +/** Why instances of a class with a custom inspect method have no static + * rendering, or null. Node calls the method with the remaining depth, the + * options and util.inspect; the method's parameters receive them as + * checked-dynamic values, and its result renders in the instance's place. */ +function customInspectReason(lowerer: Lowerer, info: ClassInfo): string | null { + const found = lowerer.findMethodOn(info, INSPECT_CUSTOM_MEMBER); + if (!found) { + const below = (c: ClassInfo): boolean => + c.subclasses.some((s) => s.methods.has(INSPECT_CUSTOM_MEMBER) || below(s)); + return below(info) + ? "classes whose subclasses declare [util.inspect.custom] have no inspect lowering yet (declare the method on the class itself)" + : null; + } + if (found.sig.params.length > 3) + return "[util.inspect.custom] methods with more than three parameters have no inspect lowering"; + for (const param of found.sig.params) { + if ( + param.type.kind !== "dyn" && + !canDynCheckTo( + param.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return `[util.inspect.custom] methods with a '${lowerer.fmt(param.type)}' parameter have no inspect lowering`; + } + const ret = found.sig.ret; + if (ret.kind === "object") + return "[util.inspect.custom] methods that return a class instance have no inspect lowering"; + if ( + ret.kind !== "string" && + ret.kind !== "dyn" && + ret.kind !== "void" && + inspectSupport(lowerer, ret, new Set(), { recursive: false }) !== null + ) + return `[util.inspect.custom] methods returning '${lowerer.fmt(ret)}' have no inspect lowering`; + return null; +} + +/** Whether values of `t` can hold a checked-dynamic value, whose own custom + * inspect functions the runtime walk calls. */ +function typeReachesDyn(lowerer: Lowerer, t: IrType): boolean { + const seen = new Set(); + const walk = (u: IrType): boolean => { + if (u.kind === "dyn") return true; + const k = typeKey(u); + if (seen.has(k)) return false; + seen.add(k); + return typeConstituents(lowerer, u).some(walk); + }; + return walk(t); +} + +/** Whether values of `t` can hold an instance of a class with a custom + * inspect method. */ +export function typeReachesCustomInspect(lowerer: Lowerer, t: IrType): boolean { + const seen = new Set(); + const walk = (u: IrType): boolean => { + const k = typeKey(u); + if (seen.has(k)) return false; + seen.add(k); + if (u.kind === "object" && customInspectMethodOf(lowerer, u.className)) return true; + return typeConstituents(lowerer, u).some(walk); + }; + return walk(t); +} + +/** `%util.inspCustom.(v, r, d): string` — formatValue's custom hook + * over a class whose instances have a `[util.inspect.custom]` method: + * with ctx.customInspect set, the method's result (a string re-indented + * at the current level, the instance itself rendered normally, any other + * value rendered in its place); otherwise the instance's own rendering. */ +function customInspectHelper( + lowerer: Lowerer, + t: IrType & { kind: "object" }, + loc: SrcLoc, +): string { + const key = `inspCustom:${typeKey(t)}`; + const existing = lowerer.inspectHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%util.inspCustom", key); + lowerer.inspectHelpers.set(key, name); + const found = customInspectMethodOf(lowerer, t.className)!; + const v = (): IrExpr => varRef("v.0", t, loc); + const r = (): IrExpr => varRef("r.0", F64, loc); + const d = (): IrExpr => varRef("d.0", F64, loc); + const ret = (value: IrExpr): IrStmt => ({ kind: "return", value, loc }); + const normal = (): IrExpr => inspectExpr(lowerer, t, v(), r(), d(), loc, true); + const runtimeArgs: IrExpr[] = [ + { kind: "libCall", fn: "insp.customDepth", args: [r(), d()], type: DYN, loc }, + { kind: "libCall", fn: "insp.customOptions", args: [d()], type: DYN, loc }, + { kind: "libCall", fn: "insp.inspectFunction", args: [], type: DYN, loc }, + ]; + const locals: IrLocal[] = [ + { id: "v.0", name: "v", type: t, mutable: false }, + { id: "r.0", name: "r", type: F64, mutable: false }, + { id: "d.0", name: "d", type: F64, mutable: false }, + ]; + // The arguments read the walk's depth and options, so they evaluate + // before the method's own context begins. + const argDecls: IrStmt[] = found.sig.params.map((param, i) => { + locals.push({ id: `arg${i}.0`, name: `arg${i}`, type: param.type, mutable: false }); + const init = lowerer.coerceToExpected(runtimeArgs[i]!, param.type); + return { kind: "varDecl", localId: `arg${i}.0`, init, loc }; + }); + const args = found.sig.params.map((param, i) => varRef(`arg${i}.0`, param.type, loc)); + const resultT = found.sig.ret; + const call = accessorCall(lowerer, t.className, INSPECT_CUSTOM_MEMBER, v(), args, resultT, loc); + // The method runs in a fresh inspect context (insp.customCallBegin); the + // walk's own context returns before the result renders. The token lets + // an enclosing end restore this walk's context when the method throws. + locals.push({ id: "tok.0", name: "tok", type: F64, mutable: false }); + const end: IrStmt = { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "insp.customCallEnd", + args: [varRef("tok.0", F64, loc)], + type: VOID, + loc, + }, + loc, + }; + const render: IrStmt[] = [ + ...argDecls, + { + kind: "varDecl", + localId: "tok.0", + init: { kind: "libCall", fn: "insp.customCallBegin", args: [], type: F64, loc }, + loc, + }, + ]; + if (resultT.kind === "void") { + render.push({ kind: "exprStmt", expr: call, loc }, end); + } else { + locals.push({ id: "res.0", name: "res", type: resultT, mutable: false }); + render.push({ kind: "varDecl", localId: "res.0", init: call, loc }, end); + } + const res = (): IrExpr => varRef("res.0", resultT, loc); + if (resultT.kind === "string") { + render.push(ret({ kind: "libCall", fn: "insp.customText", args: [res()], type: STRING, loc })); + } else if (resultT.kind === "dyn") { + render.push( + { + kind: "if", + cond: { + kind: "libCall", + fn: "insp.customIsSelf", + args: [res(), { kind: "dynFrom", value: v(), liveRef: true, type: DYN, loc }], + type: BOOL, + loc, + }, + then: [ret(normal())], + else_: null, + loc, + }, + ret({ kind: "libCall", fn: "insp.customResult", args: [res(), r(), d()], type: STRING, loc }), + ); + } else if (resultT.kind === "void") { + render.push(ret(strLit("undefined", loc))); + } else { + render.push(ret(inspectExpr(lowerer, resultT, res(), r(), d(), loc))); + } + lowerer.liftedFns.push({ + name, + params: [ + { localId: "v.0", name: "v", type: t }, + { localId: "r.0", name: "r", type: F64 }, + { localId: "d.0", name: "d", type: F64 }, + ], + returnType: STRING, + locals, + body: [ + { + kind: "if", + cond: { kind: "libCall", fn: "insp.customEnabled", args: [], type: BOOL, loc }, + then: render, + else_: null, + loc, + }, + ret(normal()), + ], + loc, + }); + return name; +} + +/** `%util.inspCustomDyn.(self, depth, options, inspect): dyn` — a + * class's custom inspect method for its instances held as checked-dynamic + * values: the runtime's inspect walk calls it with the instance's typed + * ref and the method's three arguments. */ +function customInspectDynHelper( + lowerer: Lowerer, + t: IrType & { kind: "object" }, + loc: SrcLoc, +): string { + const name = keyedHelperName(lowerer, "%util.inspCustomDyn", typeKey(t)); + const found = customInspectMethodOf(lowerer, t.className)!; + const param = (id: string): IrExpr => varRef(id, DYN, loc); + const ids = ["depth.0", "options.0", "inspect.0"]; + const call = accessorCall( + lowerer, + t.className, + INSPECT_CUSTOM_MEMBER, + varRef("v.0", t, loc), + found.sig.params.map((p, i) => lowerer.coerceToExpected(param(ids[i]!), p.type)), + found.sig.ret, + loc, + ); + const body: IrStmt[] = [ + { kind: "varDecl", localId: "v.0", init: lowerer.coerceToExpected(param("self.0"), t), loc }, + ]; + if (found.sig.ret.kind === "void") { + body.push( + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: dynUndefinedExpr(loc), loc }, + ); + } else { + body.push({ kind: "return", value: lowerer.coerceToExpected(call, DYN), loc }); + } + const params = ["self.0", ...ids]; + lowerer.liftedFns.push({ + name, + params: params.map((id) => ({ localId: id, name: id.slice(0, -2), type: DYN })), + returnType: DYN, + locals: [ + ...params.map((id) => ({ id, name: id.slice(0, -2), type: DYN, mutable: false })), + { id: "v.0", name: "v", type: t, mutable: false }, + ], + body, + loc, + }); + return name; +} + +/** The statements %main runs first, in every thread: each class whose + * instances have a custom inspect method registers its compiled call + * under its instances' typed-ref type key (scr_insp_register_custom). */ +export function customInspectRegistrations(lowerer: Lowerer, loc: SrcLoc): IrStmt[] { + const stmts: IrStmt[] = []; + for (const info of lowerer.classes.values()) { + if (info.def.runtime || info.def.abstract) continue; + // Error classes register when their own properties render statically + // (fieldedErrorReason): the others keep no method to call. + if (isErrorClass(lowerer, info.def.name) && fieldedErrorReason(lowerer, info) !== null) + continue; + if (!lowerer.findMethodOn(info, INSPECT_CUSTOM_MEMBER)) continue; + // Only constructed classes have instances, and only their methods lower + // (the constructor edge reaches the method). + if (!lowerer.classCanBeConstructed(info)) continue; + if (customInspectReason(lowerer, info) !== null) continue; + const t = { kind: "object" as const, className: info.def.name }; + const fn = customInspectDynHelper(lowerer, t, loc); + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "insp.registerCustom", + args: [ + strLit(typeKey(t), loc), + { + kind: "dynFrom", + value: { + kind: "closure", + fnName: fn, + captures: [], + type: funcOf([DYN, DYN, DYN, DYN], DYN), + loc, + }, + type: DYN, + loc, + }, + ], + type: VOID, + loc, + }, + loc, + }); + } + return stmts; +} + /* ── the interned per-type helper ────────────────────────────────────── */ /** `%util.insp.(v, r, d): string` — formatValue over the static type: @@ -2484,13 +2798,22 @@ function isDataViewTypeName(tname: string): boolean { /* ── the callsite: options, direct function/class names ──────────────── */ /** The parsed options literal: the resolved depth (numeric; Infinity for - * `null`), breakLength (numeric; default 80) and compact (numeric, 0 for + * `null`), breakLength (numeric; default 80) and compact (numeric, -1 for * false; default 3), with every other non-default knob fenced by name. */ interface InspectOptions { depth: number; breakLength: number; compact: number; sorted: boolean; + /** Set by the `atRunTime` parse: colors or a computed knob value, which + * the runtime reads. */ + runtime: boolean; + /** How the depth option was spelled, for custom inspect methods: 0 a + * number (also when absent), 1 null, 2 undefined. */ + depthKind: number; + /** customInspect: false skips [util.inspect.custom] methods (console.dir's + * default). */ + customInspect: boolean; } function parseInspectOptions( @@ -2498,9 +2821,18 @@ function parseInspectOptions( node: ts.Expression | undefined, recursive: boolean, dir = false, + atRunTime = false, ): InspectOptions { const surface = dir ? "console.dir" : "util.inspect"; - const options: InspectOptions = { depth: 2, breakLength: 80, compact: 3, sorted: false }; + const options: InspectOptions = { + depth: 2, + breakLength: 80, + compact: 3, + sorted: false, + runtime: false, + depthKind: 0, + customInspect: !dir, + }; if (!node || (ts.isIdentifier(node) && node.text === "undefined")) return options; if (!ts.isObjectLiteralExpression(node)) { lowerer.noLowering( @@ -2511,6 +2843,11 @@ function parseInspectOptions( } for (const prop of node.properties.nodes) { if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) { + // `{ depth }` and `{ ...base }` hold values known at run time. + if (atRunTime && (ts.isShorthandPropertyAssignment(prop) || ts.isSpreadAssignment(prop))) { + options.runtime = true; + continue; + } lowerer.noLowering( `${surface} options in this form`, prop, @@ -2540,8 +2877,17 @@ function parseInspectOptions( // bounded by the (finite) object graph — exactly Node. void recursive; options.depth = Infinity; - } else if (!(ts.isIdentifier(value) && value.text === "undefined")) { + options.depthKind = value.kind === ts.SyntaxKind.NullKeyword ? 1 : 0; + } else if (ts.isIdentifier(value) && value.text === "undefined") { + // Node's ctx.depth is undefined: no depth limit. + options.depth = Infinity; + options.depthKind = 2; + } else { const n = numeric(); + if (n === null && atRunTime) { + options.runtime = true; + continue; + } if (n === null) { lowerer.noLowering( `${surface} with a non-literal depth option`, @@ -2552,7 +2898,8 @@ function parseInspectOptions( options.depth = n; } } else if (key === "colors") { - if (value.kind !== ts.SyntaxKind.FalseKeyword) { + if (value.kind !== ts.SyntaxKind.FalseKeyword && atRunTime) options.runtime = true; + else if (value.kind !== ts.SyntaxKind.FalseKeyword) { lowerer.noLowering( `${surface} with colors: true`, value, @@ -2564,6 +2911,10 @@ function parseInspectOptions( // compact of at least 1; false and smaller numbers break every // composite onto its own lines. compact: true is the older layout. const n = value.kind === ts.SyntaxKind.FalseKeyword ? 0 : numeric(); + if (n === null && atRunTime && value.kind !== ts.SyntaxKind.TrueKeyword) { + options.runtime = true; + continue; + } if (n === null || Number.isNaN(n)) { lowerer.noLowering( `${surface} with this compact option`, @@ -2571,9 +2922,22 @@ function parseInspectOptions( "compact must be false or a numeric literal (compact: true has no lowering)", ); } - options.compact = Math.max(n, 0); + // false is -1: the same layout as 0, and custom inspect functions + // read it back as false. + options.compact = value.kind === ts.SyntaxKind.FalseKeyword ? -1 : Math.max(n, 0); } else if (key === "sorted") { - if (value.kind !== ts.SyntaxKind.TrueKeyword && value.kind !== ts.SyntaxKind.FalseKeyword) { + const sortedLiteral = + value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword; + if ( + !sortedLiteral && + atRunTime && + !ts.isArrowFunction(value) && + !ts.isFunctionExpression(value) + ) { + options.runtime = true; + continue; + } + if (!sortedLiteral) { lowerer.noLowering( `${surface} with this sorted option`, value, @@ -2583,6 +2947,10 @@ function parseInspectOptions( options.sorted = value.kind === ts.SyntaxKind.TrueKeyword; } else if (key === "breakLength") { const n = ts.isIdentifier(value) && value.text === "Infinity" ? Infinity : numeric(); + if (n === null && atRunTime) { + options.runtime = true; + continue; + } if (n === null || Number.isNaN(n)) { lowerer.noLowering( `${surface} with a non-literal breakLength option`, @@ -2591,6 +2959,12 @@ function parseInspectOptions( ); } options.breakLength = n; + } else if ( + !dir && + key === "customInspect" && + (value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword) + ) { + options.customInspect = value.kind === ts.SyntaxKind.TrueKeyword; } else if (dir && key === "customInspect") { // console.dir's own default; only that value is lowered. if (value.kind !== ts.SyntaxKind.FalseKeyword) { @@ -2709,16 +3083,86 @@ function directCallableInspect(lowerer: Lowerer, node: ts.Expression, loc: SrcLo return null; } -/** util.inspect(value[, options]) — the spoke's inspect entry. */ +/** util.inspect(value[, options]) and inspect(value, showHidden, depth, + * colors) — the spoke's inspect entry. Options the compiler resolves + * render through the static helpers; colors, option values known only at + * run time and the legacy forms render the value's checked-dynamic view + * in the runtime (insp.inspect). */ function lowerInspectCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { - if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) { + const args = expr.arguments.nodes; + if (args.length < 1 || args.length > 4 || args.some(ts.isSpreadElement)) { lowerer.noLowering( - `util.inspect with ${expr.arguments.nodes.length} arguments`, + `util.inspect with ${args.some(ts.isSpreadElement) ? "spread" : args.length} arguments`, expr, - "the supported forms are inspect(value) and inspect(value, options)", + "the supported forms are inspect(value), inspect(value, options) and inspect(value, showHidden, depth, colors)", ); } - return lowerInspectValue(lowerer, expr.arguments.nodes[0]!, expr.arguments.nodes[1], loc, false); + if (args.length <= 2 && !inspectOptionsAtRunTime(lowerer, args[1])) + return lowerInspectValue(lowerer, args[0]!, args[1], loc, false); + return lowerRuntimeInspect(lowerer, args, loc); +} + +/** Whether an options argument needs the runtime reader: an options value + * that is not an object literal, or a literal with colors or a computed + * depth, compact, breakLength or sorted value. Literal options otherwise + * keep the static parse, which also reports the literal options neither + * path lowers. */ +function inspectOptionsAtRunTime(lowerer: Lowerer, node: ts.Expression | undefined): boolean { + if (!node || (ts.isIdentifier(node) && node.text === "undefined")) return false; + if (!ts.isObjectLiteralExpression(node)) return true; + return parseInspectOptions(lowerer, node, false, false, true).runtime; +} + +/** util.inspect over the value's checked-dynamic view, with every argument + * evaluated in order and read in the runtime as Node's inspect reads it. */ +function lowerRuntimeInspect( + lowerer: Lowerer, + args: readonly ts.Expression[], + loc: SrcLoc, +): IrExpr { + const valueNode = args[0]!; + if ( + directCallableInspect(lowerer, valueNode, loc) !== null || + ts.isArrowFunction(valueNode) || + ts.isFunctionExpression(valueNode) + ) { + lowerer.noLowering( + "util.inspect of a function or class with these options", + valueNode, + "pass the options as a literal without colors", + ); + } + const value = lowerer.lowerExpr(valueNode); + if (value.type.kind !== "dyn") { + const reason = inspectSupport(lowerer, value.type, new Set(), { recursive: false }); + if (reason !== null) + lowerer.noLowering(`util.inspect of '${lowerer.fmt(value.type)}' values`, valueNode, reason); + if (!lowerer.dynConvertible(value.type)) + lowerer.noLowering( + `util.inspect with these options of '${lowerer.fmt(value.type)}' values`, + valueNode, + "the options are read at run time, where the value renders through its checked-dynamic view", + ); + } + const stmts: IrStmt[] = []; + const held = (init: IrExpr): IrExpr => { + const local = lowerer.declareHiddenLocal("%inspectArg", DYN); + stmts.push({ kind: "varDecl", localId: local.id, init, loc }); + return varRef(local.id, DYN, loc); + }; + const operands: IrExpr[] = [held(lowerer.coerceToExpected(value, DYN))]; + for (let i = 1; i < 4; i++) { + const node = args[i]; + operands.push(node ? held(lowerer.lowerExprExpecting(node, DYN)) : dynUndefinedExpr(loc)); + } + operands.push(numLit(args.length, loc)); + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "insp.inspect", args: operands, type: STRING, loc }, + type: STRING, + loc, + }; } /** `inspect(value, options)` over one value node: the shared body of @@ -2818,6 +3262,47 @@ export function lowerInspectValue( } const options = parseInspectOptions(lowerer, optionsNode, walk.recursive, dir); const depth = options.depth; + // Custom inspect functions read the options as Node spelled them: an + // unbounded depth given as Infinity or undefined, and customInspect: + // false (console.dir's default), apply around the render when the value + // can reach a custom method; the previous settings return afterwards. + const reachesCustom = + typeReachesCustomInspect(lowerer, value.type) || typeReachesDyn(lowerer, value.type); + const depthContext = reachesCustom && depth === Infinity && options.depthKind !== 1; + const customContext = reachesCustom && !options.customInspect; + if (depthContext || customContext) { + const stmts: IrStmt[] = []; + const restore: IrStmt[] = []; + const setting = (fn: "insp.customInspect" | "insp.depthKind", arg: IrExpr, type: IrType) => { + const previous = lowerer.declareHiddenLocal("%inspectContext", type); + const call = (a: IrExpr): IrExpr => ({ kind: "libCall", fn, args: [a], type, loc }); + stmts.push({ kind: "varDecl", localId: previous.id, init: call(arg), loc }); + restore.push({ kind: "exprStmt", expr: call(varRef(previous.id, type, loc)), loc }); + }; + if (customContext) setting("insp.customInspect", boolLit(false, loc), BOOL); + if (depthContext) setting("insp.depthKind", numLit(options.depthKind, loc), F64); + const text = lowerer.declareHiddenLocal("%inspectText", STRING); + stmts.push({ + kind: "varDecl", + localId: text.id, + init: withInspectLayout( + lowerer, + options, + value, + "inspect", + (input) => inspectExpr(lowerer, value.type, input, numLit(0, loc), numLit(depth, loc), loc), + loc, + ), + loc, + }); + return { + kind: "seqExpr", + stmts: [...stmts, ...restore.reverse()], + result: varRef(text.id, STRING, loc), + type: STRING, + loc, + }; + } if (dir && value.type.kind === "dyn") { return withInspectLayout( lowerer, @@ -2850,7 +3335,17 @@ export function lowerInspectValue( * formatNumber, strings verbatim, objects through inspect at the given * depth). Null = fence with the given reason. */ function formatSArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: SrcLoc): IrExpr { - const value = lowerer.lowerExpr(node); + return formatSValue(lowerer, node, lowerer.lowerExpr(node), depth, loc); +} + +/** formatSArg over the already-lowered value of `node`. */ +function formatSValue( + lowerer: Lowerer, + node: ts.Expression, + value: IrExpr, + depth: number, + loc: SrcLoc, +): IrExpr { const t = value.type; if (t.kind === "string") return value; if (t.kind === "f64") @@ -2867,7 +3362,7 @@ function formatSArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S if (t.kind === "dyn") { return { kind: "libCall", - fn: "insp.dynS", + fn: "insp.dynFormatS", args: [value, numLit(depth, loc)], type: STRING, loc, @@ -2945,6 +3440,13 @@ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S node, reason, ); + if (depth === 4 && typeReachesCustomInspect(lowerer, value.type)) { + lowerer.noLowering( + "util.format %o over values with [util.inspect.custom] methods", + node, + "%o passes showHidden to the methods — use %O", + ); + } if (depth === 4 && typeTreeHasHidden(lowerer, value.type, new Set())) { lowerer.noLowering( "util.format %o over arrays", @@ -3027,8 +3529,9 @@ function formatIntegerFixed(value: IrExpr, spec: "%d" | "%i", loc: SrcLoc): IrEx /** util.format(...) / util.formatWithOptions({}, ...): the compile-time * %-substitution over a literal format string (formatWithOptionsInternal * ported exactly — the arg cursor, the args-exhausted guard, %c's - * consume-and-drop), with per-static-type conversions. Non-literal - * format strings lower only in the substitution-free shapes. */ + * consume-and-drop), with per-static-type conversions. Format strings + * known only at run time, spread arguments and non-empty options lower to + * the runtime formatter (lowerRuntimeFormat, heldFormatArgs). */ export function lowerFormatCall( lowerer: Lowerer, expr: ts.CallExpression, @@ -3040,30 +3543,61 @@ export function lowerFormatCall( const argNodes = [...(argNodesOverride ?? expr.arguments.nodes)]; if (withOptions) { const opts = argNodes.shift(); + // The empty literal {} is the defaults: exactly util.format. if (!opts || !ts.isObjectLiteralExpression(opts) || opts.properties.nodes.length > 0) { - lowerer.noLowering( - "util.formatWithOptions with a non-empty options literal", - opts ?? expr, - "only the empty literal {} (the defaults — exactly util.format) is lowered", - ); + if (opts && ts.isSpreadElement(opts)) { + lowerer.noLowering( + "util.formatWithOptions with a spread options argument", + opts, + "pass the options object as the first argument", + ); + } + return lowerRuntimeFormat(lowerer, argNodes, loc, opts ?? null); } } if (argNodes.length === 0) return strLit("", loc); const first = argNodes[0]!; const firstIsLiteral = ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first); + if (argNodes.some(ts.isSpreadElement)) { + if (literalPrefix !== "" && !firstIsLiteral) + throw new InternalCompilerError("a format prefix needs a literal format string"); + return lowerRuntimeFormat(lowerer, argNodes, loc, undefined, literalPrefix); + } /** The rest-args tail: ` ` + (strings verbatim, everything else * inspected at the default depth). */ const restArg = (node: ts.Expression): IrExpr => formatSArg(lowerer, node, 2, loc); if (!firstIsLiteral) { - const firstT = lowerer.mapTypeOf(lowerer.typeOf(first)); - if (firstT?.kind === "string" && argNodes.length > 1) { - lowerer.noLowering( - "util.format with a runtime format string and further arguments", - first, - "the %-substitution positions are runtime-dependent — use a string literal", - ); + const runtime = argNodes.length > 1 ? runtimeFormatString(lowerer, first) : null; + if (runtime === "other" && !formatStringConverts(lowerer, first)) + return lowerRuntimeFormat(lowerer, argNodes, loc); + if (runtime !== null) { + // A string that substitutes formats at run time; any other string + // joins with the remaining arguments, as below. + const held = heldFormatArgs(lowerer, argNodes, loc); + if (!held.joinable) { + return { kind: "seqExpr", stmts: held.stmts, result: held.format(), type: STRING, loc }; + } + const joined: IrExpr[] = []; + held.args.forEach((arg, i) => { + if (i > 0) joined.push(strLit(" ", loc)); + joined.push(formatRestValue(lowerer, arg.node, arg.ref(), loc)); + }); + return { + kind: "seqExpr", + stmts: held.stmts, + result: { + kind: "ternary", + cond: held.specifies, + then: held.format(), + else_: concatAll(joined, loc), + type: STRING, + loc, + }, + type: STRING, + loc, + }; } // No substitutions possible: every argument joins with spaces. const parts: IrExpr[] = []; @@ -3349,6 +3883,257 @@ export function lowerFormatCall( return concatAll(parts, loc); } +/** One space-joined argument of util.format, as Node's formatter renders + * it after the format string: strings verbatim, everything else through + * inspect at the default depth. */ +function formatRestValue( + lowerer: Lowerer, + node: ts.Expression, + value: IrExpr, + loc: SrcLoc, +): IrExpr { + switch (value.type.kind) { + case "string": + return value; + case "f64": + return { kind: "libCall", fn: "insp.f64", args: [value], type: STRING, loc }; + case "bool": + return { kind: "toString", operand: value, type: STRING, loc }; + case "dyn": + return { kind: "libCall", fn: "insp.dynS", args: [value, numLit(2, loc)], type: STRING, loc }; + default: + return lowerConsoleInspectArg(lowerer, node, value, "util.format", loc); + } +} + +const UNCONVERTIBLE_HINT = + "the runtime formatter reads its arguments as checked-dynamic values — use a string literal format"; + +/** A string-valued expression that throws the SC2020 refusal where it runs + * (a JavaScript construct fence deferred to the branch that needs it). */ +function runtimeFenceValue( + lowerer: Lowerer, + surface: string, + node: ts.Node, + hint: string, + loc: SrcLoc, +): IrExpr { + const diag = noLoweringDiag(surface, locOf(node), hint); + lowerer.runtimeFences.push(diag); + const sf = ts.getSourceFileOfNode(node); + const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1; + const slot = lowerer.declareHiddenLocal("%never", STRING); + slot.mutable = true; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: slot.id, init: null, loc }, + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`, + loc, + }, + ], + result: varRef(slot.id, STRING, loc), + diverges: true, + type: STRING, + loc, + }; +} + +/** Whether the held-argument form can test a format string whose type + * only may hold a string: a union that converts to a checked-dynamic value, + * or a value whose checker type does not map (its lowered type decides). */ +export function formatStringConverts(lowerer: Lowerer, first: ts.Expression): boolean { + const t = lowerer.mapTypeOf(lowerer.typeOf(first)); + return t === null || t.kind === "dyn" || lowerer.dynConvertible(t); +} + +/** How a non-literal format string is known: "string" and "dyn" values + * test for a substitution at run time, "other" types (unions with a string + * arm) format at run time, and null types never hold a string. */ +export function runtimeFormatString( + lowerer: Lowerer, + first: ts.Expression, +): "string" | "dyn" | "other" | null { + const t = lowerer.mapTypeOf(lowerer.typeOf(first)); + if (t === null) return "other"; + if (t.kind === "string") return "string"; + if (t.kind === "dyn") return "dyn"; + if (t.kind === "union") { + const arms = lowerer.unions.get(t.unionId)?.arms ?? []; + return arms.some((arm) => arm.kind === "string" || arm.kind === "dyn") ? "other" : null; + } + return t.kind === "jsval" || t.kind === "caught" ? "other" : null; +} + +/** The runtime formatter over a call's arguments: one checked-dynamic + * pack in source order (spreads contribute their elements), after the + * options when `options` is given (null: an omitted options argument, + * which Node's validation rejects). A literal prefix joins a literal + * format string. */ +export function lowerRuntimeFormat( + lowerer: Lowerer, + argNodes: readonly ts.Expression[], + loc: SrcLoc, + options?: ts.Expression | null, + literalPrefix = "", +): IrExpr { + const stmts: IrStmt[] = []; + let optionsRef: IrExpr | null = null; + if (options !== undefined) { + const held = lowerer.declareHiddenLocal("%formatOptions", DYN); + stmts.push({ + kind: "varDecl", + localId: held.id, + init: options === null ? dynUndefinedExpr(loc) : lowerer.lowerExprExpecting(options, DYN), + loc, + }); + optionsRef = varRef(held.id, DYN, loc); + } + let nodes = argNodes; + const leading: IrExpr[] = []; + if (literalPrefix !== "") { + const first = argNodes[0] as ts.StringLiteralLike; + leading.push(strLit(literalPrefix + first.text, loc)); + nodes = argNodes.slice(1); + } + const pack = dynArgumentPack(lowerer, nodes, loc, { + leading, + blame: argNodes[0] ?? options ?? undefined, + }); + stmts.push(...pack.stmts); + const result: IrExpr = + optionsRef === null + ? { kind: "libCall", fn: "insp.format", args: [pack.pack()], type: STRING, loc } + : { + kind: "libCall", + fn: "insp.formatWithOptions", + args: [optionsRef, pack.pack()], + type: STRING, + loc, + }; + return { kind: "seqExpr", stmts, result, type: STRING, loc }; +} + +/** The arguments of a call whose first argument is a string or dyn value + * known only at run time, each evaluated once into a hidden local: + * `specifies` tests that string for a '%' before its last character, and + * `format()` is the runtime formatter over the held values. The caller + * renders the other branch (the space-joined arguments) from `args` when + * every argument is `joinable` there. */ +export function heldFormatArgs( + lowerer: Lowerer, + argNodes: readonly ts.Expression[], + loc: SrcLoc, +): { + stmts: IrStmt[]; + args: { node: ts.Expression; ref: () => IrExpr }[]; + specifies: IrExpr; + format: () => IrExpr; + joinable: boolean; +} { + const stmts: IrStmt[] = []; + let unconvertible: { node: ts.Expression; type: IrType } | null = null; + const unconvertibleSurface = (t: IrType): string => + `util.format with a runtime format string over '${lowerer.fmt(t)}' values`; + const args = argNodes.map((node) => { + const value = lowerer.lowerExpr(node); + if (value.type.kind === "void") { + lowerer.unsupported( + "SC1090", + node, + "util.format of a void call result (call it as its own statement, then pass undefined)", + ); + } + // The space-joined branch prints these values as before; only a string + // that substitutes refuses them, where it runs. + if (value.type.kind !== "dyn" && !lowerer.dynConvertible(value.type)) + unconvertible ??= { node, type: value.type }; + // Unit values have no local: their effects run here and a literal + // stands in. + const k = value.type.kind; + if (k === "undefinedT" || k === "nullT") { + if (value.kind !== "unitLit") stmts.push({ kind: "exprStmt", expr: value, loc }); + const unit = k === "nullT" ? ("null" as const) : ("undefined" as const); + return { node, ref: (): IrExpr => ({ kind: "unitLit", unit, type: value.type, loc }) }; + } + const held = lowerer.declareHiddenLocal("%formatArg", value.type); + stmts.push({ kind: "varDecl", localId: held.id, init: value, loc }); + return { node, ref: () => varRef(held.id, value.type, loc) }; + }); + const first = args[0]!.ref(); + // A lowered value that is neither a string nor convertible holds no + // string, so it never substitutes. + const specifies: IrExpr = + first.type.kind === "string" + ? { kind: "libCall", fn: "insp.formatSpecifies", args: [first], type: BOOL, loc } + : first.type.kind === "dyn" || lowerer.dynConvertible(first.type) + ? { + kind: "libCall", + fn: "insp.formatSpecifiesDyn", + args: [lowerer.coerceToExpected(first, DYN)], + type: BOOL, + loc, + } + : { kind: "boolLit", value: false, type: BOOL, loc }; + const format = (): IrExpr => { + if (unconvertible !== null) { + const fenced = unconvertible; + return runtimeFenceValue( + lowerer, + unconvertibleSurface(fenced.type), + fenced.node, + UNCONVERTIBLE_HINT, + loc, + ); + } + // Strings, numbers, booleans, null and undefined format without the + // checked-dynamic walk other values need, which keeps it out of the + // executable. + const primitive = (t: IrType): boolean => + t.kind === "string" || + t.kind === "f64" || + t.kind === "bool" || + t.kind === "undefinedT" || + t.kind === "nullT" || + (t.kind === "union" && (lowerer.unions.get(t.unionId)?.arms.every(primitive) ?? false)); + // A native list of the checked values: converting into it is a tier + // boundary, so the statement stays static. + return { + kind: "libCall", + fn: args.every((arg) => primitive(arg.ref().type)) + ? "insp.formatPrimitives" + : "insp.formatList", + args: [ + { + kind: "arrayLit", + elems: args.map((arg) => lowerer.coerceToExpected(arg.ref(), DYN)), + type: arrayOf(DYN), + loc, + }, + ], + type: STRING, + loc, + }; + }; + // The space-joined branch renders each argument statically; arguments + // the static rendering refuses take the runtime formatter in both cases. + const joinable = args.every(({ ref }) => { + const t = ref().type; + if (t.kind === "func") return false; + if (t.kind === "bytes") return true; + return inspectSupport(lowerer, t, new Set(), { recursive: false }) === null; + }); + // Without a joined branch every call formats: TypeScript refuses the + // value at compile time. + const refused = unconvertible as { node: ts.Expression; type: IrType } | null; + if (!joinable && refused !== null && !isJsSourceFile(ts.getSourceFileOfNode(refused.node))) + lowerer.noLowering(unconvertibleSurface(refused.type), refused.node, UNCONVERTIBLE_HINT); + return { stmts, args, specifies, format, joinable }; +} + /** Arrays anywhere in the tree — the %o (showHidden) gate. */ function typeTreeHasHidden(lowerer: Lowerer, t: IrType, visiting: Set): boolean { switch (t.kind) { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index fe1c51a69..a96fc2db4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -98,6 +98,7 @@ import { createRequireProgramModuleDecl, } from "./builtins/module-bindings.js"; import { isPromisifyCall, registerBuiltinCallableAlias } from "./builtins/callable-aliases.js"; +import { customInspectRegistrations } from "./lower-inspect.js"; import { registerBuiltinClassAlias } from "./builtins/class-values.js"; import { stripTypeCasts } from "./builtins/arguments.js"; import { isWebCryptoMemberType } from "./builtins/webcrypto.js"; @@ -1979,18 +1980,21 @@ export function collectGlobals( // symbol registers HERE, before any use site can lower, and the // statement lowering skips the declaration by the same check. A // bad promisify target reports here (recorded once; the statement - // lowering poisons too, like every collectGlobals failure). + // lowering poisons too, like every collectGlobals failure). Targets + // that promisify to a function value (typed callback-last functions + // and untyped functions) keep an ordinary global. if ( ts.isIdentifier(decl.name) && decl.initializer && isPromisifyCall(lowerer, decl.initializer) ) { + let registered = true; try { - lowerer.registerPromisifiedBuiltinDecl(decl.name, decl.initializer); + registered = lowerer.registerPromisifiedBuiltinDecl(decl.name, decl.initializer); } catch (e) { if (!(e instanceof PoisonError)) throw e; } - continue; + if (registered) continue; } // `const inspect = require("util").inspect` at file scope: a // named import in const clothing (builtinImportOf resolves uses) @@ -3555,6 +3559,9 @@ export function buildMain(lowerer: Lowerer): IrFunction { loc, }); } + // Classes with custom inspect methods register their compiled calls for + // the runtime's inspect walk before any module runs, in every thread. + body.unshift(...customInspectRegistrations(lowerer, loc)); // Node's startup refusal (a resolution the graph carries that Node // rejects — preflight's Node-order resolution walk — or a module-LINK // SyntaxError from either named-import checker): the graph is refused diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 9586727b3..84196476a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5422,6 +5422,12 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) const scope = decl.parent?.parent?.parent; if (symbol === undefined || (!ts.isBlock(scope) && !ts.isSourceFile(scope))) return null; + // A shorthand property's name (`{ width }`) resolves to the property + // symbol, but it reads the binding: the checker's shorthand-value symbol. + const refersToBinding = (node: ts.Identifier): boolean => + (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node + ? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent) + : lowerer.checker.getSymbolAtLocation(node)) === symbol; const isPlainAssignmentTarget = (node: ts.Identifier): boolean => { let target: ts.Expression = node; while (ts.isParenthesizedExpression(target.parent) && target.parent.expression === target) { @@ -5437,11 +5443,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) const captures = (fn: ts.Node): boolean => { let captured = false; ts.walkPreorder(fn, (node) => { - if ( - node !== fn && - ts.isIdentifier(node) && - lowerer.checker.getSymbolAtLocation(node) === symbol - ) { + if (node !== fn && ts.isIdentifier(node) && refersToBinding(node)) { captured = true; return "stop"; } @@ -5493,7 +5495,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) if ( ts.isIdentifier(node) && node !== decl.name && - lowerer.checker.getSymbolAtLocation(node) === symbol && + refersToBinding(node) && !isPlainAssignmentTarget(node) ) { reads.push(node); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 76559c5b8..1016130b6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -69,6 +69,7 @@ import { isDiffieHellmanType, lowerDiffieHellmanCall } from "./builtins/dh.js"; import { fenceWebCryptoMember, lowerWebCryptoCall } from "./builtins/webcrypto.js"; import { valueStreamOf } from "./builtins/hash-stream.js"; import { lowerNetAddressCall, lowerNetAddressNew } from "./builtins/net-address.js"; +import { lowerMimeCall, lowerMimeNew } from "./builtins/mime.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; import { iteratorSlotAdapter } from "./iterator-adapters.js"; import { iterableSlotValue } from "./iterable-values.js"; @@ -116,6 +117,7 @@ import { checkedNarrowCall, checkedNarrowOperand, deferredReadHelper, + widthNarrowHelper, } from "./coercions/unions.js"; import { lowerUtilTypeValue, @@ -176,7 +178,7 @@ import { import { DeferredModuleInitializers } from "./deferred-module-initializers.js"; import { finalizeClassMethodValues } from "./class-method-values.js"; import { finalizePrivateBrands } from "./private-brands.js"; -import { type ClassSymbolKey, tableKeyFieldType } from "./symbol-fields.js"; +import { type ClassSymbolKey, INSPECT_CUSTOM_MEMBER, tableKeyFieldType } from "./symbol-fields.js"; import type { HttpClientFnBinding } from "./lower-server.js"; export type { WidthLift } from "./width-lift.js"; export { newFnCtx, type FnCtx } from "./function-context.js"; @@ -461,7 +463,7 @@ import { bindingNeverReassigned } from "./binding-analysis.js"; import type { InstantiationPath } from "./instantiation-path.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import { completeArgs, wrappedUndefined, undefinedArgFor } from "./call-arguments.js"; -import { bodyReturnType, declaredReturnType } from "./function-returns.js"; +import { bodyReturnType, declaredReturnType, widensReducedRecords } from "./function-returns.js"; import { lowerCall, lowerPromiseMethodCall, lowerFilterNarrowCall } from "./lower-calls.js"; import { lowerFfiCall, validateFfiImports } from "./native-ffi.js"; import { @@ -2234,6 +2236,19 @@ export class Lowerer { * the body needs — indexed accesses (`T[K]` needs K's literal key) and * keyed record reads (`o[k]` where k's type is a literal-bound K). */ typeParamTsBindings: Map | null = null; + /** Generic alias references of call-keyed instances' declared signatures + * (`Catalog`), each with the same alias as every call's resolved + * signature instantiates it (`Catalog<{ g1: … }, "acme">`), together with + * that instance's ts-level bindings map (typeParamTsBindings), which every + * scope that lowers the instance installs. The checker keeps the alias + * symbolic inside the generic body and offers no way to instantiate it + * with the bound arguments, but alias instantiations are interned: the + * body's `x as Catalog` is the very type the signature declares, so + * the pair recorded during unification is that re-instantiation. */ + readonly boundAliasInstances = new Map< + ts.Type, + { tsBindings: Map; instance: ts.Type }[] + >(); /** Non-null while an IMPLICIT-ANY instance body lowers (npm-static JS; * see generic-functions.ts): bound param symbol → * the call site's checker type, consulted by typeOf for identifier @@ -3554,6 +3569,7 @@ export class Lowerer { this.internTableHelpers, this.coercions.retags, this.coercions.narrows, + this.coercions.widthNarrows, ]) for (const name of table.values()) interned.add(name); for (const fn of [...this.liftedFns, ...this.implicitFns]) @@ -3811,6 +3827,7 @@ export class Lowerer { classNamer: (decl) => this.classNamer(decl), resolveTypeParam: this.typeParamResolver, resolveTypeParamTs: this.typeParamTsResolver, + resolveBoundAliasTs: this.boundAliasTsResolver, isErasing: () => this.erasingDepth > 0, genericClassInstance: (decl, ref, mapArgument) => this.genericClassInstanceType(decl, ref, mapArgument), @@ -5235,11 +5252,11 @@ export class Lowerer { const sourceFiles = parts.map((p) => p.sf); const fnDecls = parts.flatMap((p) => p.fnDecls); // This fixed-point scan intentionally sees deferred bodies before - // reachability is known. Batch their binding queries through the - // facade's panic fence, then keep lookup free of lowering side effects: - // resolveValueSymbol also flushes deferred diagnostics and applies - // merged-namespace fences, whose authority remains ordinary lowering. - this.checker.prefetchSymbolRoots(sourceFiles, true); + // reachability is known. Its binding queries were batched through the + // facade's panic fence before collection (prefetchProgramSymbols); keep + // lookup free of lowering side effects: resolveValueSymbol also flushes + // deferred diagnostics and applies merged-namespace fences, whose + // authority remains ordinary lowering. // The facts each fixed-point site reads, by the symbols, nodes and // element keys they belong to (created with the site list below). let siteDependencies: SiteDependencies | null = null; @@ -8041,6 +8058,7 @@ export class Lowerer { if (this.remainder) { for (const fp of parts) this.checker.prefetchSourceFile(fp.sf); } + this.prefetchProgramSymbols(parts); this.collectProgram(parts); this.speculativeTypeQueries(() => { extendAssertedRecordMembers( @@ -8501,6 +8519,7 @@ export class Lowerer { this.onEdge = (name) => { collectionEdges.add(name); }; + this.prefetchProgramSymbols(parts); this.collectProgram(parts); this.speculativeTypeQueries(() => { extendAssertedRecordMembers( @@ -8687,6 +8706,19 @@ export class Lowerer { this.constructedClasses.add(name.slice(1, -".constructor".length)); for (const [owner, methods] of this.virtualEdges) for (const method of methods) this.noteVirtualEdge(owner, method); + // An instance's [util.inspect.custom] method runs from the runtime + // inspect walk (customInspectRegistrations), which no IR edge shows. + for ( + let c = this.classes.get(name.slice(1, -".constructor".length)) ?? null; + c; + c = c.base + ) { + const custom = `%${c.def.name}.${INSPECT_CUSTOM_MEMBER}`; + if (units.has(custom)) { + this.onEdge?.(custom); + break; + } + } } }; // Class EXPRESSIONS collect while init bodies lower (below): their @@ -9840,6 +9872,19 @@ export class Lowerer { return (sym && this.typeParamTsBindings.get(sym)) ?? null; }; + /** The concrete instantiation of a generic alias reference (`Catalog`) the current call-keyed instance's signature declares + * (boundAliasInstances), or null when its signature never instantiated + * that reference. Inert outside generic function instantiation. */ + readonly boundAliasTsResolver = (t: ts.Type): ts.Type | null => { + if (!this.typeParamTsBindings || t.getAliasTypeArguments().length === 0) return null; + const tsBindings = this.typeParamTsBindings; + return ( + this.boundAliasInstances.get(t)?.find((entry) => entry.tsBindings === tsBindings)?.instance ?? + null + ); + }; + irTypeOf(node: ts.Node): IrType { const t = this.typeOf(node); // JS inference can leave mutable fields, accessor parameters and @@ -10563,7 +10608,11 @@ export class Lowerer { expected.kind !== "void" && this.armTag(expr.type.unionId, expected) >= 0 ) { - const helper = this.narrowedArmHelper(expr.type.unionId, expected, expr.loc); + // A function's widened inferred return (widenReducedReturn) also + // holds records with more fields; those convert instead of throwing. + const helper = widensReducedRecords(this, expected, expr.type) + ? widthNarrowHelper(this, expr.type.unionId, expected, expr.loc) + : this.narrowedArmHelper(expr.type.unionId, expected, expr.loc); if (helper) { return this.checkedNarrowCall(helper, expr, expected, expr.loc); } @@ -10687,6 +10736,31 @@ export class Lowerer { } } } + // A promise or function value against a union whose ONLY arm of + // that kind differs from it (`Promise` into `Promise | + // undefined`, `() => Promise` into an optional + // `() => Promise` member): convert into + // that arm with the arm-level conversion (the checked payload + // widening, the function adapter), then wrap. Several arms of the + // kind stay ambiguous and fall through to the union fence. + if (expr.type.kind === "promise" || expr.type.kind === "func") { + const arms = this.unions.get(expected.unionId)?.arms ?? []; + const candidates = arms.filter((arm) => arm.kind === expr.type.kind); + const arm = candidates.length === 1 ? candidates[0]! : null; + if (arm) { + const converted = this.coerceToExpected(expr, arm); + if (converted !== expr && typeEquals(converted.type, arm)) { + return { + kind: "unionWrap", + unionId: expected.unionId, + tag: arms.indexOf(arm), + value: converted, + type: expected, + loc: expr.loc, + }; + } + } + } // A width-coercible value against a union: coerce into the SINGLE // width-liftable arm, then wrap like any arm value (widthLiftPlan's // liftWrap — several candidate arms are ambiguous and decline). @@ -12210,6 +12284,17 @@ export class Lowerer { return lowerClassExpressionInfo(this, expr); } + /** Batch-prefetches the symbols of every runtime identifier of the + * program's files. Collection's early-binding scan and the runtime- + * optional analysis look them up across every body, reached or not; + * one batch answers both, instead of one request per identifier. */ + private prefetchProgramSymbols(parts: FileParts[]): void { + this.checker.prefetchSymbolRoots( + parts.map((fp) => fp.sf), + true, + ); + } + /** Depth of the whole-program analyses that query types across every * file before reachability is known (speculativeTypeQueries). */ private speculativeTypeQueryDepth = 0; @@ -13807,6 +13892,7 @@ export class Lowerer { lowerEcdhCall(this, expr) ?? lowerDiffieHellmanCall(this, expr) ?? lowerNetAddressCall(this, expr) ?? + lowerMimeCall(this, expr) ?? lowerFetchElementMethodCall(this, expr) ?? lowerDynamicImportCall(this, expr) ?? lowerCall(this, expr) @@ -14044,6 +14130,7 @@ export class Lowerer { lowerResponseNew(this, expr) ?? lowerBlobNew(this, expr) ?? lowerNetAddressNew(this, expr) ?? + lowerMimeNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerStaticTransformStreamNew(this, expr) ?? lowerConsoleNew(this, expr); @@ -14488,6 +14575,10 @@ export class Lowerer { const assertion = assertFunctionValue(bi, loc); if (assertion) return assertion; if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc); + // util.inspect: the function custom inspect methods receive, one + // identity per thread. + if (bi.module === "util" && bi.member === "inspect") + return { kind: "libCall", fn: "insp.inspectFunction", args: [], type: DYN, loc }; if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member)) return lowerUtilTypeValue(this, bi.member, loc); if (bi.module === "util" && bi.member === "getSystemErrorMap") diff --git a/packages/compiler/src/frontend/lowering/record-inherited.ts b/packages/compiler/src/frontend/lowering/record-inherited.ts new file mode 100644 index 000000000..65d070f1d --- /dev/null +++ b/packages/compiler/src/frontend/lowering/record-inherited.ts @@ -0,0 +1,911 @@ +/* Object.prototype's methods on typed records. + * + * A record is an ordinary object, so `r.toString()`, `r.hasOwnProperty(k)` + * and the other Object.prototype methods answer from its prototype unless + * the record holds an own entry under that name. Method calls with a + * constant name call an own entry when the record's type lets one be + * callable and otherwise run the built-in natively with the record as its + * receiver. Keyed reads of records whose values are functions or checked + * values answer the built-in as a native function value on the miss path, + * so truthiness, typeof and calls behave as in Node; hits are untouched. + * Records of other value types keep reading undefined for these names + * (a documented limitation). No dynamic-tier unit is involved: every + * built-in is a small lifted function over the record. */ +import * as ts from "../ts7/adapter.js"; +import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { + BOOL, + DYN, + STRING, + UNDEFINED_T, + isUnitType, + typeEquals, + typeKey, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; +import { locOf } from "../program.js"; +import { keyedHelperName } from "./stable-names.js"; +import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; +import { nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js"; +import { recordHasOwnHelper } from "./lower-calls.js"; +import { OTHER_ARM_FAILURE_MESSAGE } from "./coercions/unions.js"; + +/** Object.prototype's methods a record answers from its prototype. */ +export const RECORD_INHERITED_METHODS: ReadonlySet = new Set([ + "toString", + "toLocaleString", + "valueOf", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", +]); + +type FuncType = IrType & { kind: "func" }; + +/** Declares a hidden local in the function being built: the lowerer's + * current function, or a lifted helper's own local list. */ +type DeclareLocal = (name: string, type: IrType) => string; + +function liftedLocals(): { declare: DeclareLocal; locals: IrLocal[] } { + const locals: IrLocal[] = []; + return { + locals, + declare: (name, type) => { + const id = `${name}.${locals.length}`; + locals.push({ id, name, type, mutable: false }); + return id; + }, + }; +} + +/** The form a read of `type` gives a built-in method value: the union's one + * function arm (beside an undefined arm), a checked value, or null when the + * type cannot hold a function. */ +function methodValueForm(lowerer: Lowerer, type: IrType): FuncType | "dyn" | null { + if (type.kind === "dyn") return "dyn"; + if (type.kind !== "union") return null; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + if (!arms.some((arm) => arm.kind === "undefinedT")) return null; + const funcs = arms.filter((arm): arm is FuncType => arm.kind === "func"); + if (funcs.length !== 1 || funcs[0]!.restAbi !== undefined) return null; + return funcs[0]!; +} + +/** A value of `type` may be a function that a call can enter. */ +function callableType(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "func" || type.kind === "dyn") return true; + if (type.kind !== "union") return false; + return (lowerer.unions.get(type.unionId)?.arms ?? []).some( + (arm) => arm.kind === "func" || arm.kind === "dyn", + ); +} + +/** A keyed record read (`r[k]`, `r["toString"]`) whose miss answers the + * inherited Object.prototype method for those names. Hits keep the + * ordinary read; only the miss path tests the key. `bound`: the read is + * the callee of a call (`r[k](…)`, `r[k]?.(…)`), whose `this` is the + * record; any other read yields the method as a plain value, whose calls + * see an undefined `this` as in Node. */ +export function withInheritedMethods( + lowerer: Lowerer, + read: IrExpr & { kind: "recordKeyGet" }, + litKey: string | null, + bound: boolean, +): IrExpr { + if (litKey !== null && !RECORD_INHERITED_METHODS.has(litKey)) return read; + if (read.missNaN === true) return read; + const form = methodValueForm(lowerer, read.type); + const shape = lowerer.shapes.get(read.shapeId); + if (form === null || !shape || shape.tuple) return read; + const loc = read.loc; + const stmts: IrStmt[] = []; + const repeatable = (value: IrExpr): boolean => isSafeToRepeat(value) || value.kind === "strLit"; + const stable = (value: IrExpr, name: string, keep: boolean): IrExpr => { + if (keep) return value; + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, value.type, loc); + }; + // The receiver is evaluated before the key: when computing the key runs + // code (it may reassign the receiver's variable), the receiver is held + // first, even when reading it again would be safe on its own. + const keyRepeatable = repeatable(read.key); + const obj = stable(read.obj, "%inheritedRecv", repeatable(read.obj) && keyRepeatable); + const key = stable(read.key, "%inheritedKey", keyRepeatable); + const helper = inheritedReadHelper(lowerer, read.shapeId, read.type, form, bound, loc); + const fallback: IrExpr = { kind: "call", callee: helper, args: [obj, key], type: read.type, loc }; + if ( + form !== "dyn" && + read.type.kind === "union" && + !(lowerer.unions.get(read.type.unionId)?.arms ?? []).some((arm) => arm.kind === "nullT") + ) { + // `r[k] ?? inherited(r, k)`: the hit is the read's own value, untouched. + const coalesced: IrExpr = { + kind: "nullish", + left: { ...read, obj, key }, + right: fallback, + type: read.type, + loc, + }; + return stmts.length === 0 + ? coalesced + : { kind: "seqExpr", stmts, result: coalesced, type: read.type, loc }; + } + const value = lowerer.declareHiddenLocal("%inheritedRead", read.type); + stmts.push({ kind: "varDecl", localId: value.id, init: { ...read, obj, key }, loc }); + const found = varRef(value.id, read.type, loc); + let missing: IrExpr; + if (form === "dyn") { + missing = { kind: "dynTest", test: "undefined", value: found, type: BOOL, loc } as IrExpr; + } else { + const unionId = (read.type as IrType & { kind: "union" }).unionId; + const tag = (lowerer.unions.get(unionId)?.arms ?? []).findIndex( + (arm) => arm.kind === "undefinedT", + ); + missing = { kind: "unionIsTag", unionId, tag, negated: false, value: found, type: BOOL, loc }; + } + return { + kind: "seqExpr", + stmts, + result: { + kind: "ternary", + cond: missing, + then: fallback, + else_: found, + type: read.type, + loc, + }, + type: read.type, + loc, + }; +} + +/** `%rec.inherited.(o, k)`: the inherited method `k` names as a value + * of `readType` (bound to `o`), or undefined. */ +function inheritedReadHelper( + lowerer: Lowerer, + shapeId: string, + readType: IrType, + form: FuncType | "dyn", + bound: boolean, + loc: SrcLoc, +): string { + const key = `rec.inherited:${bound ? "bound" : "value"}:${shapeId}:${typeKey(readType)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%rec.inherited", key); + lowerer.arrHofHelpers.set(key, name); + const recT: IrType = { kind: "record", shapeId }; + const o = varRef("o.0", recT, loc); + const k = varRef("k.0", STRING, loc); + const fnType: FuncType = + form === "dyn" ? ({ kind: "func", params: [DYN], ret: DYN } as FuncType) : form; + const undefinedValue: IrExpr = + form === "dyn" + ? dynUndefinedExpr(loc) + : lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + readType, + ); + const named = (method: string): IrExpr => ({ + kind: "strEq", + negated: false, + left: k, + right: { kind: "strLit", value: method, type: STRING, loc }, + type: BOOL, + loc, + }); + const methods = [...RECORD_INHERITED_METHODS]; + let anyName: IrExpr = named(methods[0]!); + for (const method of methods.slice(1)) + anyName = { kind: "logical", op: "||", left: anyName, right: named(method), type: BOOL, loc }; + const body: IrStmt[] = [ + // Most misses name no inherited method: one comparison chain, nothing else. + { + kind: "if", + cond: { kind: "unary", op: "!", operand: anyName, type: BOOL, loc }, + then: [{ kind: "return", value: { ...undefinedValue }, loc }], + else_: null, + loc, + }, + // An own key holding undefined shadows the prototype like any own key. + { + kind: "if", + cond: { + kind: "call", + callee: recordHasOwnHelper(lowerer, shapeId, loc), + args: [o, k], + type: BOOL, + loc, + }, + then: [{ kind: "return", value: { ...undefinedValue }, loc }], + else_: null, + loc, + }, + ]; + if (nullProtoRecordShape(lowerer, recT)) + body.push({ + kind: "if", + cond: recordNullProtoTest(o, shapeId, loc), + then: [{ kind: "return", value: { ...undefinedValue }, loc }], + else_: null, + loc, + }); + for (const method of methods) { + const value: IrExpr = bound + ? { + kind: "call", + callee: boundMethodFactory(lowerer, shapeId, method, fnType, loc), + args: [o], + type: fnType, + loc, + } + : { + kind: "closure", + fnName: unboundMethodImpl(lowerer, method, fnType, loc), + captures: [], + type: fnType, + loc, + }; + body.push({ + kind: "if", + cond: named(method), + then: [ + { + kind: "return", + value: lowerer.coerceToExpected(value, form === "dyn" ? DYN : readType), + loc, + }, + ], + else_: null, + loc, + }); + } + body.push({ kind: "return", value: { ...undefinedValue }, loc }); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "o.0", name: "o", type: recT }, + { localId: "k.0", name: "k", type: STRING }, + ], + returnType: readType, + locals: [ + { id: "o.0", name: "o", type: recT, mutable: false }, + { id: "k.0", name: "k", type: STRING, mutable: false }, + ], + body, + loc, + }); + return name; +} + +/** `%rec.bind.(o)`: the built-in `method` bound to `o` as a closure. */ +function boundMethodFactory( + lowerer: Lowerer, + shapeId: string, + method: string, + fnType: FuncType, + loc: SrcLoc, +): string { + const key = `rec.bind:${shapeId}:${method}:${typeKey(fnType)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%rec.bind", key); + lowerer.arrHofHelpers.set(key, name); + const recT: IrType = { kind: "record", shapeId }; + lowerer.liftedFns.push({ + name, + params: [{ localId: "o.0", name: "o", type: recT }], + returnType: fnType, + locals: [{ id: "o.0", name: "o", type: recT, mutable: false, boxed: true }], + body: [ + { + kind: "return", + value: { + kind: "closure", + fnName: inheritedMethodImpl(lowerer, shapeId, method, fnType, loc), + captures: ["o.0"], + type: fnType, + loc, + }, + loc, + }, + ], + loc, + }); + return name; +} + +/** The lifted body of one built-in bound to its record, with the read's + * function signature: the built-in's answer converted to the signature's + * return type, or the checked-conversion TypeError where it cannot be. */ +function inheritedMethodImpl( + lowerer: Lowerer, + shapeId: string, + method: string, + fnType: FuncType, + loc: SrcLoc, +): string { + const key = `rec.inheritedImpl:${shapeId}:${method}:${typeKey(fnType)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, `%rec.${method}`, key); + lowerer.arrHofHelpers.set(key, name); + const recT: IrType = { kind: "record", shapeId }; + const o = varRef("o.0", recT, loc); + const params = fnType.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type })); + const args = params.map((p) => varRef(p.localId, p.type, loc)); + const body: IrStmt[] = []; + const hidden = liftedLocals(); + const answer = builtinAnswer(lowerer, hidden.declare, o, shapeId, method, args, fnType.ret, loc); + body.push(...returnAs(lowerer, answer, fnType.ret, loc)); + lowerer.liftedFns.push({ + name, + params, + returnType: fnType.ret, + captures: [{ localId: "o.0", name: "o", type: recT }], + locals: [ + { id: "o.0", name: "o", type: recT, mutable: false, boxed: true }, + ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + ...hidden.locals, + ], + body, + loc, + }); + return name; +} + +/** One built-in as a plain function value (no receiver): its answer for an + * undefined `this`, converted like inheritedMethodImpl's. */ +function unboundMethodImpl( + lowerer: Lowerer, + method: string, + fnType: FuncType, + loc: SrcLoc, +): string { + const key = `rec.unboundImpl:${method}:${typeKey(fnType)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, `%obj.${method}`, key); + lowerer.arrHofHelpers.set(key, name); + const params = fnType.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type })); + const arg: IrExpr | undefined = params[0] && varRef(params[0].localId, params[0].type, loc); + const coerce = "Cannot convert undefined or null to object"; + let answer: IrExpr; + switch (method) { + case "toString": + answer = { kind: "strLit", value: "[object Undefined]", type: STRING, loc }; + break; + case "toLocaleString": + answer = nodeThrowExpr( + 1, + "", + "Object.prototype.toLocaleString called on null or undefined", + STRING, + loc, + ); + break; + case "isPrototypeOf": { + // A primitive argument answers false before `this` converts. + const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + const kind = arg?.type.kind; + if ( + arg === undefined || + isUnitType(arg.type) || + kind === "string" || + kind === "f64" || + kind === "bool" || + kind === "bigint" + ) + answer = no; + else if (kind === "dyn") + answer = { + kind: "ternary", + cond: { + kind: "logical", + op: "||", + left: { kind: "dynTest", test: "object", value: arg, type: BOOL, loc }, + right: { kind: "dynTest", test: "function", value: arg, type: BOOL, loc }, + type: BOOL, + loc, + } as IrExpr, + then: nodeThrowExpr(1, "", coerce, BOOL, loc), + else_: no, + type: BOOL, + loc, + }; + else answer = nodeThrowExpr(1, "", coerce, BOOL, loc); + break; + } + default: + answer = nodeThrowExpr(1, "", coerce, method === "valueOf" ? DYN : BOOL, loc); + } + const body: IrStmt[] = + answer.kind === "libCall" && answer.fn === "error.nodeThrow" + ? [ + { + kind: "return", + value: nodeThrowExpr( + 1, + "", + (answer.args[2] as IrExpr & { kind: "strLit" }).value, + fnType.ret, + loc, + ), + loc, + }, + ] + : returnAs(lowerer, answer, fnType.ret, loc); + lowerer.liftedFns.push({ + name, + params, + returnType: fnType.ret, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body, + loc, + }); + return name; +} + +/** The built-in's answer with `o` as its receiver. `want` is the type the + * caller needs (valueOf answers the record itself). */ +function builtinAnswer( + lowerer: Lowerer, + declare: DeclareLocal, + o: IrExpr, + shapeId: string, + method: string, + args: readonly IrExpr[], + want: IrType, + loc: SrcLoc, +): IrExpr { + switch (method) { + case "toString": + return { kind: "strLit", value: "[object Object]", type: STRING, loc }; + case "toLocaleString": + // Object.prototype.toLocaleString calls this.toString(). + return callOwnOrBuiltin( + lowerer, + declare, + o, + shapeId, + "toString", + [], + STRING, + "toString", + loc, + ); + case "valueOf": + return want.kind === "dyn" ? lowerer.coerceToExpected(o, DYN) : o; + case "isPrototypeOf": + // No object is created with a typed record as its prototype. + return { kind: "boolLit", value: false, type: BOOL, loc }; + default: { + // hasOwnProperty and propertyIsEnumerable: ToPropertyKey keeps a + // symbol a symbol, and a record's own keys are strings, so a symbol + // key is no entry of it. + const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + const arg = args[0]; + if (arg?.type.kind === "symbol") return sequence(arg, no, loc); + const has = (key: IrExpr): IrExpr => ({ + kind: "call", + callee: recordHasOwnHelper(lowerer, shapeId, loc), + args: [o, key], + type: BOOL, + loc, + }); + if (arg === undefined || !mayBeSymbol(arg.type)) return has(propertyKeyOf(lowerer, arg, loc)); + const id = declare("%protoKey", DYN); + const held = varRef(id, DYN, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: id, init: lowerer.coerceToExpected(arg, DYN), loc }], + result: { + kind: "ternary", + cond: { kind: "dynTest", test: "symbol", value: held, type: BOOL, loc } as IrExpr, + then: no, + else_: has(propertyKeyOf(lowerer, held, loc)), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + } +} + +/** Whether a value of `type` may be a symbol at run time. */ +function mayBeSymbol(type: IrType): boolean { + return !( + isUnitType(type) || + type.kind === "string" || + type.kind === "f64" || + type.kind === "bool" + ); +} + +/** ToPropertyKey of a built-in's key argument (absent: "undefined"), for + * keys that are not symbols. */ +function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc): IrExpr { + if (arg === undefined || isUnitType(arg.type)) + return { + kind: "strLit", + value: arg?.type.kind === "nullT" ? "null" : "undefined", + type: STRING, + loc, + }; + if (arg.type.kind === "string") return arg; + if (arg.type.kind === "f64" || arg.type.kind === "bool") + return { kind: "toString", operand: arg, type: STRING, loc }; + return { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [lowerer.coerceToExpected(arg, DYN)], + type: STRING, + loc, + }; +} + +/** `value` returned as `ret`: as is, wrapped into a union arm or a checked + * value, discarded for void, or the checked-conversion TypeError. */ +function returnAs(lowerer: Lowerer, value: IrExpr, ret: IrType, loc: SrcLoc): IrStmt[] { + const converted = convertTo(lowerer, value, ret); + if (converted !== null) return [{ kind: "return", value: converted, loc }]; + if (ret.kind === "void") + return [ + { kind: "exprStmt", expr: value, loc }, + { kind: "return", value: null, loc }, + ]; + return [ + { kind: "exprStmt", expr: value, loc }, + { kind: "return", value: nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, ret, loc), loc }, + ]; +} + +function convertTo(lowerer: Lowerer, value: IrExpr, want: IrType): IrExpr | null { + if (typeEquals(value.type, want)) return value; + if (want.kind === "dyn" && lowerer.dynConvertible(value.type)) + return lowerer.coerceToExpected(value, DYN); + if (want.kind === "union" && lowerer.armTag(want.unionId, value.type) >= 0) + return lowerer.coerceToExpected(value, want); + return null; +} + +/** `o.method(...args)` on a record: an own callable entry when the record + * holds one, else the built-in. `calleeText` names the callee for Node's + * "is not a function". */ +function callOwnOrBuiltin( + lowerer: Lowerer, + declare: DeclareLocal, + o: IrExpr, + shapeId: string, + method: string, + args: readonly IrExpr[], + want: IrType, + calleeText: string, + loc: SrcLoc, + resolved?: { has: IrExpr; own: IrExpr }, +): IrExpr { + const shape = lowerer.shapes.get(shapeId)!; + const field = shape.fields.find((f) => f.name === method); + const ownType = field ? field.type : shape.indexValue; + let builtin = builtinAnswer(lowerer, declare, o, shapeId, method, args, want, loc); + const recT: IrType = { kind: "record", shapeId }; + if (nullProtoRecordShape(lowerer, recT)) + builtin = { + kind: "ternary", + cond: recordNullProtoTest(o, shapeId, loc), + then: nodeThrowExpr(1, "", `${calleeText} is not a function`, builtin.type, loc), + else_: builtin, + type: builtin.type, + loc, + }; + const builtinAsWant = convertTo(lowerer, builtin, want) ?? builtin; + if (ownType === undefined || !callableType(lowerer, ownType)) return builtinAsWant; + const own = resolved?.own ?? ownEntryRead(lowerer, o, shapeId, method, loc); + const called = callOwn(lowerer, declare, own, args, builtinAsWant.type, calleeText, loc); + return { + kind: "ternary", + cond: resolved?.has ?? ownEntryTest(lowerer, o, shapeId, method, loc), + then: called, + else_: builtinAsWant, + type: builtinAsWant.type, + loc, + }; +} + +/** Whether the record holds an own entry named `method`. */ +function ownEntryTest( + lowerer: Lowerer, + o: IrExpr, + shapeId: string, + method: string, + loc: SrcLoc, +): IrExpr { + return { + kind: "call", + callee: recordHasOwnHelper(lowerer, shapeId, loc), + args: [o, { kind: "strLit", value: method, type: STRING, loc }], + type: BOOL, + loc, + }; +} + +/** The record's own entry named `method`: its field, or its overflow entry + * (undefined when absent). */ +function ownEntryRead( + lowerer: Lowerer, + o: IrExpr, + shapeId: string, + method: string, + loc: SrcLoc, +): IrExpr { + const shape = lowerer.shapes.get(shapeId)!; + const field = shape.fields.find((f) => f.name === method); + return field + ? { kind: "recordGet", obj: o, shapeId, field: method, type: field.type, loc } + : { + kind: "recordKeyGet", + obj: o, + shapeId, + key: { kind: "strLit", value: method, type: STRING, loc }, + overflowOnly: true, + type: lowerer.withUndefinedArmOf(shape.indexValue!) ?? shape.indexValue!, + loc, + }; +} + +/** Call the own entry `own` (present, of any stored type) with `args`. A + * non-function throws Node's "is not a function"; a function whose + * parameters or result cannot carry the call throws the checked-conversion + * TypeError (the program's types claimed otherwise). */ +function callOwn( + lowerer: Lowerer, + declare: DeclareLocal, + own: IrExpr, + args: readonly IrExpr[], + want: IrType, + calleeText: string, + loc: SrcLoc, +): IrExpr { + const notFunction = nodeThrowExpr(1, "", `${calleeText} is not a function`, want, loc); + const lie = nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, want, loc); + const localId = declare("%ownMethod", own.type); + const value = varRef(localId, own.type, loc); + const enter = (fn: IrExpr): IrExpr => { + if (fn.type.kind === "dyn") { + const result: IrExpr = { + kind: "dynCall", + callee: fn, + calleeName: calleeText, + args: args.map((a) => lowerer.coerceToExpected(a, DYN)), + type: DYN, + loc, + } as IrExpr; + return convertTo(lowerer, result, want) ?? sequence(result, lie, loc); + } + const sig = fn.type as FuncType; + if (sig.restAbi !== undefined) return lie; + const passed: IrExpr[] = []; + for (let i = 0; i < sig.params.length; i++) { + const param = sig.params[i]!; + const arg = args[i]; + const given = + arg === undefined + ? convertTo( + lowerer, + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + param, + ) + : convertTo(lowerer, arg, param); + if (given === null) return lie; + passed.push(given); + } + const result: IrExpr = { kind: "callValue", callee: fn, args: passed, type: sig.ret, loc }; + if (sig.ret.kind === "void") return sequence(result, lie, loc); + return convertTo(lowerer, result, want) ?? sequence(result, lie, loc); + }; + let call: IrExpr; + if (own.type.kind === "func" || own.type.kind === "dyn") call = enter(value); + else if (own.type.kind === "union") { + const unionId = own.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms ?? []; + call = notFunction; + for (let tag = arms.length - 1; tag >= 0; tag--) { + const arm = arms[tag]!; + if (arm.kind !== "func" && arm.kind !== "dyn") continue; + call = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value, type: BOOL, loc }, + then: enter({ kind: "unionNarrow", unionId, tag, value, type: arm, loc }), + else_: call, + type: want, + loc, + }; + } + } else call = notFunction; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId, init: own, loc }], + result: call, + type: want, + loc, + }; +} + +function sequence(first: IrExpr, result: IrExpr, loc: SrcLoc): IrExpr { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: first, loc }], + result, + type: result.type, + loc, + }; +} + +/** `r.toString()`, `r.hasOwnProperty(k)` and the other Object.prototype + * methods called by name on a typed record (the checker resolved the + * member to Object.prototype's). Null for other calls. */ +export function lowerRecordInheritedCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + const method = access.name.text; + if ( + call.questionDotToken || + access.questionDotToken || + !RECORD_INHERITED_METHODS.has(method) || + call.arguments.nodes.some(ts.isSpreadElement) || + !lowerer.isStdlibMember(access) + ) + return null; + const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + if (recvT?.kind !== "record") return null; + const shape = lowerer.shapes.get(recvT.shapeId); + if (!shape || shape.tuple) return null; + const want = + method === "valueOf" + ? (lowerer.mapTypeOf(lowerer.typeOf(call)) ?? recvT) + : method === "toString" || method === "toLocaleString" + ? STRING + : BOOL; + // Without a possible own function, toString and the own-key tests keep + // their existing lowerings (with the special record views they know). + if ( + (method === "hasOwnProperty" || method === "propertyIsEnumerable" || method === "toString") && + !shape.fields.some((f) => f.name === method && callableType(lowerer, f.type)) && + !(shape.indexValue && callableType(lowerer, shape.indexValue)) + ) + return null; + const loc = locOf(call); + const stmts: IrStmt[] = []; + const receiver = lowerer.lowerExpr(access.expression); + if (receiver.type.kind !== "record") return null; + const recvLocal = lowerer.declareHiddenLocal("%protoRecv", receiver.type); + stmts.push({ kind: "varDecl", localId: recvLocal.id, init: receiver, loc }); + const o = varRef(recvLocal.id, receiver.type, loc); + // `o.m(...)` reads the member before its arguments run: an argument that + // adds, deletes or replaces the own method does not change the callee. + const field = shape.fields.find((f) => f.name === method); + const ownType = field ? field.type : shape.indexValue; + let resolved: { has: IrExpr; own: IrExpr } | undefined; + if (ownType !== undefined && callableType(lowerer, ownType)) { + const shapeId = receiver.type.shapeId; + const hasLocal = lowerer.declareHiddenLocal("%protoHas", BOOL); + stmts.push({ + kind: "varDecl", + localId: hasLocal.id, + init: ownEntryTest(lowerer, o, shapeId, method, loc), + loc, + }); + const read = ownEntryRead(lowerer, o, shapeId, method, loc); + const ownLocal = lowerer.declareHiddenLocal("%protoOwn", read.type); + stmts.push({ kind: "varDecl", localId: ownLocal.id, init: read, loc }); + resolved = { has: varRef(hasLocal.id, BOOL, loc), own: varRef(ownLocal.id, read.type, loc) }; + } + const args: IrExpr[] = []; + for (const node of call.arguments.nodes) { + const arg = lowerer.lowerExpr(node); + const local = lowerer.declareHiddenLocal("%protoArg", arg.type); + stmts.push({ kind: "varDecl", localId: local.id, init: arg, loc }); + args.push(varRef(local.id, arg.type, loc)); + } + const result = callOwnOrBuiltin( + lowerer, + (name, type) => lowerer.declareHiddenLocal(name, type).id, + o, + receiver.type.shapeId, + method, + args, + want, + ts.getTextOfNode(access), + loc, + resolved, + ); + return { kind: "seqExpr", stmts, result, type: result.type, loc }; +} + +/** `r.toString` and the other Object.prototype methods read by name (not + * called) on an index-signature record: an own entry of the member's + * function type when the record holds one, else the built-in as a plain + * function value. An own entry of another type than the member's (a + * function of another signature) throws the checked-conversion TypeError: + * the program's types claimed the member. Null when the checker's member + * type is not a function. */ +export function lowerRecordInheritedMember( + lowerer: Lowerer, + receiver: IrExpr, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + const method = expr.name.text; + if (!RECORD_INHERITED_METHODS.has(method) || !lowerer.isStdlibMember(expr)) return null; + if (receiver.type.kind !== "record") return null; + const want = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (want?.kind !== "func" || want.restAbi !== undefined) return null; + const shapeId = receiver.type.shapeId; + const shape = lowerer.shapes.get(shapeId); + if (!shape || shape.tuple) return null; + const loc = locOf(expr); + const builtin: IrExpr = { + kind: "closure", + fnName: unboundMethodImpl(lowerer, method, want, loc), + captures: [], + type: want, + loc, + }; + const recvLocal = lowerer.declareHiddenLocal("%protoRecv", receiver.type); + const o = varRef(recvLocal.id, receiver.type, loc); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: recvLocal.id, init: receiver, loc }]; + let result: IrExpr = builtin; + if (shape.indexValue && typeEquals(shape.indexValue, want)) { + const ownType = lowerer.withUndefinedArmOf(want); + if (ownType?.kind === "union") { + const read: IrExpr = { + kind: "recordKeyGet", + obj: o, + shapeId, + key: { kind: "strLit", value: method, type: STRING, loc }, + overflowOnly: true, + type: ownType, + loc, + }; + const tag = lowerer.armTag(ownType.unionId, want); + const ownLocal = lowerer.declareHiddenLocal("%ownMethod", ownType); + const own = varRef(ownLocal.id, ownType, loc); + stmts.push({ kind: "varDecl", localId: ownLocal.id, init: read, loc }); + result = { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: ownType.unionId, + tag, + negated: false, + value: own, + type: BOOL, + loc, + }, + then: { kind: "unionNarrow", unionId: ownType.unionId, tag, value: own, type: want, loc }, + else_: builtin, + type: want, + loc, + }; + } + } else if (shape.indexValue && callableType(lowerer, shape.indexValue)) { + result = { + kind: "ternary", + cond: ownEntryTest(lowerer, o, shapeId, method, loc), + then: nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, want, loc), + else_: builtin, + type: want, + loc, + }; + } + return { kind: "seqExpr", stmts, result, type: want, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index b5051d79d..cf0428b60 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1740,7 +1740,8 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { // lowerVarDecl / collectGlobals BEFORE any call lowering runs — and // inspect/format/formatWithOptions plus parseArgs through the util spoke // (lower-inspect.ts: per-type synthesized traversal helpers, - // compile-time format strings, checked-dynamic config/result for + // compile-time format strings and the runtime formatter for the others, + // checked-dynamic config/result for // parseArgs), which both dispatch paths try before the generic table. // parseArgs is tabled as well so the generated surface manifest records // the dedicated static member; the spoke owns its optional arity and @@ -1767,6 +1768,67 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { ], }, parseArgs: { fn: "util.parseArgs", params: [DYN], result: DYN }, + aborted: { + fn: "util.aborted", + params: [DYN, DYN], + result: DYN, + valueParams: [ + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, + diff: { + fn: "util.diff", + params: [DYN, DYN], + result: DYN, + valueParams: [ + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, + debounce: { + fn: "util.debounce", + params: [DYN, DYN, DYN], + result: DYN, + valueParams: [ + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, + throttle: { + fn: "util.throttle", + params: [DYN, DYN, DYN, DYN], + result: DYN, + valueParams: [ + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, + convertProcessSignalToExitCode: { + fn: "util.signalExitCode", + params: [DYN], + result: F64, + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, + // The deprecated helpers report DEP0044 and DEP0060 on their first call. + isArray: { + fn: "util.isArrayDeprecated", + params: [DYN], + result: BOOL, + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, + _extend: { + fn: "util.extend", + params: [DYN, DYN], + result: DYN, + valueParams: [ + { mode: "optional", type: DYN, defaultValue: undefined }, + { mode: "optional", type: DYN, defaultValue: undefined }, + ], + }, parseEnv: { fn: "util.parseEnv", params: [DYN], result: DYN }, getSystemErrorName: { fn: "util.getSystemErrorName", diff --git a/packages/compiler/src/frontend/lowering/symbol-fields.ts b/packages/compiler/src/frontend/lowering/symbol-fields.ts index 958af5963..d017cdc7f 100644 --- a/packages/compiler/src/frontend/lowering/symbol-fields.ts +++ b/packages/compiler/src/frontend/lowering/symbol-fields.ts @@ -76,7 +76,36 @@ export function fenceSymbolFieldCopy( } } +/** The registry name util.inspect.custom denotes. */ +export const INSPECT_CUSTOM_DESCRIPTION = "nodejs.util.inspect.custom"; +/** The method slot of a class's `[util.inspect.custom]` member. */ +export const INSPECT_CUSTOM_MEMBER = `${FIELD_PREFIX}Symbol(${INSPECT_CUSTOM_DESCRIPTION})`; + +/** `inspect.custom` over node:util's inspect: a named import, `util.inspect` + * or the CommonJS `util_1.inspect`. */ +export function isInspectCustomAccess(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (!ts.isPropertyAccessExpression(expr) || expr.questionDotToken) return false; + if (!ts.isIdentifier(expr.name) || expr.name.text !== "custom") return false; + let target = expr.expression; + while (ts.isParenthesizedExpression(target)) target = target.expression; + const bi = ts.isIdentifier(target) + ? lowerer.builtinImportOf(target) + : ts.isPropertyAccessExpression(target) && !target.questionDotToken + ? lowerer.builtinMemberOf(target) + : null; + return bi?.module === "util" && bi.member === "inspect"; +} + export function classSymbolKeyOf(lowerer: Lowerer, key: ts.Expression): ClassSymbolKey | null { + // util.inspect.custom is the registered Symbol.for(INSPECT_CUSTOM_DESCRIPTION), + // one identity with the program's own Symbol.for calls of that name. + if (isInspectCustomAccess(lowerer, key)) { + let e = key; + while (ts.isParenthesizedExpression(e)) e = e.expression; + const sym = lowerer.checker.getSymbolAtLocation((e as ts.PropertyAccessExpression).name); + return sym ? symbolKeyOf(lowerer, sym, true, INSPECT_CUSTOM_DESCRIPTION) : null; + } const wellKnown = wellKnownSymbolKeyOf(lowerer, key); if (wellKnown !== undefined) return wellKnown; if ( diff --git a/packages/compiler/src/frontend/lowering/union-narrow.ts b/packages/compiler/src/frontend/lowering/union-narrow.ts index a167631fc..99cca9941 100644 --- a/packages/compiler/src/frontend/lowering/union-narrow.ts +++ b/packages/compiler/src/frontend/lowering/union-narrow.ts @@ -32,6 +32,9 @@ export function buildUnionNarrow( formatType: (type: IrType) => string, undefinedDefault: IrExpr | null = null, failureMessage: string | null = null, + /** Converts another arm's payload into the target (a record arm that is + * a width subtype of a record target), or null to throw. */ + convert: ((tag: number, payload: IrExpr) => IrExpr | null) | null = null, ): IrFunction | null { if (isUnitType(target) || target.kind === "void") return null; const targetTag = from.arms.findIndex((arm) => typeEquals(arm, target)); @@ -47,6 +50,7 @@ export function buildUnionNarrow( const inputType: IrType = { kind: "union", unionId: from.id }; const input: IrExpr = { kind: "varRef", localId: "u.0", type: inputType, loc }; const body: IrStmt[] = []; + let converted: IrExpr | null = null; for (let tag = 0; tag < from.arms.length; tag++) { const arm = from.arms[tag]!; let branch: IrStmt[]; @@ -60,6 +64,19 @@ export function buildUnionNarrow( ]; } else if (arm.kind === "undefinedT" && undefinedDefault !== null) { branch = [{ kind: "return", value: undefinedDefault, loc }]; + } else if ( + convert !== null && + !isUnitType(arm) && + (converted = convert(tag, { + kind: "unionNarrow", + unionId: from.id, + tag, + value: input, + type: arm, + loc, + })) !== null + ) { + branch = [{ kind: "return", value: converted, loc }]; } else { branch = [ { diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index ed442be79..8e26e3058 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -23,7 +23,7 @@ import { trackedRealpath, } from "./input-tracker.js"; import { npmBuiltinAliasOf } from "./builtin-modules.js"; -import { packageSourceText } from "./type-patches/layer.js"; +import { packageSourceText, typePatchSuppliesTypes } from "./type-patches/layer.js"; let activePackages: ReadonlySet = new Set(); let declarationOverloads: ReadonlyMap = new Map(); @@ -374,6 +374,7 @@ function packageIsUntyped(path: string): boolean { /* no package.json — keep probing */ } if (untyped && trackedExists(`${pkgDir}/index.d.ts`)) untyped = false; + if (untyped && typePatchSuppliesTypes(pkgDir)) untyped = false; if (untyped) { // the @types twin, hoisted anywhere up the realm chain const mangled = mangledTypesName(dirName); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index b93e3f217..284b2ce06 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -665,6 +665,14 @@ function createRequireProgramRoots7(program: ts.Program): string[] { return [...roots].sort(); } +/** Whether the tsc gate can keep a file's diagnostics when the program + * loads: it drops all of an opted-in package's files and of the package + * shims (keptErrors in preflight also drops unselected package JavaScript, + * which is not known yet here, so those files are still checked). */ +function tscGateFileKept(fileName: string): boolean { + return npmStaticPackageOfPath(fileName) === null && !isPackageShimFile(fileName); +} + /** Classify the explicitly selected installed package's own sources. */ export function entryPackageFilePredicate(entryPath: string): (file: string) => boolean { const entryFile = tsgoPath(entryPath); @@ -934,7 +942,7 @@ function loadProgram7( const original = program; // The project world must read the original text too, so it is built // before the rewritten files are served, and only when it is needed. - const diagnostics = ts.getPreEmitDiagnostics(original); + const diagnostics = ts.getPreEmitDiagnostics(original, tscGateFileKept); const errors = diagnostics.some((d) => d.category === ts.DiagnosticCategory.Error); tscWorld = { program: original, @@ -982,7 +990,7 @@ function loadProgram7( } else { tscWorld = { program: original, - diagnostics: ts.getPreEmitDiagnostics(original), + diagnostics: ts.getPreEmitDiagnostics(original, tscGateFileKept), projectWorld: null, declWorldFallback: true, }; @@ -5438,7 +5446,14 @@ function preflight7(load: LoadResult): { const sf = p.getSourceFile(d.fileName); return sf !== undefined && isCheckedJsSourceFile(sf, projectCheckJs); }; - const errorsOf = (p: ts.Program): ts.Diagnostic[] => keptErrors(p, ts.getPreEmitDiagnostics(p)); + // keptErrors drops every diagnostic of these files: the gate does not ask + // the checker for them. + const diagnosticsKept = (fileName: string): boolean => + npmStaticPackageOfPath(fileName) === null && + !unselectedPackageJsFile(fileName) && + !isPackageShimFile(fileName); + const errorsOf = (p: ts.Program): ts.Diagnostic[] => + keptErrors(p, ts.getPreEmitDiagnostics(p, diagnosticsKept)); const keptErrors = (p: ts.Program, all: readonly ts.Diagnostic[]): ts.Diagnostic[] => { // First pass: every comment-side 2300's (file, name) — the partners // the second pass forgives. diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 5b035b6cb..86252499f 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -37,9 +37,11 @@ import { trackedRealpath, } from "./input-tracker.js"; import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js"; +import { typePatchFileExists } from "./type-patches/layer.js"; function isFile(path: string): boolean { - return trackedFileExists(path); + // A declaration entry a project type patch supplies (type-patches/layer.ts). + return trackedFileExists(path) || typePatchFileExists(path); } function isDirectory(path: string): boolean { diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts index e87db5f1a..83abaeddc 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterAll, afterEach, expect, test } from "vitest"; import type { SourceFile } from "./ast-types.js"; import { + reusedKeptLibDiagnostics, reusedLibDiagnostics, setLibDiagnosticsStore, type LibDiagnosticsStore, @@ -118,6 +119,45 @@ test("a stored answer stands in for checking the supplied files, merged by file expect(full?.semantic).toEqual(reused?.semantic); }); +test("with kept files, a stored answer needs checking only the kept other files", () => { + const store = memoryStore(); + setLibDiagnosticsStore(store, "checker"); + const pkg = join(dir, "node_modules", "pkg", "index.d.ts"); + const semantic = new Map([ + [lib, [diagnostic(lib, 1)]], + [fallback, []], + [main, [diagnostic(main, 5)]], + [pkg, [diagnostic(pkg, 3)]], + ]); + const withPackage = files({ + [main]: "export const n: number = 'x';\n", + [pkg]: "export declare const p: number;\n", + }); + const all = [lib, fallback, main, pkg]; + const keep = new Set([lib, fallback, main]); + // Without a stored answer nothing is checked or stored. + const cold = sessionProgram({ semantic }); + expect(reusedKeptLibDiagnostics(cold, all, supplied, withPackage, keep)).toBeNull(); + expect(cold.asked).toEqual([]); + expect(store.entries.size).toBe(0); + reusedLibDiagnostics(sessionProgram({ semantic }), all, supplied, withPackage); + expect(store.entries.size).toBe(1); + const warm = sessionProgram({ semantic }); + expect(reusedKeptLibDiagnostics(warm, all, supplied, withPackage, keep)).toEqual({ + semantic: [diagnostic(lib, 1), diagnostic(main, 5)], + global: [], + }); + expect(warm.asked).toEqual([`checked ${main}`]); + // A left-out file that can declare something global still prevents reuse. + const augmenting = files({ + [main]: "export const n: number = 'x';\n", + [pkg]: "export {};\ndeclare global {\n var NaN: number;\n}\n", + }); + const blocked = sessionProgram({ semantic }); + expect(reusedKeptLibDiagnostics(blocked, all, supplied, augmenting, keep)).toBeNull(); + expect(blocked.asked).toEqual([]); +}); + test("files merge in code point order, which differs from UTF-16 order", () => { const store = memoryStore(); setLibDiagnosticsStore(store, "checker"); diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts index 30c6e81f0..c3e563fef 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts @@ -64,6 +64,31 @@ export function reusedLibDiagnostics( fileNames: readonly string[], supplied: (fileName: string) => boolean, sourceFile: (fileName: string) => SourceFile | undefined, +): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { + return reuse(program, fileNames, supplied, sourceFile, () => true, false); +} + +/** reusedLibDiagnostics for a caller that keeps only the diagnostics of the + * `kept` files: a stored answer then needs checking only the kept other + * files, and without a stored answer the result is null (nothing is checked + * or stored). */ +export function reusedKeptLibDiagnostics( + program: Ts7SessionProgram, + fileNames: readonly string[], + supplied: (fileName: string) => boolean, + sourceFile: (fileName: string) => SourceFile | undefined, + kept: ReadonlySet, +): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { + return reuse(program, fileNames, supplied, sourceFile, (name) => kept.has(name), true); +} + +function reuse( + program: Ts7SessionProgram, + fileNames: readonly string[], + supplied: (fileName: string) => boolean, + sourceFile: (fileName: string) => SourceFile | undefined, + keep: (fileName: string) => boolean, + storedOnly: boolean, ): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { if (configured === null || process.env["SCRIPTC_LIB_DIAGNOSTICS"] === "0") return null; const { store, checker } = configured; @@ -104,12 +129,16 @@ export function reusedLibDiagnostics( if (globals !== null) writeEntry(store, key, globals, Object.fromEntries(stored.files)); } if (stored !== null && (scripts.length === 0 || (globals !== null && !collides(globals)))) { - const checked = program.checkedFileDiagnosticData(own); + const checkedNames = storedOnly ? own.filter(keep) : own; + const checked = program.checkedFileDiagnosticData(checkedNames); if (checked === null) return null; - const byFile = new Map(stored.files); - own.forEach((name, index) => byFile.set(name, checked.files[index] ?? [])); + const byFile = new Map( + storedOnly ? [...stored.files].filter(([name]) => keep(name)) : stored.files, + ); + checkedNames.forEach((name, index) => byFile.set(name, checked.files[index] ?? [])); return { semantic: inFileOrder(byFile), global: checked.global }; } + if (storedOnly) return null; // The full check, as before. Its answers for the supplied files are stored // when it reports no global diagnostics and the program's scripts declare // none of the supplied files' globals (so nothing of the program reached @@ -339,7 +368,7 @@ function readEntry( /** Each file's diagnostics (already in the checker's order) in the order the * checker sorts files: by file name, compared by code point. */ -function inFileOrder(byFile: ReadonlyMap): Ts7DiagnosticData[] { +export function inFileOrder(byFile: ReadonlyMap): Ts7DiagnosticData[] { const names = [...byFile.keys()].sort(compareCodePoints); const out: Ts7DiagnosticData[] = []; for (const name of names) for (const d of byFile.get(name)!) out.push(d); diff --git a/packages/compiler/src/frontend/ts7/program-host.test.ts b/packages/compiler/src/frontend/ts7/program-host.test.ts index 80c4d2bcf..2ab6a0293 100644 --- a/packages/compiler/src/frontend/ts7/program-host.test.ts +++ b/packages/compiler/src/frontend/ts7/program-host.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { Ts7Api } from "./rpc-api.js"; -import { Ts7Host, type Ts7ApiOptions, type Ts7CompilerOptions } from "./program-host.js"; +import { + getPreEmitDiagnostics, + Ts7Host, + type Ts7ApiOptions, + type Ts7CompilerOptions, +} from "./program-host.js"; import { ModuleDetectionKind, ModuleKind, ModuleResolutionKind, ScriptTarget } from "./enums.js"; import { tsgoPath } from "./session-path.js"; @@ -70,6 +75,54 @@ test("implementation discovery leaves declaration trees lazy without skipping th expect(() => program.getSourceFileNames()).toThrow("disposed"); }); +test("pre-emit diagnostics for kept files check only those, unless a global diagnostic appears", () => { + const { directory, entry } = fixture(); + const other = join(directory, "other.ts"); + const later = join(directory, "later.ts"); + writeFileSync( + entry, + 'import "./other.js";\nimport "./later.js";\nexport const n: number = "x";\n', + ); + writeFileSync(other, "export const s: string = 1;\n"); + writeFileSync(later, "export const b: boolean = 0;\n"); + const { host } = makeHost(directory); + const program = host.createProgram([entry], options); + try { + const full = getPreEmitDiagnostics(program); + const keep = (name: string): boolean => name !== tsgoPath(other); + const checked = vi.spyOn(program.project.program, "checkedFileDiagnosticData"); + const kept = getPreEmitDiagnostics(program, keep); + // The full answer without the left-out file's diagnostics, in the same order. + expect(full.some((diagnostic) => diagnostic.fileName === tsgoPath(other))).toBe(true); + expect(kept.map((diagnostic) => [diagnostic.fileName, diagnostic.code])).toEqual( + full + .filter((diagnostic) => diagnostic.fileName === undefined || keep(diagnostic.fileName)) + .map((diagnostic) => [diagnostic.fileName, diagnostic.code]), + ); + expect(kept).toHaveLength(2); + expect(checked).toHaveBeenCalledTimes(1); + expect(checked.mock.calls[0]![0]).not.toContain(tsgoPath(other)); + // Keeping every file is the full check. + expect(getPreEmitDiagnostics(program, () => true)).toEqual(full); + expect(checked).toHaveBeenCalledTimes(1); + // A global diagnostic from the partial check, or a server without it: the full check. + for (const answer of [ + { + files: [], + global: [{ fileName: undefined, pos: 0, end: 0, code: 2318, category: 1, text: "global" }], + }, + null, + ]) { + checked.mockReturnValueOnce( + answer as ReturnType, + ); + expect(getPreEmitDiagnostics(program, keep)).toEqual(full); + } + } finally { + program.dispose(); + } +}); + test("the factory receives one connection with the host's filesystem and timing", () => { const { directory, entry } = fixture(); const factory = vi.fn((connection: Ts7ApiOptions) => new Ts7Api(connection)); diff --git a/packages/compiler/src/frontend/ts7/program-host.ts b/packages/compiler/src/frontend/ts7/program-host.ts index 9e8f51bb3..11bb8ef3a 100644 --- a/packages/compiler/src/frontend/ts7/program-host.ts +++ b/packages/compiler/src/frontend/ts7/program-host.ts @@ -46,9 +46,9 @@ import { trackedRealpath, } from "../input-tracker.js"; import { packageShimDeclarationText } from "../package-shims.js"; -import { typePatchedText } from "../type-patches/layer.js"; +import { typePatchedText, typePatchFileExists } from "../type-patches/layer.js"; import { ambientDtsPath, fallbackDtsPath, overridesDtsPath } from "../dts-paths.js"; -import { reusedLibDiagnostics } from "./lib-diagnostics.js"; +import { inFileOrder, reusedKeptLibDiagnostics, reusedLibDiagnostics } from "./lib-diagnostics.js"; import { ts7Diagnostic } from "./diagnostic.js"; import type { Ts7DiagnosticData } from "./session-schema.generated.js"; @@ -190,7 +190,7 @@ export class Ts7Host { fileExists: (fileName) => { if (virtualFiles.has(tsgoPath(fileName))) return true; if (shadow !== null && shadow.hideFile(fileName)) return false; - return trackedFileExists(fileName); + return trackedFileExists(fileName) || typePatchFileExists(fileName); }, directoryExists: (path) => trackedDirectoryExists(path), realpath: (path) => @@ -423,17 +423,31 @@ export class Ts7Program { /** The semantic and global diagnostics with the stored diagnostics of the * declaration files scriptc supplies (lib-diagnostics.ts), or null. */ reusedLibDiagnostics(): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { + return this.libDiagnostics(null); + } + + /** reusedLibDiagnostics for the `kept` files only: null without a stored + * answer. */ + reusedKeptLibDiagnostics( + kept: ReadonlySet, + ): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { + return this.libDiagnostics(kept); + } + + private libDiagnostics( + kept: ReadonlySet | null, + ): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { this.snapshot.ensureActive(); const program = this.project.program; const declarations = new Set([ambientDtsPath(), overridesDtsPath(), fallbackDtsPath()]); - return reusedLibDiagnostics( - program, - this.getSourceFileNames(), - (name) => - declarations.has(tsgoPath(name)) || - program.getSourceFileMetadataByPath(name)?.isDefaultLibrary === true, - (name) => this.getSourceFile(name), - ); + const names = this.getSourceFileNames(); + const supplied = (name: string): boolean => + declarations.has(tsgoPath(name)) || + program.getSourceFileMetadataByPath(name)?.isDefaultLibrary === true; + const sourceFile = (name: string): SourceFile | undefined => this.getSourceFile(name); + return kept === null + ? reusedLibDiagnostics(program, names, supplied, sourceFile) + : reusedKeptLibDiagnostics(program, names, supplied, sourceFile, kept); } isSourceFileDefaultLibrary(file: SourceFile): boolean { @@ -464,10 +478,35 @@ export class Ts7Program { /** 5.9.3's getPreEmitDiagnostics, composed from 7.0.2's split surface (the * package dropped the aggregate). Order mirrors 5.9.3: config, options/ - * program, global, syntactic, semantic. */ -export function getPreEmitDiagnostics(program: Ts7Program): readonly Diagnostic[] { + * program, global, syntactic, semantic. + * + * With `keep`, a caller that discards every diagnostic of the other files + * asks for semantic diagnostics of the kept files only: the server checks + * only those (getDiagnosticsOfCheckedFiles), or only those not answered by + * stored diagnostics of the supplied declaration files, and their + * diagnostics come in the full check's order. A global diagnostic can come + * from checking any file, so when that partial check reports one, the full + * check answers. */ +export function getPreEmitDiagnostics( + program: Ts7Program, + keep?: (fileName: string) => boolean, +): readonly Diagnostic[] { const config = program.getConfigFileParsingDiagnostics(); const options = program.getProgramDiagnostics(); + if (keep !== undefined) { + const kept = program.getSourceFileNames().filter((name) => keep(name)); + if (kept.length < program.getSourceFileNames().length) { + const partial = + program.reusedKeptLibDiagnostics(new Set(kept)) ?? keptFileDiagnostics(program, kept); + if (partial !== null && partial.global.length === 0) + return [ + ...config, + ...options, + ...program.getSyntacticDiagnostics(), + ...partial.semantic.map(ts7Diagnostic), + ]; + } + } const reused = program.reusedLibDiagnostics(); if (reused !== null) return [ @@ -486,6 +525,19 @@ export function getPreEmitDiagnostics(program: Ts7Program): readonly Diagnostic[ ]; } +/** The semantic diagnostics of the kept files, in the full check's order, + * and the global diagnostics of checking them; null when the server cannot + * check only some files. */ +function keptFileDiagnostics( + program: Ts7Program, + kept: readonly string[], +): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null { + const checked = program.project.program.checkedFileDiagnosticData(kept); + if (checked === null) return null; + const byFile = new Map(kept.map((name, index) => [name, checked.files[index] ?? []])); + return { semantic: inFileOrder(byFile), global: checked.global }; +} + /** ts.findConfigFile: nearest configName walking up from searchPath. */ export function findConfigFile( searchPath: string, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index edb3cf144..4b1bf2a42 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -10,6 +10,7 @@ import { isDynTypedRefType, BOOL, bytesOf, + canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, CHILD_T, @@ -717,6 +718,12 @@ export class UnionRegistry { return id !== undefined && this.pendingRec.has(id); } + /** True when `unionId` is a recursive placeholder whose arms are not + * known yet (its outer frame is still mapping, or failed). */ + isPendingPlaceholder(unionId: string): boolean { + return this.pendingRec.has(unionId); + } + /** Completes a recursive placeholder with its canonical arm list and * registers the structural key (first writer wins, like shapes). */ finalizeRecursive(t: ts.Type, arms: IrType[], discriminant?: IrUnionDiscriminant): string { @@ -1255,6 +1262,10 @@ export interface TypeMapperCtx { * (erasedGenericMemberType): the signature's own type parameters * resolve to `unknown` instead of refusing the mapping. */ erasingSignature?: ts.Signature; + /** Set with erasingSignature: the signature's own type parameters, and + * whether the erasure consulted an enclosing binding. An erasure that + * resolved only its own parameters answers the same in every context. */ + erasedMember?: { own: ReadonlySet; outerConsulted: boolean }; /** True while an erased generic body lowers (its type parameters are * bound to `unknown`). */ isErasing?: () => boolean; @@ -1266,6 +1277,11 @@ export interface TypeMapperCtx { * only where literal identity matters — indexed accesses (`T[K]`) inside * generic bodies whose K is bound to a literal key. */ resolveTypeParamTs?: TypeParamTsResolver; + /** A generic alias reference inside a generic body (`Catalog`) + * re-instantiated with the instance's bound type arguments: the concrete + * checker type its signature instantiates, or null. Consulted only when + * the symbolic form does not map. */ + resolveBoundAliasTs?: TypeParamTsResolver; /** GENERIC program classes (monomorphization by flow): the mapping of a * concrete instantiation reference (`Box`) — the Lowerer * registers/reuses the instantiation (`Box%0`) and answers its object @@ -1830,11 +1846,22 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!topLevel || !ts.isUnreadableConstituentsPanic(e)) throw e; mapped = null; } + // A generic alias the body spells over its own type parameters (`x as + // Catalog`) stays symbolic in the checker; when that form does not + // map, the instance's concrete instantiation of the same alias does. + if (mapped === null && ctx.resolveBoundAliasTs) { + const concrete = ctx.resolveBoundAliasTs(type); + if (concrete !== null && concrete !== type) { + mapped = mapType(concrete, ctx); + if (mapped !== null) contextResolutions++; + } + } if ( mapped !== null && memoKey !== null && topLevel && contextResolutions === sensitivityAtEntry && + ctx.erasingSignature === undefined && (ctx.canMemoizeType?.() ?? true) ) { ctx.typeMemo?.set(memoKey, mapped); @@ -1991,7 +2018,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { !ERASURE_KEPT_CONTAINERS.has(ts.symbolNameOf(type.getSymbol()) ?? "") && mentionsErasedTypeParam(type, ctx, 0) ) { - contextResolutions++; + if (!ctx.erasedMember || ctx.erasedMember.outerConsulted) contextResolutions++; return DYN; } // Readonly over a program class (Object.freeze's result type) is the @@ -2001,7 +2028,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { const bound = resolveTypeParam(type); if (bound) { - contextResolutions++; + if (!ctx.erasedMember?.own.has(type)) contextResolutions++; return bound; } } @@ -3009,6 +3036,30 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // spawn consumes a presence-sensitive option bag, including optional // stdio and env values. Keep it checked-dynamic for native normalization. if (isStdlibInterface("SpawnOptions")) return DYN; + // util.inspect as a value (a custom inspect method's third parameter is + // `typeof inspect`): the runtime's inspect function, called through the + // checked-dynamic boundary. + if ( + psym !== undefined && + ts.symbolName(psym) === "inspect" && + psym.declarations.some( + (d) => + ts.isFunctionDeclaration(d) && + ctx.isStdlibFile(ts.getSourceFileOfNode(d)) && + isDeclaredInAmbientModule(d, "util"), + ) + ) + return DYN; + // util.inspect's options bag, which inspect and formatWithOptions read + // at run time (its sorted option can be a comparator function), and the + // options custom inspect functions receive. + if ( + (isStdlibInterface("InspectOptions") || isStdlibInterface("InspectOptionsStylized")) && + psym!.declarations.some( + (d) => ts.isInterfaceDeclaration(d) && isDeclaredInAmbientModule(d, "util"), + ) + ) + return DYN; // dns.LookupAllOptions (`all: true`, the overload whose callback takes // an address list): a lookup option bag dns.lookup reads at run time. // It rides checked-dynamic storage, so the lookup function type that @@ -3072,11 +3123,14 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!elem) return null; return genResultRecord(elem, VOID, ctx.shapes, ctx.unions); } - // node:net's SocketAddress and BlockList: native handles (scr_net.c) - // whose getters and methods dispatch through the handle. + // node:net's SocketAddress and BlockList (scr_net.c) and node:util's + // MIMEType and MIMEParams (scr_mime.c): native handles whose getters and + // methods dispatch through the handle. if ( ((psym !== undefined && ts.symbolName(psym) === "SocketAddress") || - (psym !== undefined && ts.symbolName(psym) === "BlockList")) && + (psym !== undefined && ts.symbolName(psym) === "BlockList") || + (psym !== undefined && ts.symbolName(psym) === "MIMEType") || + (psym !== undefined && ts.symbolName(psym) === "MIMEParams")) && psym.declarations.some( (d) => (ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) && @@ -3085,6 +3139,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return DYN; } + // The functions node:util's debounce and throttle return + // (scr_util_timing.c): native functions with their own members and + // getters. + if ( + psym !== undefined && + (ts.symbolName(psym) === "DebouncedFunction" || ts.symbolName(psym) === "ThrottledFunction") && + psym.declarations.some( + (d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(ts.getSourceFileOfNode(d)), + ) + ) { + return DYN; + } // WebCrypto: the crypto global and its subtle object are dynamic // objects, CryptoKey values are native handles (scr_webcrypto.c), and // the algorithm dictionaries the methods take and keys report are plain @@ -5315,8 +5381,19 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions); // Belt and braces for non-strict type worlds: an optional param's // ABI slot is always the undefined-armed union (strictNullChecks - // already spells it that way; arm it here if the world didn't). - if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") { + // already spells it that way; arm it here if the world didn't). A + // back-reference to a union still being mapped (`options?: { then?: + // (ctx: Sub) => void }` where Sub extends the interface declaring the + // method) is a recursive placeholder with no arms yet; when the checker + // spelled the parameter `X | undefined`, the placeholder receives that + // undefined arm when its frame finishes. + const pendingOptional = + pt?.kind === "union" && + ctx.unions.isPendingPlaceholder(pt.unionId) && + ts + .constituentTypes(checker.getTypeOfSymbol(p)) + .some((part) => (part.flags & ts.TypeFlags.Undefined) !== 0); + if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn" && !pendingOptional) { const armed = withUndefinedArm(pt, ctx.unions); if (!armed) return null; pt = armed; @@ -7335,10 +7412,21 @@ export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType const sig = sigs[0]!; const own = new Set(sig.getTypeParameters()); const outer = ctx.resolveTypeParam; + // Resolving only the member's own parameters does not make the erasure + // context-sensitive, so a recursive record holding a generic method + // (`interface Sub extends Base { send(m: Msg): void }` reached + // from Base) still closes its knot. Consulting an enclosing binding does. + const erasedMember = { own, outerConsulted: false }; const erased = mapType(t, { ...ctx, erasingSignature: sig, - resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)), + erasedMember, + resolveTypeParam: (param) => { + if (own.has(param)) return DYN; + if (outer === undefined) return null; + erasedMember.outerConsulted = true; + return outer(param); + }, }); return erased?.kind === "func" ? erased : undefined; } @@ -8367,6 +8455,16 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { // `string | URL | Request`, fetch's own input type under @types/node. case "url": return true; + // A function the checked-dynamic tree can box (each parameter checks + // in, the result converts out) is held by reference there, and + // `typeof x === "function"` narrows it back out: `Progress | AbortSignal` + // beside fetch's handles. + case "func": + return canBoxFuncIntoDyn( + arm, + (id) => ctx.shapes.get(id), + (id) => ctx.unions.get(id), + ); case "record": case "array": // `T | Promise` with T bound to `unknown`: the checked-dynamic value diff --git a/packages/compiler/src/frontend/type-patches/layer.ts b/packages/compiler/src/frontend/type-patches/layer.ts index 79b409110..bb3b3627a 100644 --- a/packages/compiler/src/frontend/type-patches/layer.ts +++ b/packages/compiler/src/frontend/type-patches/layer.ts @@ -27,7 +27,7 @@ import { dirname, relative } from "node:path"; import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { typePatchDiag } from "../../diagnostics/diagnostic.js"; import { readProjectConfig, type TypePatchEntry } from "../../project-config.js"; -import { trackedReadFile } from "../input-tracker.js"; +import { trackedFileExists, trackedReadFile } from "../input-tracker.js"; import type { SourceFile } from "../ts7/syntax.js"; import { applyPatchEdits, patchFile, type PatchMatch, type PatchRequest } from "./locate.js"; import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js"; @@ -49,6 +49,11 @@ interface CopyInfo { root: string; /** Requests whose entry's range the version satisfies. */ requests: Request[]; + /** Replaced declaration files: package-relative path → local file. */ + files: Map; + /** Declaration entries a `types` patch supplies: package-relative path + * → local file (existing entries are replaced, missing ones appear). */ + entries: Map; } interface MatchRecord extends PatchMatch { @@ -63,9 +68,11 @@ interface State { projectDir: string; manifestText: string; parse: TypePatchParse; - byPackage: Map; + byPackage: Map; copies: Map; files: Map; + /** `files` targets that do not exist in a copy. */ + missingFiles: { entry: TypePatchEntry; root: string; rel: string }[]; /** Package copies whose version no patch range covers. */ unmatchedCopies: Map; /** Requests applicable to a copy this load read files of. */ @@ -86,22 +93,23 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse): state = null; return; } - const byPackage = new Map(); + const byPackage = new Map(); let id = 0; for (const entry of config.typePatches) { + const pkg = byPackage.get(entry.pkg) ?? { entries: [], requests: [] }; + byPackage.set(entry.pkg, pkg); + pkg.entries.push(entry); for (const decl of entry.declarations) { const selector = parseSelector(decl.selector); const op = parseTypeText(decl.type); if ("error" in selector || "error" in op) continue; // reported as SC8004 - const list = byPackage.get(entry.pkg) ?? []; - list.push({ + pkg.requests.push({ id: id++, selector: selector as Selector, op: op as TypePatchOp, entry, offset: decl.offset, }); - byPackage.set(entry.pkg, list); } } state = { @@ -112,6 +120,7 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse): byPackage, copies: new Map(), files: new Map(), + missingFiles: [], unmatchedCopies: new Map(), touched: new Set(), matches: [], @@ -164,7 +173,7 @@ function twinRuntimeVersion(s: State, nodeModules: string, pkg: string): string /** The installed package copy a file belongs to, with the requests that * apply to it; null for files outside node_modules or of unpatched * packages. */ -function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null { +function copyOf(s: State, path: string, touch = true): { copy: CopyInfo; rel: string } | null { const marker = "/node_modules/"; const at = path.lastIndexOf(marker); if (at < 0) return null; @@ -178,33 +187,122 @@ function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null let copy = s.copies.get(root); if (copy === undefined) { copy = null; - const pkg = name.startsWith("@types/") ? unmangleTypesName(name) : name; - const requests = s.byPackage.get(pkg); - if (requests !== undefined) { + const twin = name.startsWith("@types/"); + const pkg = twin ? unmangleTypesName(name) : name; + const patches = s.byPackage.get(pkg); + if (patches !== undefined) { // A twin is matched by the runtime package's version, never its own. - const version = name.startsWith("@types/") + const version = twin ? twinRuntimeVersion(s, path.slice(0, at + marker.length), pkg) : versionAt(root); - const applicable = requests.filter( - (r) => version !== null && satisfies(version, r.entry.range), + const applicable = patches.entries.filter( + (e) => version !== null && satisfies(version, e.range), ); - // Later entries win on the same selector. - const bySelector = new Map(); - for (const r of applicable) bySelector.set(r.selector.text, r); - const chosen = [...bySelector.values()]; - if (chosen.length === 0) { - const entries = [...new Set(requests.map((r) => r.entry))]; - s.unmatchedCopies.set(root, { pkg, version, entries }); + if (applicable.length === 0) { + s.unmatchedCopies.set(root, { pkg, version, entries: patches.entries }); + } else { + // Later entries win on the same selector, file or entry. + const bySelector = new Map(); + for (const r of patches.requests) + if (applicable.includes(r.entry)) bySelector.set(r.selector.text, r); + const files = new Map(); + let types: string | null = null; + const prefix = `${name}/`; + for (const e of applicable) { + for (const [target, local] of e.files) { + // `@types//` names the twin's file; plain paths the + // package's own. + const forTwin = target.startsWith("@types/"); + if (forTwin !== twin) continue; + const rel = forTwin + ? target.startsWith(prefix) + ? target.slice(prefix.length) + : null + : target; + if (rel === null) continue; + if (!trackedFileExists(`${root}/${rel}`)) { + s.missingFiles.push({ entry: e, root, rel }); + continue; + } + files.set(rel, local); + } + if (e.types !== null && !twin) types = e.types; + } + copy = { + pkg, + version, + root, + requests: [...bySelector.values()], + files, + entries: types === null ? new Map() : declarationEntries(root, types), + }; } - copy = chosen.length === 0 ? null : { pkg, version, root, requests: chosen }; } s.copies.set(root, copy); } if (copy === null) return null; - for (const r of copy.requests) s.touched.add(r); + if (touch) for (const r of copy.requests) s.touched.add(r); return { copy, rel }; } +/** The declaration entry paths of a package a `types` patch stands for: + * its `types`/`typings` file and every declaration TypeScript probes + * beside a JavaScript entry (`main`, the `exports` targets of "."). */ +function declarationEntries(root: string, local: string): Map { + const out = new Map(); + let pkg: Record = {}; + try { + pkg = JSON.parse(trackedReadFile(`${root}/package.json`) ?? "{}") as Record; + } catch { + pkg = {}; + } + const add = (target: unknown): void => { + if (typeof target !== "string") return; + const rel = target.replace(/^\.\//, ""); + if (/\.d\.[cm]?ts$/.test(rel)) out.set(rel, local); + else if (/\.[cm]?js$/.test(rel)) out.set(rel.replace(/\.([cm]?)js$/, ".d.$1ts"), local); + // TypeScript probes x.tsx and then x.d.ts for x.jsx. + else if (rel.endsWith(".jsx")) out.set(`${rel.slice(0, -".jsx".length)}.d.ts`, local); + else if (!/\.\w+$/.test(rel)) out.set(`${rel}.d.ts`, local); + }; + add(pkg["types"]); + add(pkg["typings"]); + add(typeof pkg["main"] === "string" ? pkg["main"] : "index.js"); + const exportsField = pkg["exports"]; + const dot = + exportsField !== null && typeof exportsField === "object" && "." in exportsField + ? (exportsField as Record)["."] + : exportsField; + const visit = (v: unknown): void => { + if (typeof v === "string") add(v); + else if (v !== null && typeof v === "object") for (const x of Object.values(v)) visit(x); + }; + // Subpath exports ("./x") are not the entry. + if (dot !== null && typeof dot === "object" && Object.keys(dot).some((k) => k.startsWith("."))) + return out; + visit(dot); + return out; +} + +/** True for a declaration entry a `types` patch supplies that does not + * exist on disk (a package without declarations gains one). */ +export function typePatchFileExists(fileName: string): boolean { + const s = state; + if (s === null) return false; + const path = fileName.split("\\").join("/"); + if (!/\.d\.[cm]?ts$/.test(path)) return false; + const located = copyOf(s, path, false); + return located !== null && located.copy.entries.has(located.rel); +} + +/** True when a `types` patch gives the package at `pkgDir` declarations. */ +export function typePatchSuppliesTypes(pkgDir: string): boolean { + const s = state; + if (s === null) return false; + const located = copyOf(s, `${pkgDir.split("\\").join("/")}/package.json`, false); + return located !== null && located.copy.entries.size > 0; +} + /** Names a request's root can appear under in a file's text. */ function rootWords(sel: Selector): string[] { if (sel.root === "export=") return ["exports", "export"]; @@ -224,7 +322,12 @@ export function typePatchedText(fileName: string): string | undefined { let result: string | null = null; const located = copyOf(s, path); if (located !== null) { - const text = trackedReadFile(path); + // A replaced file or a supplied entry reads as the local file; the + // declaration patches then apply to it like to the package's own. + const local = located.copy.files.get(located.rel) ?? located.copy.entries.get(located.rel); + const replaced = local === undefined ? null : trackedReadFile(local); + const text = replaced ?? trackedReadFile(path); + if (replaced !== null) result = replaced; const requests = located.copy.requests.filter( (r) => (r.selector.file === null || r.selector.file === located.rel) && @@ -333,6 +436,16 @@ export function typePatchDiagnostics(): { ); } } + for (const missing of s.missingFiles) { + out.push( + typePatchDiag( + "SC8031", + `type patch '${missing.entry.key}': ${missing.rel} is not a file of ${missing.entry.pkg} at ${relativeToManifest(s, missing.root)}`, + "name a declaration file the installed package ships; the package may have moved it", + loc(s, missing.entry.offset), + ), + ); + } for (const r of s.touched) { const mine = s.matches.filter((m) => m.id === r.id); // A selector that matched somewhere (the .d.ts) is not a problem where diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 63cb7602d..65cc0ae74 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -655,6 +655,8 @@ async function compileExecutableNative( ...(features.declAdopt ? { declAdopt: true } : {}), ...(features.dynForAwait ? { dynForAwait: true } : {}), ...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}), + ...(features.mime ? { mime: true } : {}), + ...(features.utilTiming ? { utilTiming: true } : {}), ...(features.dynBuffer ? { dynBuffer: true } : {}), ...(features.builtinValues ? { builtinValues: true } : {}), events: features.events, @@ -897,6 +899,7 @@ async function prepareExecutableInput( // the pending flag, since their link may not add an unwinder. unwindAbi: buildPlatform === "linux" && outputKind !== "obj", objectAudit: opts.sanitize === true, + sanitizeAttributes: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), ...inlineRcOption(opts.optimization, opts.sanitize === true), @@ -969,6 +972,7 @@ async function prepareExecutableInput( runtimeAbiMarker: useRuntimePack, unwindAbi: buildPlatform === "linux", objectAudit: opts.sanitize === true, + sanitizeAttributes: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), ...inlineRcOption(opts.optimization, opts.sanitize === true), @@ -2412,6 +2416,7 @@ async function compileLibraryTracked( pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", objectAudit: opts.sanitize === true, + sanitizeAttributes: opts.sanitize === true, ...inlineRcOption(profile.optimization, opts.sanitize === true), }); timing("llvm-emit", { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 2908b143f..a9e907d63 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -187,6 +187,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "num.toStringRadix", "num.toStringRadixF64", "insp.jsonDyn", + // The runtime formatter calls toString, valueOf and toJSON, and refuses + // renderings it has no lowering for. + "insp.format", + "insp.formatList", + "insp.formatWithOptions", + "insp.inspect", + // The options object reads the caller's options (getters included), and + // the checked-dynamic walk calls custom inspect functions. + "insp.customOptions", + "insp.customResult", // A checked-dynamic value the inspector does not render (a promise, a // URL, a RegExp) throws the 'not supported yet' Error. "insp.dyn", @@ -601,6 +611,13 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "util.inherits", "util.inheritsPrototype", "util.deprecate", + "util.extend", + "util.signalExitCode", + "util.diff", + "util.debounce", + "util.throttle", + "mime.typeNew", + "mime.typeParse", "util.callbackify", "util.promisifyDyn", "util.debuglog", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 0cfd2076b..2504bacc5 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1165,6 +1165,17 @@ function buildLibFnSignatures() { "util.inherits": { argTypes: [DYN, DYN], result: VOID }, "util.inheritsPrototype": { argTypes: [DYN, DYN, DYN], result: VOID }, "util.deprecate": { argTypes: [DYN, DYN, DYN, DYN], result: DYN }, + "util.isArrayDeprecated": { argTypes: [DYN], result: BOOL }, + "util.extend": { argTypes: [DYN, DYN], result: DYN }, + "util.signalExitCode": { argTypes: [DYN], result: F64 }, + "util.aborted": { argTypes: [DYN, DYN], result: DYN }, + "util.diff": { argTypes: [DYN, DYN], result: DYN }, + "util.debounce": { argTypes: [DYN, DYN, DYN], result: DYN }, + "util.throttle": { argTypes: [DYN, DYN, DYN, DYN], result: DYN }, + "mime.typeNew": { argTypes: [DYN, DYN], result: DYN }, + "mime.typeParse": { argTypes: [DYN], result: DYN }, + "mime.paramsNew": { argTypes: [], result: DYN }, + "mime.is": { argTypes: [DYN, F64], result: BOOL }, "util.callbackify": { argTypes: [DYN], result: DYN }, /** util.promisify over an untyped function value: a native dyn function * whose calls answer a dyn promise the appended callback settles. */ @@ -4609,12 +4620,54 @@ function buildLibFnSignatures() { "insp.errorCause": { argTypes: [null, F64, F64], result: STRING }, "insp.dyn": { argTypes: [DYN, F64, F64], result: STRING }, "insp.dynS": { argTypes: [DYN, F64], result: STRING }, + /** util.format over a dyn array of its arguments, and + * util.formatWithOptions(options, pack): the runtime formatter for format + * strings known at run time and spread arguments. */ + "insp.format": { argTypes: [DYN], result: STRING }, + /** insp.format over arguments that are all strings, numbers, booleans, + * null or undefined, without the checked-dynamic walk. */ + "insp.formatPrimitives": { argTypes: [arrayOf(DYN)], result: STRING }, + "insp.formatList": { argTypes: [arrayOf(DYN)], result: STRING }, + "insp.formatWithOptions": { argTypes: [DYN, DYN], result: STRING }, + /** util.inspect(value, options, depth, colors) with the call's argument + * count: the runtime reads the options and renders the checked-dynamic + * view. */ + "insp.inspect": { argTypes: [DYN, DYN, DYN, DYN, F64], result: STRING }, + /** A [util.inspect.custom] method's call: whether ctx.customInspect is + * set, its depth argument (the remaining depth at recursion r of depth + * d), its options object and util.inspect, then its result rendered: a + * string re-indented at the current level, or a checked-dynamic value + * (customIsSelf tests for the instance itself). customInspect sets the + * flag for the renders that follow and answers the previous value, and + * depthKind does the same for how an unbounded depth was given (0 + * Infinity, 1 null, 2 undefined). */ + "insp.customEnabled": { argTypes: [], result: BOOL }, + /** Registers a class's compiled custom inspect function under its + * instances' typed-ref type key, for the checked-dynamic walk. */ + "insp.registerCustom": { argTypes: [STRING, DYN], result: VOID }, + "insp.customInspect": { argTypes: [BOOL], result: BOOL }, + "insp.depthKind": { argTypes: [F64], result: F64 }, + "insp.customDepth": { argTypes: [F64, F64], result: DYN }, + "insp.customOptions": { argTypes: [F64], result: DYN }, + "insp.inspectFunction": { argTypes: [], result: DYN }, + "insp.customText": { argTypes: [STRING], result: STRING }, + "insp.customIsSelf": { argTypes: [DYN, DYN], result: BOOL }, + "insp.customResult": { argTypes: [DYN, F64, F64], result: STRING }, + /** Around the method's call: the inspect and format calls it makes run + * in a fresh inspect context with the default options. begin answers the + * token end takes; end also discards what a call that threw left. */ + "insp.customCallBegin": { argTypes: [], result: F64 }, + "insp.customCallEnd": { argTypes: [F64], result: VOID }, + /** Whether a runtime format string has a '%' before its last character. */ + "insp.formatSpecifies": { argTypes: [STRING], result: BOOL }, + "insp.formatSpecifiesDyn": { argTypes: [DYN], result: BOOL }, + "insp.dynFormatS": { argTypes: [DYN, F64], result: STRING }, /** util.format's %o over a checked-dynamic value: inspect with * showHidden. Kinds whose hidden entries have no rendering throw the * 'not supported yet' Error. */ "insp.dynHidden": { argTypes: [DYN, F64], result: STRING }, "insp.begin": { argTypes: [F64], result: VOID }, - /** util.inspect's breakLength, compact (0 for false) and sorted for the + /** util.inspect's breakLength, compact (-1 for false) and sorted for the * renders that follow; the call site restores 80, 3 and false afterwards. */ "insp.layout": { argTypes: [F64, F64, BOOL], result: VOID }, "insp.entry": { argTypes: [STRING, BOOL], result: VOID }, diff --git a/packages/compiler/src/ir/callback-specialization.ts b/packages/compiler/src/ir/callback-specialization.ts index f4421f34b..ba579061c 100644 --- a/packages/compiler/src/ir/callback-specialization.ts +++ b/packages/compiler/src/ir/callback-specialization.ts @@ -33,6 +33,22 @@ const REBINDING_KINDS: readonly IrNodeKind[] = [ "closure", ]; +/** The rebindings one function's body makes: locals it writes after + * declaring them, and the closures it creates over captured bindings. */ +interface FunctionRebindings { + ids: Set; + sites: { owner: string; fnName: string; captures: string[] }[]; +} + +/** Records `node`'s rebinding into `into` (owner `fnName`). */ +function noteRebinding(fnName: string, node: IrExpr | IrStmt, into: FunctionRebindings): void { + if (node.kind === "assign" || node.kind === "forOf") into.ids.add(node.localId); + else if (node.kind === "tryCatch" && node.catchLocalId !== null) into.ids.add(node.catchLocalId); + else if (node.kind === "assignExpr" || node.kind === "incDec") into.ids.add(node.localId); + else if (node.kind === "closure" && node.captures.length) + into.sites.push({ owner: fnName, fnName: node.fnName, captures: node.captures }); +} + function rebindings( mod: { readonly functions: readonly IrFunction[] }, nodes?: IrNodeIndex, @@ -41,21 +57,31 @@ function rebindings( /** Bindings of each function that a closure it creates writes. */ closureWritten: Map>; } { + const scan = nodes?.scan(REBINDING_KINDS); + const each = mod.functions.map((fn) => { + const own: FunctionRebindings = { ids: new Set(), sites: [] }; + eachNodeOfKinds(fn, scan, (node) => noteRebinding(fn.name, node, own)); + return own; + }); + return closeRebindings(mod, each); +} + +/** The module's rebindings from each function's own (`each`, in module + * order): + * a binding a closure writes through its environment is written by the + * function that created the closure, transitively. */ +function closeRebindings( + mod: { readonly functions: readonly IrFunction[] }, + each: readonly FunctionRebindings[], +): { written: Map>; closureWritten: Map> } { const written = new Map>(); const closureWritten = new Map>(); const sites: { owner: string; fnName: string; captures: string[] }[] = []; - const scan = nodes?.scan(REBINDING_KINDS); - for (const fn of mod.functions) { - const ids = new Set(); - eachNodeOfKinds(fn, scan, (node) => { - if (node.kind === "assign" || node.kind === "forOf") ids.add(node.localId); - else if (node.kind === "tryCatch" && node.catchLocalId !== null) ids.add(node.catchLocalId); - else if (node.kind === "assignExpr" || node.kind === "incDec") ids.add(node.localId); - else if (node.kind === "closure" && node.captures.length) - sites.push({ owner: fn.name, fnName: node.fnName, captures: node.captures }); - }); - written.set(fn.name, ids); - } + mod.functions.forEach((fn, index) => { + const own = each[index]!; + written.set(fn.name, own.ids); + for (const site of own.sites) sites.push(site); + }); const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); for (let changed = true; changed;) { changed = false; @@ -130,9 +156,10 @@ export function specializeCallbackCalls( symbolName: (name: string) => string = (name) => name, ): IrModule { const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); - const { written, closureWritten } = rebindings(mod); const copies = new Map(); - const candidates = new Map(); + // Helpers that may take callbacks: their callback parameters are final + // once the walk below finds which of them the body rebinds. + const helpers = new Map(); for (const fn of mod.functions) { if ( !(fn.ownsPrototype || fn.name.startsWith("%arr.")) || @@ -142,19 +169,13 @@ export function specializeCallbackCalls( fn.captures !== undefined || fn.classCaptures?.length || fn.fastRead !== undefined || - fn.speculativeDispatch + fn.speculativeDispatch || + !fn.params.some((param) => param.type.kind === "func") ) continue; - const nodes = countNodes(fn); - if (nodes > MAX_FUNCTION_NODES) continue; - const rebound = written.get(fn.name)!; - const slots: number[] = []; - fn.params.forEach((param, index) => { - if (param.type.kind === "func" && !rebound.has(param.localId)) slots.push(index); - }); - if (slots.length) candidates.set(fn.name, { fn, slots, nodes }); + helpers.set(fn.name, fn); } - if (!candidates.size) return mod; + if (!helpers.size) return mod; // Function values, exports, entry points and methods: an original // reachable this way stays even when every direct call moves to a copy. @@ -162,16 +183,17 @@ export function specializeCallbackCalls( for (const cls of mod.classes ?? []) for (const method of ["constructor", ...(cls.methods ?? [])]) referenced.add(`%${cls.name}.${method}`); - // Single definitions of function-typed locals and globals. - const definitions = new Map>(); const globalIds = new Set(mod.globals?.map((global) => global.id)); const globalWrites = new Map(); - // The sum of countNodes over every function, counted by the walk below. - let totalNodes = 0; - for (const fn of mod.functions) { + // One walk of every body records what the steps below read: its node + // count (countNodes), its rebindings, the values written into each of its + // bindings, and its direct calls of helpers, in walk order. + const each = mod.functions.map((fn) => { let nodes = 0; + const own: FunctionRebindings = { ids: new Set(), sites: [] }; const values = new Map(); const counts = new Map(); + const calls: (IrExpr & { kind: "call" })[] = []; const record = (id: string, value: IrExpr | null, weight: number): void => { counts.set(id, (counts.get(id) ?? 0) + weight); if (value) { @@ -183,6 +205,7 @@ export function specializeCallbackCalls( everyStmtList(fn.body, { stmt: (stmt) => { nodes++; + noteRebinding(fn.name, stmt, own); if (stmt.kind === "varDecl") record(stmt.localId, stmt.init, stmt.init ? 1 : 0); if (stmt.kind === "assign") { if (globalIds.has(stmt.localId)) { @@ -199,15 +222,42 @@ export function specializeCallbackCalls( }, expr: (expr) => { nodes++; + noteRebinding(fn.name, expr, own); if (expr.kind === "assignExpr" || expr.kind === "incDec") { record(expr.localId, null, 2); if (globalIds.has(expr.localId)) globalWrites.set(expr.localId, []); } + if (expr.kind === "call" && helpers.has(expr.callee)) calls.push(expr); const name = exprFunctionReference(expr); if (name !== undefined) referenced.add(name); return true; }, }); + return { fn, nodes: Math.min(nodes, MAX_FUNCTION_NODES + 1), own, values, counts, calls }; + }); + const { written, closureWritten } = closeRebindings( + mod, + each.map((entry) => entry.own), + ); + const nodesOf = new Map(each.map((entry) => [entry.fn.name, entry.nodes])); + const candidates = new Map(); + for (const fn of helpers.values()) { + const nodes = nodesOf.get(fn.name)!; + if (nodes > MAX_FUNCTION_NODES) continue; + const rebound = written.get(fn.name)!; + const slots: number[] = []; + fn.params.forEach((param, index) => { + if (param.type.kind === "func" && !rebound.has(param.localId)) slots.push(index); + }); + if (slots.length) candidates.set(fn.name, { fn, slots, nodes }); + } + if (!candidates.size) return mod; + + // Single definitions of function-typed locals and globals. + const definitions = new Map>(); + // The sum of countNodes over every function. + let totalNodes = 0; + for (const { fn, nodes, values, counts } of each) { const single = new Map(); const locals = new Set(fn.locals.map((local) => local.id)); const shared = closureWritten.get(fn.name); @@ -221,7 +271,7 @@ export function specializeCallbackCalls( ) single.set(id, list[0]!); definitions.set(fn.name, single); - totalNodes += Math.min(nodes, MAX_FUNCTION_NODES + 1); + totalNodes += nodes; } const globalDefinitions = new Map(); for (const global of mod.globals ?? []) @@ -288,23 +338,18 @@ export function specializeCallbackCalls( // unknown callbacks or function-value entries. const keysOf = new Map>(); const unknown = new Set(); - for (const fn of mod.functions) - everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => { - if (expr.kind !== "call") return true; - const candidate = candidates.get(expr.callee); - if (!candidate) return true; - const key = callKey(fn, new Map(), expr, candidate); - if (key === "") unknown.add(expr.callee); - else if (key !== null) { - const keys = keysOf.get(expr.callee) ?? new Set(); - keys.add(key); - keysOf.set(expr.callee, keys); - } - return true; - }, - }); + for (const { fn, calls } of each) + for (const call of calls) { + const candidate = candidates.get(call.callee); + if (!candidate) continue; + const key = callKey(fn, new Map(), call, candidate); + if (key === "") unknown.add(call.callee); + else if (key !== null) { + const keys = keysOf.get(call.callee) ?? new Set(); + keys.add(key); + keysOf.set(call.callee, keys); + } + } const specialized = new Set(); for (const [name, keys] of keysOf) if (keys.size > 1 || unknown.has(name) || referenced.has(name)) specialized.add(name); @@ -419,12 +464,9 @@ export function specializeCallbackCalls( return owner.body.map(stmt); } - const functionsOut = mod.functions.map((fn) => { + const functionsOut = each.map(({ fn, calls }) => { // Most bodies call no specialized original; keep their nodes. - const untouched = everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => expr.kind !== "call" || !specialized.has(expr.callee), - }); + const untouched = calls.every((call) => !specialized.has(call.callee)); return untouched ? fn : { ...fn, body: rewriteBody(fn, new Map(), new Set(), false) }; }); for (let i = 0; i < pending.length; i++) { @@ -437,19 +479,29 @@ export function specializeCallbackCalls( // Drop originals no remaining code reaches: every direct call moved to a // copy and nothing names them as a value. const callers = new Map>(); - for (const fn of functionsOut) + const call = (callee: string, caller: string): void => { + if (!specialized.has(callee)) return; + const set = callers.get(callee) ?? new Set(); + set.add(caller); + callers.set(callee, set); + }; + functionsOut.forEach((fn, index) => { + // A body the rewrite kept has the calls the walk recorded, and its + // closures are already in `referenced`. + const kept = each[index]; + if (kept !== undefined && kept.fn === fn) { + for (const site of kept.calls) call(site.callee, fn.name); + return; + } everyStmtList(fn.body, { stmt: () => true, expr: (expr) => { - if (expr.kind === "call" && specialized.has(expr.callee)) { - const set = callers.get(expr.callee) ?? new Set(); - set.add(fn.name); - callers.set(expr.callee, set); - } + if (expr.kind === "call") call(expr.callee, fn.name); if (expr.kind === "closure" && specialized.has(expr.fnName)) referenced.add(expr.fnName); return true; }, }); + }); const live = new Set(); const work: string[] = []; for (const fn of functionsOut) diff --git a/packages/compiler/src/ir/capture-values.ts b/packages/compiler/src/ir/capture-values.ts index bcc587f1b..19ec41363 100644 --- a/packages/compiler/src/ir/capture-values.ts +++ b/packages/compiler/src/ir/capture-values.ts @@ -86,7 +86,12 @@ export function copyStableCaptures(mod: IrModule): IrModule { // A closure body whose every use became a direct call is only reachable // through its copy now. const referenced = new Set(moduleFunctionReferences(mod)); - for (const fn of functions) + functions.forEach((fn, index) => { + // A body the rewrite kept names what the flow recorded of it. + if (fn === mod.functions[index]) { + for (const name of flow.references(fn)) referenced.add(name); + return; + } everyStmtList(fn.body, { stmt: () => true, expr: (e) => { @@ -95,6 +100,7 @@ export function copyStableCaptures(mod: IrModule): IrModule { return true; }, }); + }); const clones = flow.directClones(); return { ...mod, @@ -251,6 +257,9 @@ interface FunctionFacts { captureIndex: Map; classCaptures: Set; params: Set; + /** Functions the body names: direct call callees and function values + * (exprFunctionReference). */ + references: Set; } interface CreationSite { @@ -273,17 +282,26 @@ export class CaptureFlow { /** Environment-free bodies called directly, and their copies' names. */ private readonly clones = new Map(); - constructor(functions: readonly IrFunction[]) { + /** With `scan` false, each body's facts are collected when a question + * first needs them, and only unchanged() may be asked: the other + * questions read creation sites, which come from scanning every body. */ + constructor(functions: readonly IrFunction[], scan = true) { for (const fn of functions) this.functions.set(fn.name, fn); this.receivers = new AmbientReceiverReaders(this.functions); // Creation sites are collected while scanning each body. - for (const fn of functions) this.factsOf(fn); + if (scan) for (const fn of functions) this.factsOf(fn); } directClones(): ReadonlyMap { return this.clones; } + /** The functions the body of `fn`, one of the functions this flow was + * made from, names (FunctionFacts.references). */ + references(fn: IrFunction): ReadonlySet { + return this.factsOf(fn).references; + } + /** A lifted body that captures nothing and can be entered like a declared * function: synchronous, never naming its own closure, and unable to * observe the ambient receiver a closure call would bracket. */ @@ -459,6 +477,7 @@ export class CaptureFlow { captureIndex: new Map(), classCaptures: new Set((fn.classCaptures ?? []).map((c) => c.localId)), params: new Set(fn.params.map((p) => p.localId)), + references: new Set(), }; (fn.captures ?? []).forEach((c, index) => { if (!facts.captureIndex.has(c.localId)) facts.captureIndex.set(c.localId, index); @@ -511,6 +530,8 @@ export class CaptureFlow { facts.writes.set(id, (facts.writes.get(id) ?? 0) + 1); }; const expr = (e: IrExpr): boolean => { + const referenced = e.kind === "call" ? e.callee : exprFunctionReference(e); + if (referenced !== undefined) facts.references.add(referenced); switch (e.kind) { case "varRef": facts.reads.set(e.localId, (facts.reads.get(e.localId) ?? 0) + 1); @@ -707,8 +728,11 @@ export class CaptureFlow { const splits = new Map(); const entry: IrStmt[] = []; const locals: IrLocal[] = []; - const used = new Set(fn.locals.map((l) => l.id)); + // The local ids a copy must avoid, collected for the first copy: most + // functions make none. + let used: Set | null = null; const fresh = (local: IrLocal): IrLocal => { + used ??= new Set(fn.locals.map((l) => l.id)); let k = 0; while (used.has(`${local.name}.${k}`)) k++; const id = `${local.name}.${k}`; diff --git a/packages/compiler/src/ir/char-unit-locals.ts b/packages/compiler/src/ir/char-unit-locals.ts index aff952909..fc1d5873b 100644 --- a/packages/compiler/src/ir/char-unit-locals.ts +++ b/packages/compiler/src/ir/char-unit-locals.ts @@ -300,6 +300,8 @@ function rewriteFunction(fn: IrFunction): IrFunction { // or as copies of another such local (a switch's discriminant temp). const reads = new Map(); const copyOf = new Map(); + // Whether a candidate string is declared from a charAt. + let charAtDeclared = false; const collect = (value: unknown): void => { if (Array.isArray(value)) for (const item of value) collect(item); else if (value !== null && typeof value === "object") { @@ -307,6 +309,7 @@ function rewriteFunction(fn: IrFunction): IrFunction { if (node.kind === "varDecl" && candidates.has(node.localId as string) && node.init) { const id = node.localId as string; const init = node.init as IrExpr; + if (locals.get(id)?.type.kind === "string" && isCharAt(init)) charAtDeclared = true; const read = optionalCharRead(init); if (read) reads.set(id, read); else if (init.kind === "varRef" && reads.has(init.localId)) { @@ -317,7 +320,8 @@ function rewriteFunction(fn: IrFunction): IrFunction { for (const key in node) if (key !== "loc") collect(node[key]); } }; - collect(fn.body); + // Both walks only record candidates: a function without one holds none. + if (candidates.size > 0) collect(fn.body); const localTags = (operand: IrExpr): OptionalCharRead | null => operand.kind === "varRef" ? (reads.get(operand.localId) ?? null) : null; @@ -344,7 +348,9 @@ function rewriteFunction(fn: IrFunction): IrFunction { for (const key in node) if (key !== "loc") count(node[key]); } }; - count(fn.body); + // Only candidates declared from a charAt or an optional read can be held + // (understoodMention), and count() only decides which are. + if (reads.size > 0 || charAtDeclared) count(fn.body); // A copy is held only with its source, and the source's mention in the // copy's initializer is understood only while the copy is held. const held = new Set(); diff --git a/packages/compiler/src/ir/expression-inlining.ts b/packages/compiler/src/ir/expression-inlining.ts index 377cfaf3f..1282cdb79 100644 --- a/packages/compiler/src/ir/expression-inlining.ts +++ b/packages/compiler/src/ir/expression-inlining.ts @@ -128,15 +128,24 @@ export function inlineExpressionFunctions(mod: IrModule): IrModule { const functions = mod.functions.map((fn) => { const locals = new Map(fn.locals.map((local) => [local.id, local])); const inLoops = callsInLoops(fn.body); - if (inLoops.size === 0) return fn; + // Each call the rewrite inlines: of an expression function, in a loop, + // with arguments the copy may repeat. + const inlinable = (e: IrExpr & { kind: "call" }): boolean => { + if (e.callee === fn.name) return false; + const target = expressions.get(e.callee); + return ( + target !== undefined && + target.fn.params.length === e.args.length && + e.args.every((arg) => duplicableArgument(arg, locals)) + ); + }; + if (![...inLoops].some((e) => e.kind === "call" && inlinable(e))) return fn; let inlined = false; const body = transformStmtList(fn.body, { stmt: (s) => s, expr: (e) => { - if (e.kind !== "call" || e.callee === fn.name || !inLoops.has(e)) return e; - const target = expressions.get(e.callee); - if (target === undefined || target.fn.params.length !== e.args.length) return e; - if (!e.args.every((arg) => duplicableArgument(arg, locals))) return e; + if (e.kind !== "call" || !inLoops.has(e) || !inlinable(e)) return e; + const target = expressions.get(e.callee)!; const args = new Map(target.fn.params.map((param, i) => [param.localId, e.args[i]!])); inlined = true; return copy(target.value, (ref) => { diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 79702ce97..fbd51dbc5 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -575,6 +575,19 @@ const DYN_READ_ONLY = new Set([ "buffer.poolFromDynRange", "insp.dyn", "insp.dynS", + // util.format and util.inspect over checked values: the inspect walk and + // the format conversions read them; insp.registerCustom keeps a class's + // custom inspect thunk in the runtime's registry. + "insp.customIsSelf", + "insp.customResult", + "insp.format", + "insp.formatList", + "insp.formatPrimitives", + "insp.formatSpecifiesDyn", + "insp.formatWithOptions", + "insp.inspect", + "insp.registerCustom", + "insp.dynFormatS", "insp.dynHidden", // The dynamic-tier operators run their dyn twins (scr_js_value.c); the // conversions move a value between dyn and jsval, and js.toF64 is diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index ea866a134..427f6879c 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -618,6 +618,28 @@ function writesLocal(body: IrStmt[], localId: string): boolean { }); } +/** The counter of a `for` loop that its condition reads and its update + * steps by a constant, when nothing else in the loop writes it: each test + * of the condition then sees a different value. The local must not be + * boxed (a closure could write it). */ +export function steppedLoopCounter( + stmt: IrStmt & { kind: "for" }, + locals: ReadonlyMap, +): { ref: IrExpr & { kind: "varRef" }; step: number } | null { + const cond = stmt.cond; + if (cond?.kind !== "bin") return null; + for (const side of [cond.left, cond.right]) { + if (side.kind !== "varRef") continue; + const local = locals.get(side.localId); + if (local === undefined || local.boxed) continue; + const step = constantStep(stmt.update, side.localId); + if (step === null || writesLocal(stmt.body, side.localId)) continue; + if (writesLocal([{ kind: "exprStmt", expr: cond, loc: cond.loc }], side.localId)) continue; + return { ref: side, step }; + } + return null; +} + function isUnitIncrement(update: IrStmt | null, localId: string): boolean { // The frontend normally lowers `i++` to `i = i + 1` before backend // emission. Accept the incDec form too so this analysis remains valid for diff --git a/packages/compiler/src/ir/parameter-versions.ts b/packages/compiler/src/ir/parameter-versions.ts index 88232251c..5156d4bff 100644 --- a/packages/compiler/src/ir/parameter-versions.ts +++ b/packages/compiler/src/ir/parameter-versions.ts @@ -184,7 +184,8 @@ export function versionLoopBoundParameters( symbolName: (name: string) => string = (name) => name, ): IrModule { const candidates: { fn: IrFunction; params: Set }[] = []; - const sites = callSites(mod); + // Call sites are read only for functions with loop-bound parameters. + let sites: ReturnType | null = null; const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); const members = new Set((mod.classes ?? []).flatMap((cls) => cls.methods ?? [])); for (const fn of mod.functions) { @@ -207,7 +208,11 @@ export function versionLoopBoundParameters( const local = locals.get(id); if (!local || local.boxed || local.tdz) params.delete(id); } - if (params.size === 0 || size(fn) > MAX_FUNCTION_NODES || keepsSingleBody(fn, sites, functions)) + if ( + params.size === 0 || + size(fn) > MAX_FUNCTION_NODES || + keepsSingleBody(fn, (sites ??= callSites(mod)), functions) + ) continue; candidates.push({ fn, params }); } diff --git a/packages/compiler/src/ir/row-pairs.test.ts b/packages/compiler/src/ir/row-pairs.test.ts index f9db5b89d..8ef01c665 100644 --- a/packages/compiler/src/ir/row-pairs.test.ts +++ b/packages/compiler/src/ir/row-pairs.test.ts @@ -93,12 +93,39 @@ test("a row-sum nest runs two rows per pass behind its bounds and overlap test", // from the array the term reads. expect(JSON.stringify(go.init)).toContain('"fn":"bytes.disjoint"'); expect(branch.else_).toBeNull(); - const pairs = branch.then[0] as IrStmt & { kind: "for" }; + // The four bounds, masked to the range the test proved, then the pairs. + expect(branch.then.slice(0, 4)).toMatchObject( + Array(4).fill({ kind: "varDecl", init: { op: "&", right: { value: 2 ** 25 - 1 } } }), + ); + const pairs = branch.then[4] as IrStmt & { kind: "for" }; expect(pairs.body.filter((s) => s.kind === "bytesSet")).toHaveLength(2); - const inner = pairs.body[2] as IrStmt & { kind: "for" }; - // One read of u[j] for both rows, then the two sums. - expect(inner.body).toHaveLength(3); + const [, , inner, last] = pairs.body as [ + IrStmt, + IrStmt, + IrStmt & { kind: "for" }, + IrStmt & { kind: "if" }, + ]; + const column = inner.init as IrStmt & { kind: "varDecl" }; + // Two columns per step: for each, one read of u[j] for both rows, then + // the two sums; the second column reads `column + 1`. + expect(inner.body).toHaveLength(6); expect(inner.body[0]).toMatchObject({ kind: "varDecl", init: { kind: "bytesIntrinsic" } }); + expect(inner.body[3]).toMatchObject({ + kind: "varDecl", + init: { + kind: "bytesIntrinsic", + args: [{ op: "+", left: { localId: column.localId }, right: { value: 1 } }], + }, + }); + expect(inner.cond).toMatchObject({ op: "<", right: { op: "-", right: { value: 1 } } }); + expect(inner.update).toMatchObject({ value: { op: "+", right: { value: 2 } } }); + // The last column of an odd count, `to - 1`. + expect(last.then).toHaveLength(3); + expect(last.then[0]).toMatchObject({ + kind: "varDecl", + init: { args: [{ op: "-", right: { value: 1 } }] }, + }); + expect(last.else_).toBeNull(); // The second row's term reads `row + 1`. const row = pairs.init?.kind === "varDecl" ? pairs.init.localId : ""; expect((inner.body[2] as IrStmt & { kind: "assign" }).value).toMatchObject({ @@ -130,7 +157,7 @@ test("a row-sum nest runs two rows per pass behind its bounds and overlap test", expect(top.body).toHaveLength(4); expect( result.functions[0]!.locals.map((l) => l.id).filter((id) => id.startsWith("%pair.")), - ).toHaveLength(7); + ).toHaveLength(13); }); test("nests whose term calls, writes or reads the sum keep their loops", () => { diff --git a/packages/compiler/src/ir/row-pairs.ts b/packages/compiler/src/ir/row-pairs.ts index 2e189f42e..a3ef789a3 100644 --- a/packages/compiler/src/ir/row-pairs.ts +++ b/packages/compiler/src/ir/row-pairs.ts @@ -7,11 +7,24 @@ import { type IrModule, type IrStmt, } from "./ir.js"; -import { everyExpr, everyExprChild, mapExprChildren, transformStmtList } from "./traverse.js"; +import { + everyExpr, + everyExprChild, + everyStmtList, + mapExprChildren, + transformStmtList, +} from "./traverse.js"; +import { PRODUCT_COUNTER_BOUND } from "./integer-loops.js"; /** Inner-loop expressions above this many nodes keep the loop as written. */ const MAX_TERM_NODES = 64; +/** The largest bound a paired nest runs with. Counters below + * PRODUCT_COUNTER_BOUND keep products of their sums exact, so the nest's + * counted loops take integer counters without a run-time guard (and its + * copy of the body). */ +const SMALL_BOUND = PRODUCT_COUNTER_BOUND - 1; + /** The number operators a term may use: they run no other code and cannot * throw. */ const PURE_BINARY = new Set([ @@ -196,7 +209,7 @@ function copy( } const bin = ( - op: "+" | "-" | "&" | "|", + op: "+" | "-" | "/" | "&" | "|", left: IrExpr, right: IrExpr, loc: IrExpr["loc"], @@ -210,7 +223,8 @@ const bin = ( }); /** Runs the row sums of `match` two rows per pass when the test holds and - * as written otherwise. */ + * as written otherwise. Each pass takes two columns per step, then the + * last column when their count is odd. */ function pairRows( s: IrStmt & { kind: "for" }, match: RowSum, @@ -225,42 +239,93 @@ function pairRows( sum = fresh("sum"), next = fresh("next"), column = fresh("column"); - const atColumn = (r: IrExpr & { kind: "varRef" }): IrExpr => - r.localId === match.column ? { ...r, localId: column } : { ...r }; // Element reads that do not depend on the row are read once per column // and shared by both rows. - const shared = new Map(); - const elements: IrStmt[] = []; + const sharedReads: IrExpr[] = []; const collect = (e: IrExpr): boolean => { if (e.kind === "bytesIntrinsic" && e.method === "get" && !readsLocal(e, match.row)) { - const id = fresh("element"); - shared.set(e, id); - elements.push({ kind: "varDecl", localId: id, init: copy(e, atColumn), loc }); + sharedReads.push(e); return true; } return everyExprChild(e, collect, () => true); }; collect(match.term); - // The second row's term reads `row + 1`, so its counter sums stay visible - // to the counted-loop analysis. - const termAt = (rowExpr: () => IrExpr): IrExpr => - copy( - match.term, - (r) => (r.localId === match.row ? rowExpr() : atColumn(r)), - (node) => { - const id = shared.get(node); - return id === undefined ? null : { kind: "varRef", localId: id, type: node.type, loc }; + /** Both rows' additions at the column `columnAt()`: its shared element + * reads, then each row's sum. The second row's term reads `row + 1`, and + * the second column's `column + 1`, so their counter sums stay visible to + * the counted-loop analysis. */ + const step = (columnAt: () => IrExpr): IrStmt[] => { + const at = (r: IrExpr & { kind: "varRef" }): IrExpr => + r.localId === match.column ? columnAt() : { ...r }; + const shared = new Map(); + const elements: IrStmt[] = sharedReads.map((read) => { + const id = fresh("element"); + shared.set(read, id); + return { kind: "varDecl", localId: id, init: copy(read, at), loc }; + }); + const termAt = (rowExpr: () => IrExpr): IrExpr => + copy( + match.term, + (r) => (r.localId === match.row ? rowExpr() : at(r)), + (node) => { + const id = shared.get(node); + return id === undefined ? null : { kind: "varRef", localId: id, type: node.type, loc }; + }, + ); + return [ + ...elements, + { + kind: "assign", + localId: sum, + value: bin( + "+", + ref(sum), + termAt(() => ref(row)), + loc, + ), + loc, }, - ); + { + kind: "assign", + localId: next, + value: bin( + "+", + ref(next), + termAt(() => bin("+", ref(row), num(1), loc)), + loc, + ), + loc, + }, + ]; + }; const innerCond = match.inner.cond as IrExpr & { kind: "bin" }; + const columnStart = (match.inner.init as IrStmt & { kind: "varDecl" }).init!; + // The bounds, as the paired nest reads them (see `small` below). + const low = fresh("low"), + high = fresh("high"), + first = fresh("first"), + last = fresh("last"); + const bounds: IrStmt[] = ( + [ + [low, match.start], + [high, match.end], + [first, columnStart], + [last, innerCond.right], + ] as const + ).map(([id, bound]) => ({ + kind: "varDecl", + localId: id, + init: bin("&", again(bound), num(SMALL_BOUND), loc), + loc, + })); const pairs: IrStmt = { kind: "for", - init: { kind: "varDecl", localId: row, init: again(match.start), loc }, + init: { kind: "varDecl", localId: row, init: ref(low), loc }, cond: { kind: "bin", op: "<", left: ref(row), - right: bin("-", again(match.end), num(1), loc), + right: bin("-", ref(high), num(1), loc), type: BOOL, loc, }, @@ -268,41 +333,40 @@ function pairRows( body: [ { kind: "varDecl", localId: sum, init: { ...match.init }, loc }, { kind: "varDecl", localId: next, init: { ...match.init }, loc }, + // Both column bounds are int32, so `column < to - 1` is + // `column + 1 < to`. Each row adds its columns in order. { kind: "for", - init: { - kind: "varDecl", - localId: column, - init: again((match.inner.init as IrStmt & { kind: "varDecl" }).init!), + init: { kind: "varDecl", localId: column, init: ref(first), loc }, + cond: { + ...innerCond, + left: ref(column), + right: bin("-", ref(last), num(1), loc), + }, + update: { kind: "assign", localId: column, value: bin("+", ref(column), num(2), loc), loc }, + body: [...step(() => ref(column)), ...step(() => bin("+", ref(column), num(1), loc))], + loc, + }, + // An odd column count leaves the last column, `to - 1`. + { + kind: "if", + cond: { + kind: "logical", + op: "&&", + left: { ...innerCond, left: ref(first), right: ref(last) }, + right: { + kind: "bin", + op: "===", + left: bin("&", bin("-", ref(last), ref(first), loc), num(1), loc), + right: num(1), + type: BOOL, + loc, + }, + type: BOOL, loc, }, - cond: { ...innerCond, left: ref(column), right: again(innerCond.right) }, - update: { kind: "assign", localId: column, value: bin("+", ref(column), num(1), loc), loc }, - body: [ - ...elements, - { - kind: "assign", - localId: sum, - value: bin( - "+", - ref(sum), - termAt(() => ref(row)), - loc, - ), - loc, - }, - { - kind: "assign", - localId: next, - value: bin( - "+", - ref(next), - termAt(() => bin("+", ref(row), num(1), loc)), - loc, - ), - loc, - }, - ], + then: step(() => bin("-", ref(last), num(1), loc)), + else_: null, loc, }, { kind: "bytesSet", arr: { ...match.out }, index: ref(row), value: ref(sum), loc }, @@ -316,9 +380,13 @@ function pairRows( ], loc, }; - // Both bounds are int32, so `row < end - 1` is `row + 1 < end`, and the - // nest has at least two rows. The overlap tests run only then, so they never - // read an array the program assigns only when there are rows. Rows are + // Every bound is an integer in [0, SMALL_BOUND] and not -0, so `row < + // end - 1` is `row + 1 < end` (and the same for the columns), and the + // masks in `bounds` leave the bounds unchanged while proving both + // counters' ranges. + // The nest has at least two rows. The column bounds' tests and the + // overlap tests run only then, so they never read an array the program + // assigns only when there are rows. Rows are // independent when the written array shares no byte with an array the // term reads; an array that is not there answers false. const int32 = (e: IrExpr): IrExpr => ({ @@ -329,9 +397,24 @@ function pairRows( type: BOOL, loc, }); + const compare = (op: "<=" | ">=", e: IrExpr, value: number): IrExpr => ({ + kind: "bin", + op, + left: again(e), + right: num(value), + type: BOOL, + loc, + }); + // `1 / e > 0` holds for +0 and every positive bound, but not for -0: + // masking would turn a -0 start into +0, which a term can tell apart. + const small = (e: IrExpr): IrExpr[] => [ + int32(e), + { kind: "bin", op: ">", left: bin("/", num(1), again(e), loc), right: num(0), type: BOOL, loc }, + compare("<=", e, SMALL_BOUND), + ]; const tests: IrExpr[] = [ - int32(match.start), - int32(match.end), + ...small(match.start), + ...small(match.end), { kind: "bin", op: "<", @@ -340,6 +423,8 @@ function pairRows( type: BOOL, loc, }, + ...small(columnStart), + ...small(innerCond.right), ...match.reads.map((read): IrExpr => ({ kind: "libCall", fn: "bytes.disjoint", @@ -394,7 +479,7 @@ function pairRows( }, loc, }, - { kind: "if", cond: { ...paired }, then: [pairs], else_: null, loc }, + { kind: "if", cond: { ...paired }, then: [...bounds, pairs], else_: null, loc }, rest, ], loc, @@ -413,12 +498,30 @@ function pairRows( * store into shares no byte with any array the term reads, so no row reads * another row's result. An odd row count runs its last row as written. * Otherwise the nest runs as written. Two independent sums take turns in - * the inner loop, so neither waits on the other's addition. */ + * the inner loop, so neither waits on the other's addition. + * + * The inner loop takes two columns per step, each row adding the first + * column's term and then the second's, and the last column on its own + * when the count is odd, so the additions keep their order. Both column + * bounds must also be int32 (so `column < to - 1` is `column + 1 < to`). + * Neighboring rows and columns share counter sums (`row + 1 + column` and + * `row + (column + 1)`), and the step's divisions have no dependence + * between them. */ export function pairRowSums(mod: IrModule): IrModule { let changed = false; const functions = mod.functions.map((fn): IrFunction => { if (fn.async || fn.generator) return fn; const locals = new Map(fn.locals.map((local) => [local.id, local])); + // Most bodies hold no row sum: find one before copying the body. + let found = false; + everyStmtList(fn.body, { + expr: () => !found, + stmt: (s) => { + if (s.kind === "for" && matchRowSum(s, locals) !== null) found = true; + return !found; + }, + }); + if (!found) return fn; const added: IrLocal[] = []; const ids = new Set(fn.locals.map((local) => local.id)); const fresh = (name: string): string => { diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 3854ab2c8..cabb25909 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -103,6 +103,11 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "punycode.warn", // DEP0005 for the deprecated Buffer constructor. "buffer.deprecation", + // util.aborted answers a checked-dynamic promise, and so do the + // functions util.debounce and util.throttle return. + "util.aborted", + "util.debounce", + "util.throttle", "timers.immediatePromise", // The performance timeline (scr_perf.c, linked with this unit for its // MaxPerformanceEntryBufferExceededWarning). @@ -144,8 +149,11 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "util.promisifyDyn", "util.debuglog", "util.deprecate", + "util.diff", + "util.extend", "util.inherits", "util.inheritsPrototype", + "util.isArrayDeprecated", "util.styleText", ]); @@ -174,8 +182,11 @@ const UTIL_CALLABLE_LIB_FNS: ReadonlySet = new Set([ "util.callbackify", "util.debuglog", "util.deprecate", + "util.diff", + "util.extend", "util.inherits", "util.inheritsPrototype", + "util.isArrayDeprecated", ]); /** Entry points that link the regex unit: the regex and glob matchers (fs.glob's walk compiles its patterns with the glob matcher), and readline's terminal unit (scr_readline_tty.c), which measures with the regex unit's stripVTControlCharacters. */ @@ -997,9 +1008,20 @@ const RUNTIME_FEATURE_RULES = { (mod.classes ?? []).some( (c) => c.name === RUNTIME_EVENT_TARGET_CLASS || c.name === RUNTIME_EVENT_CLASS, ), - fns: ["events.addAbortListener", ...ABORT_SIGNAL_LIB_FNS], + // util.debounce and util.throttle register options.signal's listener. + fns: [ + "events.addAbortListener", + "util.aborted", + "util.debounce", + "util.throttle", + ...ABORT_SIGNAL_LIB_FNS, + ], prefixes: ["event."], }), + /** util.MIMEType and util.MIMEParams (scr_mime.c). */ + mime: rule({ prefixes: ["mime."] }), + /** util.debounce and util.throttle (scr_util_timing.c). */ + utilTiming: rule({ fns: ["util.debounce", "util.throttle"] }), /** events.once(emitter, name, { signal }) (scr_events_once_signal.c). */ eventsOnceSignal: rule({ fns: ["events.onceSignal"] }), fetch: rule({ prefixes: ["fetch."] }), @@ -1221,6 +1243,8 @@ function noRuntimeFeatures(): RuntimeFeatures { http: false, inspect: false, legacyTextDecoder: false, + mime: false, + utilTiming: false, mockTimers: false, net: false, netLookup: false, diff --git a/packages/compiler/src/ir/scalar-records.ts b/packages/compiler/src/ir/scalar-records.ts index 5d57cb3ec..088fd8461 100644 --- a/packages/compiler/src/ir/scalar-records.ts +++ b/packages/compiler/src/ir/scalar-records.ts @@ -554,17 +554,23 @@ export function scalarizeLiteralResults(mod: IrModule): IrModule { } if (producers.size === 0) return mod; let changed = false; + const globalIds = new Set((mod.globals ?? []).map((g) => g.id)); const functions = mod.functions.map((fn): IrFunction => { if (fn.async || fn.generator) return fn; const locals = new Map(fn.locals.map((l) => [l.id, l])); - const used = new Set([...locals.keys(), ...(mod.globals ?? []).map((g) => g.id)]); - collectLabels(fn.body, used); + // The names a fresh binding must avoid, collected when the first one is + // made: most functions inline no producer. + let used: Set | null = null; let next = 0; const fresh = (): string => { + if (used === null) { + used = new Set(locals.keys()); + collectLabels(fn.body, used); + } let id: string; do { id = `%literal.${next++}`; - } while (used.has(id)); + } while (used.has(id) || globalIds.has(id)); used.add(id); return id; }; diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts index 74f634d4a..70242cb8b 100644 --- a/packages/compiler/src/ir/self-captures.ts +++ b/packages/compiler/src/ir/self-captures.ts @@ -107,7 +107,8 @@ export function resolveSelfCaptures(mod: IrModule): IrModule { if (creator.captures?.some((c) => c.localId === source)) return false; if (creator.classCaptures?.some((c) => c.localId === source)) return false; if (declarations.get(owner)!.get(source) !== 1) return false; - flow ??= new CaptureFlow(mod.functions); + // Only unchanged() is asked: the flow reads just the bodies it needs. + flow ??= new CaptureFlow(mod.functions, false); return flow.unchanged(owner, source, definition.writes); }); if (qualifies) { diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index e204b8a6b..0250e9618 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -168,6 +168,7 @@ export class NativeCache { "sanitizer", "executable", "kept", + "linker-job", "runtime-pack", "binary", "dsym", diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index ff49e2ac9..0b56b0a53 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -70,6 +70,7 @@ import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js"; import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js"; import { lldLinkerArgs } from "../backend/lld.js"; import { openNativeExecutableCache } from "./executable-cache.js"; +import { LinkerJobs } from "./linker-job.js"; import { nativeFileIdentity } from "./file-identity.js"; /** The first partition keeps the whole-object key used before partitioning. */ @@ -530,11 +531,14 @@ export class NativeCompiler { "-o", stagedOutput, ]; - const cacheable = executableCache?.trace(linkArgs, stage) ?? false; + // Host Linux links with lld run the linker job the driver would. + const jobs = LinkerJobs.open(this.cache, toolchain, lld, options.sanitize ?? false); + const job = jobs?.link([...plan.inputs, stagedOutput], stage); + const cacheable = executableCache?.trace(linkArgs, stage, job) ?? false; timing("link-inputs"); // A successful dependency trace already produced this executable. // Keep its verified output instead of asking the linker to repeat it. - if (!cacheable) { + if (!cacheable && (job?.run(linkArgs, true) ?? null) === null) { const driver = options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker; try { runNativeTool(driver, linkArgs); diff --git a/packages/compiler/src/native/executable-cache.test.ts b/packages/compiler/src/native/executable-cache.test.ts index 8bec9916e..4f950e5b5 100644 --- a/packages/compiler/src/native/executable-cache.test.ts +++ b/packages/compiler/src/native/executable-cache.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { contentDigest, NativeCache } from "./cache.js"; -import { NativeExecutableCache } from "./executable-cache.js"; +import { identity, NativeExecutableCache } from "./executable-cache.js"; vi.mock("node:child_process", async (original) => ({ ...(await original()), @@ -331,3 +331,43 @@ test("a Linux build that keeps its output does not read the deferred inputs", () expect(open().restore(f.destination)).toBe(true); expect(deferred).toHaveBeenCalledTimes(3); }); + +test("a Linux link that runs as its linker job is traced from the job's run, and otherwise records it", () => { + const f = fixture(); + const lib = join(f.root, "lib"); + mkdirSync(lib); + const crt = join(lib, "Scrt1.o"); + writeFileSync(crt, "startup object"); + const program = join(f.stage, "program.o"); + writeFileSync(program, "program"); + const job = { + run: vi.fn((_args: string[], _list: boolean) => ({ + output: [crt, program].join("\n") + "\n", + driverInputs: [identity(crt), identity(lib)], + })), + record: vi.fn(), + }; + const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true); + const entry = open(); + expect(entry.trace(["-o", f.output], f.stage, job)).toBe(true); + // The job runs the traced link, and the driver does not. + expect(job.run.mock.calls[0]).toEqual([["-o", f.output, "-v", "-Wl,-t"], false]); + expect(spawnSync).not.toHaveBeenCalled(); + expect(job.record).not.toHaveBeenCalled(); + entry.recordOutput(f.output); + const recorded = ( + JSON.parse(f.cache.read("kept", f.key)!.toString()) as { inputs: { path: string }[] } + ).inputs.map((saved) => saved.path); + expect(recorded).toEqual(expect.arrayContaining([crt, lib])); + expect(recorded).not.toContain(program); + expect(open().keep(f.output)).toBe(true); + // A link without a job runs through the driver, and records the job from + // the driver's output. + const record = vi.fn(); + expect(open().trace(["-o", f.output], f.stage, { run: () => null, record })).toBe(true); + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(record).toHaveBeenCalledWith(["-o", f.output, "-v", "-Wl,-t"], f.library + "\n\n", [ + identity(f.sdk), + identity(f.library), + ]); +}); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index bafd2c020..a26bc1d87 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -14,8 +14,9 @@ import type { NativeOptimization } from "../backend/optimization.js"; import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; import type { NativeToolchain } from "./toolchain.js"; import { contentDigest, NativeCache } from "./cache.js"; +import type { LinkerJobLink } from "./linker-job.js"; -interface InputIdentity { +export interface InputIdentity { path: string; canonical: string; kind: "file" | "directory"; @@ -33,7 +34,7 @@ interface ExecutableEntry { symbols: string | null; } -function identity(path: string): InputIdentity { +export function identity(path: string): InputIdentity { path = resolve(path); const info = statSync(path); if (!info.isFile() && !info.isDirectory()) @@ -63,7 +64,7 @@ function sameIdentity(a: InputIdentity, b: InputIdentity): boolean { ); } -function stillMatches(inputs: InputIdentity[]): boolean { +export function stillMatches(inputs: InputIdentity[]): boolean { try { return inputs.every((input) => sameIdentity(identity(input.path), input)); } catch { @@ -71,7 +72,7 @@ function stillMatches(inputs: InputIdentity[]): boolean { } } -function commandPath(command: string): string { +export function commandPath(command: string): string { if (command.includes("/") || command.includes("\\")) return resolve(command); for (const directory of (process.env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) { const candidate = join(directory || process.cwd(), command); @@ -92,7 +93,7 @@ function toolOutput(executable: string, args: string[]): string { return result.stdout + "\n" + result.stderr; } -function tracePaths(output: string, driver: boolean, roots: string[]): string[] { +export function tracePaths(output: string, driver: boolean, roots: string[]): string[] { const paths = new Set(); for (const line of output.split(/\r?\n/)) { for (const candidate of driver ? driverTraceCandidates(line) : linkTraceCandidate(line)) { @@ -134,7 +135,7 @@ interface KeptEntry { inputs: InputIdentity[]; } -function validIdentity(input: InputIdentity): boolean { +export function validIdentity(input: InputIdentity): boolean { return ( input !== null && typeof input === "object" && @@ -290,8 +291,10 @@ export class NativeExecutableCache { * Private build inputs already belong to the LLVM/runtime cache identity. * A true result means the requested output was linked successfully and its * inputs remained stable. The caller can publish that output directly. On - * false, the caller links the output itself. */ - trace(args: string[], stage: string): boolean { + * false, the caller links the output itself. On Linux, `job` runs the + * traced link as its recorded linker job when it can, and otherwise records + * the job the driver ran (see LinkerJobs). */ + trace(args: string[], stage: string, job?: LinkerJobLink): boolean { this.traced = false; try { this.addDeferredInputs(); @@ -300,8 +303,13 @@ export class NativeExecutableCache { ? [] : tracePaths(toolOutput(this.linker, [...args, "-###"]), true, roots).map(identity); // On Linux, -v prints the jobs that -### would, while the link runs. - const linked = toolOutput(this.linker, [...args, ...(this.linux ? ["-v"] : []), "-Wl,-t"]); - if (this.linux) driverInputs = tracePaths(linked, true, roots).map(identity); + const traced = [...args, ...(this.linux ? ["-v"] : []), "-Wl,-t"]; + const direct = this.linux ? (job?.run(traced, false) ?? null) : null; + const linked = direct?.output ?? toolOutput(this.linker, traced); + if (this.linux) { + driverInputs = direct?.driverInputs ?? tracePaths(linked, true, roots).map(identity); + if (direct === null) job?.record(traced, linked, driverInputs); + } const paths = tracePaths(linked, false, roots); if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs)) return false; diff --git a/packages/compiler/src/native/linker-job.test.ts b/packages/compiler/src/native/linker-job.test.ts new file mode 100644 index 000000000..b9d718c03 --- /dev/null +++ b/packages/compiler/src/native/linker-job.test.ts @@ -0,0 +1,408 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { + LINUX_ARM64_GNU_TARGET, + LINUX_X64_GNU_TARGET, + MACOS_ARM64_TARGET, + type NativeTargetSpec, +} from "../backend/targets.js"; +import { NativeCache } from "./cache.js"; +import { LinkerJobs } from "./linker-job.js"; +import type { NativeToolchain } from "./toolchain.js"; + +const directories: string[] = []; +const saved = new Map(); +const variables = [ + "SCRIPTC_LINKER", + "COMPILER_PATH", + "LOG", + "LD", + "CRT", + "LIB", + "FAIL", + "BROKEN", + "DOUBLE", +]; +beforeEach(() => { + for (const name of variables) { + saved.set(name, process.env[name]); + delete process.env[name]; + } +}); +afterEach(() => { + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + saved.clear(); + for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +function script(path: string, text: string): string { + writeFileSync(path, text); + chmodSync(path, 0o755); + return path; +} + +/** A driver named `name` and an ld.lld that log their arguments to $LOG. The + * driver's job runs $LD with `-pie`, the startup object $CRT, `-L$LIB` and + * its own arguments (without `-fuse-ld=lld` and `-v`, `-Wl,` removed). With + * -### it prints that job as clang does, every argument quoted; with -v it + * prints the job it runs as clang does, only the program quoted. With + * $BROKEN, -### prints the job twice; with $DOUBLE, it names the path $DOUBLE + * once more. The linker writes its inputs to its output, prints them with + * `-t`, and fails with $FAIL. */ +function fixture(name = "clang") { + const root = mkdtempSync(join(tmpdir(), "scriptc-linker-job-")); + directories.push(root); + const bin = join(root, "bin"); + const lib = join(root, "lib"); + // The recorded inputs include the parents of the toolchain's paths (here + // `root`); stages are elsewhere, as a build's are. + const stages = join(root, "stages"); + for (const directory of [bin, lib, stages]) mkdirSync(directory); + const log = join(root, "log"); + writeFileSync(log, ""); + const driver = script( + join(bin, name), + `#!/bin/sh +echo "driver $*" >> "$LOG" +job=" \\"$LD\\" \\"-pie\\" \\"$CRT\\" \\"-L$LIB\\"" +raw=" \\"$LD\\" -pie $CRT -L$LIB" +listing=0; verbose=0 +for a; do + case "$a" in + -'###') listing=1 ;; + -v) verbose=1 ;; + -fuse-ld=lld) ;; + -Wl,*) job="$job \\"\${a#-Wl,}\\""; raw="$raw \${a#-Wl,}" ;; + *) job="$job \\"$a\\""; raw="$raw $a" ;; + esac +done +if [ $listing = 1 ]; then + echo "InstalledDir: $(dirname "$0")" >&2 + [ -z "$DOUBLE" ] && echo "$job" >&2 + [ -n "$BROKEN" ] && echo "$job" >&2 + [ -n "$DOUBLE" ] && echo "$job \\"$DOUBLE\\"" >&2 && exit 0 + exit 0 +fi +[ $verbose = 1 ] && echo "$raw" >&2 +eval "exec $job" +`, + ); + const ld = script( + join(bin, "ld.lld"), + `#!/bin/sh +echo "ld.lld $*" >> "$LOG" +[ -n "$FAIL" ] && exit 1 +out=""; prev="" +for a; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done +printf '%s\\n' "$@" > "$out" +for a; do [ "$a" = "-t" ] && printf '%s\\n' "$@" | grep '^/'; done +exit 0 +`, + ); + const crt = join(lib, "Scrt1.o"); + writeFileSync(crt, "startup object"); + Object.assign(process.env, { LOG: log, LD: ld, CRT: crt, LIB: lib }); + // A directory's identity is its stat: an old time makes a later change visible. + utimesSync(lib, 0, 0); + const cache = new NativeCache(join(root, "cache")); + const toolchain = (target: NativeTargetSpec = LINUX_X64_GNU_TARGET, helper = target) => + ({ + target, + helper: helper.helper, + linker: driver, + }) as unknown as NativeToolchain; + let count = 0; + /** A stage with a program object, the link's paths and its driver arguments. */ + const stage = () => { + const directory = join(stages, `stage-${++count}`); + mkdirSync(directory); + const program = join(directory, "program.o"); + const output = join(directory, "out"); + writeFileSync(program, "program"); + return { + directory, + output, + paths: [program, output], + args: ["-fuse-ld=lld", "-pthread", program, "-lm", "-o", output], + }; + }; + const calls = () => + readFileSync(log, "utf8") + .split("\n") + .filter((line) => line !== ""); + return { root, bin, lib, crt, ld, driver, cache, toolchain, stage, calls }; +} + +test.skipIf(process.platform === "win32")( + "a link records the driver's job once, and later links run ld.lld with their own paths", + () => { + const f = fixture(); + const jobs = LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + expect(jobs).not.toBeNull(); + const first = f.stage(); + const run = jobs.run(first.args, first.paths, first.directory, true); + expect(run).not.toBeNull(); + // The driver only listed the job; ld.lld linked. + expect(f.calls()).toEqual([ + `driver ${first.args.join(" ")} -v -###`, + `ld.lld -pie ${f.crt} -L${f.lib} -pthread ${first.paths[0]} -lm -o ${first.output}`, + ]); + expect(readFileSync(first.output, "utf8")).toContain(first.paths[0]); + const recorded = run!.driverInputs.map((input) => input.path); + expect(recorded).toEqual(expect.arrayContaining([f.ld, f.bin, f.crt, f.lib])); + expect(recorded.some((path) => path.startsWith(first.directory))).toBe(false); + // Another build, with other paths, runs the recorded job without the driver. + const second = f.stage(); + const again = LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + expect(again.run(second.args, second.paths, second.directory, true)).not.toBeNull(); + expect(f.calls().slice(2)).toEqual([ + `ld.lld -pie ${f.crt} -L${f.lib} -pthread ${second.paths[0]} -lm -o ${second.output}`, + ]); + // Other arguments are another job. + const third = f.stage(); + expect( + again.run([...third.args, "-Wl,-t"], third.paths, third.directory, true)?.output, + ).toContain(third.paths[0]); + expect(f.calls().slice(3)[0]).toBe(`driver ${third.args.join(" ")} -Wl,-t -v -###`); + }, +); + +test.skipIf(process.platform === "win32")( + "a driver link records its job from its -v output, and later links run it", + () => { + const f = fixture(); + const open = () => LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + const first = f.stage(); + const traced = [...first.args, "-v", "-Wl,-t"]; + // Without an entry, a link that is not listed runs through the driver. + const link = open().link(first.paths, first.directory); + expect(link.run(traced, false)).toBeNull(); + expect(f.calls()).toEqual([]); + const linked = spawnSync(f.driver, traced, { encoding: "utf8" }); + expect(linked.status).toBe(0); + link.record(traced, linked.stdout + "\n" + linked.stderr); + const second = f.stage(); + const run = open() + .link(second.paths, second.directory) + .run([...second.args, "-v", "-Wl,-t"], false); + expect(run?.output).toContain(second.paths[0]); + expect(run!.driverInputs.map((input) => input.path)).toEqual( + expect.arrayContaining([f.ld, f.bin, f.crt, f.lib]), + ); + expect(f.calls().slice(2)).toEqual([ + `ld.lld -pie ${f.crt} -L${f.lib} -pthread ${second.paths[0]} -lm -o ${second.output} -t`, + ]); + }, +); + +test.skipIf(process.platform === "win32")( + "a changed startup object, library directory or linker sends the link to the driver", + () => { + for (const change of ["crt", "lib", "ld"] as const) { + const f = fixture(); + const open = () => LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + const first = f.stage(); + expect(open().run(first.args, first.paths, first.directory, true)).not.toBeNull(); + if (change === "lib") writeFileSync(join(f.lib, "libm.so"), "new library"); + else { + const path = change === "crt" ? f.crt : f.ld; + const before = statSync(path); + writeFileSync(path + ".new", readFileSync(path)); + chmodSync(path + ".new", 0o755); + utimesSync(path + ".new", before.atime, before.mtime); + renameSync(path + ".new", path); + } + const second = f.stage(); + const count = f.calls().length; + expect(open().run(second.args, second.paths, second.directory, true)).toBeNull(); + expect(f.calls().length).toBe(count); + } + }, +); + +test.skipIf(process.platform === "win32")("a changed driver records the job again", () => { + const f = fixture(); + const first = f.stage(); + expect( + LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!.run( + first.args, + first.paths, + first.directory, + true, + ), + ).not.toBeNull(); + writeFileSync(f.driver, readFileSync(f.driver, "utf8") + "\n"); + const second = f.stage(); + expect( + LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!.run( + second.args, + second.paths, + second.directory, + true, + ), + ).not.toBeNull(); + expect(f.calls()[2]).toBe(`driver ${second.args.join(" ")} -v -###`); +}); + +test.skipIf(process.platform === "win32")( + "a job that fails sends this and the build's later links to the driver", + () => { + const f = fixture(); + const first = f.stage(); + const open = () => LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + expect(open().run(first.args, first.paths, first.directory, true)).not.toBeNull(); + process.env["FAIL"] = "1"; + const jobs = open(); + const second = f.stage(); + expect(jobs.run(second.args, second.paths, second.directory, true)).toBeNull(); + const count = f.calls().length; + expect(jobs.run(second.args, second.paths, second.directory, true)).toBeNull(); + expect(f.calls().length).toBe(count); + }, +); + +test.skipIf(process.platform === "win32")( + "a listing without one ld.lld job naming each path once keeps the driver, and is not asked again", + () => { + for (const listing of ["two jobs", "another linker", "a path twice"] as const) { + const f = fixture(); + if (listing === "two jobs") process.env["BROKEN"] = "1"; + if (listing === "another linker") + process.env["LD"] = script(join(f.bin, "ld.bfd"), readFileSync(f.ld, "utf8")); + const open = () => LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + const first = f.stage(); + if (listing === "a path twice") process.env["DOUBLE"] = first.paths[0]; + expect(open().run(first.args, first.paths, first.directory, true)).toBeNull(); + expect(f.calls()).toEqual([`driver ${first.args.join(" ")} -v -###`]); + // The answer is stored: the next build does not list the job again. + expect(open().run(first.args, first.paths, first.directory, true)).toBeNull(); + expect(f.calls().length).toBe(1); + delete process.env["BROKEN"]; + delete process.env["DOUBLE"]; + } + }, +); + +test.skipIf(process.platform === "win32")( + "arguments that name the stage besides the link's paths keep the driver", + () => { + const f = fixture(); + const first = f.stage(); + const jobs = LinkerJobs.open(f.cache, f.toolchain(), ["-fuse-ld=lld"], false)!; + const script = `-Wl,--version-script=${join(first.directory, "exports")}`; + expect(jobs.run([...first.args, script], first.paths, first.directory, true)).toBeNull(); + expect(f.calls()).toEqual([]); + }, +); + +test.skipIf(process.platform === "win32")( + "only host Linux links through a clang driver with lld run the job", + () => { + const f = fixture(); + const lld = ["-fuse-ld=lld"]; + expect(LinkerJobs.open(f.cache, f.toolchain(), lld, false)).not.toBeNull(); + // A cross-target link: the arm64 target with the x64 host's helper. + expect( + LinkerJobs.open( + f.cache, + f.toolchain(LINUX_ARM64_GNU_TARGET, LINUX_X64_GNU_TARGET), + lld, + false, + ), + ).toBeNull(); + expect( + LinkerJobs.open( + f.cache, + f.toolchain(LINUX_X64_GNU_TARGET, LINUX_ARM64_GNU_TARGET), + lld, + false, + ), + ).toBeNull(); + expect(LinkerJobs.open(f.cache, f.toolchain(MACOS_ARM64_TARGET), lld, false)).toBeNull(); + // The driver's default linker, a sanitizer link, no cache. + expect(LinkerJobs.open(f.cache, f.toolchain(), [], false)).toBeNull(); + expect(LinkerJobs.open(f.cache, f.toolchain(), lld, true)).toBeNull(); + expect(LinkerJobs.open(null, f.toolchain(), lld, false)).toBeNull(); + // A linker that is not a clang driver. + const gcc = fixture("gcc"); + expect(LinkerJobs.open(gcc.cache, gcc.toolchain(), lld, false)).toBeNull(); + // An explicit linker, or an environment that names mutable toolchain inputs. + process.env["SCRIPTC_LINKER"] = f.driver; + expect(LinkerJobs.open(f.cache, f.toolchain(), lld, false)).toBeNull(); + delete process.env["SCRIPTC_LINKER"]; + process.env["COMPILER_PATH"] = f.bin; + expect(LinkerJobs.open(f.cache, f.toolchain(), lld, false)).toBeNull(); + }, +); + +const hostTarget = + process.arch === "x64" + ? LINUX_X64_GNU_TARGET + : process.arch === "arm64" + ? LINUX_ARM64_GNU_TARGET + : null; +const hasLld = + process.platform === "linux" && + spawnSync("clang", ["-fuse-ld=lld", "-###", "-x", "c", "/dev/null"], { + encoding: "utf8", + }).stderr?.includes("ld.lld") === true; + +test.skipIf(hostTarget === null || !hasLld)("the job links the executable the driver links", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-linker-job-clang-")); + directories.push(root); + const stage = join(root, "stage"); + mkdirSync(stage); + const program = join(stage, "program.o"); + const compiled = spawnSync("clang", ["-c", "-x", "c", "-", "-o", program], { + input: '#include \nint main(void) { puts("linked"); return 0; }\n', + }); + expect(compiled.status).toBe(0); + const toolchain = { + target: hostTarget, + helper: hostTarget!.helper, + linker: "clang", + } as unknown as NativeToolchain; + const args = (output: string) => ["-fuse-ld=lld", "-pthread", program, "-lm", "-o", output]; + const viaDriver = join(stage, "driver"); + expect(spawnSync("clang", args(viaDriver)).status).toBe(0); + const cache = new NativeCache(join(root, "cache")); + // Recorded from the -v output of a traced link, and listed with -###. + const traced = (output: string) => [...args(output), "-v", "-Wl,-t"]; + const tracedOutput = join(stage, "traced"); + const linked = spawnSync("clang", traced(tracedOutput), { encoding: "utf8" }); + expect(linked.status).toBe(0); + LinkerJobs.open(cache, toolchain, ["-fuse-ld=lld"], false)! + .link([program, tracedOutput], stage) + .record(traced(tracedOutput), linked.stdout + "\n" + linked.stderr); + for (const [name, list] of [ + ["first", false], + ["second", true], + ["third", true], + ] as const) { + const output = join(stage, name); + const link = LinkerJobs.open(cache, toolchain, ["-fuse-ld=lld"], false)!.link( + [program, output], + stage, + ); + const run = list ? link.run(args(output), true) : link.run(traced(output), false); + expect(run).not.toBeNull(); + expect(readFileSync(output).equals(readFileSync(viaDriver))).toBe(true); + expect(spawnSync(output, { encoding: "utf8" }).stdout).toBe("linked\n"); + } +}); diff --git a/packages/compiler/src/native/linker-job.ts b/packages/compiler/src/native/linker-job.ts new file mode 100644 index 000000000..e1195e4c4 --- /dev/null +++ b/packages/compiler/src/native/linker-job.ts @@ -0,0 +1,286 @@ +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { basename, extname, isAbsolute, resolve } from "node:path"; +import { + toolchainEnvironmentCachePolicy, + toolchainEnvironmentFingerprint, +} from "../backend/toolchain-environment.js"; +import type { NativeToolchain } from "./toolchain.js"; +import { contentDigest, type NativeCache } from "./cache.js"; +import { + commandPath, + identity, + type InputIdentity, + stillMatches, + tracePaths, + validIdentity, +} from "./executable-cache.js"; + +/** A recorded linker job. In `job`, a number is the index of one of the link's + * own paths (its inputs and output); `inputs` are the identities of the + * other files and directories the job and the driver's `-v` lines name. */ +interface LinkerJobEntry { + schema: "scriptc.linker-job.v1"; + /** null when the driver's job could not be recorded: the link runs through + * the driver. */ + job: (string | number)[] | null; + inputs: InputIdentity[]; +} + +/** A link that ran as its recorded job: the linker's output, and the + * identities the job depends on besides the link's own paths. */ +export interface LinkerJobRun { + output: string; + driverInputs: InputIdentity[]; +} + +/** One link's use of LinkerJobs, with its own paths and stage. */ +export interface LinkerJobLink { + run(args: string[], list: boolean): LinkerJobRun | null; + record(args: string[], output: string, driverInputs?: InputIdentity[]): void; +} + +const MAX_ENTRY_BYTES = 1024 * 1024; + +function validEntry(value: unknown, paths: number): value is LinkerJobEntry { + if (value === null || typeof value !== "object") return false; + const entry = value as Partial; + if (entry.schema !== "scriptc.linker-job.v1" || !Array.isArray(entry.inputs)) return false; + if (!entry.inputs.every(validIdentity)) return false; + if (entry.job === null) return true; + if (!Array.isArray(entry.job) || typeof entry.job[0] !== "string") return false; + const used = new Set(); + for (const token of entry.job) { + if (typeof token === "string") continue; + if (!Number.isInteger(token) || token < 0 || token >= paths || used.has(token)) return false; + used.add(token); + } + return used.size === paths; +} + +function underRoots(text: string, roots: string[]): boolean { + return roots.some((root) => text.includes(root)); +} + +/** The arguments of one job line as the driver prints them: with `-###` + * each argument is quoted, with `-v` only the program and the arguments that + * contain a space, `"`, `\` or `$`; a quoted argument escapes `"`, `\` and + * `$` with `\`. Null for any other line. */ +function jobArguments(line: string): string[] | null { + const pattern = /\s*(?:"((?:\\.|[^"\\])*)"|([^\s"\\]+))/y; + const args: string[] = []; + let end = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(line)) !== null) { + args.push(match[1] === undefined ? match[2]! : match[1].replace(/\\(.)/g, "$1")); + end = pattern.lastIndex; + } + return args.length === 0 || line.slice(end).trim() !== "" ? null : args; +} + +/** + * Linux links through a clang driver with lld. The driver only starts ld.lld + * with a job it derives from its arguments, its environment, its own + * installation and the system's (startup objects, the GCC installation, + * library directories). The job is recorded once per driver identity, + * arguments and environment, with the identities of the files and + * directories it and the driver's `-v` lines name. A later link whose recorded + * paths still have those identities runs ld.lld with the job directly; every + * other link runs through the driver. + */ +export class LinkerJobs { + /** A job run that failed in this build: later links run through the + * driver, which reports the error. */ + private failed = false; + + private constructor( + private readonly cache: NativeCache, + private readonly driver: string, + private readonly driverIdentity: InputIdentity, + ) {} + + /** Null unless the link is a host Linux link through a clang driver with + * `-fuse-ld=lld` (`lld`, the link's lld arguments), outside the sanitizer, + * with a native cache and a toolchain environment that allows complete + * artifacts. */ + static open( + cache: NativeCache | null, + toolchain: NativeToolchain, + lld: readonly string[], + sanitize: boolean, + ): LinkerJobs | null { + const target = toolchain.target; + if ( + cache === null || + sanitize || + target.platform !== "linux" || + // A cross-target link keeps the driver. + target.llvmTriple !== toolchain.helper.defaultTarget || + !lld.includes("-fuse-ld=lld") || + process.env["SCRIPTC_LINKER"] !== undefined || + !toolchainEnvironmentCachePolicy().completeArtifacts + ) + return null; + try { + const driver = commandPath(toolchain.linker); + if (!/^clang(-\d+)?$/.test(basename(realpathSync(driver)))) return null; + return new LinkerJobs(cache, driver, identity(driver)); + } catch { + return null; + } + } + + /** The link whose own inputs and output are `paths`, in `stage`. */ + link(paths: readonly string[], stage: string): LinkerJobLink { + return { + run: (args, list) => this.run(args, paths, stage, list), + record: (args, output, driverInputs) => this.record(args, paths, stage, output, driverInputs), + }; + } + + /** The entry key of the link `args` with its own `paths`, or null when + * the arguments name the stage besides those paths. */ + private key(args: readonly string[], paths: readonly string[], roots: string[]): string | null { + const index = new Map(paths.map((path, i) => [path, i])); + if (index.size !== paths.length || !paths.every((path) => args.includes(path))) return null; + if (args.some((arg) => !index.has(arg) && underRoots(arg, roots))) return null; + const shape = args.map((arg) => { + const at = index.get(arg); + return at === undefined ? arg : { path: at, extension: extname(arg) }; + }); + return contentDigest( + JSON.stringify({ + schema: 1, + driver: this.driverIdentity, + args: shape, + environment: toolchainEnvironmentFingerprint(), + cwd: process.cwd(), + path: process.env["PATH"] ?? null, + }), + ); + } + + /** + * Runs the link that the driver arguments `args` describe as its recorded + * job. `paths` are the link's own inputs and output among `args`, which may + * differ from build to build; `stage` is the directory they are in. Without + * an entry, `list` records the job first with the driver's `-v -###`; + * otherwise the caller links through the driver and passes its `-v` output + * to record(). Null when the link has to run through the driver: no job is + * recorded, a recorded path changed, or the job failed. + */ + run( + args: readonly string[], + paths: readonly string[], + stage: string, + list: boolean, + ): LinkerJobRun | null { + if (this.failed) return null; + try { + const roots = [resolve(stage), realpathSync(stage)]; + const key = this.key(args, paths, roots); + if (key === null) return null; + let entry = this.read(key, paths.length); + if (entry === null && list) { + const result = spawnSync(this.driver, [...args, "-v", "-###"], { + encoding: "utf8", + stdio: "pipe", + }); + if (result.error !== undefined || result.status !== 0 || result.signal !== null) + return null; + entry = this.store(key, result.stdout + "\n" + result.stderr, paths, roots); + } + if (entry === null || entry.job === null || !stillMatches(entry.inputs)) return null; + const job = entry.job.map((token) => (typeof token === "number" ? paths[token]! : token)); + const result = spawnSync(job[0]!, job.slice(1), { + encoding: "utf8", + stdio: "pipe", + }); + if (result.error !== undefined || result.status !== 0 || result.signal !== null) { + this.failed = true; + return null; + } + return { output: result.stdout + "\n" + result.stderr, driverInputs: entry.inputs }; + } catch { + return null; + } + } + + private read(key: string, paths: number): LinkerJobEntry | null { + const bytes = this.cache.read("linker-job", key, MAX_ENTRY_BYTES); + if (bytes === null) return null; + try { + const entry: unknown = JSON.parse(bytes.toString("utf8")); + return validEntry(entry, paths) ? entry : null; + } catch { + return null; + } + } + + /** Records the job of the link `args` from `output`, the `-v` output of + * the driver's successful link, unless an entry exists. `driverInputs` + * are the identities of the paths `output` names, when the caller has + * them. */ + record( + args: readonly string[], + paths: readonly string[], + stage: string, + output: string, + driverInputs?: InputIdentity[], + ): void { + try { + const roots = [resolve(stage), realpathSync(stage)]; + const key = this.key(args, paths, roots); + if (key !== null && this.read(key, paths.length) === null) + this.store(key, output, paths, roots, driverInputs); + } catch { + /* The next link records it. */ + } + } + + /** Stores the entry for `output`, the driver's `-v` lines and job. */ + private store( + key: string, + output: string, + paths: readonly string[], + roots: string[], + driverInputs?: InputIdentity[], + ): LinkerJobEntry { + const lines = output.split(/\r?\n/).filter((line) => line.startsWith(' "')); + const job = lines.length === 1 ? this.jobOf(jobArguments(lines[0]!), paths, roots) : null; + const entry: LinkerJobEntry = { + schema: "scriptc.linker-job.v1", + job, + inputs: job === null ? [] : (driverInputs ?? tracePaths(output, true, roots).map(identity)), + }; + this.cache.write("linker-job", key, JSON.stringify(entry)); + return entry; + } + + /** `job` with each of `paths` as its index: null unless the job runs + * ld.lld and names each of the paths once, as one whole argument, and names + * nothing else in the stage. */ + private jobOf( + job: string[] | null, + paths: readonly string[], + roots: string[], + ): (string | number)[] | null { + if (job === null || job.length === 0) return null; + if (!isAbsolute(job[0]!) || basename(job[0]!) !== "ld.lld") return null; + const index = new Map(paths.map((path, i) => [path, i])); + const used = new Set(); + const tokens: (string | number)[] = []; + for (const arg of job) { + const at = index.get(arg); + if (at === undefined) { + if (underRoots(arg, roots)) return null; + tokens.push(arg); + } else { + if (used.has(at)) return null; + used.add(at); + tokens.push(at); + } + } + return used.size === paths.length ? tokens : null; + } +} diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 11b8f9dd1..749027b80 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -431,6 +431,7 @@ export function prepareNativeExecutable( (outputKind === "exe" || outputKind === "llvm" || outputKind === "asm") && toolchain.target.platform === "linux", objectAudit: options.sanitize === true, + sanitizeAttributes: options.sanitize === true, lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true), functionWorkers: emitWorkers(), forkPrimitives: emitForkPrimitives(), @@ -545,6 +546,7 @@ export function prepareNativeLibrary( pointerBits: toolchain.target.pointerBits, wasi: toolchain.target.platform === "wasi", objectAudit: options.sanitize === true, + sanitizeAttributes: options.sanitize === true, ...inlineRcOption(profile.optimization, options.sanitize === true), }); timing("llvm-emit"); diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index d9de688d4..65ddf2a9e 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -83,6 +83,8 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( declAdopt: false, dynForAwait: false, eventsOnceSignal: false, + mime: false, + utilTiming: false, dynBuffer: false, builtinValues: false, warningReplace: false, diff --git a/packages/compiler/src/project-config.test.ts b/packages/compiler/src/project-config.test.ts index 04a5d2e0b..f12a8ea89 100644 --- a/packages/compiler/src/project-config.test.ts +++ b/packages/compiler/src/project-config.test.ts @@ -214,7 +214,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () = "A.c": "string\nnumber", "A.d": "| undefined", }, - files: {}, + patches: {}, }, "other@^1.0.0": [], }, @@ -225,7 +225,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () = `package.json 'scriptc.typePatches.ms@^banana' has the version range "^banana", which is not a semver range`, "package.json 'scriptc.typePatches.@types/node@*' patches @types/node, whose declarations ship with scriptc or TypeScript", "package.json 'scriptc.typePatches.typescript@5' patches typescript, whose declarations ship with scriptc or TypeScript", - "package.json 'scriptc.typePatches.lib@^1.0.0.files' is not a known type patch setting", + "package.json 'scriptc.typePatches.lib@^1.0.0.patches' is not a known type patch setting", "package.json 'scriptc.typePatches.lib@^1.0.0.declarations.1bad' is not a selector: it must start with a declared name, 'export=' or 'default'", "package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.b' must map to a type string, not 3", "package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.c' has a type that must be one line", @@ -241,3 +241,68 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () = }); expect(partial.typePatches[0]!.declarations.map((d) => d.selector)).toEqual(["A.d"]); }); + +test("typePatches: files and types name local declaration files", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-")); + try { + mkdirSync(join(root, "patches")); + writeFileSync(join(root, "patches", "entry.d.ts"), "export {};\n"); + const text = JSON.stringify({ + scriptc: { + typePatches: { + "lib@^1.0.0": { + files: { + "dist/entry.d.ts": "./patches/entry.d.ts", + "@types/lib/index.d.ts": "patches/entry.d.ts", + "dist/index.js": "./patches/entry.d.ts", + "../outside.d.ts": "./patches/entry.d.ts", + "dist/other.d.ts": "./patches/missing.d.ts", + }, + types: "./patches/entry.d.ts", + }, + "other@^1.0.0": { types: "./patches/entry.ts" }, + }, + }, + }); + const c = parseProjectConfig(join(root, "package.json"), text); + expect(c.diagnostics.map((d) => d.message)).toEqual([ + "package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/index.js' must name a declaration file inside the package", + "package.json 'scriptc.typePatches.lib@^1.0.0.files.../outside.d.ts' must name a declaration file inside the package", + `package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/other.d.ts' names "./patches/missing.d.ts", which does not exist`, + `package.json 'scriptc.typePatches.other@^1.0.0.types' names "./patches/entry.ts", which is not a declaration file`, + ]); + const entry = c.typePatches.find((e) => e.pkg === "lib")!; + expect([...entry.files]).toEqual([ + ["dist/entry.d.ts", join(root, "patches/entry.d.ts")], + ["@types/lib/index.d.ts", join(root, "patches/entry.d.ts")], + ]); + expect(entry.types).toBe(join(root, "patches/entry.d.ts")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("readProjectConfig checks local type patch files again for an unchanged package.json", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-")); + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ scriptc: { typePatches: { "lib@^1.0.0": { types: "./lib.d.ts" } } } }), + ); + const entry = join(root, "main.ts"); + const missing = readProjectConfig(entry); + expect(missing.diagnostics.map((d) => d.message)).toEqual([ + `package.json 'scriptc.typePatches.lib@^1.0.0.types' names "./lib.d.ts", which does not exist`, + ]); + expect(readProjectConfig(entry)).toBe(missing); + writeFileSync(join(root, "lib.d.ts"), "export {};\n"); + const created = readProjectConfig(entry); + expect(created.diagnostics).toEqual([]); + expect(created.typePatches[0]!.types).toBe(join(root, "lib.d.ts")); + expect(readProjectConfig(entry)).toBe(created); + rmSync(join(root, "lib.d.ts")); + expect(readProjectConfig(entry).diagnostics.map((d) => d.code)).toEqual(["SC8004"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/src/project-config.ts b/packages/compiler/src/project-config.ts index c43a6bd2c..b87b67838 100644 --- a/packages/compiler/src/project-config.ts +++ b/packages/compiler/src/project-config.ts @@ -27,7 +27,7 @@ * to their own readers. */ import { dirname, join, resolve } from "node:path"; import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; -import { trackedReadFile } from "./frontend/input-tracker.js"; +import { trackedFileExists, trackedReadFile } from "./frontend/input-tracker.js"; import { parseSelector, parseTypeText } from "./frontend/type-patches/selector.js"; import { isValidRange } from "./frontend/type-patches/semver.js"; @@ -79,6 +79,13 @@ export interface TypePatchEntry { pkg: string; range: string; declarations: TypePatchDeclaration[]; + /** `files`: declaration files of the package (or of its `@types/` twin, + * written `@types//`) replaced by local files, by + * package-relative path. The local paths are absolute. */ + files: Map; + /** `types`: a local declaration file that becomes the package's + * declaration entry (absolute), or null. */ + types: string | null; /** Offset of the key in the manifest. */ offset: number; } @@ -94,6 +101,10 @@ export interface ProjectConfig { wasm: string[]; /** `scriptc.typePatches`, in manifest order. */ typePatches: TypePatchEntry[]; + /** The local files the configuration checked for existence, with the + * answer. A configuration read again from an unchanged package.json is + * reused only while every answer still holds. */ + localFiles: { path: string; exists: boolean }[]; /** SC8004 for every unknown key or value. */ diagnostics: ScrDiagnostic[]; } @@ -146,6 +157,7 @@ export function emptyProjectConfig(): ProjectConfig { nodeIslands: defaultNodeIslands(), wasm: [], typePatches: [], + localFiles: [], diagnostics: [], }; } @@ -341,7 +353,8 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi return config; } -const TYPE_PATCH_KEYS = ["declarations", "note"]; +const TYPE_PATCH_KEYS = ["declarations", "files", "types", "note"]; +const DECLARATION_FILE = /\.d\.[cm]?ts$/; /** Packages whose declarations ship with scriptc or TypeScript: a wrong * one is a bug to report, not a project-local patch. */ const UNPATCHABLE = /^(?:@types\/node|typescript|@typescript\/.*)$/; @@ -404,8 +417,66 @@ function readTypePatches( pkg, range, declarations: [], + files: new Map(), + types: null, offset: locate.offsetOf(["scriptc", "typePatches", key]), }; + const base = dirname(config.manifest!); + const localFile = (path: string[], v: unknown): string | null => { + if (typeof v !== "string" || v === "") { + problem( + path, + `must be the path of a local declaration file, not ${shown(v)}`, + `write "./type-patches/${pkg}.d.ts"`, + ); + return null; + } + const abs = resolve(base, v); + if (!DECLARATION_FILE.test(abs)) { + problem( + path, + `names "${v}", which is not a declaration file`, + "use a .d.ts, .d.mts or .d.cts file", + ); + return null; + } + const exists = trackedFileExists(abs); + config.localFiles.push({ path: abs, exists }); + if (!exists) { + problem( + path, + `names "${v}", which does not exist`, + "give the path relative to this package.json", + ); + return null; + } + return abs; + }; + const files = value["files"]; + if (files !== undefined && !isRecord(files)) { + problem( + ["typePatches", key, "files"], + `must be an object mapping declaration files of the package to local files, not ${shown(files)}`, + `write "files": { "dist/index.d.ts": "./type-patches/${pkg}.d.ts" }`, + ); + } else if (files !== undefined) { + for (const target of Object.keys(files)) { + const path = ["typePatches", key, "files", target]; + const rel = target.replace(/^\.\//, ""); + if (!DECLARATION_FILE.test(rel) || rel.startsWith("/") || rel.split("/").includes("..")) { + problem( + path, + "must name a declaration file inside the package", + `write a package-relative .d.ts path such as "dist/index.d.ts", or "@types//index.d.ts" for its @types package`, + ); + continue; + } + const abs = localFile(path, files[target]); + if (abs !== null) entry.files.set(rel, abs); + } + } + if (value["types"] !== undefined) + entry.types = localFile(["typePatches", key, "types"], value["types"]); let valid = true; for (const k of Object.keys(value)) { if (!TYPE_PATCH_KEYS.includes(k)) { @@ -482,7 +553,12 @@ export function readProjectConfig(entryPath: string): ProjectConfig { const manifest = join(dir, "package.json"); const text = trackedReadFile(manifest); if (text !== null) { - if (cachedConfig !== null && cachedManifest === manifest && cachedText === text) + if ( + cachedConfig !== null && + cachedManifest === manifest && + cachedText === text && + cachedConfig.localFiles.every((file) => trackedFileExists(file.path) === file.exists) + ) return cachedConfig; // A manifest without a `scriptc` key (none spelled out, none behind a // \u escape) has the empty configuration; builds of such projects diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 5f5d8f7e0..2237619ef 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -2482,12 +2482,36 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.util._extend", + "kind": "node-builtin", + "name": "util._extend", + "status": "static" + }, + { + "id": "node-builtin.util.aborted", + "kind": "node-builtin", + "name": "util.aborted", + "status": "static" + }, { "id": "node-builtin.util.callbackify", "kind": "node-builtin", "name": "util.callbackify", "status": "static" }, + { + "id": "node-builtin.util.convertProcessSignalToExitCode", + "kind": "node-builtin", + "name": "util.convertProcessSignalToExitCode", + "status": "static" + }, + { + "id": "node-builtin.util.debounce", + "kind": "node-builtin", + "name": "util.debounce", + "status": "static" + }, { "id": "node-builtin.util.debug", "kind": "node-builtin", @@ -2506,6 +2530,12 @@ "name": "util.deprecate", "status": "static" }, + { + "id": "node-builtin.util.diff", + "kind": "node-builtin", + "name": "util.diff", + "status": "static" + }, { "id": "node-builtin.util.getSystemErrorMap", "kind": "node-builtin", @@ -2530,6 +2560,12 @@ "name": "util.inherits", "status": "static" }, + { + "id": "node-builtin.util.isArray", + "kind": "node-builtin", + "name": "util.isArray", + "status": "static" + }, { "id": "node-builtin.util.isDeepStrictEqual", "kind": "node-builtin", @@ -2568,6 +2604,12 @@ "name": "util.styleText", "status": "static" }, + { + "id": "node-builtin.util.throttle", + "kind": "node-builtin", + "name": "util.throttle", + "status": "static" + }, { "id": "node-builtin.util.toUSVString", "kind": "node-builtin", diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index f617c6ab4..d7ebc97fa 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -136,3 +136,111 @@ test("worker programs poll counted loops over typed arrays between blocks of ite await rm(dir, { recursive: true, force: true }); } }); + +test("worker programs poll no loop whose proven ranges bound its runs inside another loop", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-bounded-loop-polls-")); + try { + const entry = join(dir, "bounded.ts"); + await writeFile( + entry, + [ + 'import { isMainThread, Worker } from "node:worker_threads";', + "const LIMIT = 50;", + "export function rows(cr: Float64Array, n: number): number {", + " let t = 0;", + " for (let r = 0; r < n; r++)", + " for (let i = 0; i < 8; i += 2) {", + " const c = cr[i]!;", + " for (let j = 0; j < LIMIT; j++) {", + " t = (t * 31 + j + c) % 1000003;", + " if (t === 7) break;", + " }", + " }", + " return t;", + "}", + "export function open(cr: Float64Array, n: number, k: number): number {", + " let t = 0;", + " for (let r = 0; r < n; r++)", + " for (let i = 0; i < 8; i += 2) {", + " const c = cr[i]!;", + " for (let j = 0; j < k; j++) {", + " t = (t * 31 + j + c) % 1000003;", + " if (t === 7) break;", + " }", + " }", + " return t;", + "}", + "export function wide(n: number): number {", + " let t = 0;", + " for (let r = 0; r < n; r++) for (let j = 0; j < 5000; j++) t = (t * 31 + j) % 1000003;", + " return t;", + "}", + "export function exact(n: number): number {", + " let t = 0;", + " for (let r = 0; r < n; r++)", + " for (let j = 9007199254740900; j < 9007199254740950; j++) t = (t + (j % 7)) % 1000003;", + " return t;", + "}", + "export function stalled(n: number): number {", + " let t = 0;", + " for (let r = 0; r < n; r++)", + " for (let j = 9007199254740990; j < 9007199254741002; j++) t = (t + (j % 7)) % 1000003;", + " return t;", + "}", + "export function single(): number {", + " let t = 0;", + " for (let j = 0; j < 50; j++) t = (t * 31 + j) % 1000003;", + " return t;", + "}", + "const cr = new Float64Array(8).fill(0.5);", + 'const k = Number(process.argv[2] ?? "50");', + "console.log(rows(cr, 3), open(cr, 3, k), wide(2), exact(2), stalled(0), single());", + "if (!isMainThread) new Worker(new URL(import.meta.url));", + ].join("\n"), + ); + const outPath = join(dir, "bounded.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const body = (name: string): string => + [ + ...ll.matchAll( + new RegExp( + `define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`, + "g", + ), + ), + ] + .map((match) => match[1]!) + .join("\n"); + const polls = (text: string): number => text.match(/@scr_exc_flag/g)?.length ?? 0; + // The i and j loops of `rows` run at most 4 × (1 + 50) bodies: only the + // r loop polls, and the i loop over the typed array is not strip-mined. + const rows = body("rows"); + expect(rows).not.toMatch(/^loop\.poll\d+:$/m); + expect(polls(rows)).toBeGreaterThan(0); + // With a limit known only at run time the inner loops keep their polls. + // The innermost one is strip-mined, and a block that ends at the limit + // leaves it without polling: only a block that ends short of it polls. + const open = body("open"); + expect(polls(open)).toBeGreaterThan(polls(rows)); + expect(open).toMatch(/^loop\.poll\d+:$/m); + const ends = [...open.matchAll(/^loop\.end\d+:$[\s\S]*?^\S/gm)].map((match) => match[0]); + expect(ends.length).toBeGreaterThan(0); + for (const end of ends) expect(end).toMatch(/br i1 %\w+, label %loop\.poll\d+, label %/); + // A run of up to 16384 iterations is one block, polled as it leaves when + // it took more than 64. + expect(open).toMatch(/^loop\.poll\.run\d+:$/m); + // Steps of 1 below 2^53 are exact, so 50 runs near it are bounded. From + // 2^53, `j + 1 === j`: a counter ranging up to it can run without end + // and keeps its poll. + expect(polls(body("exact"))).toBe(polls(body("single"))); + expect(polls(body("stalled"))).toBeGreaterThan(polls(body("exact"))); + // A nest over the bound, or a loop no other loop encloses, polls. + expect(polls(body("wide"))).toBeGreaterThan(0); + expect(body("wide")).toMatch(/^loop\.poll\d+:$/m); + expect(polls(body("single"))).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/generator-emission.test.ts b/packages/compiler/test/generator-emission.test.ts index 8c43925ea..47e5f16c5 100644 --- a/packages/compiler/test/generator-emission.test.ts +++ b/packages/compiler/test/generator-emission.test.ts @@ -50,7 +50,7 @@ console.log(sum); expect(definition(llvm, "sc_f_range")).not.toContain("@scr_coro_yield_f64("); expect(llvm).not.toContain("@scr_gen_take_out_f64("); expect(llvm).toContain("%ScrGenHead = type { i64, ptr, ptr, i32, %ScrGenSlot }"); - expect(llvm).toContain("attributes #1 = { sanitize_address presplitcoroutine }"); + expect(llvm).toContain("attributes #1 = { presplitcoroutine }"); expect(definition(llvm, "sc_gs_range")).toContain("call ptr @scr_gen_new_stackless("); expect(llvm).not.toContain("@scr_gen_yield_f64("); }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7520-util-format-runtime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7520-util-format-runtime.ts.json new file mode 100644 index 000000000..825fd9882 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7520-util-format-runtime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7520-util-format-runtime.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7521-util-format-runtime.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7521-util-format-runtime.cjs.json new file mode 100644 index 000000000..0714d5c08 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7521-util-format-runtime.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7521-util-format-runtime.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7522-promisify-untyped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7522-promisify-untyped.cjs.json new file mode 100644 index 000000000..bcd6b54c6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7522-promisify-untyped.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7522-promisify-untyped.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7523-promisify-untyped.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7523-promisify-untyped.ts.json new file mode 100644 index 000000000..20e96495a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7523-promisify-untyped.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7523-promisify-untyped.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7524-inspect-custom.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7524-inspect-custom.ts.json new file mode 100644 index 000000000..4f5a3d007 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7524-inspect-custom.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7524-inspect-custom.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7525-inspect-custom.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7525-inspect-custom.cjs.json new file mode 100644 index 000000000..dddad2358 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7525-inspect-custom.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7525-inspect-custom.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7526-inspect-options-runtime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7526-inspect-options-runtime.ts.json new file mode 100644 index 000000000..70b202903 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7526-inspect-options-runtime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7526-inspect-options-runtime.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7536-inspect-custom-errors.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7536-inspect-custom-errors.ts.json new file mode 100644 index 000000000..2c5325056 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7536-inspect-custom-errors.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7536-inspect-custom-errors.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7537-inspect-custom-errors.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7537-inspect-custom-errors.cjs.json new file mode 100644 index 000000000..16cb948f6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7537-inspect-custom-errors.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7537-inspect-custom-errors.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7538-inspect-custom-prototypes.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7538-inspect-custom-prototypes.mjs.json new file mode 100644 index 000000000..279015554 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7538-inspect-custom-prototypes.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7538-inspect-custom-prototypes.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7700-util-deprecated-helpers.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7700-util-deprecated-helpers.cjs.json new file mode 100644 index 000000000..3ea85d24f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7700-util-deprecated-helpers.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7700-util-deprecated-helpers.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7701-util-deprecated-helpers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7701-util-deprecated-helpers.ts.json new file mode 100644 index 000000000..10519ed2f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7701-util-deprecated-helpers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7701-util-deprecated-helpers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7702-util-signal-exit-code.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7702-util-signal-exit-code.cjs.json new file mode 100644 index 000000000..fe1de1b5f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7702-util-signal-exit-code.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7702-util-signal-exit-code.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7703-util-aborted.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7703-util-aborted.cjs.json new file mode 100644 index 000000000..81b57b493 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7703-util-aborted.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7703-util-aborted.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7704-util-aborted.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7704-util-aborted.ts.json new file mode 100644 index 000000000..d59d1194f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7704-util-aborted.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7704-util-aborted.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7705-util-diff.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7705-util-diff.cjs.json new file mode 100644 index 000000000..630cca625 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7705-util-diff.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7705-util-diff.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7706-util-diff.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7706-util-diff.ts.json new file mode 100644 index 000000000..af97c99f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7706-util-diff.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7706-util-diff.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7707-util-mime-type.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7707-util-mime-type.cjs.json new file mode 100644 index 000000000..4c1430134 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7707-util-mime-type.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7707-util-mime-type.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7708-util-mime-type.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7708-util-mime-type.ts.json new file mode 100644 index 000000000..2c099bc66 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7708-util-mime-type.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7708-util-mime-type.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7709-function-own-properties.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7709-function-own-properties.cjs.json new file mode 100644 index 000000000..121ca711c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7709-function-own-properties.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7709-function-own-properties.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7710-util-debounce.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7710-util-debounce.cjs.json new file mode 100644 index 000000000..ed7e96287 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7710-util-debounce.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7710-util-debounce.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7711-util-throttle.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7711-util-throttle.cjs.json new file mode 100644 index 000000000..0e5bba052 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7711-util-throttle.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7711-util-throttle.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7712-util-debounce-throttle.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7712-util-debounce-throttle.ts.json new file mode 100644 index 000000000..b9ca2c2c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7712-util-debounce-throttle.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7712-util-debounce-throttle.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json new file mode 100644 index 000000000..6a55f1f2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7990-inferred-return-overlapping-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8005-buffer-encoding-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8005-buffer-encoding-paths.ts.json new file mode 100644 index 000000000..7d6dac570 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8005-buffer-encoding-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8005-buffer-encoding-paths.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts.json new file mode 100644 index 000000000..872a85396 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8120-inspect-custom-nested.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8120-inspect-custom-nested.ts.json new file mode 100644 index 000000000..49e0de238 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8120-inspect-custom-nested.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8120-inspect-custom-nested.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8121-inspect-custom-nested.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8121-inspect-custom-nested.cjs.json new file mode 100644 index 000000000..27372abb0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8121-inspect-custom-nested.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8121-inspect-custom-nested.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8122-inspect-custom-runtime-only.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8122-inspect-custom-runtime-only.ts.json new file mode 100644 index 000000000..0f25586d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8122-inspect-custom-runtime-only.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8122-inspect-custom-runtime-only.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8125-inspect-custom-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8125-inspect-custom-throws.ts.json new file mode 100644 index 000000000..1dc598fd4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8125-inspect-custom-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8125-inspect-custom-throws.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8126-inspect-custom-throws.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8126-inspect-custom-throws.cjs.json new file mode 100644 index 000000000..374de8db3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8126-inspect-custom-throws.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8126-inspect-custom-throws.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8331-map-number-hashed-integer-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8331-map-number-hashed-integer-keys.ts.json new file mode 100644 index 000000000..86822b641 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8331-map-number-hashed-integer-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8331-map-number-hashed-integer-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8355-row-pair-columns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8355-row-pair-columns.ts.json new file mode 100644 index 000000000..900de6fb0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8355-row-pair-columns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8355-row-pair-columns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8356-shared-view-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8356-shared-view-loop-versions.ts.json new file mode 100644 index 000000000..76cd99065 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8356-shared-view-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8356-shared-view-loop-versions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8360-record-prototype-method-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8360-record-prototype-method-calls.ts.json new file mode 100644 index 000000000..2bbaaaa88 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8360-record-prototype-method-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8360-record-prototype-method-calls.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8361-record-function-table-inherited.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8361-record-function-table-inherited.ts.json new file mode 100644 index 000000000..a9d8bf4b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8361-record-function-table-inherited.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8361-record-function-table-inherited.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8362-record-inherited-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8362-record-inherited-order.ts.json new file mode 100644 index 000000000..e080ae4d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8362-record-inherited-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8362-record-inherited-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json new file mode 100644 index 000000000..80675e5e6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dyn-union-function-arms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-shorthand-reads.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-shorthand-reads.js.json new file mode 100644 index 000000000..26d07a9f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-shorthand-reads.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/evolving-let-shorthand-reads.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json new file mode 100644 index 000000000..a229ee862 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-alias-cast-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-alias-cast-instances.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-lambda-impls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-lambda-impls.ts.json new file mode 100644 index 000000000..bc87c6ea0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-lambda-impls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-member-lambda-impls.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-terminate-bounded-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-terminate-bounded-loops.ts.json new file mode 100644 index 000000000..2e52a519c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-terminate-bounded-loops.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/native-worker-terminate-bounded-loops.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json new file mode 100644 index 000000000..6c3590ab8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/recursive-generic-method-members.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-optional-callback-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-optional-callback-params.ts.json new file mode 100644 index 000000000..abf783f85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-optional-callback-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/recursive-optional-callback-params.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json new file mode 100644 index 000000000..91c268992 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-promise-function-arm-widening.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-record-lossless-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-record-lossless-arm.ts.json new file mode 100644 index 000000000..143cda7d0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-record-lossless-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-record-lossless-arm.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/inspect-custom-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/inspect-custom-fences.ts.json new file mode 100644 index 000000000..dff7b7d6b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/inspect-custom-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/inspect-custom-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-files/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-files/main.ts.json new file mode 100644 index 000000000..4a15a1975 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-files/main.ts.json @@ -0,0 +1,25 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/type-patch-files/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Property 'toUpperCase' does not exist on type 'number'.", + "loc": { + "file": "/tests/fixtures/npm/cases/type-patch-files/main.ts", + "start": 346, + "end": 357 + } + }, + { + "code": "SC0001", + "message": "This comparison appears to be unintentional because the types 'number' and 'string' have no overlap.", + "loc": { + "file": "/tests/fixtures/npm/cases/type-patch-files/main.ts", + "start": 429, + "end": 447 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-types-entry/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-types-entry/main.ts.json new file mode 100644 index 000000000..bc1622724 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/type-patch-types-entry/main.ts.json @@ -0,0 +1,16 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/type-patch-types-entry/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Could not find a declaration file for module 'pointjs'. '/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/pointjs` if it exists or add a new declaration (.d.ts) file containing `declare module 'pointjs';`", + "loc": { + "file": "/tests/fixtures/npm/cases/type-patch-types-entry/main.ts", + "start": 246, + "end": 255 + } + } + ] +} diff --git a/packages/compiler/test/type-patches.test.ts b/packages/compiler/test/type-patches.test.ts index b4c0af9bc..5b586fb01 100644 --- a/packages/compiler/test/type-patches.test.ts +++ b/packages/compiler/test/type-patches.test.ts @@ -12,7 +12,9 @@ import { typePatchDiagnostics, typePatchedSpans, typePatchedText, + typePatchFileExists, typePatchMatches, + typePatchSuppliesTypes, } from "../src/frontend/type-patches/layer.js"; const fixtures = join(import.meta.dirname, "../../../tests/fixtures"); @@ -217,3 +219,82 @@ test("an export before its hoisted function is patched, and string text that loo rmSync(root, { recursive: true, force: true }); } }); + +test("files replaces a declaration file and declarations still apply on top", () => { + const root = join(fixtures, "npm/cases/type-patch-files"); + configureTypePatches(join(root, "main.ts"), parse); + const entry = typePatchedText(join(root, "node_modules/eventlog/dist/entry.d.ts")); + expect(entry).toBe(readFileSync(join(root, "type-patches/entry.d.ts"), "utf8")); + expect(typePatchedText(join(root, "node_modules/eventlog/dist/index.d.ts"))).toContain( + "{ count: number; last: string | undefined }", + ); + expect(typePatchDiagnostics()!.diagnostics).toEqual([]); +}); + +test("types gives a package without declarations an entry beside its main", () => { + const root = join(fixtures, "npm/cases/type-patch-types-entry"); + configureTypePatches(join(root, "main.ts"), parse); + const pkg = join(root, "node_modules/pointjs"); + expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(true); + expect(typePatchFileExists(join(pkg, "index.d.ts"))).toBe(false); + expect(typePatchFileExists(join(pkg, "lib/other.d.ts"))).toBe(false); + expect(typePatchSuppliesTypes(pkg)).toBe(true); + expect(typePatchedText(join(pkg, "lib/point.d.ts"))).toBe( + readFileSync(join(root, "type-patches/pointjs.d.ts"), "utf8"), + ); + // Without patches nothing exists that is not on disk. + configureTypePatches(join(tmpdir(), "none", "main.ts"), parse); + expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(false); + expect(typePatchSuppliesTypes(pkg)).toBe(false); +}); + +test("types gives a package whose main is a .jsx file an entry beside it", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-type-patches-")); + try { + const pkg = join(root, "node_modules/viewjs"); + mkdirSync(join(pkg, "lib"), { recursive: true }); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ name: "viewjs", version: "1.0.0", main: "lib/view.jsx" }), + ); + writeFileSync(join(pkg, "lib/view.jsx"), "export const view = 1;\n"); + writeFileSync(join(root, "viewjs.d.ts"), "export declare const view: number;\n"); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ scriptc: { typePatches: { "viewjs@^1.0.0": { types: "./viewjs.d.ts" } } } }), + ); + configureTypePatches(join(root, "main.ts"), parse); + expect(typePatchFileExists(join(pkg, "lib/view.d.ts"))).toBe(true); + expect(typePatchSuppliesTypes(pkg)).toBe(true); + expect(typePatchedText(join(pkg, "lib/view.d.ts"))).toBe( + "export declare const view: number;\n", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a files target the installed package does not ship warns", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-type-patches-")); + try { + const twin = join(fixtures, "npm/cases/type-patch-files"); + writeFileSync(join(root, "local.d.ts"), "export type Entry = {};\n"); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + scriptc: { + typePatches: { "eventlog@^2.0.0": { files: { "dist/gone.d.ts": "./local.d.ts" } } }, + }, + }), + ); + configureTypePatches(join(root, "main.ts"), parse); + expect(typePatchedText(join(twin, "node_modules/eventlog/dist/index.d.ts"))).toBeUndefined(); + const diags = typePatchDiagnostics()!.diagnostics; + expect(diags.map((d) => d.code)).toEqual(["SC8031"]); + expect(diags[0]!.message).toMatch( + /^type patch 'eventlog@\^2\.0\.0': dist\/gone\.d\.ts is not a file of eventlog at .*type-patch-files\/node_modules\/eventlog$/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/widened-returns.test.ts b/packages/compiler/test/widened-returns.test.ts new file mode 100644 index 000000000..fe78c6b75 --- /dev/null +++ b/packages/compiler/test/widened-returns.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lowerModule(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-widened-returns-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Functions that each forward to the previous one along two return + * branches, above one whose inferred type the checker reduced. */ +function forwardingChain(depth: number, recursive = false): string { + const lines = [ + "export {};", + "type Short = { status: string };", + "type Long = { status: string; value: number };", + "function f0(a: Short, b: Long, n: number) {", + " if (n > 5) return b;", + " return a;", + "}", + ]; + for (let i = 1; i <= depth; i++) + lines.push( + `function f${i}(a: Short, b: Long, n: number) {`, + ...(recursive ? [` if (n > 20) return f${i}(a, b, n - 1);`] : []), + ` if (n > 1) return f${i - 1}(a, b, n - 1);`, + ` if (n > 0) return f${i - 1}(a, b, n + 1);`, + " return a;", + "}", + ); + lines.push( + `console.log(JSON.stringify(f${depth}({ status: "a" }, { status: "b", value: 1 }, 9)));`, + ); + return lines.join("\n"); +} + +test("return widening reads each forwarded callee's type once", { timeout: 60_000 }, async () => { + // Rescanning the callee along every return branch takes 2^depth scans. + const mod = await lowerModule(forwardingChain(40)); + const top = mod.functions.find((f) => f.name === "f40"); + expect(top).toBeDefined(); + expect(top!.returnType.kind).toBe("union"); +}); + +test( + "return widening reads each forwarded callee's type once when the callees call themselves", + { timeout: 60_000 }, + async () => { + const mod = await lowerModule(forwardingChain(40, true)); + const top = mod.functions.find((f) => f.name === "f40"); + expect(top).toBeDefined(); + expect(top!.returnType.kind).toBe("union"); + }, +); + +test("a nested function's widened return follows each instance's type arguments", async () => { + const mod = await lowerModule( + [ + "export {};", + "function outer(x: T, n: number) {", + " const inner = () => {", + " if (n > 0) return { status: x, value: n };", + " return { status: x };", + " };", + " return inner();", + "}", + "console.log(JSON.stringify(outer(1, 1)));", + 'console.log(JSON.stringify(outer("s", 1)));', + "console.log(JSON.stringify(outer(true, 0)));", + ].join("\n"), + ); + const shapes = new Map((mod.records ?? []).map((r) => [r.id, r])); + const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); + // Each closure returns records whose `status` has the type its captured + // `x` has. + const closures = mod.functions.filter( + (f) => f.name.startsWith("%fn.") && !f.name.includes("adapt"), + ); + expect(closures.length).toBe(3); + for (const closure of closures) { + const captured = closure.captures?.find((c) => c.type.kind !== "f64")?.type.kind ?? "f64"; + const ret = closure.returnType; + expect(ret.kind).toBe("union"); + const arms = ret.kind === "union" ? (unions.get(ret.unionId)?.arms ?? []) : []; + expect(arms.length).toBe(2); + for (const arm of arms) { + expect(arm.kind).toBe("record"); + const status = + arm.kind === "record" + ? shapes.get(arm.shapeId)?.fields.find((f) => f.name === "status") + : undefined; + expect(status?.type.kind).toBe(captured); + } + } +}); diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index eea84ab90..3ece8aff1 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -93,6 +93,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_events_emitter.c", "emitter"], // events.once with options registers through the emitter and event-target units. ["scr_events_once_signal.c", "eventsOnceSignal"], + ["scr_mime.c", "mime"], + ["scr_util_timing.c", "utilTiming"], ["scr_dyn_handle.c", any("emitter", "netEffective")], ["scr_symbol.c", "symbol"], ["scr_bigint_assert.c", all("assert", "bigint")], diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 5fb8a58d4..6399683fc 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1586,6 +1586,7 @@ static ScrStr *scr_bytes_encode_b64(const uint8_t *in, size_t n, bool url) { if (!url) out[o++] = '='; } out[outlen] = '\0'; + scr_str_note_ascii(s); return s; } @@ -2546,6 +2547,106 @@ static ScrStr *scr_bytes_decode_utf16le(const uint8_t *in, size_t n) { return s; } +/* The UTF-8 bytes of U+0000..U+00FF: the byte itself below 0x80 (the + * second entry is overwritten by the next write), else a two-byte + * sequence. */ +static const uint8_t scr_latin1_utf8[256][2] = { + {0x00, 0x00}, {0x01, 0x00}, {0x02, 0x00}, {0x03, 0x00}, {0x04, 0x00}, {0x05, 0x00}, {0x06, 0x00}, {0x07, 0x00}, + {0x08, 0x00}, {0x09, 0x00}, {0x0a, 0x00}, {0x0b, 0x00}, {0x0c, 0x00}, {0x0d, 0x00}, {0x0e, 0x00}, {0x0f, 0x00}, + {0x10, 0x00}, {0x11, 0x00}, {0x12, 0x00}, {0x13, 0x00}, {0x14, 0x00}, {0x15, 0x00}, {0x16, 0x00}, {0x17, 0x00}, + {0x18, 0x00}, {0x19, 0x00}, {0x1a, 0x00}, {0x1b, 0x00}, {0x1c, 0x00}, {0x1d, 0x00}, {0x1e, 0x00}, {0x1f, 0x00}, + {0x20, 0x00}, {0x21, 0x00}, {0x22, 0x00}, {0x23, 0x00}, {0x24, 0x00}, {0x25, 0x00}, {0x26, 0x00}, {0x27, 0x00}, + {0x28, 0x00}, {0x29, 0x00}, {0x2a, 0x00}, {0x2b, 0x00}, {0x2c, 0x00}, {0x2d, 0x00}, {0x2e, 0x00}, {0x2f, 0x00}, + {0x30, 0x00}, {0x31, 0x00}, {0x32, 0x00}, {0x33, 0x00}, {0x34, 0x00}, {0x35, 0x00}, {0x36, 0x00}, {0x37, 0x00}, + {0x38, 0x00}, {0x39, 0x00}, {0x3a, 0x00}, {0x3b, 0x00}, {0x3c, 0x00}, {0x3d, 0x00}, {0x3e, 0x00}, {0x3f, 0x00}, + {0x40, 0x00}, {0x41, 0x00}, {0x42, 0x00}, {0x43, 0x00}, {0x44, 0x00}, {0x45, 0x00}, {0x46, 0x00}, {0x47, 0x00}, + {0x48, 0x00}, {0x49, 0x00}, {0x4a, 0x00}, {0x4b, 0x00}, {0x4c, 0x00}, {0x4d, 0x00}, {0x4e, 0x00}, {0x4f, 0x00}, + {0x50, 0x00}, {0x51, 0x00}, {0x52, 0x00}, {0x53, 0x00}, {0x54, 0x00}, {0x55, 0x00}, {0x56, 0x00}, {0x57, 0x00}, + {0x58, 0x00}, {0x59, 0x00}, {0x5a, 0x00}, {0x5b, 0x00}, {0x5c, 0x00}, {0x5d, 0x00}, {0x5e, 0x00}, {0x5f, 0x00}, + {0x60, 0x00}, {0x61, 0x00}, {0x62, 0x00}, {0x63, 0x00}, {0x64, 0x00}, {0x65, 0x00}, {0x66, 0x00}, {0x67, 0x00}, + {0x68, 0x00}, {0x69, 0x00}, {0x6a, 0x00}, {0x6b, 0x00}, {0x6c, 0x00}, {0x6d, 0x00}, {0x6e, 0x00}, {0x6f, 0x00}, + {0x70, 0x00}, {0x71, 0x00}, {0x72, 0x00}, {0x73, 0x00}, {0x74, 0x00}, {0x75, 0x00}, {0x76, 0x00}, {0x77, 0x00}, + {0x78, 0x00}, {0x79, 0x00}, {0x7a, 0x00}, {0x7b, 0x00}, {0x7c, 0x00}, {0x7d, 0x00}, {0x7e, 0x00}, {0x7f, 0x00}, + {0xc2, 0x80}, {0xc2, 0x81}, {0xc2, 0x82}, {0xc2, 0x83}, {0xc2, 0x84}, {0xc2, 0x85}, {0xc2, 0x86}, {0xc2, 0x87}, + {0xc2, 0x88}, {0xc2, 0x89}, {0xc2, 0x8a}, {0xc2, 0x8b}, {0xc2, 0x8c}, {0xc2, 0x8d}, {0xc2, 0x8e}, {0xc2, 0x8f}, + {0xc2, 0x90}, {0xc2, 0x91}, {0xc2, 0x92}, {0xc2, 0x93}, {0xc2, 0x94}, {0xc2, 0x95}, {0xc2, 0x96}, {0xc2, 0x97}, + {0xc2, 0x98}, {0xc2, 0x99}, {0xc2, 0x9a}, {0xc2, 0x9b}, {0xc2, 0x9c}, {0xc2, 0x9d}, {0xc2, 0x9e}, {0xc2, 0x9f}, + {0xc2, 0xa0}, {0xc2, 0xa1}, {0xc2, 0xa2}, {0xc2, 0xa3}, {0xc2, 0xa4}, {0xc2, 0xa5}, {0xc2, 0xa6}, {0xc2, 0xa7}, + {0xc2, 0xa8}, {0xc2, 0xa9}, {0xc2, 0xaa}, {0xc2, 0xab}, {0xc2, 0xac}, {0xc2, 0xad}, {0xc2, 0xae}, {0xc2, 0xaf}, + {0xc2, 0xb0}, {0xc2, 0xb1}, {0xc2, 0xb2}, {0xc2, 0xb3}, {0xc2, 0xb4}, {0xc2, 0xb5}, {0xc2, 0xb6}, {0xc2, 0xb7}, + {0xc2, 0xb8}, {0xc2, 0xb9}, {0xc2, 0xba}, {0xc2, 0xbb}, {0xc2, 0xbc}, {0xc2, 0xbd}, {0xc2, 0xbe}, {0xc2, 0xbf}, + {0xc3, 0x80}, {0xc3, 0x81}, {0xc3, 0x82}, {0xc3, 0x83}, {0xc3, 0x84}, {0xc3, 0x85}, {0xc3, 0x86}, {0xc3, 0x87}, + {0xc3, 0x88}, {0xc3, 0x89}, {0xc3, 0x8a}, {0xc3, 0x8b}, {0xc3, 0x8c}, {0xc3, 0x8d}, {0xc3, 0x8e}, {0xc3, 0x8f}, + {0xc3, 0x90}, {0xc3, 0x91}, {0xc3, 0x92}, {0xc3, 0x93}, {0xc3, 0x94}, {0xc3, 0x95}, {0xc3, 0x96}, {0xc3, 0x97}, + {0xc3, 0x98}, {0xc3, 0x99}, {0xc3, 0x9a}, {0xc3, 0x9b}, {0xc3, 0x9c}, {0xc3, 0x9d}, {0xc3, 0x9e}, {0xc3, 0x9f}, + {0xc3, 0xa0}, {0xc3, 0xa1}, {0xc3, 0xa2}, {0xc3, 0xa3}, {0xc3, 0xa4}, {0xc3, 0xa5}, {0xc3, 0xa6}, {0xc3, 0xa7}, + {0xc3, 0xa8}, {0xc3, 0xa9}, {0xc3, 0xaa}, {0xc3, 0xab}, {0xc3, 0xac}, {0xc3, 0xad}, {0xc3, 0xae}, {0xc3, 0xaf}, + {0xc3, 0xb0}, {0xc3, 0xb1}, {0xc3, 0xb2}, {0xc3, 0xb3}, {0xc3, 0xb4}, {0xc3, 0xb5}, {0xc3, 0xb6}, {0xc3, 0xb7}, + {0xc3, 0xb8}, {0xc3, 0xb9}, {0xc3, 0xba}, {0xc3, 0xbb}, {0xc3, 0xbc}, {0xc3, 0xbd}, {0xc3, 0xbe}, {0xc3, 0xbf}, +}; + +/* Each byte is U+00XX ("binary" normalizes here upstream): a byte below + * 0x80 is one UTF-8 byte and any other byte is two. Eight bytes without a + * high bit copy as one word; other bytes take their two table bytes and + * advance by their length, without branches (the terminator slot holds + * the last spare byte). */ +/* The number of bytes from 0x80: eight per step, each step adding its + * high bits into byte lanes that are summed every 255 steps. */ +static size_t scr_bytes_count_high(const uint8_t *in, size_t n) { + const uint64_t ones = UINT64_C(0x0101010101010101); + const uint64_t pairs = UINT64_C(0x00ff00ff00ff00ff); + size_t high = 0, i = 0; + while (n - i >= 8) { + size_t steps = (n - i) / 8 < 255 ? (n - i) / 8 : 255; + uint64_t lanes = 0; + for (size_t end = i + steps * 8; i < end; i += 8) { + uint64_t w; + memcpy(&w, in + i, 8); + lanes += (w >> 7) & ones; + } + lanes = (lanes & pairs) + ((lanes >> 8) & pairs); + high += (size_t)((lanes * UINT64_C(0x0001000100010001)) >> 48); + } + for (; i < n; i++) high += in[i] >> 7; + return high; +} + +static ScrStr *scr_bytes_latin1_text(const uint8_t *in, size_t n) { + size_t high = scr_bytes_count_high(in, n); + if (n > SIZE_MAX - high) scr_bytes_oom(); + ScrStr *s = scr_str_alloc_raw(n + high, n + high); + uint8_t *out = (uint8_t *)s->data; + if (high == 0) { + memcpy(out, in, n); + out[n] = '\0'; + scr_str_note_ascii(s); + return s; + } + const uint64_t top = UINT64_C(0x8080808080808080); + size_t i = 0, o = 0; + while (n - i >= 8) { + uint64_t w; + memcpy(&w, in + i, 8); + if (!(w & top)) { + memcpy(out + o, &w, 8); + i += 8; + o += 8; + continue; + } + for (size_t end = i + 8; i < end; i++) { + memcpy(out + o, scr_latin1_utf8[in[i]], 2); + o += 1 + (in[i] >> 7); + } + } + for (; i < n; i++) { + memcpy(out + o, scr_latin1_utf8[in[i]], 2); + o += 1 + (in[i] >> 7); + } + out[o] = '\0'; + scr_str_note_utf16_len(s, n); + return s; +} + ScrStr *scr_bytes_to_str(const ScrBytes *b, const ScrStr *enc) { SCR_BYTES_SNAPSHOT(b); const uint8_t *in = b->data; @@ -2561,21 +2662,12 @@ ScrStr *scr_bytes_to_str(const ScrBytes *b, const ScrStr *enc) { out[i * 2 + 1] = scr_hex_digits[in[i] & 0xf]; } out[n * 2] = '\0'; + scr_str_note_ascii(s); return s; } if (scr_enc_is(enc, "base64")) return scr_bytes_encode_b64(in, n, false); if (scr_enc_is(enc, "base64url")) return scr_bytes_encode_b64(in, n, true); - if (scr_enc_is(enc, "latin1")) { - /* Each byte is U+00XX ("binary" normalizes here upstream). */ - size_t high = 0; - for (size_t i = 0; i < n; i++) high += in[i] >> 7; - if (n > SIZE_MAX - high) scr_bytes_oom(); - ScrStr *s = scr_str_alloc_raw(n + high, n + high); - size_t o = 0; - for (size_t i = 0; i < n; i++) o = scr_bytes_put_cp(s->data, o, in[i]); - s->data[o] = '\0'; - return s; - } + if (scr_enc_is(enc, "latin1")) return scr_bytes_latin1_text(in, n); if (scr_enc_is(enc, "ascii")) { /* Node's ascii decode masks the high bit: byte & 0x7f. */ ScrStr *s = scr_str_alloc_raw(n, n); @@ -2670,12 +2762,25 @@ ScrStr *scr_bytes_to_str_checked_range(const ScrBytes *b, const ScrStr *enc, return out; } -static int scr_hex_val(uint8_t c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; -} +/* Hex digit values, -1 for any other byte. */ +static const int8_t scr_hex_values[256] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, + -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, +}; /* Both alphabets are accepted by both Buffer base64 encoding names. */ static const int8_t scr_b64_values[256] = { @@ -2816,19 +2921,82 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const size_t n = s->len; if (scr_enc_is(enc, "latin1") || scr_enc_is(enc, "ascii")) { /* Node writes charCodeAt(i) & 0xFF for BOTH spellings — an astral - * code point contributes its two surrogates' low bytes. */ + * code point contributes its two surrogates' low bytes. The ASCII + * prefix copies. Elsewhere eight ASCII bytes copy as one word. In eight bytes + * holding two or more high bytes, all of them in two-byte sequences, + * every continuation byte's output byte (the code point's low byte, + * from the continuation and its lead) is computed in one word; each + * byte is then stored without branches, a continuation replacing the + * byte its lead wrote. A lead in a word's last byte stays pending: its + * continuation, if the next byte is one, replaces it the same way. Any + * other byte decodes with scr_bytes_next_cp. */ ScrBytes *b = scr_bytes_conversion_storage_in(sink, n); + /* A local copy: a byte store could otherwise change b->data. */ + uint8_t *dst = b->data; + const uint64_t top = UINT64_C(0x8080808080808080); /* An ASCII prefix (all of an ASCII string) is its own encoding. */ size_t o = s->ascii ? n : scr_bytes_ascii_prefix(in, n), i = o; - memcpy(b->data, in, o); + memcpy(dst, in, o); + /* in[i - 1] is a two-byte lead (0xC2..0xDF) the word path wrote as + * dst[o - 1], and in[i] is not yet known to continue it. */ + bool pending = false; while (i < n) { + if (n - i >= 8) { + uint64_t w; + memcpy(&w, in + i, 8); + uint64_t high = w & top; + if (!high) { + memcpy(dst + o, &w, 8); + i += 8; + o += 8; + pending = false; + continue; + } +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + /* Byte k of the input is bits 8k..8k+7 of w; bit 8k+7 of w << j + * is bit 7 - j of byte k. */ + uint64_t cont = w & ~(w << 1) & top; /* 10xxxxxx */ + uint64_t lead = w & (w << 1) & ~(w << 2) & top; /* 110xxxxx */ + /* 0xC0 and 0xC1 (bits 4..1 clear) only begin overlong forms. */ + uint64_t overlong = lead & ~((w << 3) | (w << 4) | (w << 5) | (w << 6)); + if ((high & (high - 1)) && high == (cont | lead) && !overlong && + cont == ((lead << 8) | (pending ? 0x80u : 0u))) { + uint64_t prev = (w << 8) | (pending ? in[i - 1] : 0u); + uint64_t low = ((prev & UINT64_C(0x0303030303030303)) << 6) | + (w & UINT64_C(0x3f3f3f3f3f3f3f3f)); + uint64_t mask = (cont >> 7) * 0xff; + uint64_t out = (w & ~mask) | (low & mask); + for (unsigned k = 0; k < 8; k++) { + o -= (size_t)(cont >> (8 * k + 7)) & 1; + dst[o++] = (uint8_t)(out >> (8 * k)); + } + i += 8; + pending = (lead >> 63) != 0; + continue; + } +#endif + } + unsigned c = in[i]; + if (pending && c >> 6 == 2) { + /* The rest of the two-byte sequence whose lead ended a word. */ + dst[o - 1] = (uint8_t)(((in[i - 1] & 3u) << 6) | (c & 0x3f)); + i++; + pending = false; + continue; + } + pending = false; + if (c < 0x80) { + dst[o++] = (uint8_t)c; + i++; + continue; + } uint32_t cp = scr_bytes_next_cp(in, n, &i); if (cp > 0xffff) { cp -= 0x10000; - b->data[o++] = (uint8_t)(0xd800 + (cp >> 10)); - b->data[o++] = (uint8_t)(0xdc00 + (cp & 0x3ff)); + dst[o++] = (uint8_t)(0xd800 + (cp >> 10)); + dst[o++] = (uint8_t)(0xdc00 + (cp & 0x3ff)); } else { - b->data[o++] = (uint8_t)cp; + dst[o++] = (uint8_t)cp; } } b->len = o; /* never grows past n: multi-byte cps shrink */ @@ -2871,11 +3039,12 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const if (hex) { /* Stop at the first invalid pair, discarding an odd tail. */ b = scr_bytes_conversion_storage_in(sink, n / 2); - size_t o = 0; - for (size_t i = 0; i + 1 < n; i += 2) { - int hi = scr_hex_val(in[i]), lo = scr_hex_val(in[i + 1]); - if (hi < 0 || lo < 0) break; - b->data[o++] = (uint8_t)((hi << 4) | lo); + uint8_t *dst = b->data; + size_t pairs = n / 2, o = 0; + for (; o < pairs; o++) { + int hi = scr_hex_values[in[2 * o]], lo = scr_hex_values[in[2 * o + 1]]; + if ((hi | lo) < 0) break; + dst[o] = (uint8_t)((hi << 4) | lo); } b->len = o; } else { diff --git a/packages/runtime/src/scr_console_native.c b/packages/runtime/src/scr_console_native.c index 8c7467127..492d57d82 100644 --- a/packages/runtime/src/scr_console_native.c +++ b/packages/runtime/src/scr_console_native.c @@ -523,7 +523,11 @@ ScrStr *scr_console_dir_dyn(ScrDyn *value, double depth) { scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return scr_str_new("", 0); } - return scr_insp_dyn(value, 0, depth); + /* console.dir is inspect with customInspect: false. */ + bool custom = scr_insp_custom_inspect(false); + ScrStr *out = scr_insp_dyn(value, 0, depth); + scr_insp_custom_inspect(custom); + return out; } /* ── console.table ─────────────────────────────────────────────────── diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 1004a93c2..2d916a825 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -186,6 +186,8 @@ typedef enum { X(MATCH_SOURCE, 55) /* a match result's pending metadata (scr_regex.c) */ \ X(MESSAGE_EVENT, 50) /* ScrEvent with data — BroadcastChannel's 'message' */ \ X(MESSAGE_PORT, 36) \ + X(MIME_PARAMS, 81) /* util.MIMEParams (scr_mime.c) */ \ + X(MIME_TYPE, 82) /* util.MIMEType (scr_mime.c) */ \ X(MODULE_NAMESPACE, 79) /* module namespace object read through its exports (scr_module_namespace.c) */ \ X(NET_BLOCK_LIST, 48) /* net.BlockList (scr_net.c) */ \ X(NET_SERVER, 3) /* ScrNetServer — net.Server (http.Server rides the same handle) */ \ @@ -961,6 +963,10 @@ ScrDyn *scr_dyn_open_iterator(const ScrDyn *value, int *kind, bool generators); * for-of protocol reads it. +1, or NULL with the error pending. */ ScrDyn *scr_dyn_iterator_result_get(const ScrDyn *result, const char *key, size_t len); void scr_dyn_install_iterator_symbol(ScrDyn *key); +/* Symbol.for('nodejs.util.inspect.custom'): the symbol unit installs it + * when the registry creates it; NULL (borrowed) until then. */ +void scr_dyn_install_inspect_custom_symbol(ScrDyn *key); +ScrDyn *scr_dyn_inspect_custom_symbol(void); void scr_dyn_install_has_instance_symbol(ScrDyn *key); void scr_dyn_install_async_iterator_symbol(ScrDyn *key); void scr_dyn_install_async_dispose_symbol(ScrDyn *key); diff --git a/packages/runtime/src/scr_event_target.c b/packages/runtime/src/scr_event_target.c index 9ca91879a..0f59d8ca7 100644 --- a/packages/runtime/src/scr_event_target.c +++ b/packages/runtime/src/scr_event_target.c @@ -1962,3 +1962,60 @@ ScrPromise *scr_tp_scheduler_wait_signal(double ms, ScrPromise *value, bool ref, ScrPromise *scr_tp_set_immediate_signal(ScrPromise *value, bool ref, ScrDyn *signal) { return scr_tp_signal(0, value, ref, signal, true); } + +/* ── util.aborted(signal, resource) ──────────────────────────────────── + * Node's async function: the signal must be an AbortSignal and the + * resource an object (functions and arrays included), or the promise + * rejects with ERR_INVALID_ARG_TYPE. An aborted signal gives a promise of + * undefined; otherwise a once 'abort' listener that resists + * stopImmediatePropagation resolves it with the event. The answer adopts + * that promise, as an async function's result does. */ +static ScrDyn *scr_util_aborted_listener(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrPromise *inner = scr_box_get_ref(closure->caps[0]); + /* scr_promise_resolve_dyn takes the value's reference. */ + scr_promise_resolve_dyn(inner, scr_dyn_retain(argc ? args[0] : scr_dyn_undefined())); + scr_promise_release(inner); + return scr_dyn_retain(scr_dyn_undefined()); +} + +ScrDyn *scr_util_aborted(ScrDyn *signal, ScrDyn *resource) { + ScrPromise *outer = scr_promise_new(); + bool aborted = false; + ScrEventTargetCore *core = scr_et_signal_core_fn ? scr_et_signal_core_fn(signal, &aborted) : NULL; + bool object = resource->kind == SCR_DYN_OBJ || resource->kind == SCR_DYN_ARR || resource->kind == SCR_DYN_FUNC || + resource->kind == SCR_DYN_TYPED_REF || resource->kind == SCR_DYN_HANDLE || + resource->kind == SCR_DYN_BYTES || resource->kind == SCR_DYN_PROMISE || + resource->kind == SCR_DYN_PROXY; + if (!core) { + static const char message[] = "signal is not of type AbortSignal."; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_ARG_TYPE"); + } else if (!object) { + scr_dyn_arg_type_fail("resource", "of type object", resource); + } + if (scr_exc_pending()) { + scr_promise_reject_pending(outer); + ScrDyn *rejected = scr_dyn_new_promise(outer); + scr_promise_release(outer); + return rejected; + } + ScrPromise *inner = scr_promise_new(); + if (aborted) { + scr_promise_fulfill_void(inner); + } else { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new_obj(scr_promise_retain_v, scr_promise_release_v, NULL); + scr_box_set_ref(closure->caps[0], scr_promise_retain(inner)); + ScrDyn *listener = scr_dyn_new_func(closure, scr_util_aborted_listener, 1, "native:function", ""); + ScrStr *abort = scr_str_new("abort", 5); + bool duplicate; + (void)scr_et_append(core, abort, listener, SCR_ET_ONCE | SCR_ET_RESIST, &duplicate); + scr_str_release(abort); + scr_dyn_release(listener); + } + ScrDyn *adopted = scr_dyn_new_promise(inner); + scr_promise_release(inner); + scr_promise_resolve_dyn(outer, adopted); /* moves the promise box in */ + ScrDyn *result = scr_dyn_new_promise(outer); + scr_promise_release(outer); + return result; +} diff --git a/packages/runtime/src/scr_event_target.h b/packages/runtime/src/scr_event_target.h index e1ff5d0da..67f8d1544 100644 --- a/packages/runtime/src/scr_event_target.h +++ b/packages/runtime/src/scr_event_target.h @@ -106,6 +106,8 @@ typedef struct { extern SCR_TL const ScrEtBroadcastOps *scr_et_broadcast_ops; void scr_et_broadcast_install(void); +/* util.aborted(signal, resource): +1 dyn promise; argument errors reject it. */ +ScrDyn *scr_util_aborted(ScrDyn *signal, ScrDyn *resource); /* events.addAbortListener(signal, listener): +1 disposable, or NULL. */ ScrDyn *scr_events_add_abort_listener(ScrDyn *signal, ScrDyn *listener); /* timers/promises setTimeout(delay, value, options) and setImmediate(value, diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index c84fcf4ad..bec5be95a 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -152,6 +152,154 @@ static size_t insp_width(const char *data, size_t len) { return width; } +/* ── colors (stylizeWithColor) ───────────────────────────────────────── */ + +/* ctx.colors for the render in progress: the checked-dynamic walk wraps + * each styled leaf in inspect.colors' SGR codes for its inspect.styles + * style, and the layout measures entries without the codes. Static + * renderings never run with it set. */ +static SCR_TL bool g_colors; + +typedef enum { + INSP_SPECIAL, + INSP_NUMBER, + INSP_BIGINT, + INSP_BOOLEAN, + INSP_UNDEFINED, + INSP_NULL, + INSP_STRING, + INSP_SYMBOL, + INSP_DATE, +} InspStyle; + +/* inspect.styles mapped through inspect.colors: [open, close]. */ +static const unsigned char insp_style_codes[][2] = { + {36, 39}, /* special: cyan */ + {33, 39}, /* number: yellow */ + {33, 39}, /* bigint: yellow */ + {33, 39}, /* boolean: yellow */ + {90, 39}, /* undefined: grey */ + {1, 22}, /* null: bold */ + {32, 39}, /* string: green */ + {32, 39}, /* symbol: green */ + {35, 39}, /* date: magenta */ +}; + +static void insp_style_open(InspBuf *b, InspStyle style) { + char code[8]; + int n = snprintf(code, sizeof code, "\x1b[%um", insp_style_codes[style][0]); + ib_bytes(b, code, (size_t)n); +} + +static void insp_style_close(InspBuf *b, InspStyle style) { + char code[8]; + int n = snprintf(code, sizeof code, "\x1b[%um", insp_style_codes[style][1]); + ib_bytes(b, code, (size_t)n); +} + +/* ctx.stylize(text, style): +1; moves `text` in. */ +static ScrStr *insp_styled(ScrStr *text, InspStyle style) { + if (!g_colors) return text; + InspBuf b = {0}; + insp_style_open(&b, style); + ib_str(&b, text); + insp_style_close(&b, style); + scr_str_release(text); + return ib_take(&b); +} + +static ScrStr *insp_styled_lit(const char *text, size_t len, InspStyle style) { + return insp_styled(scr_str_new(text, len), style); +} + +/* The SC2020 refusal for colored renderings of values whose styles this + * runtime does not produce; answers an empty string. */ +static ScrStr *insp_colors_fence(const char *what) { + InspBuf b = {0}; + ib_cstr(&b, "colored inspect output of "); + ib_cstr(&b, what); + ib_cstr(&b, " has no lowering"); + ScrStr *message = ib_take(&b); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); + return scr_str_new("", 0); +} + +static ScrStr *insp_colors_fence_values(const char *cls) { + InspBuf b = {0}; + ib_cstr(&b, cls); + ib_cstr(&b, " values"); + ScrStr *what = ib_take(&b); + ScrStr *out = insp_colors_fence(what->data); + scr_str_release(what); + return out; +} + +/* The SGR sequence Node's removeColors strips (\x1b[<1 or 2 digits>m) at + * data[i]: its length, or 0. */ +static size_t insp_sgr_at(const char *data, size_t len, size_t i) { + if (data[i] != '\x1b' || i + 3 >= len || data[i + 1] != '[') return 0; + size_t j = i + 2, digits = 0; + while (j < len && digits < 2 && data[j] >= '0' && data[j] <= '9') j++, digits++; + return digits > 0 && j < len && data[j] == 'm' ? j + 1 - i : 0; +} + +/* A control sequence stripVTControlCharacters removes at data[i] (CSI + * sequences after ESC [ or C1 CSI, and OSC sequences ended by BEL): its + * length, or 0. */ +static size_t insp_vt_at(const char *data, size_t len, size_t i) { + const unsigned char *p = (const unsigned char *)data; + size_t j; + if (p[i] == 0x1b && i + 1 < len && p[i + 1] == '[') j = i + 2; + else if (p[i] == 0xc2 && i + 1 < len && p[i + 1] == 0x9b) j = i + 2; + else if (p[i] == 0x1b && i + 1 < len && p[i + 1] == ']') { + for (j = i + 2; j < len; j++) + if (p[j] == 0x07) return j + 1 - i; + return 0; + } else return 0; + while (j < len && ((p[j] >= '0' && p[j] <= '9') || p[j] == ';' || p[j] == '?')) j++; + if (j >= len) return 0; + unsigned char c = p[j]; + bool final = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'P') || (c >= 'R' && c <= 'T') || + c == 'Z' || c == 'c' || (c >= 'f' && c <= 'n') || c == 'q' || c == 'r' || + c == 't' || c == 'y' || c == '=' || c == '>' || c == '<' || c == '~'; + return final ? j + 1 - i : 0; +} + +/* An entry's display width for the grid (getStringWidth(entry, + * ctx.colors)) and its UTF-16 length for the break-length test (Node's + * removeColors under ctx.colors). */ +static size_t insp_entry_width(const ScrStr *s) { + if (!g_colors) return insp_width(s->data, s->len); + size_t width = 0; + for (size_t i = 0; i < s->len;) { + size_t skip = insp_vt_at(s->data, s->len, i); + if (skip) { + i += skip; + continue; + } + uint32_t cp = insp_cp_at(s->data, s->len, &i); + if (insp_full_width(cp)) width += 2; + else if (!insp_zero_width(cp)) width += 1; + } + return width; +} + +static size_t insp_entry_len(const ScrStr *s) { + if (!g_colors) return insp_utf16_len(s->data, s->len); + size_t units = 0; + for (size_t i = 0; i < s->len;) { + size_t skip = insp_sgr_at(s->data, s->len, i); + if (skip) { + i += skip; + continue; + } + uint32_t cp = insp_cp_at(s->data, s->len, &i); + units += cp >= 0x10000 ? 2 : 1; + } + return units; +} + /* ── number rendering ────────────────────────────────────────────────── */ /* formatNumber: JS ToString EXCEPT -0 renders as '-0'. */ @@ -245,12 +393,12 @@ static SCR_TL size_t g_frames_cap; static SCR_TL size_t g_indent; /* ctx.indentationLvl */ static SCR_TL bool g_no_break; /* breakLength: Infinity (console.table cells) */ static SCR_TL double g_break_length = 80; /* ctx.breakLength */ -static SCR_TL double g_compact = 3; /* ctx.compact: a number, 0 for false */ +static SCR_TL double g_compact = 3; /* ctx.compact: a number, -1 for false */ static SCR_TL bool g_sorted; /* ctx.sorted === true */ static SCR_TL double g_cur_depth; /* ctx.currentDepth (last-entered composite) */ /* util.inspect's breakLength, compact and sorted for the renders that - * follow (compact false is 0); the call site restores 80, 3 and false + * follow (compact false is -1); the call site restores 80, 3 and false * after its render. */ void scr_insp_layout(double break_length, double compact, bool sorted) { g_break_length = break_length; @@ -372,7 +520,7 @@ void scr_insp_seen_push(const void *v) { ScrStr *scr_insp_circular(double id) { char buf[32]; int n = snprintf(buf, sizeof buf, "[Circular *%.0f]", id); - return scr_str_new(buf, (size_t)n); + return insp_styled_lit(buf, (size_t)n, INSP_SPECIAL); } /* Pops the value's seen entry after its frame closed. scr_insp_end already @@ -406,7 +554,7 @@ static void insp_group(InspFrame *f, bool trailing_more) { size_t *data_len = malloc((output_len ? output_len : 1) * sizeof(size_t)); if (!data_len) insp_oom(); for (size_t i = 0; i < output_len; i++) { - size_t w = insp_width(f->items[i]->data, f->items[i]->len); + size_t w = insp_entry_width(f->items[i]); data_len[i] = w; total_length += (double)w + 2; if ((double)w > max_length) max_length = (double)w; @@ -497,7 +645,7 @@ static bool insp_below_break_length(InspFrame *f, size_t start, const ScrStr *ba size_t total = f->len + start; if ((double)(total + f->len) > g_break_length) return false; for (size_t i = 0; i < f->len; i++) { - total += insp_utf16_len(f->items[i]->data, f->items[i]->len); + total += insp_entry_len(f->items[i]); if ((double)total > g_break_length) return false; } return base->len == 0 || !insp_contains_byte(base->data, base->len, '\n'); @@ -523,12 +671,16 @@ ScrStr *scr_insp_end(ScrStr *base, ScrStr *b0, ScrStr *b1, double recurse, bool int ref_id = f->seen ? insp_circ_id(f->seen) : 0; if (ref_id) { char buf[32]; - int n = snprintf(buf, sizeof buf, base->len ? " " : "", ref_id); - size_t len = (size_t)n + base->len; - ref_base = scr_str_alloc_raw(len, len); - memcpy(ref_base->data, buf, (size_t)n); - memcpy(ref_base->data + n, base->data, base->len); - ref_base->data[len] = '\0'; + int n = snprintf(buf, sizeof buf, "", ref_id); + InspBuf rb = {0}; + if (g_colors) insp_style_open(&rb, INSP_SPECIAL); + ib_bytes(&rb, buf, (size_t)n); + if (g_colors) insp_style_close(&rb, INSP_SPECIAL); + if (base->len) { + ib_char(&rb, ' '); + ib_str(&rb, base); + } + ref_base = ib_take(&rb); base = ref_base; } size_t entries = f->len; @@ -625,6 +777,19 @@ void scr_insp_nested_begin(void) { g_ncirc = 0; } +/* nested_end's restore alone: the caller's context returns. */ +void scr_insp_nested_restore(void) { + InspSaved *sv = &g_saved[--g_nsaved]; + free(g_seen); + g_indent = sv->indent; + g_cur_depth = sv->cur_depth; + g_seen = sv->seen; + g_nseen = sv->nseen; + g_seen_cap = sv->seen_cap; + memcpy(g_circ, sv->circ, sizeof g_circ); + g_ncirc = sv->ncirc; +} + ScrStr *scr_insp_nested_end(ScrStr *prefix, ScrStr *inner) { InspSaved *sv = &g_saved[--g_nsaved]; free(g_seen); @@ -656,10 +821,14 @@ typedef struct { size_t nframes; size_t nsaved; size_t nseen; + size_t ncall; size_t indent; double cur_depth; } InspMark; +static size_t insp_call_depth(void); /* custom calls open, below */ +static void insp_call_unwind(size_t ncall); /* their options, below */ + static SCR_TL InspMark *g_marks; static SCR_TL size_t g_nmarks; static SCR_TL size_t g_marks_cap; @@ -670,11 +839,12 @@ void scr_insp_mark(void) { g_marks = realloc(g_marks, g_marks_cap * sizeof(InspMark)); if (!g_marks) insp_oom(); } - g_marks[g_nmarks++] = (InspMark){g_nframes, g_nsaved, g_nseen, g_indent, g_cur_depth}; + g_marks[g_nmarks++] = (InspMark){g_nframes, g_nsaved, g_nseen, insp_call_depth(), g_indent, g_cur_depth}; } void scr_insp_unwind(void) { InspMark m = g_marks[--g_nmarks]; + insp_call_unwind(m.ncall); while (g_nframes > m.nframes) { InspFrame *f = &g_frames[--g_nframes]; for (size_t i = 0; i < f->len; i++) scr_str_release(f->items[i]); @@ -768,13 +938,17 @@ ScrStr *scr_insp_str(ScrStr *s) { ib_cstr(&out, " +\n"); ib_spaces(&out, g_indent + 2); } + if (g_colors) insp_style_open(&out, INSP_STRING); insp_quote_into(&out, v + chunk_start, end - chunk_start); + if (g_colors) insp_style_close(&out, INSP_STRING); chunk_start = end; first = false; } } } else { + if (g_colors) insp_style_open(&out, INSP_STRING); insp_quote_into(&out, v, vlen); + if (g_colors) insp_style_close(&out, INSP_STRING); } if (trailer[0]) ib_cstr(&out, trailer); free(val.data); @@ -1130,7 +1304,9 @@ static void insp_key_into(InspBuf *b, const char *key, size_t len) { if (bare) { ib_bytes(b, key, len); } else { + if (g_colors) insp_style_open(b, INSP_STRING); insp_quote_into(b, key, len); + if (g_colors) insp_style_close(b, INSP_STRING); } } @@ -1467,7 +1643,7 @@ static ScrStr *scr_insp_dyn_collection(ScrDyn *d, double recurse, double depth, ib_char(&out, '['); ib_cstr(&out, label); ib_char(&out, ']'); - return ib_take(&out); + return insp_styled(ib_take(&out), INSP_SPECIAL); } ScrStr *base = ib_take(&head); scr_insp_begin(recurse + 1); @@ -1539,9 +1715,9 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char : scr_str_new("{}", 2); } if (recurse > depth) { - if (cls) return scr_insp_class_tag(cls, NULL); - return d->null_proto ? scr_str_new("[Object: null prototype]", 24) - : scr_str_new("[Object]", 8); + if (cls) return insp_styled(scr_insp_class_tag(cls, NULL), INSP_SPECIAL); + return d->null_proto ? insp_styled_lit("[Object: null prototype]", 24, INSP_SPECIAL) + : insp_styled_lit("[Object]", 8, INSP_SPECIAL); } scr_insp_begin(recurse + 1); for (size_t i = 0; i < d->v.obj.len; i++) { @@ -1563,6 +1739,7 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char ScrStr *entry = ib_take(&eb); scr_insp_entry(entry, false); scr_str_release(entry); + if (scr_exc_pending()) break; } for (size_t i = 0; i < nsymbols; i++) { ScrDynEntry *ent = scr_insp_symbol_entry(d, i); @@ -1612,12 +1789,12 @@ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth) { if (recurse > depth) { char tag[32]; int n = snprintf(tag, sizeof tag, "[%s]", scr_bytes_elem_name(b->elem)); - return scr_str_new(tag, (size_t)n); + return insp_styled_lit(tag, (size_t)n, INSP_SPECIAL); } scr_insp_begin(recurse + 1); size_t shown = b->len < 100 ? b->len : 100; for (size_t i = 0; i < shown; i++) { - ScrStr *s = scr_insp_f64(scr_bytes_get(b, (double)i)); + ScrStr *s = insp_styled(scr_insp_f64(scr_bytes_get(b, (double)i)), INSP_NUMBER); scr_insp_entry(s, true); scr_str_release(s); } @@ -1646,6 +1823,473 @@ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth) { return out; } +/* ── custom inspect functions (util.inspect.custom) ────────────────────── + * formatValue's hook: an object whose [util.inspect.custom] member is a + * function (other than util.inspect itself, on an object that is not its + * constructor's prototype) renders as that function's result. It runs + * before the circular and depth checks, with the remaining depth + * (ctx.depth - recurseTimes), the options object getUserOptions builds and + * util.inspect. A string result is re-indented at the current level; the + * object itself renders normally; any other value renders in its place. + * Class instances held as checked-dynamic values have compiled methods + * only, so one whose class defines the method throws SC2020. */ +static SCR_TL bool g_custom_inspect = true; /* ctx.customInspect */ +/* ctx.depth as the options gave it, for the custom functions' depth + * argument and options when the render's depth is unbounded: 0 the number + * g_depth_number (Infinity or NaN), 1 null, 2 undefined. */ +static SCR_TL int g_depth_kind = 1; +static SCR_TL double g_depth_number; +/* The options object that held keys inspect does not read: getUserOptions + * spreads it into the options the custom functions receive (borrowed for + * the render). */ +static SCR_TL const ScrDyn *g_user_options; + +double scr_insp_depth_kind(double kind) { + double previous = g_depth_kind; + g_depth_kind = (int)kind; + g_depth_number = INFINITY; + return previous; +} + +bool scr_insp_custom_inspect(bool enabled) { + bool previous = g_custom_inspect; + g_custom_inspect = enabled; + return previous; +} + +/* The walk's options while a custom inspect function runs. */ +typedef struct { + bool colors; + bool show_hidden; + bool custom_inspect; + bool sorted; + bool no_break; + int depth_kind; + double depth_number; + double break_length; + double compact; + const ScrDyn *user_options; + size_t nsaved; /* the nested-context stack's depth before the call */ + size_t nframes; /* the frame stack's depth before the call */ +} InspCallSaved; + +static SCR_TL InspCallSaved *g_call_saved; +static SCR_TL size_t g_ncall_saved; +static SCR_TL size_t g_call_saved_cap; + +static void insp_call_saved_cleanup(void) { + free(g_call_saved); + g_call_saved = NULL; + g_ncall_saved = g_call_saved_cap = 0; +} + +/* A custom inspect function's call: Node gives each util.inspect and + * util.format call its own ctx, so the calls the function makes start + * with no indentation, no seen objects, no colors and the default layout + * and options. begin answers a token; end(token) restores the walk's + * context as it was at that begin. A call that threw skips its end, and + * an enclosing custom function's end (or the walk's, when the function + * caught the error) discards what the throw left: the contexts of calls + * that never ended and the frames of renders that never finished. */ +double scr_insp_custom_call_begin(void) { + size_t nsaved = g_nsaved; + scr_insp_nested_begin(); + if (g_ncall_saved == g_call_saved_cap) { + if (g_call_saved_cap == 0) scr_atexit(insp_call_saved_cleanup); + g_call_saved_cap = g_call_saved_cap ? g_call_saved_cap * 2 : 4; + g_call_saved = realloc(g_call_saved, g_call_saved_cap * sizeof(InspCallSaved)); + if (!g_call_saved) insp_oom(); + } + double token = (double)g_ncall_saved; + InspCallSaved *sv = &g_call_saved[g_ncall_saved++]; + sv->nsaved = nsaved; + sv->nframes = g_nframes; + sv->colors = g_colors; + sv->show_hidden = g_show_hidden; + sv->custom_inspect = g_custom_inspect; + sv->sorted = g_sorted; + sv->no_break = g_no_break; + sv->depth_kind = g_depth_kind; + sv->depth_number = g_depth_number; + sv->break_length = g_break_length; + sv->compact = g_compact; + sv->user_options = g_user_options; + g_colors = false; + g_show_hidden = false; + g_custom_inspect = true; + g_no_break = false; + g_depth_kind = 1; + g_depth_number = 0; + g_user_options = NULL; + scr_insp_layout(80, 3, false); + return token; +} + +/* Restores the options saved by the custom call at `depth` and drops it + * and every call above it; NULL when there is none. */ +static InspCallSaved *insp_call_options_restore(size_t depth) { + if (depth >= g_ncall_saved) return NULL; + InspCallSaved *sv = &g_call_saved[depth]; + g_ncall_saved = depth; + g_colors = sv->colors; + g_show_hidden = sv->show_hidden; + g_custom_inspect = sv->custom_inspect; + g_no_break = sv->no_break; + g_depth_kind = sv->depth_kind; + g_depth_number = sv->depth_number; + g_user_options = sv->user_options; + scr_insp_layout(sv->break_length, sv->compact, sv->sorted); + return sv; +} + +void scr_insp_custom_call_end(double token) { + InspCallSaved *sv = insp_call_options_restore((size_t)token); + if (!sv) return; /* an enclosing end already restored it */ + while (g_nframes > sv->nframes) { + InspFrame *f = &g_frames[--g_nframes]; + for (size_t i = 0; i < f->len; i++) scr_str_release(f->items[i]); + free(f->items); + } + g_pending_seen = NULL; + while (g_nsaved > sv->nsaved) scr_insp_nested_restore(); +} + +/* scr_insp_unwind's share: the options of custom calls the refused render + * left open. */ +static void insp_call_unwind(size_t ncall) { insp_call_options_restore(ncall); } + +static size_t insp_call_depth(void) { return g_ncall_saved; } + +/* The style inspect.styles maps a styleType to, as [open, close] codes: + * the styles above plus regexp (red) and module (underline). false when + * the type has no style. */ +static bool insp_style_of(const ScrStr *type, unsigned *open, unsigned *close) { + static const struct { + const char *name; + unsigned open, close; + } styles[] = { + {"special", 36, 39}, {"number", 33, 39}, {"bigint", 33, 39}, {"boolean", 33, 39}, + {"undefined", 90, 39}, {"null", 1, 22}, {"string", 32, 39}, {"symbol", 32, 39}, + {"date", 35, 39}, {"regexp", 31, 39}, {"module", 4, 24}, + }; + for (size_t i = 0; i < sizeof styles / sizeof *styles; i++) { + if (type->len == strlen(styles[i].name) && memcmp(type->data, styles[i].name, type->len) == 0) { + *open = styles[i].open; + *close = styles[i].close; + return true; + } + } + return false; +} + +/* options.stylize: stylizeWithColor under colors (the text in its style's + * codes, or unchanged), stylizeNoColor otherwise (the argument itself). + * caps[0] holds whether colors apply. */ +static ScrDyn *insp_stylize_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrDyn *colors = scr_box_get_ref(closure->caps[0]); + bool colored = colors && scr_dyn_truthy(colors); + scr_dyn_release(colors); + ScrDyn *text = argc > 0 ? args[0] : (ScrDyn *)scr_dyn_undefined(); + if (!colored) return scr_dyn_retain(text); + ScrStr *str = scr_dyn_string_coerce_js(text); + if (!str) return NULL; + unsigned open, close; + ScrStr *type = argc > 1 && args[1]->kind == SCR_DYN_STR ? args[1]->v.str : NULL; + if (!type || !insp_style_of(type, &open, &close)) { + ScrDyn *out = scr_dyn_new_str(str); + scr_str_release(str); + return out; + } + InspBuf b = {0}; + char code[8]; + int n = snprintf(code, sizeof code, "\x1b[%um", open); + ib_bytes(&b, code, (size_t)n); + ib_str(&b, str); + n = snprintf(code, sizeof code, "\x1b[%um", close); + ib_bytes(&b, code, (size_t)n); + scr_str_release(str); + ScrStr *styled = ib_take(&b); + ScrDyn *out = scr_dyn_new_str(styled); + scr_str_release(styled); + return out; +} + +static ScrDyn *insp_inspect_call(ScrClosure *closure, ScrDyn *const *args, size_t argc); + +/* Whether `value` is the stylize function getUserOptions built for these + * colors. */ +static bool insp_own_stylize(const ScrDyn *value, bool colors) { + if (value->kind != SCR_DYN_FUNC || value->v.fn.thunk != insp_stylize_call) return false; + ScrDyn *flag = scr_box_get_ref(value->v.fn.clo->caps[0]); + bool colored = flag && scr_dyn_truthy(flag); + scr_dyn_release(flag); + return colored == colors; +} + +/* util.inspect as a function value (the custom functions' third + * argument): a fresh inspect context over the call's arguments, the + * caller's restored afterwards. One function per thread, so the argument + * compares equal to util.inspect. +1. */ +static SCR_TL ScrDyn *g_inspect_function; + +static void insp_inspect_function_cleanup(void) { + scr_dyn_release(g_inspect_function); + g_inspect_function = NULL; +} + +/* util.inspect's own members (custom, colors, styles, defaultOptions) + * read or written through the function value: the tables are not modeled, + * so each throws SC2020 instead of answering undefined. */ +static ScrDyn *insp_inspect_member_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; (void)argc; + char message[128]; + int n = snprintf(message, sizeof message, "util.inspect.%s through the inspect function is not supported yet", + (const char *)closure->fn); + scr_throw_error_msg_code(SCR_ERR_ERROR, message, (size_t)n, "SC2020"); + return NULL; +} + +static ScrDyn *insp_inspect_value(void) { + if (!g_inspect_function) { + ScrClosure *closure = scr_closure_new(NULL, 0); + g_inspect_function = + scr_dyn_new_func(closure, insp_inspect_call, 2, "native:util.inspect", "inspect"); + scr_atexit(insp_inspect_function_cleanup); + static const char *const members[] = {"custom", "defaultOptions", "colors", "styles"}; + ScrDyn *table = scr_dyn_function_properties(g_inspect_function); + for (size_t i = 0; i < sizeof members / sizeof members[0]; i++) { + ScrDyn *get = scr_dyn_new_func(scr_closure_new((void *)members[i], 0), insp_inspect_member_call, 0, + "native:function", members[i]); + ScrDyn *set = scr_dyn_new_func(scr_closure_new((void *)members[i], 0), insp_inspect_member_call, 1, + "native:function", members[i]); + scr_dyn_obj_set_accessor(table, members[i], strlen(members[i]), get, set); + table->v.obj.entries[table->v.obj.len - 1].enumerable = false; + } + scr_dyn_release(table); + } + return scr_dyn_retain(g_inspect_function); +} + +static void insp_opt_set(ScrDyn *options, const char *key, ScrDyn *value /* moves in */) { + scr_dyn_obj_set(options, key, strlen(key), value); +} + +/* getUserOptions(ctx) for the render in progress at `depth`. +1. */ +static ScrDyn *insp_user_options(double depth) { + ScrDyn *options = scr_dyn_new_obj(); + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[0], scr_dyn_new_bool(g_colors)); + insp_opt_set(options, "stylize", + scr_dyn_new_func(closure, insp_stylize_call, 2, "native:util.stylize", + g_colors ? "stylizeWithColor" : "stylizeNoColor")); + insp_opt_set(options, "showHidden", scr_dyn_new_bool(false)); + insp_opt_set(options, "depth", + isfinite(depth) ? scr_dyn_new_num(depth) + : g_depth_kind == 0 ? scr_dyn_new_num(g_depth_number) + : g_depth_kind == 1 ? scr_dyn_new_null() + : scr_dyn_retain(scr_dyn_undefined())); + insp_opt_set(options, "colors", scr_dyn_new_bool(g_colors)); + insp_opt_set(options, "customInspect", scr_dyn_new_bool(true)); + insp_opt_set(options, "showProxy", scr_dyn_new_bool(false)); + insp_opt_set(options, "maxArrayLength", scr_dyn_new_num(100)); + insp_opt_set(options, "maxStringLength", scr_dyn_new_num(10000)); + insp_opt_set(options, "breakLength", scr_dyn_new_num(g_break_length)); + insp_opt_set(options, "compact", g_compact < 0 ? scr_dyn_new_bool(false) : scr_dyn_new_num(g_compact)); + insp_opt_set(options, "sorted", scr_dyn_new_bool(g_sorted)); + insp_opt_set(options, "getters", scr_dyn_new_bool(false)); + insp_opt_set(options, "numericSeparator", scr_dyn_new_bool(false)); + if (g_user_options && g_user_options->kind == SCR_DYN_OBJ) { + for (size_t i = 0; i < g_user_options->v.obj.len; i++) { + const ScrDynEntry *ent = &g_user_options->v.obj.entries[i]; + if (!ent->enumerable) continue; + ScrDyn *value = scr_dyn_obj_read((ScrDyn *)g_user_options, ent->key, ent->key_len); + if (!value) { + scr_dyn_release(options); + return NULL; + } + scr_dyn_obj_set(options, ent->key, ent->key_len, value); + } + } + return options; +} + +/* The custom inspect functions of the program's classes, by the type key + * of their instances' typed refs: compiled functions (self, depth, + * options, inspect) that call the instance's method and answer its + * result as a checked-dynamic value. The program registers them before + * it runs. */ +typedef struct { + char *type_key; + ScrDyn *fn; +} InspCustomClass; + +static SCR_TL InspCustomClass *g_custom_classes; +static SCR_TL size_t g_ncustom_classes; + +static void insp_custom_classes_cleanup(void) { + for (size_t i = 0; i < g_ncustom_classes; i++) { + free(g_custom_classes[i].type_key); + scr_dyn_release(g_custom_classes[i].fn); + } + free(g_custom_classes); + g_custom_classes = NULL; + g_ncustom_classes = 0; +} + +void scr_insp_register_custom(ScrStr *type_key, ScrDyn *fn) { + if (g_ncustom_classes == 0) scr_atexit(insp_custom_classes_cleanup); + InspCustomClass *grown = + realloc(g_custom_classes, (g_ncustom_classes + 1) * sizeof *g_custom_classes); + if (!grown) insp_oom(); + g_custom_classes = grown; + char *key = malloc(type_key->len + 1); + if (!key) insp_oom(); + memcpy(key, type_key->data, type_key->len); + key[type_key->len] = '\0'; + g_custom_classes[g_ncustom_classes].type_key = key; + g_custom_classes[g_ncustom_classes].fn = scr_dyn_retain(fn); + g_ncustom_classes++; +} + +static ScrDyn *insp_custom_class_fn(const ScrDyn *d) { + for (size_t i = 0; i < g_ncustom_classes; i++) + if (strcmp(g_custom_classes[i].type_key, d->v.typed_ref.type_key) == 0) + return g_custom_classes[i].fn; + return NULL; +} + +/* The [util.inspect.custom] result for an object kind, or NULL when the + * value renders normally (*thrown reports a pending exception). +1. */ +static ScrStr *insp_custom(ScrDyn *d, double recurse, double depth, bool *thrown) { + *thrown = false; + if (!g_custom_inspect) return NULL; + ScrDyn *fn; + bool compiled = d->kind == SCR_DYN_TYPED_REF; + if (compiled) { + /* A class instance: its class's compiled function, when it has a + * custom inspect method. A class whose method has no compiled call + * (the compiler refuses such classes where it renders them) refuses + * here too. */ + fn = insp_custom_class_fn(d); + if (!fn) { + ScrStr *name = scr_str_new("%symbol:Symbol(nodejs.util.inspect.custom)", 42); + bool declared = scr_dyn_typed_ref_class_has(d, name); + scr_str_release(name); + if (!declared) return NULL; + static const char message[] = + "util.inspect of this class instance's [util.inspect.custom] method has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + *thrown = true; + return NULL; + } + fn = scr_dyn_retain(fn); + } else { + ScrDyn *key = scr_dyn_inspect_custom_symbol(); + if (!key) return NULL; + if (d->kind != SCR_DYN_OBJ && d->kind != SCR_DYN_ARR && d->kind != SCR_DYN_FUNC) return NULL; + fn = scr_dyn_reflect_get(d, key, d); + if (!fn) { + *thrown = true; + return NULL; + } + } + if (fn->kind != SCR_DYN_FUNC || fn->v.fn.thunk == insp_inspect_call) { + scr_dyn_release(fn); + return NULL; + } + /* A constructor's prototype object renders normally. */ + ScrDyn *ctor = !compiled && d->kind == SCR_DYN_OBJ ? scr_dyn_obj_read(d, "constructor", 11) : NULL; + if (ctor && ctor->kind == SCR_DYN_FUNC) { + ScrDyn *prototype = scr_dyn_fn_get(ctor, "prototype", 9); + bool own_prototype = prototype == d; + scr_dyn_release(prototype); + if (own_prototype) { + scr_dyn_release(ctor); + scr_dyn_release(fn); + return NULL; + } + } + scr_dyn_release(ctor); + ScrDyn *args[3]; + /* ctx.depth - recurseTimes, or null for a null depth. */ + args[0] = scr_insp_custom_depth(recurse, depth); + args[1] = insp_user_options(depth); + if (!args[1]) { + scr_dyn_release(args[0]); + scr_dyn_release(fn); + *thrown = true; + return NULL; + } + args[2] = insp_inspect_value(); + ScrDyn *ret; + double token = scr_insp_custom_call_begin(); + if (compiled) { + ScrDyn *call[4] = {d, args[0], args[1], args[2]}; + ret = scr_dyn_call(fn, call, 4, "[nodejs.util.inspect.custom]"); + } else { + scr_dyn_this_push_dyn(d); + ret = scr_dyn_call(fn, args, 3, "[nodejs.util.inspect.custom]"); + scr_dyn_this_pop(); + } + scr_insp_custom_call_end(token); + for (size_t i = 0; i < 3; i++) scr_dyn_release(args[i]); + scr_dyn_release(fn); + if (!ret) { + *thrown = true; + return NULL; + } + if (ret == d || scr_insp_custom_is_self(ret, d)) { + scr_dyn_release(ret); + return NULL; + } + ScrStr *out; + if (ret->kind == SCR_DYN_STR) out = scr_insp_custom_text(ret->v.str); + else out = scr_insp_dyn(ret, recurse, depth); + scr_dyn_release(ret); + if (scr_exc_pending()) { + scr_str_release(out); + *thrown = true; + return NULL; + } + return out; +} + +bool scr_insp_custom_enabled(void) { return g_custom_inspect; } + +/* ctx.depth - recurseTimes, or null for a null depth (NaN for undefined). */ +ScrDyn *scr_insp_custom_depth(double recurse, double depth) { + if (isfinite(depth)) return scr_dyn_new_num(depth - recurse); + if (g_depth_kind == 0) return scr_dyn_new_num(g_depth_number - recurse); + return g_depth_kind == 1 ? scr_dyn_new_null() : scr_dyn_new_num(NAN); +} + +ScrDyn *scr_insp_custom_options(double depth) { return insp_user_options(depth); } + +ScrDyn *scr_insp_inspect_function(void) { return insp_inspect_value(); } + +bool scr_insp_custom_is_self(ScrDyn *result, ScrDyn *self) { + return result->kind == SCR_DYN_TYPED_REF && self->kind == SCR_DYN_TYPED_REF && + result->v.typed_ref.ptr == self->v.typed_ref.ptr; +} + +ScrStr *scr_insp_custom_result(ScrDyn *result, double recurse, double depth) { + if (result->kind == SCR_DYN_STR) return scr_insp_custom_text(result->v.str); + return scr_insp_dyn(result, recurse, depth); +} + +/* A custom function's string result: each newline followed by the + * current indentation. +1. */ +ScrStr *scr_insp_custom_text(ScrStr *text) { + if (!memchr(text->data, '\n', text->len)) return scr_str_retain(text); + InspBuf out = {0}; + for (size_t i = 0; i < text->len; i++) { + ib_char(&out, text->data[i]); + if (text->data[i] == '\n') ib_spaces(&out, g_indent); + } + return ib_take(&out); +} + /* Node's function rendering: [Function: name] / [Function (anonymous)], * and [class C extends B] / [class (anonymous)] for a class. The boxed * name is the compiler's best-effort static spelling (SEMANTICS.md — @@ -1671,12 +2315,22 @@ static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { ib_cstr(&out, base->v.fn.name); } ib_char(&out, ']'); - } else if (name && name[0]) { - ib_cstr(&out, "[Function: "); - ib_cstr(&out, name); - ib_char(&out, ']'); } else { - ib_cstr(&out, "[Function (anonymous)]"); + /* The base names the function's own string `name` when its table has + * one (Object.defineProperty, util.callbackify, util.debounce). */ + size_t name_len = name ? strlen(name) : 0; + const ScrDyn *own_name = table && table->kind == SCR_DYN_OBJ ? scr_dyn_obj_get(table, "name", 4) : NULL; + if (own_name && own_name->kind == SCR_DYN_STR) { + name = own_name->v.str->data; + name_len = own_name->v.str->len; + } + if (name_len) { + ib_cstr(&out, "[Function: "); + ib_bytes(&out, name, name_len); + ib_char(&out, ']'); + } else { + ib_cstr(&out, "[Function (anonymous)]"); + } } ScrStr *tag = ib_take(&out); if (cls && !props && cls->own_static_fields) { @@ -1695,7 +2349,7 @@ static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { } if (shown == 0) { scr_dyn_release(table); - return tag; + return insp_styled(tag, INSP_SPECIAL); } /* A function already on the traversal stack renders as [Circular *N], * even past the depth limit. Its identity is the class object for a class @@ -1711,8 +2365,9 @@ static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { if (recurse > depth) { scr_dyn_release(table); scr_str_release(tag); - return scr_str_new("[Function]", 10); + return insp_styled_lit("[Function]", 10, INSP_SPECIAL); } + tag = insp_styled(tag, INSP_SPECIAL); char *prefix = malloc(tag->len + 1); if (!prefix) scr_trap("scriptc: out of memory\n"); memcpy(prefix, tag->data, tag->len); @@ -1738,8 +2393,10 @@ static const char *insp_hidden_refusal(const ScrDyn *d) { case SCR_DYN_BYTES: return d->buffer ? "Buffer" : d->v.bytes->is_data_view ? "DataView" : "typed array"; case SCR_DYN_OBJ: return scr_dyn_obj_get(d, "%error", 6) != NULL ? "Error" : NULL; case SCR_DYN_HANDLE: + /* A MIMEParams keeps its entries private: Node prints MIMEParams {} + * with or without showHidden. */ return d->v.handle.tag == SCR_DYNH_MAP || d->v.handle.tag == SCR_DYNH_SET || - d->v.handle.tag == SCR_DYNH_DATE + d->v.handle.tag == SCR_DYNH_DATE || d->v.handle.tag == SCR_DYNH_MIME_PARAMS ? NULL : scr_dyn_handle_cls(d); default: return NULL; @@ -1802,6 +2459,14 @@ static ScrStr *scr_insp_search_params(ScrSearchParams *sp, double recurse, doubl ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->kind == SCR_DYN_PROXY) return scr_insp_dyn(d->v.proxy.target, recurse, depth); + if ((d->kind == SCR_DYN_TYPED_REF ? g_ncustom_classes > 0 : scr_dyn_inspect_custom_symbol() != NULL) && + (d->kind == SCR_DYN_OBJ || d->kind == SCR_DYN_ARR || d->kind == SCR_DYN_FUNC || + d->kind == SCR_DYN_TYPED_REF)) { + bool thrown; + ScrStr *custom = insp_custom(d, recurse, depth, &thrown); + if (custom) return custom; + if (thrown) return scr_str_new("", 0); + } const char *refused = g_show_hidden ? insp_hidden_refusal(d) : NULL; if (refused) { InspBuf out = {0}; @@ -1813,17 +2478,17 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } switch (d->kind) { case SCR_DYN_NULL: - return scr_str_new("null", 4); + return insp_styled_lit("null", 4, INSP_NULL); case SCR_DYN_UNDEF: - return scr_str_new("undefined", 9); + return insp_styled_lit("undefined", 9, INSP_UNDEFINED); case SCR_DYN_BOOL: - return d->v.b ? scr_str_new("true", 4) : scr_str_new("false", 5); + return d->v.b ? insp_styled_lit("true", 4, INSP_BOOLEAN) : insp_styled_lit("false", 5, INSP_BOOLEAN); case SCR_DYN_BIGINT: - return scr_bigint_inspect(d->v.bigint); + return insp_styled(scr_bigint_inspect(d->v.bigint), INSP_BIGINT); case SCR_DYN_SYMBOL: - return d->v.symbol.render(d->v.symbol.value); + return insp_styled(d->v.symbol.render(d->v.symbol.value), INSP_SYMBOL); case SCR_DYN_NUM: - return scr_insp_f64(d->v.num); + return insp_styled(scr_insp_f64(d->v.num), INSP_NUMBER); case SCR_DYN_STR: return scr_insp_str(d->v.str); case SCR_DYN_ARR: { @@ -1835,7 +2500,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { size_t nprops = 0; for (size_t i = 0; props && i < props->v.obj.len; i++) nprops += insp_obj_shows(&props->v.obj.entries[i]); if (len == 0 && nprops == 0 && !g_show_hidden) return scr_str_new("[]", 2); - if (recurse > depth) return scr_str_new("[Array]", 7); + if (recurse > depth) return insp_styled_lit("[Array]", 7, INSP_SPECIAL); scr_insp_begin(recurse + 1); /* Node's maxArrayLength (100) counts output entries; a run of holes * is one `` entry (formatSpecialArray). */ @@ -1848,7 +2513,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { while (next < len && !presence[next]) next++; char hole[48]; int hn = snprintf(hole, sizeof hole, "<%zu empty item%s>", next - i, next - i == 1 ? "" : "s"); - ScrStr *s = scr_str_new(hole, (size_t)hn); + ScrStr *s = insp_styled_lit(hole, (size_t)hn, INSP_UNDEFINED); scr_insp_entry(s, false); scr_str_release(s); i = next; @@ -1858,6 +2523,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { ScrStr *s = scr_insp_dyn(item, recurse + 1, depth); scr_insp_entry(s, item->kind == SCR_DYN_NUM); scr_str_release(s); + if (scr_exc_pending()) break; } bool more = i < len; size_t entries = shown + (more ? 1 : 0); @@ -1916,7 +2582,10 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { /* An Error carried as a checked-dynamic value (a caught error, an * Error stored in an `unknown` slot): the error's own rendering, * never the encoding's internal marker. */ - if (scr_dyn_obj_get(d, "%error", 6) != NULL) return scr_insp_dyn_error(d, recurse, depth); + if (scr_dyn_obj_get(d, "%error", 6) != NULL) { + if (g_colors) return insp_colors_fence("Error values"); + return scr_insp_dyn_error(d, recurse, depth); + } /* Object.create(null)'s dictionary: Node prefixes the rendering * with "[Object: null prototype]" (formatValue's constructor-less * base), the empty form included, and the beyond-depth answer IS @@ -1926,6 +2595,9 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { case SCR_DYN_FUNC: return scr_insp_dyn_func(d, recurse, depth); case SCR_DYN_HANDLE: { + if (g_colors && d->v.handle.tag != SCR_DYNH_SET && d->v.handle.tag != SCR_DYNH_MAP && + d->v.handle.tag != SCR_DYNH_DATE) + return insp_colors_fence_values(scr_dyn_handle_cls(d)); if (d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || d->v.handle.tag == SCR_DYNH_SHARED_ARRAY_BUFFER) { return scr_insp_array_buffer(d->v.handle.ptr, scr_dyn_handle_cls(d), recurse, depth); @@ -1935,7 +2607,8 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->v.handle.tag == SCR_DYNH_WEAK_MAP || d->v.handle.tag == SCR_DYNH_WEAK_SET) return scr_insp_weak_collection(d->v.handle.tag == SCR_DYNH_WEAK_MAP ? "WeakMap" : "WeakSet", recurse, depth); - if (d->v.handle.tag == SCR_DYNH_DATE) return scr_insp_date(scr_dyn_native_date_value(d)); + if (d->v.handle.tag == SCR_DYNH_DATE) + return insp_styled(scr_insp_date(scr_dyn_native_date_value(d)), INSP_DATE); if (d->v.handle.tag == SCR_DYNH_SEARCH_PARAMS) return scr_insp_search_params(d->v.handle.ptr, recurse, depth); if (d->v.handle.tag == SCR_DYNH_ITERATOR) { @@ -2004,9 +2677,14 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } case SCR_DYN_TYPED_REF: { + if (g_colors && scr_insp_is_generator(d)) return insp_colors_fence("generator objects"); if (scr_insp_is_generator(d)) return scr_insp_generator(d); const char *cls = NULL; ScrDyn *view = scr_event_ref_hooks ? scr_event_ref_hooks->inspect_view(d, &cls) : NULL; + if (view && g_colors) { + scr_dyn_release(view); + return insp_colors_fence_values(cls); + } if (view) { /* The handle rule above: the class name past the depth limit. */ if (recurse > depth) { @@ -2036,6 +2714,11 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { double time; ScrStr *date_class = materialized->kind == SCR_DYN_OBJ && scr_dyn_date_class_time(d, &time) ? scr_dyn_date_class_name(d) : NULL; + if (date_class && g_colors) { + scr_str_release(date_class); + scr_dyn_release(materialized); + return insp_colors_fence("Date subclass instances"); + } if (date_class) { /* An instance of a class declared `extends Date`: the class name and * the time as Date.prototype.toISOString formats it, then its own @@ -2109,6 +2792,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } case SCR_DYN_BYTES: { ScrBytes *b = d->v.bytes; + if (b->is_data_view && g_colors) return insp_colors_fence("DataView values"); if (b->is_data_view) return scr_insp_data_view(b, recurse, depth); if (d->buffer) { /* Buffer-flavored (a stream chunk): Node's form. */ @@ -2128,6 +2812,569 @@ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth) { return scr_insp_dyn(d, 0, depth); } +ScrStr *scr_insp_dyn_format_s(ScrDyn *d, double depth) { + /* hasBuiltInToString is false for MIMEType and MIMEParams (their + * constructors are not globals): %s prints String(value). */ + if (d->kind == SCR_DYN_HANDLE && (d->v.handle.tag == SCR_DYNH_MIME_TYPE || d->v.handle.tag == SCR_DYNH_MIME_PARAMS)) + return scr_dyn_string_coerce_js(d); + return scr_insp_dyn_s(d, depth); +} + +/* ── util.format over checked-dynamic values ───────────────────────────── + * formatWithOptionsInternal over argument values whose types are known + * only at run time: util.format and util.formatWithOptions with a runtime + * format string or spread arguments, console output with a runtime format + * string, and debuglog's lines. The %-substitutions (%s %d %i %f %j %o %O + * %c %%) follow Node's argument cursor, then the remaining arguments join + * with spaces, strings verbatim and everything else inspected under the + * options. With colors, primitives take inspect's default styles; colored + * object renderings have no lowering. These entries call program code + * (toString, valueOf and toJSON), so unlike the rest of this file they + * can throw: NULL with the exception pending. */ +static void insp_fmt_fence(const ScrInspFormatOptions *o, const char *what) { + InspBuf b = {0}; + ib_cstr(&b, o->surface); + ib_char(&b, ' '); + ib_cstr(&b, what); + ScrStr *message = ib_take(&b); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); +} + +static bool insp_fmt_object(const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_OBJ: + case SCR_DYN_ARR: + case SCR_DYN_FUNC: + case SCR_DYN_BYTES: + case SCR_DYN_HANDLE: + case SCR_DYN_PROMISE: + case SCR_DYN_TYPED_REF: + case SCR_DYN_PROXY: + return true; + default: + return false; + } +} + +void scr_insp_format_defaults(ScrInspFormatOptions *o, const char *surface) { + o->surface = surface; + o->depth = 2; + o->depth_kind = 0; + o->depth_number = 2; + o->colors = false; + o->custom_inspect = true; + o->break_length = 80; + o->compact = 3; + o->sorted = false; + o->user_options = NULL; +} + +/* inspect(value, options) at the given depth, compact and colors, with + * the options' breakLength and sorted; the previous layout returns + * afterwards. +1, or NULL with the exception pending. */ +static ScrStr *insp_fmt_inspect(ScrDyn *value, const ScrInspFormatOptions *o, double depth, + double compact, bool colors) { + double break_length = g_break_length, saved_compact = g_compact; + bool sorted = g_sorted, saved_colors = g_colors, saved_custom = g_custom_inspect; + int saved_depth_kind = g_depth_kind; + double saved_depth_number = g_depth_number; + const ScrDyn *saved_user = g_user_options; + scr_insp_layout(o->break_length, compact, o->sorted); + g_colors = colors; + g_custom_inspect = o->custom_inspect; + g_depth_kind = o->depth_kind; + g_depth_number = o->depth_number; + g_user_options = o->user_options; + ScrStr *text = scr_insp_dyn(value, 0, depth); + g_colors = saved_colors; + g_custom_inspect = saved_custom; + g_depth_kind = saved_depth_kind; + g_depth_number = saved_depth_number; + g_user_options = saved_user; + scr_insp_layout(break_length, saved_compact, sorted); + if (scr_exc_pending()) { + scr_str_release(text); + return NULL; + } + return text; +} + +/* hasBuiltInToString's negative answer: a toString the program defined on + * the object or its prototype chain (1). Symbol-keyed members (where + * Symbol.toPrimitive would also count) are reported as unknown (-1), and a + * class instance whose class defines toString as -2: its methods are + * compiled for the class's own calls, which this runtime cannot make. */ +static int insp_fmt_custom_to_string(const ScrDyn *value) { + if (value->kind == SCR_DYN_TYPED_REF) { + ScrStr *to_string = scr_str_new("toString", 8); + bool has = scr_dyn_typed_ref_class_has(value, to_string); + scr_str_release(to_string); + return has ? -2 : 0; + } + for (const ScrDyn *current = value; current && current->kind == SCR_DYN_OBJ; current = current->prototype) { + if (current->symbol_properties) return -1; + const ScrDyn *member = scr_dyn_obj_get(current, "toString", 8); + if (member) return member->kind == SCR_DYN_FUNC; + } + return 0; +} + +static ScrStr *insp_fmt_s(ScrDyn *value, const ScrInspFormatOptions *o) { + if (value->kind == SCR_DYN_STR) return scr_str_retain(value->v.str); + if (value->kind == SCR_DYN_NUM) return scr_insp_f64(value->v.num); + if (value->kind == SCR_DYN_BIGINT) return scr_bigint_inspect(value->v.bigint); + if (value->kind == SCR_DYN_HANDLE && (value->v.handle.tag == SCR_DYNH_MIME_TYPE || value->v.handle.tag == SCR_DYNH_MIME_PARAMS)) + return scr_dyn_string_coerce_js(value); + if (value->kind == SCR_DYN_FUNC) { + insp_fmt_fence(o, "%s of a function prints its source text, which a compiled binary does not keep"); + return NULL; + } + if ((value->kind == SCR_DYN_OBJ && scr_dyn_obj_get(value, "%error", 6) == NULL) || + value->kind == SCR_DYN_TYPED_REF) { + int custom = insp_fmt_custom_to_string(value); + if (custom == -2) { + insp_fmt_fence(o, "%s of a class instance whose class defines toString has no lowering"); + return NULL; + } + if (custom < 0) { + insp_fmt_fence(o, "%s of objects with symbol-keyed members has no lowering"); + return NULL; + } + if (custom) return scr_dyn_string_coerce_js(value); + } + if (!insp_fmt_object(value)) return scr_insp_dyn(value, 0, 0); + /* inspect(arg, { ...inspectOptions, depth: 0, colors: false, compact: 3 }) */ + return insp_fmt_inspect(value, o, 0, 3, false); +} + +static ScrStr *insp_fmt_number(ScrDyn *value, int spec) { + if (value->kind == SCR_DYN_BIGINT && spec != 'f') return scr_bigint_inspect(value->v.bigint); + if (value->kind == SCR_DYN_SYMBOL) return scr_str_new("NaN", 3); + double number; + if (spec == 'd') { + if (!scr_dyn_number_coerce_js(value, &number)) return NULL; + } else { + ScrStr *text = scr_dyn_string_coerce_js(value); + if (!text) return NULL; + number = spec == 'i' ? scr_parse_int(text, 0) : scr_parse_float(text); + scr_str_release(text); + } + return scr_insp_f64(number); +} + +/* One formatter's conversions: %s, %j, %d/%i/%f, %O/%o and the remaining + * arguments' inspect (depth -1: the options' depth). The loop references + * only the table it runs, so a program links only the conversions it uses. */ +typedef struct { + ScrStr *(*s)(ScrDyn *value, const ScrInspFormatOptions *o); + ScrStr *(*j)(const ScrDyn *value); + ScrStr *(*number)(ScrDyn *value, int spec); + ScrStr *(*inspect)(ScrDyn *value, const ScrInspFormatOptions *o, double depth); +} InspFmtOps; + +static ScrStr *insp_format_loop(ScrDyn *const *args, size_t argc, const ScrInspFormatOptions *o, + const InspFmtOps *ops) { + if (argc == 0) return scr_str_new("", 0); + InspBuf out = {0}; + size_t a = 0; + const char *join = ""; + if (args[0]->kind == SCR_DYN_STR) { + const ScrStr *first = args[0]->v.str; + if (argc == 1) return scr_str_retain((ScrStr *)first); + const char *text = first->data; + size_t last = 0; + for (size_t i = 0; first->len > 0 && i < first->len - 1; i++) { + if (text[i] != '%') continue; + int next = (unsigned char)text[++i]; + if (a + 1 != argc) { + ScrStr *piece = NULL; + switch (next) { + case 's': piece = ops->s(args[++a], o); break; + case 'j': piece = ops->j(args[++a]); break; + case 'd': + case 'i': + case 'f': piece = ops->number(args[++a], next); break; + case 'O': piece = ops->inspect(args[++a], o, o->depth); break; + case 'o': + if (insp_fmt_object(args[a + 1])) { + insp_fmt_fence(o, "%o of objects renders hidden properties, which have no lowering; use %O"); + break; + } + piece = ops->inspect(args[++a], o, 4); + break; + case 'c': + a += 1; + piece = scr_str_new("", 0); + break; + case '%': + ib_bytes(&out, text + last, i - last); + last = i + 1; + continue; + default: + continue; + } + if (!piece) { + free(out.data); + return NULL; + } + if (last != i - 1) ib_bytes(&out, text + last, i - 1 - last); + ib_str(&out, piece); + scr_str_release(piece); + last = i + 1; + } else if (next == '%') { + ib_bytes(&out, text + last, i - last); + last = i + 1; + } + } + if (last != 0) { + a++; + join = " "; + if (last < first->len) ib_bytes(&out, text + last, first->len - last); + } + } + for (; a < argc; a++) { + ib_cstr(&out, join); + if (args[a]->kind == SCR_DYN_STR) { + ib_str(&out, args[a]->v.str); + } else { + ScrStr *piece = ops->inspect(args[a], o, o->depth); + if (!piece) { + free(out.data); + return NULL; + } + ib_str(&out, piece); + scr_str_release(piece); + } + join = " "; + } + return ib_take(&out); +} + + +static ScrStr *insp_fmt_inspect_full(ScrDyn *value, const ScrInspFormatOptions *o, double depth) { + return insp_fmt_inspect(value, o, depth, o->compact, o->colors); +} + +/* The conversion tables are filled where they run: a constant table is a + * global, and sanitized builds keep instrumented globals (and the + * conversions they point at) through --gc-sections. */ +ScrStr *scr_insp_format(ScrDyn *const *args, size_t argc, const ScrInspFormatOptions *o) { + InspFmtOps ops; + ops.s = insp_fmt_s; + ops.j = scr_util_format_j; + ops.number = insp_fmt_number; + ops.inspect = insp_fmt_inspect_full; + return insp_format_loop(args, argc, o, &ops); +} + +/* The formatter over arguments that are all strings, numbers, booleans, + * null or undefined (the compiler proves it from their types): the same + * rules for those kinds, without the checked-dynamic walk the other + * values need. */ +static ScrStr *insp_prim_to_string(const ScrDyn *v) { + switch (v->kind) { + case SCR_DYN_STR: return scr_str_retain(v->v.str); + case SCR_DYN_NUM: { + char buf[40]; + size_t n = scr_f64_to_str(v->v.num, buf); + return scr_str_new(buf, n); + } + case SCR_DYN_BOOL: return v->v.b ? scr_str_new("true", 4) : scr_str_new("false", 5); + case SCR_DYN_NULL: return scr_str_new("null", 4); + default: return scr_str_new("undefined", 9); + } +} + +static ScrStr *insp_prim_inspect(ScrDyn *v, const ScrInspFormatOptions *o, double depth) { + (void)o; + (void)depth; + if (v->kind == SCR_DYN_STR) return scr_insp_str(v->v.str); + if (v->kind == SCR_DYN_NUM) return scr_insp_f64(v->v.num); + return insp_prim_to_string(v); +} + +static ScrStr *insp_prim_s(ScrDyn *v, const ScrInspFormatOptions *o) { + (void)o; + if (v->kind == SCR_DYN_NUM) return scr_insp_f64(v->v.num); + return insp_prim_to_string(v); +} + +static ScrStr *insp_prim_j(const ScrDyn *v) { + if (v->kind == SCR_DYN_STR) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_json_str(&b, v->v.str); + return scr_jb_finish(&b); + } + if (v->kind == SCR_DYN_NUM && !isfinite(v->v.num)) return scr_str_new("null", 4); + return insp_prim_to_string(v); +} + +static ScrStr *insp_prim_number(ScrDyn *v, int spec) { + double number; + if (spec == 'd') { + number = v->kind == SCR_DYN_NUM ? v->v.num + : v->kind == SCR_DYN_STR ? scr_string_to_number(v->v.str) + : v->kind == SCR_DYN_BOOL ? (v->v.b ? 1 : 0) + : v->kind == SCR_DYN_NULL ? 0 + : NAN; + } else { + ScrStr *text = insp_prim_to_string(v); + number = spec == 'i' ? scr_parse_int(text, 0) : scr_parse_float(text); + scr_str_release(text); + } + return scr_insp_f64(number); +} + +/* A native list of checked values (the compiled format calls) as the + * runtime's argument pack. +1. */ +static ScrDyn *insp_list_pack(ScrArr *list) { + ScrDyn *pack = scr_dyn_new_arr(); + for (uint32_t i = 0; i < list->len; i++) scr_dyn_arr_push(pack, scr_arr_get_ref(list, i)); + return pack; +} + +ScrStr *scr_insp_format_primitives(ScrArr *list) { + ScrInspFormatOptions o; + scr_insp_format_defaults(&o, "util.format"); + ScrDyn *pack = insp_list_pack(list); + InspFmtOps ops; + ops.s = insp_prim_s; + ops.j = insp_prim_j; + ops.number = insp_prim_number; + ops.inspect = insp_prim_inspect; + ScrStr *out = insp_format_loop(pack->v.arr.items, pack->v.arr.len, &o, &ops); + scr_dyn_release(pack); + return out; +} + +/* The arguments of a dyn array pack (+1 text, or NULL pending). */ +static ScrStr *insp_fmt_pack(ScrDyn *pack, const ScrInspFormatOptions *o) { + if (pack->kind != SCR_DYN_ARR) return scr_insp_format(NULL, 0, o); + return scr_insp_format(pack->v.arr.items, pack->v.arr.len, o); +} + +ScrStr *scr_insp_format_pack(ScrDyn *pack) { + ScrInspFormatOptions o; + scr_insp_format_defaults(&o, "util.format"); + return insp_fmt_pack(pack, &o); +} + +ScrStr *scr_insp_format_list(ScrArr *list) { + ScrDyn *pack = insp_list_pack(list); + ScrStr *out = scr_insp_format_pack(pack); + scr_dyn_release(pack); + return out; +} + +/* Whether a format string can substitute: a '%' before its last character. + * A string without one formats as its arguments joined with spaces. */ +bool scr_insp_format_specifies(ScrStr *first) { + return first->len > 1 && memchr(first->data, '%', first->len - 1) != NULL; +} + +bool scr_insp_format_specifies_dyn(ScrDyn *first) { + return first->kind == SCR_DYN_STR && scr_insp_format_specifies(first->v.str); +} + +/* One own enumerable option of an inspect options object: +1 value, a + * borrowed undefined when absent, or NULL with the exception pending. */ +static ScrDyn *insp_opt(const ScrDyn *options, const char *key, bool *present) { + size_t len = strlen(key); + *present = options->kind == SCR_DYN_OBJ && scr_dyn_obj_get(options, key, len) != NULL && + scr_dyn_obj_enumerable(options, key, len); + if (!*present) return scr_dyn_retain(scr_dyn_undefined()); + return scr_dyn_obj_read(options, key, len); +} + +/* ctx.depth from an option or inspect's third argument: a number, null + * or undefined (no limit for null, undefined and NaN). */ +static bool insp_depth_option(const ScrDyn *value, ScrInspFormatOptions *o) { + if (value->kind == SCR_DYN_NUM) { + o->depth = isnan(value->v.num) ? INFINITY : value->v.num; + o->depth_kind = 0; + o->depth_number = value->v.num; + } else if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF) { + o->depth = INFINITY; + o->depth_kind = value->kind == SCR_DYN_NULL ? 1 : 2; + } else { + return false; + } + return true; +} + +/* inspect's option keys (inspectDefaultOptions and stylize) over an + * options object, as `inspect(value, options)` copies them. Options at + * their defaults, depth, colors, compact (a number or false), + * breakLength (a number) and sorted (a boolean) lower; other values of + * the known options throw the SC2020 refusal. */ +bool scr_insp_format_read_options(const ScrDyn *options, ScrInspFormatOptions *o) { + ScrDyn *view = NULL; + if (options->kind == SCR_DYN_TYPED_REF) { + view = scr_dyn_typed_ref_materialize(options); + if (scr_exc_pending()) { + scr_dyn_release(view); + return false; + } + options = view; + } + bool ok = false, present; + ScrDyn *value = NULL; +#define INSP_OPT(key) \ + do { \ + scr_dyn_release(value); \ + value = insp_opt(options, key, &present); \ + if (!value) goto done; \ + } while (0) + INSP_OPT("depth"); + if (present) { + if (!insp_depth_option(value, o)) { + insp_fmt_fence(o, "with a depth option that is not a number or null has no lowering"); + goto done; + } + } + INSP_OPT("colors"); + if (present) o->colors = scr_dyn_truthy(value); + INSP_OPT("customInspect"); + if (present) o->custom_inspect = scr_dyn_truthy(value); + INSP_OPT("compact"); + if (present) { + if (value->kind == SCR_DYN_NUM) o->compact = value->v.num < 0 ? 0 : value->v.num; + else if (value->kind == SCR_DYN_BOOL && !value->v.b) o->compact = -1; + else { + insp_fmt_fence(o, "with this compact option has no lowering (compact must be false or a number)"); + goto done; + } + } + INSP_OPT("breakLength"); + if (present) { + if (value->kind != SCR_DYN_NUM) { + insp_fmt_fence(o, "with a breakLength option that is not a number has no lowering"); + goto done; + } + o->break_length = value->v.num; + } + INSP_OPT("sorted"); + if (present) { + if (value->kind != SCR_DYN_BOOL) { + insp_fmt_fence(o, "with a sorted option that is not a boolean has no lowering"); + goto done; + } + o->sorted = value->v.b; + } + /* The options whose non-default values change renderings this runtime + * does not produce. */ + static const struct { + const char *key; + int kind; /* 0: falsy, 1: true, 2: the number 100, 3: the number 10000 */ + } fixed[] = { + {"showHidden", 0}, {"showProxy", 0}, {"maxArrayLength", 2}, {"maxStringLength", 3}, + {"getters", 0}, {"numericSeparator", 0}, {"stylize", 0}, + }; + for (size_t i = 0; i < sizeof fixed / sizeof *fixed; i++) { + INSP_OPT(fixed[i].key); + if (!present) continue; + bool at_default = fixed[i].kind == 0 ? !scr_dyn_truthy(value) + : fixed[i].kind == 1 ? value->kind == SCR_DYN_BOOL && value->v.b + : value->kind == SCR_DYN_NUM && value->v.num == (fixed[i].kind == 2 ? 100 : 10000); + /* The stylize function a custom inspect function received (options + * passed back into util.inspect) is the default for its colors. */ + if (!at_default && strcmp(fixed[i].key, "stylize") == 0 && insp_own_stylize(value, o->colors)) + at_default = true; + /* stylize is replaced by the color stylizer when colors is set. */ + if (!at_default && !(o->colors && strcmp(fixed[i].key, "stylize") == 0)) { + InspBuf b = {0}; + ib_cstr(&b, "with the "); + ib_cstr(&b, fixed[i].key); + ib_cstr(&b, " option has no lowering"); + ScrStr *what = ib_take(&b); + insp_fmt_fence(o, what->data); + scr_str_release(what); + goto done; + } + } +#undef INSP_OPT + /* Keys inspect does not read: the custom functions receive them. */ + if (!view && options->kind == SCR_DYN_OBJ) { + static const char *const known[] = { + "showHidden", "depth", "colors", "customInspect", "showProxy", + "maxArrayLength", "maxStringLength", "breakLength", "compact", "sorted", + "getters", "numericSeparator", "stylize", + }; + for (size_t i = 0; i < options->v.obj.len && !o->user_options; i++) { + const ScrDynEntry *ent = &options->v.obj.entries[i]; + if (!ent->enumerable) continue; + bool read = false; + for (size_t k = 0; k < sizeof known / sizeof *known && !read; k++) + read = ent->key_len == strlen(known[k]) && memcmp(ent->key, known[k], ent->key_len) == 0; + if (!read) o->user_options = options; + } + } + ok = true; +done: + scr_dyn_release(value); + scr_dyn_release(view); + return ok; +} + +/* util.inspect(value[, options]) and inspect(value, showHidden, depth, + * colors) over checked-dynamic arguments; `argc` is the call's argument + * count. A third argument other than undefined sets the depth and a + * fourth the colors; a boolean second argument is showHidden, and an + * object supplies the options, which apply after the legacy arguments. + * +1, or NULL with the exception pending. */ +ScrStr *scr_insp_inspect(ScrDyn *value, ScrDyn *options, ScrDyn *depth, ScrDyn *colors, + double argc) { + ScrInspFormatOptions o; + scr_insp_format_defaults(&o, "util.inspect"); + if (argc >= 3 && depth->kind != SCR_DYN_UNDEF) { + if (!insp_depth_option(depth, &o)) { + insp_fmt_fence(&o, "with a depth argument that is not a number or null has no lowering"); + return NULL; + } + } + if (argc >= 4 && colors->kind != SCR_DYN_UNDEF) o.colors = scr_dyn_truthy(colors); + if (options->kind == SCR_DYN_BOOL) { + if (options->v.b) { + insp_fmt_fence(&o, "with showHidden has no lowering"); + return NULL; + } + } else if (options->kind == SCR_DYN_OBJ || options->kind == SCR_DYN_TYPED_REF) { + if (!scr_insp_format_read_options(options, &o)) return NULL; + } + return insp_fmt_inspect(value, &o, o.depth, o.compact, o.colors); +} + +static ScrDyn *insp_inspect_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *undefined = scr_dyn_undefined(); + scr_insp_nested_begin(); + ScrStr *text = scr_insp_inspect(argc > 0 ? args[0] : undefined, argc > 1 ? args[1] : undefined, + argc > 2 ? args[2] : undefined, argc > 3 ? args[3] : undefined, + (double)argc); + scr_insp_nested_restore(); + if (!text) return NULL; + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +} + +/* util.formatWithOptions(inspectOptions, ...pack): validateObject (arrays + * pass, null and functions do not), then the options as inspect reads + * them. */ +ScrStr *scr_insp_format_with_options(ScrDyn *options, ScrDyn *pack) { + if (options->kind != SCR_DYN_OBJ && options->kind != SCR_DYN_ARR && + options->kind != SCR_DYN_TYPED_REF) { + scr_dyn_arg_type_fail("inspectOptions", "of type object", options); + return NULL; + } + ScrInspFormatOptions o; + scr_insp_format_defaults(&o, "util.formatWithOptions"); + if (!scr_insp_format_read_options(options, &o)) return NULL; + return insp_fmt_pack(pack, &o); +} + /* util.format's %o over a dyn value: util.inspect with showHidden at the * given depth. */ ScrStr *scr_insp_dyn_hidden(ScrDyn *d, double depth) { diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 8af60f0c0..aac4e6589 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -51,9 +51,58 @@ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth); ScrStr *scr_insp_bytes(ScrBytes *b, double recurse, double depth); /* format's %s / rest-args twin: dyn strings pass verbatim. */ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth); +/* util.format over checked-dynamic arguments (formatWithOptionsInternal): + * the options an inspect of each argument takes, and the API name the + * SC2020 refusals name. These entries call program code, so they answer + * NULL with the exception pending when it throws. */ +typedef struct ScrInspFormatOptions { + const char *surface; + double depth; /* Infinity for null, undefined and NaN */ + /* The depth as the options gave it, which custom inspect functions + * receive when the depth is unbounded: 0 a number (depth_number, + * Infinity or NaN), 1 null, 2 undefined. */ + int depth_kind; + double depth_number; + bool colors; + bool custom_inspect; + double break_length; + double compact; /* -1 for false */ + bool sorted; + /* The options object, when it holds keys inspect does not read: custom + * inspect functions receive them (borrowed; NULL otherwise). */ + const ScrDyn *user_options; +} ScrInspFormatOptions; +void scr_insp_format_defaults(ScrInspFormatOptions *o, const char *surface); +/* Reads inspect's options from an options object into o; false with the + * SC2020 refusal pending for values that have no lowering. */ +bool scr_insp_format_read_options(const ScrDyn *options, ScrInspFormatOptions *o); +ScrStr *scr_insp_format(ScrDyn *const *args, size_t argc, const ScrInspFormatOptions *o); +/* util.format(...pack) and util.formatWithOptions(options, ...pack) over a + * dyn array of the arguments (+1). */ +ScrStr *scr_insp_format_pack(ScrDyn *pack); +/* util.format over a native list of checked values, the compiled calls' + * held arguments (+1). */ +struct ScrArr; +ScrStr *scr_insp_format_list(struct ScrArr *list); +/* scr_insp_format_list when every argument is a string, number, boolean, + * null or undefined, without the checked-dynamic walk (+1). */ +ScrStr *scr_insp_format_primitives(struct ScrArr *list); +ScrStr *scr_insp_format_with_options(ScrDyn *options, ScrDyn *pack); +/* util.inspect(value, options, depth, colors) over checked-dynamic + * arguments with Node's legacy-argument rules; argc is the call's + * argument count (+1, or NULL with the exception pending). */ +ScrStr *scr_insp_inspect(ScrDyn *value, ScrDyn *options, ScrDyn *depth, ScrDyn *colors, + double argc); +/* Whether a format string has a '%' before its last character, the only + * strings whose substitutions can change the output. */ +bool scr_insp_format_specifies(ScrStr *first); +bool scr_insp_format_specifies_dyn(ScrDyn *first); +/* format's %s over a dyn value: scr_insp_dyn_s, except that values whose + * class defines its own toString print String(value). */ +ScrStr *scr_insp_dyn_format_s(ScrDyn *d, double depth); ScrStr *scr_insp_dyn_hidden(ScrDyn *d, double depth); /* %o: showHidden */ void scr_insp_begin(double recurse); -/* util.inspect's breakLength, compact (0 for false) and sorted for the +/* util.inspect's breakLength, compact (-1 for false) and sorted for the * renders that follow; the call site restores the defaults (80, 3, false) * afterwards. */ void scr_insp_layout(double break_length, double compact, bool sorted); @@ -80,6 +129,38 @@ ScrStr *scr_insp_end(ScrStr *base, ScrStr *b0, ScrStr *b1, double recurse, * caller's level (+1; borrows both). */ void scr_insp_nested_begin(void); ScrStr *scr_insp_nested_end(ScrStr *prefix, ScrStr *inner); +/* nested_end's restore without its text: the caller's context returns. */ +void scr_insp_nested_restore(void); +/* ctx.customInspect for the renders that follow; answers the previous + * value for the caller to restore. */ +bool scr_insp_custom_inspect(bool enabled); +/* Around a custom inspect function's call: begin gives the calls it makes + * a fresh inspect context with the default options and answers a token; + * end(token) restores the caller's, also after calls that threw. */ +double scr_insp_custom_call_begin(void); +void scr_insp_custom_call_end(double token); +/* How the unbounded depth of the renders that follow was given, for custom + * inspect functions: 0 Infinity, 1 null, 2 undefined. Answers the + * previous kind for the caller to restore. */ +double scr_insp_depth_kind(double kind); +/* A custom inspect function's string result re-indented at the current + * level (+1; borrows text). */ +ScrStr *scr_insp_custom_text(ScrStr *text); +/* The compiled [util.inspect.custom] calls: ctx.customInspect, the depth + * argument at recursion `recurse` of `depth` and the options object (+1 + * dyn values; options NULL with an exception pending), util.inspect as a + * function value (+1), whether a dyn result is the instance itself, and a + * dyn result's rendering at the instance's level (+1). */ +bool scr_insp_custom_enabled(void); +/* Registers a class's compiled custom inspect function under the type key + * of its instances' typed refs: fn(self, depth, options, inspect) answers + * the method's result (borrows both). */ +void scr_insp_register_custom(ScrStr *type_key, ScrDyn *fn); +ScrDyn *scr_insp_custom_depth(double recurse, double depth); +ScrDyn *scr_insp_custom_options(double depth); +ScrDyn *scr_insp_inspect_function(void); +bool scr_insp_custom_is_self(ScrDyn *result, ScrDyn *self); +ScrStr *scr_insp_custom_result(ScrDyn *result, double recurse, double depth); /* Mark the inspector's state before a top-level render, and return to the * mark when the render ends, normally or by an inspection refusal. */ void scr_insp_mark(void); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index fb62bc7a4..e19ea65cc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -184,6 +184,24 @@ bool scr_dyn_is_async_dispose_symbol(const ScrDyn *key) { key->v.symbol.value == scr_async_dispose_symbol->v.symbol.value; } +/* Symbol.for('nodejs.util.inspect.custom'), installed by the symbol unit + * when the program creates it: the key util.inspect reads custom inspect + * functions under. */ +static SCR_TL ScrDyn *scr_inspect_custom_symbol; + +static void scr_inspect_custom_symbol_cleanup(void) { + scr_dyn_release(scr_inspect_custom_symbol); + scr_inspect_custom_symbol = NULL; +} + +void scr_dyn_install_inspect_custom_symbol(ScrDyn *key) { + if (scr_inspect_custom_symbol) return; + scr_inspect_custom_symbol = scr_dyn_retain(key); + scr_atexit(scr_inspect_custom_symbol_cleanup); +} + +ScrDyn *scr_dyn_inspect_custom_symbol(void) { return scr_inspect_custom_symbol; } + static void scr_to_primitive_symbol_cleanup(void) { scr_dyn_release(scr_to_primitive_symbol); scr_to_primitive_symbol = NULL; @@ -6797,11 +6815,12 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { owner->props = scr_box_retain(function->v.fn.class_obj->static_data); if (!owner->props) { ScrDyn *table = scr_dyn_new_obj(); + /* V8's own-key order: length, then name. */ + scr_dyn_obj_set(table, "length", 6, scr_dyn_new_num((double)function->v.fn.arity)); const char *name = function->v.fn.name ? function->v.fn.name : ""; ScrStr *name_string = scr_str_new(name, strlen(name)); scr_dyn_obj_set(table, "name", 4, scr_dyn_new_str(name_string)); scr_str_release(name_string); - scr_dyn_obj_set(table, "length", 6, scr_dyn_new_num((double)function->v.fn.arity)); for (size_t i = 0; i < table->v.obj.len; i++) { table->v.obj.entries[i].writable = false; table->v.obj.entries[i].enumerable = false; @@ -10388,7 +10407,8 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { if (scr_dyn_error_encoding(d) && !scr_dyn_property_owner(d, "toString", 8)) return scr_dyn_error_string(d); return scr_str_new("[object Object]", 15); case SCR_DYN_HANDLE: - if (d->v.handle.tag == SCR_DYNH_REGEXP || d->v.handle.tag == SCR_DYNH_URL || d->v.handle.tag == SCR_DYNH_DATE) { + if (d->v.handle.tag == SCR_DYNH_REGEXP || d->v.handle.tag == SCR_DYNH_URL || d->v.handle.tag == SCR_DYNH_DATE || + d->v.handle.tag == SCR_DYNH_MIME_TYPE || d->v.handle.tag == SCR_DYNH_MIME_PARAMS) { ScrDyn *text = scr_dyn_handle_ops_of(d)->invoke(d->v.handle.ptr, (ScrDyn *)d, "toString", NULL, 0, "RegExp.toString"); if (!text) return NULL; ScrStr *out = scr_str_retain(text->v.str); @@ -13823,7 +13843,9 @@ static ScrDyn *scr_json_to_json_at(ScrDyn *value, const ScrStr *key, size_t inde if (scr_exc_pending()) { scr_dyn_release(view); scr_dyn_release(value); return NULL; } if (inspected->kind == SCR_DYN_HANDLE && (inspected->v.handle.tag == SCR_DYNH_DATE || inspected->v.handle.tag == SCR_DYNH_URL || inspected->v.handle.tag == SCR_DYNH_NET_SOCKET_ADDRESS || - inspected->v.handle.tag == SCR_DYNH_NET_BLOCK_LIST)) { + inspected->v.handle.tag == SCR_DYNH_NET_BLOCK_LIST || + inspected->v.handle.tag == SCR_DYNH_MIME_TYPE || + inspected->v.handle.tag == SCR_DYNH_MIME_PARAMS)) { const ScrDynHandleOps *ops = scr_dyn_handle_ops_of(inspected); ScrDyn *converted = ops->invoke(inspected->v.handle.ptr, value, "toJSON", NULL, 0, "toJSON"); scr_dyn_release(value); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index a9fd25915..1a625cdf1 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -11588,8 +11588,8 @@ double scr_date_get_timezone_offset(double ms) { * from local fields reads those fields back. */ /* The host zone's offset (local - UTC) in ms at UTC instant `ms` (an - * integral time value within about ±8.65e15). */ -static bool scr_date_offset_at(double ms, double *out) { + * integral time value within about ±8.65e15), from the host's localtime. */ +static bool scr_date_offset_at_uncached(double ms, double *out) { long long cms = (long long)ms; double secd = (double)(cms / 1000 - (cms % 1000 < 0)); struct tm tmv; @@ -11611,6 +11611,60 @@ static bool scr_date_offset_at(double ms, double *out) { return true; } +/* Offsets at UTC midnights, for scr_date_offset_at. A day whose two + * midnights have the same offset has that offset throughout, given at most + * one transition per day (scr_date_local_to_utc already takes transitions + * to be at least a day apart). Each local setter asks for three to five + * offsets around one time value, and consecutive ones land on the same or + * neighbouring days, so most answers come from here instead of a + * localtime(3) call: glibc takes its zone lock in each and, for dates past + * the zone file's last transition, parses the zone's rule string again. A + * change of process.env.TZ starts a new generation. */ +#define SCR_DATE_MIDNIGHTS 64 +typedef struct { + long long day; + double offset; + bool valid; +} ScrDateMidnight; +typedef struct { + bool valid; + unsigned generation; + ScrDateMidnight entries[SCR_DATE_MIDNIGHTS]; +} ScrDateMidnightCache; +static SCR_TL ScrDateMidnightCache scr_date_midnight_cache; + +static bool scr_date_midnight_offset(ScrDateMidnightCache *c, long long day, double *out) { + ScrDateMidnight *e = &c->entries[(unsigned long long)day % SCR_DATE_MIDNIGHTS]; + if (e->valid && e->day == day) { + *out = e->offset; + return true; + } + if (!scr_date_offset_at_uncached((double)day * 86400000.0, out)) return false; + e->valid = true; + e->day = day; + e->offset = *out; + return true; +} + +static bool scr_date_offset_at(double ms, double *out) { + ScrDateMidnightCache *c = &scr_date_midnight_cache; + unsigned generation = scr_date_tz_generation; + if (!c->valid || c->generation != generation) { + memset(c, 0, sizeof *c); + c->valid = true; + c->generation = generation; + } + long long cms = (long long)ms; + long long day = cms / 86400000 - (cms % 86400000 < 0); + double start, end; + if (scr_date_midnight_offset(c, day, &start) && scr_date_midnight_offset(c, day + 1, &end) && + start == end) { + *out = start; + return true; + } + return scr_date_offset_at_uncached(ms, out); +} + static volatile const double scr_date_day_ms = 86400000.0; static double scr_date_local_to_utc(double t) { diff --git a/packages/runtime/src/scr_mime.c b/packages/runtime/src/scr_mime.c new file mode 100644 index 000000000..352a7d391 --- /dev/null +++ b/packages/runtime/src/scr_mime.c @@ -0,0 +1,603 @@ +/* node:util MIMEType and MIMEParams (Node's lib/internal/mime.js) as + * native handles of the checked-dynamic tree. The parser scans UTF-16 code + * units, as Node's string searches do, so error offsets match; every value + * that survives validation is ASCII (types, subtypes, parameter names) or + * Latin-1 (parameter values). MIMEParams keeps Map semantics: insertion + * order, a set of an existing name keeps its place, and a deleted entry + * leaves a gap that live iterators skip. A separate unit, linked only by + * programs that construct these classes. */ +#include "scr_runtime.h" + +#include +#include +#include + +typedef struct { + size_t rc; + ScrStr **keys; + ScrStr **values; /* NULL for a deleted entry */ + size_t len, cap; +} ScrMimeParams; + +typedef struct { + size_t rc; + ScrStr *type; + ScrStr *subtype; + ScrDyn *params; /* the MIMEParams handle box, owned */ +} ScrMimeType; + +static void scr_mime_oom(void) { scr_trap("scriptc: out of memory\n"); } + +/* ── code units ─────────────────────────────────────────────────────── */ + +typedef struct { + uint16_t *units; + size_t len; +} ScrMimeUnits; + +static ScrMimeUnits scr_mime_units(ScrStr *text) { + ScrMimeUnits out; + out.len = (size_t)scr_str_utf16_len(text); + out.units = malloc((out.len ? out.len : 1) * sizeof *out.units); + if (!out.units) scr_mime_oom(); + for (size_t i = 0; i < out.len; i++) out.units[i] = (uint16_t)scr_str_char_code_at(text, (double)i); + return out; +} + +static bool scr_mime_ws(uint16_t c) { return c == '\r' || c == '\n' || c == '\t' || c == ' '; } + +static bool scr_mime_token(uint16_t c) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true; + return c != 0 && c < 0x80 && strchr("!#$%&'*+-.^_`|~", (char)c) != NULL; +} + +static bool scr_mime_quoted(uint16_t c) { + return c == '\t' || (c >= 0x20 && c <= 0x7E) || (c >= 0x80 && c <= 0xFF); +} + +/* The index of the first unit of [from, to) outside the token set, as an + * offset from `from`, or -1. */ +static long scr_mime_invalid_token(const uint16_t *u, size_t from, size_t to) { + for (size_t i = from; i < to; i++) + if (!scr_mime_token(u[i])) return (long)(i - from); + return -1; +} + +/* A string of units that are all at most 0xFF, lowered (ASCII only) when + * `lower`. +1. */ +static ScrStr *scr_mime_latin1(const uint16_t *u, size_t from, size_t to, bool lower) { + ScrJsonBuf b; + scr_jb_init(&b); + for (size_t i = from; i < to; i++) { + uint16_t c = u[i]; + if (lower && c >= 'A' && c <= 'Z') c = (uint16_t)(c + ('a' - 'A')); + if (c < 0x80) { + scr_jb_putc(&b, (char)c); + } else { + char pair[2] = {(char)(0xC0 | (c >> 6)), (char)(0x80 | (c & 0x3F))}; + scr_jb_write(&b, pair, 2); + } + } + return scr_jb_finish(&b); +} + +/* ERR_INVALID_MIME_SYNTAX(production, str, invalidIndex). */ +static void scr_mime_syntax_fail(const char *production, const ScrStr *str, long index) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "The MIME syntax for a "); + scr_jb_puts(&b, production); + scr_jb_puts(&b, " in \""); + scr_jb_put_str(&b, str); + scr_jb_puts(&b, "\" is invalid"); + if (index != -1) { + char at[32]; + snprintf(at, sizeof at, " at %ld", index); + scr_jb_puts(&b, at); + } + ScrStr *message = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_TYPE, message->data, message->len, "ERR_INVALID_MIME_SYNTAX"); + scr_str_release(message); +} + +/* ── MIMEParams ─────────────────────────────────────────────────────── */ + +static ScrMimeParams *scr_mime_params_alloc(void) { + ScrMimeParams *p = calloc(1, sizeof *p); + if (!p) scr_mime_oom(); + p->rc = 1; + return p; +} + +static void *scr_mime_params_retain(void *h) { + ((ScrMimeParams *)h)->rc++; + return h; +} + +static void scr_mime_params_release(void *h) { + ScrMimeParams *p = h; + if (!p || --p->rc > 0) return; + for (size_t i = 0; i < p->len; i++) { + scr_str_release(p->keys[i]); + scr_str_release(p->values[i]); + } + free(p->keys); + free(p->values); + free(p); +} + +static long scr_mime_params_find(const ScrMimeParams *p, const ScrStr *key) { + for (size_t i = 0; i < p->len; i++) + if (p->values[i] && scr_str_eq((ScrStr *)p->keys[i], (ScrStr *)key)) return (long)i; + return -1; +} + +/* Map.prototype.set: an existing name keeps its place. Both borrowed. */ +static void scr_mime_params_put(ScrMimeParams *p, ScrStr *key, ScrStr *value) { + long at = scr_mime_params_find(p, key); + if (at >= 0) { + scr_str_release(p->values[at]); + p->values[at] = scr_str_retain(value); + return; + } + if (p->len == p->cap) { + p->cap = p->cap ? p->cap * 2 : 4; + p->keys = realloc(p->keys, p->cap * sizeof *p->keys); + p->values = realloc(p->values, p->cap * sizeof *p->values); + if (!p->keys || !p->values) scr_mime_oom(); + } + p->keys[p->len] = scr_str_retain(key); + p->values[p->len] = scr_str_retain(value); + p->len++; +} + +/* Node's MIMEParams#parse over the text after the subtype's ';'. */ +static void scr_mime_params_parse(ScrMimeParams *p, const uint16_t *u, size_t n) { + size_t end = n; + while (end > 0 && scr_mime_ws(u[end - 1])) end--; + size_t pos = 0; + while (pos < end) { + while (pos < n && scr_mime_ws(u[pos])) pos++; + size_t name_end = pos; + while (name_end < n && u[name_end] != ';' && u[name_end] != '=') name_end++; + size_t name_from = pos; + pos = name_end; + if (pos < end) { + uint16_t c = u[pos]; + pos++; + if (c == ';') continue; + } + if (pos >= end) break; + uint16_t *value = NULL; + size_t value_len = 0; + if (u[pos] == '"') { + pos++; + /* QUOTED_VALUE_PATTERN: escaped units, a trailing backslash (group 1) + * and the closing quote (group 2). */ + size_t i = pos; + bool trailing_backslash = false, closed = false; + while (i < n) { + if (u[i] == '\\') { + if (i + 1 == n) { + trailing_backslash = true; + i++; + break; + } + bool pair = u[i + 1] >= 0xD800 && u[i + 1] <= 0xDBFF && i + 2 < n && u[i + 2] >= 0xDC00 && u[i + 2] <= 0xDFFF; + i += pair ? 3 : 2; + } else if (u[i] != '"') { + i++; + } else { + break; + } + } + if (i < n && u[i] == '"' && !trailing_backslash) { + closed = true; + i++; + } + size_t match = i - pos; + size_t inside = trailing_backslash || closed ? match - 1 : match; + value = malloc((inside + 2) * sizeof *value); + if (!value) scr_mime_oom(); + /* removeBackslashes, then the unmatched backslash back at the end. */ + size_t k; + for (k = 0; inside > 0 && k < inside - 1; k++) { + uint16_t c = u[pos + k]; + if (c == '\\') { + k++; + value[value_len++] = u[pos + k]; + } else { + value[value_len++] = c; + } + } + if (inside > 0 && k == inside - 1) value[value_len++] = u[pos + k]; + if (trailing_backslash) value[value_len++] = '\\'; + pos += match; + } else { + size_t value_end = pos; + while (value_end < n && u[value_end] != ';') value_end++; + size_t trimmed = value_end; + while (trimmed > pos && scr_mime_ws(u[trimmed - 1])) trimmed--; + size_t raw = value_end - pos; + if (trimmed == pos) { + pos += raw; + continue; + } + value_len = trimmed - pos; + value = malloc(value_len * sizeof *value); + if (!value) scr_mime_oom(); + memcpy(value, u + pos, value_len * sizeof *value); + pos += raw; + } + bool valid_name = name_end > name_from && scr_mime_invalid_token(u, name_from, name_end) == -1; + bool valid_value = true; + for (size_t i = 0; i < value_len && valid_value; i++) valid_value = scr_mime_quoted(value[i]); + if (valid_name && valid_value) { + ScrStr *key = scr_mime_latin1(u, name_from, name_end, true); + if (scr_mime_params_find(p, key) < 0) { + ScrStr *text = scr_mime_latin1(value, 0, value_len, false); + scr_mime_params_put(p, key, text); + scr_str_release(text); + } + scr_str_release(key); + } + free(value); + pos++; + } +} + +/* The `encode` step of MIMEParams#toString. */ +static void scr_mime_encode_value(ScrJsonBuf *b, const ScrStr *value) { + if (value->len == 0) { + scr_jb_puts(b, "\"\""); + return; + } + bool plain = true; + for (size_t i = 0; i < value->len && plain; i++) { + unsigned char c = (unsigned char)value->data[i]; + plain = c < 0x80 && scr_mime_token(c); + } + if (plain) { + scr_jb_put_str(b, value); + return; + } + scr_jb_putc(b, '"'); + for (size_t i = 0; i < value->len; i++) { + char c = value->data[i]; + if (c == '"' || c == '\\') scr_jb_putc(b, '\\'); + scr_jb_putc(b, c); + } + scr_jb_putc(b, '"'); +} + +static ScrStr *scr_mime_params_to_string(const ScrMimeParams *p) { + ScrJsonBuf b; + scr_jb_init(&b); + bool first = true; + for (size_t i = 0; i < p->len; i++) { + if (!p->values[i]) continue; + if (!first) scr_jb_putc(&b, ';'); + first = false; + scr_jb_put_str(&b, p->keys[i]); + scr_jb_putc(&b, '='); + scr_mime_encode_value(&b, p->values[i]); + } + return scr_jb_finish(&b); +} + +/* ToString(name), lowered: `toASCIILower(`${name}`)`. +1 or NULL pending. */ +static ScrStr *scr_mime_name_arg(ScrDyn *const *args, size_t argc) { + ScrStr *raw = scr_dyn_string_coerce_js(argc ? args[0] : scr_dyn_undefined()); + if (!raw) return NULL; + ScrJsonBuf b; + scr_jb_init(&b); + for (size_t i = 0; i < raw->len; i++) { + char c = raw->data[i]; + scr_jb_putc(&b, c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : c); + } + scr_str_release(raw); + return scr_jb_finish(&b); +} + +static ScrDyn *scr_mime_not_a_function(const char *what) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, what); + scr_jb_puts(&b, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return NULL; +} + +static ScrDyn *scr_mime_params_step(void *h, double *index, unsigned selection) { + ScrMimeParams *p = h; + size_t i = (size_t)*index; + while (i < p->len && !p->values[i]) i++; + bool done = i >= p->len; + ScrDyn *value; + if (done) { + value = scr_dyn_retain(scr_dyn_undefined()); + *index = (double)p->len; + } else { + if (selection == 2) { + value = scr_dyn_new_arr(); + scr_dyn_arr_push(value, scr_dyn_new_str(p->keys[i])); + scr_dyn_arr_push(value, scr_dyn_new_str(p->values[i])); + } else { + value = scr_dyn_new_str(selection == 1 ? p->keys[i] : p->values[i]); + } + *index = (double)(i + 1); + } + ScrDyn *result = scr_dyn_new_obj(); + scr_dyn_obj_set(result, "value", 5, value); + scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(done)); + return result; +} + +static ScrDyn *scr_mime_params_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + ScrMimeParams *p = h; + if (!strcmp(method, "entries") || !strcmp(method, "keys") || !strcmp(method, "values")) + return scr_dyn_native_handle_iterator(self, !strcmp(method, "entries") ? 2 : !strcmp(method, "keys") ? 1 : 0); + if (!strcmp(method, "toString") || !strcmp(method, "toJSON")) { + ScrStr *text = scr_mime_params_to_string(p); + ScrDyn *result = scr_dyn_new_str(text); + scr_str_release(text); + return result; + } + bool get = !strcmp(method, "get"), has = !strcmp(method, "has"), del = !strcmp(method, "delete"); + bool set = !strcmp(method, "set"); + if (!get && !has && !del && !set) return scr_mime_not_a_function(what); + ScrStr *name = scr_mime_name_arg(args, argc); + if (!name) return NULL; + ScrDyn *result = NULL; + long at = scr_mime_params_find(p, name); + if (get) { + result = at >= 0 ? scr_dyn_new_str(p->values[at]) : scr_dyn_new_null(); + } else if (has) { + result = scr_dyn_new_bool(at >= 0); + } else if (del) { + if (at >= 0) { + scr_str_release(p->values[at]); + p->values[at] = NULL; + } + result = scr_dyn_retain(scr_dyn_undefined()); + } else { + ScrStr *value = scr_dyn_string_coerce_js(argc > 1 ? args[1] : scr_dyn_undefined()); + if (value) { + ScrMimeUnits nu = scr_mime_units(name); + long bad_name = nu.len == 0 ? -1 : scr_mime_invalid_token(nu.units, 0, nu.len); + free(nu.units); + ScrMimeUnits vu = scr_mime_units(value); + long bad_value = -1; + for (size_t i = 0; i < vu.len && bad_value == -1; i++) + if (!scr_mime_quoted(vu.units[i])) bad_value = (long)i; + free(vu.units); + if (name->len == 0 || bad_name != -1) { + scr_mime_syntax_fail("parameter name", name, bad_name); + } else if (bad_value != -1) { + scr_mime_syntax_fail("parameter value", value, bad_value); + } else { + scr_mime_params_put(p, name, value); + result = scr_dyn_retain(scr_dyn_undefined()); + } + scr_str_release(value); + } + } + scr_str_release(name); + return result; +} + +static ScrDyn *scr_mime_empty_view(void *h) { + (void)h; + return scr_dyn_new_obj(); +} + +/* A method read as a value (`params.get` without a call) has no dyn + * function value here: it throws the "not supported yet" Error; other + * names answer undefined. */ +static ScrDyn *scr_mime_member_get(const char *cls, const char *const *methods, const char *key, size_t len) { + for (size_t i = 0; methods[i]; i++) { + if (strlen(methods[i]) != len || memcmp(methods[i], key, len) != 0) continue; + char message[128]; + int n = snprintf(message, sizeof message, "'%s.prototype.%s' as a value is not supported yet", cls, methods[i]); + scr_throw_error_msg(SCR_ERR_ERROR, message, (size_t)n); + return NULL; + } + return NULL; +} + +static ScrDyn *scr_mime_params_get(void *h, const char *key, size_t len) { + (void)h; + static const char *const methods[] = {"delete", "entries", "get", "has", "keys", "set", "values", "toString", "toJSON", NULL}; + return scr_mime_member_get("MIMEParams", methods, key, len); +} + +static ScrDyn *scr_mime_params_box(ScrMimeParams *p) { + static const ScrDynHandleOps ops = { + .cls = "MIMEParams", + .retain = scr_mime_params_retain, + .release = scr_mime_params_release, + .invoke = scr_mime_params_invoke, + .get = scr_mime_params_get, + .iter_step = scr_mime_params_step, + .inspect_view = scr_mime_empty_view, + .inspect_view_empty = true, + }; + scr_dyn_handle_install(SCR_DYNH_MIME_PARAMS, &ops); + ScrDyn *box = scr_dyn_new_handle(p, SCR_DYNH_MIME_PARAMS); + scr_mime_params_release(p); + return box; +} + +/* new util.MIMEParams(): an empty parameter map. +1. */ +ScrDyn *scr_mime_params_new(void) { return scr_mime_params_box(scr_mime_params_alloc()); } + +/* ── MIMEType ───────────────────────────────────────────────────────── */ + +static void *scr_mime_type_retain(void *h) { + ((ScrMimeType *)h)->rc++; + return h; +} + +static void scr_mime_type_release(void *h) { + ScrMimeType *t = h; + if (!t || --t->rc > 0) return; + scr_str_release(t->type); + scr_str_release(t->subtype); + scr_dyn_release(t->params); + free(t); +} + +/* The setters' validation: a non-empty token, lowered. +1 or NULL pending. */ +static ScrStr *scr_mime_part(const char *production, const ScrDyn *value) { + ScrStr *text = scr_dyn_string_coerce_js(value); + if (!text) return NULL; + ScrMimeUnits u = scr_mime_units(text); + long bad = scr_mime_invalid_token(u.units, 0, u.len); + ScrStr *out = NULL; + if (u.len == 0 || bad != -1) scr_mime_syntax_fail(production, text, bad); + else out = scr_mime_latin1(u.units, 0, u.len, true); + free(u.units); + scr_str_release(text); + return out; +} + +static ScrStr *scr_mime_type_to_string(const ScrMimeType *t) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_str(&b, t->type); + scr_jb_putc(&b, '/'); + scr_jb_put_str(&b, t->subtype); + ScrStr *params = scr_mime_params_to_string(t->params->v.handle.ptr); + if (params->len) { + scr_jb_putc(&b, ';'); + scr_jb_put_str(&b, params); + } + scr_str_release(params); + return scr_jb_finish(&b); +} + +static ScrDyn *scr_mime_type_get(void *h, const char *key, size_t len) { + ScrMimeType *t = h; + if (len == 4 && !memcmp(key, "type", 4)) return scr_dyn_new_str(t->type); + if (len == 7 && !memcmp(key, "subtype", 7)) return scr_dyn_new_str(t->subtype); + if (len == 6 && !memcmp(key, "params", 6)) return scr_dyn_retain(t->params); + if (len == 7 && !memcmp(key, "essence", 7)) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_str(&b, t->type); + scr_jb_putc(&b, '/'); + scr_jb_put_str(&b, t->subtype); + ScrStr *text = scr_jb_finish(&b); + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; + } + static const char *const methods[] = {"toString", "toJSON", NULL}; + return scr_mime_member_get("MIMEType", methods, key, len); +} + +static bool scr_mime_type_set(void *h, const char *key, size_t len, const ScrDyn *value) { + ScrMimeType *t = h; + bool type = len == 4 && !memcmp(key, "type", 4), subtype = len == 7 && !memcmp(key, "subtype", 7); + if (!type && !subtype) return false; + ScrStr *part = scr_mime_part(type ? "type" : "subtype", value); + if (!part) return true; + ScrStr **slot = type ? &t->type : &t->subtype; + scr_str_release(*slot); + *slot = part; + return true; +} + +static ScrDyn *scr_mime_type_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + (void)self; (void)args; (void)argc; + if (strcmp(method, "toString") && strcmp(method, "toJSON")) return scr_mime_not_a_function(what); + ScrStr *text = scr_mime_type_to_string(h); + ScrDyn *result = scr_dyn_new_str(text); + scr_str_release(text); + return result; +} + +/* new MIMEType(string, noThrow) and MIMEType.parse(string): NULL with the + * error pending, or (parse) NULL with nothing pending for invalid input. */ +static ScrDyn *scr_mime_type_make(const ScrDyn *input, bool no_throw) { + ScrStr *text = scr_dyn_string_coerce_js(input); + if (!text) return NULL; + ScrMimeUnits u = scr_mime_units(text); + size_t n = u.len, pos = 0; + while (pos < n && scr_mime_ws(u.units[pos])) pos++; + size_t type_end = pos; + while (type_end < n && u.units[type_end] != '/') type_end++; + bool slash = type_end < n; + long bad_type = scr_mime_invalid_token(u.units, pos, type_end); + ScrDyn *result = NULL; + if (type_end == pos || bad_type != -1 || !slash) { + if (!no_throw) scr_mime_syntax_fail("type", text, bad_type); + goto done; + } + size_t type_from = pos; + pos = type_end + 1; + size_t sub_end = pos; + while (sub_end < n && u.units[sub_end] != ';') sub_end++; + size_t sub_from = pos; + size_t trimmed = sub_end; + while (trimmed > sub_from && scr_mime_ws(u.units[trimmed - 1])) trimmed--; + pos = sub_end < n ? sub_end + 1 : sub_end; + long bad_sub = scr_mime_invalid_token(u.units, sub_from, trimmed); + if (trimmed == sub_from || bad_sub != -1) { + if (!no_throw) scr_mime_syntax_fail("subtype", text, bad_sub); + goto done; + } + ScrMimeType *t = calloc(1, sizeof *t); + if (!t) scr_mime_oom(); + t->rc = 1; + t->type = scr_mime_latin1(u.units, type_from, type_end, true); + t->subtype = scr_mime_latin1(u.units, sub_from, trimmed, true); + ScrMimeParams *p = scr_mime_params_alloc(); + scr_mime_params_parse(p, u.units + pos, n - pos); + t->params = scr_mime_params_box(p); + static const ScrDynHandleOps ops = { + .cls = "MIMEType", + .retain = scr_mime_type_retain, + .release = scr_mime_type_release, + .invoke = scr_mime_type_invoke, + .get = scr_mime_type_get, + .set = scr_mime_type_set, + .inspect_view = scr_mime_empty_view, + .inspect_view_empty = true, + }; + scr_dyn_handle_install(SCR_DYNH_MIME_TYPE, &ops); + result = scr_dyn_new_handle(t, SCR_DYNH_MIME_TYPE); + scr_mime_type_release(t); +done: + free(u.units); + scr_str_release(text); + return result; +} + +/* new util.MIMEType(string[, noThrowSymbol]): a second argument that is not + * null or undefined throws ERR_ILLEGAL_CONSTRUCTOR (Node's internal + * kNoThrow symbol is unreachable from programs). +1 or NULL pending. */ +ScrDyn *scr_mime_type_new(ScrDyn *input, ScrDyn *no_throw) { + if (no_throw->kind != SCR_DYN_UNDEF && no_throw->kind != SCR_DYN_NULL) { + static const char message[] = "Illegal constructor"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_ILLEGAL_CONSTRUCTOR"); + return NULL; + } + return scr_mime_type_make(input, false); +} + +/* MIMEType.parse(string): the MIMEType, or null for invalid input. +1, or + * NULL with a conversion error pending. */ +ScrDyn *scr_mime_type_parse(ScrDyn *input) { + ScrDyn *result = scr_mime_type_make(input, true); + if (result || scr_exc_pending()) return result; + return scr_dyn_new_null(); +} + +/* `value instanceof MIMEType` (kind 0) or `MIMEParams` (1). */ +bool scr_mime_is(const ScrDyn *value, double kind) { + return value->kind == SCR_DYN_HANDLE && + value->v.handle.tag == (kind == 0 ? SCR_DYNH_MIME_TYPE : SCR_DYNH_MIME_PARAMS); +} diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index 8fa68738d..8bc476045 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -1701,3 +1701,33 @@ ScrDyn *scr_fs_constants_object(void) { scr_atexit(scr_fs_constants_clear); return scr_dyn_retain(o); } + +/* util.convertProcessSignalToExitCode(signalCode): 128 plus the number of + * the os.constants.signals entry, and validateOneOf's + * ERR_INVALID_ARG_VALUE, listing every name, for anything else. NaN with + * the exception pending. */ +double scr_util_signal_exit_code(const ScrDyn *code) { + ScrDyn *signals = scr_dyn_new_obj(); + scr_os_signal_constants(signals); + const ScrDyn *number = + code->kind == SCR_DYN_STR ? scr_dyn_obj_get(signals, code->v.str->data, code->v.str->len) : NULL; + double result = NAN; + if (number && number->kind == SCR_DYN_NUM) { + result = 128 + number->v.num; + } else { + ScrJsonBuf reason; + scr_jb_init(&reason); + scr_jb_puts(&reason, "must be one of: "); + for (size_t i = 0; i < signals->v.obj.len; i++) { + if (i > 0) scr_jb_puts(&reason, ", "); + scr_jb_putc(&reason, '\''); + scr_jb_write(&reason, signals->v.obj.entries[i].key, signals->v.obj.entries[i].key_len); + scr_jb_putc(&reason, '\''); + } + ScrStr *text = scr_jb_finish(&reason); + scr_dyn_arg_value_fail("signalCode", text->data, code); + scr_str_release(text); + } + scr_dyn_release(signals); + return result; +} diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index 57f219ad1..41bee5a8f 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -23,6 +23,9 @@ ScrStr *scr_os_hostname(void); ScrStr *scr_os_endianness(void); double scr_os_freemem(void); double scr_os_uptime(void); +/* util.convertProcessSignalToExitCode: 128 plus the signal number, or NaN + * with ERR_INVALID_ARG_VALUE pending (scr_os.c). */ +double scr_util_signal_exit_code(const struct ScrDyn *code); ScrArr *scr_os_loadavg(void); /* os.getPriority(pid): validateInt32, then the scheduling priority; * failures throw Node's RangeError or SystemError (ERR_SYSTEM_ERROR). */ diff --git a/packages/runtime/src/scr_qs.h b/packages/runtime/src/scr_qs.h index 2232d2d48..1548bfb72 100644 --- a/packages/runtime/src/scr_qs.h +++ b/packages/runtime/src/scr_qs.h @@ -66,6 +66,24 @@ void scr_util_inherits_prototype(struct ScrDyn *ctor, struct ScrDyn *super_ctor, struct ScrDyn *super_prototype); struct ScrDyn *scr_util_deprecate(struct ScrDyn *fn, struct ScrDyn *msg, struct ScrDyn *code, struct ScrDyn *options); +/* util.isArray and util._extend, with Node's DEP0044 and DEP0060 on the + * first call (scr_util_callable.c). _extend answers target +1. */ +bool scr_util_is_array_deprecated(const struct ScrDyn *value); +struct ScrDyn *scr_util_extend(struct ScrDyn *target, struct ScrDyn *source); +/* util.diff(actual, expected): Node's operation list (+1), or NULL with + * ERR_INVALID_ARG_TYPE pending. */ +struct ScrDyn *scr_util_diff(struct ScrDyn *actual, struct ScrDyn *expected); +/* util.MIMEType and util.MIMEParams (scr_mime.c): +1 handles, or NULL + * with the error pending; parse answers null for invalid input. */ +struct ScrDyn *scr_mime_type_new(struct ScrDyn *input, struct ScrDyn *no_throw); +struct ScrDyn *scr_mime_type_parse(struct ScrDyn *input); +struct ScrDyn *scr_mime_params_new(void); +bool scr_mime_is(const struct ScrDyn *value, double kind); +/* util.debounce(fn, wait, options) and util.throttle(fn, limit, interval, + * options) (scr_util_timing.c): the wrapping function (+1), or NULL with + * the validation error pending. */ +struct ScrDyn *scr_util_debounce(struct ScrDyn *fn, struct ScrDyn *wait, struct ScrDyn *options); +struct ScrDyn *scr_util_throttle(struct ScrDyn *fn, struct ScrDyn *limit, struct ScrDyn *interval, struct ScrDyn *options); struct ScrDyn *scr_util_callbackify(struct ScrDyn *original); struct ScrDyn *scr_util_promisify(struct ScrDyn *original); struct ScrDyn *scr_util_debuglog(struct ScrDyn *section, struct ScrDyn *callback); diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 2a98e92c6..37f94bd65 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -2418,6 +2418,57 @@ static void *scr_normalize_realloc(void *opaque, void *ptr, size_t size) { return realloc(ptr, size); } +/* unicode_normalize, except that U+11A7 never composes. libunicode composes + * an LV syllable with U+11A7 as a trailing consonant (those are U+11A8 to + * U+11C2), which drops it: "\uAC00\u11A7" became "\uAC00". U+11A7 is a + * class-0 vowel that composes with nothing, so for the composing forms the + * code points between occurrences of it normalize on their own and it is + * kept as it is, as in Node. */ +static int scr_unicode_normalize(uint32_t **pdst, const uint32_t *src, int len, + UnicodeNormalizationEnum mode) { + int at = 0; + if (mode == UNICODE_NFC || mode == UNICODE_NFKC) + while (at < len && src[at] != 0x11A7) at++; + else + at = len; + if (at == len) return unicode_normalize(pdst, src, len, mode, NULL, scr_normalize_realloc); + uint32_t *out = NULL; + size_t count = 0, cap = 0; + int start = 0; + for (int i = 0; i <= len; i++) { + if (i < len && src[i] != 0x11A7) continue; + uint32_t *part = NULL; + int n = 0; + if (i > start) { + n = unicode_normalize(&part, src + start, i - start, mode, NULL, scr_normalize_realloc); + if (n < 0) { + free(out); + return -1; + } + } + size_t need = count + (size_t)n + 1; + if (need > cap) { + size_t grown = cap ? cap * 2 : 16; + if (grown < need) grown = need; + uint32_t *bigger = realloc(out, grown * sizeof *out); + if (!bigger) { + free(part); + free(out); + return -1; + } + out = bigger; + cap = grown; + } + if (n > 0) memcpy(out + count, part, (size_t)n * sizeof *out); + count += (size_t)n; + free(part); + if (i < len) out[count++] = 0x11A7; + start = i + 1; + } + *pdst = out; + return (int)count; +} + ScrStr *scr_str_normalize(const ScrStr *s, const ScrStr *form) { UnicodeNormalizationEnum mode; if (form->len == 3 && !memcmp(form->data, "NFC", 3)) mode = UNICODE_NFC; @@ -2442,7 +2493,7 @@ ScrStr *scr_str_normalize(const ScrStr *s, const ScrStr *form) { offset += step; } uint32_t *output = NULL; - int count = unicode_normalize(&output, input, length, mode, NULL, scr_normalize_realloc); + int count = scr_unicode_normalize(&output, input, length, mode); free(input); if (count < 0) scr_regex_oom(); ScrJsonBuf buffer; @@ -2455,7 +2506,7 @@ ScrStr *scr_str_normalize(const ScrStr *s, const ScrStr *form) { uint32_t *scr_nfc_code_points(const uint32_t *input, size_t length, size_t *out_len) { if (length > INT32_MAX) scr_regex_oom(); uint32_t *output = NULL; - int count = unicode_normalize(&output, input, (int)length, UNICODE_NFC, NULL, scr_normalize_realloc); + int count = scr_unicode_normalize(&output, input, (int)length, UNICODE_NFC); if (count < 0) scr_regex_oom(); *out_len = (size_t)count; return output; diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index e855a5fb4..7e573fc28 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -150,6 +150,11 @@ ScrSym *scr_sym_for(ScrStr *key) { s->reg_key = scr_str_retain(key); s->reg_next = g_sym_registry; g_sym_registry = scr_sym_retain(s); + if (key->len == 26 && !memcmp(key->data, "nodejs.util.inspect.custom", 26)) { + ScrDyn *custom = scr_dyn_new_symbol(s); + scr_dyn_install_inspect_custom_symbol(custom); + scr_dyn_release(custom); + } return s; } diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c index 60de92121..17076b078 100644 --- a/packages/runtime/src/scr_util_callable.c +++ b/packages/runtime/src/scr_util_callable.c @@ -9,8 +9,8 @@ * and reported on the next tick (scr_warning_emit_deferred), which also * applies process.noDeprecation and process.throwDeprecation. debuglog * matches sections against NODE_DEBUG as the process started and writes - * Node's "SECTION PID: message" lines to stderr. All arguments are - * borrowed. */ + * Node's "SECTION PID: message" lines, formatted by scr_insp_format, to + * stderr. All arguments are borrowed. */ #include "scr_runtime.h" #include @@ -365,6 +365,66 @@ ScrDyn *scr_util_deprecate(ScrDyn *fn, ScrDyn *msg, ScrDyn *code, ScrDyn *option return deprecated; } +/* ── util.isArray and util._extend ───────────────────────────────────── + * Node wraps both with its internal deprecate(): the first call while + * deprecation warnings are on reports DEP0044 or DEP0060 on the next tick, + * unless a util.deprecate wrapper already reported that code. */ +static SCR_TL bool uc_is_array_warned = false; +static SCR_TL bool uc_extend_warned = false; + +static bool uc_internal_deprecation(bool *warned, const char *message, const char *code) { + if (scr_process_deprecation_flag_on(0) || *warned) return true; + *warned = true; + if (!uc_codes_warned) { + uc_codes_warned = scr_dyn_new_obj_null_proto(); + scr_atexit(uc_codes_warned_teardown); + } + size_t code_len = strlen(code); + if (scr_dyn_obj_get(uc_codes_warned, code, code_len)) return true; + ScrStr *text = scr_str_new(message, strlen(message)); + ScrDyn *msg = scr_dyn_new_str(text); + scr_str_release(text); + ScrStr *key = scr_str_new(code, code_len); + bool ok = uc_emit(msg, "DeprecationWarning", key); + if (ok) scr_dyn_obj_set(uc_codes_warned, code, code_len, scr_dyn_new_bool(true)); + scr_str_release(key); + scr_dyn_release(msg); + return ok; +} + +bool scr_util_is_array_deprecated(const ScrDyn *value) { + if (!uc_internal_deprecation(&uc_is_array_warned, + "The `util.isArray` API is deprecated. Please use `Array.isArray()` instead.", + "DEP0044")) + return false; + return value->kind == SCR_DYN_ARR || scr_dyn_ref_is_array(value); +} + +/* util._extend(target, source): source's own enumerable string keys, + * assigned to target from the last key to the first; a source that is + * null or not an object leaves target as it is. +1. */ +ScrDyn *scr_util_extend(ScrDyn *target, ScrDyn *source) { + if (!uc_internal_deprecation(&uc_extend_warned, + "The `util._extend` API is deprecated. Please use Object.assign() instead.", + "DEP0060")) + return NULL; + bool object = source->kind == SCR_DYN_OBJ || source->kind == SCR_DYN_ARR || source->kind == SCR_DYN_TYPED_REF || + source->kind == SCR_DYN_HANDLE || source->kind == SCR_DYN_BYTES || source->kind == SCR_DYN_PROMISE || + source->kind == SCR_DYN_PROXY; + if (!object) return scr_dyn_retain(target); + ScrDyn *keys = scr_dyn_obj_keys(source); + if (!keys) return NULL; + for (size_t i = keys->v.arr.len; i-- > 0 && !scr_exc_pending();) { + ScrDyn *key = keys->v.arr.items[i]; + ScrDyn *value = scr_dyn_reflect_get(source, key, source); + if (!value) break; + scr_dyn_key_set(target, key->v.str, value); + scr_dyn_release(value); + } + scr_dyn_release(keys); + return scr_exc_pending() ? NULL : scr_dyn_retain(target); +} + /* ── util.callbackify ────────────────────────────────────────────────── * The wrapper pops the callback, binds it to the call's receiver and * calls original; the promise's settlement schedules the callback on the @@ -533,163 +593,6 @@ ScrDyn *scr_util_callbackify(ScrDyn *original) { return callbackified; } -/* ── util.format over dyn arguments ──────────────────────────────────── - * formatWithOptionsInternal for debuglog's lines: the %-substitutions - * (%s %d %i %f %j %o %O %c %%) with Node's argument cursor, then the - * remaining arguments space-joined, strings verbatim and everything else - * inspected. With terminal colors, primitives take inspect's default - * styles; colored object renderings have no lowering. */ -static bool uc_primitive(const ScrDyn *value) { - return !uc_object(value); -} - -static ScrStr *uc_inspect(ScrDyn *value, double depth, bool colors) { - if (!colors) return scr_insp_dyn(value, 0, depth); - if (!uc_primitive(value)) { - uc_fence("util.debuglog output of objects on a color terminal has no lowering"); - return NULL; - } - const char *open = "\x1b[33m", *close = "\x1b[39m"; - if (value->kind == SCR_DYN_UNDEF) open = "\x1b[90m"; - else if (value->kind == SCR_DYN_NULL) open = "\x1b[1m", close = "\x1b[22m"; - else if (value->kind == SCR_DYN_STR || value->kind == SCR_DYN_SYMBOL) open = "\x1b[32m"; - ScrStr *text = scr_insp_dyn(value, 0, depth); - ScrJsonBuf b; - scr_jb_init(&b); - scr_jb_puts(&b, open); - scr_jb_put_str(&b, text); - scr_jb_puts(&b, close); - scr_str_release(text); - return scr_jb_finish(&b); -} - -/* hasBuiltInToString's negative answer for checked objects: an own or - * inherited toString the program defined. Symbol-keyed members (where - * Symbol.toPrimitive would also count) are reported as unknown. */ -static int uc_custom_to_string(const ScrDyn *value) { - for (const ScrDyn *current = value; current && current->kind == SCR_DYN_OBJ; current = current->prototype) { - if (current->symbol_properties) return -1; - const ScrDyn *member = scr_dyn_obj_get(current, "toString", 8); - if (member) return member->kind == SCR_DYN_FUNC; - } - return 0; -} - -static ScrStr *uc_format_s(ScrDyn *value) { - if (value->kind == SCR_DYN_STR) return scr_str_retain(value->v.str); - if (value->kind == SCR_DYN_NUM) return scr_insp_f64(value->v.num); - if (value->kind == SCR_DYN_FUNC) { - uc_fence("util.debuglog %s of a function prints its source text, which a compiled binary does not keep"); - return NULL; - } - if (value->kind == SCR_DYN_OBJ && scr_dyn_obj_get(value, "%error", 6) == NULL) { - int custom = uc_custom_to_string(value); - if (custom < 0) { - uc_fence("util.debuglog %s of objects with symbol-keyed members has no lowering"); - return NULL; - } - if (custom) return scr_dyn_string_coerce_js(value); - } - return scr_insp_dyn_s(value, 0); -} - -static ScrStr *uc_format_number(ScrDyn *value, int spec) { - if (value->kind == SCR_DYN_BIGINT && spec != 'f') return scr_bigint_inspect(value->v.bigint); - if (value->kind == SCR_DYN_SYMBOL) return scr_str_new("NaN", 3); - double number; - if (spec == 'd') { - if (value->kind == SCR_DYN_BIGINT) return scr_bigint_inspect(value->v.bigint); - if (!scr_dyn_number_coerce_js(value, &number)) return NULL; - } else { - ScrStr *text = scr_dyn_string_coerce_js(value); - if (!text) return NULL; - number = spec == 'i' ? scr_parse_int(text, 0) : scr_parse_float(text); - scr_str_release(text); - } - return scr_insp_f64(number); -} - -static ScrStr *uc_format(ScrDyn *const *args, size_t argc, bool colors) { - if (argc == 0) return scr_str_new("", 0); - ScrJsonBuf out; - scr_jb_init(&out); - size_t a = 0; - const char *join = ""; - if (args[0]->kind == SCR_DYN_STR) { - const ScrStr *first = args[0]->v.str; - if (argc == 1) { - scr_str_release(scr_jb_finish(&out)); - return scr_str_retain((ScrStr *)first); - } - const char *text = first->data; - size_t last = 0; - for (size_t i = 0; first->len > 0 && i < first->len - 1; i++) { - if (text[i] != '%') continue; - int next = (unsigned char)text[++i]; - if (a + 1 != argc) { - ScrStr *piece = NULL; - switch (next) { - case 's': piece = uc_format_s(args[++a]); break; - case 'j': piece = scr_util_format_j(args[++a]); break; - case 'd': - case 'i': - case 'f': piece = uc_format_number(args[++a], next); break; - case 'O': piece = uc_inspect(args[++a], 2, colors); break; - case 'o': - if (!uc_primitive(args[a + 1])) { - uc_fence("util.debuglog %o of objects renders hidden properties, which have no lowering; use %O"); - break; - } - piece = uc_inspect(args[++a], 4, colors); - break; - case 'c': - a += 1; - piece = scr_str_new("", 0); - break; - case '%': - scr_jb_write(&out, text + last, i - last); - last = i + 1; - continue; - default: - continue; - } - if (!piece) { - scr_str_release(scr_jb_finish(&out)); - return NULL; - } - if (last != i - 1) scr_jb_write(&out, text + last, i - 1 - last); - scr_jb_put_str(&out, piece); - scr_str_release(piece); - last = i + 1; - } else if (next == '%') { - scr_jb_write(&out, text + last, i - last); - last = i + 1; - } - } - if (last != 0) { - a++; - join = " "; - if (last < first->len) scr_jb_write(&out, text + last, first->len - last); - } - } - for (; a < argc; a++) { - scr_jb_puts(&out, join); - if (args[a]->kind == SCR_DYN_STR) { - scr_jb_put_str(&out, args[a]->v.str); - } else { - ScrStr *piece = uc_inspect(args[a], 2, colors); - if (!piece) { - scr_str_release(scr_jb_finish(&out)); - return NULL; - } - scr_jb_put_str(&out, piece); - scr_str_release(piece); - } - join = " "; - } - return scr_jb_finish(&out); -} - /* ── util.debuglog ───────────────────────────────────────────────────── * The logger defers its NODE_DEBUG test to its first call or `enabled` * read. The first call also selects the section's shared implementation @@ -755,7 +658,10 @@ static bool uc_debug_test(const ScrStr *section) { static ScrDyn *uc_debug_write(ScrClosure *closure, ScrDyn *const *args, size_t argc) { bool colors = scr_getenv("FORCE_COLOR") ? scr_util_terminal_colors() : scr_process_is_tty(2) && scr_util_terminal_colors(); - ScrStr *message = uc_format(args, argc, colors); + ScrInspFormatOptions options; + scr_insp_format_defaults(&options, "util.debuglog"); + options.colors = colors; + ScrStr *message = scr_insp_format(args, argc, &options); if (!message) return NULL; ScrDyn *set = uc_cap(closure, 0); char pid[64]; @@ -912,3 +818,158 @@ ScrDyn *scr_util_debuglog(ScrDyn *section, ScrDyn *callback) { } return logger; } + +/* ── util.diff(actual, expected) ─────────────────────────────────────── + * Node's diff over strings (one entry per UTF-16 code unit) or arrays of + * strings: identical values answer an empty array before validation; + * otherwise each value must be a string or an array of strings + * (ERR_INVALID_ARG_TYPE naming `actual`, `actual[i]`, ...), and the answer + * is myersDiff's operation list reversed: [0, item] for kept items, + * [1, item] for items of actual and [-1, item] for items of expected. */ +typedef struct { + ScrStr **items; + size_t len; +} UcDiffSeq; + +static void uc_diff_seq_free(UcDiffSeq *seq) { + for (size_t i = 0; i < seq->len; i++) scr_str_release(seq->items[i]); + free(seq->items); + seq->items = NULL; + seq->len = 0; +} + +static bool uc_diff_input(const ScrDyn *value, const char *name, UcDiffSeq *out) { + out->items = NULL; + out->len = 0; + if (value->kind == SCR_DYN_STR) { + ScrStr *text = value->v.str; + size_t len = (size_t)scr_str_utf16_len(text); + out->items = malloc((len ? len : 1) * sizeof *out->items); + if (!out->items) scr_trap("scriptc: out of memory\n"); + for (size_t i = 0; i < len; i++) out->items[out->len++] = scr_str_char_at(text, (double)i); + return true; + } + ScrDyn *array = value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value) + ? scr_dyn_typed_ref_materialize(value) + : value->kind == SCR_DYN_ARR ? scr_dyn_retain((ScrDyn *)value) : NULL; + if (!array) { + scr_dyn_arg_type_fail(name, "of type string", value); + return false; + } + size_t len = array->v.arr.len; + out->items = malloc((len ? len : 1) * sizeof *out->items); + if (!out->items) scr_trap("scriptc: out of memory\n"); + for (size_t i = 0; i < len; i++) { + const ScrDyn *item = array->v.arr.presence && !array->v.arr.presence[i] ? scr_dyn_undefined() + : array->v.arr.items[i]; + if (item->kind != SCR_DYN_STR) { + char element[64]; + snprintf(element, sizeof element, "%s[%zu]", name, i); + scr_dyn_arg_type_fail(element, "of type string", item); + scr_dyn_release(array); + uc_diff_seq_free(out); + return false; + } + out->items[out->len++] = scr_str_retain(item->v.str); + } + scr_dyn_release(array); + return true; +} + +static void uc_diff_push(ScrDyn *result, int op, ScrStr *item) { + ScrDyn *entry = scr_dyn_new_arr(); + scr_dyn_arr_push(entry, scr_dyn_new_num((double)op)); + scr_dyn_arr_push(entry, scr_dyn_new_str(item)); + scr_dyn_arr_push(result, entry); +} + +ScrDyn *scr_util_diff(ScrDyn *actual, ScrDyn *expected) { + if (scr_dyn_strict_eq(actual, expected)) return scr_dyn_new_arr(); + UcDiffSeq a, e; + if (!uc_diff_input(actual, "actual", &a)) return NULL; + if (!uc_diff_input(expected, "expected", &e)) { + uc_diff_seq_free(&a); + return NULL; + } + size_t max = a.len + e.len; + if (max > 2147483647u) { + uc_diff_seq_free(&a); + uc_diff_seq_free(&e); + char message[160]; + int n = snprintf(message, sizeof message, + "The value of \"myersDiff input size\" is out of range. It must be < 2^31. Received %zu", max); + scr_throw_error_msg_code(SCR_ERR_RANGE, message, (size_t)n, "ERR_OUT_OF_RANGE"); + return NULL; + } + /* Two distinct empty sequences: Node's myersDiff reads past its + * one-element state, finds no end and answers undefined, which the + * ArrayPrototypeReverse of util.diff rejects. The trace below would be read + * out of bounds. */ + if (max == 0) { + uc_diff_seq_free(&a); + uc_diff_seq_free(&e); + static const char message[] = "Cannot convert undefined or null to object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + size_t width = 2 * max + 1; + int32_t *v = calloc(width, sizeof *v); + int32_t **trace = malloc((max + 1) * sizeof *trace); + if (!v || !trace) scr_trap("scriptc: out of memory\n"); + size_t levels = 0; + bool done = false; + for (size_t level = 0; level <= max && !done; level++) { + trace[levels] = malloc(width * sizeof **trace); + if (!trace[levels]) scr_trap("scriptc: out of memory\n"); + memcpy(trace[levels], v, width * sizeof *v); + levels++; + long d = (long)level; + for (long k = -d; k <= d; k += 2) { + size_t offset = (size_t)(k + (long)max); + int32_t previous = offset > 0 ? v[offset - 1] : 0; + int32_t next = offset + 1 < width ? v[offset + 1] : 0; + long x = k == -d || (k != d && previous < next) ? next : (long)previous + 1; + long y = x - k; + while ((size_t)x < a.len && y >= 0 && (size_t)y < e.len && scr_str_eq(a.items[x], e.items[y])) { + x++; + y++; + } + v[offset] = (int32_t)x; + if ((size_t)x >= a.len && y >= 0 && (size_t)y >= e.len) { + done = true; + break; + } + } + } + /* backtrack: the operations from the end, which the answer reverses. */ + ScrDyn *reversed = scr_dyn_new_arr(); + long x = (long)a.len, y = (long)e.len; + for (long level = (long)levels - 1; level >= 0; level--) { + const int32_t *row = trace[level]; + long k = x - y; + size_t offset = (size_t)(k + (long)max); + long prev_k; + if (k == -level || (k != level && row[offset - 1] < row[offset + 1])) prev_k = k + 1; + else prev_k = k - 1; + long prev_x = row[(size_t)(prev_k + (long)max)]; + long prev_y = prev_x - prev_k; + while (x > prev_x && y > prev_y) { + uc_diff_push(reversed, 0, a.items[x - 1]); + x--; + y--; + } + if (level > 0) { + if (x > prev_x) uc_diff_push(reversed, 1, a.items[--x]); + else uc_diff_push(reversed, -1, e.items[--y]); + } + } + for (size_t i = 0; i < levels; i++) free(trace[i]); + free(trace); + free(v); + ScrDyn *result = scr_dyn_new_arr(); + for (size_t i = reversed->v.arr.len; i-- > 0;) scr_dyn_arr_push(result, scr_dyn_retain(reversed->v.arr.items[i])); + scr_dyn_release(reversed); + uc_diff_seq_free(&a); + uc_diff_seq_free(&e); + return result; +} diff --git a/packages/runtime/src/scr_util_timing.c b/packages/runtime/src/scr_util_timing.c new file mode 100644 index 000000000..13651f5ff --- /dev/null +++ b/packages/runtime/src/scr_util_timing.c @@ -0,0 +1,1062 @@ +/* node:util's debounce and throttle over the checked-dynamic tree, step + * for step with Node's lib/internal/util/debounce.js and throttle.js. + * Each returns a native dyn function (an ordinary function with its own + * prototype, fn's name and length) whose own cancel/flush/ref/unref + * members (throttle: cancel/hasImmediateCapacity/ref/unref) and + * non-enumerable pending/pendingCount (throttle: and activeCount) getters + * share one refcounted state. Calls answer dyn promises. + * + * The state is invisible to the cycle collector, so nothing it holds + * leads back to it: fn sits in a traced box on the returned function's + * closure (fn may refer to that function), the state reaches the function through + * a weak pointer that the function's closure clears when it is freed; an + * armed timer and a throttled call's settle reaction hold the function + * and the state until they run or are cleared; and the options.signal + * 'abort' listener holds a weak slot that the state clears when it is + * freed. The listener stays registered, as in Node. The clock is the + * monotonic loop clock in whole milliseconds, like libuv's uv_now. All + * arguments are borrowed. */ +#include "scr_runtime.h" + +#include +#include +#include +#include + +/* Node's TIMEOUT_MAX (2 ** 31 - 1) and Number.MAX_SAFE_INTEGER. */ +#define UT_TIMEOUT_MAX 2147483647.0 +#define UT_SAFE_MAX 9007199254740991.0 + +typedef struct UtState UtState; + +/* The abort listener's view of a state: NULL once the state is freed. */ +typedef struct { + size_t rc; + UtState *state; +} UtWeak; + +/* The returned function's closure holds its state through this token; + * freeing the closure frees the token, which clears the state's weak + * pointer to the function. */ +typedef struct { + size_t rc; + UtState *state; +} UtOwner; + +/* One pending call: its promise and, for throttle, its arguments. */ +typedef struct { + ScrPromise *promise; + ScrDyn **args; + size_t argc; +} UtCall; + +struct UtState { + size_t rc; + bool throttle; + ScrClosure *self; /* the returned function's closure (weak), or NULL */ + ScrDynThunk thunk; + ScrDyn *signal; /* NULL without options.signal */ + UtWeak *weak; /* the abort listener's slot, or NULL */ + ScrDyn *abort_error; /* NULL until the signal aborts */ + double timeout; /* the armed timer's handle; 0 for none */ + bool refed; + /* pendingCalls: the live calls are [head, len). */ + UtCall *calls; + size_t head, len, cap; + /* debounce */ + double wait; + bool leading, reject_on_cancel; + ScrDyn **args; /* the latest call's arguments */ + size_t argc; + /* throttle */ + double limit, interval, concurrency, max_pending, active; + bool drop, strict; + double *ticks; /* strictTicks: the live ticks are [tick_head, tick_len) */ + size_t tick_head, tick_len, tick_cap; + double window_count, window_start; + bool window_open; +}; + +static const char UT_SIG[] = "native:util.debounce"; + +static void *ut_oom(void) { + scr_trap("scriptc: out of memory\n"); +} + +static ScrDyn **ut_args_copy(ScrDyn *const *args, size_t argc) { + if (argc == 0) return NULL; + ScrDyn **copy = malloc(argc * sizeof *copy); + if (!copy) return ut_oom(); + for (size_t i = 0; i < argc; i++) copy[i] = scr_dyn_retain(args[i]); + return copy; +} + +static void ut_args_free(ScrDyn **args, size_t argc) { + for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); + free(args); +} + +static void *ut_weak_retain(void *p) { + ((UtWeak *)p)->rc++; + return p; +} + +static void ut_weak_release(void *p) { + UtWeak *weak = p; + if (weak && --weak->rc == 0) free(weak); +} + +static void *ut_state_retain(void *p) { + ((UtState *)p)->rc++; + return p; +} + +static void ut_state_release(void *p) { + UtState *s = p; + if (!s || --s->rc) return; + if (s->weak) { + s->weak->state = NULL; + ut_weak_release(s->weak); + } + scr_dyn_release(s->signal); + scr_dyn_release(s->abort_error); + ut_args_free(s->args, s->argc); + for (size_t i = s->head; i < s->len; i++) { + scr_promise_release(s->calls[i].promise); + ut_args_free(s->calls[i].args, s->calls[i].argc); + } + free(s->calls); + free(s->ticks); + free(s); +} + +static void *ut_owner_retain(void *p) { + ((UtOwner *)p)->rc++; + return p; +} + +static void ut_owner_release(void *p) { + UtOwner *owner = p; + if (--owner->rc) return; + owner->state->self = NULL; + ut_state_release(owner->state); + free(owner); +} + +static ScrBox *ut_state_box(UtState *s) { + ScrBox *box = scr_box_new_obj(ut_state_retain, ut_state_release, NULL); + scr_box_set_ref(box, ut_state_retain(s)); + return box; +} + +static ScrBox *ut_dyn_box(ScrDyn *value /* moves in */) { + ScrBox *box = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(box, value); + return box; +} + +/* The state behind a member, getter, timer or listener closure (+1). */ +static UtState *ut_cap_state(ScrClosure *closure) { + return scr_box_get_ref(closure->caps[0]); +} + +/* The state behind the returned function's own closure (+1). */ +static UtState *ut_owner_state(ScrClosure *closure) { + UtOwner *owner = scr_box_get_ref(closure->caps[0]); + UtState *s = ut_state_retain(owner->state); + ut_owner_release(owner); + return s; +} + +static ScrDyn *ut_box_fn(UtState *s, ScrClosure *closure) { + return scr_dyn_new_func(scr_closure_retain(closure), s->thunk, 0, UT_SIG, s->throttle ? "throttled" : "debounced"); +} + +/* The returned function (+1), or NULL once nothing else holds it. */ +static ScrDyn *ut_self(UtState *s) { + return s->self ? ut_box_fn(s, s->self) : NULL; +} + +static void ut_fence(const char *message) { + scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); +} + +static double ut_now(void) { + return floor(scr_now_ms()); +} + +/* ── promises and errors ───────────────────────────────────────────── */ + +static void ut_reject(ScrPromise *p, const ScrDyn *error) { + scr_dyn_throw(scr_dyn_retain((ScrDyn *)error)); + scr_promise_reject_pending(p); +} + +static ScrDyn *ut_promise_box(ScrPromise *p /* moves in */) { + ScrDyn *box = scr_dyn_new_promise(p); + scr_promise_release(p); + return box; +} + +/* PromiseReject(error) (+1). */ +static ScrDyn *ut_rejected(const ScrDyn *error) { + ScrPromise *p = scr_promise_new(); + ut_reject(p, error); + return ut_promise_box(p); +} + +/* new AbortError(message, { cause }) (+1); a NULL cause defines none. */ +static ScrDyn *ut_abort_error(const char *message, const ScrDyn *cause) { + ScrStr *text = scr_str_new(message, strlen(message)); + ScrError *e = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_str_release(e->name); + e->name = scr_str_new("AbortError", 10); + scr_error_set_code(e, "ABORT_ERR"); + e->name_present = true; + e->name_enumerable = true; + if (cause) { + /* The constructor's { cause } option: a non-enumerable own property. */ + scr_error_define_cause(e, (ScrDyn *)cause); + e->cause_enumerable = false; + } + ScrDyn *error = scr_dyn_from_error(e); + scr_error_release(e); + return error; +} + +/* cancel(reason)'s error: no cause for an undefined reason (+1). */ +static ScrDyn *ut_cancel_error(ScrDyn *const *args, size_t argc) { + const ScrDyn *reason = argc ? args[0] : scr_dyn_undefined(); + return ut_abort_error("The operation was aborted", reason->kind == SCR_DYN_UNDEF ? NULL : reason); +} + +/* fn.apply(self, args): the result (+1), or NULL with fn's throw pending. + * fn lives on the returned function's closure. */ +static ScrDyn *ut_apply(const ScrDyn *self, ScrDyn *const *args, size_t argc) { + ScrDyn *fn = scr_box_get_ref(scr_closure_identity(self->v.fn.clo)->caps[1]); + scr_dyn_this_push_dyn(self); + ScrDyn *result = scr_dyn_call(fn, args, argc, "fn"); + scr_dyn_this_pop(); + scr_dyn_release(fn); + return result; +} + +/* ── the signal ────────────────────────────────────────────────────── */ + +static bool ut_signal_aborted(const UtState *s) { + if (!s->signal || !scr_et_signal_core_fn) return false; + bool aborted = false; + (void)scr_et_signal_core_fn(s->signal, &aborted); + return aborted; +} + +static void ut_cancel_with_error(UtState *s, const ScrDyn *error); + +/* abort(): the first abort builds abortError from signal.reason. */ +static void ut_abort(UtState *s) { + if (!s->abort_error) { + ScrStr *key = scr_str_new("reason", 6); + ScrDyn *reason = scr_dyn_handle_key_get(s->signal, key); + scr_str_release(key); + if (!reason) return; + s->abort_error = ut_abort_error("The operation was aborted", reason->kind == SCR_DYN_UNDEF ? NULL : reason); + scr_dyn_release(reason); + } + ut_cancel_with_error(s, s->abort_error); +} + +static ScrDyn *ut_abort_listener(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtWeak *weak = scr_box_get_ref(closure->caps[0]); + UtState *s = weak->state ? ut_state_retain(weak->state) : NULL; + ut_weak_release(weak); + if (s) { + ut_abort(s); + ut_state_release(s); + } + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +/* addAbortListener(signal, abort). */ +static bool ut_listen(UtState *s) { + UtWeak *weak = malloc(sizeof *weak); + if (!weak) ut_oom(); + weak->rc = 1; + weak->state = s; + s->weak = weak; + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new_obj(ut_weak_retain, ut_weak_release, NULL); + scr_box_set_ref(closure->caps[0], ut_weak_retain(weak)); + ScrDyn *listener = scr_dyn_new_func(closure, ut_abort_listener, 0, UT_SIG, "abort"); + ScrDyn *disposable = scr_events_add_abort_listener(s->signal, listener); + scr_dyn_release(listener); + scr_dyn_release(disposable); + return !scr_exc_pending(); +} + +/* ── pending calls ─────────────────────────────────────────────────── */ + +static size_t ut_pending_count(const UtState *s) { + return s->len - s->head; +} + +static void ut_push(UtState *s, ScrPromise *promise /* moves in */, ScrDyn **args /* moves in */, size_t argc) { + /* Calls already taken free their slots, so a queue that never empties + * keeps its storage within twice its live calls. */ + if (s->len == s->cap && s->head > 0) { + s->len -= s->head; + memmove(s->calls, s->calls + s->head, s->len * sizeof *s->calls); + s->head = 0; + } + if (s->len == s->cap) { + size_t cap = s->cap ? s->cap * 2 : 4; + UtCall *grown = realloc(s->calls, cap * sizeof *grown); + if (!grown) ut_oom(); + s->calls = grown; + s->cap = cap; + } + s->calls[s->len++] = (UtCall){promise, args, argc}; +} + +/* Takes the pending calls, leaving none: *count live calls at the result + * (free it, the promises and arguments when done). */ +static UtCall *ut_take_calls(UtState *s, size_t *count) { + UtCall *calls = s->calls; + size_t head = s->head; + *count = s->len - head; + if (head) memmove(calls, calls + head, *count * sizeof *calls); + s->calls = NULL; + s->head = s->len = s->cap = 0; + return calls; +} + +static void ut_calls_free(UtCall *calls, size_t count) { + for (size_t i = 0; i < count; i++) { + scr_promise_release(calls[i].promise); + ut_args_free(calls[i].args, calls[i].argc); + } + free(calls); +} + +/* rejectPending(error). */ +static void ut_reject_pending(UtState *s, const ScrDyn *error) { + size_t count; + UtCall *calls = ut_take_calls(s, &count); + for (size_t i = 0; i < count; i++) ut_reject(calls[i].promise, error); + ut_calls_free(calls, count); +} + +static void ut_clear_timer(UtState *s) { + double timeout = s->timeout; + s->timeout = 0; + scr_clear_interval(timeout); +} + +static void ut_arm(UtState *s, const ScrDyn *self, double delay, void (*fire)(ScrClosure *)) { + ScrClosure *timer = scr_closure_new((void *)fire, 2); + timer->caps[0] = ut_state_box(s); + timer->caps[1] = ut_dyn_box(scr_dyn_retain((ScrDyn *)self)); + s->timeout = scr_set_timeout_handle(timer, delay); + if (!s->refed) scr_timer_unref(s->timeout); +} + +/* The timer's state (+1) and function (+1). */ +static UtState *ut_timer_state(ScrClosure *timer, ScrDyn **self) { + *self = scr_box_get_ref(timer->caps[1]); + return ut_cap_state(timer); +} + +/* ── throttle's limiter ────────────────────────────────────────────── */ + +static void th_reset_limiter(UtState *s) { + s->tick_head = s->tick_len = 0; + s->window_count = 0; + s->window_open = false; +} + +static void th_prune_ticks(UtState *s, double now) { + while (s->tick_head < s->tick_len && now - s->ticks[s->tick_head] >= s->interval) s->tick_head++; + if (s->tick_head == s->tick_len) s->tick_head = s->tick_len = 0; +} + +static bool th_has_capacity(UtState *s, double now) { + if (s->strict) { + th_prune_ticks(s, now); + return (double)(s->tick_len - s->tick_head) < s->limit; + } + if (!s->window_open || now - s->window_start >= s->interval) { + s->window_count = 0; + s->window_start = now; + s->window_open = true; + } + return s->window_count < s->limit; +} + +static void th_reserve(UtState *s, double now) { + if (!s->strict) { + s->window_count++; + return; + } + /* Ticks that left the window free their slots: at most limit ticks are + * live, so the storage stays within twice that. */ + if (s->tick_len == s->tick_cap && s->tick_head > 0) { + s->tick_len -= s->tick_head; + memmove(s->ticks, s->ticks + s->tick_head, s->tick_len * sizeof *s->ticks); + s->tick_head = 0; + } + if (s->tick_len == s->tick_cap) { + size_t cap = s->tick_cap ? s->tick_cap * 2 : 8; + double *grown = realloc(s->ticks, cap * sizeof *grown); + if (!grown) ut_oom(); + s->ticks = grown; + s->tick_cap = cap; + } + s->ticks[s->tick_len++] = now; +} + +static void ut_cancel_with_error(UtState *s, const ScrDyn *error) { + if (!s->throttle) { + /* debounce's cancelWithError */ + if (s->timeout == 0) return; + ut_clear_timer(s); + ut_args_free(s->args, s->argc); + s->args = NULL; + s->argc = 0; + ut_reject_pending(s, error); + return; + } + if (s->timeout != 0) ut_clear_timer(s); + th_reset_limiter(s); + ut_reject_pending(s, error); +} + +/* ── debounce ──────────────────────────────────────────────────────── */ + +/* invoke(callArgs, calls): every call settles with fn's one result. */ +static void db_invoke(UtState *s, const ScrDyn *self, ScrDyn *const *args, size_t argc, UtCall *calls, size_t count) { + if (ut_signal_aborted(s)) { + ut_abort(s); + if (!s->abort_error) return; + for (size_t i = 0; i < count; i++) ut_reject(calls[i].promise, s->abort_error); + return; + } + ScrDyn *result = ut_apply(self, args, argc); + if (!result) { + scr_promise_reject_pending(calls[0].promise); + ScrDyn *error = scr_promise_reason_dyn(calls[0].promise); + for (size_t i = 1; i < count; i++) ut_reject(calls[i].promise, error); + scr_dyn_release(error); + return; + } + for (size_t i = 0; i < count; i++) scr_promise_resolve_dyn(calls[i].promise, scr_dyn_retain(result)); + scr_dyn_release(result); +} + +static void db_invoke_pending(UtState *s, const ScrDyn *self) { + ScrDyn **args = s->args; + size_t argc = s->argc; + s->args = NULL; + s->argc = 0; + size_t count; + UtCall *calls = ut_take_calls(s, &count); + if (count) db_invoke(s, self, args, argc, calls, count); + ut_calls_free(calls, count); + ut_args_free(args, argc); +} + +static void db_on_timeout(ScrClosure *timer) { + ScrDyn *self; + UtState *s = ut_timer_state(timer, &self); + s->timeout = 0; + db_invoke_pending(s, self); + scr_dyn_release(self); + ut_state_release(s); +} + +static ScrDyn *db_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + UtState *s = ut_owner_state(closure); + ScrDyn *self = ut_box_fn(s, closure); + ScrDyn *out = NULL; + if (ut_signal_aborted(s)) ut_abort(s); + if (scr_exc_pending()) goto done; + if (s->abort_error) { + out = ut_rejected(s->abort_error); + goto done; + } + bool invoke_now = s->leading && s->timeout == 0; + if (s->timeout != 0) { + if (ut_pending_count(s) != 0) { + scr_promise_mark_handled(s->calls[s->len - 1].promise); + if (s->reject_on_cancel) { + ScrDyn *error = ut_abort_error("The debounced call was superseded", NULL); + ut_reject_pending(s, error); + scr_dyn_release(error); + } + } + scr_timer_refresh(s->timeout); + } else { + ut_arm(s, self, s->wait, db_on_timeout); + } + if (ut_signal_aborted(s)) { + ut_abort(s); + if (s->abort_error) out = ut_rejected(s->abort_error); + goto done; + } + ScrPromise *promise = scr_promise_new(); + if (invoke_now) { + UtCall call = {promise, NULL, 0}; + db_invoke(s, self, args, argc, &call, 1); + } else { + ut_push(s, scr_promise_retain(promise), NULL, 0); + ut_args_free(s->args, s->argc); + s->args = ut_args_copy(args, argc); + s->argc = argc; + } + out = ut_promise_box(promise); +done: + scr_dyn_release(self); + ut_state_release(s); + return out; +} + +static ScrDyn *db_flush(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtState *s = ut_cap_state(closure); + if (ut_pending_count(s) != 0) { + /* The armed timer holds the function until it is cleared. */ + ScrDyn *self = ut_self(s); + ut_clear_timer(s); + db_invoke_pending(s, self); + scr_dyn_release(self); + } + ut_state_release(s); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +/* ── throttle ──────────────────────────────────────────────────────── */ + +static void th_drain(UtState *s, const ScrDyn *self); + +static ScrDyn *th_on_settled(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *self; + UtState *s = ut_timer_state(closure, &self); + s->active--; + th_drain(s, self); + scr_dyn_release(self); + ut_state_release(s); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +/* invoke(call): the call adopts PromiseResolve(fn's result), and the + * active count drops when that promise settles. Moves the call in. */ +static void th_invoke(UtState *s, const ScrDyn *self, UtCall call) { + s->active++; + ScrDyn *result = ut_apply(self, call.args, call.argc); + ScrPromise *settled; + if (!result) { + settled = scr_promise_new(); + scr_promise_reject_pending(settled); + } else if (scr_dyn_promise_of(result)) { + settled = scr_promise_retain(scr_dyn_promise_of(result)); + scr_dyn_release(result); + } else { + settled = scr_promise_new(); + scr_promise_resolve_dyn(settled, result); + } + scr_promise_resolve_dyn(call.promise, scr_dyn_new_promise(settled)); + ScrClosure *reaction = scr_closure_new(NULL, 2); + reaction->caps[0] = ut_state_box(s); + reaction->caps[1] = ut_dyn_box(scr_dyn_retain((ScrDyn *)self)); + ScrDyn *on_settled = scr_dyn_new_func(reaction, th_on_settled, 0, UT_SIG, "onSettled"); + scr_dyn_release(scr_dyn_promise_then(settled, on_settled, on_settled, NULL)); + scr_dyn_release(on_settled); + scr_promise_release(settled); + scr_promise_release(call.promise); + ut_args_free(call.args, call.argc); +} + +static void th_on_timeout(ScrClosure *timer); + +static void th_schedule(UtState *s, const ScrDyn *self) { + if (s->timeout != 0 || ut_pending_count(s) == 0 || s->active >= s->concurrency) return; + double now = ut_now(); + if (th_has_capacity(s, now)) { + th_drain(s, self); + return; + } + double delay = s->strict ? s->ticks[s->tick_head] + s->interval - now : s->window_start + s->interval - now; + ut_arm(s, self, delay, th_on_timeout); +} + +static void th_drain(UtState *s, const ScrDyn *self) { + if (ut_signal_aborted(s)) { + ut_abort(s); + return; + } + while (ut_pending_count(s) != 0 && s->active < s->concurrency) { + double now = ut_now(); + if (!th_has_capacity(s, now)) break; + UtCall call = s->calls[s->head++]; + if (s->head == s->len) s->head = s->len = 0; + th_reserve(s, now); + th_invoke(s, self, call); + } + th_schedule(s, self); +} + +static void th_on_timeout(ScrClosure *timer) { + ScrDyn *self; + UtState *s = ut_timer_state(timer, &self); + s->timeout = 0; + th_drain(s, self); + scr_dyn_release(self); + ut_state_release(s); +} + +/* drop(): a handled rejection with ERR_THROTTLED (+1). */ +static ScrDyn *th_drop(void) { + static const char message[] = "The call was dropped by the throttled function"; + ScrStr *text = scr_str_new(message, sizeof message - 1); + ScrError *e = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_error_set_code(e, "ERR_THROTTLED"); + ScrDyn *error = scr_dyn_from_error(e); + scr_error_release(e); + ScrPromise *p = scr_promise_new(); + ut_reject(p, error); + scr_dyn_release(error); + scr_promise_mark_handled(p); + return ut_promise_box(p); +} + +static ScrDyn *th_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + UtState *s = ut_owner_state(closure); + ScrDyn *self = ut_box_fn(s, closure); + ScrDyn *out = NULL; + if (ut_signal_aborted(s)) ut_abort(s); + if (scr_exc_pending()) goto done; + if (s->abort_error) { + out = ut_rejected(s->abort_error); + goto done; + } + double now = ut_now(); + bool can_invoke = ut_pending_count(s) == 0 && s->active < s->concurrency && th_has_capacity(s, now); + if (!can_invoke && (s->drop || (double)ut_pending_count(s) >= s->max_pending)) { + out = th_drop(); + goto done; + } + ScrPromise *promise = scr_promise_new(); + out = scr_dyn_new_promise(promise); + UtCall call = {promise, ut_args_copy(args, argc), argc}; + if (can_invoke) { + th_reserve(s, now); + th_invoke(s, self, call); + } else { + ut_push(s, call.promise, call.args, call.argc); + th_schedule(s, self); + } +done: + scr_dyn_release(self); + ut_state_release(s); + return out; +} + +static ScrDyn *th_has_immediate_capacity(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtState *s = ut_cap_state(closure); + bool capacity = false; + if (!ut_signal_aborted(s) && ut_pending_count(s) == 0 && s->active < s->concurrency) { + double now = ut_now(); + if (s->strict) { + size_t index = s->tick_head; + while (index < s->tick_len && now - s->ticks[index] >= s->interval) index++; + capacity = (double)(s->tick_len - index) < s->limit; + } else { + capacity = !s->window_open || now - s->window_start >= s->interval || s->window_count < s->limit; + } + } + ut_state_release(s); + return scr_dyn_new_bool(capacity); +} + +/* ── the shared members ────────────────────────────────────────────── */ + +static ScrDyn *ut_cancel(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + UtState *s = ut_cap_state(closure); + if (s->throttle || s->timeout != 0) { + ScrDyn *error = ut_cancel_error(args, argc); + ut_cancel_with_error(s, error); + scr_dyn_release(error); + } + ut_state_release(s); + return scr_dyn_retain(scr_dyn_undefined()); +} + +static ScrDyn *ut_set_ref(ScrClosure *closure, bool refed) { + UtState *s = ut_cap_state(closure); + s->refed = refed; + if (s->timeout != 0) { + if (refed) scr_timer_ref(s->timeout); + else scr_timer_unref(s->timeout); + } + ScrDyn *self = ut_self(s); + if (!self) + ut_fence(s->throttle ? "util.throttle ref() and unref() after the throttled function is unreachable have no lowering" + : "util.debounce ref() and unref() after the debounced function is unreachable have no lowering"); + ut_state_release(s); + return self; +} + +static ScrDyn *ut_ref(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + return ut_set_ref(closure, true); +} + +static ScrDyn *ut_unref(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + return ut_set_ref(closure, false); +} + +static ScrDyn *ut_pending(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtState *s = ut_cap_state(closure); + ScrDyn *out = ut_pending_count(s) == 0 ? scr_dyn_new_null() : scr_dyn_new_promise(s->calls[s->len - 1].promise); + ut_state_release(s); + return out; +} + +static ScrDyn *ut_pending_count_get(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtState *s = ut_cap_state(closure); + ScrDyn *out = scr_dyn_new_num((double)ut_pending_count(s)); + ut_state_release(s); + return out; +} + +static ScrDyn *th_active_count(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + UtState *s = ut_cap_state(closure); + ScrDyn *out = scr_dyn_new_num(s->active); + ut_state_release(s); + return out; +} + +/* ── construction ──────────────────────────────────────────────────── */ + +/* Object.defineProperty(target, key, descriptor); the descriptor moves in. */ +static bool ut_define(ScrDyn *target, const char *key, ScrDyn *descriptor) { + ScrStr *name = scr_str_new(key, strlen(key)); + ScrDyn *property = scr_dyn_new_str(name); + scr_str_release(name); + ScrDyn *defined = scr_dyn_define_property(target, property, descriptor); + scr_dyn_release(property); + scr_dyn_release(descriptor); + scr_dyn_release(defined); + return defined != NULL; +} + +static ScrDyn *ut_member_fn(UtState *s, ScrDynThunk thunk, uint32_t arity, const char *name) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = ut_state_box(s); + return scr_dyn_new_func(closure, thunk, arity, UT_SIG, name); +} + +/* createFn(value): a writable, enumerable, configurable method. */ +static bool ut_define_method(ScrDyn *target, UtState *s, const char *key, ScrDynThunk thunk, uint32_t arity) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(true)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(true)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(true)); + scr_dyn_obj_set(descriptor, "value", 5, ut_member_fn(s, thunk, arity, key)); + return ut_define(target, key, descriptor); +} + +static bool ut_define_getter(ScrDyn *target, UtState *s, const char *key, ScrDynThunk thunk, const char *name) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "get", 3, ut_member_fn(s, thunk, 0, name)); + return ut_define(target, key, descriptor); +} + +/* `length` and `name` take fn's values. */ +static bool ut_define_value(ScrDyn *target, const char *key, ScrDyn *value /* moves in */) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(true)); + scr_dyn_obj_set(descriptor, "value", 5, value); + return ut_define(target, key, descriptor); +} + +/* `value[key]` over an options object (+1, or NULL with an exception + * pending). */ +static ScrDyn *ut_get(const ScrDyn *value, const char *key) { + size_t len = strlen(key); + ScrDyn *result = NULL; + if (value->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(value); + if (scr_exc_pending()) { + scr_dyn_release(view); + return NULL; + } + result = ut_get(view, key); + scr_dyn_release(view); + return result; + } + if (value->kind == SCR_DYN_OBJ || value->kind == SCR_DYN_ARR || value->kind == SCR_DYN_PROXY) { + result = scr_dyn_obj_read(value, key, len); + } else if (value->kind == SCR_DYN_HANDLE) { + ScrStr *name = scr_str_new(key, len); + result = scr_dyn_handle_key_get(value, name); + scr_str_release(name); + return result; + } else { + return scr_dyn_retain(scr_dyn_undefined()); + } + if (!result && !scr_exc_pending()) result = scr_dyn_retain(scr_dyn_undefined()); + return result; +} + +static void ut_type_fail(const char *name, const char *expected, const ScrDyn *got) { + if (strchr(name, '.')) scr_dyn_prop_type_fail(name, expected, got); + else scr_dyn_arg_type_fail(name, expected, got); +} + +/* validateFunction(fn, 'fn'). */ +static bool ut_function_arg(const ScrDyn *fn, const char *api) { + if (fn->kind == SCR_DYN_PROXY && scr_dyn_is_callable(fn)) { + char message[96]; + snprintf(message, sizeof message, "util.%s over Proxy functions has no lowering", api); + ut_fence(message); + return false; + } + if (fn->kind != SCR_DYN_FUNC) { + ut_type_fail("fn", "of type function", fn); + return false; + } + return true; +} + +/* validateInteger(value, name, min, max). */ +static bool ut_integer(const ScrDyn *value, const char *name, double min, double max, double *out) { + if (value->kind != SCR_DYN_NUM) { + ut_type_fail(name, "of type number", value); + return false; + } + double v = value->v.num; + bool integer = isfinite(v) && trunc(v) == v; + if (integer && v >= min && v <= max) { + *out = v; + return true; + } + char received[48]; + if (v == 0 && signbit(v)) memcpy(received, "-0", 3); + else scr_num_received(v, received); + char range[64]; + if (integer) snprintf(range, sizeof range, ">= %.0f && <= %.0f", min, max); + else memcpy(range, "an integer", 11); + char message[256]; + int n = snprintf(message, sizeof message, "The value of \"%s\" is out of range. It must be %s. Received %s", name, + range, received); + scr_throw_error_msg_code(SCR_ERR_RANGE, message, (size_t)n, "ERR_OUT_OF_RANGE"); + return false; +} + +/* validateBoolean(value, name) over a defaulted option. */ +static bool ut_boolean(const ScrDyn *value, const char *name, bool *out) { + if (value->kind == SCR_DYN_UNDEF) { + *out = false; + return true; + } + if (value->kind != SCR_DYN_BOOL) { + ut_type_fail(name, "of type boolean", value); + return false; + } + *out = value->v.b; + return true; +} + +/* validateObject(options, 'options'), with undefined as the empty object. */ +static bool ut_options_object(const ScrDyn *options) { + if (options->kind == SCR_DYN_UNDEF) return true; + bool object = options->kind == SCR_DYN_OBJ || options->kind == SCR_DYN_HANDLE || options->kind == SCR_DYN_BYTES || + options->kind == SCR_DYN_PROMISE || (options->kind == SCR_DYN_PROXY && !scr_dyn_is_callable(options)); + if (options->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(options); + if (!view) return false; + object = view->kind == SCR_DYN_OBJ; + scr_dyn_release(view); + } + if (!object) ut_type_fail("options", "of type object", options); + return object; +} + +/* validateAbortSignal(signal, 'options.signal'), then Node's throw for an + * already-aborted signal. Objects that pass Node's duck-typed check + * without being AbortSignal instances are refused. */ +static bool ut_signal_option(const ScrDyn *signal, const char *api) { + if (signal->kind == SCR_DYN_UNDEF) return true; + bool aborted = false; + if (scr_et_signal_core_fn && scr_et_signal_core_fn(signal, &aborted)) { + if (!aborted) return true; + ScrStr *key = scr_str_new("reason", 6); + ScrDyn *reason = scr_dyn_handle_key_get(signal, key); + scr_str_release(key); + if (!reason) return false; + ScrDyn *error = ut_abort_error("The operation was aborted", reason); + scr_dyn_release(reason); + scr_dyn_throw(error); + return false; + } + bool object = signal->kind == SCR_DYN_OBJ || signal->kind == SCR_DYN_TYPED_REF || signal->kind == SCR_DYN_HANDLE || + signal->kind == SCR_DYN_PROXY; + ScrStr *key = scr_str_new("aborted", 7); + bool duck = object && signal->kind != SCR_DYN_HANDLE && scr_dyn_in(signal, key); + scr_str_release(key); + if (scr_exc_pending()) return false; + if (duck) { + char message[112]; + snprintf(message, sizeof message, "util.%s options.signal values that are not AbortSignal instances have no lowering", + api); + ut_fence(message); + return false; + } + scr_dyn_prop_type_fail("options.signal", "an instance of AbortSignal", signal); + return false; +} + +static UtState *ut_state_new(bool throttle, ScrDyn *signal) { + UtState *s = calloc(1, sizeof *s); + if (!s) ut_oom(); + s->rc = 1; + s->throttle = throttle; + s->thunk = throttle ? th_call : db_call; + s->signal = signal->kind == SCR_DYN_UNDEF ? NULL : scr_dyn_retain(signal); + s->refed = true; + return s; +} + +/* The returned function, its members and getters, fn's length and name, + * and the abort listener. Consumes the caller's state reference. fn sits + * in a traced box on the function's closure, so a cycle through it (fn + * refers to the returned function) stays collectable. */ +static ScrDyn *ut_build(UtState *s, ScrDyn *wrapped) { + ScrClosure *closure = scr_closure_new(NULL, 2); + closure->function_kind = 4; /* an ordinary function with its own prototype */ + UtOwner *owner = malloc(sizeof *owner); + if (!owner) ut_oom(); + owner->rc = 1; + owner->state = s; /* the caller's reference */ + closure->caps[0] = scr_box_new_obj(ut_owner_retain, ut_owner_release, NULL); + scr_box_set_ref(closure->caps[0], owner); + closure->caps[1] = ut_dyn_box(scr_dyn_retain(wrapped)); + s->self = closure; + ScrDyn *fn = ut_box_fn(s, closure); + scr_closure_release(closure); + bool ok = ut_define_method(fn, s, "cancel", ut_cancel, 1); + if (s->throttle) { + ok = ok && ut_define_method(fn, s, "hasImmediateCapacity", th_has_immediate_capacity, 0); + } else { + ok = ok && ut_define_method(fn, s, "flush", db_flush, 0); + } + ok = ok && ut_define_method(fn, s, "ref", ut_ref, 0) && ut_define_method(fn, s, "unref", ut_unref, 0) && + ut_define_getter(fn, s, "pending", ut_pending, "get") && + ut_define_getter(fn, s, "pendingCount", ut_pending_count_get, s->throttle ? "pendingCount" : "get"); + if (s->throttle) ok = ok && ut_define_getter(fn, s, "activeCount", th_active_count, "get"); + if (ok) { + ScrDyn *length = scr_dyn_fn_get(wrapped, "length", 6); + ok = ut_define_value(fn, "length", length ? length : scr_dyn_retain(scr_dyn_undefined())); + } + if (ok) { + ScrDyn *name = scr_dyn_fn_get(wrapped, "name", 4); + ok = ut_define_value(fn, "name", name ? name : scr_dyn_retain(scr_dyn_undefined())); + } + if (ok && s->signal) ok = ut_listen(s); + if (!ok) { + scr_dyn_release(fn); + return NULL; + } + return fn; +} + +ScrDyn *scr_util_debounce(ScrDyn *fn, ScrDyn *wait, ScrDyn *options) { + double ms; + if (!ut_function_arg(fn, "debounce") || !ut_integer(wait, "wait", 0, UT_TIMEOUT_MAX, &ms) || + !ut_options_object(options)) + return NULL; + ScrDyn *leading = ut_get(options, "leading"); + ScrDyn *reject_on_cancel = leading ? ut_get(options, "rejectOnCancel") : NULL; + ScrDyn *signal = reject_on_cancel ? ut_get(options, "signal") : NULL; + bool lead = false, reject = false; + bool ok = signal && ut_boolean(leading, "options.leading", &lead) && + ut_boolean(reject_on_cancel, "options.rejectOnCancel", &reject) && ut_signal_option(signal, "debounce"); + ScrDyn *result = NULL; + if (ok) { + UtState *s = ut_state_new(false, signal); + s->wait = ms; + s->leading = lead; + s->reject_on_cancel = reject; + result = ut_build(s, fn); + } + scr_dyn_release(signal); + scr_dyn_release(reject_on_cancel); + scr_dyn_release(leading); + return result; +} + +/* `= Infinity` options: validateInteger(value, name, min) unless Infinity. */ +static bool ut_limit_option(const ScrDyn *value, const char *name, double min, double *out) { + if (value->kind == SCR_DYN_UNDEF || (value->kind == SCR_DYN_NUM && value->v.num == INFINITY)) { + *out = INFINITY; + return true; + } + return ut_integer(value, name, min, UT_SAFE_MAX, out); +} + +ScrDyn *scr_util_throttle(ScrDyn *fn, ScrDyn *limit, ScrDyn *interval, ScrDyn *options) { + double count, ms; + if (!ut_function_arg(fn, "throttle") || !ut_integer(limit, "limit", 1, UT_SAFE_MAX, &count) || + !ut_integer(interval, "interval", 0, UT_TIMEOUT_MAX, &ms) || !ut_options_object(options)) + return NULL; + static const char *const keys[] = {"concurrency", "maxPending", "overflow", "signal", "strict"}; + ScrDyn *values[5] = {NULL}; + bool ok = true; + for (size_t i = 0; i < 5 && ok; i++) ok = (values[i] = ut_get(options, keys[i])) != NULL; + double concurrency = INFINITY, max_pending = INFINITY; + bool drop = false, strict = false; + ok = ok && ut_limit_option(values[0], "options.concurrency", 1, &concurrency) && + ut_limit_option(values[1], "options.maxPending", 0, &max_pending); + if (ok) { + const ScrDyn *overflow = values[2]; + bool queue = overflow->kind == SCR_DYN_UNDEF || + (overflow->kind == SCR_DYN_STR && overflow->v.str->len == 5 && !memcmp(overflow->v.str->data, "queue", 5)); + drop = overflow->kind == SCR_DYN_STR && overflow->v.str->len == 4 && !memcmp(overflow->v.str->data, "drop", 4); + if (!queue && !drop) { + scr_dyn_arg_value_fail("options.overflow", "must be one of: 'queue', 'drop'", overflow); + ok = false; + } + } + ok = ok && ut_signal_option(values[3], "throttle") && ut_boolean(values[4], "options.strict", &strict); + ScrDyn *result = NULL; + if (ok) { + UtState *s = ut_state_new(true, values[3]); + s->limit = count; + s->interval = ms; + s->concurrency = concurrency; + s->max_pending = max_pending; + s->drop = drop; + s->strict = strict; + result = ut_build(s, fn); + } + for (size_t i = 0; i < 5; i++) scr_dyn_release(values[i]); + return result; +} diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 184f04d1c..88a77e32e 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -134,6 +134,15 @@ export const leakSuppressions = fileURLToPath( new URL("../tests/harness/lsan.supp", import.meta.url), ); +// The sanitized three.js nodes build runs in its own Linux job +// (three_nodes_san in .github/workflows/ci.yml). Beside the corpus's other +// workers it took 860 to more than 1200 seconds, past its test timeout. +export const sanitizedNodesJobEnv = { SCRIPTC_THREE_SANITIZED_NODES_JOB: "1" }; + +function corpusThreeEnv(flavor, platform) { + return flavor === "san" && platform === "linux" ? sanitizedNodesJobEnv : {}; +} + function corpusLeakEnv(flavor, platform) { if (flavor !== "san" || platform !== "linux") return {}; return { @@ -246,6 +255,7 @@ export function ciTestPlan({ env: { ...env, ...corpusLeakEnv(flavor, platform), + ...corpusThreeEnv(flavor, platform), SCRIPTC_TEST_WORKERS: String(workers - sideWorkers), SCRIPTC_LLVM_TEST_MODE: "dev", }, diff --git a/scripts/lowering-order.mjs b/scripts/lowering-order.mjs index 81611e6a4..6d68530cb 100644 --- a/scripts/lowering-order.mjs +++ b/scripts/lowering-order.mjs @@ -16,13 +16,16 @@ * [--seeds 1,2,3] [--jobs 8] [--entry ]... [--out ] * [--fail-on names|content] * + * --stride and --offset sample the top-level corpus programs by number, or by + * a hash of the name of a program without one. + * * Needs a built compiler (pnpm build). Writes /results.json and prints * a ranking of fields by the programs whose IR they change. With --fail-on, * it exits with status 1 when some program's IR differs between orders: * in any way (names), or beyond names and ids (content). */ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { cpus, tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -306,12 +309,25 @@ if (values["fail-on"] !== undefined && !["names", "content"].includes(values["fa const out = resolve(values.out); mkdirSync(out, { recursive: true }); const seeds = values.seeds.split(",").filter(Boolean); +/** A top-level corpus program's sampling key: its number, or, for a program + * named without one, a stable hash of its name (FNV-1a). */ +const programKey = (name) => + /^\d+-/.test(name) + ? Number.parseInt(name, 10) + : [...name].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0, 2166136261); +/** Numbered programs by number, then the named ones by name. */ +const programOrder = (a, b) => + /^\d+-/.test(a) !== /^\d+-/.test(b) + ? /^\d+-/.test(a) + ? -1 + : 1 + : programKey(a) - programKey(b) || (a < b ? -1 : a > b ? 1 : 0); const corpus = join(root, "tests/corpus"); const programs = [ ...readdirSync(corpus) - .filter((name) => /^\d+-.*\.ts$/.test(name)) - .filter((name) => Number.parseInt(name, 10) % Number(values.stride) === Number(values.offset)) - .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)) + .filter((name) => /^[\w-]+\.ts$/.test(name) && statSync(join(corpus, name)).isFile()) + .filter((name) => programKey(name) % Number(values.stride) === Number(values.offset)) + .sort(programOrder) .slice(0, Number(values.limit)) .map((name) => join(corpus, name)), ...values.entry.map((entry) => resolve(entry)), diff --git a/scripts/refresh-cli-profile.mjs b/scripts/refresh-cli-profile.mjs index 1285b5503..dde4e21b9 100644 --- a/scripts/refresh-cli-profile.mjs +++ b/scripts/refresh-cli-profile.mjs @@ -7,8 +7,9 @@ * Builds an instrumented native compiler for this Linux host, runs it on the * training builds below, and merges the raw profiles it writes into one * sparse indexed profile. The training builds are tsc-ts (a cold `speed` - * build, then a rebuild after an edit), every seventh corpus program at the - * default optimization and a further slice in `dev`, and the runtime + * build, then a rebuild after an edit), every seventh top-level corpus + * program (by number, or by a hash of the name of a program without one) at + * the default optimization and a further slice in `dev`, and the runtime * benchmark workloads. Run it on a quiet Linux x64 host after changes that * move much compiler code; a stale profile only loses part of its gain. A * refresh takes about 11 minutes on 16 cores. @@ -126,16 +127,30 @@ async function pool(tasks) { return passed; } -const programNumber = (name) => Number.parseInt(name, 10); +/** A top-level corpus program's sampling key: its number, or, for a program + * named without one, a stable hash of its name (FNV-1a). */ +const programKey = (name) => + /^\d+-/.test(name) + ? Number.parseInt(name, 10) + : [...name].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0, 2166136261); +/** Numbered programs by number, then the named ones by name. */ +const programOrder = (a, b) => + /^\d+-/.test(a) !== /^\d+-/.test(b) + ? /^\d+-/.test(a) + ? -1 + : 1 + : programKey(a) - programKey(b) || (a < b ? -1 : a > b ? 1 : 0); const corpus = readdirSync(join(root, "tests/corpus")) - .filter((name) => /^\d+-.*\.ts$/.test(name)) - .sort((a, b) => programNumber(a) - programNumber(b)); + .filter( + (name) => /^[\w-]+\.ts$/.test(name) && statSync(join(root, "tests/corpus", name)).isFile(), + ) + .sort(programOrder); const training = [ ...corpus - .filter((name) => programNumber(name) % 7 === 0) + .filter((name) => programKey(name) % 7 === 0) .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "release" })), ...corpus - .filter((name) => programNumber(name) % 14 === 7) + .filter((name) => programKey(name) % 14 === 7) .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "dev" })), ...readdirSync(join(root, "benchmarks/runtime/workloads")) .filter((name) => name.endsWith(".ts")) diff --git a/tests/corpus/7520-util-format-runtime.ts b/tests/corpus/7520-util-format-runtime.ts new file mode 100644 index 000000000..d4a7f9ea6 --- /dev/null +++ b/tests/corpus/7520-util-format-runtime.ts @@ -0,0 +1,110 @@ +// util.format, util.formatWithOptions and console output with format +// strings known only at run time, spread arguments and options objects: +// every substitution, the argument cursor, %% and %c, missing and extra +// arguments, the space-joined form of strings without a '%', and Node's +// inspectOptions validation. +import * as util from "node:util"; + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +interface Row { + id: number; + tags: string[]; + nested: { deep: { deeper: { deepest: number } } }; +} + +const formats: string[] = [ + "%s and %s", + "%d|%i|%f", + "%j", + "%O / %o", + "100%% of %s", + "%c%s%c", + "no specifiers", + "trailing %", + "%x %s", + "%s", +]; +const row: Row = { id: 7, tags: ["a", "b"], nested: { deep: { deeper: { deepest: 1 } } } }; + +for (const format of formats) { + console.log(util.format(format, "first", 42, row)); + console.log(util.format(format, 3.5, "12.75")); + console.log(util.format(format)); +} + +function pick(i: number): string { + return ["%s=%d", "%j", "%O", "plain"][i % 4]!; +} +for (let i = 0; i < 4; i++) { + console.log(util.format(pick(i), new Point(1, 2), -0, [1, 2, 3])); + console.log(pick(i), new Map([["k", new Point(3, 4)]]), new Set([1n, 2n])); + console.error(pick(i), "to stderr", null, undefined); +} + +const label: string = process.argv.length > 99 ? "" : "count: %d of %s"; +console.log(label, 5, "five", "extra"); +console.log(util.format(label, 10n, Symbol("sym"))); +console.log(util.format(label, new Point(0, 0), [new Point(5, 6)])); +console.log(util.format(pick(0), { toString: "not a function" }, true)); + +// Arguments that are all strings, numbers, booleans, null or undefined. +const prim: string = process.argv.length > 99 ? "" : "%s|%d|%i|%f|%j|%O|%o|%c|%%"; +console.log(prim, -0, "12.5", -0, true, 'q"uote', "s", 3, "css", "rest", null, undefined); +console.log(prim, NaN, null, undefined, "0x10", Infinity, NaN, false); +console.log(util.format(prim, "a\nb", " 7 ", "-3.9e2", "", -Infinity, 'it\'s', 1e21)); +console.log(util.format(label, 2.5)); + +// A union first argument formats only when it holds a string. +const maybe: string | number = process.argv.length > 99 ? 1 : "%s/%s"; +const notString: string | number = process.argv.length > 99 ? "%s" : 9; +console.log(util.format(maybe, "u", "v")); +console.log(util.format(notString, "u", "v")); +console.log(maybe, "w", "x"); + +// An unknown first argument. +const loose: unknown = process.argv.length > 99 ? 0 : "%s-%i"; +console.log(util.format(loose, "a", "7.9")); +console.log(loose, "b", 3.2); + +// Spread arguments. +const values: unknown[] = ["%s + %s = %d", 1, 2, 3, "tail"]; +console.log(util.format(...values)); +console.log(util.format("%s|%s", ...values)); +console.log(...values); +console.log("prefix", ...new Set(["x", "y"]), "suffix"); +console.error(...[1, "two", [3]]); +console.log(util.format(...([] as string[]))); + +// formatWithOptions with an options object. +const deep = { a: { b: { c: { d: { e: 1 } } } } }; +const depth = process.argv.length > 99 ? 1 : 0; +console.log(util.formatWithOptions({ depth }, "%O", deep)); +console.log(util.formatWithOptions({ depth: null }, deep)); +console.log(util.formatWithOptions({ depth: undefined }, "x", deep)); +console.log(util.formatWithOptions({ depth: 0 }, "%s|%O", deep, deep)); +console.log(util.formatWithOptions({ compact: false }, "%s", "v", { a: 1, b: [1, 2] })); +console.log(util.formatWithOptions({ breakLength: 20 }, { alpha: 1, beta: 2, gamma: 3 })); +console.log(util.formatWithOptions({ sorted: true }, "%O", { b: 1, a: 2, c: { z: 1, y: 2 } })); +console.log(util.formatWithOptions({ colors: true }, "%s %d %O", "s", 2, 3), "|"); +console.log(util.formatWithOptions({ colors: true }, 1, "plain", null, undefined, true, 4n)); +const options: util.InspectOptions = { depth: 1, compact: 1 }; +console.log(util.formatWithOptions(options, "%O", deep)); +console.log(util.formatWithOptions(options, ...values)); +console.log(util.formatWithOptions([] as unknown as util.InspectOptions, "%s", 1)); +const invalid: unknown[] = [null, 1, "x", undefined, () => {}]; +for (const bad of invalid) { + try { + util.formatWithOptions(bad as util.InspectOptions, "a"); + } catch (error) { + const e = error as NodeJS.ErrnoException; + console.log(e.name, e.code, e.message); + } +} diff --git a/tests/corpus/7521-util-format-runtime.cjs b/tests/corpus/7521-util-format-runtime.cjs new file mode 100644 index 000000000..559d09243 --- /dev/null +++ b/tests/corpus/7521-util-format-runtime.cjs @@ -0,0 +1,64 @@ +// util.format, util.formatWithOptions and console output over untyped +// values: format strings held in variables and parameters, spread +// arguments, %s over objects with and without their own toString, %d and +// %i coercions, %j over toJSON, and options objects built at run time. +"use strict"; +const util = require("util"); + +function log(message, ...rest) { + console.log(message, ...rest); +} + +function formatAll(format, values) { + return util.format(format, ...values); +} + +const messages = ["%s: %d items", "%s", "%i%%", "%j", "plain text", 42]; +for (const message of messages) { + log(message, "cart", "3.7", { total: 9 }); + console.log(formatAll(message, ["a", 1n])); +} + +const withToString = { + name: "custom", + toString() { + return `<${this.name}>`; + }, +}; +const inherited = Object.create(withToString); +inherited.name = "inherited"; +const fmt = "%s / %s / %s"; +console.log(util.format(fmt, withToString, inherited, [1, [2, [3]]])); +console.log(fmt, new Date(0), new Error("boom").message, null, Symbol("s")); + +const numeric = "%d %i %f"; +console.log(util.format(numeric, "0x1f", "12.9px", "1e3")); +console.log(util.format(numeric, { valueOf: () => 5 }, true, [])); +console.log(util.format(numeric, 7n, 8n, 9)); + +const json = "%j|%j|%j"; +const circular = { name: "loop" }; +circular.self = circular; +console.log(util.format(json, { toJSON: () => ({ replaced: 1 }) }, circular, undefined)); + +const options = {}; +options.depth = 0; +options.breakLength = 40; +console.log(util.formatWithOptions(options, "%O", { a: { b: 1 } }, ["x", { y: { z: 2 } }])); +options.depth = null; +options.unknownOption = true; +console.log(util.formatWithOptions(options, { a: { b: { c: { d: 1 } } } })); +console.log(util.formatWithOptions({ compact: 1 }, ...[{ k: [1, 2] }, "end"])); +for (const bad of [null, 0, "options", () => {}]) { + try { + util.formatWithOptions(bad, "x"); + } catch (error) { + console.log(error.name, error.code, error.message); + } +} + +const args = ["%s=%s", "key"]; +args.push("value", "rest"); +console.log(...args); +console.warn(...args); +console.log(util.format(...args.slice(1))); diff --git a/tests/corpus/7522-promisify-untyped.cjs b/tests/corpus/7522-promisify-untyped.cjs new file mode 100644 index 000000000..2e1476eac --- /dev/null +++ b/tests/corpus/7522-promisify-untyped.cjs @@ -0,0 +1,64 @@ +// util.promisify over JavaScript functions whose types are not known: +// arrows, function expressions, methods and functions chosen at run time. +// The callback's error rejects and its first value fulfills, a +// synchronous throw rejects, the call's receiver reaches the function, +// the wrapper keeps the original's name and length, and arguments that +// are not functions throw Node's ERR_INVALID_ARG_TYPE. +"use strict"; +const util = require("util"); + +function pick(kind) { + if (kind === "double") return (value, callback) => setImmediate(() => callback(null, value * 2)); + if (kind === "fail") return function failing(callback) { callback(new RangeError("failed")); }; + if (kind === "throw") return () => { throw new TypeError("thrown"); }; + return null; +} + +const store = { + prefix: "item", + read(key, callback) { + setTimeout(() => callback(null, `${this.prefix}:${key}`, "ignored"), 1); + }, +}; + +async function main() { + const double = util.promisify(pick("double")); + console.log("double:", await double(21), double.name, double.length); + try { + await util.promisify(pick("fail"))(); + } catch (error) { + console.log("rejected:", error.name, error.message); + } + try { + await util.promisify(pick("throw"))(); + } catch (error) { + console.log("sync:", error.name, error.message); + } + const read = util.promisify(store.read); + console.log("unbound:", await read.call(store, "a")); + store.readAsync = read; + console.log("method:", await store.readAsync("b")); + const bound = util.promisify(store.read.bind({ prefix: "bound" })); + console.log("bound:", await bound("c")); + const named = util.promisify(function lookup(a, b, callback) { + callback(null, [a, b]); + }); + console.log("named:", named.name, named.length, await named(1, 2)); + const values = []; + for (const n of [1, 2, 3]) values.push(await double(n)); + console.log("each:", values); + for (const value of [pick("none"), 42, "fn", {}, undefined]) { + try { + util.promisify(value); + } catch (error) { + console.log(error.name, error.code, error.message); + } + } + try { + util.promisify(); + } catch (error) { + console.log(error.code, error.message); + } +} + +main(); diff --git a/tests/corpus/7523-promisify-untyped.ts b/tests/corpus/7523-promisify-untyped.ts new file mode 100644 index 000000000..65d1d01c1 --- /dev/null +++ b/tests/corpus/7523-promisify-untyped.ts @@ -0,0 +1,38 @@ +// util.promisify over untyped functions in TypeScript: values typed `any` +// and functions whose parameters are `any` promisify at run time, and +// their promises settle with the callback's value (not the type of a +// builtin target's promisified form). +import { promisify } from "node:util"; + +const loose: any = (value: number, callback: (error: Error | null, result?: number) => void) => + callback(null, value + 1); +const looseAsync = promisify(loose); + +function legacy(a: any, b: any, callback: any): void { + setImmediate(() => (b === 0 ? callback(new RangeError("divide by zero")) : callback(null, a / b))); +} +const divide = promisify(legacy); + +const handlers: Record = { + echo: (text: string, callback: (error: null, value: string) => void) => callback(null, text), + later: (callback: (error: null, value: string[]) => void) => + setTimeout(() => callback(null, ["a", "b"]), 1), +}; + +async function main(): Promise { + console.log("loose:", await looseAsync(1)); + looseAsync(2).then((value: unknown) => console.log("then:", value)); + console.log("divide:", await divide(10, 4)); + try { + await divide(1, 0); + } catch (error) { + console.log("rejected:", (error as Error).message); + } + for (const name of ["echo", "later"]) { + const fn = promisify(handlers[name]); + console.log(`${name}:`, name === "echo" ? await fn("hi") : await fn()); + } + console.log("name:", divide.name, "length:", divide.length); +} + +main(); diff --git a/tests/corpus/7524-inspect-custom.ts b/tests/corpus/7524-inspect-custom.ts new file mode 100644 index 000000000..642ce75a9 --- /dev/null +++ b/tests/corpus/7524-inspect-custom.ts @@ -0,0 +1,91 @@ +// [util.inspect.custom] methods on classes: the method's string replaces +// the instance in util.inspect, console output and util.format, with +// Node's depth argument (negative past the depth limit, null for depth: +// null), options object and util.inspect; a multi-line result indents at +// its position, a returned value renders in the instance's place, and +// console.dir, customInspect: false, String() and JSON skip the method. +import * as util from "node:util"; +import { inspect } from "node:util"; + +class Money { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + [inspect.custom](): string { + return `Money(${(this.cents / 100).toFixed(2)})`; + } +} + +class Probe { + label: string; + constructor(label: string) { + this.label = label; + } + [util.inspect.custom](depth: number | null, options: util.InspectOptionsStylized): string { + const keys = Object.keys(options).join(","); + return `Probe<${this.label} depth=${depth} optionsDepth=${options.depth} compact=${options.compact}> ${keys}`; + } +} + +class Lines { + [inspect.custom](): string { + return "first line\nsecond line"; + } +} + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + [inspect.custom](): { x: number; y: number; sum: number } { + return { x: this.x, y: this.y, sum: this.x + this.y }; + } +} + +class Wrapper { + inner: { values: number[]; nested: { deep: { deeper: number } } }; + constructor() { + this.inner = { values: [1, 2, 3], nested: { deep: { deeper: 4 } } }; + } + [inspect.custom](depth: number, options: util.InspectOptionsStylized, show: typeof inspect): string { + if (depth < 0) return options.stylize("[Wrapper]", "special"); + return `Wrapper ${show(this.inner, { ...options, depth: options.depth == null ? null : depth - 1 })}`; + } +} + +class Base { + name = "base"; + [inspect.custom](): string { + return `Custom(${this.name})`; + } +} +class Derived extends Base { + extra = 1; +} + +console.log(util.inspect(new Money(1234))); +console.log(new Money(5), [new Money(1), new Money(2)]); +console.log(util.inspect({ wallet: { cash: new Money(99) } })); +console.log(util.inspect(new Probe("top"))); +console.log(util.inspect({ a: { b: new Probe("nested") } })); +console.log(util.inspect([new Probe("past depth")], { depth: 0 })); +console.log(util.inspect(new Probe("unbounded"), { depth: null })); +console.log(util.inspect(new Probe("infinite"), { depth: Infinity })); +console.log(util.inspect([new Probe("undefined")], { depth: undefined })); +console.log(util.inspect(new Probe("compact"), { compact: false, breakLength: 40 })); +console.log(util.inspect({ key: new Lines(), list: [new Lines()] })); +console.log(util.inspect(new Map([["k", new Lines()]]))); +console.log(util.inspect(new Point(1, 2)), util.inspect({ p: new Point(3, 4) })); +console.log(util.inspect(new Wrapper()), util.inspect({ w: new Wrapper() }, { depth: 0 })); +console.log(util.inspect(new Derived()), util.inspect([new Base(), new Derived()])); +console.log(util.format("%s | %O", new Money(10), new Money(20))); +console.log("%s and %d", new Money(1), 2); +console.log(util.inspect(new Money(7), { customInspect: false })); +console.dir(new Money(8)); +console.log(String(new Money(9)), JSON.stringify(new Money(10))); +const values: unknown[] = [new Money(11), { money: new Money(12) }]; +console.log(values, util.inspect(values[1]), [new Lines(), values[0]]); diff --git a/tests/corpus/7525-inspect-custom.cjs b/tests/corpus/7525-inspect-custom.cjs new file mode 100644 index 000000000..9c7302fac --- /dev/null +++ b/tests/corpus/7525-inspect-custom.cjs @@ -0,0 +1,75 @@ +// [util.inspect.custom] in JavaScript: methods on classes and object +// literals (also keyed by Symbol.for('nodejs.util.inspect.custom')), the +// CommonJS call form (0, util_1.inspect)(value), results that are the +// object itself or another value, a non-function member under the key, +// and the options and util.inspect a method receives. +"use strict"; +const util_1 = require("util"); + +class Temperature { + constructor(degrees) { + this.degrees = degrees; + } + [util_1.inspect.custom](depth, options, inspect) { + return `Temperature<${this.degrees}> ${typeof options.stylize} ${inspect === util_1.inspect}`; + } +} + +const literal = { + id: 1, + [util_1.inspect.custom](depth) { + return `literal at depth ${depth}`; + }, +}; +const registered = { + [Symbol.for("nodejs.util.inspect.custom")]() { + return { replaced: [1, 2], nested: { x: 1 } }; + }, +}; +const self = { + v: 1, + [util_1.inspect.custom]() { + return this; + }, +}; +const notFunction = { x: 1, [util_1.inspect.custom]: "not a function" }; +const styled = { + [util_1.inspect.custom](depth, options) { + return options.stylize("styled", "special") + options.stylize(5, "number"); + }, +}; +const nesting = { + data: { a: { b: { c: 1 } } }, + [util_1.inspect.custom](depth, options, inspect) { + return `nesting ${inspect(this.data, { ...options, depth: 0 })}`; + }, +}; + +console.log((0, util_1.inspect)(new Temperature(21))); +console.log((0, util_1.inspect)({ t: new Temperature(22), list: [literal] })); +console.log(util_1.inspect([[literal]], { depth: 0 })); +console.log(util_1.inspect(registered), util_1.inspect({ r: registered })); +console.log(util_1.inspect(self), util_1.inspect(notFunction)); +console.log(util_1.inspect(styled), JSON.stringify(util_1.inspect(styled, { colors: true }))); +console.log(util_1.inspect(nesting), util_1.inspect({ n: nesting })); +console.log(literal, [registered]); +console.log((0, util_1.format)("%s / %O", literal, literal)); +console.log(util_1.inspect(literal, { customInspect: false })); +console.dir(literal); +console.log(util_1.inspect.custom === Symbol.for("nodejs.util.inspect.custom"), util_1.inspect.custom); + +const throwing = { + [util_1.inspect.custom]() { + throw new RangeError("from custom"); + }, +}; +try { + console.log([throwing]); +} catch (error) { + console.log("caught", error.name, error.message); +} +try { + util_1.inspect({ t: throwing }); +} catch (error) { + console.log("caught", error.message); +} diff --git a/tests/corpus/7526-inspect-options-runtime.ts b/tests/corpus/7526-inspect-options-runtime.ts new file mode 100644 index 000000000..b9f0c61d7 --- /dev/null +++ b/tests/corpus/7526-inspect-options-runtime.ts @@ -0,0 +1,62 @@ +// util.inspect with colors, option values and options objects known only +// at run time, and the legacy inspect(value, showHidden, depth, colors) +// form: Node's ANSI styles per value kind, color-aware line breaking and +// grouping, runtime depth and layout options, and colored output from +// util.formatWithOptions. +import * as util from "node:util"; + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +const sparse: number[] = [1, 2, 3]; +sparse.length = 5; +const values: unknown[] = [ + 1, + -0, + "text", + "it's", + null, + undefined, + true, + 10n, + Symbol("sym"), + [1, "two", [3, [4, [5]]]], + { a: 1, "b-c": "d", nested: { deep: { deeper: { deepest: 1 } } } }, + new Map([["k", { v: 1 }]]), + new Set(["s", 2]), + new Date(0), + new Uint8Array([1, 2, 3]), + Buffer.from("hi"), + function named() {}, + new Point(1, 2), + sparse, + Object.create(null), + Array.from({ length: 30 }, (_, i) => i * 37), + "a long string with a newline\nand more text than fits on one line of eighty characters", +]; +for (const value of values) { + console.log(JSON.stringify(util.inspect(value, { colors: true }))); +} +const depth = process.argv.length > 99 ? 5 : 1; +const breakLength = process.argv.length > 99 ? 80 : 30; +console.log(util.inspect(values[10], { depth })); +console.log(util.inspect(values[10], { depth: depth - 1, breakLength })); +console.log(util.inspect(values[20], { breakLength, compact: depth })); +console.log(util.inspect({ b: 2, a: 1 }, { sorted: depth > 0 })); +console.log(util.inspect(values[10], { depth: undefined })); +const options: util.InspectOptions = { depth: 0, colors: true }; +console.log(JSON.stringify(util.inspect(values[10], options))); +options.colors = false; +console.log(util.inspect(values[10], options)); +console.log(util.inspect(values[10], false, 0)); +console.log(util.inspect(values[10], false, null, false)); +console.log(JSON.stringify(util.inspect(values[11], false, 1, true))); +console.log(util.inspect(values[10], undefined, depth)); +console.log(JSON.stringify(util.formatWithOptions({ colors: true }, "%s %O", "x", values[12]))); +console.log(JSON.stringify(util.formatWithOptions({ colors: true }, values[10], values[9]))); diff --git a/tests/corpus/7536-inspect-custom-errors.ts b/tests/corpus/7536-inspect-custom-errors.ts new file mode 100644 index 000000000..affdd587b --- /dev/null +++ b/tests/corpus/7536-inspect-custom-errors.ts @@ -0,0 +1,23 @@ +// [util.inspect.custom] on Error subclasses: console.log, util.inspect and +// %O call the method for typed values, values held as unknown and errors +// inside containers; a subclass without its own method inherits it, and +// errors without one print as before. +import * as util from "node:util"; + +class Tagged extends Error { + [util.inspect.custom](depth: number): string { + return `Tagged<${this.message}> at depth ${depth}`; + } +} +class Nested extends Tagged {} +class Plain extends Error {} + +const tagged = new Tagged("a"); +console.log(tagged); +console.log(util.inspect(tagged), util.format("%O", new Nested("b"))); +const held: unknown = new Nested("c"); +console.log(held); +const list: unknown[] = [tagged, { inner: new Tagged("d") }]; +console.log(list); +const plain: unknown = new Plain("plain"); +console.log(String(plain), plain instanceof Error); diff --git a/tests/corpus/7537-inspect-custom-errors.cjs b/tests/corpus/7537-inspect-custom-errors.cjs new file mode 100644 index 000000000..3d17f985a --- /dev/null +++ b/tests/corpus/7537-inspect-custom-errors.cjs @@ -0,0 +1,26 @@ +// An Effect-style error class in JavaScript: a method keyed by +// Symbol.for("nodejs.util.inspect.custom") on an Error subclass, called by +// console.log and util.inspect for direct, caught and contained values, +// and the class still constructs and throws when nothing inspects it. +"use strict"; +const util = require("util"); +const NodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +class YieldableError extends Error { + toJSON() { + return { _tag: "YieldableError", message: this.message }; + } + [NodeInspectSymbol]() { + return `YieldableError: ${this.message}`; + } +} + +const direct = new YieldableError("direct"); +console.log(direct, direct instanceof Error, JSON.stringify(direct)); +try { + throw new YieldableError("boom"); +} catch (e) { + console.log(e instanceof YieldableError, e.message); + console.log(e); + console.log(util.inspect([e]), util.inspect({ e })); +} diff --git a/tests/corpus/7538-inspect-custom-prototypes.mjs b/tests/corpus/7538-inspect-custom-prototypes.mjs new file mode 100644 index 000000000..7a4bfb1a4 --- /dev/null +++ b/tests/corpus/7538-inspect-custom-prototypes.mjs @@ -0,0 +1,44 @@ +// [util.inspect.custom] methods the runtime inspect walk calls: a class +// method returning an object (Effect's `return this.toJSON()`) that no +// typed code inspects, and a method an Effect-style class inherits from a +// constructor function's prototype object. +const NodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +class Exit { + constructor(value) { + this.value = value; + } + toJSON() { + return { _id: "Exit", value: this.value }; + } + [NodeInspectSymbol]() { + return this.toJSON(); + } +} +const exits = [new Exit(1), new Exit("two")]; +console.log(JSON.stringify(exits)); +console.log(exits); + +const CommitPrototype = { + toJSON() { + return { _id: "Commit" }; + }, + [NodeInspectSymbol]() { + return this.toJSON(); + }, +}; +const Base = (function () { + function Base() {} + Base.prototype = CommitPrototype; + return Base; +})(); +class Effectable extends Base {} +class RefImpl extends Effectable { + constructor(ref) { + super(); + this.ref = ref; + } +} +const ref = new RefImpl(3); +console.log(ref.ref); +console.log(ref, [ref]); diff --git a/tests/corpus/7700-util-deprecated-helpers.cjs b/tests/corpus/7700-util-deprecated-helpers.cjs new file mode 100644 index 000000000..39ec5d65f --- /dev/null +++ b/tests/corpus/7700-util-deprecated-helpers.cjs @@ -0,0 +1,30 @@ +// util.isArray and util._extend, the deprecated helpers: Node's answers +// (_extend assigns from the last own key to the first and leaves +// non-object sources alone), and DEP0044/DEP0060 once each on the next +// tick, shared with util.deprecate's codes. The program re-runs itself and +// prints the child's output with process ids normalized. +"use strict"; +const { spawnSync } = require("node:child_process"); +const util = require("util"); +function child() { + process.on("warning", (w) => console.log("listener", w.name, w.code)); + console.log(util.isArray([1]), util.isArray("x"), util.isArray({ length: 0 }), util.isArray()); + console.log(util._extend({ a: 1 }, { b: 2, c: 3 })); + console.log(Object.keys(util._extend({}, { x: 1, y: 2, z: 3 }))); + console.log(util._extend({ a: 1 }, null), util._extend({ a: 1 }, "str"), util._extend({ a: 1 }, () => 1)); + console.log(util._extend([0], [5, 6]), util._extend({ k: 1 })); + const t = { keep: true }; + console.log(util._extend(t, { keep: false }) === t, t); + try { util._extend(null, { a: 1 }); } catch (e) { console.log(e.name, e.message); } + console.log(util._extend(null, {})); + util.isArray([]); + const dep = util.deprecate(() => "wrapped", "a custom DEP0060", "DEP0060"); + console.log(dep(), "sync end"); +} +if (process.argv[2] === "child") child(); +else { + const run = spawnSync(process.execPath, [process.argv[1], "child"], { encoding: "utf8" }); + console.log(`status: ${run.status}`); + console.log(run.stdout); + console.log(run.stderr.replace(/\(node:\d+\)/g, "(node:)")); +} diff --git a/tests/corpus/7701-util-deprecated-helpers.ts b/tests/corpus/7701-util-deprecated-helpers.ts new file mode 100644 index 000000000..badc8ec05 --- /dev/null +++ b/tests/corpus/7701-util-deprecated-helpers.ts @@ -0,0 +1,21 @@ +// util.isArray and util._extend over typed values: isArray answers by the +// runtime value, and _extend writes into the typed target and answers the +// target itself. +import * as util from "node:util"; + +process.noDeprecation = true; +const nums: number[] = [1, 2]; +const text: string = "x"; +const anything: unknown = [3]; +console.log(util.isArray(nums), util.isArray(text), util.isArray(new Map()), util.isArray(anything)); + +interface Opts { + a: number; + b?: string; +} +const base: Opts = { a: 1 }; +const merged = util._extend(base, { b: "two" }); +console.log(merged, merged === base, base.b); +const plain = util._extend({}, { x: 1, y: 2 }); +console.log(plain, Object.keys(plain)); +console.log(util._extend({ keep: 1 }, null), util._extend({ keep: 2 }, undefined)); diff --git a/tests/corpus/7702-util-signal-exit-code.cjs b/tests/corpus/7702-util-signal-exit-code.cjs new file mode 100644 index 000000000..000a342df --- /dev/null +++ b/tests/corpus/7702-util-signal-exit-code.cjs @@ -0,0 +1,17 @@ +// util.convertProcessSignalToExitCode: 128 plus the os.constants.signals +// number, aliases included, and ERR_INVALID_ARG_VALUE listing every signal +// name for unknown names, other cases and non-strings. +"use strict"; +const util = require("util"); +const os = require("os"); + +for (const v of ["SIGKILL", "SIGTERM", "SIGINT", "SIGHUP", "SIGSYS", "SIGIOT", "nope", "sigint", 9, undefined, null]) { + try { + console.log(util.convertProcessSignalToExitCode(v)); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} +console.log(Object.keys(os.constants.signals).every((name) => util.convertProcessSignalToExitCode(name) === 128 + os.constants.signals[name])); +const convert = util.convertProcessSignalToExitCode; +console.log(convert("SIGPIPE"), typeof convert); diff --git a/tests/corpus/7703-util-aborted.cjs b/tests/corpus/7703-util-aborted.cjs new file mode 100644 index 000000000..66a28c1cd --- /dev/null +++ b/tests/corpus/7703-util-aborted.cjs @@ -0,0 +1,17 @@ +// util.aborted(signal, resource): a promise that rejects with Node's +// ERR_INVALID_ARG_TYPE for a non-AbortSignal or a non-object resource, +// resolves with undefined for an aborted signal and with the abort event +// later, in Node's microtask order. +const util = require("util"); +const ac = new AbortController(); +const p = util.aborted(ac.signal, {}); +console.log(p instanceof Promise); +p.then(/** @param {any} v */ (v) => console.log("later", typeof v, v && v.type, v instanceof Event)); +const done = new AbortController(); done.abort(); +util.aborted(done.signal, {}).then((v) => console.log("already", v)); +util.aborted({}, {}).catch((e) => console.log(e.name, e.code, e.message)); +util.aborted(ac.signal, 1).catch((e) => console.log(e.name, e.code, e.message)); +util.aborted(ac.signal, null).catch((e) => console.log(e.name, e.code, e.message)); +util.aborted(ac.signal, () => {}).then(() => console.log("fn resource ok")); +util.aborted(undefined).catch((e) => console.log(e.name, e.code, e.message)); +setTimeout(() => ac.abort("why"), 1); diff --git a/tests/corpus/7704-util-aborted.ts b/tests/corpus/7704-util-aborted.ts new file mode 100644 index 000000000..18af30182 --- /dev/null +++ b/tests/corpus/7704-util-aborted.ts @@ -0,0 +1,21 @@ +// util.aborted in TypeScript: the listener resists stopImmediatePropagation, +// and the promise settles two microtask turns after the signal's own +// promise, as an async function's result adopts it. +import { aborted } from "node:util"; +const ac = new AbortController(); +ac.signal.addEventListener("abort", (e) => { + console.log("first listener stops propagation"); + e.stopImmediatePropagation(); +}); +const resource = { name: "job" }; +const order: string[] = []; +aborted(ac.signal, resource).then(() => order.push("aborted")); +Promise.resolve().then(() => order.push("tick 1")).then(() => order.push("tick 2")); +ac.abort(); +setTimeout(() => console.log(order), 0); +const done = new AbortController(); +done.abort(); +const seq: string[] = []; +aborted(done.signal, resource).then(() => seq.push("already")); +Promise.resolve().then(() => seq.push("a")).then(() => seq.push("b")).then(() => seq.push("c")); +setTimeout(() => console.log(seq), 0); diff --git a/tests/corpus/7705-util-diff.cjs b/tests/corpus/7705-util-diff.cjs new file mode 100644 index 000000000..489586a3b --- /dev/null +++ b/tests/corpus/7705-util-diff.cjs @@ -0,0 +1,14 @@ +// util.diff over strings (one entry per UTF-16 code unit, astral pairs as +// two) and arrays of strings: Node's Myers diff entries in order, identical +// values answering [] before validation, and ERR_INVALID_ARG_TYPE naming +// the argument or the array element. +const util = require("util"); +const show = (a, e) => { + try { console.log(JSON.stringify(util.diff(a, e))); } catch (err) { console.log(err.name, err.code, err.message); } +}; +show("abc", "abd"); show("kitten", "sitting"); show("", "x"); show("same", "same"); show("", ""); +show(["a", "b", "c"], ["a", "c", "d"]); show(["x"], []); show([], ["y", "z"]); show([], []); +const arr = ["p"]; show(arr, arr); show(1, 1); show(1, 2); show("a", 2); show(["a", 1], ["a"]); show(["a"], [, "b"]); +show(null, "x"); show("x", undefined); show("héllo😀", "hello😀"); show({}, "x"); +show("the quick brown fox", "the quack brown box"); +console.log(util.diff(["line 1", "line 2"], ["line 1", "line 3"])); diff --git a/tests/corpus/7706-util-diff.ts b/tests/corpus/7706-util-diff.ts new file mode 100644 index 000000000..7effff734 --- /dev/null +++ b/tests/corpus/7706-util-diff.ts @@ -0,0 +1,13 @@ +// util.diff with typed arrays of strings and DiffEntry tuples. +import { diff, type DiffEntry } from "node:util"; +const before: string[] = ["alpha", "beta", "gamma", "delta"]; +const after: string[] = ["alpha", "gamma", "delta", "epsilon"]; +const entries: DiffEntry[] = diff(before, after); +for (const [op, value] of entries) console.log(op === 0 ? " " : op === 1 ? "-" : "+", value); +const counts = { kept: 0, actual: 0, expected: 0 }; +for (const entry of diff("abcdef", "azcdxf")) { + if (entry[0] === 0) counts.kept++; + else if (entry[0] === 1) counts.actual++; + else counts.expected++; +} +console.log(counts, diff("x", "x").length); diff --git a/tests/corpus/7707-util-mime-type.cjs b/tests/corpus/7707-util-mime-type.cjs new file mode 100644 index 000000000..e860d1d30 --- /dev/null +++ b/tests/corpus/7707-util-mime-type.cjs @@ -0,0 +1,87 @@ +// util.MIMEType and util.MIMEParams: parsing, serialization, setters, +// parameter maps, iteration, errors, inspect, JSON and %s formatting. +const util = require("util"); +const { MIMEType, MIMEParams, inspect, format } = util; +const show = (label, run) => { + try { console.log(label, run()); } catch (e) { console.log(label, e.name, e.code, e.message); } +}; +const inputs = [ + "text/javascript", " TEXT/HTML ; Charset=UTF-8 ", "application/json;a=1;b=2;a=3", "text/plain;x", "text/plain;x=", + 'text/plain;q="a\\"b\\\\c";r="unterminated', 'text/plain;q="ends with \\', "text/plain;;;k=v;;", "text/plain; k = v", + "text/plain;k=\"\"", "text/plain;é=1;v=é", "text/plain;k=vĀ", "image/svg+xml;charset=utf-8", + "text", "text/", "/plain", "te xt/plain", "text/pla in", "té/x", "", " \t\r\n", +]; +for (const input of inputs) { + show(JSON.stringify(input), () => { + const m = new MIMEType(input); + return [m.type, m.subtype, m.essence, String(m), m.params.toString(), [...m.params]]; + }); +} +show("parse ok", () => String(MIMEType.parse("Text/Plain;A=B"))); +show("parse bad", () => MIMEType.parse("nope")); +show("illegal", () => new MIMEType("text/plain", 1)); +show("null second", () => String(new MIMEType("text/plain", null))); +show("object input", () => String(new MIMEType({ toString: () => "a/b;c=d" }))); +const m = new MIMEType("text/plain;charset=ascii"); +m.type = "Application"; m.subtype = "JSON"; +show("setters", () => String(m)); +show("bad type", () => { m.type = "a b"; }); +show("bad subtype", () => { m.subtype = ""; }); +const p = m.params; +show("same params", () => p === m.params); +p.set("Charset", "utf-8"); p.set("q", "1 2"); p.set("empty", ""); +show("params", () => [String(m), p.get("CHARSET"), p.get("none"), p.has("q"), p.toJSON()]); +show("bad name", () => p.set("a b", "x")); +show("bad value", () => p.set("ok", "Ā")); +p.delete("q"); +show("after delete", () => [...p.keys()].join(",")); +show("entries", () => [...p.entries()]); +show("values", () => [...p.values()]); +const fresh = new MIMEParams(); +fresh.set("b", "2"); fresh.set("a", "1"); fresh.set("b", "3"); +show("fresh", () => [String(fresh), [...fresh]]); +show("json", () => JSON.stringify({ m, p: fresh })); +show("instanceof", () => [m instanceof MIMEType, p instanceof MIMEParams, p instanceof MIMEType, {} instanceof MIMEType]); +console.log(m, p); +console.log(inspect({ nested: { m } }, { depth: 0 })); +const live = new MIMEParams(); +live.set("a", "1"); live.set("b", "2"); live.set("c", "3"); +const seen = []; +for (const [k] of live) { seen.push(k); if (k === "a") { live.delete("b"); live.set("d", "4"); } } +console.log(seen, `${m}`); + +{ + show("number input", () => new MIMEType(5)); + show("undefined input", () => new MIMEType()); + const m = new MIMEType("a/b"); + const p = m.params; + p.set(1, 2); p.set("semi", "x;y"); p.set("quote", 'say "hi"'); p.set("back", "a\\b"); p.set("space", "a b"); + show("quoted out", () => p.toString()); + show("get coerced", () => [p.get(1), p.has(1), p.get(), p.has()]); + p.delete(1); + show("deleted", () => [...p.keys()]); + show("typeof", () => [typeof m, typeof p, typeof MIMEType]); + show("keys", () => [Object.keys(m), Object.keys(p), m.foo, p.size]); + show("upper set", () => { m.type = "TEXT"; m.subtype = "X-Thing+JSON"; return m.essence; }); + show("empty type", () => { m.type = ""; }); + show("type with slash", () => { m.type = "a/b"; }); + show("set name empty", () => p.set("", "v")); + show("format", () => format("%s|%j|%o", m, m, p)); + show("inspect arr", () => inspect([m, p, { m }])); + show("concat", () => "x" + m + p); + show("parse throws?", () => [MIMEType.parse("a/b;c=\"d"), MIMEType.parse("")]); + show("parse coerces", () => String(MIMEType.parse({ toString() { return "x/y;z=1"; } }))); + show("params iter", () => { const it = p[Symbol.iterator](); return [it.next(), it.next().value]; }); + show("entries next", () => { const it = p.entries(); return it.next(); }); + const bare = new MIMEType("a/b"); + console.log(Object.keys(bare), bare.foo, bare.params.size); + console.log(format("%s|%s", bare, bare.params), "%s", new MIMEType("c/d;e=f")); + // Every argument evaluates before the constructor or parse runs. + const order = []; + const note = (label) => { order.push(label); return label; }; + const coerced = { toString() { order.push("coerce"); return "a/b"; } }; + show("surplus, then coercion", () => { new MIMEType(coerced, undefined, note("third")); return order.splice(0).join(","); }); + show("surplus, then parse error", () => { try { new MIMEType("bad", undefined, note("x")); } catch (e) { order.push(e.code); } return order.splice(0).join(","); }); + show("parse surplus", () => { MIMEType.parse(coerced, note("second")); return order.splice(0).join(","); }); + show("params surplus", () => { new MIMEParams(note("p")); return order.splice(0).join(","); }); +} diff --git a/tests/corpus/7708-util-mime-type.ts b/tests/corpus/7708-util-mime-type.ts new file mode 100644 index 000000000..a3db92555 --- /dev/null +++ b/tests/corpus/7708-util-mime-type.ts @@ -0,0 +1,44 @@ +// util.MIMEType and util.MIMEParams through their TypeScript types. +import { MIMEType, MIMEParams, format, inspect } from "node:util"; + +function describe(m: MIMEType): string { + return `${m.essence} [${m.params.toString()}]`; +} + +const types: MIMEType[] = []; +for (const s of ["text/html;charset=utf-8", "application/ld+json", "multipart/form-data; boundary=abc"]) { + types.push(new MIMEType(s)); +} +for (const m of types) console.log(describe(m), m.type.length, m.subtype.toUpperCase()); + +const parsed: MIMEType | null = MIMEType.parse("bad"); +console.log(parsed === null, MIMEType.parse("a/b")?.essence); + +const params: MIMEParams = types[0].params; +params.set("level", "1"); +let joined = ""; +for (const [name, value] of params) joined += `${name}:${value} `; +console.log(joined.trim(), params.has("level"), params.get("missing") ?? "none"); +const keys: string[] = [...params.keys()]; +const values: string[] = Array.from(params.values()); +console.log(keys, values, JSON.stringify(types[0]), JSON.stringify(params)); + +try { + new MIMEType("nope"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err instanceof TypeError, err.code, err.message); +} +try { + types[1].type = "bad type"; +} catch (e) { + console.log((e as Error).message); +} +types[1].subtype = "xml"; +console.log(String(types[1]), types[1] instanceof MIMEType, inspect(types[1]), inspect(params)); +const empty = new MIMEParams(); +console.log(empty.toString() === "", [...empty].length, `${types[2]}`); +const seeded = new Map(params); +const pairs: [string, string][] = [...params.entries()]; +console.log(seeded.get("level"), seeded.size, pairs, [...empty.values()]); +console.log(format("%s / %s", types[0], params), `${params}`); diff --git a/tests/corpus/7709-function-own-properties.cjs b/tests/corpus/7709-function-own-properties.cjs new file mode 100644 index 000000000..7bf6fc77f --- /dev/null +++ b/tests/corpus/7709-function-own-properties.cjs @@ -0,0 +1,22 @@ +// Function values with own properties: inspect shows their own name and +// enumerable members. +const util = require("node:util"); + +function named() {} +named.x = 1; +named.list = [1, 2]; +console.log(named); + +const dep = util.deprecate(function inner(a, b) {}, "unused"); +dep.y = 2; +console.log(dep, Object.keys(dep)); +/** @type {any} */ +const held = dep; +console.log({ g: held }, [held]); +console.log({ a: { b: { c: { f: held } } } }); +Object.defineProperty(dep, "name", { value: "renamed" }); +Object.defineProperty(dep, "hidden", { value: 3, enumerable: false }); +console.log(dep, util.inspect(held, { compact: false })); + +const plain = util.callbackify(async () => 1); +console.log(plain); diff --git a/tests/corpus/7710-util-debounce.cjs b/tests/corpus/7710-util-debounce.cjs new file mode 100644 index 000000000..5ae49004d --- /dev/null +++ b/tests/corpus/7710-util-debounce.cjs @@ -0,0 +1,109 @@ +// util.debounce: trailing and leading calls, flush, cancel, rejectOnCancel, +// receivers, re-entry, options.signal, validation, the returned +// function's own members, and unref'd timers at exit. +const util = require("node:util"); + +const settle = (label, p) => p.then((v) => console.log(label, "ok", v), (e) => console.log(label, "err", e.name, e.message, e.cause instanceof Error ? e.cause.message : e.cause)); + +async function main() { + // Trailing calls collapse onto the last arguments. + let calls = 0; + const add = util.debounce(function (a, b) { calls++; return [this === add, a + b]; }, 20); + const p1 = add(1, 2), p2 = add(3, 4), p3 = add(5, 6); + console.log(add.pendingCount, add.pending === p3, p1 === p2); + console.log(await p1, await p2, await p3, calls, add.pendingCount, add.pending); + + // leading: the first call runs at once, later ones trail. + const lead = util.debounce((x) => x * 10, 15, { leading: true }); + const l1 = lead(1), l2 = lead(2); + console.log(lead.pendingCount); + console.log(await l1, await l2); + + // flush runs the pending call now; cancel rejects it. + const f = util.debounce((x) => `ran ${x}`, 1000); + const f1 = f("a"); + f.flush(); + console.log(await f1, f.pendingCount); + f.flush(); + const c1 = f("b"); + f.cancel(); + await settle("cancel", c1); + const c2 = f("c"); + f.cancel(new Error("stop")); + await settle("cancel-reason", c2); + f.cancel(); + + // rejectOnCancel rejects superseded calls. + const r = util.debounce((x) => x, 10, { rejectOnCancel: true }); + const r1 = r(1), r2 = r(2); + await settle("superseded", r1); + await settle("latest", r2); + + // A throwing fn rejects every waiting call. + const t = util.debounce(() => { throw new TypeError("bad"); }, 5); + const t1 = t(), t2 = t(); + await settle("throw1", t1); + await settle("throw2", t2); + + // ref and unref answer the function. + console.log(f.ref() === f, f.unref() === f, f.ref() === f); + + // options.signal: aborting rejects pending calls and every later call. + const ac = new AbortController(); + const s = util.debounce((x) => x, 1000, { signal: ac.signal }); + const s1 = s(1); + ac.abort("reason!"); + await settle("abort-pending", s1); + await settle("abort-later", s(2)); + try { + util.debounce(() => 1, 5, { signal: AbortSignal.abort("pre") }); + } catch (e) { + console.log("pre-aborted", e.name, e.code, e.message, e.cause); + } + + // Ordering against microtasks and a timer of the same delay. + const order = []; + const o = util.debounce(() => order.push("debounced"), 10); + o().then(() => order.push("then")); + setTimeout(() => order.push("timer"), 10); + Promise.resolve().then(() => order.push("micro")); + await new Promise((resolve) => setTimeout(resolve, 40)); + console.log(order); +} +main().then(tail); + +function tail() { + const tryIt = (run) => { try { run(); } catch (e) { console.log(e.name, e.code, e.message); } }; + tryIt(() => util.debounce(1, 5)); + tryIt(() => util.debounce(() => 1, -1)); + tryIt(() => util.debounce(() => 1, 1.5)); + tryIt(() => util.debounce(() => 1, "5")); + tryIt(() => util.debounce(() => 1, 5, null)); + tryIt(() => util.debounce(() => 1, 5, [1])); + tryIt(() => util.debounce(() => 1, 5, { leading: 1 })); + tryIt(() => util.debounce(() => 1, 5, { rejectOnCancel: "yes" })); + tryIt(() => util.debounce(() => 1, 5, { signal: {} })); + + const d = util.debounce(function named(a, b) { return a + b; }, 5); + console.log(typeof d, d.name, d.length, Object.keys(d), Object.getOwnPropertyNames(d)); + console.log(d); + const p = d(1, 2); + console.log(d.pending === p, d.pendingCount); + d.flush(); + + const holder = {}; + holder.run = util.debounce(function () { return this === holder.run; }, 5); + holder.run().then((same) => console.log("receiver is the function", same)); + let depth = 0; + const again = util.debounce(function (n) { + depth++; + if (n > 0) again(n - 1).then((v) => console.log("inner", v)); + return n; + }, 5); + again(2).then((v) => console.log("outer", v, depth)); + + const unrefd = util.debounce(() => console.log("never"), 500); + unrefd(); + unrefd.unref(); + process.on("exit", () => console.log("exit", unrefd.pendingCount)); +} diff --git a/tests/corpus/7711-util-throttle.cjs b/tests/corpus/7711-util-throttle.cjs new file mode 100644 index 000000000..f776e7174 --- /dev/null +++ b/tests/corpus/7711-util-throttle.cjs @@ -0,0 +1,101 @@ +// util.throttle: limits per interval, queueing, overflow 'drop', maxPending, +// concurrency, strict windows, cancel, options.signal, validation and the +// returned function's own members. +const util = require("node:util"); + +const settle = (label, p) => p.then((v) => console.log(label, "ok", v), (e) => console.log(label, "err", e.name, e.code, e.message)); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + // Two calls per 60 ms; the rest queue in order. + const seen = []; + const t = util.throttle(function (x) { seen.push(x); return x * 2; }, 2, 60); + const ps = [1, 2, 3, 4, 5].map((x) => t(x)); + console.log(seen, t.pendingCount, t.activeCount, t.pending === ps[4], t.hasImmediateCapacity()); + console.log(await Promise.all(ps), seen); + + // overflow 'drop' rejects with ERR_THROTTLED once the window is full. + const d = util.throttle((x) => x, 1, 1000, { overflow: "drop" }); + await settle("drop-first", d(1)); + await settle("drop-second", d(2)); + d.cancel(); + await settle("after-cancel", d(3)); + + // maxPending bounds the queue. + const m = util.throttle((x) => x, 1, 30, { maxPending: 1 }); + const m1 = m(1), m2 = m(2), m3 = m(3); + await settle("max1", m1); + await settle("max3", m3); + await settle("max2", m2); + + // concurrency counts unsettled async calls. + const order = []; + const c = util.throttle(async (x) => { order.push(`start ${x}`); await sleep(15); order.push(`end ${x}`); return x; }, 10, 1000, { concurrency: 1 }); + const ca = c("a"), cb = c("b"); + console.log(c.activeCount, c.pendingCount); + console.log(await ca, await cb, order, c.activeCount); + + // A queue that never empties while calls are taken and others arrive. + const q = util.throttle(async (x) => { await null; return x; }, 1000, 1000, { concurrency: 1 }); + const queued = []; + for (let i = 0; i < 5; i++) queued.push(q(i)); + const results = []; + for (let i = 5; i < 25; i++) { + results.push(await queued[i - 5]); + queued.push(q(i)); + } + for (let i = 20; i < 25; i++) results.push(await queued[i]); + console.log(results.join(","), q.pendingCount, q.activeCount); + + // A throwing fn rejects that call only. + const e = util.throttle((x) => { if (x === 2) throw new RangeError("two"); return x; }, 5, 10); + await settle("e1", e(1)); + await settle("e2", e(2)); + await settle("e3", e(3)); + + // strict keeps a rolling window. + const s = util.throttle((x) => x, 1, 25, { strict: true }); + const s1 = s(1), s2 = s(2); + console.log(s.hasImmediateCapacity(), await s1, await s2); + + // cancel rejects the queue and resets the limiter. + const k = util.throttle((x) => x, 1, 1000); + const k1 = k(1), k2 = k(2); + k.cancel("why"); + await settle("k1", k1); + await settle("k2", k2); + console.log(k.hasImmediateCapacity(), k.ref() === k, k.unref() === k); + await settle("k3", k(3)); + + // options.signal + const ac = new AbortController(); + const a = util.throttle((x) => x, 1, 1000, { signal: ac.signal }); + await settle("a1", a(1)); + const a2 = a(2); + ac.abort(); + await settle("a2", a2); + await settle("a3", a(3)); + console.log(a.hasImmediateCapacity(), a.pendingCount); +} +main().then(tail); + +function tail() { + const tryIt = (run) => { try { run(); } catch (e) { console.log(e.name, e.code, e.message); } }; + tryIt(() => util.throttle(null, 1, 5)); + tryIt(() => util.throttle(() => 1, 0, 5)); + tryIt(() => util.throttle(() => 1, 1.5, 5)); + tryIt(() => util.throttle(() => 1, 1, 2 ** 31)); + tryIt(() => util.throttle(() => 1, 1, 5, "options")); + tryIt(() => util.throttle(() => 1, 1, 5, { concurrency: 0 })); + tryIt(() => util.throttle(() => 1, 1, 5, { concurrency: "2" })); + tryIt(() => util.throttle(() => 1, 1, 5, { overflow: "x" })); + tryIt(() => util.throttle(() => 1, 1, 5, { maxPending: -1 })); + tryIt(() => util.throttle(() => 1, 1, 5, { strict: "y" })); + tryIt(() => util.throttle(() => 1, 1, 5, { signal: AbortSignal.abort("pre") })); + + const t = util.throttle(() => 1, 1, 50, { overflow: "drop" }); + console.log(Object.keys(t), Object.getOwnPropertyNames(t), t.name, t.length); + console.log(t); + t(); + t().catch((e) => console.log(e.name, e.code, e.message, Object.keys(e))); +} diff --git a/tests/corpus/7712-util-debounce-throttle.ts b/tests/corpus/7712-util-debounce-throttle.ts new file mode 100644 index 000000000..007635bac --- /dev/null +++ b/tests/corpus/7712-util-debounce-throttle.ts @@ -0,0 +1,27 @@ +// util.debounce and util.throttle through their TypeScript types. +import { debounce, throttle } from "node:util"; + +async function main(): Promise { + const save = debounce((text: string, times: number): string => text.repeat(times), 10); + const first = save("a", 1); + const last = save("b", 3); + console.log(save.pendingCount, save.pending !== null); + const results: string[] = await Promise.all([first, last]); + console.log(results, save.pendingCount); + + const doubled = throttle(async (n: number): Promise => n * 2, 2, 20); + const values: number[] = await Promise.all([doubled(1), doubled(2), doubled(3)]); + console.log(values, doubled.activeCount, doubled.hasImmediateCapacity()); + + const later = debounce((n: number) => n, 1000); + const pending = later(5); + later.cancel("stopped"); + try { + await pending; + } catch (e) { + const err = e as Error; + console.log(err.name, err.message, err.cause); + } + console.log(later.ref() === later, later.unref() === later); +} +main(); diff --git a/tests/corpus/7990-inferred-return-overlapping-records.ts b/tests/corpus/7990-inferred-return-overlapping-records.ts new file mode 100644 index 000000000..c2d1c9ea0 --- /dev/null +++ b/tests/corpus/7990-inferred-return-overlapping-records.ts @@ -0,0 +1,59 @@ +// Functions whose inferred return type is a union of overlapping records. +// TypeScript's subtype reduction infers `{ status }` for a function that +// returns `{ status }` in one branch and `{ status, value }` in another, so +// the inferred type is narrower than the values. Returned values keep their +// fields, chained returns keep them, and bindings typed by the reduced type +// read their declared fields. +type Aborted = { status: string }; +const ABORTED: Aborted = { status: "aborted" }; + +function inner(ok: boolean, value: unknown) { + if (!ok) return ABORTED; + return { status: "valid", value }; +} + +// Returns inner's union through a call: inferred as `{ status }` alone. +function outer(ok: boolean, value: unknown) { + if (value === "stop") return ABORTED; + return inner(ok, value); +} + +function merge(entries: [string, number][]) { + const result: Record = {}; + for (const [key, n] of entries) { + if (key === "__proto__") return ABORTED; + result[key] = n; + } + return { status: "valid", value: result }; +} + +function parse(entries: [string, number][]) { + if (entries.length === 0) return ABORTED; + return merge(entries); +} + +console.log(JSON.stringify(outer(true, [1, 2]))); +console.log(JSON.stringify(outer(false, 1))); +console.log(JSON.stringify(outer(true, "stop"))); +console.log(JSON.stringify(parse([["a", 1], ["b", 2]]))); +console.log(JSON.stringify(parse([]))); + +// Into untyped positions: the value keeps every field. +const seen: unknown[] = []; +seen.push(outer(true, { nested: true })); +seen.push(parse([["c", 3]])); +console.log(JSON.stringify(seen)); +const loose: unknown = outer(true, "kept"); +console.log(JSON.stringify(loose)); + +// Bindings typed by the reduced type: declared fields read as in Node. +const r = outer(true, 42); +console.log(r.status); +let status = ""; +for (const ok of [true, false]) status += outer(ok, 0).status + " "; +console.log(status.trim()); +function statusOf(ok: boolean): string { + const local = outer(ok, null); + return local.status; +} +console.log(statusOf(true), statusOf(false)); diff --git a/tests/corpus/8005-buffer-encoding-paths.ts b/tests/corpus/8005-buffer-encoding-paths.ts new file mode 100644 index 000000000..014f22abf --- /dev/null +++ b/tests/corpus/8005-buffer-encoding-paths.ts @@ -0,0 +1,105 @@ +// Buffer latin1, ascii, hex and base64 conversions in both directions: +// ASCII, binary and mixed contents at sizes around eight-byte words and the +// pool threshold, strings with code points past U+00FF, invalid hex and +// base64 text, and the lengths and characters of the strings produced. + +let seed = 12345; +function next(): number { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed >>> 8; +} + +function fill(size: number, kind: string): Buffer { + const buf = Buffer.alloc(size); + for (let i = 0; i < size; i++) { + const r = next(); + if (kind === "ascii") buf[i] = 32 + (r % 95); + else if (kind === "binary") buf[i] = r & 255; + else if (kind === "high") buf[i] = 128 + (r % 128); + else buf[i] = r % 23 === 0 ? 160 + (r % 96) : 97 + (r % 26); + } + return buf; +} + +function digest(text: string): number { + let h = 0; + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0; + return h; +} + +const sizes = [0, 1, 7, 8, 9, 15, 16, 17, 63, 64, 65, 4095, 4096, 4097, 70001]; +for (const kind of ["ascii", "binary", "high", "sparse"]) { + const rows: string[] = []; + for (const size of sizes) { + const buf = fill(size, kind); + const latin1 = buf.toString("latin1"); + const back = Buffer.from(latin1, "latin1"); + const ascii = Buffer.from(latin1, "ascii"); + const binary = Buffer.from(buf.toString("binary"), "binary"); + const hex = buf.toString("hex"); + const b64 = buf.toString("base64"); + const url = buf.toString("base64url"); + rows.push( + [ + size, + latin1.length, + digest(latin1), + back.equals(buf), + ascii.equals(buf), + binary.equals(buf), + Buffer.from(hex, "hex").equals(buf), + Buffer.from(b64, "base64").equals(buf), + Buffer.from(url, "base64url").equals(buf), + hex.length, + b64.length, + Buffer.byteLength(latin1, "latin1"), + size > 2 ? latin1.charCodeAt(size - 2) : -1, + size > 4 ? latin1.slice(1, 4).length : -1, + ].join(","), + ); + } + console.log(kind, rows.join(" ")); +} + +// Strings with code points past U+00FF keep the low byte of each UTF-16 +// unit, and an astral code point gives its two surrogates' low bytes. +const wide = [ + "abcéÿ", + "ĀŁ߿", + "xࠀ€￿y", + "\u{1F600}a\u{10FFFF}", + "é".repeat(9) + "€" + "è".repeat(9), + "aaaaaaaébbbbbbbb中cccccccc\u{1F600}", + "ÀÁÂÃÄÅÆÇࠀÈ", +]; +for (const text of wide) { + console.log( + Buffer.from(text, "latin1").toString("hex"), + Buffer.from(text, "ascii").toString("hex"), + Buffer.byteLength(text, "latin1"), + ); +} +const mixed = "é".repeat(5000) + "中" + "z".repeat(5000) + "ÿ".repeat(3000); +const mixedBytes = Buffer.from(mixed, "latin1"); +console.log(mixedBytes.length, mixedBytes[0], mixedBytes[5000], mixedBytes[5001], mixedBytes[13000]); + +// Hex stops at the first invalid pair and drops an odd last digit. +for (const text of ["0aFf", "0aF", "0a1g22", "zz00", "", "ABCDEF0123456789", "0aİ0b", "00a"]) { + console.log(JSON.stringify(text), Buffer.from(text, "hex").toString("hex"), Buffer.byteLength(text, "hex")); +} +const longHex = "0123456789abcdef".repeat(600) + "0x" + "ff".repeat(10); +console.log(Buffer.from(longHex, "hex").length); + +// base64 skips characters outside both alphabets and stops at padding. +for (const text of ["aGVsbG8=", "aGVsbG8", "aGV sbG8=", "aGV\nsbG8", "-_-_", "+/+/", "aGVsbG8=d29ybGQ=", "a", "ab", "abc", "aébc", "====", "YQ"]) { + console.log(JSON.stringify(text), Buffer.from(text, "base64").toString("hex"), Buffer.from(text, "base64url").toString("hex")); +} + +// Strings produced by hex and base64 behave as ASCII text afterwards. +const produced = fill(5000, "binary"); +const producedHex = produced.toString("hex"); +const joined = producedHex + "é" + produced.toString("base64"); +console.log(joined.length, joined.indexOf("é"), digest(joined.slice(9990, 10020))); +const latinText = fill(9000, "high").toString("latin1"); +const appended = latinText + "€"; +console.log(latinText.length, appended.length, appended.charCodeAt(8999), appended.charCodeAt(9000), appended.slice(8998).length); diff --git a/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts b/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts new file mode 100644 index 000000000..b7cb5418a --- /dev/null +++ b/tests/corpus/8009-normalize-hangul-jungseong-o-yae.ts @@ -0,0 +1,28 @@ +// U+11A7 (HANGUL JUNGSEONG O-YAE) is a vowel that composes with nothing: +// after an LV syllable, after a leading consonant, and around trailing +// consonants it stays as it is in every normalization form, as in Node. + +const samples = [ + "가ᆧ", + "가ᆧ", + "가ᆧᆨ", + "각ᆧ", + "ᄀᆧ", + "ᆧ", + "ᆧᆧ", + "x가ᆧy각z", + "café 가ᆧ é", + "ガᆧ가", +]; + +function points(text: string): string { + return Array.from(text, (ch) => ch.codePointAt(0)!.toString(16)).join("."); +} + +for (const sample of samples) { + console.log( + points(sample), + ["NFC", "NFD", "NFKC", "NFKD"].map((form) => points(sample.normalize(form))).join(" "), + ); +} +console.log("가ᆧ".normalize() === "가ᆧ", "가ᆧ".normalize().length); diff --git a/tests/corpus/8120-inspect-custom-nested.ts b/tests/corpus/8120-inspect-custom-nested.ts new file mode 100644 index 000000000..283f9e9cc --- /dev/null +++ b/tests/corpus/8120-inspect-custom-nested.ts @@ -0,0 +1,67 @@ +// util.inspect and util.format calls inside [util.inspect.custom] methods +// start from their own defaults: no indentation from the outer render, the +// outer circular references kept, and no colors from the outer options. +import * as util from "node:util"; + +class Wide { + v: number; + constructor(v: number) { + this.v = v; + } + [util.inspect.custom](): string { + return util.inspect({ a: { b: [1, 2, { c: this.v }] }, s: "x".repeat(50), t: "y".repeat(30) }); + } +} +console.log({ outer: { inner: new Wide(1) } }); +type Holder = { first: Wide; list: Wide[]; self?: Holder }; +const holder: Holder = { first: new Wide(2), list: [new Wide(3)] }; +holder.self = holder; +console.log(holder); +console.log(util.inspect({ deep: { deeper: { c: new Wide(4) } } })); + +const shared = { k: 1 }; +class Shares { + [util.inspect.custom](): string { + return "S<" + util.inspect(shared) + ">"; + } +} +console.log({ a: shared, c: new Shares(), b: shared }); +type Loop = { c: Shares; me?: Loop; again?: Loop }; +const loop: Loop = { c: new Shares() }; +loop.me = loop; +loop.again = loop; +console.log(loop); + +class Formats { + [util.inspect.custom](): string { + return util.format("%s|%O|%o|%j|%d", "a", "b", "c", "d", 5); + } +} +console.log(JSON.stringify(util.inspect(new Formats(), { colors: true }))); +console.log(JSON.stringify(util.inspect({ f: new Formats() }, { colors: true }))); + +class Leaf { + [util.inspect.custom](depth: unknown, options: any): string { + return options.stylize("leaf", "special") + util.inspect({ z: 1, a: [1, 2] }); + } +} +class Branch { + leaf = new Leaf(); + [util.inspect.custom](): string { + return "B(" + util.inspect({ b: 2, a: { leaf: this.leaf, s: "q" } }) + ")"; + } +} +class Throws { + [util.inspect.custom](): string { + throw new Error("nope"); + } +} +const opts = { sorted: true, breakLength: 20, compact: false, colors: true }; +console.log(JSON.stringify(util.inspect({ y: new Branch(), x: [new Leaf()] }, opts))); +console.log(util.inspect({ k: { j: new Branch() } }, { depth: 0 })); +try { + console.log(util.inspect({ a: { b: new Throws() } })); +} catch (e) { + console.log("caught", (e as Error).message); +} +console.log({ after: { deep: { x: 1 } }, list: [1, 2, 3] }); diff --git a/tests/corpus/8121-inspect-custom-nested.cjs b/tests/corpus/8121-inspect-custom-nested.cjs new file mode 100644 index 000000000..aa5c75ca5 --- /dev/null +++ b/tests/corpus/8121-inspect-custom-nested.cjs @@ -0,0 +1,69 @@ +// util.inspect and util.format calls inside [util.inspect.custom] methods +// start from their own defaults: no indentation from the outer render and +// no colors from the outer options. +const util = require("node:util"); + +class Wide { + constructor(v) { + this.v = v; + } + [util.inspect.custom]() { + return util.inspect({ a: { b: [1, 2, { c: this.v }] }, s: "x".repeat(50), t: "y".repeat(30) }); + } +} +console.log({ outer: { inner: new Wide(1) } }); +console.log({ first: new Wide(2), list: [new Wide(3)] }); +console.log(util.inspect({ deep: { deeper: { c: new Wide(4) } } })); + +const shared = { k: 1 }; +class Shares { + [util.inspect.custom]() { + return "S<" + util.inspect(shared) + ">"; + } +} +console.log({ a: shared, c: new Shares(), b: shared }); +class Formats { + [util.inspect.custom]() { + return util.format("%s|%O|%o|%j|%d", "a", "b", "c", "d", 5); + } +} +console.log(JSON.stringify(util.inspect(new Formats(), { colors: true }))); +console.log(JSON.stringify(util.inspect({ f: new Formats() }, { colors: true }))); +class FormatsObjects { + [util.inspect.custom]() { + return ( + util.format("%O %O", { s: "b" }, [1]) + + " " + + util.format("x", "y", 3) + + " " + + util.formatWithOptions({}, "%O", "z") + ); + } +} +console.log(JSON.stringify(util.inspect([new FormatsObjects()], { colors: true }))); + +class Leaf { + [util.inspect.custom](depth, options) { + return options.stylize("leaf", "special") + util.inspect({ z: 1, a: [1, 2] }); + } +} +class Branch { + leaf = new Leaf(); + [util.inspect.custom]() { + return "B(" + util.inspect({ b: 2, a: { leaf: this.leaf, s: "q" } }) + ")"; + } +} +class Throws { + [util.inspect.custom]() { + throw new Error("nope"); + } +} +const opts = { sorted: true, breakLength: 20, compact: false, colors: true }; +console.log(JSON.stringify(util.inspect({ y: new Branch(), x: [new Leaf()] }, opts))); +console.log(util.inspect({ k: { j: new Branch() } }, { depth: 0 })); +try { + console.log(util.inspect({ a: { b: new Throws() } })); +} catch (e) { + console.log("caught", e.message); +} +console.log({ after: { deep: { x: 1 } }, list: [1, 2, 3] }); diff --git a/tests/corpus/8122-inspect-custom-runtime-only.ts b/tests/corpus/8122-inspect-custom-runtime-only.ts new file mode 100644 index 000000000..d84dfa63d --- /dev/null +++ b/tests/corpus/8122-inspect-custom-runtime-only.ts @@ -0,0 +1,34 @@ +// Classes whose [util.inspect.custom] method runs only from the runtime +// inspect walk (instances held as unknown or in parsed JSON) compile with +// the functions and methods that method calls. +import * as util from "node:util"; +function helper(n: number): string { + return "H" + n; +} +class Base { + [util.inspect.custom](): string { + return helper(1) + this.tag(); + } + tag(): string { + return "-base"; + } +} +class Derived extends Base { + tag(): string { + return "-derived"; + } +} +class Box { + item: T; + constructor(item: T) { + this.item = item; + } + [util.inspect.custom](): string { + return "Box(" + helper(2) + ")"; + } +} +const xs: unknown[] = [new Derived(), new Box(3)]; +console.log(xs); +const j = JSON.parse('{"a":1}'); +j.d = new Derived(); +console.log(j); diff --git a/tests/corpus/8125-inspect-custom-throws.ts b/tests/corpus/8125-inspect-custom-throws.ts new file mode 100644 index 000000000..33077a32b --- /dev/null +++ b/tests/corpus/8125-inspect-custom-throws.ts @@ -0,0 +1,32 @@ +// A [util.inspect.custom] method that throws inside an outer inspect: an +// enclosing custom method that catches the error, and the walk around it, +// continue with their own indentation, circular references and colors. +import * as util from "node:util"; +class Throws { + [util.inspect.custom](): string { + throw new Error("inner"); + } +} +class Catches { + [util.inspect.custom](): string { + let note = ""; + try { + note = util.inspect({ a: { b: [new Throws()] } }); + } catch (e) { + note = "caught " + (e as Error).message; + } + return note + " then " + util.inspect({ x: { y: [1, 2] } }); + } +} +const shared = { k: 1 }; +type Outer = { first: { deep: Catches }; list: unknown[]; s: object; t: object; me?: Outer }; +const outer: Outer = { first: { deep: new Catches() }, list: [new Catches(), { z: 3 }], s: shared, t: shared }; +outer.me = outer; +console.log(util.inspect(outer, { breakLength: 50 })); +console.log(JSON.stringify(util.inspect([new Catches(), "s"], { colors: true }))); +try { + console.log(util.inspect({ a: { b: new Throws() } })); +} catch (e) { + console.log("outer caught", (e as Error).message); +} +console.log({ after: { deep: { x: 1 } }, list: [1, 2, 3] }); diff --git a/tests/corpus/8126-inspect-custom-throws.cjs b/tests/corpus/8126-inspect-custom-throws.cjs new file mode 100644 index 000000000..8d4e97608 --- /dev/null +++ b/tests/corpus/8126-inspect-custom-throws.cjs @@ -0,0 +1,30 @@ +// A [util.inspect.custom] method that throws inside an outer inspect: an +// enclosing custom method that catches the error, and the walk around it, +// continue with their own indentation and colors. +const util = require("node:util"); +class Throws { + [util.inspect.custom]() { + throw new Error("inner"); + } +} +class Catches { + [util.inspect.custom]() { + let note = ""; + try { + note = util.inspect({ a: { b: [new Throws()] } }); + } catch (e) { + note = "caught " + e.message; + } + return note + " then " + util.inspect({ x: { y: [1, 2] } }); + } +} +const shared = { k: 1 }; +const outer = { first: { deep: new Catches() }, list: [new Catches(), { z: 3 }], s: shared, t: shared }; +console.log(util.inspect(outer, { breakLength: 50 })); +console.log(JSON.stringify(util.inspect([new Catches(), "s"], { colors: true }))); +try { + console.log(util.inspect({ a: { b: new Throws() } })); +} catch (e) { + console.log("outer caught", e.message); +} +console.log({ after: { deep: { x: 1 } }, list: [1, 2, 3] }); diff --git a/tests/corpus/8183-key-passphrase-encoding.cjs b/tests/corpus/8183-key-passphrase-encoding.cjs index e52fa6c1c..ca817f386 100644 --- a/tests/corpus/8183-key-passphrase-encoding.cjs +++ b/tests/corpus/8183-key-passphrase-encoding.cjs @@ -9,12 +9,17 @@ const hex = Buffer.from(pass).toString("hex"); const pem = privateKey.export({ format: "pem", type: "pkcs8", cipher: "aes-256-cbc", passphrase: hex, encoding: "hex" }); const der = privateKey.export({ format: "der", type: "pkcs8", cipher: "aes-128-cbc", passphrase: pass }); +// A wrong passphrase usually fails the decryption's padding check +// (ERR_OSSL_BAD_DECRYPT); with a fresh key and IV each run, Node's +// OpenSSL now and then accepts the padding and rejects the decrypted +// structure instead (ERR_OSSL_UNSUPPORTED). Both are reported as one. function attempt(label, options) { try { const key = c.createPrivateKey(options); console.log(label, key.asymmetricKeyType, key.equals(privateKey)); } catch (e) { - console.log(label, "throws", e.code); + const wrong = e.code === "ERR_OSSL_BAD_DECRYPT" || e.code === "ERR_OSSL_UNSUPPORTED"; + console.log(label, "throws", wrong ? "a decryption error" : e.code); } } diff --git a/tests/corpus/8331-map-number-hashed-integer-keys.ts b/tests/corpus/8331-map-number-hashed-integer-keys.ts new file mode 100644 index 000000000..8af7293e9 --- /dev/null +++ b/tests/corpus/8331-map-number-hashed-integer-keys.ts @@ -0,0 +1,102 @@ +// Number-keyed Map and Set lookups whose keys are integers the compiler +// tracks as integers (loop counters, literals, integer arithmetic) on +// hashed storage, and lookups of absent keys on small bucket tables. + +// Keys spread past the direct index's range, so the maps hash them. +const spread = new Map(); +for (let i = 0; i < 500; i++) spread.set(i * 104729 - 26000000, i); +let found = 0, + absent = 0, + total = 0; +for (let i = -10; i < 520; i++) { + const v = spread.get(i * 104729 - 26000000); + if (v === undefined) absent++; + else { + found++; + total += v; + } +} +console.log("spread", spread.size, found, absent, total); +console.log("spread literals", spread.get(-26000000), spread.get(-25895271), spread.get(0), spread.has(-1)); + +// Zero stored through -0 and through an integer; read back through both. +const zeros = new Map(); +for (let i = 1; i <= 20; i++) zeros.set(i * 1000003, "k" + i); +zeros.set(-0, "minus zero"); +let z = 0; +console.log("zeros", zeros.size, zeros.get(z), zeros.get(0), zeros.get(-0), zeros.get(z * -1)); +zeros.set(z, "zero"); +console.log("zeros overwrite", zeros.size, zeros.get(-0), [...zeros.keys()].includes(0)); + +// Large and negative integer keys. +const wide = new Map(); +for (let i = 0; i < 64; i++) wide.set(2 ** 40 + i * 3, i); +for (let i = 0; i < 64; i++) wide.set(-(2 ** 33) - i, -i); +let wideUp = 0, + wideDown = 0, + wideMissing = 0; +for (let i = 0; i < 200; i++) { + const a = wide.get(2 ** 40 + i); + const b = wide.get(-(2 ** 33) - i); + if (a === undefined) wideMissing++; + else wideUp += a; + if (b === undefined) wideMissing++; + else wideDown += b; +} +console.log("wide", wide.size, wideUp, wideDown, wideMissing, wide.get(1099511627776), wide.get(-8589934592)); + +// Absent keys on bucket tables just past the linear scan, and as they grow. +for (const size of [9, 12, 16, 33, 64, 100, 257, 400]) { + const m = new Map(); + for (let i = 0; i < size; i++) m.set(i * 7919 + 1000000, i); + let hits = 0, + misses = 0; + for (let i = 0; i < size * 4; i++) { + if (m.has(i * 7919 + 1000000)) hits++; + else misses++; + if (m.get(i * 7919 + 1000001) !== undefined) hits += 1000; + } + for (let i = 0; i < size; i += 2) m.delete(i * 7919 + 1000000); + let after = 0; + for (let i = 0; i < size; i++) if (m.has(i * 7919 + 1000000)) after++; + console.log("size", size, hits, misses, m.size, after, m.get(1000000), m.get(1007919)); +} + +// Small maps that scan their entries, with integer keys. +const tiny = new Map(); +for (let i = 0; i < 8; i++) tiny.set(i * 5000011, "t" + i); +let tinyHits = 0; +for (let i = 0; i < 40; i++) if (tiny.has(i * 5000011)) tinyHits++; +console.log("tiny", tiny.size, tinyHits, tiny.get(0), tiny.get(35000077), tiny.has(5000012)); + +// Sets with integer keys. +const seen = new Set(); +let dupes = 0; +for (let i = 0; i < 3000; i++) { + const k = ((i * 2654435761) % 1000003) - 500000; + if (seen.has(k)) dupes++; + else seen.add(k); +} +let present = 0; +for (let i = -500000; i < 500003; i += 997) if (seen.has(i)) present++; +console.log("set", seen.size, dupes, present, seen.has(-500000), seen.has(500002)); + +// A counting loop like a k-mer histogram: get, then update the found entry. +class Count { + n = 0; +} +const counts = new Map(); +let key = 0; +for (let i = 0; i < 20000; i++) { + key = ((key * 4) % 4194304) + (i % 7 === 0 ? 3 : i % 3); + const c = counts.get(key); + if (c === undefined) { + const fresh = new Count(); + fresh.n = 1; + counts.set(key, fresh); + } else c.n++; +} +let most = 0, + mostKey = -1; +for (const [k, c] of counts) if (c.n > most) [most, mostKey] = [c.n, k]; +console.log("counts", counts.size, most, mostKey); diff --git a/tests/corpus/8355-row-pair-columns.ts b/tests/corpus/8355-row-pair-columns.ts new file mode 100644 index 000000000..dccd9ca6d --- /dev/null +++ b/tests/corpus/8355-row-pair-columns.ts @@ -0,0 +1,94 @@ +// Row sums whose inner loop runs two columns per step: even, odd, zero, one +// and two columns, column ranges that start above zero or at or past their +// end, fractional, negative, NaN and very large column bounds, a column +// bound read from an array's length, terms that read two elements per +// column, row bounds at and past 2^25, and row and column starts of -0 +// with terms that tell -0 from +0. Every sum adds its columns in order, +// so each has the bits of the loop as written. +function rows(u: Float64Array, v: Float64Array, start: number, end: number, from: number, to: number): void { + for (let i = start; i < end; i++) { + let t = 0; + for (let j = from; j < to; j++) t += u[j]! / ((((i + j) * (i + j + 1)) >>> 1) + i + 1); + v[i] = t; + } +} + +function pairs(u: Float64Array, w: Float32Array, v: Float64Array, end: number): void { + for (let i = 0; i < end; i++) { + let t = -0.25; + for (let j = 0; j < u.length; j++) t += u[j]! * w[j + 1]! - (i + 1) / (j + 3); + v[i] = t; + } +} + +// 1 / (i * 4 - j) is -Infinity at a -0 row and +0 column, and +// 1 / (j - i * 4) at a -0 column and +0 row: the first pass sees the sign. +function signs(v: Float64Array, start: number, end: number, from: number, to: number): void { + for (let i = start; i < end; i++) { + let t = 0; + for (let j = from; j < to; j++) t += 1 / (i * 4 - j) + 1 / (j - i * 4) + 1 / (i * 4 + j + 1); + v[i] = t; + } +} + +const show = (label: string, v: Float64Array): void => { + const parts: string[] = []; + for (const x of v) parts.push(Object.is(x, -0) ? "-0" : x.toPrecision(17)); + console.log(label, parts.join(" ")); +}; + +const n = 9; +const u = new Float64Array(n).map((_, k) => 1 / (k + 1) + k); +for (const [from, to] of [ + [0, n], + [0, n - 1], + [0, 0], + [0, 1], + [0, 2], + [3, n], + [4, n], + [5, 5], + [7, 2], + [0.5, 6], + [0, 6.5], + [-2, 5], + [-0, 4], + [NaN, 4], + [0, NaN], + [0, n + 3], + [2 ** 25, 2 ** 25 + 3], +]) { + const v = new Float64Array(6); + rows(u, v, 0, 6, from!, to!); + show(`columns ${from} ${to}`, v); +} +for (const [start, end] of [ + [2 ** 25 - 3, 2 ** 25 + 1], + [2 ** 25, 2 ** 25 + 2], + [1, 6], +]) { + const v = new Float64Array(8); + rows(u, v, start!, end!, 0, n); + show(`rows ${start} ${end}`, v); +} + +const w = new Float32Array(n + 1).map((_, k) => (k % 4) - 1.5); +for (const end of [0, 1, 4, 5]) { + const v = new Float64Array(5); + pairs(u, w, v, end); + show(`two reads ${end}`, v); +} +const short = new Float64Array(4).map((_, k) => k * 2.5); +const out = new Float64Array(5); +pairs(short, w, out, 5); +show("two reads short", out); +for (const [start, from] of [ + [0, 0], + [-0, 0], + [0, -0], + [-0, -0], +]) { + const v = new Float64Array(4); + signs(v, start!, 4, from!, 5); + show(`signs ${Object.is(start, -0) ? "-0" : start} ${Object.is(from, -0) ? "-0" : from}`, v); +} diff --git a/tests/corpus/8356-shared-view-loop-versions.ts b/tests/corpus/8356-shared-view-loop-versions.ts new file mode 100644 index 000000000..28f4307fb --- /dev/null +++ b/tests/corpus/8356-shared-view-loop-versions.ts @@ -0,0 +1,93 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Loops over typed-array views of shared memory in a worker program: row +// sums and element-wise passes whose accesses are in bounds, the same loops +// running past a view's end, a loop that mixes a shared view with one the +// thread owns, and views of one SharedArrayBuffer that overlap, written in +// an order that shows whether each read sees the previous write. The worker +// writes its results into shared memory, which the main thread reads after +// the worker reports. + +interface Job { + sab: SharedArrayBuffer; + n: number; +} + +function rowSums(u: Float64Array, v: Float64Array, start: number, end: number, n: number): void { + for (let i = start; i < end; i++) { + let t = 0; + for (let j = 0; j < n; j++) t = t + u[j]! / (((i + j) * (i + j + 1)) / 2 + i + 1); + v[i] = t; + } +} + +function scaled(src: Float64Array, dst: Float64Array, k: number, count: number): void { + for (let i = 0; i < count; i++) dst[i] = src[i]! * k + 1; +} + +function mixed(shared: Int32Array, owned: Int32Array, count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) { + owned[i] = shared[i]! * 3; + shared[i] = owned[i]! - i; + sum = (sum + shared[i]! + owned[i]!) | 0; + } + return sum; +} + +function shift(a: Float64Array, b: Float64Array, count: number): void { + for (let i = 0; i < count; i++) a[i + 1] = b[i]! + 1; +} + +function checksum(values: Float64Array): string { + let h = 0; + for (let i = 0; i < values.length; i++) h = (Math.imul(h, 31) + Math.round(values[i]! * 1e6)) >>> 0; + return h.toString(16); +} + +function work({ sab, n }: Job): string[] { + const out: string[] = []; + const u = new Float64Array(sab, 0, n); + const v = new Float64Array(sab, n * 8, n); + for (let i = 0; i < n; i++) u[i] = 1 + (i % 5) / 4; + rowSums(u, v, 0, n, n); + out.push(`rows ${checksum(v)}`); + // Past the end of u: the reads beyond it are undefined, so the sums are NaN. + rowSums(u, v, 0, 4, n + 3); + out.push(`past ${v[0]} ${v[3]} ${checksum(v.subarray(4))}`); + scaled(u, v, 0.5, n); + out.push(`scaled ${checksum(v)}`); + scaled(u, v, 2, n + 2); + out.push(`scaled past ${checksum(v)}`); + const ints = new Int32Array(sab, 2 * n * 8, 32); + for (let i = 0; i < 32; i++) ints[i] = i * 7 - 50; + const own = new Int32Array(32); + out.push(`mixed ${mixed(ints, own, 32)} ${mixed(ints, own, 34)} ${ints[5]} ${own[31]}`); + // a[i + 1] lands on b[i + 1] when b starts one element after a. + const a = new Float64Array(sab, 0, 24); + const b = new Float64Array(sab, 8, 24); + for (let i = 0; i < 25; i++) u[i] = i; + shift(a, b, 20); + out.push(`overlap ${Array.from(a.subarray(0, 8)).join(",")}`); + shift(b, a, 20); + out.push(`overlap back ${Array.from(b.subarray(0, 8)).join(",")}`); + return out; +} + +if (isMainThread) { + const n = 40; + const sab = new SharedArrayBuffer(2 * n * 8 + 32 * 4); + const worker = new Worker(new URL(import.meta.url), { workerData: { sab, n } satisfies Job }); + worker.on("message", (lines: string[]) => { + for (const line of lines) console.log("worker", line); + const v = new Float64Array(sab, n * 8, n); + const ints = new Int32Array(sab, 2 * n * 8, 32); + console.log("main sees", checksum(v), ints[0], ints[31]); + // The same passes on the main thread, over a fresh buffer. + const again = new SharedArrayBuffer(2 * n * 8 + 32 * 4); + for (const line of work({ sab: again, n })) console.log("main", line); + }); + worker.on("exit", (code: number) => console.log("exit", code)); +} else { + parentPort!.postMessage(work(workerData as Job)); +} diff --git a/tests/corpus/8360-record-prototype-method-calls.ts b/tests/corpus/8360-record-prototype-method-calls.ts new file mode 100644 index 000000000..980e4b5d2 --- /dev/null +++ b/tests/corpus/8360-record-prototype-method-calls.ts @@ -0,0 +1,56 @@ +// Object.prototype's methods called by name on typed records: the built-in +// with the record as its receiver, or an own entry that shadows it. +interface Point { + x: number; + y: number; +} +const counts: Record = { apple: 2, pear: 0 }; +const point: Point = { x: 1, y: 2 }; +const handlers: Record number> = { double: (n) => n * 2 }; +const own: Record string> = { + toString: () => "own toString", + valueOf: () => "own valueOf", +}; + +function line(...parts: unknown[]): void { + console.log(parts.map(String).join(" | ")); +} + +line("toString", counts.toString(), point.toString(), handlers.toString(), String(counts)); +line("toLocaleString", counts.toLocaleString(), point.toLocaleString()); +line("valueOf", counts.valueOf() === counts, point.valueOf() === point); +line( + "hasOwnProperty", + counts.hasOwnProperty("apple"), + counts.hasOwnProperty("pear"), + counts.hasOwnProperty("plum"), + counts.hasOwnProperty("toString"), + point.hasOwnProperty("x"), + point.hasOwnProperty("z"), + handlers.hasOwnProperty("double"), + handlers.hasOwnProperty("valueOf"), +); +line("propertyIsEnumerable", counts.propertyIsEnumerable("apple"), point.propertyIsEnumerable("toString")); +line("isPrototypeOf", counts.isPrototypeOf(point), point.isPrototypeOf(counts)); + +// Own entries shadow the built-ins; toLocaleString calls the own toString. +line("own", own.toString(), own.toLocaleString(), own.valueOf(), own.hasOwnProperty("toString")); +delete own["toString"]; +line("own deleted", own.toString(), own.toLocaleString()); +own.toString = () => "own again"; +line("own again", own.toString(), `${String(own.toString())}`); + +// Keys added at run time are own keys too. +const later: Record = {}; +for (const word of "toString valueOf plain toString".split(" ")) { + later[word] = (later.hasOwnProperty(word) ? later[word]! : 0) + 1; +} +line("later", JSON.stringify(later), later.hasOwnProperty("toString"), later.hasOwnProperty("constructor")); + +// A null-prototype record has no inherited methods. +const groups = Object.groupBy([1, 2, 3], (n): string => (n % 2 ? "odd" : "even")); +try { + line("groups", groups.toString()); +} catch (e) { + line("groups threw", (e as Error).name + ": " + (e as Error).message); +} diff --git a/tests/corpus/8361-record-function-table-inherited.ts b/tests/corpus/8361-record-function-table-inherited.ts new file mode 100644 index 000000000..eecf74f02 --- /dev/null +++ b/tests/corpus/8361-record-function-table-inherited.ts @@ -0,0 +1,40 @@ +// Records of functions used as dispatch tables meet Object.prototype's method +// names: a keyed read answers the inherited method, bound to the record when +// the read is called at once and a plain function value otherwise. +type Command = (arg: string) => unknown; +const commands: Record = { + echo: (arg) => arg, + shout: (arg) => arg.toUpperCase(), +}; +const inputs = process.argv.length > 99 ? [] : ["echo", "toString", "valueOf", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toLocaleString", "shout", "nope"]; +function show(v: unknown): string { + return v === commands ? "" : typeof v + ":" + String(v); +} +for (const name of inputs) { + const handler = commands[name]; + if (!handler) { + console.log(name, "unknown handler"); + continue; + } + console.log(name, typeof handler, "direct", show(commands[name](name === "hasOwnProperty" ? "echo" : "x"))); + console.log(name, "optional", show(commands[name]?.("shout"))); + try { + console.log(name, "detached", show(handler("echo"))); + } catch (e) { + console.log(name, "detached threw", (e as Error).name + ": " + (e as Error).message); + } +} +console.log(typeof commands["toString"], typeof commands["valueOf"], commands["toString"] === undefined, commands["toString"] === commands["toString"]); +console.log(String(commands["toString"]("x")), commands["hasOwnProperty"]("shout"), commands["hasOwnProperty"]("nope")); +const literal: Record = {}; +console.log(Object.keys(literal).length, literal["isPrototypeOf"]?.("x"), typeof literal.toString); + +// A word count whose words are not Object.prototype names keeps `?? 0`. +const freq: Record = {}; +for (const w of "a rose is a rose is a rose".split(" ")) freq[w] = (freq[w] ?? 0) + 1; +console.log(JSON.stringify(freq), freq["tulip"] ?? 0); + +// Read by name without a call: a plain function value. +const counts: Record = { a: 1 }; +const named = counts.toString; +console.log(typeof counts.toString, typeof commands.valueOf, String(named())); diff --git a/tests/corpus/8362-record-inherited-order.ts b/tests/corpus/8362-record-inherited-order.ts new file mode 100644 index 000000000..33a72eb91 --- /dev/null +++ b/tests/corpus/8362-record-inherited-order.ts @@ -0,0 +1,40 @@ +// Object.prototype's methods on typed records keep JavaScript's evaluation +// order and key conversions. + +// A function-table key that reassigns the receiver reads the table +// evaluated before the key. +let table: Record string> = { a: () => "first" }; +const other: Record string> = { a: () => "second" }; +function key(): string { + table = other; + return "a"; +} +console.log(String(table[key()]())); + +// hasOwnProperty and propertyIsEnumerable with a symbol key on a record of +// functions test the symbol key. +const fns: Record number> = { double: (n) => n * 2 }; +const sym = Symbol("s"); +console.log(fns.hasOwnProperty(sym as unknown as string), fns.propertyIsEnumerable(sym as unknown as string)); + +// A call by name reads the method before its arguments run: an argument +// that adds, replaces or deletes the own method does not change the callee. +type Has = Record boolean>; +function add(r: Has): string { + r.hasOwnProperty = () => true; + return "zzz"; +} +function swap(r: Has): string { + r.hasOwnProperty = (key) => key === "b"; + return "a"; +} +function drop(r: Has): string { + delete r["hasOwnProperty"]; + return "zzz"; +} +const added: Has = {}; +console.log(added.hasOwnProperty(add(added)), added.hasOwnProperty("zzz")); +const swapped: Has = { hasOwnProperty: (key) => key === "a" }; +console.log(swapped.hasOwnProperty(swap(swapped)), swapped.hasOwnProperty("a")); +const removed: Has = { hasOwnProperty: () => true }; +console.log(removed.hasOwnProperty(drop(removed)), removed.hasOwnProperty("zzz")); diff --git a/tests/corpus/dyn-union-function-arms.ts b/tests/corpus/dyn-union-function-arms.ts new file mode 100644 index 000000000..e85cc2e6b --- /dev/null +++ b/tests/corpus/dyn-union-function-arms.ts @@ -0,0 +1,48 @@ +// A union of a function type with a value the checked-dynamic tree holds +// (AbortSignal, `unknown`) rides that representation: the function is held +// by reference, `typeof x === "function"` narrows it back out, and calls, +// identity and the other arm's members behave as in Node. + +type Progress = (loaded: number, total: number) => void; + +function listAll( + dirOrProgress?: string | Progress, + progressOrSignal?: Progress | AbortSignal, + signal?: AbortSignal, +): string { + const dir = typeof dirOrProgress === "string" ? dirOrProgress : "/default"; + const progress = + typeof dirOrProgress === "function" + ? dirOrProgress + : typeof progressOrSignal === "function" + ? progressOrSignal + : undefined; + const abortSignal = + typeof dirOrProgress === "string" || typeof progressOrSignal === "function" + ? signal + : (progressOrSignal ?? signal); + progress?.(1, 3); + progress?.(3, 3); + return `${dir} aborted=${abortSignal?.aborted ?? "none"}`; +} + +const seen: string[] = []; +const report: Progress = (loaded, total) => seen.push(`${loaded}/${total}`); +const controller = new AbortController(); +console.log(listAll()); +console.log(listAll("/x", report)); +console.log(listAll(report, controller.signal)); +controller.abort(); +console.log(listAll("/y", undefined, controller.signal)); +console.log(listAll(report, controller.signal), seen.join(" ")); + +function same(handler: Progress | AbortSignal | undefined): boolean { + return handler === report; +} +console.log(same(report), same(controller.signal), same(undefined), same((a, b) => void (a + b))); + +function describe(value: unknown | ((n: number) => string)): string { + if (typeof value === "function") return `function -> ${(value as (n: number) => string)(7)}`; + return `value ${JSON.stringify(value)}`; +} +console.log(describe((n: number) => `n=${n}`), describe({ a: 1 }), describe("s")); diff --git a/tests/corpus/evolving-let-shorthand-reads.js b/tests/corpus/evolving-let-shorthand-reads.js new file mode 100644 index 000000000..201171bcc --- /dev/null +++ b/tests/corpus/evolving-let-shorthand-reads.js @@ -0,0 +1,41 @@ +// An initializer-less JavaScript `let` read through a shorthand property +// (`{ width }`) still reads undefined on the paths that never assign it, as +// npm packages do when they build an options or size object. +function size(tty) { + let width; + if (tty) width = 80; + return { width }; +} + +/** + * @param {boolean} tty + * @returns {{ width: number | undefined }} + */ +function documented(tty) { + /** @type {number | undefined} */ + let width; + if (tty) width = 80; + return { width }; +} + +function captured(tty) { + let width; + const read = () => ({ width }); + const before = read().width; + if (tty) width = 80; + return [before, read().width]; +} + +function nested(flags) { + let columns; + let rows; + if (flags.columns) columns = 120; + if (flags.rows) rows = 40; + return { size: { columns, rows } }; +} + +console.log(size(true).width, size(false).width); +console.log(documented(true).width, documented(false).width); +console.log(JSON.stringify(size(false)), "width" in size(false)); +console.log(captured(true), captured(false)); +console.log(nested({ columns: true }), nested({ rows: true }), nested({})); diff --git a/tests/corpus/generic-alias-cast-instances.ts b/tests/corpus/generic-alias-cast-instances.ts new file mode 100644 index 000000000..11a098214 --- /dev/null +++ b/tests/corpus/generic-alias-cast-instances.ts @@ -0,0 +1,103 @@ +// Casts to a generic type alias spelled over the enclosing function's own +// type parameters (`x as Catalog`). The checker keeps such an alias +// symbolic inside the generic body; each instance maps it with its own bound +// type arguments. + +type Groups = Record>; + +type EntryKey = { [A in keyof G]: keyof G[A] }[keyof G] & string; +type KeyOfKind = Extract, `${K}:${string}`>; +type EntryId = K extends `${string}:${infer I}` ? I : never; + +type Catalog = { + [Key in KeyOfKind as EntryId]: { id: EntryId; label: string; owner: P }; +}; + +function flatten(groups: Groups, kind: string): Record { + return Object.fromEntries( + Object.values(groups) + .flatMap((entries) => Object.entries(entries)) + .filter(([key]) => key.startsWith(kind + ":")) + .map(([, entry]) => [(entry as { id: string }).id, entry]), + ); +} + +function toolCatalog( + _owner: P, + groups: G, +): Catalog { + return flatten(groups, "tool") as Catalog; +} + +function partCatalog( + _owner: P, + groups: G, +): Catalog { + return flatten(groups, "part") as Catalog; +} + +const workshop = { + bench: { + "tool:saw": { id: "saw", label: "Hand saw", owner: "acme" }, + "part:bolt": { id: "bolt", label: "Hex bolt", owner: "acme" }, + }, + shelf: { + "tool:drill": { id: "drill", label: "Cordless drill", owner: "acme" }, + }, +}; + +const tools = toolCatalog("acme", workshop); +console.log(tools.saw.label, tools.drill.id, tools.drill.owner); +console.log(Object.keys(tools).join(","), JSON.stringify(tools.saw)); + +const parts = partCatalog("acme", workshop); +console.log(parts.bolt.label, Object.keys(parts).length); + +// A second instantiation with different groups gets its own layout. +const garden = { + shed: { + "tool:rake": { id: "rake", label: "Leaf rake", owner: "green" }, + "tool:hoe": { id: "hoe", label: "Garden hoe", owner: "green" }, + }, +}; +const gardenTools = toolCatalog("green", garden); +console.log(gardenTools.rake.label, gardenTools.hoe.label, Object.keys(gardenTools).join(",")); + +// No entry of the kind: the alias resolves to the empty record. +const gardenParts = partCatalog("green", garden); +console.log(Object.keys(gardenParts).length, JSON.stringify(gardenParts)); + +// A homomorphic mapped alias over the bound type, cast inside the body. +type Boxed = { [K in keyof T]: { value: T[K] } }; + +function boxAll>(values: T): Boxed { + const out: Record = {}; + for (const key of Object.keys(values)) out[key] = { value: values[key] }; + return out as Boxed; +} + +const boxed = boxAll({ width: 3, height: 4 }); +console.log(boxed.width.value * boxed.height.value); + +// The alias nested in an array type of the signature. +function boxEach>(rows: T[]): Boxed[] { + return rows.map((row) => { + const out: Record = {}; + for (const key of Object.keys(row)) out[key] = { value: row[key] }; + return out as Boxed; + }); +} + +const rows = boxEach([ + { x: 1, y: 2 }, + { x: 5, y: 8 }, +]); +console.log(rows.map((r) => r.x.value + r.y.value).join(" ")); + +// A generic body forwarding its parameters to another generic cast. +function ownedTools(groups: G): Catalog { + return toolCatalog("shared", groups); +} + +const shared = ownedTools({ rack: { "tool:clamp": { id: "clamp", label: "C-clamp", owner: "shared" } } }); +console.log(shared.clamp.label, shared.clamp.owner); diff --git a/tests/corpus/generic-member-lambda-impls.ts b/tests/corpus/generic-member-lambda-impls.ts new file mode 100644 index 000000000..fd25bae96 --- /dev/null +++ b/tests/corpus/generic-member-lambda-impls.ts @@ -0,0 +1,40 @@ +// An object literal implements an interface's generic method with a plain +// lambda (`send: (m) => …` for `send(m: Message): string`). The +// lambda takes the contextual signature's type parameters as its own and +// lowers once, erased, like a generic lambda: its parameters hold whatever +// each call passes. + +interface Message { + content: string; + details?: T; +} + +interface Sender { + name: string; + send(message: Message): string; + notify(message: Message): void; + pick(keys: K[], fallback: K): K; +} + +const log: string[] = []; + +function makeSender(name: string): Sender { + return { + name, + send: (message) => `${name} sent ${message.content} (${message.details === undefined ? "no details" : JSON.stringify(message.details)})`, + notify: (message) => { + log.push(`${name} notified ${message.content}`); + }, + pick: (keys, fallback) => keys[0] ?? fallback, + }; +} + +const senders: Sender[] = [makeSender("first"), makeSender("second")]; +for (const sender of senders) { + console.log(sender.send({ content: "hi", details: { n: 3 } })); + console.log(sender.send({ content: "bare" })); + console.log(sender.send({ content: "list", details: [1, 2] })); + sender.notify({ content: "ping", details: true }); + console.log(sender.pick(["a", "b"], "z"), sender.pick([], "z")); +} +console.log(log.join("; ")); diff --git a/tests/corpus/native-worker-terminate-bounded-loops.ts b/tests/corpus/native-worker-terminate-bounded-loops.ts new file mode 100644 index 000000000..ec1e4c736 --- /dev/null +++ b/tests/corpus/native-worker-terminate-bounded-loops.ts @@ -0,0 +1,102 @@ +// worker.terminate() stops a worker whose loop runs short inner loops: +// counted loops of a few iterations, a two-counter loop, an inner loop +// whose length grows past 64 iterations, an inner loop too long to finish, +// a nest of loops with constant bounds, and an inner loop whose counter +// stops changing at 2 ** 53 (`j + 1 === j` there), so it never ends. +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +function counted(values: number[]): number { + let acc = 0; + for (let round = 0; ; round++) { + for (let i = 0; i < values.length; i++) acc = (acc + values[i]! * i) % 1000003; + for (let i = values.length - 1; i >= 0; i -= 2) acc = (acc * 7 + values[i]!) % 1000003; + if (round === 200) parentPort!.postMessage("ready"); + } + return acc; +} + +function paired(values: number[]): number { + let flips = 0; + for (let round = 0; ; round++) { + for (let i = 0, j = values.length - 1; i < j; i++, j--) { + const t = values[i]!; + values[i] = values[j]!; + values[j] = t; + flips++; + } + if (round === 200) parentPort!.postMessage("ready"); + } + return flips; +} + +function growing(): number { + let acc = 0; + for (let n = 1; ; n++) { + const limit = n % 200; + for (let i = 0; i < limit; i++) acc = (acc + i) % 1000003; + if (n === 200) parentPort!.postMessage("ready"); + } + return acc; +} + +function long(limit: number): number { + let acc = 0; + for (let round = 0; ; round++) { + for (let i = 0; i < limit; i++) { + acc = (acc * 7 + i) % 1000003; + if (i === 100000) parentPort!.postMessage("ready"); + } + } + return acc; +} + +const ROUNDS = 50; + +function constant(row: Float64Array): number { + let acc = 0; + for (let y = 0; ; y++) { + for (let i = 0; i < 8; i += 2) { + const c = row[i]!; + for (let j = 0; j < ROUNDS; j++) { + acc = (acc * 3 + j + c) % 1000003; + if (acc === -1) break; + } + } + if (y === 200) parentPort!.postMessage("ready"); + } + return acc; +} + +function stalled(): number { + let acc = 0; + for (let round = 0; ; round++) { + if (round === 0) parentPort!.postMessage("ready"); + for (let j = 9007199254740990; j < 9007199254741002; j++) acc = (acc + (j % 7)) % 1000003; + } + return acc; +} + +if (isMainThread) { + for (const kind of ["counted", "paired", "growing", "long", "constant", "stalled"]) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + worker.once("message", () => { + void worker.terminate().then((code: number) => console.log(kind, "terminate", code)); + }); + worker.once("exit", (code: number) => { + console.log(kind, "exit", code); + resolve(); + }); + }); + } +} else { + const values = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8]; + let result = 0; + if (workerData === "counted") result = counted(values); + else if (workerData === "paired") result = paired(values); + else if (workerData === "growing") result = growing(); + else if (workerData === "long") result = long(1e15); + else if (workerData === "stalled") result = stalled(); + else result = constant(new Float64Array(8).fill(0.25)); + console.log("unexpected result", result); +} diff --git a/tests/corpus/recursive-generic-method-members.ts b/tests/corpus/recursive-generic-method-members.ts new file mode 100644 index 000000000..1f4d8372d --- /dev/null +++ b/tests/corpus/recursive-generic-method-members.ts @@ -0,0 +1,58 @@ +// A recursive record that declares a generic method compiles. Erasing the +// method's own type parameters does not depend on any enclosing generic +// binding, so the record still closes its recursive knot: a parent +// interface whose child is a subinterface declaring a generic method, and a +// list node whose generic method sits beside a self-referencing field. + +interface Message { + content: string; + details?: T; +} + +interface TreeNode { + name: string; + child?: SendingNode; +} + +interface SendingNode extends TreeNode { + send(message: Message): string; +} + +interface ListNode { + value: number; + next: ListNode | null; + map(f: (value: number) => U): string; +} + +function describe(node: TreeNode): string { + const inner = node.child; + return inner ? `${node.name} > ${describe(inner)}` : node.name; +} + +function leaf(name: string): SendingNode { + return { name, send: () => `${name} got a message` }; +} + +const tree: SendingNode = { + name: "root", + child: { name: "middle", child: leaf("leaf"), send: () => "middle got a message" }, + send: () => "root got a message", +}; +console.log(describe(tree)); +console.log(tree.send({ content: "hello" })); +const middle = tree.child; +if (middle !== undefined) console.log(middle.send({ content: "hi", details: 42 })); + +function cons(value: number, next: ListNode | null): ListNode { + return { value, next, map: () => `node ${value}` }; +} + +let list: ListNode | null = cons(1, cons(2, cons(3, null))); +const values: number[] = []; +let labels = ""; +while (list !== null) { + values.push(list.value); + labels += `[${list.map((v) => v * 2)}]`; + list = list.next; +} +console.log(values.join(","), labels); diff --git a/tests/corpus/recursive-optional-callback-params.ts b/tests/corpus/recursive-optional-callback-params.ts new file mode 100644 index 000000000..0c81235d6 --- /dev/null +++ b/tests/corpus/recursive-optional-callback-params.ts @@ -0,0 +1,107 @@ +// An optional parameter whose type reaches back to the interface that +// declares the method keeps its whole union. A command context's +// `newSession(options?)` takes callbacks typed by a subinterface of the +// context, so mapping the context passes through the options union while +// that union is still being mapped; the parameter still holds the options +// record, not only `undefined`. + +interface CommandContext { + cwd: string; + newSession(options?: { + parent?: string; + withSession?: (ctx: ReplacedContext) => Promise; + }): Promise<{ cancelled: boolean }>; + fork( + entryId: string, + options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedContext) => Promise }, + ): Promise<{ cancelled: boolean }>; +} + +interface ReplacedContext extends CommandContext { + depth: number; + send(content: string): Promise; +} + +interface Command { + description?: string; + handler: (args: string, ctx: CommandContext) => Promise; +} + +const log: string[] = []; +const commands = new Map(); + +function register(name: string, command: Command): void { + commands.set(name, command); +} + +register("new", { + description: "start a session", + handler: async (args, ctx) => { + const first = await ctx.newSession({ + parent: args, + withSession: async (next) => { + await next.send(`hello from ${next.cwd} at depth ${next.depth}`); + const nested = await next.newSession({ + withSession: async (inner) => { + await inner.send(`nested at depth ${inner.depth}`); + }, + }); + log.push(`nested cancelled=${nested.cancelled}`); + }, + }); + log.push(`first cancelled=${first.cancelled}`); + const bare = await ctx.newSession(); + log.push(`bare cancelled=${bare.cancelled}`); + }, +}); + +register("fork", { + handler: async (args, ctx) => { + const r = await ctx.fork(args, { + position: "at", + withSession: async (next) => next.send(`forked ${args} at depth ${next.depth}`), + }); + log.push(`fork cancelled=${r.cancelled}`); + const plain = await ctx.fork(args); + log.push(`plain fork cancelled=${plain.cancelled}`); + }, +}); + +function makeContext(depth: number): ReplacedContext { + return { + cwd: `/work/${depth}`, + depth, + newSession: async (options) => { + log.push(`newSession depth=${depth} parent=${options?.parent ?? "-"}`); + if (options?.withSession) { + await options.withSession(makeContext(depth + 1)); + return { cancelled: false }; + } + return { cancelled: true }; + }, + fork: async (entryId, options) => { + log.push(`fork ${entryId} position=${options?.position ?? "-"}`); + const callback = options?.withSession; + if (callback === undefined) return { cancelled: true }; + await callback(makeContext(depth + 1)); + return { cancelled: false }; + }, + send: async (content) => { + log.push(`send: ${content}`); + }, + }; +} + +async function run(): Promise { + const root: CommandContext = makeContext(0); + for (const [name, command] of commands) { + log.push(`run ${name} (${command.description ?? "no description"})`); + await command.handler(`entry-${name}`, root); + } + for (const line of log) console.log(line); +} + +run().catch((e: unknown) => { + console.log("failed", e instanceof Error ? e.message : String(e)); + process.exitCode = 1; +}); diff --git a/tests/corpus/union-promise-function-arm-widening.ts b/tests/corpus/union-promise-function-arm-widening.ts new file mode 100644 index 000000000..d3b8f618d --- /dev/null +++ b/tests/corpus/union-promise-function-arm-widening.ts @@ -0,0 +1,77 @@ +// A promise or function value flowing into a union whose only arm of that +// kind has a wider payload or result: the value converts into that arm. +// Promises keep their identity under === and !== against the union. + +const chains = new Map>(); + +async function serialize(key: string, task: () => Promise): Promise { + const previous = chains.get(key) ?? Promise.resolve(); + const queued = (async () => { + await previous.catch(() => {}); + return task(); + })(); + const tail = queued.catch(() => {}); + chains.set(key, tail); + void tail.then(() => { + if (chains.get(key) === tail) chains.delete(key); + console.log(`settled ${key}: ${chains.get(key) !== tail}`); + }); + return queued; +} + +interface Store { + pending?: Promise; + lookup?: (name: string) => Promise; + scale?: (n: number) => number | string; +} + +async function findName(name: string): Promise { + return name.length > 3 ? name.toUpperCase() : null; +} + +function double(n: number): number { + return n * 2; +} + +async function failing(): Promise { + throw new Error("task failed"); +} + +async function main(): Promise { + const a = await serialize("alpha", async () => 1); + const b = await serialize("alpha", async () => "two"); + const c = await serialize("beta", async (): Promise => true); + console.log(a, b, c, chains.size); + try { + await serialize("alpha", failing); + } catch (e) { + console.log("caught", (e as Error).message); + } + for (let i = 0; i < 4; i++) await Promise.resolve(); + console.log("remaining", chains.size); + + const store: Store = { lookup: findName, scale: double }; + const work: Promise = Promise.resolve(41); + store.pending = work; + console.log(store.pending === work, store.pending !== work); + const other: Promise = Promise.resolve(41); + console.log(store.pending === other); + console.log(await store.pending); + + // One promise in two optional slots: each holds its own conversion. + const second: Store = {}; + second.pending = work; + console.log(store.pending === second.pending, store.pending !== second.pending); + const slots: Store[] = [store, second, { pending: other }]; + console.log(slots.map((slot) => slot.pending === slots[0]!.pending).join(",")); + second.lookup = findName; + console.log(store.lookup === second.lookup); + + const lookup = store.lookup; + if (lookup) console.log(await lookup("abcd"), await lookup("ab")); + console.log(store.scale?.(21)); + const empty: Store = {}; + console.log(empty.pending === work, empty.lookup === undefined, empty.scale?.(1)); +} + +main(); diff --git a/tests/corpus/union-record-lossless-arm.ts b/tests/corpus/union-record-lossless-arm.ts new file mode 100644 index 000000000..b90d5486e --- /dev/null +++ b/tests/corpus/union-record-lossless-arm.ts @@ -0,0 +1,55 @@ +// A record flowing into a union where several record arms can hold it by a +// width conversion: the arm that keeps every one of its fields is the +// value's own layout, widened, and wins over arms that drop fields. + +interface FastOpts { + fast?: boolean; + strict?: boolean; +} +interface DepthOpts { + depth?: number; +} +interface StrictOpts { + strict?: boolean; +} + +interface Wide { + id: string; + window: number; + kind?: string; + limit: number; + opts?: FastOpts | DepthOpts | StrictOpts; +} + +interface Judge { + id: string; + window: number; + kind: string; + opts?: StrictOpts; +} + +interface Narrow { + id: string; + window: number; + kind?: string; + limit: number; + opts?: FastOpts | DepthOpts; +} + +function show(entry: Wide | Judge): string { + const limit = "limit" in entry ? ` limit=${entry.limit}` : ""; + return `${entry.kind ?? "wide"} ${entry.id} window=${entry.window}${limit} ${JSON.stringify(entry.opts ?? null)}`; +} + +const narrow: Narrow = { id: "n1", window: 4, limit: 2, opts: { depth: 3 } }; +console.log(show(narrow)); + +const entries: (Wide | Judge)[] = [narrow, { id: "j1", window: 1, kind: "judge", opts: { strict: true } }]; +const plain: Narrow = { id: "n2", window: 8, kind: "custom", limit: 5 }; +entries.push(plain); +for (const entry of entries) console.log(show(entry)); + +let latest: Wide | Judge | undefined; +for (const item of [narrow, plain]) latest = item; +console.log(latest === undefined ? "none" : show(latest)); + diff --git a/tests/diagnostics/inspect-custom-fences.ts b/tests/diagnostics/inspect-custom-fences.ts new file mode 100644 index 000000000..404f6ddab --- /dev/null +++ b/tests/diagnostics/inspect-custom-fences.ts @@ -0,0 +1,37 @@ +// [util.inspect.custom] methods keep their boundaries: a class whose +// subclass declares the method without declaring it itself, a method with +// more than three parameters, a method returning a class instance, and %o +// over instances with one. +import { format, inspect } from "node:util"; + +class Plain { + a = 1; +} +class Fancy extends Plain { + [inspect.custom](): string { + return "fancy"; + } +} +const items: Plain[] = [new Fancy()]; +console.log(inspect(items)); + +class Wide { + [inspect.custom](a: number, b: unknown, c: unknown, d: unknown): string { + return `${a}${String(b)}${String(c)}${String(d)}`; + } +} +console.log(inspect(new Wide())); + +class Echo { + [inspect.custom](): Echo { + return new Echo(); + } +} +console.log(inspect(new Echo())); + +class Shown { + [inspect.custom](): string { + return "shown"; + } +} +console.log(format("%o", new Shown())); diff --git a/tests/fixtures/npm-static/declared-members-cli.ts b/tests/fixtures/npm-static/declared-members-cli.ts new file mode 100644 index 000000000..107ffc54d --- /dev/null +++ b/tests/fixtures/npm-static/declared-members-cli.ts @@ -0,0 +1,21 @@ +// A consumer reads members a package's declarations promise but its +// JavaScript adds in ways the inferred literal type misses: absent keys of +// index signatures read undefined, and expando properties read their values, +// as in Node. +import { counts, fonts, settings, stats } from "declared-members"; + +const key = process.argv.length > 10 ? "size" : "missing"; + +const s = settings(); +console.log(s["font"] ?? "default font", s["size"] ?? "default size", s.family); +console.log(s["font"] === undefined, typeof s["weight"], s.weight ?? "dot miss"); +console.log(s[key] ?? "dynamic miss", s[key], Object.keys(s)); + +const c = counts(); +console.log(c["chars"] ?? -1, c["lines"], c[key] === undefined); + +const st = stats([4, 9, 2]); +console.log(st.max, st.sum, st.count, st); + +const f = fonts(); +console.log(f["title"]?.name ?? "no title", f[key] === undefined, f["body"]?.name, f.code.name); diff --git a/tests/fixtures/npm-static/node_modules/declared-members/index.d.ts b/tests/fixtures/npm-static/node_modules/declared-members/index.d.ts new file mode 100644 index 000000000..8061fd5cb --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/declared-members/index.d.ts @@ -0,0 +1,7 @@ +export declare function settings(): { [key: string]: string }; +export declare function counts(): Record; +export declare function stats(xs: number[]): { count: number; sum: number; max: number }; +export interface Font { + name: string; +} +export declare function fonts(): Record; diff --git a/tests/fixtures/npm-static/node_modules/declared-members/index.js b/tests/fixtures/npm-static/node_modules/declared-members/index.js new file mode 100644 index 000000000..381bd4a46 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/declared-members/index.js @@ -0,0 +1,34 @@ +"use strict"; +// Each function returns an object literal whose JavaScript-inferred type +// lacks members that index.d.ts promises. + +// An index signature over a literal built empty, then assigned. +function settings() { + const out = {}; + out.size = "12px"; + out.family = "serif"; + return out; +} + +// An index signature over a literal with fixed keys. +function counts() { + return { lines: 3, words: 12 }; +} + +// An expando on a non-empty literal: the checker's literal type has no `max`. +function stats(xs) { + const out = { count: xs.length, sum: 0 }; + for (const x of xs) out.sum += x; + out.max = Math.max(...xs); + return out; +} + +// An index signature whose values are a declared interface. +function fonts() { + return { body: { name: "serif" }, code: { name: "mono" } }; +} + +exports.settings = settings; +exports.counts = counts; +exports.stats = stats; +exports.fonts = fonts; diff --git a/tests/fixtures/npm-static/node_modules/declared-members/package.json b/tests/fixtures/npm-static/node_modules/declared-members/package.json new file mode 100644 index 000000000..4a42ebdf2 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/declared-members/package.json @@ -0,0 +1,6 @@ +{ + "name": "declared-members", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm/cases/type-patch-files/main.ts b/tests/fixtures/npm/cases/type-patch-files/main.ts new file mode 100644 index 000000000..32e041ce0 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/main.ts @@ -0,0 +1,10 @@ +// A package declaration file replaced by a local one (scriptc.typePatches +// files), plus a declaration patch on another file of the same package. +import { parseLog, summarize } from "eventlog"; + +const entries = parseLog("10 info ada started\n12 warn - disk low\n15 error lin crashed hard\n"); +for (const e of entries) console.log(e.at, e.level.toUpperCase(), e.user ?? "(system)", e.message); +console.log(entries.filter((e) => e.level !== "info").length, JSON.stringify(entries[1])); +const all = summarize(entries); +const none = summarize([]); +console.log(all.count, all.last ?? "-", none.count, none.last ?? "-"); diff --git a/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/entry.d.ts b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/entry.d.ts new file mode 100644 index 000000000..0a42b298f --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/entry.d.ts @@ -0,0 +1,8 @@ +// Out of date: the JavaScript reports levels as strings and has no +// `user` for system entries. +export type Entry = { + at: number; + level: number; + user: string; + message: string; +}; diff --git a/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.d.ts b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.d.ts new file mode 100644 index 000000000..c16d59d46 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.d.ts @@ -0,0 +1,4 @@ +export type { Entry } from "./entry"; +import type { Entry } from "./entry"; +export declare function parseLog(text: string): Entry[]; +export declare function summarize(entries: Entry[]): { count: number; last: string }; diff --git a/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.js b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.js new file mode 100644 index 000000000..4c10d88f4 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/dist/index.js @@ -0,0 +1,17 @@ +"use strict"; +function parseLog(text) { + const rows = String(text) + .split("\n") + .filter(Boolean) + .map((line) => { + const [at, level, user, ...rest] = line.split(" "); + return { at: Number(at), level: level, user: user === "-" ? undefined : user, message: rest.join(" ") }; + }); + // Entries travel as JSON (the log format's own wire form). + return JSON.parse(JSON.stringify(rows)); +} +function summarize(entries) { + return { count: entries.length, last: entries.length === 0 ? undefined : entries[entries.length - 1].message }; +} +exports.parseLog = parseLog; +exports.summarize = summarize; diff --git a/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/package.json b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/package.json new file mode 100644 index 000000000..27ebf4625 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/node_modules/eventlog/package.json @@ -0,0 +1 @@ +{ "name": "eventlog", "version": "2.3.1", "main": "dist/index.js", "types": "dist/index.d.ts" } diff --git a/tests/fixtures/npm/cases/type-patch-files/package.json b/tests/fixtures/npm/cases/type-patch-files/package.json new file mode 100644 index 000000000..92a38c108 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/package.json @@ -0,0 +1,13 @@ +{ + "name": "type-patch-files-case", + "private": true, + "type": "module", + "scriptc": { + "typePatches": { + "eventlog@^2.0.0": { + "files": { "dist/entry.d.ts": "./type-patches/entry.d.ts" }, + "declarations": { "summarize().last": "| undefined" } + } + } + } +} diff --git a/tests/fixtures/npm/cases/type-patch-files/type-patches/entry.d.ts b/tests/fixtures/npm/cases/type-patch-files/type-patches/entry.d.ts new file mode 100644 index 000000000..380338e96 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-files/type-patches/entry.d.ts @@ -0,0 +1,7 @@ +// The project's replacement for eventlog's dist/entry.d.ts. +export type Entry = { + at: number; + level: "info" | "warn" | "error"; + user?: string; + message: string; +}; diff --git a/tests/fixtures/npm/cases/type-patch-types-entry/main.ts b/tests/fixtures/npm/cases/type-patch-types-entry/main.ts new file mode 100644 index 000000000..3d0ab5ac9 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-types-entry/main.ts @@ -0,0 +1,9 @@ +// A package without declarations, typed by a local declaration file +// (scriptc.typePatches types): user code is checked against it, and the +// values cross the declaration boundary as native records. +import { centroid, point, type Point } from "pointjs"; + +const points: Point[] = [point(0, 0), point(4, 0), point(4, 3)]; +const c = centroid(points); +console.log(c === null ? "none" : `${c.x.toFixed(2)},${c.y.toFixed(2)}`); +console.log(centroid([]) === null, JSON.stringify(points.map((p) => p.x + p.y))); diff --git a/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js b/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js new file mode 100644 index 000000000..f324e9acb --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js @@ -0,0 +1,17 @@ +"use strict"; +// Plain JavaScript without declarations. +function point(x, y) { + return { x: x, y: y }; +} +function centroid(points) { + if (points.length === 0) return null; + var sx = 0; + var sy = 0; + for (var i = 0; i < points.length; i++) { + sx += points[i].x; + sy += points[i].y; + } + return point(sx / points.length, sy / points.length); +} +exports.point = point; +exports.centroid = centroid; diff --git a/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/package.json b/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/package.json new file mode 100644 index 000000000..917576875 --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/package.json @@ -0,0 +1 @@ +{ "name": "pointjs", "version": "1.4.0", "main": "lib/point.js" } diff --git a/tests/fixtures/npm/cases/type-patch-types-entry/package.json b/tests/fixtures/npm/cases/type-patch-types-entry/package.json new file mode 100644 index 000000000..06846a01d --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-types-entry/package.json @@ -0,0 +1,10 @@ +{ + "name": "type-patch-types-entry-case", + "private": true, + "type": "module", + "scriptc": { + "typePatches": { + "pointjs@1.x": { "types": "./type-patches/pointjs.d.ts" } + } + } +} diff --git a/tests/fixtures/npm/cases/type-patch-types-entry/type-patches/pointjs.d.ts b/tests/fixtures/npm/cases/type-patch-types-entry/type-patches/pointjs.d.ts new file mode 100644 index 000000000..30ae94a5e --- /dev/null +++ b/tests/fixtures/npm/cases/type-patch-types-entry/type-patches/pointjs.d.ts @@ -0,0 +1,4 @@ +// The project's declarations for pointjs, which ships none. +export type Point = { x: number; y: number }; +export declare function point(x: number, y: number): Point; +export declare function centroid(points: Point[]): Point | null; diff --git a/tests/fixtures/record-inherited/member-signature.ts b/tests/fixtures/record-inherited/member-signature.ts new file mode 100644 index 000000000..c9c65f8ad --- /dev/null +++ b/tests/fixtures/record-inherited/member-signature.ts @@ -0,0 +1,12 @@ +// A dot read of a prototype-named member whose own entry has another +// signature than the member's (tests/harness/record-inherited-member.test.ts). +const plain: Record number> = { double: (x) => x * 2 }; +const builtin = plain.toString; +console.log(typeof builtin); +const numeric: Record number> = { toString: (x: number) => x + 1 }; +try { + const own = numeric.toString; + console.log(typeof own); +} catch (e) { + console.log(e instanceof TypeError, (e as Error).message); +} diff --git a/tests/harness/__snapshots__/inspect-custom-fences.ts.txt b/tests/harness/__snapshots__/inspect-custom-fences.ts.txt new file mode 100644 index 000000000..4f5da9030 --- /dev/null +++ b/tests/harness/__snapshots__/inspect-custom-fences.ts.txt @@ -0,0 +1,35 @@ +inspect-custom-fences.ts:16:21 - error SC2020: 'util.inspect of 'Plain[]' values' is part of the standard library types but has no scriptc lowering yet + + 15 | const items: Plain[] = [new Fancy()]; + 16 | console.log(inspect(items)); + | ^~~~~ + 17 | + + hint: classes whose subclasses declare [util.inspect.custom] have no inspect lowering yet (declare the method on the class itself) + +inspect-custom-fences.ts:23:21 - error SC2020: 'util.inspect of 'Wide' values' is part of the standard library types but has no scriptc lowering yet + + 22 | } + 23 | console.log(inspect(new Wide())); + | ^~~~~~~~~~ + 24 | + + hint: [util.inspect.custom] methods with more than three parameters have no inspect lowering + +inspect-custom-fences.ts:30:21 - error SC2020: 'util.inspect of 'Echo' values' is part of the standard library types but has no scriptc lowering yet + + 29 | } + 30 | console.log(inspect(new Echo())); + | ^~~~~~~~~~ + 31 | + + hint: [util.inspect.custom] methods that return a class instance have no inspect lowering + +inspect-custom-fences.ts:37:26 - error SC2020: 'util.format %o over values with [util.inspect.custom] methods' is part of the standard library types but has no scriptc lowering yet + + 36 | } + 37 | console.log(format("%o", new Shown())); + | ^~~~~~~~~~~ + 38 | + + hint: %o passes showHidden to the methods — use %O \ No newline at end of file diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index 29268c841..e67e5582b 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -12,6 +12,7 @@ import { isolatedFiles, leakSuppressions, memoryHeavyFiles, + sanitizedNodesJobEnv, sideOwners, testCosts, } from "../../scripts/ci-test-plan.mjs"; @@ -90,6 +91,42 @@ test("LeakSanitizer runs over the sanitized corpus on Linux only", () => { for (const task of plan("san", "linux").side) expect(task.env.ASAN_OPTIONS).toBeUndefined(); }); +test("the sanitized three.js nodes build leaves the Linux corpus for its own job", () => { + const plan = (flavor: "plain" | "san", platform: string) => + ciTestPlan({ shard: "1/20", flavor, workers: 4, platform }); + const [name, value] = Object.entries(sanitizedNodesJobEnv)[0]!; + expect(plan("san", "linux").corpus.env[name]).toBe(value); + for (const env of [plan("plain", "linux").corpus.env, plan("san", "darwin").corpus.env]) + expect(env[name]).toBeUndefined(); + const three = readFileSync(new URL("./three.test.ts", import.meta.url), "utf8"); + expect(three).toContain(`process.env["${name}"] === "${value}"`); + const { jobs } = load( + readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"), + ) as { + jobs: Record< + string, + { needs: string[]; env: Record; steps: { run?: string }[] } + >; + }; + const job = jobs["three_nodes_san"]!; + expect(job.needs).toContain("native_linux"); + expect(jobs["test"]!.needs).toContain("three_nodes_san"); + expect(job.env[name]).toBeUndefined(); + expect(job.env["SCRIPTC_SAN"]).toBe("1"); + // The same LeakSanitizer settings as the corpus it left. + const corpus = plan("san", "linux").corpus.env; + expect(job.env["ASAN_OPTIONS"]).toBe(corpus.ASAN_OPTIONS); + expect(job.env["LSAN_OPTIONS"]).toBe( + corpus.LSAN_OPTIONS!.replace( + leakSuppressions, + "${{ github.workspace }}/tests/harness/lsan.supp", + ), + ); + expect(job.steps.at(-1)!.run).toBe( + `pnpm test tests/harness/three.test.ts -t "'nodes' runs statically through LLVM on 'native'"`, + ); +}); + test("the side queue balances measured files across the twenty Linux shards", () => { for (const flavor of ["plain", "san"] as const) { const costs = testCosts[flavor] as Record; diff --git a/tests/harness/corpus-costs/7520-util-format-runtime.ts.json b/tests/harness/corpus-costs/7520-util-format-runtime.ts.json new file mode 100644 index 000000000..9cb750202 --- /dev/null +++ b/tests/harness/corpus-costs/7520-util-format-runtime.ts.json @@ -0,0 +1 @@ +{ "plain": 6414, "san": 9821 } diff --git a/tests/harness/corpus-costs/7521-util-format-runtime.cjs.json b/tests/harness/corpus-costs/7521-util-format-runtime.cjs.json new file mode 100644 index 000000000..05cc5696d --- /dev/null +++ b/tests/harness/corpus-costs/7521-util-format-runtime.cjs.json @@ -0,0 +1 @@ +{ "plain": 4635, "san": 6305 } diff --git a/tests/harness/corpus-costs/7522-promisify-untyped.cjs.json b/tests/harness/corpus-costs/7522-promisify-untyped.cjs.json new file mode 100644 index 000000000..c5358642b --- /dev/null +++ b/tests/harness/corpus-costs/7522-promisify-untyped.cjs.json @@ -0,0 +1 @@ +{ "plain": 3984, "san": 6122 } diff --git a/tests/harness/corpus-costs/7523-promisify-untyped.ts.json b/tests/harness/corpus-costs/7523-promisify-untyped.ts.json new file mode 100644 index 000000000..be46b70d4 --- /dev/null +++ b/tests/harness/corpus-costs/7523-promisify-untyped.ts.json @@ -0,0 +1 @@ +{ "plain": 3595, "san": 36166 } diff --git a/tests/harness/corpus-costs/7524-inspect-custom.ts.json b/tests/harness/corpus-costs/7524-inspect-custom.ts.json new file mode 100644 index 000000000..b9c089238 --- /dev/null +++ b/tests/harness/corpus-costs/7524-inspect-custom.ts.json @@ -0,0 +1 @@ +{ "plain": 5331, "san": 10004 } diff --git a/tests/harness/corpus-costs/7525-inspect-custom.cjs.json b/tests/harness/corpus-costs/7525-inspect-custom.cjs.json new file mode 100644 index 000000000..010f8b482 --- /dev/null +++ b/tests/harness/corpus-costs/7525-inspect-custom.cjs.json @@ -0,0 +1 @@ +{ "plain": 4178, "san": 6619 } diff --git a/tests/harness/corpus-costs/7526-inspect-options-runtime.ts.json b/tests/harness/corpus-costs/7526-inspect-options-runtime.ts.json new file mode 100644 index 000000000..0599901b9 --- /dev/null +++ b/tests/harness/corpus-costs/7526-inspect-options-runtime.ts.json @@ -0,0 +1 @@ +{ "plain": 4855, "san": 7570 } diff --git a/tests/harness/corpus-costs/7536-inspect-custom-errors.ts.json b/tests/harness/corpus-costs/7536-inspect-custom-errors.ts.json new file mode 100644 index 000000000..bc864c5b3 --- /dev/null +++ b/tests/harness/corpus-costs/7536-inspect-custom-errors.ts.json @@ -0,0 +1 @@ +{ "plain": 5921, "san": 72405 } diff --git a/tests/harness/corpus-costs/7537-inspect-custom-errors.cjs.json b/tests/harness/corpus-costs/7537-inspect-custom-errors.cjs.json new file mode 100644 index 000000000..cfee42705 --- /dev/null +++ b/tests/harness/corpus-costs/7537-inspect-custom-errors.cjs.json @@ -0,0 +1 @@ +{ "plain": 4421, "san": 8837 } diff --git a/tests/harness/corpus-costs/7538-inspect-custom-prototypes.mjs.json b/tests/harness/corpus-costs/7538-inspect-custom-prototypes.mjs.json new file mode 100644 index 000000000..d5314d959 --- /dev/null +++ b/tests/harness/corpus-costs/7538-inspect-custom-prototypes.mjs.json @@ -0,0 +1 @@ +{ "plain": 2529, "san": 2333 } diff --git a/tests/harness/corpus-costs/7700-util-deprecated-helpers.cjs.json b/tests/harness/corpus-costs/7700-util-deprecated-helpers.cjs.json new file mode 100644 index 000000000..9e59ed2d0 --- /dev/null +++ b/tests/harness/corpus-costs/7700-util-deprecated-helpers.cjs.json @@ -0,0 +1 @@ +{ "plain": 6131, "san": 10266 } diff --git a/tests/harness/corpus-costs/7701-util-deprecated-helpers.ts.json b/tests/harness/corpus-costs/7701-util-deprecated-helpers.ts.json new file mode 100644 index 000000000..d9de27c92 --- /dev/null +++ b/tests/harness/corpus-costs/7701-util-deprecated-helpers.ts.json @@ -0,0 +1 @@ +{ "plain": 5541, "san": 12389 } diff --git a/tests/harness/corpus-costs/7702-util-signal-exit-code.cjs.json b/tests/harness/corpus-costs/7702-util-signal-exit-code.cjs.json new file mode 100644 index 000000000..93d4cfd1b --- /dev/null +++ b/tests/harness/corpus-costs/7702-util-signal-exit-code.cjs.json @@ -0,0 +1 @@ +{ "plain": 5273, "san": 8954 } diff --git a/tests/harness/corpus-costs/7703-util-aborted.cjs.json b/tests/harness/corpus-costs/7703-util-aborted.cjs.json new file mode 100644 index 000000000..9a0b7ccf5 --- /dev/null +++ b/tests/harness/corpus-costs/7703-util-aborted.cjs.json @@ -0,0 +1 @@ +{ "plain": 5462, "san": 10299 } diff --git a/tests/harness/corpus-costs/7704-util-aborted.ts.json b/tests/harness/corpus-costs/7704-util-aborted.ts.json new file mode 100644 index 000000000..24879623e --- /dev/null +++ b/tests/harness/corpus-costs/7704-util-aborted.ts.json @@ -0,0 +1 @@ +{ "plain": 6045, "san": 10336 } diff --git a/tests/harness/corpus-costs/7705-util-diff.cjs.json b/tests/harness/corpus-costs/7705-util-diff.cjs.json new file mode 100644 index 000000000..cfa6c89a3 --- /dev/null +++ b/tests/harness/corpus-costs/7705-util-diff.cjs.json @@ -0,0 +1 @@ +{ "plain": 5530, "san": 9522 } diff --git a/tests/harness/corpus-costs/7706-util-diff.ts.json b/tests/harness/corpus-costs/7706-util-diff.ts.json new file mode 100644 index 000000000..2f2a5b423 --- /dev/null +++ b/tests/harness/corpus-costs/7706-util-diff.ts.json @@ -0,0 +1 @@ +{ "plain": 5532, "san": 9167 } diff --git a/tests/harness/corpus-costs/7707-util-mime-type.cjs.json b/tests/harness/corpus-costs/7707-util-mime-type.cjs.json new file mode 100644 index 000000000..69d56fb64 --- /dev/null +++ b/tests/harness/corpus-costs/7707-util-mime-type.cjs.json @@ -0,0 +1 @@ +{ "plain": 7466, "san": 14712 } diff --git a/tests/harness/corpus-costs/7708-util-mime-type.ts.json b/tests/harness/corpus-costs/7708-util-mime-type.ts.json new file mode 100644 index 000000000..5400687cd --- /dev/null +++ b/tests/harness/corpus-costs/7708-util-mime-type.ts.json @@ -0,0 +1 @@ +{ "plain": 5938, "san": 13241 } diff --git a/tests/harness/corpus-costs/7709-function-own-properties.cjs.json b/tests/harness/corpus-costs/7709-function-own-properties.cjs.json new file mode 100644 index 000000000..615caa295 --- /dev/null +++ b/tests/harness/corpus-costs/7709-function-own-properties.cjs.json @@ -0,0 +1 @@ +{ "plain": 5394, "san": 9208 } diff --git a/tests/harness/corpus-costs/7710-util-debounce.cjs.json b/tests/harness/corpus-costs/7710-util-debounce.cjs.json new file mode 100644 index 000000000..3a5f6ba71 --- /dev/null +++ b/tests/harness/corpus-costs/7710-util-debounce.cjs.json @@ -0,0 +1 @@ +{ "plain": 7005, "san": 20190 } diff --git a/tests/harness/corpus-costs/7711-util-throttle.cjs.json b/tests/harness/corpus-costs/7711-util-throttle.cjs.json new file mode 100644 index 000000000..3b4d2438e --- /dev/null +++ b/tests/harness/corpus-costs/7711-util-throttle.cjs.json @@ -0,0 +1 @@ +{ "plain": 8250, "san": 15025 } diff --git a/tests/harness/corpus-costs/7712-util-debounce-throttle.ts.json b/tests/harness/corpus-costs/7712-util-debounce-throttle.ts.json new file mode 100644 index 000000000..bb4ea7997 --- /dev/null +++ b/tests/harness/corpus-costs/7712-util-debounce-throttle.ts.json @@ -0,0 +1 @@ +{ "plain": 5966, "san": 11757 } diff --git a/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json b/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json new file mode 100644 index 000000000..7da9b7545 --- /dev/null +++ b/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json @@ -0,0 +1 @@ +{ "plain": 3700 } diff --git a/tests/harness/corpus-costs/8005-buffer-encoding-paths.ts.json b/tests/harness/corpus-costs/8005-buffer-encoding-paths.ts.json new file mode 100644 index 000000000..3aafbe296 --- /dev/null +++ b/tests/harness/corpus-costs/8005-buffer-encoding-paths.ts.json @@ -0,0 +1 @@ +{ "plain": 2558, "san": 4160 } diff --git a/tests/harness/corpus-costs/8009-normalize-hangul-jungseong-o-yae.ts.json b/tests/harness/corpus-costs/8009-normalize-hangul-jungseong-o-yae.ts.json new file mode 100644 index 000000000..6c20e6ad7 --- /dev/null +++ b/tests/harness/corpus-costs/8009-normalize-hangul-jungseong-o-yae.ts.json @@ -0,0 +1 @@ +{ "plain": 2503, "san": 2110 } diff --git a/tests/harness/corpus-costs/8120-inspect-custom-nested.ts.json b/tests/harness/corpus-costs/8120-inspect-custom-nested.ts.json new file mode 100644 index 000000000..745c35791 --- /dev/null +++ b/tests/harness/corpus-costs/8120-inspect-custom-nested.ts.json @@ -0,0 +1 @@ +{ "plain": 5488, "san": 58791 } diff --git a/tests/harness/corpus-costs/8121-inspect-custom-nested.cjs.json b/tests/harness/corpus-costs/8121-inspect-custom-nested.cjs.json new file mode 100644 index 000000000..baf450a0d --- /dev/null +++ b/tests/harness/corpus-costs/8121-inspect-custom-nested.cjs.json @@ -0,0 +1 @@ +{ "plain": 4226, "san": 8699 } diff --git a/tests/harness/corpus-costs/8122-inspect-custom-runtime-only.ts.json b/tests/harness/corpus-costs/8122-inspect-custom-runtime-only.ts.json new file mode 100644 index 000000000..547cd3ddc --- /dev/null +++ b/tests/harness/corpus-costs/8122-inspect-custom-runtime-only.ts.json @@ -0,0 +1 @@ +{ "plain": 2738, "san": 4052 } diff --git a/tests/harness/corpus-costs/8125-inspect-custom-throws.ts.json b/tests/harness/corpus-costs/8125-inspect-custom-throws.ts.json new file mode 100644 index 000000000..7d8f08f56 --- /dev/null +++ b/tests/harness/corpus-costs/8125-inspect-custom-throws.ts.json @@ -0,0 +1 @@ +{ "plain": 3090, "san": 5295 } diff --git a/tests/harness/corpus-costs/8126-inspect-custom-throws.cjs.json b/tests/harness/corpus-costs/8126-inspect-custom-throws.cjs.json new file mode 100644 index 000000000..49ea4c6d4 --- /dev/null +++ b/tests/harness/corpus-costs/8126-inspect-custom-throws.cjs.json @@ -0,0 +1 @@ +{ "plain": 2826, "san": 4308 } diff --git a/tests/harness/corpus-costs/8331-map-number-hashed-integer-keys.ts.json b/tests/harness/corpus-costs/8331-map-number-hashed-integer-keys.ts.json new file mode 100644 index 000000000..961fe0dad --- /dev/null +++ b/tests/harness/corpus-costs/8331-map-number-hashed-integer-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 3441, "san": 1752 } diff --git a/tests/harness/corpus-costs/8360-record-prototype-method-calls.ts.json b/tests/harness/corpus-costs/8360-record-prototype-method-calls.ts.json new file mode 100644 index 000000000..1b2d59a51 --- /dev/null +++ b/tests/harness/corpus-costs/8360-record-prototype-method-calls.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 3900 } diff --git a/tests/harness/corpus-costs/8361-record-function-table-inherited.ts.json b/tests/harness/corpus-costs/8361-record-function-table-inherited.ts.json new file mode 100644 index 000000000..1b2d59a51 --- /dev/null +++ b/tests/harness/corpus-costs/8361-record-function-table-inherited.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 3900 } diff --git a/tests/harness/corpus-costs/8362-record-inherited-order.ts.json b/tests/harness/corpus-costs/8362-record-inherited-order.ts.json new file mode 100644 index 000000000..8bcdf4a18 --- /dev/null +++ b/tests/harness/corpus-costs/8362-record-inherited-order.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2000 } diff --git a/tests/harness/corpus-costs/dyn-union-function-arms.ts.json b/tests/harness/corpus-costs/dyn-union-function-arms.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/dyn-union-function-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/evolving-let-shorthand-reads.js.json b/tests/harness/corpus-costs/evolving-let-shorthand-reads.js.json new file mode 100644 index 000000000..f93ba161d --- /dev/null +++ b/tests/harness/corpus-costs/evolving-let-shorthand-reads.js.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 2800 } diff --git a/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json b/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json new file mode 100644 index 000000000..8c0e66c3c --- /dev/null +++ b/tests/harness/corpus-costs/generic-alias-cast-instances.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 } diff --git a/tests/harness/corpus-costs/generic-member-lambda-impls.ts.json b/tests/harness/corpus-costs/generic-member-lambda-impls.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/generic-member-lambda-impls.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/native-worker-terminate-bounded-loops.ts.json b/tests/harness/corpus-costs/native-worker-terminate-bounded-loops.ts.json new file mode 100644 index 000000000..bb744e4ed --- /dev/null +++ b/tests/harness/corpus-costs/native-worker-terminate-bounded-loops.ts.json @@ -0,0 +1 @@ +{ "plain": 4034, "san": 5956 } diff --git a/tests/harness/corpus-costs/recursive-generic-method-members.ts.json b/tests/harness/corpus-costs/recursive-generic-method-members.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/recursive-generic-method-members.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/recursive-optional-callback-params.ts.json b/tests/harness/corpus-costs/recursive-optional-callback-params.ts.json new file mode 100644 index 000000000..c33a4b32c --- /dev/null +++ b/tests/harness/corpus-costs/recursive-optional-callback-params.ts.json @@ -0,0 +1 @@ +{ "plain": 6000 } diff --git a/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json b/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json new file mode 100644 index 000000000..5c3da345d --- /dev/null +++ b/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json @@ -0,0 +1 @@ +{ "plain": 14002, "san": 16102 } diff --git a/tests/harness/corpus-costs/union-record-lossless-arm.ts.json b/tests/harness/corpus-costs/union-record-lossless-arm.ts.json new file mode 100644 index 000000000..011a8fbbf --- /dev/null +++ b/tests/harness/corpus-costs/union-record-lossless-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 11809, "san": 13580 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 3e59e534f..0a66038ac 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -2283,6 +2283,53 @@ console.log("unreachable"); }); }); +// The runtime formatter (format strings known only at run time, spread +// arguments, options objects) throws a coded SC2020 error for renderings +// it has no lowering for. +describe("runtime format refusals", () => { + test("%s of a class instance with toString, %o of objects, colored errors and options", async () => { + const r = await compileAndRun( + "format-runtime-refusals", + `import * as util from "node:util"; +class Named { + name = "n"; + toString(): string { + return this.name; + } +} +const s: string = process.argv.length > 99 ? "" : "%s"; +const o: string = process.argv.length > 99 ? "" : "%o"; +const attempts: Array<() => string> = [ + () => util.format(s, new Named()), + () => util.format(o, { a: 1 }), + () => util.formatWithOptions({ colors: true }, s, "x", new Error("e")), + () => util.formatWithOptions({ maxArrayLength: 1 }, [1, 2]), + () => util.formatWithOptions({ compact: true }, [1, 2]), +]; +for (const attempt of attempts) { + try { + console.log(attempt()); + } catch (error) { + const e = error as Error & { code?: string }; + console.log(e.name, e.code, e.message); + } +} +console.log(util.format(s, { a: 1 }), util.formatWithOptions({ maxArrayLength: 100 }, [1, 2])); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + "Error SC2020 util.format %s of a class instance whose class defines toString has no lowering\n" + + "Error SC2020 util.format %o of objects renders hidden properties, which have no lowering; use %O\n" + + "Error SC2020 colored inspect output of Error values has no lowering\n" + + "Error SC2020 util.formatWithOptions with the maxArrayLength option has no lowering\n" + + "Error SC2020 util.formatWithOptions with this compact option has no lowering (compact must be false or a number)\n" + + "{ a: 1 } [ 1, 2 ]\n", + ); + expect(r.stderr).toBe(""); + }); +}); + describe("numeric properties of native arrays", () => { // A native array keeps its numeric properties ("-1", "1.5") in a property // table that holds values without attributes, so defining one with a diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index f3a6225c4..076733739 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -1207,6 +1207,18 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(nativeRes.exitCode).toBe(nodeRes.exitCode); }, 180_000); + test("package values cross at the members their declarations promise", async () => { + const entry = join(pilotRoot, "declared-members-cli.ts"); + const binary = await buildStatic(entry, ["declared-members"]); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 180_000); + test("the published Effect Function subpath initializes and runs statically", async () => { const entry = join(pilotRoot, "effect-function-cli.ts"); const { coverage } = analyze(entry, { npmStatic: ["effect"] }); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index d51a2e242..8e133f6e2 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -74,7 +74,7 @@ const KNOWN: Record = { "node24-file-url-conversion": { build: "SC1090" }, "node24-fs-descriptors": { run: "SC2020" }, "node24-mime": { run: "SC2020" }, - "node24-mime-errors": { run: "SC1090" }, + "node24-mime-errors": { run: "SC2020" }, "node24-no-error-assertions": { run: "SC2020" }, "node24-parseargs": { run: "SC1090" }, "node24-promise-timers": { run: "SC2020" }, diff --git a/tests/harness/record-inherited-member.test.ts b/tests/harness/record-inherited-member.test.ts new file mode 100644 index 000000000..ed14524d2 --- /dev/null +++ b/tests/harness/record-inherited-member.test.ts @@ -0,0 +1,32 @@ +/* Object.prototype's methods on typed records (frontend/lowering/ + * record-inherited.ts): a dot read of a prototype-named member answers the + * built-in when the record has no own entry, and throws the checked + * conversion's TypeError when its own entry is a function of another + * signature than the member's, which the program's types claimed. Node reads + * the own function there, so the program cannot be a corpus case. */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; +import { OTHER_ARM_FAILURE_MESSAGE } from "../../packages/compiler/src/frontend/lowering/coercions/unions.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const entry = join(repoRoot, "tests/fixtures/record-inherited/member-signature.ts"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/record-inherited-member"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +test( + "a prototype-named member read with an own function of another signature throws", + { timeout: 300_000 }, + async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { outPath: join(outDir, "main"), outDir, sanitize }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + expect(stdout).toBe(`function\ntrue ${OTHER_ARM_FAILURE_MESSAGE}\n`); + }, +); diff --git a/tests/harness/three.test.ts b/tests/harness/three.test.ts index 27fb86d1f..e55242149 100644 --- a/tests/harness/three.test.ts +++ b/tests/harness/three.test.ts @@ -68,6 +68,15 @@ test.for(cases)( async ({ fixture, target }, context) => { const entry = join(import.meta.dirname, `../fixtures/three/${fixture}.mjs`); if (target === "wasm32-wasi" && !hasZig) context.skip(); + // CI's Linux sanitized lane builds this one in its own job + // (scripts/ci-test-plan.mjs). + if ( + fixture === "nodes" && + target === "native" && + process.env["SCRIPTC_SAN"] === "1" && + process.env["SCRIPTC_THREE_SANITIZED_NODES_JOB"] === "1" + ) + context.skip(); const dir = await mkdtemp("/tmp/scriptc-three-"); const previousTarget = process.env["SCRIPTC_TARGET"]; const previousCc = process.env["SCRIPTC_CC"]; diff --git a/tests/harness/type-patches.test.ts b/tests/harness/type-patches.test.ts index ecd8ee923..816418a96 100644 --- a/tests/harness/type-patches.test.ts +++ b/tests/harness/type-patches.test.ts @@ -4,8 +4,10 @@ * value (the shape of commander's HelpContext.helpWidth), and ms's @return * tags, compile natively and byte-match Node once patched; without the * patches the same declarations throw where Node runs. Patches that do not - * apply as written warn (SC8030-SC8033). The `.d.ts` and `@types` cases - * run in npm.test.ts (type-patch-dts, type-patch-types-twin), and + * apply as written warn (SC8030-SC8033). The `.d.ts`, `@types`, replaced + * file and supplied entry cases run in npm.test.ts (type-patch-dts, + * type-patch-types-twin, type-patch-files, type-patch-types-entry); here + * their unpatched copies show the patches are what makes them compile. And * commander-calc carries the HelpContext.helpWidth patch. */ import { execFile } from "node:child_process"; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -121,6 +123,48 @@ describe("type patches", () => { }, ); + test( + "without its patch, code using the out-of-date declaration file does not type-check", + { timeout: 600_000 }, + async () => { + const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-files")); + try { + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(copy, "main.ts"), { + outPath: join(outDir, "files-unpatched"), + outDir, + sanitize, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((d) => d.message)).toContain( + "Property 'toUpperCase' does not exist on type 'number'.", + ); + } finally { + rmSync(copy, { recursive: true, force: true }); + } + }, + ); + + test( + "without its patch, a package without declarations is not compiled for a typed import", + { timeout: 600_000 }, + async () => { + const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-types-entry")); + try { + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(copy, "main.ts"), { + outPath: join(outDir, "types-entry-unpatched"), + outDir, + sanitize, + }); + expect(result.ok).toBe(false); + } finally { + rmSync(copy, { recursive: true, force: true }); + } + }, + ); + test( "patches that do not apply as written warn and the build goes on", { timeout: 600_000 },