diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f131747fa..26a38482a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -216,9 +216,19 @@ jobs:
# Start the corpus immediately beside a serial side queue. One shared
# worker budget covers both processes; file and case shard axes stay
# separate so every fixture still runs exactly once in each lane.
+ # The tests run in a transient scope capped below the runner's 16 GB, so
+ # the runner itself keeps memory: past 14 GB of RAM the scope swaps, as
+ # the whole runner did before (a sanitized shard peaks near 16 GB), and
+ # when RAM and swap run out the kernel kills the scope's largest process.
+ # OOMPolicy=continue leaves the rest of the scope running, so vitest
+ # reports the killed worker and the job log names the test.
- name: Tests (corpus and integration suites)
run: |
ulimit -c 0
+ if [ -d /run/systemd/system ]; then
+ exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue \
+ --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" node scripts/ci-test.mjs
+ fi
node scripts/ci-test.mjs
tests_macos:
@@ -524,7 +534,7 @@ jobs:
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
name: test (tsgo and tsc-ts give the same IR)
runs-on: ubuntu-24.04
- timeout-minutes: 30
+ timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec0b27dbf..612df47b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,51 @@ All notable changes to scriptc will be documented in this file.
+## 0.4.4
+
+### Highlights
+
+- **Fixes for 0.4.3:**
+ - Unchanged rebuilds hit the frontend cache again. An unchanged rebuild of a 23-module program takes 93 ms instead of 1.26 s, and of tsc-ts (dev) 1.6 s instead of 58.2 s.
+ - The native compiler builds programs with several typed-array accesses at one masked index in a loop again, including tsc-ts's release build, which failed with SC3004.
+ - `x!` returned from a function, used as an arrow function's result, or passed as an argument keeps null or undefined when the destination type holds it, as JavaScript does. `x!` operands of `+`, `+=` and other conversions that hold undefined or null convert as Node does.
+ - Effect 4 programs whose generators yield clients built by other generators run again.
+ - The cache for untyped property access in programs that declare classes is reverted, fixing a crash in Effect 4 observability programs.
+- **Faster:**
+ - Number compares and typed-array indexes use the exact integer of sums like `i + 1`: a heap sift-down over an `Int32Array` runs 17.5% faster.
+ - glibc's per-thread heaps use huge pages: the VS Code type-check runs 3.4% faster.
+ - Mid-size executables (over 1 MiB of LLVM text) build in at least eight partitions: the LLVM backend phase takes 34 to 74% less time for three.js programs of 1.4 to 7.8 MB.
+ - JavaScript class instances are boxed at their exact class, and stores into nested string-keyed dictionaries keep class method calls direct.
+ - `--target-cpu` selects an x86-64 microarchitecture level.
+- **Node.js parity:**
+ - X25519 keys and `crypto.diffieHellman`; `existsSync` and `fs.promises` accept `file:` URLs; `Buffer.from` ranges over ArrayBuffers, the deprecated `Buffer()` constructor and `Buffer` as a value.
+ - Dates are objects with identity; `Promise.all` over different entry types settles on Node's turns; completion callbacks run in their call's AsyncLocalStorage context.
+ - TypedArray methods run on typed arrays held as untyped values, and unions of typed-array classes compile. Borrowed `TypedArray.prototype.set` calls compile and throw Node's TypeError for other targets.
+ - Split CommonJS `require` bindings, `__`-prefixed exports, string-key destructuring of `any` values, and record key order for values JavaScript built.
+- **npm packages:** zod 4 and `@hono/zod-validator` values are projected at the declaration boundary.
+
+
+
+## 0.4.3
+
+### Highlights
+
+- **Faster checking and smaller builds with tsc-ts 0.0.9:** scriptc checks with tsc-ts 0.0.9, and builds reuse the bundled declaration files' diagnostics instead of checking them again. Small IR builds are 10.6 to 16.0% faster and small executable builds 5.5 to 7.6% faster, with identical output. A native build of tsc-ts with this release checks 0.4 to 6.6% faster than with 0.4.2 and peaks 4 to 16% lower.
+- **Integer arrays and loops:**
+ - Number arrays that only hold int32 values use 4-byte slots in locals, across direct calls and in module globals, and loop-carried locals written from their elements stay int32. A workload that walks arrays of cursors runs 54.5% faster.
+ - Loops with two counters closing in on each other (`for (let i = lo, j = hi; i < j; i++, j--)`) and element copies through `a[i]!` run without per-element checks, and two-counter `while` loops run on integer counters.
+ - Loops over typed arrays and charAt scans are versioned on their bounds, and ASCII strings are scanned as bytes.
+- **Less work for untyped and dynamic code:**
+ - String-keyed record reads under `??` and `!` no longer allocate: a dictionary-walking workload makes no union boxes instead of 363,359 and runs 12 to 15% faster.
+ - Checked-dynamic method calls dispatch on method ids, observed class methods call natively while their slot is unchanged, and calls on bag-tracked class instances skip building their views.
+- **Node.js parity:**
+ - `crypto.getCipherInfo`, AES-CCM, AES key wrap, the legacy `createCipheriv` modes, `checkPrime`/`generatePrime`, and Node's argument checks for `generateKeyPair`.
+ - Streams: `construct`, `writev`, `decodeStrings` and `defaultEncoding`, including `writev` on `Transform` and `PassThrough`.
+ - `FinalizationRegistry`, `new.target`, `String.prototype.at` on untyped values, `Error.stackTraceLimit`, `spawnSync`'s `env` option, and class constructors called through `call`/`apply` throw Node's TypeError.
+ - Records enumerate in the key order their object literal wrote, and objects converting to a union with an empty-record arm keep every field.
+- **npm packages:** zod 3 compiles natively and zod 4's core runs, minified packages such as marked compile, chalk 5 and hot-shots 8 build from built-in shims, and `@hono/node-server` 2.x is built in. User code types npm package declarations at a checked, identity-preserving boundary.
+- **Builds:** the cached build is reused after whitespace and comment edits, function bodies are emitted on forked workers, and the LLVM emitter reads its analyses from one recorded walk of each module.
+
## 0.4.2
### Highlights
@@ -159,8 +204,6 @@ All notable changes to scriptc will be documented in this file.
- **The same program compiles to the same executable wherever it sits on disk** and whatever order its functions lower in, and edits outside a declaration do not rename the helpers and closures inside it.
- **Libraries that use untyped byte arrays** link.
-
-
## 0.4.1
### Highlights
diff --git a/benchmarks/runtime/workloads/index-cursors.json b/benchmarks/runtime/workloads/index-cursors.json
new file mode 100644
index 000000000..24f2a263c
--- /dev/null
+++ b/benchmarks/runtime/workloads/index-cursors.json
@@ -0,0 +1,6 @@
+{
+ "entry": "workloads/index-cursors.ts",
+ "args": [
+ "20000"
+ ]
+}
diff --git a/benchmarks/runtime/workloads/index-cursors.ts b/benchmarks/runtime/workloads/index-cursors.ts
new file mode 100644
index 000000000..b65ded46b
--- /dev/null
+++ b/benchmarks/runtime/workloads/index-cursors.ts
@@ -0,0 +1,25 @@
+// Arrays of indices walked by loop-carried cursors: each step reads the
+// next index from the array and relinks the visited entries, so the
+// cursor and every stored value are small integers read back from the
+// array itself.
+const N = 4096;
+
+function relink(rounds: number): number {
+ const next: number[] = [];
+ for (let i = 0; i < N; i++) next.push(i + 1 < N ? i + 1 : 0);
+ let at = next[0]!;
+ let total = 0;
+ for (let r = 0; r < rounds; r++) {
+ for (let s = 0; s < N; s++) {
+ const t = next[at]!;
+ next[at] = next[t]!;
+ next[t] = at;
+ at = t;
+ total = (total + at) | 0;
+ }
+ }
+ return total;
+}
+
+const rounds = Number(process.argv[2] ?? "1");
+console.log("relink", relink(rounds));
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index 8b0353620..9fa5e9447 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -135,6 +135,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--detached-exit
On Linux, when the program calls process.exit(), hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.
+ --target-cpu <baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>
+ On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, baseline, runs on any x86-64 CPU. A higher level lets LLVM use more instructions, and the executable then runs only on CPUs that support that level. native uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See Target CPU .
+
--profile-generate <pattern>
On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where %p expands to the process ID; the LLVM_PROFILE_FILE environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with --optimization=release or speed.
@@ -228,6 +231,43 @@ $ scriptc build app.ts --optimization=speed --profile-use app.profdata
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
+## Target CPU
+
+By default, x86-64 executables run on any x86-64 CPU, so the compiler uses only the baseline instruction set (SSE2). `--target-cpu` compiles the program's code for a higher x86-64 microarchitecture level. Each level includes the ones below it:
+
+
+
+
+ Level
+ Adds
+
+
+
+
+ x86-64-v2
+ SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT
+
+
+ x86-64-v3
+ AVX, AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE
+
+
+ x86-64-v4
+ AVX-512 (F, BW, CD, DQ, VL)
+
+
+
+
+```console
+$ scriptc build app.ts --target-cpu=x86-64-v3 -o app
+```
+
+Loops over typed arrays and numbers gain the most, because LLVM vectorizes them with wider registers. Some loops run slower at a higher level, so measure the program on the machines it will run on.
+
+An executable built for a level runs only on CPUs that support that level. On an older CPU, it crashes with an illegal-instruction signal (`SIGILL`) when it reaches an instruction the CPU lacks. A named level is portable to every machine of that level. `native` selects the highest level the build machine supports, so use it only for executables that run on the machine that built them or on identical machines.
+
+The level applies to the program's own code. The runtime pack keeps its baseline code. Builds for different levels never share cached artifacts, and `baseline` builds are the same as builds without the option.
+
## LLVM backend
scriptc generates LLVM IR and compiles it to native code. Executable builds link the result with the matching precompiled runtime pack. Use --emit=llvm to inspect the generated code.
diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx
index f1b64948d..e017267d7 100644
--- a/docs/content/docs/how-it-works.mdx
+++ b/docs/content/docs/how-it-works.mdx
@@ -42,17 +42,16 @@ Parsing, type checking, lowering, and LLVM IR generation run one after another o
### Partitions
-The LLVM helper divides an executable's LLVM IR into partitions by its size: one partition per 4 MiB of LLVM text, rounded up to a power of two, and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets.
+The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 1 MiB of LLVM text is one partition, and up to 2 MiB is four. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets.
LLVM text Partitions
- Up to 4 MiB 1
- Over 4 MiB, up to 8 MiB 2
- Over 8 MiB, up to 16 MiB 4
- Over 16 MiB, up to 32 MiB 8
+ Up to 1 MiB 1
+ Over 1 MiB, up to 2 MiB 4
+ Over 2 MiB, up to 32 MiB 8
Over 32 MiB, up to 64 MiB 16
Over 64 MiB, up to 128 MiB 32
Over 128 MiB 64
@@ -63,7 +62,7 @@ The count depends only on the size of the LLVM text, so a program has the same p
These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler.
-The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with two partitions uses at most two cores in the backend. A program of one partition is optimized and compiled on one thread.
+The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend. A program of one partition is optimized and compiled on one thread.
How partitions are compiled depends on `--optimization`:
diff --git a/docs/content/limitations/buffer-construction.mdx b/docs/content/limitations/buffer-construction.mdx
index aca0c74ca..c88e01336 100644
--- a/docs/content/limitations/buffer-construction.mdx
+++ b/docs/content/limitations/buffer-construction.mdx
@@ -1,3 +1,8 @@
`Buffer.from` accepts untyped strings, byte arrays, arrays, and plain array-like or Buffer JSON objects.
Encoding arguments must be supported literals.
+`Buffer.from(arrayBuffer, byteOffset, length)` is a view of the ArrayBuffer or SharedArrayBuffer, with Node's offset and length checks.
Custom input `valueOf` hooks and opaque class or native-handle inputs throw an unsupported-operation error.
+The deprecated `Buffer()` and `new Buffer()` take up to three arguments, without spreads, and follow Node: the first call made outside `node_modules` reports DEP0005 on the next tick, a number allocates zero-filled bytes, and other arguments convert as `Buffer.from` does.
+In JavaScript, `Buffer` read as a value (the global or the buffer module's `Buffer`) is one function with Node's statics and `poolSize`.
+Calling or constructing it through a stored value, reading its `prototype`, and its `copyBytesFrom` and `of` statics throw an unsupported-operation error.
+`Buffer.prototype` members read as values are functions that run on Buffer receivers; inherited and symbol-keyed members throw an unsupported-operation error.
diff --git a/docs/content/limitations/builtin-apis/asynclocalstorage.mdx b/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
index 341994faa..ce77d57f5 100644
--- a/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
+++ b/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
@@ -1,2 +1,12 @@
- **AsyncLocalStorage:** timer, interval, `setImmediate`, `process.nextTick` and `queueMicrotask` callbacks run with the stores that were active when they were scheduled, as in Node.
- Completion callbacks of callback-style I/O APIs, such as `fs.readFile` and `crypto.randomBytes`, run with no store, where Node runs them with the stores that were active when the operation started.
+ The completion callbacks of `fs`, `crypto` and `zlib` callback APIs, `dns.lookup`, `child_process.execFile`, `server.listen`, `net.connect`, `http.request`, `http.get` and `dgram` run with the stores that were active at the call, as in Node.
+ Events of `net`, `tls`, `http` and `https` sockets, servers, requests and responses run with the stores Node runs them with.
+ A socket's or client request's events see the context of the call that created it, and a server's events and its requests' and responses' events see the context of its `listen()` call.
+ Socket `write` and `end` callbacks, `'finish'` and `server.close(callback)` see the context of their call.
+ Stream events and callbacks run with the stores of the code that triggers them, as in Node.
+ Stream steps that Node defers with `process.nextTick` see the context that scheduled them, and `stream.finished` and `stream.pipeline` callbacks see the context of their call.
+ `fs` read and write streams and `zlib` streams run their events and callbacks in the context that created them.
+ Events of `child_process` children, their standard streams and their IPC channels see the context of the `spawn()` or `fork()` call; stdin's `'finish'` and `send()` callbacks see the context of their call.
+ A client `http2` session's events see the context of its `http2.connect()` call, and a client stream's events the context of its `request()` call.
+ An `http2` server's `'session'` and `'stream'` events and the compatibility API's request handler see the context of its `listen()` call; a server stream's own events, and its compatibility request's and response's, run with no store, as in Node.
+ `AsyncResource` has no lowering yet.
diff --git a/docs/content/limitations/builtin-apis/compression.mdx b/docs/content/limitations/builtin-apis/compression.mdx
index 36cec0bc4..83b0fd683 100644
--- a/docs/content/limitations/builtin-apis/compression.mdx
+++ b/docs/content/limitations/builtin-apis/compression.mdx
@@ -4,4 +4,5 @@
The `Deflate`, `Inflate`, `Gzip`, `Gunzip`, `DeflateRaw`, `InflateRaw`, and `Unzip` streams, from `createGzip()` and the other factories or `new zlib.Gzip()`, accept Node's options and support writes, piping, `pipeline`, async iteration, `flush`, `params`, `close`, `reset`, and `bytesWritten`.
Each output chunk is its own Buffer rather than a slice of a shared chunk-size buffer.
`zlib.constants` members and their module-level copies read as Node's values.
- `util.inspect`, `instanceof zlib.Gzip`, and Brotli and Zstd compression are unsupported.
+ The Brotli one-shots `brotliCompress` and `brotliDecompress` and their `Sync` forms, and the `BrotliCompress` and `BrotliDecompress` streams, accept Node's options, including `params` and `dictionary`, and produce the same bytes as Node.
+ `util.inspect` and Zstd compression are unsupported.
diff --git a/docs/content/limitations/builtin-apis/crypto.mdx b/docs/content/limitations/builtin-apis/crypto.mdx
index e4b555806..329aa6809 100644
--- a/docs/content/limitations/builtin-apis/crypto.mdx
+++ b/docs/content/limitations/builtin-apis/crypto.mdx
@@ -27,20 +27,22 @@
A call on a DiffieHellman value whose arguments no @types/node overload accepts, or whose output encoding is typed `any`, is refused at compile time, as are its `Object.prototype` methods; other property reads, and property writes, throw a coded SC2020 error.
`Object.keys()` and `in` do not see a DiffieHellman's own `verifyError` property, and `JSON.stringify()` and `instanceof` are refused.
After `createDiffieHellman(primeLength)` throws `Invalid DH parameters`, the next OpenSSL error Node throws can list the failed generation's errors in `opensslErrorStack`; errors in a compiled binary list only their own.
- `crypto.diffieHellman`, which takes DH, EC, or X25519 KeyObjects, and the `DiffieHellman` and `DiffieHellmanGroup` constructors are not available.
+ `crypto.diffieHellman` computes ECDH and X25519 secrets from EC and X25519 keys, given as KeyObjects or in any form `createPrivateKey` and `createPublicKey` read, with Node's errors; DH keys have no KeyObjects here, and the `DiffieHellman` and `DiffieHellmanGroup` constructors are not available.
`checkPrime`, `checkPrimeSync`, `generatePrime`, and `generatePrimeSync` support bigint, ArrayBuffer, typed-array, Buffer, and DataView values and the `checks`, `safe`, `add`, `rem`, and `bigint` options on native targets, with Node's errors; the callback forms compute before they return and call back in a later macrotask.
Candidates and sizes longer than 262144 bits throw a coded SC2020 error, and wasm32-wasi builds are refused.
Node calls their callbacks with `undefined` as the error, which a callback whose error parameter is typed `Error | null`, as @types/node declares it, receives as `null`.
- RSA, EC, and Ed25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
+ RSA, EC, Ed25519, and X25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
`generateKeyPairSync` and `generateKeyPair` check their arguments in Node's order, with Node's errors, including the OpenSSL errors for RSA modulus lengths below 512 or above 2147483647 bits and for public exponents of 0, 1, or an even number, which `generateKeyPair` passes to its callback.
- RSA keys generate with even modulus lengths from 1024 to 8192 bits and public exponents up to 2147483647; other lengths and exponents that Node accepts, the `raw-public`, `raw-private`, and `raw-seed` encodings, and `paramEncoding: 'explicit'` throw a coded SC2020 error.
+ RSA keys generate with even modulus lengths from 1024 to 8192 bits and public exponents up to 2147483647; other lengths and exponents that Node accepts, and `paramEncoding: 'explicit'`, throw a coded SC2020 error.
The EC curves are prime256v1 (P-256), secp384r1 (P-384), secp521r1 (P-521), and secp256k1.
- Keys read and write PEM and DER as PKCS#1, SEC1, PKCS#8, and SubjectPublicKeyInfo, and JWK (Ed25519 keys use PKCS#8, SubjectPublicKeyInfo, and JWK); `createPublicKey` also takes a private key or an X.509 certificate.
+ Keys read and write PEM and DER as PKCS#1, SEC1, PKCS#8, and SubjectPublicKeyInfo, and JWK (Ed25519 and X25519 keys use PKCS#8, SubjectPublicKeyInfo, and JWK); `createPublicKey` also takes a private key or an X.509 certificate.
+ EC, Ed25519, and X25519 keys also read and write the `raw-public` and `raw-private` formats, including compressed EC points.
+ Raw X448, Ed448, and post-quantum keys, EC scalars of 0 or at least the curve order, and the EC point at infinity, which Node accepts, throw a coded SC2020 error.
They support `type`, `asymmetricKeyType`, `asymmetricKeyDetails`, `export()`, `equals()`, and `instanceof KeyObject`.
`crypto.sign`, `crypto.verify`, `createSign`, and `createVerify` support RSA PKCS#1 v1.5 and PSS padding and ECDSA with DER and IEEE P1363 encodings over SHA-1 and SHA-2 digests, and Ed25519 through `crypto.sign` and `crypto.verify` with a `null` or `undefined` algorithm.
Ed25519 signing, verification, and private keys link when a string literal in the program names `ed25519` (in any case); without one they throw a coded SC2020 error, while Ed25519 public keys still import and export.
Sign and Verify values accumulate `update()` data and are not writable streams.
- Encrypted private keys (`passphrase`, `cipher`), the other key types (`rsa-pss`, `dsa`, `ed448`, `x25519`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused.
+ Encrypted private keys (`passphrase`, `cipher`), the other key types (`rsa-pss`, `dsa`, `ed448`, `x448`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused.
Comparing keys of different types with `equals()` answers false; Node also leaves an OpenSSL error queued, which a later key operation can report.
`publicEncrypt`/`privateDecrypt` remain unsupported.
The WebCrypto `crypto` global, which node:crypto also exports as `webcrypto`, supports `randomUUID()` and `getRandomValues()`, also read as values from untyped JavaScript.
diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx
index ac8bf9115..89266b745 100644
--- a/docs/content/limitations/builtin-apis/date.mdx
+++ b/docs/content/limitations/builtin-apis/date.mdx
@@ -1,5 +1,6 @@
-- **Date:** Date unions, spread constructor arguments, setters, statically typed TypeScript identity comparisons, thrown Date values, and locale formatters are unsupported.
- Zero-argument, single number/string, and local-time year/month field constructors, storage and passing, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
- JavaScript Dates retain identity through checked storage and support Date-copy construction and JSON serialization.
- A TypeScript Date converted to `unknown` becomes a Date with the same time value; each conversion creates a new Date, so two `unknown` values converted from one TypeScript Date are not `===`.
+- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported.
+ Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
+ Unions of a Date with other non-nullish types are unsupported.
+ Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
+ A Date converted to `unknown` is the same Date object.
See [Dates and timezones](#dates-and-timezones) for timezone data.
diff --git a/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx
index d5079ed5a..9b3580647 100644
--- a/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx
+++ b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx
@@ -1,3 +1,3 @@
-- **Filesystem URL paths:** Synchronous `fs` functions accept `file:` URL paths and convert them as `fileURLToPath` does.
- `existsSync` and the `fs.promises` functions refuse URL paths; pass `fileURLToPath(url)` instead.
- A URL path after a string path, or with options that are not literals, is refused, because Node checks those arguments before it converts the URL.
+- **Filesystem URL paths:** `fs` and `fs.promises` functions accept `file:` URL paths and convert them as `fileURLToPath` does; `existsSync` answers `false` and the `fs.promises` functions reject for a URL that does not convert.
+ A URL path with options that are not literals is refused where Node checks those options before it converts the URL, except a `readFileSync` encoding.
+ `fs.promises` functions also refuse a string path before a URL path; pass `fileURLToPath(url)` instead.
diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
index 45a784742..f9d69e3db 100644
--- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx
+++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
@@ -27,7 +27,8 @@
A `Duplex.from` body function may take the `{ signal }` argument, which is aborted with the error the Duplex is destroyed with.
`stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it.
A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`.
- A web stream in a later compose stage, `Readable.toWeb`, and web `WritableStream` and `TransformStream` interop are unsupported.
+ `new TransformStream()` without arguments builds the identity transform: its readable side is a web `ReadableStream`, its writable side supports `getWriter`, `abort`, `close`, and `locked`, and the writer supports `write`, `close`, `abort`, `releaseLock`, `ready`, `closed`, and `desiredSize`, with Node's backpressure (a write completes when the readable side reads its chunk).
+ A web stream in a later compose stage, `Readable.toWeb`, a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough` are unsupported.
The `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream.
`readable.compose(stage, { signal })` is `stream.compose(readable, stage)` with the signal added; the options are an object literal whose `signal` is a variable or a property read.
`pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, with or without function stages, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags.
diff --git a/docs/content/limitations/builtin-apis/text-encoding.mdx b/docs/content/limitations/builtin-apis/text-encoding.mdx
index bffd24424..e20cfd943 100644
--- a/docs/content/limitations/builtin-apis/text-encoding.mdx
+++ b/docs/content/limitations/builtin-apis/text-encoding.mdx
@@ -1,6 +1,7 @@
- **Text encoding:** `TextEncoder` and `TextDecoder` can be stored in fields, arguments, return values, arrays, and closures.
Encoding supports UTF-8, including `encodeInto` into a Uint8Array or Buffer view.
Codec properties expose the canonical encoding and decoder flags.
+ Codecs held in `any` or `unknown` storage keep their identity and streaming state, and dynamic code can call `encode`, `encodeInto` and `decode`, with options objects, and read their properties.
Decoding accepts recognized WHATWG labels, including runtime labels and omitted or undefined encodings.
Literal constructor options support `fatal` and `ignoreBOM`; literal decode options support streaming UTF-8, UTF-16, and single-byte encodings.
Other legacy streaming encodings, runtime options objects, inspection, JSON conversion, and module-scope declarations using `var` or lacking an initializer are unsupported.
diff --git a/docs/content/limitations/builtin-apis/url.mdx b/docs/content/limitations/builtin-apis/url.mdx
index 6345e2616..c9eee85b3 100644
--- a/docs/content/limitations/builtin-apis/url.mdx
+++ b/docs/content/limitations/builtin-apis/url.mdx
@@ -1,4 +1,4 @@
-- **URL:** Non-ASCII or percent-escaped hosts are unsupported.
+- **URL:** Hosts of special URLs that hold non-ASCII text, or percent-escapes that decode to it, throw `ERR_INVALID_URL`, and `xn--` labels are kept without Punycode validation.
Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile.
`URL.canParse`, `URL.parse`, `toString()`, and `toJSON()` compile; the factories use the same parser as construction, returning `false` or `null` for rejected inputs while preserving conversion errors.
`href`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash` support assignment in native code.
@@ -6,4 +6,3 @@
Native URL assignments require statically known property keys; `+=` accepts primitive operands, while other assignment operators and object operands for `+=` are refused.
`origin` is read-only.
`searchParams` is a live view whose identity survives query and URL replacement.
- Opaque paths retain their input bytes without WHATWG C0 encoding.
diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx
index cc16f74d5..dd180cb7b 100644
--- a/docs/content/limitations/record-identity-and-property-order.mdx
+++ b/docs/content/limitations/record-identity-and-property-order.mdx
@@ -4,6 +4,8 @@ Empty record layouts are also snapshots because they have no property storage.
`scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal.
Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage.
+A record or class instance converted to a string-keyed dictionary type such as `Record`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type.
+Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary.
Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js.
These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects.
A type whose values are only read field by field, from fields the class does not write after construction and does not implement as accessors, stays a native record and holds a copy.
diff --git a/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx b/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx
index baff4d190..45659b493 100644
--- a/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx
+++ b/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx
@@ -1,4 +1,5 @@
- **Symbol fields:** Instance fields require a stable module-level `Symbol()` or `Symbol.for()` binding with a literal description or constant string binding.
+ A `[Symbol.toStringTag]` field or getter with a string value sets the tag that `String()`, `Object.prototype.toString` and `util.inspect` print; fields keyed by other well-known symbols are unsupported.
Reassigned keys, runtime-computed descriptions, distinct symbols with the same description in one class, and copying symbol fields with rest bindings or `Object.assign` are unsupported.
JavaScript object spreads copy represented own symbol fields.
Equal registry keys share a field across modules.
diff --git a/docs/content/limitations/untyped-values.mdx b/docs/content/limitations/untyped-values.mdx
index 6a881aec3..05245a396 100644
--- a/docs/content/limitations/untyped-values.mdx
+++ b/docs/content/limitations/untyped-values.mdx
@@ -15,3 +15,6 @@ JavaScript argument and return conversions preserve native array references; oth
Supported operations on `unknown` include truthiness, `typeof` narrowing, property access, `+`, `switch`, and `throw`.
Cast before using other operations.
+
+In JavaScript, a call whose untyped argument could select an overload with another result type returns a checked value, whichever overload the type checker picks, and so do the unannotated bindings and functions that hold or return it.
+In TypeScript, an `any` argument keeps the overload the type checker picks, and a value that selects another overload at runtime throws a `TypeError` where the result is used.
diff --git a/docs/package.json b/docs/package.json
index 3c2a950fd..51d74d0a1 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -10,9 +10,10 @@
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
+ "test:unit": "node --test test/compatibility-matrix.test.mjs",
"test:routes": "node scripts/test-routes.mjs",
"compat:check": "pnpm --dir .. node-compat:check",
- "check": "pnpm compat:check && pnpm typecheck && pnpm build && pnpm test:routes"
+ "check": "pnpm compat:check && pnpm typecheck && pnpm test:unit && pnpm build && pnpm test:routes"
},
"dependencies": {
"@vercel/geistdocs": "2.7.8",
diff --git a/docs/src/components/node-compatibility-matrix.tsx b/docs/src/components/node-compatibility-matrix.tsx
index 095bcdaf2..af2435668 100644
--- a/docs/src/components/node-compatibility-matrix.tsx
+++ b/docs/src/components/node-compatibility-matrix.tsx
@@ -4,52 +4,28 @@
// empty after an edit.
// @refresh reset
-import { useEffect, useMemo, useState } from "react";
+import { memo, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from "react";
+import {
+ buildTree,
+ expandableIds,
+ filterTree,
+ flattenTree,
+ nodeCount,
+ rowMatcher,
+ searchTexts,
+ statusCounts,
+ statuses,
+ type NodeStability,
+ type Row,
+ type Snapshot,
+ type Status,
+ type Verification,
+} from "@/lib/compatibility-tree";
// Set by next.config.mjs from the committed artifact.
const artifactVersion = process.env.COMPATIBILITY_ARTIFACT_VERSION;
const rowCount = Number(process.env.COMPATIBILITY_ROW_COUNT);
-type Status =
- | "supported"
- | "partial"
- | "refused"
- | "not-implemented"
- | "by-design"
- | "unreviewed"
- | "not-applicable";
-type VerificationBasis =
- | "test-backed"
- | "explicit-refusal"
- | "verified-absence"
- | "declared-gap"
- | "registry-gap"
- | "architectural-policy"
- | "unreviewed"
- | "not-applicable"
- | "derived";
-type Verification = { label: string; detail: string };
-type Tier = { status: Status; detail: string; verification: VerificationBasis };
-type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
-type Row = {
- id: string;
- chapter: string;
- kind: string;
- name: string;
- signature: string;
- label: string;
- apiSymbol: string;
- depth: number;
- scope: "api" | "documentation" | "metadata" | "configuration";
- anchor: string;
- nodeStability: NodeStability;
- static: Tier;
-};
-type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
-type Snapshot = { nodeVersion: string; verificationBases: Record; chapters: Chapter[]; rows: Row[] };
-type TreeNode = { row: Row; children: TreeNode[] };
-type VisibleNode = { node: TreeNode; level: number };
-
const labels: Record = {
supported: "Supported",
partial: "Partial",
@@ -70,7 +46,6 @@ const styles: Record = {
"not-applicable": "border-gray-alpha-400 bg-background-200 text-gray-700",
};
-const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
const stabilityStyles: Record = {
0: "border-red-500/40 bg-red-100 text-red-900 dark:bg-red-200",
1: "border-amber-500/50 bg-amber-100 text-amber-900 dark:bg-amber-200",
@@ -78,114 +53,83 @@ const stabilityStyles: Record = {
3: "border-gray-alpha-500 bg-gray-alpha-100 text-gray-900",
};
-function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
- const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
+/** A badge's tooltip, mounted only while the badge is hovered or has
+ * keyboard focus (the :focus-visible rule the CSS-only tooltip used), and
+ * named by aria-describedby while open. Hover and focus are tracked apart,
+ * so moving the pointer away keeps a keyboard-focused badge's tooltip.
+ * Escape dismisses it until the next hover or focus. */
+function BadgeTooltip({ tooltipClass, children, badge }: { tooltipClass: string; children: ReactNode; badge: ReactNode }) {
+ const [hovered, setHovered] = useState(false);
+ const [focused, setFocused] = useState(false);
+ const open = hovered || focused;
+ const id = useId();
+ const enter = useCallback(() => setHovered(true), []);
+ const leave = useCallback(() => setHovered(false), []);
+ const focus = useCallback((event: FocusEvent) => {
+ if (event.currentTarget.matches(":focus-visible")) setFocused(true);
+ }, []);
+ const blur = useCallback(() => setFocused(false), []);
+ const key = useCallback((event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ setHovered(false);
+ setFocused(false);
+ }, []);
return (
-
-
- {labels[status]}
-
- {tooltip ? (
-
- {detail ? {detail} : null}
- {verification ? Verification: {verification.label}. {verification.detail} : null}
+
+ {badge}
+ {open ? (
+
+ {children}
) : null}
);
}
-function StabilityBadge({ stability }: { stability: NodeStability }) {
+export const StatusBadge = memo(function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
+ const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
+ const badge = (
+
+ {labels[status]}
+
+ );
+ if (!tooltip) return {badge} ;
+ return (
+
+ {detail ? {detail} : null}
+ {verification ? Verification: {verification.label}. {verification.detail} : null}
+
+ );
+});
+
+export const StabilityBadge = memo(function StabilityBadge({ stability }: { stability: NodeStability }) {
if (stability === null) return — ;
const detail = `Node.js Stability ${stability.index}: ${stability.text}${stability.inherited ? " (inherited from the nearest parent section)" : ""}`;
return (
-
-
- {stability.index}
-
-
- {detail}
-
-
- );
-}
-
-function statusCounts(rows: Row[], tier: "static") {
- return rows.reduce>(
- (counts, row) => {
- if (row.scope !== "api" || row.depth === 0) return counts;
- counts[row[tier].status] += 1;
- return counts;
- },
- {
- supported: 0,
- partial: 0,
- refused: 0,
- "not-implemented": 0,
- "by-design": 0,
- unreviewed: 0,
- "not-applicable": 0,
- },
- );
-}
-
-/** Node's all.json is preorder with an explicit heading depth. */
-function buildTree(rows: Row[]): TreeNode[] {
- const roots: TreeNode[] = [];
- const stack: TreeNode[] = [];
- for (const row of rows) {
- const node: TreeNode = { row, children: [] };
- while (stack.length > row.depth) stack.pop();
- const parent = stack[row.depth - 1];
- if (row.depth === 0 || parent === undefined) roots.push(node);
- else parent.children.push(node);
- stack[row.depth] = node;
- stack.length = row.depth + 1;
- }
- return roots;
-}
-
-function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): TreeNode[] {
- const filtered: TreeNode[] = [];
- for (const node of nodes) {
- const children = filterTree(node.children, matches);
- if (matches(node.row) || children.length > 0) filtered.push({ row: node.row, children });
- }
- return filtered;
-}
-
-function flattenTree(nodes: TreeNode[], expanded: Set, forceExpanded: boolean): VisibleNode[] {
- const visible: VisibleNode[] = [];
- const visit = (items: TreeNode[], level: number) => {
- for (const node of items) {
- visible.push({ node, level });
- if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
- visit(node.children, level + 1);
+
+ {stability.index}
+
}
- }
- };
- visit(nodes, 1);
- return visible;
-}
-
-function nodeCount(nodes: TreeNode[]): number {
- return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
-}
-
-function expandableIds(nodes: TreeNode[]): Set {
- const ids = new Set();
- const visit = (items: TreeNode[]) => {
- for (const node of items) {
- if (node.children.length > 0) ids.add(node.row.id);
- visit(node.children);
- }
- };
- visit(nodes);
- return ids;
-}
+ >
+ {detail}
+
+ );
+});
function Chevron({ open }: { open: boolean }) {
return (
@@ -195,6 +139,47 @@ function Chevron({ open }: { open: boolean }) {
);
}
+type MatrixRowProps = {
+ row: Row;
+ level: number;
+ hasChildren: boolean;
+ open: boolean;
+ filtering: boolean;
+ displayName: string;
+ href: string;
+ verification: Verification | undefined;
+ onToggle: (id: string) => void;
+};
+
+/** One table row. Memoized on its own values, so a filter or expansion
+ * change re-renders only the rows whose values changed. */
+const MatrixRow = memo(function MatrixRow({ row, level, hasChildren, open, filtering, displayName, href, verification, onToggle }: MatrixRowProps) {
+ const isRoot = level === 1;
+ return (
+
+
+
+ {hasChildren ? (
+
onToggle(row.id)} disabled={filtering} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
+
+
+ ) :
}
+
+
+ {displayName}
+
+
+ {row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
+
+
+
+
+
+
+
+ );
+});
+
export function NodeCompatibilityMatrix() {
const [snapshot, setSnapshot] = useState(null);
const [loadError, setLoadError] = useState(false);
@@ -202,15 +187,16 @@ export function NodeCompatibilityMatrix() {
const [staticFilter, setStaticFilter] = useState<"all" | Status>("all");
const [expanded, setExpanded] = useState>(() => new Set());
- const rows = snapshot?.rows ?? [];
- const chapters = snapshot?.chapters ?? [];
+ const rows = useMemo(() => snapshot?.rows ?? [], [snapshot]);
+ const chapters = useMemo(() => snapshot?.chapters ?? [], [snapshot]);
const verificationBases = snapshot?.verificationBases;
const apiRowCount = useMemo(() => rows.filter((row) => row.scope === "api").length, [rows]);
const chapterTitles = useMemo(() => new Map(chapters.map((item) => [item.slug, item.title])), [chapters]);
const base = `https://nodejs.org/docs/v${snapshot?.nodeVersion ?? "24.22.0"}/api/`;
- const staticCounts = useMemo(() => statusCounts(rows, "static"), [rows]);
+ const staticCounts = useMemo(() => statusCounts(rows), [rows]);
const visibleStatuses = useMemo(() => statuses.filter((status) => staticCounts[status] > 0), [staticCounts]);
const tree = useMemo(() => buildTree(rows), [rows]);
+ const texts = useMemo(() => searchTexts(rows, chapterTitles), [rows, chapterTitles]);
useEffect(() => {
const controller = new AbortController();
@@ -231,36 +217,23 @@ export function NodeCompatibilityMatrix() {
return () => controller.abort();
}, []);
- const needle = query.trim().toLocaleLowerCase();
- const hasResultFilter = needle !== "" || staticFilter !== "all";
- const rowMatches = (row: Row) => {
- if (staticFilter !== "all" && row.static.status !== staticFilter) return false;
- if (!needle) return true;
- return [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
- .join(" ")
- .toLocaleLowerCase()
- .includes(needle);
- };
+ // The search field and the status select update at once; the table
+ // follows the deferred values, so input stays responsive while the rows
+ // re-render.
+ const deferredQuery = useDeferredValue(query);
+ const deferredStatic = useDeferredValue(staticFilter);
+ const needle = deferredQuery.trim().toLocaleLowerCase();
+ const hasResultFilter = needle !== "" || deferredStatic !== "all";
- const resultTree = useMemo(
- () => hasResultFilter ? filterTree(tree, rowMatches) : tree,
- // rowMatches closes over these filter values.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [hasResultFilter, needle, staticFilter, tree],
+ const result = useMemo(
+ () => hasResultFilter ? filterTree(tree, rowMatcher(texts, needle, deferredStatic)) : { nodes: tree, matched: nodeCount(tree) },
+ [hasResultFilter, needle, deferredStatic, texts, tree],
);
const visible = useMemo(
- () => flattenTree(resultTree, expanded, hasResultFilter),
- [expanded, hasResultFilter, resultTree],
- );
- const matchedCount = useMemo(
- () => hasResultFilter ? tree.reduce((count, node) => {
- const visit = (item: TreeNode): number => (rowMatches(item.row) ? 1 : 0) + item.children.reduce((sum, child) => sum + visit(child), 0);
- return count + visit(node);
- }, 0) : nodeCount(tree),
- // rowMatches closes over these filter values.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [hasResultFilter, needle, staticFilter, tree],
+ () => flattenTree(result.nodes, expanded, hasResultFilter),
+ [expanded, hasResultFilter, result],
);
+ const matchedCount = result.matched;
const changeQuery = (value: string) => setQuery(value);
const resetFilters = () => {
@@ -268,12 +241,12 @@ export function NodeCompatibilityMatrix() {
setStaticFilter("all");
setExpanded(new Set());
};
- const toggle = (id: string) => setExpanded((current) => {
+ const toggle = useCallback((id: string) => setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
- });
+ }), []);
useEffect(() => {
if (!snapshot) return;
@@ -316,8 +289,8 @@ export function NodeCompatibilityMatrix() {
{visible.length.toLocaleString()} visible · {matchedCount.toLocaleString()} {hasResultFilter ? "matching" : "API"} rows
{!hasResultFilter ? (
- setExpanded(expandableIds(tree))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all
- setExpanded(new Set())} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all
+ startTransition(() => setExpanded(expandableIds(tree)))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all
+ startTransition(() => setExpanded(new Set()))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all
) : null}
@@ -335,33 +308,19 @@ export function NodeCompatibilityMatrix() {
{visible.map(({ node, level }) => {
const row = node.row;
- const hasChildren = node.children.length > 0;
- const open = hasResultFilter || expanded.has(row.id);
- const href = `${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`;
- const isRoot = level === 1;
- const displayName = isRoot ? chapterTitles.get(row.chapter) ?? row.label : row.label;
return (
-
-
-
- {hasChildren ? (
-
toggle(row.id)} disabled={hasResultFilter} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
-
-
- ) :
}
-
-
- {displayName}
-
-
- {row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
-
-
-
-
-
-
-
+ 0}
+ open={hasResultFilter || expanded.has(row.id)}
+ filtering={hasResultFilter}
+ displayName={level === 1 ? chapterTitles.get(row.chapter) ?? row.label : row.label}
+ href={`${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`}
+ verification={verificationBases?.[row.static.verification]}
+ onToggle={toggle}
+ />
);
})}
diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json
index c3f1835ab..2475cd122 100644
--- a/docs/src/generated/node-v24-compatibility.json
+++ b/docs/src/generated/node-v24-compatibility.json
@@ -11476,9 +11476,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -18268,9 +18268,9 @@
"anchor": "err_brotli_invalid_param",
"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"
}
},
{
@@ -35971,9 +35971,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"
}
},
{
@@ -71509,9 +71509,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"
}
},
{
@@ -81657,9 +81657,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 static async-iteration subset.",
+ "verification": "test-backed"
}
},
{
@@ -84302,9 +84302,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "BrotliCompress and BrotliDecompress are Transform streams over the brotli version Node bundles, with Node's parameters, dictionaries, flush, reset, error codes, and option validation; like Node's, they have no params().",
+ "verification": "test-backed"
}
},
{
@@ -84325,9 +84325,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "BrotliCompress and BrotliDecompress are Transform streams over the brotli version Node bundles, with Node's parameters, dictionaries, flush, reset, error codes, and option validation; like Node's, they have no params().",
+ "verification": "test-backed"
}
},
{
@@ -84349,7 +84349,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84372,7 +84372,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84395,7 +84395,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84418,7 +84418,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84441,7 +84441,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84464,7 +84464,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84487,7 +84487,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84510,7 +84510,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84533,7 +84533,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84556,7 +84556,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84579,7 +84579,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84602,7 +84602,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84625,7 +84625,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -84716,9 +84716,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -84739,9 +84739,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -84969,9 +84969,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -84992,9 +84992,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85015,9 +85015,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85038,9 +85038,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85499,7 +85499,7 @@
},
"static": {
"status": "partial",
- "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.",
+ "detail": "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.",
"verification": "test-backed"
}
},
@@ -85521,9 +85521,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "BrotliCompress and BrotliDecompress are Transform streams over the brotli version Node bundles, with Node's parameters, dictionaries, flush, reset, error codes, and option validation; like Node's, they have no params().",
+ "verification": "test-backed"
}
},
{
@@ -85567,9 +85567,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85590,9 +85590,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85613,9 +85613,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -85636,9 +85636,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
diff --git a/docs/src/lib/compatibility-tree.ts b/docs/src/lib/compatibility-tree.ts
new file mode 100644
index 000000000..4044bbc8d
--- /dev/null
+++ b/docs/src/lib/compatibility-tree.ts
@@ -0,0 +1,157 @@
+// The compatibility matrix's data model: the ledger rows as a tree, and the
+// search and filter passes over it. Kept free of React so the passes can be
+// tested directly.
+
+export type Status =
+ | "supported"
+ | "partial"
+ | "refused"
+ | "not-implemented"
+ | "by-design"
+ | "unreviewed"
+ | "not-applicable";
+export type VerificationBasis =
+ | "test-backed"
+ | "explicit-refusal"
+ | "verified-absence"
+ | "declared-gap"
+ | "registry-gap"
+ | "architectural-policy"
+ | "unreviewed"
+ | "not-applicable"
+ | "derived";
+export type Verification = { label: string; detail: string };
+export type Tier = { status: Status; detail: string; verification: VerificationBasis };
+export type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
+export type Row = {
+ id: string;
+ chapter: string;
+ kind: string;
+ name: string;
+ signature: string;
+ label: string;
+ apiSymbol: string;
+ depth: number;
+ scope: "api" | "documentation" | "metadata" | "configuration";
+ anchor: string;
+ nodeStability: NodeStability;
+ static: Tier;
+};
+export type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
+export type Snapshot = {
+ nodeVersion: string;
+ verificationBases: Record;
+ chapters: Chapter[];
+ rows: Row[];
+};
+export type TreeNode = { row: Row; children: TreeNode[] };
+export type VisibleNode = { node: TreeNode; level: number };
+
+export const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
+
+export function statusCounts(rows: Row[]): Record {
+ return rows.reduce>(
+ (counts, row) => {
+ if (row.scope !== "api" || row.depth === 0) return counts;
+ counts[row.static.status] += 1;
+ return counts;
+ },
+ {
+ supported: 0,
+ partial: 0,
+ refused: 0,
+ "not-implemented": 0,
+ "by-design": 0,
+ unreviewed: 0,
+ "not-applicable": 0,
+ },
+ );
+}
+
+/** Node's all.json is preorder with an explicit heading depth. */
+export function buildTree(rows: Row[]): TreeNode[] {
+ const roots: TreeNode[] = [];
+ const stack: TreeNode[] = [];
+ for (const row of rows) {
+ const node: TreeNode = { row, children: [] };
+ while (stack.length > row.depth) stack.pop();
+ const parent = stack[row.depth - 1];
+ if (row.depth === 0 || parent === undefined) roots.push(node);
+ else parent.children.push(node);
+ stack[row.depth] = node;
+ stack.length = row.depth + 1;
+ }
+ return roots;
+}
+
+/** The lowercase text a search matches against, built once per row: the
+ * chapter title, kind, name, signature, API symbol and support detail. */
+export function searchTexts(rows: Row[], chapterTitles: ReadonlyMap): Map {
+ return new Map(
+ rows.map((row) => [
+ row.id,
+ [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
+ .join(" ")
+ .toLocaleLowerCase(),
+ ]),
+ );
+}
+
+/** The rows a status filter and a lowercase search needle keep. */
+export function rowMatcher(
+ texts: ReadonlyMap,
+ needle: string,
+ status: "all" | Status,
+): (row: Row) => boolean {
+ return (row) => {
+ if (status !== "all" && row.static.status !== status) return false;
+ return needle === "" || (texts.get(row.id) ?? "").includes(needle);
+ };
+}
+
+/** The matching rows with their ancestors, and the number of matching
+ * rows, in one pass. */
+export function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): { nodes: TreeNode[]; matched: number } {
+ let matched = 0;
+ const visit = (items: TreeNode[]): TreeNode[] => {
+ const filtered: TreeNode[] = [];
+ for (const node of items) {
+ const children = visit(node.children);
+ const own = matches(node.row);
+ if (own) matched += 1;
+ if (own || children.length > 0) filtered.push({ row: node.row, children });
+ }
+ return filtered;
+ };
+ return { nodes: visit(nodes), matched };
+}
+
+export function flattenTree(nodes: TreeNode[], expanded: ReadonlySet, forceExpanded: boolean): VisibleNode[] {
+ const visible: VisibleNode[] = [];
+ const visit = (items: TreeNode[], level: number) => {
+ for (const node of items) {
+ visible.push({ node, level });
+ if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
+ visit(node.children, level + 1);
+ }
+ }
+ };
+ visit(nodes, 1);
+ return visible;
+}
+
+export function nodeCount(nodes: TreeNode[]): number {
+ return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
+}
+
+export function expandableIds(nodes: TreeNode[]): Set {
+ const ids = new Set();
+ const visit = (items: TreeNode[]) => {
+ for (const node of items) {
+ if (node.children.length > 0) ids.add(node.row.id);
+ visit(node.children);
+ }
+ };
+ visit(nodes);
+ return ids;
+}
diff --git a/docs/test/compatibility-matrix.test.mjs b/docs/test/compatibility-matrix.test.mjs
new file mode 100644
index 000000000..abd3df945
--- /dev/null
+++ b/docs/test/compatibility-matrix.test.mjs
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { test } from "node:test";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import ts from "typescript";
+import {
+ buildTree,
+ expandableIds,
+ filterTree,
+ flattenTree,
+ nodeCount,
+ rowMatcher,
+ searchTexts,
+ statusCounts,
+} from "../src/lib/compatibility-tree.ts";
+
+const row = (id, depth, label, status, detail = "") => ({
+ id,
+ chapter: "zlib",
+ kind: depth === 0 ? "module" : "method",
+ name: label,
+ signature: label,
+ label,
+ apiSymbol: label,
+ depth,
+ scope: "api",
+ anchor: "",
+ nodeStability: null,
+ static: { status, detail, verification: "test-backed" },
+});
+const rows = [
+ row("root", 0, "zlib", "partial"),
+ row("a", 1, "zlib.gzipSync()", "supported", "Lowered with Node's options"),
+ row("b", 1, "zlib.Gzip", "partial"),
+ row("b1", 2, "gzip.flush()", "supported"),
+ row("c", 1, "zlib.zstdCompress()", "not-implemented"),
+];
+const titles = new Map([["zlib", "Zlib"]]);
+
+test("the tree follows the rows' heading depths", () => {
+ const tree = buildTree(rows);
+ assert.equal(tree.length, 1);
+ assert.deepEqual(tree[0].children.map((node) => node.row.id), ["a", "b", "c"]);
+ assert.deepEqual(tree[0].children[1].children.map((node) => node.row.id), ["b1"]);
+ assert.equal(nodeCount(tree), 5);
+ assert.deepEqual([...expandableIds(tree)].sort(), ["b", "root"]);
+});
+
+test("filtering keeps matching rows with their ancestors and counts the matches once", () => {
+ const tree = buildTree(rows);
+ const texts = searchTexts(rows, titles);
+ const byText = filterTree(tree, rowMatcher(texts, "flush", "all"));
+ assert.equal(byText.matched, 1);
+ assert.deepEqual(flattenTree(byText.nodes, new Set(), true).map(({ node, level }) => `${node.row.id}@${level}`), ["root@1", "b@2", "b1@3"]);
+ const byDetail = filterTree(tree, rowMatcher(texts, "node's options", "all"));
+ assert.deepEqual(flattenTree(byDetail.nodes, new Set(), true).map(({ node }) => node.row.id), ["root", "a"]);
+ const byStatus = filterTree(tree, rowMatcher(texts, "", "supported"));
+ assert.equal(byStatus.matched, 2);
+ const both = filterTree(tree, rowMatcher(texts, "gzip", "partial"));
+ assert.equal(both.matched, 1);
+ assert.equal(filterTree(tree, rowMatcher(texts, "brotli", "all")).nodes.length, 0);
+});
+
+test("collapsed rows hide their children until expanded", () => {
+ const tree = buildTree(rows);
+ assert.deepEqual(flattenTree(tree, new Set(), false).map(({ node }) => node.row.id), ["root"]);
+ assert.deepEqual(flattenTree(tree, new Set(["root"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "c"]);
+ assert.deepEqual(flattenTree(tree, new Set(["root", "b"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "b1", "c"]);
+});
+
+test("status counts cover API rows below the chapters", () => {
+ const counts = statusCounts(rows);
+ assert.equal(counts.supported, 2);
+ assert.equal(counts.partial, 1);
+ assert.equal(counts["not-implemented"], 1);
+});
+
+/** The matrix component, compiled for Node from its TSX source. */
+async function loadMatrix() {
+ const docs = new URL("../", import.meta.url);
+ const source = await readFile(new URL("src/components/node-compatibility-matrix.tsx", docs), "utf8");
+ const { outputText } = ts.transpileModule(source, {
+ compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+ });
+ const lib = pathToFileURL(fileURLToPath(new URL("src/lib/compatibility-tree.ts", docs))).href;
+ const dir = new URL("node_modules/.cache/compatibility-matrix-test/", docs);
+ await mkdir(dir, { recursive: true });
+ const file = new URL("node-compatibility-matrix.mjs", dir);
+ await writeFile(file, outputText.replace('"@/lib/compatibility-tree"', JSON.stringify(lib)));
+ return import(file.href);
+}
+
+test("badges render without their tooltips until hovered or focused", async () => {
+ const { StatusBadge, StabilityBadge } = await loadMatrix();
+ const status = renderToStaticMarkup(
+ createElement(StatusBadge, {
+ status: "partial",
+ detail: "Lowered with Node's options",
+ verification: { label: "Test-backed", detail: "Corpus programs cover it." },
+ }),
+ );
+ assert.ok(status.includes('tabindex="0"'), status);
+ assert.ok(status.includes('title="Lowered with Node's options'), status);
+ assert.ok(!status.includes('role="tooltip"'), status);
+ assert.ok(!status.includes("aria-describedby"), status);
+ const stability = renderToStaticMarkup(
+ createElement(StabilityBadge, { stability: { index: "2", level: 2, text: "Stable", inherited: false } }),
+ );
+ assert.ok(stability.includes('tabindex="0"'), stability);
+ assert.ok(!stability.includes('role="tooltip"'), stability);
+ const legend = renderToStaticMarkup(createElement(StatusBadge, { status: "supported" }));
+ assert.ok(!legend.includes("tabindex"), legend);
+});
diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json
index ba8692b49..cb06685f8 100644
--- a/internal/compatibility/generated/node-v24-internal.json
+++ b/internal/compatibility/generated/node-v24-internal.json
@@ -1387,7 +1387,20 @@
"evidence": "derived:descendants",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "chapter"
@@ -1437,7 +1450,20 @@
"evidence": "compiler-dedicated:AsyncLocalStorage",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1510,7 +1536,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.disable",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1537,7 +1576,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.getStore",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1564,7 +1616,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.enterWith",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1591,7 +1656,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.run",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1618,7 +1696,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.exit",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1922,6 +2013,19 @@
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
"tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs",
"tests/harness/node-test.test.ts"
]
},
@@ -2484,7 +2588,20 @@
"evidence": "compiler-dedicated:AsyncLocalStorage",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -20228,8 +20345,19 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.crypto.diffieHellman"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.crypto.diffieHellman",
+ "tests": [
+ "tests/corpus/1358-crypto-random.ts",
+ "tests/corpus/1534-crypto-hash-chain.ts",
+ "tests/corpus/2556-crypto-introspection.ts",
+ "tests/corpus/2890-crypto-static-utility.ts",
+ "tests/harness/npm-static.test.ts",
+ "tests/corpus/node24-crypto-sha2.ts",
+ "tests/corpus/node24-crypto-kdf.ts",
+ "tests/corpus/node24-crypto-kdf-errors.ts",
+ "tests/corpus/node24-crypto-kdf-options.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -32551,6 +32679,7 @@
"tests": [
"tests/corpus/6702-ecdh-errors.js",
"tests/corpus/6708-dh-errors.js",
+ "tests/corpus/7714-zlib-brotli-compress.cjs",
"tests/corpus/caught-system-error-fields.ts",
"tests/corpus/error-stack-frames.ts",
"tests/corpus/error-stack-limit.ts",
@@ -33343,8 +33472,11 @@
"anchor": "err_brotli_invalid_param",
"nodeStability": null,
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:errors"
+ "status": "partial",
+ "evidence": "compiler-feature:errors.ERR_BROTLI_INVALID_PARAM",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -52194,6 +52326,8 @@
"tests/corpus/6294-webcrypto-js-values.mjs",
"tests/corpus/6295-jose-hs256/main.mjs",
"tests/corpus/6331-jose-root-entry/main.ts",
+ "tests/corpus/7743-transform-stream-identity.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/text-codec-values/main.ts",
"tests/corpus/webassembly-api.mjs",
"tests/corpus/webassembly-dylink/main.mjs",
@@ -53346,7 +53480,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53397,7 +53532,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53443,8 +53579,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:globals"
+ "status": "partial",
+ "evidence": "compiler-feature:globals.TransformStream",
+ "tests": [
+ "tests/corpus/7743-transform-stream-identity.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -99348,6 +99487,7 @@
"evidence": "derived:descendants",
"tests": [
"packages/runtime/test/url-legacy.test.ts",
+ "tests/corpus/1794-searchparams-url-live.ts",
"tests/corpus/5220-url-parse-warnings.ts",
"tests/corpus/5221-url-parse-shapes.ts",
"tests/corpus/5222-url-parse-hosts.ts",
@@ -100606,8 +100746,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:url"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.url.urlToHttpOptions",
+ "tests": [
+ "tests/corpus/1794-searchparams-url-live.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -101277,6 +101420,7 @@
"tests/corpus/5835-util-format-integer-specifiers.ts",
"tests/corpus/5884-inspect-layout-options.ts",
"tests/corpus/6428-promisify-callback-functions.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/node24-deep-equality-properties.js",
"tests/corpus/node24-deep-equality.cjs",
"tests/corpus/node24-deep-equality.ts",
@@ -101805,7 +101949,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -101926,7 +102071,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102036,7 +102182,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102065,7 +102212,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -111483,6 +111631,7 @@
"tests/corpus/2630-stream-consumers-errors.ts",
"tests/corpus/2862-web-readable-async-iteration.ts",
"tests/corpus/4875-stream-consumers-array-buffer.ts",
+ "tests/corpus/7743-transform-stream-identity.ts",
"tests/corpus/stream-web-readable.ts",
"tests/corpus/stream-web-require.cjs",
"tests/harness/fetch-conformance.test.ts"
@@ -112957,8 +113106,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:webstreams"
+ "status": "partial",
+ "evidence": "compiler-feature:webstreams.TransformStream",
+ "tests": [
+ "tests/corpus/7743-transform-stream-identity.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -115612,7 +115764,10 @@
"tests/corpus/5377-zlib-stream-backpressure.cjs",
"tests/corpus/5378-zlib-sync-options.cjs",
"tests/corpus/5379-zlib-callback-options.ts",
- "tests/corpus/5415-zlib-dynamic-inputs.cjs"
+ "tests/corpus/5415-zlib-dynamic-inputs.cjs",
+ "tests/corpus/7714-zlib-brotli-compress.cjs",
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
]
},
"anchorSource": "chapter"
@@ -116095,8 +116250,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.BrotliCompress"
+ "status": "partial",
+ "evidence": "compiler-feature:zlib.zlib.BrotliCompress",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -116118,8 +116276,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.BrotliDecompress"
+ "status": "partial",
+ "evidence": "compiler-feature:zlib.zlib.BrotliDecompress",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -116637,8 +116799,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.createBrotliCompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.createBrotliCompress",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -116660,8 +116825,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.createBrotliDecompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.createBrotliDecompress",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -116967,8 +117136,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliCompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliCompress",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -116990,8 +117162,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliCompressSync"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliCompressSync",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -117013,8 +117188,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliDecompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliDecompress",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -117036,8 +117215,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliDecompressSync"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliDecompressSync",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -118054,8 +118237,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:zlib"
+ "status": "partial",
+ "evidence": "compiler-feature:zlib.BrotliOptions",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "exact"
},
@@ -118123,8 +118309,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliCompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliCompress",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "ancestor"
},
@@ -118146,8 +118335,11 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliCompressSync"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliCompressSync",
+ "tests": [
+ "tests/corpus/7714-zlib-brotli-compress.cjs"
+ ]
},
"anchorSource": "ancestor"
},
@@ -118169,8 +118361,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliDecompress"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliDecompress",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "ancestor"
},
@@ -118192,8 +118388,12 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.zlib.brotliDecompressSync"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.zlib.brotliDecompressSync",
+ "tests": [
+ "tests/corpus/7715-zlib-brotli-decompress.cjs",
+ "tests/corpus/7716-zlib-brotli.ts"
+ ]
},
"anchorSource": "ancestor"
},
diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs
index 662c0abd6..1b598fbd3 100644
--- a/internal/compatibility/src/generate.mjs
+++ b/internal/compatibility/src/generate.mjs
@@ -652,7 +652,14 @@ function publicDetail(tier) {
source,
)
) {
- return "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Brotli and Zstd streams remain unsupported.";
+ return "Deflate, Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, and Unzip are Transform streams with Node's output chunking, flush, params, reset, error codes, and option validation; Zstd streams remain unsupported.";
+ }
+ if (
+ /^compiler-feature:zlib\.(?:zlib\.(?:BrotliCompress|BrotliDecompress)|BrotliOptions)$/.test(
+ source,
+ )
+ ) {
+ return "BrotliCompress and BrotliDecompress are Transform streams over the brotli version Node bundles, with Node's parameters, dictionaries, flush, reset, error codes, and option validation; like Node's, they have no params().";
}
if (source === "compiler-feature:zlib.zlib.constants") {
return "Each zlib.constants member and its module-level copy reads as Node's value; the constants object itself is not a runtime value.";
diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json
index 100882c1a..89992231e 100644
--- a/internal/compatibility/static-support.json
+++ b/internal/compatibility/static-support.json
@@ -190,6 +190,12 @@
"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"],
+ "node-builtin.zlib.brotliCompressSync": ["tests/corpus/7714-zlib-brotli-compress.cjs"],
+ "node-builtin.zlib.brotliCompress": ["tests/corpus/7714-zlib-brotli-compress.cjs"],
+ "node-builtin.zlib.createBrotliCompress": ["tests/corpus/7714-zlib-brotli-compress.cjs"],
+ "node-builtin.zlib.brotliDecompressSync": ["tests/corpus/7715-zlib-brotli-decompress.cjs", "tests/corpus/7716-zlib-brotli.ts"],
+ "node-builtin.zlib.brotliDecompress": ["tests/corpus/7715-zlib-brotli-decompress.cjs", "tests/corpus/7716-zlib-brotli.ts"],
+ "node-builtin.zlib.createBrotliDecompress": ["tests/corpus/7715-zlib-brotli-decompress.cjs", "tests/corpus/7716-zlib-brotli.ts"],
"stdlib.fetch": ["tests/harness/fetch-conformance.test.ts"],
"stdlib.map": ["tests/corpus/typed-collection-iterators.ts", "tests/corpus/typed-collection-iterator-references.ts", "tests/corpus/537-map-iter-drains.ts"],
"stdlib.set": ["tests/corpus/typed-collection-iterators.ts", "tests/corpus/typed-collection-iterator-references.ts", "tests/corpus/checked-collection-iterators.ts"],
@@ -332,11 +338,11 @@
{ "chapter": "buffer", "signatures": ["`buf.readBigInt64BE([offset])`", "`buf.readBigInt64LE([offset])`", "`buf.readBigUInt64BE([offset])`", "`buf.readBigUInt64LE([offset])`", "`buf.writeBigInt64BE(value[, offset])`", "`buf.writeBigInt64LE(value[, offset])`", "`buf.writeBigUInt64BE(value[, offset])`", "`buf.writeBigUInt64LE(value[, offset])`"], "status": "supported", "evidence": ["tests/corpus/2902-bigint-bytes.ts", "tests/corpus/2904-bigint-errors.ts"] },
{ "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" },
{ "chapter": "globals", "symbols": ["fetch"], "status": "partial", "evidence": ["tests/harness/fetch-conformance.test.ts"] },
- { "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
+ { "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "globals", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5287-blob-inspect.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5290-blob-dynamic.js"] },
{ "chapter": "buffer", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5288-blob-validation.js", "tests/fixtures/node-types/blob-file.ts"] },
{ "chapter": "buffer", "signatures": ["`blob.arrayBuffer()`", "`blob.bytes()`", "`blob.text()`", "`blob.slice([start[, end[, type]]])`", "`blob.size`", "`type` Type: {string}", "`name` Type: {string}", "`lastModified` Type: {number}"], "status": "supported", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5289-blob-read-order.ts", "tests/corpus/5290-blob-dynamic.js", "tests/fixtures/node-types/blob-file.ts"] },
- { "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
+ { "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "modules", "symbols": ["__dirname", "__filename", "exports", "require", "module.require", "main", "filename"], "status": "partial", "evidence": ["tests/corpus/1612-cjs-module-globals.cjs", "tests/corpus/1596-cjs-modules/main.js", "tests/corpus/1629-require-main-filename.cjs", "tests/corpus/2830-cjs-require-self-name/main.cjs", "tests/corpus/2880-cjs-module-graph/main.cjs", "tests/corpus/2923-cjs-factory-export/main.cjs"] },
{ "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths"], "status": "partial", "evidence": ["tests/corpus/2858-import-meta-resolve/main.mjs", "tests/corpus/2860-require-resolve/main.cjs"] },
{ "chapter": "modules", "symbols": ["cache", "children", "id", "isPreloading", "loaded", "parent", "path", "paths", "Module"], "status": "partial", "evidence": ["tests/corpus/2880-cjs-module-graph/main.cjs"] },
@@ -404,10 +410,15 @@
{ "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": "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"] },
+ { "chapter": "errors", "symbols": ["ERR_BROTLI_INVALID_PARAM"], "status": "partial", "evidence": ["tests/corpus/7714-zlib-brotli-compress.cjs"] },
+ { "chapter": "zlib", "signatures": ["Class: `zlib.BrotliDecompress`"], "status": "partial", "evidence": ["tests/corpus/7715-zlib-brotli-decompress.cjs", "tests/corpus/7716-zlib-brotli.ts"] },
{ "chapter": "zlib", "signatures": ["`zlib.constants`"], "status": "partial", "evidence": ["tests/corpus/5376-zlib-constants.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts"] },
{ "chapter": "dns", "symbols": ["dns.lookup", "dns.lookupService", "dns.getServers", "dns.getDefaultResultOrder", "dns.setDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/5648-dns-lookup-forms.ts", "tests/corpus/5649-dns-service-servers.ts", "tests/corpus/5650-dns-lookup-errors-untyped.cjs"] },
{ "chapter": "dns", "symbols": ["dnsPromises.lookup", "dnsPromises.lookupService", "dnsPromises.getDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/dns-promises-lookup.ts", "tests/corpus/dns-promises-untyped.cjs"] },
- { "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] }
+ { "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] },
+ { "chapter": "webstreams", "symbols": ["TransformStream"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] },
+ { "chapter": "globals", "signatures": ["Class: `TransformStream`"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] }
],
"dedicated": [
{ "symbols": ["assert.doesNotThrow", "assert.doesNotReject"], "status": "partial", "evidence": ["tests/corpus/node24-no-error-assertions.ts", "tests/corpus/node24-no-error-assertions.cjs", "tests/corpus/node24-no-error-assertions-coercion.js", "tests/fixtures/node-types/no-error-assertions.ts", "tests/harness/project-config.test.ts"] },
@@ -439,7 +450,7 @@
{ "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] },
{ "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] },
{ "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts", "tests/corpus/5846-events-esm-default-max.ts", "tests/corpus/5847-events-cjs-default-max.cjs"] },
- { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts"] },
+ { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable", "asyncLocalStorage.name", "AsyncLocalStorage.bind", "AsyncLocalStorage.snapshot"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts", "tests/corpus/7110-als-completion-callbacks.ts", "tests/corpus/7111-als-bind-snapshot-options.ts", "tests/corpus/7112-als-bind-snapshot-dynamic.mjs", "tests/corpus/7119-als-net-event-context.mjs", "tests/corpus/7380-als-http-event-context.mjs", "tests/corpus/7383-als-stream-events.ts", "tests/corpus/7384-als-stream-events.mjs", "tests/corpus/7385-als-child-process-events.ts", "tests/corpus/7386-als-child-fork-events/main.mjs", "tests/corpus/7387-als-http2-events.mjs", "tests/corpus/7388-als-http2-compat-events.mjs", "tests/corpus/7389-als-http2-secure-events.mjs", "tests/corpus/7391-als-response-end-and-finished.mjs"] },
{ "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] },
{ "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] },
{ "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] },
diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json
index 23584b60a..bac01cc44 100644
--- a/packages/cli-darwin-arm64/package.json
+++ b/packages/cli-darwin-arm64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-arm64",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json
index d9e6dafc6..6c2fb3123 100644
--- a/packages/cli-darwin-x64/package.json
+++ b/packages/cli-darwin-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-x64",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json
index 5b8cb4db2..289d8d6bd 100644
--- a/packages/cli-linux-arm64-gnu/package.json
+++ b/packages/cli-linux-arm64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json
index 0901602ec..3a601ca33 100644
--- a/packages/cli-linux-arm64-musl/package.json
+++ b/packages/cli-linux-arm64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json
index f01478aad..8f6b1aa5e 100644
--- a/packages/cli-linux-x64-gnu/package.json
+++ b/packages/cli-linux-x64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json
index a6986a66a..ff0d1d686 100644
--- a/packages/cli-linux-x64-musl/package.json
+++ b/packages/cli-linux-x64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-musl",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json
index 6b03811b8..e94ba31df 100644
--- a/packages/cli-win32-x64-msvc/package.json
+++ b/packages/cli-win32-x64-msvc/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 034747c29..26cf10f07 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "scriptc",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts
index 709bd1124..127a11064 100644
--- a/packages/cli/src/bootstrap.ts
+++ b/packages/cli/src/bootstrap.ts
@@ -70,6 +70,8 @@ async function tryFastPath(): Promise {
values["profile-generate"] !== undefined ||
values["profile-use"] !== undefined ||
values.sparse ||
+ // The full compiler resolves `native` against the build host.
+ values["target-cpu"] !== undefined ||
(values["external-types"] ?? []).length > 0 ||
// The full CLI validates the Node island policy flags.
hasNodeIslandFlags(values)
@@ -217,6 +219,7 @@ async function coverageFastPath(
values.emit !== undefined ||
values.strip ||
values["detached-exit"] ||
+ values["target-cpu"] !== undefined ||
values.lib ||
values.profile !== undefined ||
values.ffi !== undefined ||
diff --git a/packages/cli/test/target-cpu.test.ts b/packages/cli/test/target-cpu.test.ts
new file mode 100644
index 000000000..ca5243564
--- /dev/null
+++ b/packages/cli/test/target-cpu.test.ts
@@ -0,0 +1,100 @@
+import { execFile } from "node:child_process";
+import { createRequire } from "node:module";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { promisify } from "node:util";
+import { expect, test } from "vitest";
+import { hostTargetCpu } from "../../compiler/src/backend/target-cpu.js";
+
+const execFileAsync = promisify(execFile);
+const require = createRequire(import.meta.url);
+const repoRoot = join(import.meta.dirname, "../../..");
+const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
+const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js");
+const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
+const host = hostTargetCpu();
+const runsV3 = host === "x86-64-v3" || host === "x86-64-v4";
+
+const program = `const a = new Int32Array(4096);
+for (let i = 0; i < a.length; i++) a[i] = (i * 2654435761) | 0;
+let s = 0;
+for (let r = 0; r < 64; r++) for (let i = 0; i < a.length; i++) s = (s + a[i]!) | 0;
+console.log(s);
+`;
+
+test.runIf(process.platform === "linux" && process.arch === "x64")(
+ "--target-cpu compiles the program for the level without mixing cache variants",
+ async () => {
+ const dir = await mkdtemp(join("/tmp", "scriptc-target-cpu-"));
+ const entry = join(dir, "main.ts");
+ const outPath = join(dir, "program");
+ const llvmPath = join(dir, "main.ll");
+ const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") };
+ const build = async (args: string[], bootstrap = false): Promise => {
+ await execFileAsync(
+ process.execPath,
+ [
+ ...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]),
+ "build",
+ entry,
+ "-o",
+ outPath,
+ ...args,
+ ],
+ { env, maxBuffer: 4 * 1024 * 1024 },
+ );
+ return readFile(llvmPath, "utf8");
+ };
+ try {
+ await writeFile(entry, program);
+ const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`;
+ const baseline = await build([]);
+ expect(baseline).not.toContain('"target-cpu"');
+ 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);
+ if (runsV3) expect((await execFileAsync(outPath)).stdout).toBe(expected);
+ // The bootstrap's cached route builds without a level.
+ expect(await build([], true)).toBe(baseline);
+ expect((await execFileAsync(outPath)).stdout).toBe(expected);
+ await expect(build(["--target-cpu=haswell"])).rejects.toThrow(/unknown target CPU "haswell"/);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ },
+ 240_000,
+);
+
+test("--target-cpu levels are refused for arm64 and WASI targets", async () => {
+ const dir = await mkdtemp(join("/tmp", "scriptc-target-cpu-refusal-"));
+ const entry = join(dir, "main.ts");
+ try {
+ await writeFile(entry, program);
+ for (const target of ["aarch64-unknown-linux-gnu", "wasm32-wasi"]) {
+ for (const kind of ["llvm", "asm", "obj", "exe"]) {
+ const build = execFileAsync(
+ process.execPath,
+ [
+ "--import",
+ tsxLoader,
+ cliEntry,
+ "build",
+ entry,
+ `--emit=${kind}`,
+ "-o",
+ join(dir, `out-${kind}`),
+ "--target-cpu=x86-64-v3",
+ ],
+ {
+ env: { ...process.env, SCRIPTC_TARGET: target, SCRIPTC_CACHE_DIR: join(dir, "cache") },
+ },
+ );
+ await expect(build, `${target} ${kind}`).rejects.toThrow(
+ /--target-cpu=x86-64-v3 requires an x86-64 target/,
+ );
+ }
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}, 240_000);
diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts
index d7abe70df..1fa787e35 100644
--- a/packages/compiler/ambient/scriptc-node-fallback.d.ts
+++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts
@@ -917,6 +917,14 @@ interface Buffer<
readBigUint64LE(offset?: number): bigint;
}
interface BufferConstructor {
+ /** @deprecated since v10.0.0 - Use `Buffer.from(string[, encoding])` instead. */
+ new (str: string, encoding?: BufferEncoding): Buffer;
+ /** @deprecated since v10.0.0 - Use `Buffer.alloc()` instead. */
+ new (size: number): Buffer;
+ /** @deprecated since v10.0.0 - Use `Buffer.from(array)` instead. */
+ new (array: ArrayLike): Buffer;
+ /** @deprecated since v10.0.0 - Use `Buffer.from(arrayBuffer[, byteOffset[, length]])` instead. */
+ new (arrayBuffer: ArrayBuffer | SharedArrayBuffer): Buffer;
from(data: string, encoding?: BufferEncoding): Buffer;
/* The ArrayBuffer form is the VIEW construction: x.buffer with an
* optional byte offset/length shares x's storage (the subarray rule). */
@@ -1106,6 +1114,34 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream;
readonly prototype: ReadableStream;
};
+/* The writable side of a TransformStream and its writer. The
+ * WritableStream constructor has no native lowering; writable streams come
+ * from `new TransformStream()`. */
+interface WritableStreamDefaultWriter {
+ readonly closed: Promise;
+ readonly desiredSize: number | null;
+ readonly ready: Promise;
+ abort(reason?: unknown): Promise;
+ close(): Promise;
+ releaseLock(): void;
+ write(chunk?: W): Promise;
+}
+interface WritableStream {
+ readonly locked: boolean;
+ abort(reason?: unknown): Promise;
+ close(): Promise;
+ getWriter(): WritableStreamDefaultWriter;
+}
+/* The identity transform: chunks written to `writable` are read from
+ * `readable`, and a write completes when its chunk is read. */
+interface TransformStream {
+ readonly readable: ReadableStream;
+ readonly writable: WritableStream;
+}
+declare var TransformStream: {
+ new (): TransformStream;
+ readonly prototype: TransformStream;
+};
/* fetch, typed as the native JSON + readable-stream slice: one URL, an
* optional init with the members CLI requests carry, and a Response
@@ -1554,7 +1590,7 @@ declare module "node:fs" {
export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void;
export function writeFileSync(path: string | URL, data: string | Uint8Array): void;
export function appendFileSync(path: string | URL, data: string | Uint8Array): void;
- export function existsSync(path: string): boolean;
+ export function existsSync(path: string | URL): boolean;
export function mkdirSync(path: string | URL): void;
export function mkdirSync(
path: string | URL,
@@ -2195,7 +2231,7 @@ declare module "fs/promises" {
stat(): Promise;
[Symbol.asyncDispose](): Promise;
}
- export function open(path: string, flags?: string, mode?: number): Promise;
+ export function open(path: string | URL, flags?: string, mode?: number): Promise;
export function link(
existingPath: string | Uint8Array | URL,
newPath: string | Uint8Array | URL,
@@ -2232,14 +2268,14 @@ declare module "fs/promises" {
atime: number | string | Date,
mtime: number | string | Date,
): Promise;
- export function readFile(path: string, encoding: "utf8" | "utf-8"): Promise;
+ export function readFile(path: string | URL, encoding: "utf8" | "utf-8"): Promise;
export function readFile(
- path: string,
+ path: string | URL,
options: BufferEncoding | { encoding: BufferEncoding },
): Promise;
- export function readFile(path: string): Promise;
+ export function readFile(path: string | URL): Promise;
export function writeFile(
- path: string,
+ path: string | URL,
data: string,
options?: {
mode?: number;
@@ -2248,14 +2284,18 @@ declare module "fs/promises" {
[option: string]: unknown;
},
): Promise;
- export function writeFile(path: string, data: string, encoding: "utf8" | "utf-8"): Promise;
+ export function writeFile(
+ path: string | URL,
+ data: string,
+ encoding: "utf8" | "utf-8",
+ ): Promise;
export function mkdir(
- path: string,
+ path: string | URL,
options?: { recursive?: boolean; mode?: number },
): Promise;
- export function readdir(path: string): Promise;
+ export function readdir(path: string | URL): Promise;
export function readdir(
- path: string,
+ path: string | URL,
options: {
encoding?: "utf8" | "utf-8";
withFileTypes: true;
@@ -2272,12 +2312,12 @@ declare module "fs/promises" {
pattern: string | readonly string[],
options: import("node:fs").GlobOptionsWithFileTypes,
): AsyncIterableIterator;
- export function rm(path: string): Promise;
+ export function rm(path: string | URL): Promise;
export function rm(
- path: string,
+ path: string | URL,
options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number },
): Promise;
- export function stat(path: string): Promise;
+ export function stat(path: string | URL): Promise;
export function statfs(
path: string | Uint8Array | URL,
options?: import("node:fs").StatFsOptions & { bigint?: false },
@@ -2290,21 +2330,21 @@ declare module "fs/promises" {
path: string | Uint8Array | URL,
options?: import("node:fs").StatFsOptions,
): Promise;
- export function realpath(path: string): Promise;
- export function lstat(path: string): Promise;
- export function unlink(path: string): Promise;
- export function chmod(path: string, mode: number): Promise;
- export function chown(path: string, uid: number, gid: number): Promise;
- export function lchown(path: string, uid: number, gid: number): Promise;
- export function access(path: string, mode?: number): Promise;
- export function truncate(path: string, len?: number): Promise;
- export function appendFile(path: string, data: string | Uint8Array): Promise;
- export function copyFile(src: string, dest: string): Promise;
- export function mkdtemp(prefix: string): Promise;
- export function rmdir(path: string): Promise;
- export function rename(oldPath: string, newPath: string): Promise;
+ export function realpath(path: string | URL): Promise;
+ export function lstat(path: string | URL): Promise;
+ export function unlink(path: string | URL): Promise;
+ export function chmod(path: string | URL, mode: number): Promise;
+ export function chown(path: string | URL, uid: number, gid: number): Promise;
+ export function lchown(path: string | URL, uid: number, gid: number): Promise;
+ export function access(path: string | URL, mode?: number): Promise;
+ export function truncate(path: string | URL, len?: number): Promise;
+ export function appendFile(path: string | URL, data: string | Uint8Array): Promise;
+ export function copyFile(src: string | URL, dest: string | URL): Promise;
+ export function mkdtemp(prefix: string | URL): Promise;
+ export function rmdir(path: string | URL): Promise;
+ export function rename(oldPath: string | URL, newPath: string | URL): Promise;
export function opendir(
- path: string,
+ path: string | URL,
options?: import("node:fs").OpenDirOptions,
): Promise;
}
@@ -2756,8 +2796,9 @@ declare var URLSearchParams: {
* slashes, non-empty host); pathToFileURL resolves the path and percent-
* encodes it into a file: URL. The legacy API: parse() answers a Url
* object, format() serializes a Url-shaped object, a string, or a URL,
- * resolve() resolves one URL string against another, and the domain pair
- * converts a hostname to and from its Punycode form. */
+ * resolve() resolves one URL string against another, the domain pair
+ * converts a hostname to and from its Punycode form, and urlToHttpOptions
+ * builds http.request options from a URL. */
declare module "url" {
export type URL = globalThis.URL;
export type URLSearchParams = globalThis.URLSearchParams;
@@ -2825,6 +2866,19 @@ declare module "url" {
export function resolve(from: string, to: string): string;
export function domainToASCII(domain: string): string;
export function domainToUnicode(domain: string): string;
+ /** urlToHttpOptions's null-prototype request options. */
+ export interface UrlHttpOptions {
+ protocol: string;
+ hostname: string;
+ hash: string;
+ search: string;
+ pathname: string;
+ path: string;
+ href: string;
+ port?: number;
+ auth?: string;
+ }
+ export function urlToHttpOptions(url: URL): UrlHttpOptions;
}
declare module "node:url" {
export * from "url";
@@ -2964,6 +3018,24 @@ declare module "crypto" {
options: ED25519KeyPairOptions<"der", "der">,
): KeyPairSyncResult;
export function generateKeyPairSync(type: "ed25519", options?: {}): KeyPairKeyObjectResult;
+ export interface X25519KeyPairOptions {
+ publicKeyEncoding: { type: "spki"; format: PubF };
+ privateKeyEncoding: { type: "pkcs8"; format: PrivF };
+ }
+ export function generateKeyPairSync(
+ type: "x25519",
+ options: X25519KeyPairOptions<"pem", "pem">,
+ ): KeyPairSyncResult;
+ export function generateKeyPairSync(
+ type: "x25519",
+ options: X25519KeyPairOptions<"der", "der">,
+ ): KeyPairSyncResult;
+ export function generateKeyPairSync(type: "x25519", options?: {}): KeyPairKeyObjectResult;
+ export function generateKeyPair(
+ type: "x25519",
+ options: {} | undefined,
+ callback: (err: Error | null, publicKey: KeyObject, privateKey: KeyObject) => void,
+ ): void;
export function generateKeyPair(
type: "ed25519",
options: {} | undefined,
@@ -2992,6 +3064,11 @@ declare module "crypto" {
export interface VerifyKeyObjectInput extends SigningOptions {
key: KeyObject;
}
+ export function diffieHellman(options: { privateKey: KeyObject; publicKey: KeyObject }): Buffer;
+ export function diffieHellman(
+ options: { privateKey: KeyObject; publicKey: KeyObject },
+ callback: (err: Error | null, secret: Buffer) => void,
+ ): void;
export function sign(
algorithm: string | null | undefined,
data: ArrayBufferView,
@@ -4795,9 +4872,9 @@ declare module "node:readline/promises" {
* inputs with Node's default options; libz links only into zlib-using
* binaries. Callback forms use the executable worker pool. Options stay
* declared so reached uses receive a named SC2020 refusal. The Deflate,
- * Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw and Unzip streams are
- * runtime Transform streams; Brotli and Zstd remain outside the lowered
- * surface. */
+ * Inflate, Gzip, Gunzip, DeflateRaw, InflateRaw, Unzip, BrotliCompress
+ * and BrotliDecompress streams are runtime Transform streams; Zstd remains
+ * outside the lowered surface. */
declare module "zlib" {
import { Transform } from "stream";
export interface ZlibOptions {
@@ -4854,50 +4931,66 @@ declare module "zlib" {
): void;
export function unzipSync(data: string | Uint8Array, options?: ZlibOptions): Buffer;
export function crc32(data: string | Uint8Array, value?: number): number;
- export function brotliCompressSync(data: string | Uint8Array): Buffer;
- export function brotliDecompressSync(data: Uint8Array): Buffer;
- /* Brotli needs a codec the static runtime does not vendor: these are
- * declared so their uses receive the named refusal. */
+ /* Brotli over the vendored brotli 1.2.0 (scr_brotli.c): `params` keys
+ * are the numeric BROTLI_PARAM_* and BROTLI_DECODER_PARAM_* constants. */
+ export interface BrotliOptions {
+ [option: string]: unknown;
+ }
export function brotliCompress(data: string | Uint8Array, callback: ZlibCallback): void;
- export function brotliDecompress(data: Uint8Array, callback: ZlibCallback): void;
- export function createBrotliCompress(options?: ZlibOptions): Transform;
- export function createBrotliDecompress(options?: ZlibOptions): Transform;
- export class BrotliCompress extends Transform {
- constructor(options?: ZlibOptions);
- }
- export class BrotliDecompress extends Transform {
- constructor(options?: ZlibOptions);
- }
+ export function brotliCompress(
+ data: string | Uint8Array,
+ options: BrotliOptions,
+ callback: ZlibCallback,
+ ): void;
+ export function brotliCompressSync(data: string | Uint8Array, options?: BrotliOptions): Buffer;
+ export function brotliDecompress(data: string | Uint8Array, callback: ZlibCallback): void;
+ export function brotliDecompress(
+ data: string | Uint8Array,
+ options: BrotliOptions,
+ callback: ZlibCallback,
+ ): void;
+ export function brotliDecompressSync(data: string | Uint8Array, options?: BrotliOptions): Buffer;
/* The stream classes: runtime Transform streams over the zlib engine
* (scr_zlib_stream.c). createGzip(options) and new Gzip(options) build
- * the same stream; options are validated at runtime with Node's rules. */
+ * the same stream; options are validated at runtime with Node's rules.
+ * Brotli streams have no params(), as in Node. */
class ZlibBase extends Transform {
readonly bytesWritten: number;
flush(kind?: number, callback?: () => void): void;
flush(callback: () => void): void;
- params(level: number, strategy: number, callback?: () => void): void;
close(callback?: () => void): void;
reset(): void;
}
- export class Deflate extends ZlibBase {
+ class Zlib extends ZlibBase {
+ params(level: number, strategy: number, callback?: () => void): void;
+ }
+ export class BrotliCompress extends ZlibBase {
+ constructor(options?: BrotliOptions);
+ }
+ export class BrotliDecompress extends ZlibBase {
+ constructor(options?: BrotliOptions);
+ }
+ export function createBrotliCompress(options?: BrotliOptions): BrotliCompress;
+ export function createBrotliDecompress(options?: BrotliOptions): BrotliDecompress;
+ export class Deflate extends Zlib {
constructor(options?: ZlibOptions);
}
- export class Inflate extends ZlibBase {
+ export class Inflate extends Zlib {
constructor(options?: ZlibOptions);
}
- export class Gzip extends ZlibBase {
+ export class Gzip extends Zlib {
constructor(options?: ZlibOptions);
}
- export class Gunzip extends ZlibBase {
+ export class Gunzip extends Zlib {
constructor(options?: ZlibOptions);
}
- export class DeflateRaw extends ZlibBase {
+ export class DeflateRaw extends Zlib {
constructor(options?: ZlibOptions);
}
- export class InflateRaw extends ZlibBase {
+ export class InflateRaw extends Zlib {
constructor(options?: ZlibOptions);
}
- export class Unzip extends ZlibBase {
+ export class Unzip extends Zlib {
constructor(options?: ZlibOptions);
}
export function createDeflate(options?: ZlibOptions): Deflate;
@@ -7207,9 +7300,18 @@ declare module "node:tty" {
declare module "async_hooks" {
/* AsyncLocalStorage: fiber-carried context snapshots — run/getStore/
* exit/enterWith/disable lower onto the runtime's active-slot machinery
- * (als.* libCalls); values cross as dyn values. */
+ * (als.* libCalls); values cross as dyn values. bind and snapshot bind
+ * functions to the active context; the options carry the store's
+ * default value and name. */
+ export interface AsyncLocalStorageOptions {
+ defaultValue?: any;
+ name?: string | undefined;
+ }
export class AsyncLocalStorage {
- constructor();
+ constructor(options?: AsyncLocalStorageOptions);
+ static bind any>(fn: Func): Func;
+ static snapshot(): (fn: (...args: TArgs) => R, ...args: TArgs) => R;
+ readonly name: string;
run(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
exit(callback: (...args: any[]) => R, ...args: any[]): R;
getStore(): T | undefined;
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index a358ae545..570c0a1a5 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/compiler",
- "version": "0.4.2",
+ "version": "0.4.4",
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
- "tsc-ts": "0.0.8",
+ "tsc-ts": "0.0.9",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts
index 32318d53e..2afb887ea 100644
--- a/packages/compiler/src/backend/executable-features.ts
+++ b/packages/compiler/src/backend/executable-features.ts
@@ -20,6 +20,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.warningReplace ? { warningReplace: true } : {}),
...(features.stdioExtended ? { stdioExtended: true } : {}),
zlib: features.zlib,
+ ...(features.brotli ? { brotli: true } : {}),
assert: features.assert,
inspect: features.inspect,
dynInvoke: features.dynInvoke,
@@ -50,6 +51,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
+ textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts
index e9f52f7a0..79be316db 100644
--- a/packages/compiler/src/backend/library-pack.ts
+++ b/packages/compiler/src/backend/library-pack.ts
@@ -54,6 +54,7 @@ export async function compilePackedLibrary(
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts
index aa6345dab..2efeb96ac 100644
--- a/packages/compiler/src/backend/llvm/alloc.ts
+++ b/packages/compiler/src/backend/llvm/alloc.ts
@@ -459,18 +459,72 @@ export type FreeObservers = "weak" | "weak-or-source";
* type's disposals), and the block — `scr_cyc_free` for collector-headered
* objects, `scr_rt_free` otherwise. Labels use the `sf.` prefix; the lines
* end in an open block.
+ *
+ * With `keep` (an alloca of ptr holding NULL; inline allocation paths only),
+ * a block that would go onto the calling thread's free list is stored there
+ * instead, for a teardown segment (emitSegmentFree); a block the system or
+ * another arena owns is still freed.
*/
export function emitObjectFree(
host: AllocHost,
cyc: boolean,
observers: FreeObservers = "weak",
+ keep: string | null = null,
): string[] {
- const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers));
+ const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers, keep));
if (host.objectAudit === false) return lines.filter((line) => line !== FREE_NOTE);
host.declare(`declare void @scr_obj_free_note()`);
return lines;
}
+/** Lines that put the blocks `first` to `last` (linked through their first
+ * words; one block when they are equal) of the class at reservation offset
+ * `off` on the calling thread's free list. */
+function freeListPushLines(prefix: string, off: string, first: string, last: string): string[] {
+ return [
+ ` %${prefix}.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
+ ` %${prefix}.sh32 = load i32, ptr %${prefix}.shp`,
+ ` %${prefix}.sh = zext i32 %${prefix}.sh32 to i64`,
+ ` %${prefix}.c = lshr i64 ${off}, %${prefix}.sh`,
+ ` %${prefix}.c8 = shl i64 %${prefix}.c, 3`,
+ ` %${prefix}.fo = add i64 %${prefix}.c8, ${SA_FREE}`,
+ ` %${prefix}.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %${prefix}.fo`,
+ ` %${prefix}.head = load ptr, ptr %${prefix}.fp`,
+ ` store ptr %${prefix}.head, ptr ${last}`,
+ ` store ptr ${first}, ptr %${prefix}.fp`,
+ ];
+}
+
+/**
+ * Lines that free a teardown segment: the blocks `first` to `last` (ptr
+ * values, both NULL for an empty segment), linked through their first words,
+ * which emitObjectFree kept. Every block of a segment is in the calling
+ * thread's arena and of one size class, so the segment goes onto that
+ * class's free list in one step, and the next allocations of the class take
+ * its blocks in segment order. Labels use `prefix`; the lines end in the
+ * open block `${prefix}.done`.
+ */
+export function emitSegmentFree(
+ host: AllocHost,
+ prefix: string,
+ first: string,
+ last: string,
+): string[] {
+ declareState(host);
+ const lines = [
+ ` %${prefix}.none = icmp eq ptr ${first}, null`,
+ ` br i1 %${prefix}.none, label %${prefix}.done, label %${prefix}.push`,
+ `${prefix}.push:`,
+ ` %${prefix}.base = load i64, ptr @scr_sa`,
+ ` %${prefix}.bi = ptrtoint ptr ${first} to i64`,
+ ` %${prefix}.off = sub i64 %${prefix}.bi, %${prefix}.base`,
+ ...freeListPushLines(prefix, `%${prefix}.off`, first, last),
+ ` br label %${prefix}.done`,
+ `${prefix}.done:`,
+ ];
+ return threadLocalState(host, prefix, lines);
+}
+
/** Lines that leave in `%.obs` whether the program can observe a
* disposal of this type (folds to a constant). */
function observedLines(prefix: string, observers: FreeObservers): string[] {
@@ -482,8 +536,14 @@ function observedLines(prefix: string, observers: FreeObservers): string[] {
];
}
-function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): string[] {
+function freeLines(
+ host: AllocHost,
+ cyc: boolean,
+ observers: FreeObservers,
+ keep: string | null,
+): string[] {
if (host.inlineAlloc !== true) {
+ if (keep !== null) throw new Error("a kept block needs the inline allocation paths");
if (cyc) {
host.declare(`declare void @scr_cyc_free(ptr)`);
return [` call void @scr_obj_free_note()`, ` call void @scr_cyc_free(ptr %o)`];
@@ -548,16 +608,9 @@ function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): str
` %sf.inx = call i1 @llvm.expect.i1(i1 %sf.in, i1 true)`,
` br i1 %sf.inx, label %sf.push, label %sf.sys`,
`sf.push:`,
- ` %sf.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
- ` %sf.sh32 = load i32, ptr %sf.shp`,
- ` %sf.sh = zext i32 %sf.sh32 to i64`,
- ` %sf.c = lshr i64 %sf.off, %sf.sh`,
- ` %sf.c8 = shl i64 %sf.c, 3`,
- ` %sf.fo = add i64 %sf.c8, ${SA_FREE}`,
- ` %sf.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %sf.fo`,
- ` %sf.head = load ptr, ptr %sf.fp`,
- ` store ptr %sf.head, ptr ${blk}`,
- ` store ptr ${blk}, ptr %sf.fp`,
+ ...(keep !== null
+ ? [` store ptr ${blk}, ptr ${keep}`]
+ : freeListPushLines("sf", "%sf.off", blk, blk)),
` br label %sf.done`,
`sf.sys: ; outside the reservation, or the allocator is compiled out`,
` call void @scr_obj_free_note()`,
diff --git a/packages/compiler/src/backend/llvm/bytes-alias.ts b/packages/compiler/src/backend/llvm/bytes-alias.ts
index 9aaeaa6c7..d43620fd9 100644
--- a/packages/compiler/src/backend/llvm/bytes-alias.ts
+++ b/packages/compiler/src/backend/llvm/bytes-alias.ts
@@ -7,9 +7,15 @@ import type { LlvmEmitterContext } from "./expr-context.js";
* buffer. Neither overlaps the other, a native field, array storage or a
* module global's slot, so element stores leave header loads and global
* receiver slots loop-invariant. Untagged accesses and calls still alias
- * every category. */
+ * every category.
+ *
+ * Header loads are also `!invariant.load`: no store or call between the
+ * construction of a view and its release changes them. Without it, a data
+ * pointer load ahead of a bounds branch is sunk into the in-bounds block,
+ * and LICM keeps a conditionally executed load inside the loop. */
export function bytesHeaderAlias(host: LlvmEmitterContext): string {
- return host.memoryAliasAttachment("bytes:header");
+ const tag = host.memoryAliasAttachment("bytes:header");
+ return tag === "" ? "" : `${tag}, !invariant.load !{}`;
}
export function bytesElementAlias(host: LlvmEmitterContext): string {
diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
index cee91c66d..aef39ef75 100644
--- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
+++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
@@ -11,7 +11,7 @@ import {
type IrType,
} from "../../ir/ir.js";
import { analyzeIntegerRanges } from "../../ir/integer-ranges.js";
-import { matchIntegerCountedForLoop } from "../../ir/integer-loops.js";
+import { matchIntegerCountedForLoop, matchIntegerPairedForLoop } from "../../ir/integer-loops.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion, type BytesLoopVersion } from "./bytes-loop-versions.js";
import { emitLlvmModule } from "./emitter.js";
@@ -249,6 +249,11 @@ test("emitted loops version the body and tag typed-array memory", () => {
const elements = /store double [^\n]*!tbaa !(\d+)/.exec(body)![1];
const header = /load ptr, ptr [^\n]*!tbaa !(\d+)/.exec(body)![1];
expect(elements).not.toBe(header);
+ // Header loads are invariant for the view's lifetime; element accesses
+ // are not.
+ expect(body).toMatch(/load ptr, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
+ expect(body).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
+ expect(body).not.toMatch(/(load double|store double)[^\n]*invariant\.load/);
});
test("loops to a typed array's length version the other typed arrays they index", () => {
@@ -380,7 +385,10 @@ test("while loop counters below a bound version their accesses", () => {
]);
const declared = (loop: IrStmt): IrFunction =>
fn([{ kind: "varDecl", localId: "i", init: ref("k"), loc }, loop]);
- const versionOf = (loop: IrStmt & { kind: "while" }): BytesLoopVersion | null => {
+ const versionOf = (
+ loop: IrStmt & { kind: "while" },
+ workers = false,
+ ): BytesLoopVersion | null => {
const f = declared(loop);
const ranges = analyzeIntegerRanges(f);
return matchBytesLoopVersion(loop, null, {
@@ -391,7 +399,7 @@ test("while loop counters below a bound version their accesses", () => {
initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(),
proven: new Set(),
scalarStrings: new Set(),
- workers: false,
+ workers,
owned: new Set(),
});
};
@@ -408,6 +416,11 @@ test("while loop counters below a bound version their accesses", () => {
// An access after the counter advances in the same pass stays checked.
const advanced = whileLoop([step, set(ref("out", f64s), ref("i"), num(0))]);
expect(versionOf(advanced)).toBeNull();
+ // In a worker program, a while loop versions for its counter's accesses,
+ // and not for thread-owned receivers alone.
+ expect(versionOf(copy, true)!.ownedReceivers).toHaveLength(2);
+ const job = whileLoop([set(ref("out", f64s), ref("k"), get(ref("src", f64s), ref("k"))), step]);
+ expect(versionOf(job, true)).toBeNull();
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
@@ -618,3 +631,78 @@ test("while loop counters below a string's length version its reads", () => {
expect(version.bounded.map((c) => c.slack)).toEqual([0]);
expect(version.fixed).toEqual([]);
});
+
+test("a two-counter loop versions the typed arrays it indexes at either counter", () => {
+ // for (let i = 0, j = k; i < j; i++, j--) { const t = out[i]; out[i] = out[j]; out[j] = t; }
+ const loop: IrStmt & { kind: "for" } = {
+ kind: "for",
+ init: {
+ kind: "block",
+ body: [
+ { kind: "varDecl", localId: "i", init: num(0), loc },
+ { kind: "varDecl", localId: "j", init: ref("k"), loc },
+ ],
+ loc,
+ },
+ cond: { kind: "bin", op: "<", left: ref("i"), right: ref("j"), type: BOOL, loc },
+ update: {
+ kind: "block",
+ body: [
+ { kind: "assign", localId: "i", value: bin("+", ref("i"), num(1)), loc },
+ { kind: "assign", localId: "j", value: bin("-", ref("j"), num(1)), loc },
+ ],
+ loc,
+ },
+ body: [
+ { kind: "varDecl", localId: "t", init: get(ref("out", f64s), ref("i")), loc },
+ set(ref("out", f64s), ref("i"), get(ref("out", f64s), ref("j"))),
+ set(ref("out", f64s), ref("j"), ref("t")),
+ // Another index stays checked.
+ set(ref("src", f64s), bin("+", ref("i"), num(1)), num(0)),
+ ],
+ loc,
+ };
+ const kernel = fn([loop], [local("t", F64)]);
+ const ranges = analyzeIntegerRanges(kernel);
+ const locals = new Map(kernel.locals.map((l) => [l.id, l]));
+ const paired = matchIntegerPairedForLoop(loop, locals, ranges)!;
+ expect(paired).not.toBeNull();
+ const version = matchBytesLoopVersion(
+ loop,
+ null,
+ {
+ locals,
+ invariantGlobals: new Set(),
+ captured: new Set(),
+ ranges,
+ initialized: findInitializedByteLoopBindings(kernel).get(loop) ?? new Set(),
+ proven: new Set(),
+ workers: false,
+ owned: new Set(),
+ scalarStrings: new Set(),
+ },
+ paired,
+ )!;
+ expect(version.paired?.loop).toBe(paired);
+ expect(version.paired?.receivers.map((r) => version.receivers[r]!.ref)).toEqual([
+ ref("out", f64s),
+ ]);
+ expect(version.bounds.size).toBe(4);
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "__main",
+ functions: [
+ { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
+ kernel,
+ ],
+ };
+ const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
+ const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m));
+ expect(guard).toMatch(/icmp sge i64 %\S+, 0/);
+ expect(guard).toMatch(/icmp slt i64 %\S+, %\S+/);
+ const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0];
+ // The fast copy keeps only the `src[i + 1]` check, and no second guard.
+ expect(fast.match(/bytes\.index\.invalid\d+:/g)).toHaveLength(1);
+ expect(fast).not.toContain("counted.fast");
+});
diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
index 704b6723a..1b51deee1 100644
--- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
+++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
@@ -1,11 +1,15 @@
import { F64, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import type { IntegerRange, IntegerRanges } from "../../ir/integer-ranges.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { exactInteger, widenInteger } from "./integer-values.js";
-import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js";
+import {
+ emitCountedLoopLimit,
+ emitCountedLoopSafety,
+ emitExactIntegerTest,
+} from "./counted-loops.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import { f64Lit } from "./common.js";
import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js";
@@ -79,6 +83,10 @@ export interface BytesLoopVersion {
guarded: ReadonlySet;
/** The counted loop whose iteration space the affine checks cover. */
counted: IntegerCountedForLoop | null;
+ /** A two-counter loop (`for (let i = lo, j = hi; i < j; i++, j--)`) and
+ * the receivers it indexes at either counter. Both counters stay between
+ * the two starts, so the guard requires `lo >= 0` and `hi < length`. */
+ paired: { loop: IntegerPairedForLoop; receivers: number[] } | null;
/** The guard also requires every counter value within [-bound, bound]. */
counterBound: number | null;
/** Integer ranges of affine index operands that hold in the unchecked
@@ -153,6 +161,7 @@ export function matchBytesLoopVersion(
loop: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
facts: BytesLoopFacts,
+ paired: IntegerPairedForLoop | null = null,
): BytesLoopVersion | null {
const written = new Set();
const temps = new Map();
@@ -438,6 +447,9 @@ export function matchBytesLoopVersion(
string,
{ check: BoundedCheck; nodes: { node: Node; access: WhileAccess }[] }
>();
+ const pairedCounters =
+ paired !== null && counted === null ? new Set([paired.low.localId, paired.high.localId]) : null;
+ const pairedNodes = new Map();
const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => {
const ref = root(array);
if (!ref) return;
@@ -448,6 +460,20 @@ export function matchBytesLoopVersion(
else owned.set(receiver, [node]);
}
if (facts.proven.has(node)) return;
+ // An element at either counter of a two-counter loop.
+ if (
+ pairedCounters !== null &&
+ width === 1 &&
+ ref.type.kind === "bytes" &&
+ index.kind === "varRef" &&
+ pairedCounters.has(index.localId)
+ ) {
+ const receiver = receiverOf(ref);
+ const nodes = pairedNodes.get(receiver);
+ if (nodes) nodes.push(node);
+ else pairedNodes.set(receiver, [node]);
+ return;
+ }
const shape = counter !== null ? affine(index) : null;
if (shape && shape.counter !== 0) {
const operands = new Set();
@@ -543,13 +569,23 @@ export function matchBytesLoopVersion(
Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length),
);
const ownedKept = [...owned.entries()].slice(0, MAX_CHECKS);
+ const pairedKept = [...pairedNodes.entries()].slice(
+ 0,
+ Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length - boundedKept.length),
+ );
if (
fixedKept.length === 0 &&
affineKept.length === 0 &&
boundedKept.length === 0 &&
- ownedKept.length === 0
+ ownedKept.length === 0 &&
+ pairedKept.length === 0
)
return null;
+ // A while loop versions only for accesses at its counters. Its other
+ // checks (thread-owned receivers, static indices) save a test or two per
+ // pass, while a second copy of its body doubles the calls it makes, which
+ // can keep a worker's job loop from inlining the function that runs a job.
+ if (loop.kind === "while" && boundedKept.length === 0) return null;
const bounds = new Set();
const ownedNodes = new Set();
const used = new Set();
@@ -616,6 +652,10 @@ export function matchBytesLoopVersion(
if (access.index !== access.ref) narrow(access.index, { min: 0, max: SAFE });
}
}
+ for (const [receiver, nodes] of pairedKept) {
+ used.add(receiver);
+ for (const node of nodes) bounds.add(node);
+ }
const counterChecks: CounterCheck[] = [];
for (const [id, min] of counterMins) {
const info = whileAccesses!.counters.get(id)!;
@@ -658,6 +698,10 @@ export function matchBytesLoopVersion(
counters: counterChecks,
guarded,
counted: affineKept.length > 0 ? counted : null,
+ paired:
+ paired !== null && pairedKept.length > 0
+ ? { loop: paired, receivers: pairedKept.map(([receiver]) => renumber.get(receiver)!) }
+ : null,
counterBound: needsBound ? bound : null,
ranges,
};
@@ -1143,6 +1187,25 @@ export function emitBytesLoopVersionGuard(
conditions.push(both);
}
}
+ if (version.paired !== null) {
+ // Both counters stay within [lo, hi] (exact integers below 2^53): every
+ // access at either one is in bounds when `lo >= 0` and `hi < length`.
+ const { loop } = version.paired;
+ const [low, high] = [loop.low.start, loop.high.start].map((expr) => {
+ const tested = emitExactIntegerTest(host, host.emitExpr(expr).name, -SAFE, SAFE);
+ conditions.push(tested.ok);
+ return tested.integer;
+ });
+ const nonNegative = B.tmp();
+ B.line(`${nonNegative} = icmp sge i64 ${low}, 0`);
+ conditions.push(nonNegative);
+ for (const receiver of version.paired.receivers) {
+ // Lengths are at most 2^53 elements: a signed comparison is exact.
+ const below = B.tmp();
+ B.line(`${below} = icmp slt i64 ${high}, ${lengths[receiver]}`);
+ conditions.push(below);
+ }
+ }
let valid = "true";
for (const cond of conditions) {
if (valid === "true") valid = cond;
@@ -1156,39 +1219,6 @@ export function emitBytesLoopVersionGuard(
else B.condBr(valid, fast, slow);
}
-/** Tests that a double is an exact integer within [min, max] (not -0; NaN
- * fails the ordered comparisons) and gives its i64. The integer is frozen,
- * so it is a fixed value even when the test fails. */
-function emitExactIntegerTest(
- host: LlvmEmitterContext,
- value: string,
- min: number,
- max: number,
-): { ok: string; integer: string } {
- const B = host.B;
- const low = B.tmp(),
- high = B.tmp(),
- inRange = B.tmp(),
- raw = B.tmp(),
- integer = B.tmp(),
- back = B.tmp(),
- bits = B.tmp(),
- backBits = B.tmp(),
- exact = B.tmp(),
- ok = B.tmp();
- B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
- B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
- B.line(`${inRange} = and i1 ${low}, ${high}`);
- B.line(`${raw} = fptosi double ${value} to i64`);
- B.line(`${integer} = freeze i64 ${raw}`);
- B.line(`${back} = sitofp i64 ${integer} to double`);
- B.line(`${bits} = bitcast double ${value} to i64`);
- B.line(`${backBits} = bitcast double ${back} to i64`);
- B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
- B.line(`${ok} = and i1 ${inRange}, ${exact}`);
- return { ok, integer };
-}
-
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
if (scale === 1) return value;
const scaled = host.B.tmp();
diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts
index 90909a024..5c40bb4ce 100644
--- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts
+++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts
@@ -760,3 +760,154 @@ test("a parameter stored as its last use takes the owned convention", () => {
const passed = ctor([{ kind: "exprStmt", expr: call("use", [ref("item", cell)]), loc }]);
expect(analyze(passed).borrowed.get("%Box.constructor")).toEqual(new Set([0, 1]));
});
+
+test("a wrap into a pointer union forwards the wrapped binding to the callee's parameter", () => {
+ const wrap = (id: string): IrExpr => ({
+ kind: "unionWrap",
+ unionId: "optional",
+ tag: 0,
+ value: ref(id, record),
+ type: optional,
+ loc,
+ });
+ const leaf = helper("leaf");
+ const outer = helper("outer");
+ outer.params[0]!.type = outer.locals[0]!.type = record;
+ outer.locals.push(local("cell", record));
+ outer.body = [
+ {
+ kind: "varDecl",
+ localId: "cell",
+ init: { kind: "recordLit", fields: [], type: record, loc },
+ loc,
+ },
+ { kind: "exprStmt", expr: call("leaf", [wrap("value")]), loc },
+ ret(call("leaf", [wrap("cell")])),
+ ];
+ const functions = new Map([leaf, outer].map((fn) => [fn.name, fn]));
+ const analyzeWith = (pointer: boolean) =>
+ analyzeCallLifetimes(
+ functions,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ () => pointer,
+ );
+ // A boxed union builds a new box around the payload: a whole-value use.
+ expect(analyzeWith(false).parameters.get("outer")).toBeUndefined();
+ expect(analyzeWith(false).locals.get("outer")).toEqual(new Set());
+ // A pointer union passes the payload pointer itself.
+ expect(analyzeWith(true).parameters.get("outer")).toEqual(new Set([0]));
+ expect(analyzeWith(true).locals.get("outer")).toEqual(new Set(["cell"]));
+ // A callee that keeps its parameter keeps the wrapped binding too.
+ leaf.body.push(ret(ref("value")));
+ expect(analyzeWith(true).parameters.get("outer")).toBeUndefined();
+ expect(analyzeWith(true).locals.get("outer")).toEqual(new Set());
+});
+
+test("a pointer-wrapped binding needs a callee that keeps the narrowed payload in place", () => {
+ const wrapCell: IrExpr = {
+ kind: "unionWrap",
+ unionId: "optional",
+ tag: 0,
+ value: ref("cell", record),
+ type: optional,
+ loc,
+ };
+ const outer = helper("outer", []);
+ outer.locals.push(local("cell", record));
+ outer.body = [
+ {
+ kind: "varDecl",
+ localId: "cell",
+ init: { kind: "recordLit", fields: [], type: record, loc },
+ loc,
+ },
+ ret(call("leaf", [wrapCell])),
+ ];
+ const read = (value: IrExpr): IrExpr => ({
+ kind: "recordGet",
+ obj: value,
+ shapeId: "cell",
+ field: "x",
+ type: F64,
+ loc,
+ });
+ const keeps = (leaf: IrFunction, ...more: IrFunction[]): boolean => {
+ const result = analyzeCallLifetimes(
+ new Map([outer, leaf, ...more].map((fn) => [fn.name, fn])),
+ (className) => className === "Node",
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ () => true,
+ );
+ return result.locals.get("outer")?.has("cell") !== true;
+ };
+ const leafWith = (body: IrStmt[]): IrFunction => ({ ...helper("leaf"), body });
+ // Tag tests and field reads of the narrowed payload leave it in place.
+ expect(keeps(leafWith([{ kind: "exprStmt", expr: tag("value"), loc }]))).toBe(false);
+ expect(keeps(leafWith([ret(read(narrow("value")))]))).toBe(false);
+ // So do field reads through a checked narrowing, which throws on the
+ // other arm (`value.x` of a `T | undefined` parameter).
+ const checked: IrExpr = {
+ kind: "ternary",
+ cond: tag("value"),
+ then: { kind: "libCall", fn: "error.nodeThrow", args: [], type: record, loc },
+ else_: narrow("value"),
+ type: record,
+ loc,
+ };
+ expect(keeps(leafWith([ret(read(checked))]))).toBe(false);
+ // Returning it keeps the payload.
+ const returnsChecked = leafWith([ret(checked)]);
+ returnsChecked.returnType = record;
+ expect(keeps(returnsChecked)).toBe(true);
+ // Returning the narrowed payload, or a narrowed nullish read, keeps it,
+ // although the union itself is only projected.
+ const returned = leafWith([ret(narrow("value"))]);
+ returned.returnType = record;
+ expect(analyze(returned).parameters.get("leaf")).toEqual(new Set([0]));
+ expect(keeps(returned)).toBe(true);
+ const defaulted = leafWith([
+ ret({ kind: "nullish", left: ref("value"), right: ref("other", record), type: record, loc }),
+ ]);
+ defaulted.locals.push(local("other", record));
+ expect(keeps(defaulted)).toBe(true);
+ // So does a nullable-pointer field store of the union.
+ const self: IrType = { kind: "object", className: "Node" };
+ const stored = leafWith([
+ {
+ kind: "fieldSet",
+ obj: ref("this", self),
+ className: "Node",
+ field: "next",
+ value: ref("value"),
+ loc,
+ },
+ ]);
+ stored.params = [{ localId: "value", name: "value", type: optional }];
+ stored.locals.push(local("this", self));
+ expect(keeps(stored)).toBe(true);
+ // A narrowed payload passed on, or the union passed on, is in place when
+ // the next callee keeps it in place.
+ const inner = (body: IrStmt[], type: IrType = record): IrFunction => {
+ const fn = helper("inner");
+ fn.params[0]!.type = fn.locals[0]!.type = type;
+ fn.body = body;
+ return fn;
+ };
+ const passNarrowed = leafWith([ret(call("inner", [narrow("value")]))]);
+ expect(keeps(passNarrowed, inner([ret(read(ref("value", record)))]))).toBe(false);
+ const returnsRecord = inner([ret(ref("value", record))]);
+ returnsRecord.returnType = record;
+ expect(keeps(passNarrowed, returnsRecord)).toBe(true);
+ const passUnion = leafWith([ret(call("inner", [ref("value")]))]);
+ expect(keeps(passUnion, inner([ret(read(narrow("value")))], optional))).toBe(false);
+ const innerReturned = inner([ret(narrow("value"))], optional);
+ innerReturned.returnType = record;
+ expect(keeps(passUnion, innerReturned)).toBe(true);
+});
diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts
index db5bdb574..becd24805 100644
--- a/packages/compiler/src/backend/llvm/call-lifetimes.ts
+++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts
@@ -14,6 +14,11 @@ import { storedLastUseRead } from "./local-extents.js";
interface ForwardedUse {
callee: string;
index: number;
+ /** The binding reached the call inside a nullable-pointer union
+ * (PointerWrapTest): the callee's parameter holds the binding itself as
+ * its payload, so the callee must keep the payload, not only the union,
+ * in place. */
+ wrapped: boolean;
}
interface Uses {
@@ -21,6 +26,13 @@ interface Uses {
written: Set;
declarations: Map;
forwards: Map;
+ /** Union bindings whose payload a use may keep: a narrowed read that is
+ * not projected or passed on, a narrowed nullish read, a store into a
+ * nullable-pointer field. The union itself can still be only projected. */
+ payloadKept: Set;
+ /** Narrowed payloads of union bindings passed directly as call arguments:
+ * the payload stays in place if the callee's parameter is proved. */
+ payloadForwards: Map;
}
interface Parameter {
@@ -99,19 +111,55 @@ const NO_INLINE_FIELDS: InlineFieldTest = { record: () => false, classField: ()
* reads the record and keeps no reference to it. */
export type RecordDynCopyTest = (shapeId: string) => boolean;
+/** Wraps into a nullable-pointer union whose value is the payload pointer
+ * itself (LlEmitter.borrowableNullableWrap): the wrapped binding reaches the
+ * callee's parameter unchanged. */
+export type PointerWrapTest = (wrap: IrExpr & { kind: "unionWrap" }) => boolean;
+
function collectUses(
fn: IrFunction,
nullableField: NullableFieldTest,
registerReturn: boolean,
inlineField: InlineFieldTest,
recordDynCopy: RecordDynCopyTest,
+ pointerWrap: PointerWrapTest,
): Uses {
const uses: Uses = {
invalid: new Set(),
written: new Set(),
declarations: new Map(),
forwards: new Map(),
+ payloadKept: new Set(),
+ payloadForwards: new Map(),
};
+ const forward = (
+ table: Map,
+ localId: string,
+ use: ForwardedUse,
+ ): void => {
+ let forwards = table.get(localId);
+ if (!forwards) table.set(localId, (forwards = []));
+ forwards.push(use);
+ };
+ /** The union binding `x` whose payload `value` is: `unionNarrow(x)`, or a
+ * checked narrowing `unionIsTag(x) ? : unionNarrow(x)` (either
+ * order), whose other branch is visited as any use. */
+ function narrowedBinding(value: IrExpr): string | null {
+ if (value.kind === "unionNarrow")
+ return value.value.kind === "varRef" ? value.value.localId : null;
+ if (
+ value.kind !== "ternary" ||
+ value.cond.kind !== "unionIsTag" ||
+ value.cond.value.kind !== "varRef"
+ )
+ return null;
+ const id = value.cond.value.localId;
+ if (value.else_.kind === "libCall" && narrowedBinding(value.then) === id)
+ return expr(value.else_) ? id : null;
+ if (value.then.kind === "libCall" && narrowedBinding(value.else_) === id)
+ return expr(value.then) ? id : null;
+ return null;
+ }
const aliases: { alias: string; source: string }[] = [];
const stringInput = (value: IrExpr): boolean => {
if (value.kind === "varRef" && value.type.kind === "string") return true;
@@ -145,6 +193,12 @@ function collectUses(
if (borrowsMapReadInputs(node)) return expr(node.receiver) && node.args.every(stringInput);
break;
case "unionNarrow":
+ // The payload may escape; the union itself does not.
+ if (node.value.kind === "varRef") {
+ uses.payloadKept.add(node.value.localId);
+ return true;
+ }
+ break;
case "unionIsTag":
if (node.value.kind === "varRef") return true;
break;
@@ -161,12 +215,16 @@ function collectUses(
node.left.kind === "varRef" &&
node.left.type.kind === "union" &&
node.type.kind !== "union"
- )
+ ) {
+ uses.payloadKept.add(node.left.localId);
return expr(node.right);
+ }
break;
case "fieldGet":
case "recordGet":
- if (node.obj.kind === "varRef") return true;
+ // A field read of a binding, or of a union binding's narrowed
+ // payload, keeps neither.
+ if (node.obj.kind === "varRef" || narrowedBinding(node.obj) !== null) return true;
break;
case "dynFrom":
// A copied record converts field by field; a live capsule keeps it.
@@ -200,13 +258,19 @@ function collectUses(
return node.args.every(expr);
case "call":
node.args.forEach((arg, index) => {
- if (arg.kind !== "varRef") {
+ const narrowed = narrowedBinding(arg);
+ if (narrowed !== null) {
+ forward(uses.payloadForwards, narrowed, { callee: node.callee, index, wrapped: false });
+ return;
+ }
+ const wrapped =
+ arg.kind === "unionWrap" && arg.value.kind === "varRef" && pointerWrap(arg);
+ const value = wrapped ? arg.value : arg;
+ if (value.kind !== "varRef") {
expr(arg);
return;
}
- let forwards = uses.forwards.get(arg.localId);
- if (!forwards) uses.forwards.set(arg.localId, (forwards = []));
- forwards.push({ callee: node.callee, index });
+ forward(uses.forwards, value.localId, { callee: node.callee, index, wrapped });
});
return true;
case "assignExpr":
@@ -250,8 +314,11 @@ function collectUses(
node.value.kind === "varRef" &&
node.value.type.kind === "union" &&
nullableField(node.className, node.field)
- )
+ ) {
+ // The field retains the payload.
+ uses.payloadKept.add(node.value.localId);
return expr(node.obj);
+ }
const stored = fieldStore(
node.obj,
node.value,
@@ -345,10 +412,14 @@ export function analyzeCallLifetimes(
registerReturns: ReadonlySet = new Set(),
inlineField: InlineFieldTest = NO_INLINE_FIELDS,
recordDynCopy: RecordDynCopyTest = () => false,
+ pointerWrap: PointerWrapTest = () => false,
): CallLifetimes {
const usesByFunction = new Map();
const lazyCaptures = new LazyCaptures(functions);
const nodes = new Map();
+ /** Union parameters that keep their payload in place too, which a
+ * pointer-wrapped argument needs (ForwardedUse.wrapped). */
+ const payloadNodes = new Map();
const unsafe: Parameter[] = [];
const consumed: { fn: string; index: number }[] = [];
const result: CallLifetimes = {
@@ -361,7 +432,7 @@ export function analyzeCallLifetimes(
};
for (const fn of functions.values()) {
if (fn.generator !== undefined && fn.classCaptures === undefined) {
- const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy);
+ const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy, pointerWrap);
const locals = new Map(fn.locals.map((local) => [local.id, local]));
const stable = new Set();
for (const param of fn.params) {
@@ -386,6 +457,7 @@ export function analyzeCallLifetimes(
registerReturns.has(fn.name),
inlineField,
recordDynCopy,
+ pointerWrap,
);
usesByFunction.set(fn.name, uses);
// Class-capturing bodies get only local facts (computed below from
@@ -460,23 +532,50 @@ export function analyzeCallLifetimes(
return node;
}),
);
+ payloadNodes.set(
+ fn.name,
+ fn.params.map((param) => {
+ if (param.type.kind !== "union") return undefined;
+ const node: Parameter = { safe: !uses.payloadKept.has(param.localId), callers: new Set() };
+ if (!node.safe) unsafe.push(node);
+ return node;
+ }),
+ );
}
+ /** The fact a forwarded use needs from the callee's parameter. */
+ const target = (use: ForwardedUse): Parameter | undefined =>
+ use.wrapped ? payloadNodes.get(use.callee)?.[use.index] : nodes.get(use.callee)?.[use.index];
for (const fn of functions.values()) {
const params = nodes.get(fn.name);
const uses = usesByFunction.get(fn.name);
if (!params || !uses) continue;
- fn.params.forEach((param, index) => {
- const node = params[index]!;
+ const depend = (node: Parameter, callees: (Parameter | undefined)[]): void => {
if (!node.safe) return;
- for (const use of uses.forwards.get(param.localId) ?? []) {
- const callee = nodes.get(use.callee)?.[use.index];
+ for (const callee of callees) {
if (!callee) {
node.safe = false;
unsafe.push(node);
- break;
+ return;
}
callee.callers.add(node);
}
+ };
+ fn.params.forEach((param, index) => {
+ const node = params[index]!;
+ const forwards = uses.forwards.get(param.localId) ?? [];
+ depend(node, forwards.map(target));
+ const payload = payloadNodes.get(fn.name)?.[index];
+ if (!payload) return;
+ // The payload stays in place when the union does, when every callee
+ // the union reaches keeps its payload too, and when every callee its
+ // narrowed payload reaches proves that parameter.
+ depend(payload, [
+ node,
+ ...forwards.map((use) => payloadNodes.get(use.callee)?.[use.index]),
+ ...(uses.payloadForwards.get(param.localId) ?? []).map(
+ (use) => nodes.get(use.callee)?.[use.index],
+ ),
+ ]);
});
}
for (let i = 0; i < unsafe.length; i++) {
@@ -508,7 +607,7 @@ export function analyzeCallLifetimes(
)
continue;
const forwards = uses.forwards.get(local.id) ?? [];
- if (forwards.every((use) => result.parameters.get(use.callee)?.has(use.index) === true)) {
+ if (forwards.every((use) => target(use)?.safe === true)) {
projected.add(local.id);
if (eligible(local) && !uses.written.has(local.id)) safe.add(local.id);
}
diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts
index bee954afb..f472eec60 100644
--- a/packages/compiler/src/backend/llvm/classes.test.ts
+++ b/packages/compiler/src/backend/llvm/classes.test.ts
@@ -77,6 +77,65 @@ test("leaf layouts keep direct teardown while recursive layouts share the depth
}
});
+test("teardowns of types with children of their own type free their blocks as one segment", () => {
+ const linked = { kind: "object" as const, className: "Linked" };
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "main",
+ functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
+ classes: [
+ {
+ name: "Linked",
+ fields: [
+ { name: "next", type: linked },
+ { name: "other", type: linked },
+ ],
+ methods: [],
+ loc,
+ },
+ ],
+ records: [{ id: "branch", fields: [{ name: "next", type: linked }] }],
+ };
+ const define = (llvm: string, name: string): string =>
+ new RegExp(`^define internal [^@]*@${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? "";
+ const linkedDestroy = `${mangleClassRelease("Linked")}_destroy`;
+ const branchDestroy = `${mangleRecordRelease("branch")}_destroy`;
+ for (const workers of [false, true]) {
+ const llvm = emitLlvmModule({ ...mod, workers }, { pointerBits: 64 });
+ // Linked's teardown keeps its block and its children's in a segment: it
+ // releases its own block first and places it by address against its
+ // first child, a child torn down directly appends to the segment, one
+ // past the direct chain appends through the runtime, and the chain's
+ // first teardown frees the segment onto the free list in one step.
+ const seg = define(llvm, `${linkedDestroy}_s`);
+ expect(seg.startsWith("define internal ptr"), "segment form").toBe(true);
+ expect(seg).toMatch(
+ /store ptr %(?:sf\.blk|o), ptr %sg\.own[^]*call ptr @sc_release_Linked_destroy_s\(/,
+ );
+ // The children's releases have a copy for each place of the own block.
+ for (const copy of ["ta", "tb"])
+ expect(seg).toContain(
+ `call ptr @scr_rc_destroy_seg(ptr %v0, ptr @${linkedDestroy}_sc, ptr @${linkedDestroy}, ptr %${copy}0.bt)`,
+ );
+ expect(seg).not.toContain("call void @scr_rc_destroy(");
+ expect(seg).toContain("icmp ult ptr %sg.o, %sg.r1");
+ const first = define(llvm, `${linkedDestroy}_d`);
+ expect(first).toContain(`call ptr @${linkedDestroy}_s(ptr %o, i32 %d, ptr %head)`);
+ expect(first).toContain("store ptr %first, ptr %sgf.fp");
+ expect(first.includes("llvm.threadlocal.address"), "thread-local state").toBe(workers);
+ expect(define(llvm, linkedDestroy)).toContain(`call void @${linkedDestroy}_d(ptr %o, i32 0)`);
+ // A record whose child is another type frees its own block as before.
+ expect(define(llvm, `${branchDestroy}_s`)).toBe("");
+ expect(define(llvm, branchDestroy)).toContain(`call void @${mangleClassRelease("Linked")}(`);
+ }
+ // Without the inline allocation paths (32-bit targets) a teardown frees
+ // through the runtime, block by block.
+ const llvm32 = emitLlvmModule(mod, { pointerBits: 32 });
+ expect(define(llvm32, `${linkedDestroy}_s`)).toBe("");
+ expect(define(llvm32, `${linkedDestroy}_d`)).toContain(`call void @${linkedDestroy}_d(`);
+});
+
test("releases split into an inlinable fast path and an out-of-line remainder", () => {
// An array of the class itself makes the hierarchy cycle-capable.
const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } };
diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts
index 8f179828d..699755157 100644
--- a/packages/compiler/src/backend/llvm/classes.ts
+++ b/packages/compiler/src/backend/llvm/classes.ts
@@ -43,6 +43,9 @@ import {
destroyDefinitions,
directChildReleaseLines,
directDestroyTarget,
+ segmentPrologueLines,
+ segmentTeardownLines,
+ segmentedTeardown,
usedDirectTargets,
type DirectDestroy,
type RegionHooks,
@@ -1082,25 +1085,62 @@ export function emitClassShapes(
bounded &&
(directTargets.has(`object:${cls.name}`) ||
refFields.some((f) => directDestroyTarget(host, f.type, directTargets) !== null));
+ // The teardown's own symbol, and whether it keeps a segment (shapes.ts
+ // segmentedTeardown).
+ const destroy = `${meta.hierarchy ? mangleClassReleaseDirect(cls.name) : mangleClassRelease(cls.name)}_destroy`;
+ const segmented =
+ chained &&
+ !meta.compact &&
+ runtimePrefix === null &&
+ !isStreamRooted &&
+ !meta.threadSlot &&
+ segmentedTeardown(
+ host,
+ destroy,
+ refFields.map((f) => f.type),
+ directTargets,
+ );
// The field-releasing teardown body shared by both release shapes
// (the public one on standalone classes, the DIRECT one on hierarchy
// members). Runs at rc == 0.
const teardown = (lines: string[]): void => {
- refFields.forEach((f, t) => {
- lines.push(
- ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
- ` %v${t} = load ptr, ptr %f${t}`,
- );
+ const release = (prefix: string, f: (typeof refFields)[number], t: number): string[] => {
const direct = bounded ? directDestroyTarget(host, f.type, directTargets) : null;
- if (direct)
- lines.push(
- ...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(f.name)),
- );
- else
- lines.push(
+ if (direct === null)
+ return [
` call void ${releaseSym(host, f.type)}(ptr %v${t}) ; ${llvmCommentText(f.name)}`,
- );
- });
+ ];
+ const own = segmented && direct.destroy === destroy;
+ return directChildReleaseLines(
+ host,
+ `${prefix}${t}`,
+ `%v${t}`,
+ direct,
+ llvmCommentText(f.name),
+ own,
+ );
+ };
+ const loads = refFields.map((f, t) => [
+ ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
+ ` %v${t} = load ptr, ptr %f${t}`,
+ ]);
+ if (segmented) {
+ // A segmented teardown loads its fields, releases its own block, and
+ // then releases the fields (shapes.ts segmentTeardownLines).
+ const ownType = refFields.flatMap((f, t) =>
+ directDestroyTarget(host, f.type, directTargets)?.destroy === destroy ? [`%v${t}`] : [],
+ );
+ lines.push(
+ ...segmentPrologueLines(),
+ ...loads.flat(),
+ ...emitObjectFree(host, traced, "weak", "%sg.own"),
+ ...segmentTeardownLines(ownType, (prefix) =>
+ refFields.flatMap((f, t) => release(prefix, f, t)),
+ ),
+ );
+ return;
+ }
+ refFields.forEach((f, t) => lines.push(...loads[t]!, ...release("td", f, t)));
if (runtimePrefix) lines.push(...regCall("td", runtimePrefix.drop, ""));
if (isStreamRooted) lines.push(...stCall("tds", "scr_stream_st_release", ""));
if (meta.threadSlot) lines.push(...slotForgetLines(host, struct, meta, "tdw"));
@@ -1182,10 +1222,12 @@ export function emitClassShapes(
];
if (traced) reld.push(...cycleDeadLines(host, "teardown"));
if (bounded) {
- const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`;
const destroyBody: string[] = [];
teardown(destroyBody);
- defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``);
+ defs.push(
+ ...destroyDefinitions(destroy, destroyBody, chained, segmented ? host : null),
+ ``,
+ );
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`);
} else teardown(reld);
@@ -1207,6 +1249,7 @@ export function emitClassShapes(
bounded,
lifetime,
chained,
+ segmented,
),
``,
);
@@ -1367,18 +1410,18 @@ export function emitClassShapes(
* statics plus their construct thunks — allocate, run the constructor over
* a +1 `this`, and hand the remaining +1 out. No pending check is needed:
* in-tier constructors are throw-free by construction.
- * `classObjs` maps className → { nameSym } registered during body emission
- * (the literal interned while the table was open). */
+ * `classObjs` maps className → { nameSym, baseNameSym } registered during
+ * body emission (the literals interned while the table was open). */
export function emitClassObjDefs(
host: ClassHost,
metaMap: Map,
- classObjs: Map,
+ classObjs: Map,
fnByName: Map,
llType: (t: IrType) => string,
threadLocal = false,
): string[] {
const out: string[] = [];
- for (const [className, { nameSym }] of classObjs) {
+ for (const [className, { nameSym, baseNameSym }] of classObjs) {
const meta = metaMap.get(className);
if (!meta)
throw new InternalCompilerError(
@@ -1404,7 +1447,7 @@ export function emitClassObjDefs(
const ctorArgs = params.map((p, i) => `${llType(p.type)} %a${i}`);
const classObj =
`@${mangleClassObj(className)} = internal ${threadLocal ? "thread_local " : ""}global %ScrClassObj ` +
- `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym}, ${host.sizeType} 0, ${host.sizeType} ${meta.def.jsLength ?? (meta.def.runtime ? 1 : 0)}, ptr null, ptr null } ; class ${className}`;
+ `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym}, ${host.sizeType} 0, ${host.sizeType} ${meta.def.jsLength ?? (meta.def.runtime ? 1 : 0)}, ptr null, ptr null, ptr ${baseNameSym ?? "null"}, ${host.sizeType} ${meta.def.ownStaticFields ? 1 : 0} } ; class ${className}`;
if (meta.def.runtime) {
// A runtime-provided class: %C.construct allocates through the
// runtime and returns the instance.
diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts
index ac4e28708..cca1b603b 100644
--- a/packages/compiler/src/backend/llvm/common.ts
+++ b/packages/compiler/src/backend/llvm/common.ts
@@ -29,6 +29,9 @@ export const SCR_ARR_FIELD = {
* (SCR_ARR_STATES in scr_array.h). */
export const SCR_ARR_STATES = 16;
+/** The `spare` flag of a frozen %ScrArr (SCR_ARR_FROZEN in scr_array.h). */
+export const SCR_ARR_FROZEN = 32;
+
/** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a
* size-type count. */
export function loadArrCount(
diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts
index ca2dd6b92..1ff4c6580 100644
--- a/packages/compiler/src/backend/llvm/counted-loops.ts
+++ b/packages/compiler/src/backend/llvm/counted-loops.ts
@@ -151,3 +151,36 @@ function emitExactStart(host: LlvmEmitterContext, expr: IrExpr): string {
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return ok;
}
+
+/** Tests that a double is an exact integer within [min, max] (not -0; NaN
+ * fails the ordered comparisons) and gives its i64. The integer is frozen,
+ * so it is a fixed value even when the test fails. */
+export function emitExactIntegerTest(
+ host: LlvmEmitterContext,
+ value: string,
+ min: number,
+ max: number,
+): { ok: string; integer: string } {
+ const B = host.B;
+ const low = B.tmp(),
+ high = B.tmp(),
+ inRange = B.tmp(),
+ raw = B.tmp(),
+ integer = B.tmp(),
+ back = B.tmp(),
+ bits = B.tmp(),
+ backBits = B.tmp(),
+ exact = B.tmp(),
+ ok = B.tmp();
+ B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
+ B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
+ B.line(`${inRange} = and i1 ${low}, ${high}`);
+ B.line(`${raw} = fptosi double ${value} to i64`);
+ B.line(`${integer} = freeze i64 ${raw}`);
+ B.line(`${back} = sitofp i64 ${integer} to double`);
+ B.line(`${bits} = bitcast double ${value} to i64`);
+ B.line(`${backBits} = bitcast double ${back} to i64`);
+ B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
+ B.line(`${ok} = and i1 ${inRange}, ${exact}`);
+ return { ok, integer };
+}
diff --git a/packages/compiler/src/backend/llvm/debug-info.ts b/packages/compiler/src/backend/llvm/debug-info.ts
index 4e37ee5bc..3a75c6263 100644
--- a/packages/compiler/src/backend/llvm/debug-info.ts
+++ b/packages/compiler/src/backend/llvm/debug-info.ts
@@ -139,7 +139,6 @@ export class LlvmDebugInfo {
let id: string;
switch (type.kind) {
case "f64":
- case "date":
case "procStream":
id = this.add(
`!DIBasicType(name: ${quoted(type.kind === "f64" ? "number" : type.kind)}, size: 64, encoding: DW_ATE_float)`,
@@ -190,7 +189,7 @@ export class LlvmDebugInfo {
? []
: [
this.add(
- `!DIDerivedType(tag: DW_TAG_member, name: "arm${i}", baseType: ${this.type(arm)}, size: ${arm.kind === "bool" ? 8 : arm.kind === "f64" || arm.kind === "date" ? 64 : this.pointerBits})`,
+ `!DIDerivedType(tag: DW_TAG_member, name: "arm${i}", baseType: ${this.type(arm)}, size: ${arm.kind === "bool" ? 8 : arm.kind === "f64" ? 64 : this.pointerBits})`,
),
],
);
@@ -239,9 +238,7 @@ export class LlvmDebugInfo {
// Scalar forward captures use an array-backed TDZ box. Expose that
// exceptional representation opaquely rather than reporting a bad value.
const opaqueBox =
- local.boxed &&
- local.tdz &&
- (local.type.kind === "f64" || local.type.kind === "bool" || local.type.kind === "date");
+ local.boxed && local.tdz && (local.type.kind === "f64" || local.type.kind === "bool");
const type = opaqueBox
? this.pointer(
this.add(
diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts
index 95612e57c..df37d9c58 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.ts
@@ -505,8 +505,12 @@ export function emitDenseArrayState(
indexExpr: IrExpr | undefined,
kind: "state" | "has",
width: number,
+ /** The index is a dense array loop counter its entry test proved below
+ * the hole-free prefix (provenDenseIndex): the slot is a present value. */
+ proven = false,
): string {
const B = host.B;
+ if (proven) return kind === "state" ? f64Lit(SCR_ARR_VALUE) : "true";
const fn = kind === "state" ? "scr_arr_state" : "scr_arr_has";
const ty = kind === "state" ? "double" : "i1";
host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`);
diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
index f7a4bc892..a88c38575 100644
--- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
@@ -1,9 +1,10 @@
import { expect, test } from "vitest";
import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
countedDenseShape,
matchDenseArrayLoop,
+ pairedDenseShape,
type DenseLoopShape,
} from "./dense-array-loops.js";
@@ -42,6 +43,8 @@ const locals = new Map([
["a", { id: "a", name: "a", type: ARR, mutable: false }],
["b", { id: "b", name: "b", type: ARR, mutable: true }],
["c", { id: "c", name: "c", type: ARR, mutable: false }],
+ ["j", { id: "j", name: "j", type: F64, mutable: true }],
+ ["t", { id: "t", name: "t", type: F64, mutable: false }],
]);
/** for (let i = 0; i < a.length; i++) body */
@@ -193,3 +196,100 @@ test("a loop to another invariant limit covers each array whose length it keeps"
expect([...matchLimited(resizing, new Set(["b"]))!.arrays.keys()]).toEqual(["a"]);
expect(matchLimited(resizing)).toBeNull();
});
+
+test("a two-counter loop covers the arrays indexed at either counter", () => {
+ // for (let i = 1, j = k - 1; i < j; i++, j--) { t = a[i]; a[i] = a[j]; a[j] = t; }
+ const paired: IntegerPairedForLoop = {
+ low: { localId: "i", start: num(1), step: 1, startGuarded: false },
+ high: { localId: "j", start: ref("k"), step: -1, startGuarded: true },
+ inclusive: false,
+ guarded: true,
+ range: { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
+ };
+ const pairedShape = pairedDenseShape(paired);
+ const matchPaired = (body: IrStmt[]) =>
+ matchDenseArrayLoop(loop(body), pairedShape, locals, new Set(), new Set(["a", "b"]));
+ const swap: IrStmt[] = [
+ { kind: "varDecl", localId: "t", init: read("a"), loc },
+ write("a", read("a", ref("j"))),
+ write("a", ref("t"), ref("j")),
+ ];
+ const dense = matchPaired(swap);
+ expect([...dense!.arrays.keys()]).toEqual(["a"]);
+ expect(dense!.pair).toEqual({ high: "j", lowStart: num(1), highStart: ref("k") });
+ expect(dense!.range).toEqual({ min: 0, max: 2147483646 });
+ // Stores at any other index may extend the array.
+ expect(matchPaired([...swap, write("a", num(0), ref("k"))])).toBeNull();
+});
+
+test("a present-undefined store is covered only where it stores a covered read's missing arm", () => {
+ // const v = b[i] (as number | undefined);
+ // if (v is undefined) c[i] = undefined; else c[i] = v is number ? v : throw;
+ const unionType = { kind: "union" as const, unionId: "u" };
+ const v: IrExpr = { kind: "varRef", localId: "v", type: unionType, loc };
+ const isTag = (tag: number): IrExpr => ({
+ kind: "unionIsTag",
+ unionId: "u",
+ tag,
+ negated: false,
+ value: v,
+ type: BOOL,
+ loc,
+ });
+ const reader = (array: string, index = ref("i")): IrExpr => ({
+ kind: "call",
+ callee: "%arr.idxOr.f64",
+ args: [arr(array), index],
+ type: unionType,
+ loc,
+ });
+ const undefinedStore: IrStmt = { kind: "arraySetUndefined", arr: arr("c"), index: ref("i"), loc };
+ const guarded = (array: string): IrStmt[] => [
+ { kind: "varDecl", localId: "v", init: reader(array), loc },
+ {
+ kind: "if",
+ cond: isTag(1),
+ then: [undefinedStore],
+ else_: [
+ write("c", {
+ kind: "ternary",
+ cond: isTag(0),
+ then: { kind: "unionNarrow", unionId: "u", tag: 0, value: v, type: F64, loc },
+ else_: {
+ kind: "libCall",
+ fn: "error.nodeThrow",
+ args: [num(1), { kind: "strLit", value: "", type: { kind: "string" }, loc }],
+ type: F64,
+ loc,
+ },
+ type: F64,
+ loc,
+ }),
+ ],
+ loc,
+ },
+ ];
+ const withV = new Map([
+ ...locals,
+ ["v", { id: "v", name: "v", type: unionType, mutable: false }],
+ ]);
+ const matchStores = (body: IrStmt[], initialized: string[]) =>
+ matchDenseArrayLoop(loop(body), shape, withV, new Set(), new Set(initialized));
+ const dense = matchStores(guarded("b"), ["a", "b", "c"]);
+ expect([...dense!.arrays.keys()].sort()).toEqual(["b", "c"]);
+ // A read of an array the entry test does not cover may miss in the fast
+ // copy, and its undefined store would run there.
+ expect(matchStores(guarded("b"), ["a", "c"])).toBeNull();
+ // A present undefined stored on its own leaves out its array and every
+ // array the other name may hold.
+ const alone: IrStmt[] = [{ kind: "exprStmt", expr: read("b"), loc }, undefinedStore];
+ expect(matchStores(alone, ["a", "b", "c"])).toBeNull();
+ const separate = matchDenseArrayLoop(
+ loop(alone),
+ shape,
+ withV,
+ new Set(["c"]),
+ new Set(["a", "b", "c"]),
+ );
+ expect([...separate!.arrays.keys()]).toEqual(["b"]);
+});
diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.ts b/packages/compiler/src/backend/llvm/dense-array-loops.ts
index 184ff1353..b806939b5 100644
--- a/packages/compiler/src/backend/llvm/dense-array-loops.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-loops.ts
@@ -20,16 +20,23 @@
* A counted loop with another invariant limit, such as `j < n` after
* `const n = a.length` or `j < 5`, is covered the same way: the test bounds
* the loop's exclusive limit by 2^31 - 1 and by each indexed array's
- * `filled`, and the loop must provably never change any of their lengths. */
+ * `filled`, and the loop must provably never change any of their lengths.
+ *
+ * A two-counter loop closing in on itself, such as
+ * `for (let i = 1, j = k - 1; i < j; i++, j--)`, indexes only between its
+ * low counter's start and its high counter's: the low counter only rises
+ * and stays below the high one, which only falls. Its test bounds the low
+ * start below by 0 and the high start by 2^31 - 2 and below each indexed
+ * array's `filled`, both starts being exact integers. */
import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js";
import type { IntegerRange } from "../../ir/integer-ranges.js";
import { preservesArrayLength } from "../../ir/array-length-stability.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { loadArrCount, SCR_ARR_FIELD } from "./common.js";
-import { emitCountedLoopLimit } from "./counted-loops.js";
+import { emitCountedLoopLimit, emitExactIntegerTest } from "./counted-loops.js";
export const INT32_MAX = 2147483647;
@@ -47,6 +54,17 @@ export interface DenseArrayLoop {
arrays: ReadonlyMap;
/** The counter's range on every pass of the fast copy. */
range: IntegerRange;
+ /** A two-counter loop's high counter and both starts, when the counter
+ * is its low one (`length` and `limit` are then null). */
+ pair: DensePair | null;
+}
+
+/** The high counter of a two-counter dense loop, and the starts its entry
+ * test bounds. */
+export interface DensePair {
+ high: string;
+ lowStart: IrExpr;
+ highStart: IrExpr;
}
/** The fast copy duplicates the loop; keep the duplicated code small. */
@@ -62,6 +80,8 @@ export interface DenseLoopShape {
/** The counter's proven range on every pass of the ordinary loop; the
* fast copy keeps its minimum, which must be at least 0. */
range: IntegerRange;
+ /** A two-counter loop's high counter and starts. */
+ pair?: DensePair;
}
/** `length.length - c` (c an integer literal, 0 when absent). */
@@ -107,6 +127,124 @@ export function countedDenseShape(
return { counter: counted.localId, length: start.receiver, range: counted.range };
}
+/** The shape of a two-counter loop whose low counter rises and high
+ * counter falls: both stay between the two starts, which the entry test
+ * bounds, so the fast copy's counters lie in [0, 2^31 - 2]. */
+export function pairedDenseShape(loop: IntegerPairedForLoop): DenseLoopShape {
+ return {
+ counter: loop.low.localId,
+ length: null,
+ range: { min: 0, max: INT32_MAX - 1 },
+ pair: { high: loop.high.localId, lowStart: loop.low.start, highStart: loop.high.start },
+ };
+}
+
+interface ElementRead {
+ arr: IrExpr;
+ index: IrExpr;
+}
+
+/** The array and index of a call of an element-reader helper
+ * (`%arr.idxOr.*`, ir/int32-arrays.ts isElementReader): `a[i]` as
+ * `number | undefined`. */
+function elementReaderCall(e: IrExpr): ElementRead | null {
+ if (e.kind !== "call" || !e.callee.startsWith("%arr.idxOr.") || e.args.length !== 2) return null;
+ return { arr: e.args[0]!, index: e.args[1]! };
+}
+
+/** The array and index of an element read as `T | undefined`: an
+ * element-reader call, or its body inlined for an int32 array
+ * (`arrayState(a, i) === 1 ? wrap(a[i]) : wrap(undefined)`). */
+function elementRead(e: IrExpr): ElementRead | null {
+ const call = elementReaderCall(e);
+ if (call) return call;
+ if (e.kind !== "ternary") return null;
+ const cond = e.cond;
+ if (cond.kind !== "bin" || cond.op !== "===") return null;
+ const state = cond.left,
+ present = cond.right,
+ wrapped = e.then,
+ missing = e.else_;
+ if (state.kind !== "arrayState" || present.kind !== "numLit" || present.value !== 1) return null;
+ if (wrapped.kind !== "unionWrap" || missing.kind !== "unionWrap") return null;
+ const value = wrapped.value;
+ if (value.kind !== "arrayGet" || missing.value.kind !== "unitLit") return null;
+ const same = (x: IrExpr, y: IrExpr): boolean =>
+ x.kind === "varRef" && y.kind === "varRef" && x.localId === y.localId;
+ return same(state.arr, value.arr) && same(state.index, value.index)
+ ? { arr: state.arr, index: state.index }
+ : null;
+}
+
+/** The present-undefined stores of `a[i] = b[j]!` as lowering spells it,
+ * with the element read each one stores the missing arm of:
+ *
+ * const v = %arr.idxOr(b, j);
+ * if (v is undefined) a[i] = undefined; else a[i] = v is number ? v : throw;
+ *
+ * The read's local is declared once in the same list as the `if`, and only
+ * read after it. */
+function missingElementStores(body: IrStmt[]): Map {
+ const result = new Map();
+ const writes = new Map();
+ const count = (id: string): void => void writes.set(id, (writes.get(id) ?? 0) + 1);
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "assignExpr" || e.kind === "incDec") count(e.localId);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "varDecl" || s.kind === "assign") count(s.localId);
+ return everyStmtChild(s, expr, stmt);
+ };
+ body.every(stmt);
+ const list = (stmts: IrStmt[]): void => {
+ const reads = new Map();
+ for (const s of stmts) {
+ if (s.kind === "varDecl" && s.init && writes.get(s.localId) === 1) {
+ const read = elementRead(s.init);
+ if (read) reads.set(s.localId, read);
+ }
+ if (s.kind === "if") {
+ const store = missingArmStore(s, reads);
+ if (store) result.set(store.stmt, store.read);
+ }
+ // Nested loops keep a loop out of the fast copy (matchDenseArrayLoop),
+ // and other statement lists leave their stores unmatched.
+ if (s.kind === "block") list(s.body);
+ if (s.kind === "if") {
+ list(s.then);
+ if (s.else_) list(s.else_);
+ }
+ }
+ };
+ list(body);
+ return result;
+}
+
+/** The present-undefined store of `if (v is undefined) a[i] = undefined;
+ * else a[i] = v is number ? v : throw;`, with the read `v` holds. */
+function missingArmStore(
+ s: IrStmt & { kind: "if" },
+ reads: ReadonlyMap,
+): { stmt: IrStmt; read: ElementRead } | null {
+ const cond = s.cond;
+ if (cond.kind !== "unionIsTag" || cond.negated || cond.value.kind !== "varRef") return null;
+ const local = cond.value.localId;
+ const read = reads.get(local);
+ if (!read || s.then.length !== 1 || s.else_?.length !== 1) return null;
+ const store = s.then[0]!,
+ other = s.else_[0]!;
+ if (store.kind !== "arraySetUndefined" || other.kind !== "arraySet") return null;
+ const value = other.value;
+ if (value.kind !== "ternary") return null;
+ const test = value.cond,
+ narrowed = value.then;
+ if (test.kind !== "unionIsTag" || test.negated || test.tag === cond.tag) return null;
+ if (test.value.kind !== "varRef" || test.value.localId !== local) return null;
+ if (narrowed.kind !== "unionNarrow" || narrowed.tag !== test.tag) return null;
+ return narrowed.type.kind === "f64" ? { stmt: store, read } : null;
+}
+
/** Match the fast copy of a loop of the given shape. `initialized` holds the
* bindings definitely assigned when the loop starts; `unaliased` the local
* arrays no other binding holds. */
@@ -118,6 +256,8 @@ export function matchDenseArrayLoop(
initialized: ReadonlySet,
): DenseArrayLoop | null {
const { counter, length } = shape;
+ const high = shape.pair?.high ?? null;
+ const isCounter = (id: string): boolean => id === counter || id === high;
if (shape.range.min < 0 || !loop.cond) return null;
let nodes = 0,
nested = false;
@@ -126,13 +266,21 @@ export function matchDenseArrayLoop(
// element write.
const disturbed = new Set();
const access = (arr: IrExpr, index: IrExpr): void => {
- if (arr.kind === "varRef" && index.kind === "varRef" && index.localId === counter)
+ if (arr.kind === "varRef" && index.kind === "varRef" && isCounter(index.localId))
candidates.set(arr.localId, arr);
};
+ const missing = missingElementStores(loop.body);
+ // The element reads behind the guarded present-undefined stores.
+ const guards: ElementRead[] = [];
+ // The arrays of the other present-undefined stores. The fast copy runs
+ // them, and the slot each one writes leaves the hole-free prefix.
+ const undefinedStores: IrExpr[] = [];
const expr = (e: IrExpr): boolean => {
if (++nodes > MAX_LOOP_NODES) return false;
if (e.kind === "assignExpr" || e.kind === "incDec") disturbed.add(e.localId);
if (e.kind === "arrayGet") access(e.arr, e.index);
+ const reader = elementReaderCall(e);
+ if (reader) access(reader.arr, reader.index);
if (e.kind === "arrIntrinsic") {
if (e.method === "getNumber" && e.args[0]) access(e.receiver, e.args[0]);
else if (e.method !== "length" && e.receiver.kind === "varRef")
@@ -147,9 +295,21 @@ export function matchDenseArrayLoop(
if (s.kind === "assign" || s.kind === "varDecl") disturbed.add(s.localId);
if (s.kind === "arraySet") {
access(s.arr, s.index);
- if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || s.index.localId !== counter))
+ if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || !isCounter(s.index.localId)))
disturbed.add(s.arr.localId);
}
+ if (s.kind === "arraySetUndefined") {
+ // A present undefined at a counter only where it stores a missing
+ // element read (`a[i] = b[j]!`): the fast copy reads a present value
+ // there, so the store never runs in it.
+ const read = missing.get(s);
+ if (read === undefined || s.index.kind !== "varRef" || !isCounter(s.index.localId)) {
+ undefinedStores.push(s.arr);
+ } else {
+ access(s.arr, s.index);
+ guards.push(read);
+ }
+ }
return everyStmtChild(s, expr, stmt);
};
if (!loop.body.every(stmt) || !expr(loop.cond) || nested) return null;
@@ -164,6 +324,15 @@ export function matchDenseArrayLoop(
)
)
return null;
+ // A store through another name, such as `const b = a` or a field, lands
+ // in `a` unless one of the two bindings is a local array no other binding
+ // holds.
+ const mayStoreUndefined = (id: string): boolean =>
+ undefinedStores.some((arr) =>
+ arr.kind === "varRef"
+ ? arr.localId === id || (!unaliased.has(id) && !unaliased.has(arr.localId))
+ : !unaliased.has(id),
+ );
const arrays = new Map();
for (const [id, ref] of candidates) {
const local = locals.get(id);
@@ -173,6 +342,7 @@ export function matchDenseArrayLoop(
local.boxed ||
local.tdz ||
disturbed.has(id) ||
+ mayStoreUndefined(id) ||
(id !== length?.localId && !initialized.has(id))
)
continue;
@@ -180,18 +350,33 @@ export function matchDenseArrayLoop(
// once: the loop must not change any of their lengths.
if (
length === null &&
- !preservesArrayLength(nodesOfLoop, { receiver: id, inRange: counter }, locals, unaliased)
+ !preservesArrayLength(
+ nodesOfLoop,
+ { receiver: id, inRange: counter, inRangeHigh: high },
+ locals,
+ unaliased,
+ )
)
continue;
arrays.set(id, ref);
}
if (arrays.size === 0) return null;
+ // Each guarded store's read must be one the fast copy proves present.
+ for (const { arr, index } of guards)
+ if (
+ arr.kind !== "varRef" ||
+ !arrays.has(arr.localId) ||
+ index.kind !== "varRef" ||
+ !isCounter(index.localId)
+ )
+ return null;
return {
counter,
length,
limit: length === null ? (shape.limit ?? null) : null,
arrays,
range: { min: shape.range.min, max: Math.min(shape.range.max, INT32_MAX - 1) },
+ pair: shape.pair ?? null,
};
}
@@ -207,6 +392,7 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
+ if (loop.pair !== null) return emitPairedDenseGuard(host, loop, loop.pair, header);
if (loop.length === null) {
// A limit at or below the start runs no pass, and passes the test.
const limit = emitCountedLoopLimit(host, loop.limit!);
@@ -254,3 +440,36 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
}
return valid;
}
+
+/** A two-counter loop's entry test: both starts are exact integers, the
+ * low one at least 0 and the high one at most 2^31 - 2 and below every
+ * indexed array's hole-free prefix bound. The starts are stable values
+ * (matchIntegerPairedForLoop), so evaluating them here again is unobservable. */
+function emitPairedDenseGuard(
+ host: LlvmEmitterContext,
+ loop: DenseArrayLoop,
+ pair: DensePair,
+ header: (array: string, field: number) => string,
+): string {
+ const B = host.B;
+ const start = (expr: IrExpr, min: number, max: number) =>
+ emitExactIntegerTest(host, host.emitExpr(expr).name, min, max);
+ const low = start(pair.lowStart, 0, INT32_MAX - 1);
+ const high = start(pair.highStart, -1, INT32_MAX - 1);
+ let valid = B.tmp();
+ B.line(`${valid} = and i1 ${low.ok}, ${high.ok}`);
+ for (const ref of loop.arrays.values()) {
+ const array = host.emitStableReceiver(ref, []);
+ const filledPtr = header(array.name, SCR_ARR_FIELD.filled),
+ filled = B.tmp(),
+ wide = B.tmp(),
+ covered = B.tmp(),
+ both = B.tmp();
+ B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
+ B.line(`${wide} = zext i32 ${filled} to i64`);
+ B.line(`${covered} = icmp slt i64 ${high.integer}, ${wide}`);
+ B.line(`${both} = and i1 ${valid}, ${covered}`);
+ valid = both;
+ }
+ return valid;
+}
diff --git a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
index c015b85d0..8c3e5de80 100644
--- a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
+++ b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
import { DYN_HANDLE_KINDS } from "../../ir/ir.js";
-import { DYN_HANDLE_TAG_NUM } from "./dyn.js";
+import { DYN_HANDLE_TAG_NUM, TEXT_CODEC_TAG_NUM } from "./dyn.js";
// SCR_DYNH_TAGS in scr_dyn.h lists the ScrDynHandleTag values by name, each
// with an explicit value, so changes that add tags edit different lines. The
@@ -39,3 +39,9 @@ test("the backend's handle tag numbers match scr_dyn.h", () => {
expect(values.get(tag!), `${kind} (${tag})`).toBe(num);
}
});
+
+test("the backend's codec handle tag numbers match scr_dyn.h", () => {
+ const values = new Map(tags.map((tag) => [tag.name, tag.value]));
+ expect(values.get("SCR_DYNH_TEXT_DECODER")).toBe(TEXT_CODEC_TAG_NUM.TextDecoder);
+ expect(values.get("SCR_DYNH_TEXT_ENCODER")).toBe(TEXT_CODEC_TAG_NUM.TextEncoder);
+});
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index 86626d304..6d02daa8b 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -43,6 +43,8 @@ import {
isDynTypedRefType,
isRefCounted,
isUnitType,
+ recordTextCodecClass,
+ textCodecRecordClass,
typeKey,
} from "../../ir/ir.js";
import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
@@ -94,6 +96,17 @@ export const DYN_HANDLE_TAG_NUM: Record = {
cryptoHmac: 53,
};
+/** The handle tags of TextEncoder and TextDecoder records boxed as dyn
+ * (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */
+export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = {
+ TextDecoder: 76,
+ TextEncoder: 77,
+};
+
+/** The decoder record fields scr_text_decoder_to_dyn reads, in its
+ * parameter order. */
+const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const;
+
/** Handle kinds whose runtime unit boxes them through its own function,
* which installs the tag's ops on first use (scr_crypto.h). Programs that
* never box one of these values do not link its dynamic members. */
@@ -456,7 +469,7 @@ export class LlDyn {
/** The value LLVM type of a dynCheck result / toDyn operand for `t`. */
private valTy(t: IrType): string {
- return t.kind === "f64" || t.kind === "date" || t.kind === "procStream"
+ return t.kind === "f64" || t.kind === "procStream"
? "double"
: t.kind === "bool"
? "i1"
@@ -927,6 +940,21 @@ export class LlDyn {
const name = `sc_dm_${this.dynMatchers.size}`;
this.dynMatchers.set(matcherKey, name);
this.host.journalAllocated?.(name, "dyn.dynMatchHelper", [t, preserveRefs]);
+ const codec = textCodecRecordClass(t, (id) => this.host.recordsById.get(id));
+ if (codec !== null) {
+ // A codec matches only its own handle box.
+ const B = new BlockBuilder();
+ const matched = this.handleTagMatches(B, TEXT_CODEC_TAG_NUM[codec]);
+ B.terminate(`ret i1 ${matched}`);
+ this.defs.push(
+ `define internal zeroext i1 @${name}(ptr %d) ${FN_ATTRS} { ; matches ${key}`,
+ B.render(),
+ `}`,
+ ``,
+ );
+ this.host.journalBuilt?.();
+ return name;
+ }
const recursive = this.isRecursiveShape(t);
const wrapped = recursive || this.holdsRecursiveShape(t);
const B = new BlockBuilder();
@@ -951,7 +979,10 @@ export class LlDyn {
const lPlain = B.newLabel("dm.tr.plain");
B.condBr(capsule, lCapsule, lPlain);
B.startBlock(lCapsule);
- if (preserveRefs) {
+ // A class target matches only capsules of that class (tested above):
+ // a materialized view is a plain object, which never carries a class
+ // brand, so materializing one cannot change the answer.
+ if (preserveRefs || (t.kind === "object" && t.className !== "%Error")) {
B.terminate(`ret i1 false`);
} else {
const materialized = B.tmp();
@@ -1535,6 +1566,19 @@ export class LlDyn {
return descriptor;
}
+ /** `record.sourceSpread(built, value)`: the built record keeps value's
+ * kept-source order over its own declared names. */
+ emitRecordSourceSpread(B: BlockBuilder, built: string, value: string, shapeId: string): void {
+ const host = this.host;
+ const shape = host.recordsById.get(shapeId)!;
+ this.keepsRecordSources = true;
+ const names = this.recordNameTable(shapeId, shape);
+ host.declare(`declare void @scr_record_source_spread(ptr, ptr, ptr, ptr, ${host.sizeType})`);
+ B.line(
+ `call void @scr_record_source_spread(ptr ${built}, ptr ${value}, ptr ${names.names}, ptr ${names.lens}, ${host.sizeType} ${shape.fields.length})`,
+ );
+ }
+
/** The declared-name tables scr_record_source_note scans: parallel
* constant arrays of field-name bytes and byte lengths in declaration
* order (the order the record serializes), interned per shape. Internal
@@ -1643,6 +1687,24 @@ export class LlDyn {
this.host.journalBuilt?.();
return name;
}
+ const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id));
+ if (codec !== null) {
+ // The record the codec's handle box holds (+1), or the path TypeError.
+ host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
+ const checked = B.tmp();
+ B.line(
+ `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
+ );
+ B.terminate(`ret ptr ${checked}`);
+ this.defs.push(
+ `define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`,
+ B.render(),
+ `}`,
+ ``,
+ );
+ this.host.journalBuilt?.();
+ return name;
+ }
if (isRefCounted(t) && t.kind !== "dyn") {
host.declare(`declare ptr @scr_dyn_typed_ref_unbox(ptr)`);
if (t.kind === "object" && t.className === "%Error") {
@@ -1895,10 +1957,10 @@ export class LlDyn {
};
switch (t.kind) {
case "date": {
- host.declare(`declare double @scr_dyn_native_date_value(ptr)`);
+ host.declare(`declare ptr @scr_dyn_native_date_check(ptr, ptr)`);
const r = B.tmp();
- B.line(`${r} = call double @scr_dyn_native_date_value(ptr %d)`);
- B.terminate(`ret double ${r}`);
+ B.line(`${r} = call ptr @scr_dyn_native_date_check(ptr %d, ptr %path)`);
+ B.terminate(`ret ptr ${r}`);
break;
}
case "url":
@@ -2671,6 +2733,77 @@ export class LlDyn {
return name;
}
+ /** `i1`: %d is a handle box with tag `tag`. */
+ private handleTagMatches(B: BlockBuilder, tag: number): string {
+ const kd = this.kindOf(B, "%d");
+ const isHandle = B.tmp();
+ B.line(`${isHandle} = icmp eq i32 ${kd}, ${DYN_KIND.HANDLE}`);
+ const lHandle = B.newLabel("dm.handle");
+ const lDone = B.newLabel("dm.handle.done");
+ const entry = B.currentLabel();
+ B.condBr(isHandle, lHandle, lDone);
+ B.startBlock(lHandle);
+ const tagPtr = B.tmp();
+ const loaded = B.tmp();
+ B.line(
+ `${tagPtr} = getelementptr inbounds i8, ptr %d, i64 ${this.abiOffset(24, 20)} ; ->v.handle.tag`,
+ );
+ B.line(`${loaded} = load i32, ptr ${tagPtr}`);
+ const same = B.tmp();
+ B.line(`${same} = icmp eq i32 ${loaded}, ${tag}`);
+ B.br(lDone);
+ B.startBlock(lDone);
+ const matched = B.tmp();
+ B.line(`${matched} = phi i1 [ false, %${entry} ], [ ${same}, %${lHandle} ]`);
+ return matched;
+ }
+
+ /** A TextEncoder or TextDecoder record boxes as its handle (+1), which
+ * holds the record by reference. A decoder passes the byte offsets of the
+ * fields its boxed decode reads. */
+ private textCodecToDyn(
+ B: BlockBuilder,
+ t: IrType & { kind: "record" },
+ shape: IrRecordShape,
+ codec: "TextEncoder" | "TextDecoder",
+ ): void {
+ const host = this.host;
+ const rc = vAdapters(host, t);
+ const r = B.tmp();
+ if (codec === "TextEncoder") {
+ host.declare(`declare ptr @scr_text_encoder_to_dyn(ptr, ptr, ptr)`);
+ B.line(
+ `${r} = call ptr @scr_text_encoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release})`,
+ );
+ } else {
+ const struct = mangleRecordStruct(t.shapeId);
+ const base = host.nullableUnions.fieldBase(t.shapeId);
+ const start = B.tmp();
+ B.line(`${start} = ptrtoint ptr %v to ${this.S}`);
+ const offsets = TEXT_DECODER_FIELDS.map((field) => {
+ const index = shape.fields.findIndex((f) => f.name === field);
+ if (index < 0)
+ throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`);
+ const p = B.tmp();
+ const at = B.tmp();
+ const offset = B.tmp();
+ B.line(
+ `${p} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${index + base} ; .${llvmCommentText(field)}`,
+ );
+ B.line(`${at} = ptrtoint ptr ${p} to ${this.S}`);
+ B.line(`${offset} = sub ${this.S} ${at}, ${start}`);
+ return `${this.S} ${offset}`;
+ });
+ host.declare(
+ `declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`,
+ );
+ B.line(
+ `${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
+ );
+ }
+ B.terminate(`ret ptr ${r}`);
+ }
+
/* ── toDynHelper (walkers.ts, ported) ─────────────────────────── */
/** `sc_td_( v) -> ptr` — build a fresh dyn value from a
@@ -2688,11 +2821,10 @@ export class LlDyn {
let sourceAccessor: { name: string; release: string } | null = null;
switch (t.kind) {
case "date": {
- // A Date handle holding the same time value; native Dates are
- // immutable values, and so is the handle.
- host.declare(`declare ptr @scr_dyn_native_date_from_ms(double)`);
+ // A Date handle holding the same Date object.
+ host.declare(`declare ptr @scr_dyn_native_date(ptr)`);
const r = B.tmp();
- B.line(`${r} = call ptr @scr_dyn_native_date_from_ms(double %v)`);
+ B.line(`${r} = call ptr @scr_dyn_native_date(ptr %v)`);
B.terminate(`ret ptr ${r}`);
break;
}
@@ -2846,6 +2978,11 @@ export class LlDyn {
const shape = host.recordsById.get(t.shapeId);
if (!shape)
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
+ const codec = recordTextCodecClass(shape);
+ if (codec !== null) {
+ this.textCodecToDyn(B, t, shape, codec);
+ break;
+ }
const struct = mangleRecordStruct(t.shapeId);
const base = host.nullableUnions.fieldBase(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base]));
diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts
index b6df7bcca..8f306035a 100644
--- a/packages/compiler/src/backend/llvm/emit-function.ts
+++ b/packages/compiler/src/backend/llvm/emit-function.ts
@@ -304,11 +304,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
// ScrGen owns the frame), so its OUT slot address is fixed.
let out: string | null = null;
const yieldKind = fn.generator?.yieldT.kind;
- if (
- !emitter.wasi &&
- fn.async !== true &&
- (yieldKind === "f64" || yieldKind === "date" || yieldKind === "bool")
- ) {
+ if (!emitter.wasi && fn.async !== true && (yieldKind === "f64" || yieldKind === "bool")) {
emitter.declare(`declare ptr @scr_gen_of_fiber(ptr)`);
const gen = B.tmp();
B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${self})`);
@@ -704,7 +700,6 @@ export function emitGenScaffolding(emitter: LlEmitter): string[] {
tr.push(` br label %done ; void body: the done value is undefined (NONE)`);
break;
case "f64":
- case "date":
emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`);
tr.push(` call void @scr_gen_out_f64(ptr %g, double %r)`, ` br label %done`);
break;
@@ -1053,7 +1048,6 @@ export function emitAsyncAwaitCall(
emitter.declare(`declare void @scr_await_void(ptr)`);
return ` call void @scr_await_void(ptr ${promise})`;
case "f64":
- case "date":
emitter.declare(`declare double @scr_await_f64(ptr)`);
return ` ${dest} = call double @scr_await_f64(ptr ${promise})`;
case "bool":
@@ -1108,7 +1102,7 @@ export function emitAsyncAwaitCall(
const kind =
ret.kind === "void"
? "void"
- : ret.kind === "f64" || ret.kind === "date"
+ : ret.kind === "f64"
? "f64"
: ret.kind === "bool"
? "bool"
@@ -1187,7 +1181,6 @@ export function emitWasiFulfill(emitter: LlEmitter, v: LlValue | null): void {
case "void":
return;
case "f64":
- case "date":
emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`);
emitter.B.line(`call void @scr_gen_out_f64(ptr ${gen}, double ${v!.name})`);
return;
@@ -1212,7 +1205,6 @@ export function emitWasiFulfill(emitter: LlEmitter, v: LlValue | null): void {
emitter.B.line(`call void @scr_promise_fulfill_void(ptr ${pr})`);
break;
case "f64":
- case "date":
emitter.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
emitter.B.line(`call void @scr_promise_fulfill_f64(ptr ${pr}, double ${v!.name})`);
break;
diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts
index 16a1b1601..5aa18bf9f 100644
--- a/packages/compiler/src/backend/llvm/emit-module.ts
+++ b/packages/compiler/src/backend/llvm/emit-module.ts
@@ -64,6 +64,7 @@ import { literalEqualityDefinition } from "./string-literal-equality.js";
import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js";
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
import { InternalCompilerError } from "../../errors.js";
+import { targetCpuAttribute } from "../target-cpu.js";
/** The function bodies emitted in `chunks` contiguous ranges, each by its
* own emitter (LlEmitter.chunkEmitter), then merged in function order: the
@@ -191,6 +192,20 @@ 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. */
+function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] {
+ const cpu = targetCpuAttribute(emitter.targetCpu);
+ return [
+ `attributes #0 = { sanitize_address${cpu} }`,
+ ...(emitter.usesCoroutines
+ ? [`attributes #1 = { sanitize_address presplitcoroutine${cpu} }`]
+ : []),
+ ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address${cpu} }`] : []),
+ ];
+}
+
export function emitModuleParts(emitter: LlEmitter): string[] {
// Function bodies first (the literal/unit/fn-value tables fill as they
// emit), then the file assembles around them — the runtime ABI’s order.
@@ -631,7 +646,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// class-object shape { rc, pre, post, ctor, name } — field reads on
// builtin errors and classval loads GEP through these.
`%ScrError = type { ${emitter.sizeType}, ptr, ptr, ptr, ptr, ptr, i8, i8, i8, i8, i8, ptr, ptr }`,
- `%ScrClassObj = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`,
+ `%ScrClassObj = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ptr, ${emitter.sizeType} }`,
// The runtime emitter prefix { rc, vt, reg, cls } — user subclasses
// embed it (classes.ts), and bare-emitter GEPs address through it.
`%ScrEmitter = type { ${emitter.sizeType}, ptr, ptr, ptr }`,
@@ -972,9 +987,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// symbols specified by the library IR instead.
for (const line of emitter.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line);
out.push(...emitter.inlineRcTail(flushedDecls));
- out.push(`attributes #0 = { sanitize_address }`);
- if (emitter.usesCoroutines) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`);
- if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`);
+ out.push(...functionAttributes(emitter, hasNoInlineRecordClone));
out.push(``);
out.push(...emitter.fieldAliasMetadata());
if (emitter.debug !== null) out.push(emitter.debug.render());
@@ -1122,9 +1135,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// 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).
- `attributes #0 = { sanitize_address }`,
- ...(emitter.usesCoroutines ? [`attributes #1 = { sanitize_address presplitcoroutine }`] : []),
- ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address }`] : []),
+ ...functionAttributes(emitter, hasNoInlineRecordClone),
``,
);
if (emitter.mod.workers) {
diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts
index c2846a9d4..55e31219c 100644
--- a/packages/compiler/src/backend/llvm/emit-statements.ts
+++ b/packages/compiler/src/backend/llvm/emit-statements.ts
@@ -52,13 +52,19 @@ import {
matchIntegerPairedForLoop,
} from "../../ir/integer-loops.js";
import { emitByteWindowGuard, matchByteWindow } from "./byte-windows.js";
-import { countedDenseShape, emitDenseLoopGuard, matchDenseArrayLoop } from "./dense-array-loops.js";
+import {
+ countedDenseShape,
+ emitDenseLoopGuard,
+ matchDenseArrayLoop,
+ pairedDenseShape,
+} from "./dense-array-loops.js";
import {
emitCountedLoopLimit,
emitCountedLoopSafety,
emitPairedLoopSafety,
} from "./counted-loops.js";
import { emitBytesLoopVersionGuard, type BytesLoopVersion } from "./bytes-loop-versions.js";
+import { bytesHeaderAlias } from "./bytes-alias.js";
import { mangleLocal } from "../mangle.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlValue } from "./expr-context.js";
@@ -966,7 +972,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
: countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0
? countedDenseShape(s, countedLoop)
- : null;
+ : pairedLoop
+ ? pairedDenseShape(pairedLoop)
+ : null;
const dense =
denseShape && !window
? matchDenseArrayLoop(
@@ -1043,15 +1051,17 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
LOOPS.of(emitter).unaliasedArrays,
)
: countedLoop;
- const version = versionable ? emitter.matchBytesLoopVersion(s, versionCounted) : null;
+ const version = versionable
+ ? emitter.matchBytesLoopVersion(s, versionCounted, versionCounted ? null : pairedLoop)
+ : null;
if (version) {
emitVersionedLoop(emitter, s, version);
break;
}
let countedJoin: string | null = null;
if (
- (countedLoop?.guarded && !LOOPS.of(emitter).provenCountedGuards.has(s)) ||
- pairedLoop?.guarded
+ (countedLoop?.guarded || pairedLoop?.guarded) &&
+ !LOOPS.of(emitter).provenCountedGuards.has(s)
) {
const safe = countedLoop
? emitCountedLoopSafety(emitter, countedLoop)
@@ -1144,11 +1154,15 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.line(`${start} = fptosi double ${value.name} to i64`);
B.line(`store i64 ${start}, ptr ${slot}`);
pairedSlots.push(slot);
+ const dense = LOOPS.of(emitter).denseLoop;
emitter.integerLoopBindings.set(counter.localId, {
slot,
type: "i64",
signed: true,
- range: pairedLoop.range,
+ range:
+ dense?.pair && dense.counter === pairedLoop.low.localId
+ ? dense.range
+ : pairedLoop.range,
});
}
} else if (s.init) {
@@ -1218,10 +1232,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
B.line(`${inBounds} = icmp ult i32 ${narrow}, ${len}`);
} else {
- emitter.markMemoryPointer(lenPtr, "bytes:header");
- B.line(
- `${len} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}`,
- );
+ B.line(`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`);
B.line(`${inBounds} = icmp ult ${emitter.sizeType} ${index}, ${len}`);
}
B.condBr(inBounds, lb, exit);
@@ -2085,7 +2096,8 @@ function emitVersionedLoop(
if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
if (version.ranges.size > 0)
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
- if (version.counted?.guarded) LOOPS.of(emitter).provenCountedGuards.add(s);
+ if (version.counted?.guarded || version.paired?.loop.guarded)
+ LOOPS.of(emitter).provenCountedGuards.add(s);
emitter.emitStmt(s);
B.br(done);
} finally {
diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts
index 4793663aa..876476cc1 100644
--- a/packages/compiler/src/backend/llvm/emitter.test.ts
+++ b/packages/compiler/src/backend/llvm/emitter.test.ts
@@ -1,7 +1,6 @@
import { expect, test } from "vitest";
import {
BOOL,
- DATE_T,
F64,
PROCSTREAM_T,
STRING,
@@ -202,6 +201,16 @@ test("detached exit defines the runtime opt-in symbol only when requested", () =
expect(detached.replace("@scr_detached_exit = constant i8 1\n", "")).toBe(plain);
});
+test("a target CPU level reaches every function attribute group and nothing else", () => {
+ const module = moduleFor([call()]);
+ const plain = emitLlvmModule(module);
+ expect(plain).not.toContain("target-cpu");
+ const v3 = emitLlvmModule(module, { targetCpu: "x86-64-v3" });
+ expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m);
+ expect(v3.replaceAll(' "target-cpu"="x86-64-v3"', "")).toBe(plain);
+ expect(emitLlvmModule(module, { targetCpu: "baseline" })).toBe(plain);
+});
+
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 });
@@ -287,7 +296,7 @@ test("shared exceptional exits preserve the function's scalar return ABI", () =>
});
test("exceptional exits use each scalar kind's LLVM return type", () => {
- for (const type of [F64, DATE_T, BOOL, PROCSTREAM_T]) {
+ for (const type of [F64, BOOL, PROCSTREAM_T]) {
for (const ownedLocals of [false, true]) {
const module = moduleFor([call(), call(), { kind: "throw", value: literal("failed"), loc }]);
const work = module.functions[1]!;
@@ -499,6 +508,27 @@ test("class brand probes compare the program's key constant before the key text"
const named = work(probe("dyn.typedRefIs"), dyn);
expect(named).toContain("; ->v.typed_ref.type_key");
expect(named).toContain("@scr_dyn_typed_ref_is_key");
+ // Another key pointer is rejected on its length and last eight bytes
+ // ("Example" plus the prefix's ':') before the out-of-line comparison.
+ expect(named).toContain("; ->v.typed_ref.type_key_len");
+ expect(named).toContain("and i32 %t");
+ expect(named).toMatch(/icmp eq i32 %t\d+, 14\b/);
+ const tail = Buffer.from("object:Example").readBigInt64LE(6);
+ expect(named).toContain(`icmp eq i64 %t`);
+ expect(named).toContain(`, ${tail}`);
+ const shortKey: IrExpr = { kind: "strLit", value: "o:E", type: STRING, loc: receiverLoc };
+ const short = work(
+ {
+ kind: "libCall",
+ fn: "dyn.typedRefIs",
+ args: [ref(dyn), shortKey],
+ type: BOOL,
+ loc: receiverLoc,
+ },
+ dyn,
+ );
+ expect(short).toMatch(/icmp eq i32 %t\d+, 3\b/);
+ expect(short).not.toContain("load i64");
// The key-pointer test answers false for capsules boxed elsewhere.
const exact = work(probe("dyn.typedRefKeyIs"), dyn);
expect(exact).toContain("; ->v.typed_ref.type_key");
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index 050c3fb90..6760f3b74 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -1,3 +1,4 @@
+import type { TargetCpu } from "../target-cpu.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
@@ -60,7 +61,7 @@ import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion } from "./bytes-loop-versions.js";
import { globalSlotAlias } from "./bytes-alias.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
materializeSplitPiece,
type SplitSpan,
@@ -68,7 +69,7 @@ import {
type StringSplit,
} from "./split-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
-import { addRecordResultEntries } from "./record-results.js";
+import { addRecordResultEntries, resultEntryName } from "./record-results.js";
import { specializeCallbackCalls } from "../../ir/callback-specialization.js";
import {
type ChunkResult,
@@ -82,6 +83,7 @@ import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import {
allocatedLiteral,
fillAllocationLength,
+ int32ArrayKey,
selectInt32Arrays,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
@@ -185,7 +187,7 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
-import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js";
+import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -481,6 +483,10 @@ export interface LlvmTargetOptions {
* store the instance's address over 8 in 32 bits (classes.ts
* compactRefLoadLines). Elsewhere they keep 8-byte pointers. */
lowRegion?: boolean;
+ /** The x86-64 level the program's code may require (`--target-cpu`):
+ * every emitted function carries it as its `target-cpu`. Unset, code
+ * runs on any x86-64 CPU. */
+ targetCpu?: TargetCpu;
}
/** LlvmTargetOptions.lowRegion for an executable: Linux targets without
@@ -510,6 +516,15 @@ function provenInt32Arrays(mod: IrModule): ReadonlySet {
return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]);
}
+/** The functions whose calls the backend may emit as calls of their
+ * result-slot entries (record-results.ts). */
+function resultEntryTargets(mod: IrModule): ReadonlySet {
+ const names = new Set(mod.functions.map((fn) => fn.name));
+ const targets = new Set();
+ for (const name of names) if (names.has(resultEntryName(name))) targets.add(name);
+ return targets;
+}
+
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
@@ -545,6 +560,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
),
),
provenInt32Arrays,
+ resultEntryTargets,
)
: mod,
options,
@@ -781,7 +797,7 @@ export class LlEmitter {
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind !== "varDecl" && s.kind !== "assign") return true;
- const key = `${fn.name}\0${s.localId}`;
+ const key = int32ArrayKey(fn.name, s.localId);
const checked = checkedInt32Arrays.has(key);
if (!checked && !int32Arrays.has(key)) return true;
const value = s.kind === "varDecl" ? s.init : s.value;
@@ -799,15 +815,16 @@ export class LlEmitter {
});
}
- /** Whether `arr` is one of the current function's int32 arrays
- * (ir/int32-arrays.ts, kept by the int32 slot analysis). */
+ /** Whether `arr` reads one of the int32 arrays (ir/int32-arrays.ts, kept
+ * by the int32 slot analysis): a local or parameter of the current
+ * function, or a module global. */
int32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef") return false;
if (this.int32KindArray(arr)) return true;
if (this.int32Slots.int32Arrays.size === 0) return false;
return (
this.int32ArrayTemps.has(arr.localId) ||
- this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`)
+ this.int32Slots.int32Arrays.has(int32ArrayKey(this.currentFunctionName, arr.localId))
);
}
@@ -974,8 +991,8 @@ export class LlEmitter {
}
/** Whether an element access at `index` of `arr` lies in the hole-free
- * prefix: the counter of a dense array loop's fast copy indexing one of
- * the arrays its entry test covered. */
+ * prefix: a counter of a dense array loop's fast copy indexing one of the
+ * arrays its entry test covered. */
provenDenseIndex(arr: IrExpr, index: IrExpr): boolean {
const loop = LOOPS.of(this).denseLoop;
return (
@@ -983,8 +1000,8 @@ export class LlEmitter {
arr.kind === "varRef" &&
loop.arrays.has(arr.localId) &&
index.kind === "varRef" &&
- index.localId === loop.counter &&
- this.integerLoopBindings.has(loop.counter)
+ (index.localId === loop.counter || index.localId === loop.pair?.high) &&
+ this.integerLoopBindings.has(index.localId)
);
}
@@ -1036,6 +1053,7 @@ export class LlEmitter {
readonly emitLibraryIdentity: boolean;
readonly runtimeAbiMarker: boolean;
readonly detachedExit: boolean;
+ readonly targetCpu: TargetCpu | undefined;
readonly wasmBoundsChecks: boolean;
/** Interned string literals: UTF-8 text → { symbol, byte length } —
* first-use order, the runtime ABI’s determinism discipline. */
@@ -1313,7 +1331,7 @@ export class LlEmitter {
/** Class objects (classes as first-class values): className → the
* interned .name literal ref — registered during body emission, the
* statics and construct thunks assemble around the bodies. */
- readonly classObjs = new Map();
+ readonly classObjs = new Map();
private readonly unionFieldReadGroups = new Map();
/** Preorder intervals of the runtime error classes under THIS module's
* class-forest numbering (main() stamps scr_error_vts with them, exactly
@@ -1618,6 +1636,7 @@ export class LlEmitter {
this.emitLibraryIdentity = options.emitLibraryIdentity !== false;
this.runtimeAbiMarker = options.runtimeAbiMarker === true;
this.detachedExit = options.detachedExit === true;
+ this.targetCpu = options.targetCpu;
this.wasmBoundsChecks =
options.wasmBoundsChecks === true ||
wasmBoundsChecks({
@@ -1762,6 +1781,7 @@ export class LlEmitter {
const shape = this.recordsById.get(shapeId);
return shape !== undefined && !recordDynKeepsIdentity(shape);
},
+ (wrap) => this.borrowableNullableWrap(wrap) === "ref",
);
this.lentStringParameters =
this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map();
@@ -2032,7 +2052,6 @@ export class LlEmitter {
if (POINTER_KINDS.has(t.kind)) return "ptr";
switch (t.kind) {
case "f64":
- case "date":
return "double";
case "bool":
return "i1";
@@ -2165,7 +2184,6 @@ export class LlEmitter {
this.declare(`declare void @scr_promise_fulfill_void(ptr)`);
return [` call void @scr_promise_fulfill_void(ptr ${promise})`];
case "f64":
- case "date":
this.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
return [` call void @scr_promise_fulfill_f64(ptr ${promise}, double %r)`];
case "bool":
@@ -2395,7 +2413,7 @@ export class LlEmitter {
return { name: "", type };
}
const t = B.tmp();
- if (type.kind === "f64" || type.kind === "date") {
+ if (type.kind === "f64") {
this.declare(`declare double @scr_coro_direct_f64(ptr)`);
B.line(`${t} = call double @scr_coro_direct_f64(ptr ${record})`);
} else if (type.kind === "bool") {
@@ -3791,8 +3809,17 @@ export class LlEmitter {
/** A union value's tag (i32): the box's tag word, or the NULL test of a
* nullable union's pointer. */
+ /** A box's tag and payload words are written only where the box is
+ * built (runtime constructors and untagged stack stores), so their loads
+ * carry their own alias tags: element and field stores cannot change
+ * them, and loops keep them hoisted. */
unionTag(uName: string, unionId: string): string {
- return emitUnionTag(this.B, this.nullableUnions.get(unionId), uName);
+ return emitUnionTag(
+ this.B,
+ this.nullableUnions.get(unionId),
+ uName,
+ this.memoryAliasAttachment("union.tag"),
+ );
}
/** Guard for helpers that build or read a union's `%ScrUnion` box
@@ -3808,7 +3835,12 @@ export class LlEmitter {
/** The BORROWED payload pointer of a ref arm (scr_union_peek inlined —
* the runtime's is a static inline); a nullable union is its payload. */
unionPeek(uName: string, unionId: string): string {
- return emitUnionPeek(this.B, this.nullableUnions.get(unionId), uName);
+ return emitUnionPeek(
+ this.B,
+ this.nullableUnions.get(unionId),
+ uName,
+ this.memoryAliasAttachment("union.payload"),
+ );
}
/** Emits `switch` over a union's tag with one block per arm; each arm
@@ -4037,7 +4069,12 @@ export class LlEmitter {
classObjSym(className: string): string {
if (!this.classObjs.has(className)) {
const meta = this.classMetaOf(className);
- this.classObjs.set(className, { nameSym: this.internLiteral(classJsName(meta.def)) });
+ // util.inspect prints `[class C extends B]` with the base's name.
+ const baseName = meta.base ? classJsName(meta.base.def) : "";
+ this.classObjs.set(className, {
+ nameSym: this.internLiteral(classJsName(meta.def)),
+ baseNameSym: baseName ? this.internLiteral(baseName) : null,
+ });
}
this.journal?.entry("classObj", className, this.classObjs.get(className));
return mangleClassObj(className);
@@ -4251,6 +4288,7 @@ export class LlEmitter {
matchBytesLoopVersion(
s: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
+ paired: IntegerPairedForLoop | null = null,
): ReturnType {
const fn = LOOPS.of(this).bytesLoopFunction;
if (!fn) return null;
@@ -4260,17 +4298,22 @@ export class LlEmitter {
const invariantGlobals = new Set(this.stableGlobals);
for (const global of this.initializerBindings.get(fn.name) ?? [])
invariantGlobals.add(global.id);
- return matchBytesLoopVersion(s, counted, {
- locals: this.currentLocals,
- invariantGlobals,
- captured: this.captureIds,
- ranges: this.integerRanges,
- initialized,
- proven: this.bytesBounds,
- workers: this.mod.workers === true,
- owned: this.bytesOwned,
- scalarStrings: new Set(this.stringSlices.keys()),
- });
+ return matchBytesLoopVersion(
+ s,
+ counted,
+ {
+ locals: this.currentLocals,
+ invariantGlobals,
+ captured: this.captureIds,
+ ranges: this.integerRanges,
+ initialized,
+ proven: this.bytesBounds,
+ workers: this.mod.workers === true,
+ owned: this.bytesOwned,
+ scalarStrings: new Set(this.stringSlices.keys()),
+ },
+ paired,
+ );
}
/** The alias tag of a whole-slot load of a binding (module globals only). */
@@ -4897,7 +4940,7 @@ export class LlEmitter {
return;
}
case "classObj":
- addEntry(this.classObjs, key, value as { nameSym: string });
+ addEntry(this.classObjs, key, value as { nameSym: string; baseNameSym: string | null });
return;
case "retainWrapper":
addEntry(this.nullableUnions.retainWrappers, key, value as string);
@@ -6279,6 +6322,41 @@ export class LlEmitter {
return ok;
}
+ /** Branches on "the array `arr` is frozen" (SCR_ARR_FROZEN in its storage
+ * flags, scr_array.h), or published too when `published`; like
+ * emitPublishedTest, leaves the builder in the cold block and answers the
+ * continuation label. */
+ private emitFrozenArrayTest(arr: string, published: boolean): string {
+ const B = this.B;
+ const sparep = B.tmp(),
+ spare = B.tmp(),
+ bit = B.tmp();
+ let frozen = B.tmp();
+ B.line(
+ `${sparep} = getelementptr inbounds %ScrArr, ptr ${arr}, i32 0, i32 ${SCR_ARR_FIELD.spare}`,
+ );
+ B.line(`${spare} = load i16, ptr ${sparep}`);
+ B.line(`${bit} = and i16 ${spare}, ${SCR_ARR_FROZEN}`);
+ B.line(`${frozen} = icmp ne i16 ${bit}, 0`);
+ if (published) {
+ const rc = B.tmp(),
+ immortal = B.tmp(),
+ either = B.tmp();
+ B.line(`${rc} = load ${this.sizeType}, ptr ${arr}`);
+ B.line(`${immortal} = icmp eq ${this.sizeType} ${rc}, -1`);
+ B.line(`${either} = or i1 ${frozen}, ${immortal}`);
+ frozen = either;
+ }
+ this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
+ const expected = B.tmp();
+ B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`);
+ const cold = B.newLabel("arr.frozen"),
+ ok = B.newLabel("arr.ok");
+ B.condBr(expected, cold, ok);
+ B.startBlock(cold);
+ return ok;
+ }
+
private publishedNamesSym: string | null = null;
/** The class-name table the hierarchy field guards index by preorder. */
@@ -6343,15 +6421,29 @@ export class LlEmitter {
op: number,
index = f64Lit(0),
count = f64Lit(0),
+ third: string | null = null,
): void {
- if (this.publishedTypes?.containerGuarded(arrType) !== true) return;
+ const published = this.publishedTypes;
+ if (published?.containerGuarded(arrType) !== true) return;
const B = this.B;
- const ok = this.emitPublishedTest(arr);
- this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
+ const guards = published.arrayGuards(arrType);
+ const ok = guards.frozen
+ ? this.emitFrozenArrayTest(arr, guards.published)
+ : this.emitPublishedTest(arr);
const threw = B.tmp();
- B.line(
- `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
- );
+ if (third === null) {
+ this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
+ B.line(
+ `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
+ );
+ } else {
+ this.declare(
+ `declare zeroext i1 @scr_throw_published_array_range(i32, ptr, double, double, double)`,
+ );
+ B.line(
+ `${threw} = call zeroext i1 @scr_throw_published_array_range(i32 ${op}, ptr ${arr}, double ${index}, double ${count}, double ${third})`,
+ );
+ }
const unwind = B.newLabel("pub.throw");
B.condBr(threw, unwind, ok);
B.startBlock(unwind);
diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts
index b5d765b16..ee9ff2983 100644
--- a/packages/compiler/src/backend/llvm/expr-async.ts
+++ b/packages/compiler/src/backend/llvm/expr-async.ts
@@ -184,7 +184,7 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic
}
const v = host.emitExpr(e.args[0]!);
const t = e.args[0]!.type;
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
B.line(`call void @scr_promise_fulfill_f64(ptr ${p}, double ${v.name})`);
} else if (t.kind === "bool") {
@@ -500,7 +500,7 @@ export function emitAsyncExpr(
host.declare(`declare void @scr_async_gen_hop_done()`);
B.line(`call void @scr_async_gen_hop_done()`);
}
- if (yt.kind === "f64" || yt.kind === "date") {
+ if (yt.kind === "f64") {
if (host.currentCoro !== null) {
const coro = host.currentCoro;
if (coro.out !== null) host.emitGenSlotStore(coro.out, "f64", v.name);
@@ -582,7 +582,7 @@ export function emitAsyncExpr(
return { name: "", type: e.type };
}
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
host.declare(`declare double @scr_gen_take_in_f64()`);
B.line(`${t} = call double @scr_gen_take_in_f64()`);
return { name: t, type: e.type };
@@ -710,12 +710,12 @@ export function emitAsyncExpr(
throw new InternalCompilerError(`llvm emitter bug: genResume ${e.mode} on async`);
const gb = emitBorrowedInput(host, e.gen);
if (e.mode === "value") {
- if (!host.wasi && (e.type.kind === "f64" || e.type.kind === "date"))
+ if (!host.wasi && e.type.kind === "f64")
return { name: host.emitGenSlotTake(host.genOutSlot(gb.name), "f64"), type: e.type };
if (!host.wasi && e.type.kind === "bool")
return { name: host.emitGenSlotTake(host.genOutSlot(gb.name), "bool"), type: e.type };
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
host.declare(`declare double @scr_gen_take_out_f64(ptr)`);
B.line(`${t} = call double @scr_gen_take_out_f64(ptr ${gb.name})`);
return { name: t, type: e.type };
@@ -752,7 +752,7 @@ export function emitAsyncExpr(
store(a.name, e.arg!.type);
};
const parkIn = (name: string, t: IrType): void => {
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_gen_in_f64(ptr, double)`);
B.line(`call void @scr_gen_in_f64(ptr ${g.name}, double ${name})`);
} else if (t.kind === "bool") {
@@ -787,7 +787,7 @@ export function emitAsyncExpr(
const a = host.emitExpr(e.arg);
const t = e.arg.type;
if (isRefCounted(t)) host.toHeap(a);
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare ptr @scr_async_gen_next_f64(ptr, double)`);
call = `call ptr @scr_async_gen_next_f64(ptr ${g.name}, double ${a.name})`;
} else if (t.kind === "bool") {
@@ -806,7 +806,7 @@ export function emitAsyncExpr(
const a = host.emitExpr(e.arg);
const t = e.arg.type;
if (isRefCounted(t)) host.toHeap(a);
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare ptr @scr_async_gen_return_f64(ptr, double)`);
call = `call ptr @scr_async_gen_return_f64(ptr ${g.name}, double ${a.name})`;
} else if (t.kind === "bool") {
@@ -849,7 +849,7 @@ export function emitAsyncExpr(
B.line(`call void @scr_gen_ret_none(ptr ${g.name})`);
} else {
sendArg((name, t) => {
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_gen_ret_f64(ptr, double)`);
B.line(`call void @scr_gen_ret_f64(ptr ${g.name}, double ${name})`);
} else if (t.kind === "bool") {
@@ -960,7 +960,7 @@ export function emitAsyncExpr(
return { name: "", type: e.type };
}
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
const f64Await = host.awaitResultFn("f64");
host.declare(`declare double @${f64Await}(ptr)`);
B.line(`${t} = call double @${f64Await}(ptr ${pr.name})`);
@@ -1050,7 +1050,7 @@ export function emitAsyncExpr(
const peek = host.unionPeek(u.name, def.id);
if (host.coroAwait) host.emitCoroAwait(peek);
let awaited: LlValue;
- if (inner.kind === "f64" || inner.kind === "date") {
+ if (inner.kind === "f64") {
const f64Await = host.awaitResultFn("f64");
host.declare(`declare double @${f64Await}(ptr)`);
const x = B.tmp();
@@ -1136,7 +1136,6 @@ export function emitAsyncExpr(
let resolve: string;
const kindNums: Partial> = {
f64: 0,
- date: 0,
bool: 1,
string: 2,
void: 3,
@@ -1191,7 +1190,6 @@ export function emitAsyncExpr(
B.line(`${p} = call ptr @scr_promise_new()`);
const kindNums: Partial> = {
f64: 0,
- date: 0,
bool: 1,
string: 2,
void: 3,
diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts
index 41ddf2b9d..23025d00c 100644
--- a/packages/compiler/src/backend/llvm/expr-bytes.ts
+++ b/packages/compiler/src/backend/llvm/expr-bytes.ts
@@ -4,7 +4,7 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js";
-import { exactInteger, widenInteger } from "./integer-values.js";
+import { alwaysExactInteger, exactInteger } from "./integer-values.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { emitByteNumber } from "./byte-numbers.js";
import { integerOrNaNIndex, integerOrNaNRange } from "./wrapping-sums.js";
@@ -86,17 +86,18 @@ export function emitBytesIndex(
): string {
const B = host.B;
const index = value.name;
- const integer = exactInteger(host, value, expr);
- if (inBounds && integer) {
- const wide = widenInteger(host, integer);
- if (host.sizeType === "i64") return wide;
+ // The exact integer, or a wide view that always equals the double (such
+ // as `i + 1` of a speculated counter).
+ const exact = alwaysExactInteger(host, value, exactInteger(host, value, expr));
+ if (inBounds && exact !== null) {
+ if (host.sizeType === "i64") return exact;
const narrow = B.tmp();
- B.line(`${narrow} = trunc i64 ${wide} to ${host.sizeType}`);
+ B.line(`${narrow} = trunc i64 ${exact} to ${host.sizeType}`);
return narrow;
}
const len = emitBytesHeaderLength(host, receiver);
- if (integer) {
- const wideIndex = widenInteger(host, integer);
+ if (exact !== null) {
+ const wideIndex = exact;
const wideLength = host.sizeType === "i64" ? len : B.tmp();
if (host.sizeType !== "i64") B.line(`${wideLength} = zext ${host.sizeType} ${len} to i64`);
const inRange = B.tmp();
@@ -936,6 +937,23 @@ export function emitBytesIntrinsic(
B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`);
return { name: t, type: e.type };
}
+ case "indexOfElem":
+ case "lastIndexOfElem":
+ return call(
+ "scr_bytes_index_of_elem",
+ "double (ptr, double, double, i1 zeroext)",
+ `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]!.name}, i1 ${method === "lastIndexOfElem"}`,
+ false,
+ false,
+ );
+ case "includesElem":
+ return call(
+ "scr_bytes_includes_elem",
+ "zeroext i1 (ptr, double, double)",
+ `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]!.name}`,
+ false,
+ false,
+ );
case "fillElem":
// Per-element TypedArray fill (non-u8): slice-style index
// defaults, never throws; the receiver comes back +1.
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index a4e7cde2c..6ff52619e 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -44,6 +44,7 @@ import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
+import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { emitCallArrayRead } from "./local-array-reads.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { emitStackMapRead } from "./map-read-lifetimes.js";
@@ -166,6 +167,9 @@ export function emitCallExpr(
return { name: host.emitOwnedNullableStack(a).box, type: a.type };
// An inline union field read copies its tag and bits.
if (host.inlineFieldGet(a)) return host.emitReadReceiver(a);
+ // A scalar keyed record read writes its tag and bits.
+ if (a.kind === "recordKeyGet" && scalarRecordKeyGet(host, a))
+ return { name: emitStackRecordKeyGet(host, a), type: a.type };
}
if (inputs[index]) return host.emitReadReceiver(a);
}
diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts
index 1ba5da818..279b8c044 100644
--- a/packages/compiler/src/backend/llvm/expr-containers.ts
+++ b/packages/compiler/src/backend/llvm/expr-containers.ts
@@ -257,6 +257,16 @@ function emitDiscarded(host: LlvmEmitterContext, e: IrExpr): void {
const start = host.emitExpr(e.args[0]!);
const count = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
const values = items.map((item) => host.emitExpr(item));
+ // Before the values move into the buffer: a throw releases them with the
+ // frame.
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 10,
+ start.name,
+ count,
+ f64Lit(values.length),
+ );
if (acc === "ref") values.forEach((value) => host.toHeap(value));
let buffer = "null";
if (values.length > 0) {
@@ -904,6 +914,12 @@ export function emitArrIntrinsic(
B.line(`${t} = call ptr @${helper}(ptr ${r.name}, ptr ${sep.name})`);
return host.own({ name: t, type: e.type });
}
+ if (elem.kind === "date") {
+ host.declare(`declare ptr @scr_date_arr_join(ptr, ptr)`);
+ const t = B.tmp();
+ B.line(`${t} = call ptr @scr_date_arr_join(ptr ${r.name}, ptr ${sep.name})`);
+ return host.own({ name: t, type: e.type });
+ }
host.declare(`declare ptr @scr_arr_join(ptr, ptr)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_arr_join(ptr ${r.name}, ptr ${sep.name})`);
@@ -928,6 +944,16 @@ export function emitArrIntrinsic(
case "sortPrimitive":
case "toSortedPrimitive": {
if (e.method === "sortPrimitive") host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ if (elem.kind === "date") {
+ // Dates order by their toString() text.
+ host.declare(`declare ptr @scr_arr_sort_by_text(ptr, i1 zeroext, ptr)`);
+ host.declare(`declare ptr @scr_date_object_to_string(ptr)`);
+ const sorted = B.tmp();
+ B.line(
+ `${sorted} = call ptr @scr_arr_sort_by_text(ptr ${r.name}, i1 ${e.method === "toSortedPrimitive" ? 1 : 0}, ptr @scr_date_object_to_string)`,
+ );
+ return host.own({ name: sorted, type: e.type });
+ }
host.declare(`declare ptr @scr_arr_sort_primitive(ptr, i1 zeroext)`);
const result = B.tmp();
B.line(
@@ -978,7 +1004,25 @@ export function emitArrIntrinsic(
case "fill":
case "fillUndefined": {
const args = e.args.map((arg) => host.emitExpr(arg));
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ // fill's range is its last two arguments; copyWithin's target,
+ // start and end are its three.
+ if (e.method === "copyWithin")
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 9,
+ args[0]!.name,
+ args[1]!.name,
+ args[2]!.name,
+ );
+ else
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 8,
+ args[args.length - 2]!.name,
+ args[args.length - 1]!.name,
+ );
if (host.checkedFills.has(e)) {
// A new int32 array with checked stores, filled with a value that
// may not be an int32: widen it first then.
@@ -1042,7 +1086,7 @@ export function emitArrIntrinsic(
// removes to the end (+Infinity, the slice convention).
const start = host.emitExpr(e.args[0]!);
const cnt = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, cnt, f64Lit(0));
host.declare(`declare ptr @scr_arr_splice(ptr, double, double)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_arr_splice(ptr ${r.name}, double ${start.name}, double ${cnt})`);
@@ -1052,7 +1096,12 @@ export function emitArrIntrinsic(
const start = host.emitExpr(e.args[0]!);
const count = host.emitExpr(e.args[1]!);
const items = emitBorrowedInput(host, e.args[2]!);
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ if (host.publishedTypes?.containerGuarded(e.receiver.type) === true) {
+ host.declare(`declare double @scr_arr_len(ptr)`);
+ const n = B.tmp();
+ B.line(`${n} = call double @scr_arr_len(ptr ${items.name})`);
+ host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, count.name, n);
+ }
host.declare(`declare ptr @scr_arr_splice_insert(ptr, double, double, ptr)`);
const t = B.tmp();
B.line(
diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts
index 07a538184..26905a7ef 100644
--- a/packages/compiler/src/backend/llvm/expr-dynamic.ts
+++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts
@@ -18,6 +18,7 @@ import {
type IrExpr,
type IrType,
isDynTypedRefType,
+ textCodecRecordClass,
isRefCounted,
isUnitType,
typeEquals,
@@ -86,7 +87,10 @@ export function emitDynamicExpr(
// Bytes already box their shared mutable storage directly. A second
// capsule would split identity between checked and native views.
const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className);
- if (!errorRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
+ // A TextEncoder or TextDecoder record boxes as its own handle, which
+ // keeps the record's identity (toDynHelper).
+ const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null;
+ if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
if (v.type.kind === "union") {
const adapter = host.liveDynUnionRefAdapter(v.type);
const boxed = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts
index 1eec6a965..ccbe3edcc 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.ts
@@ -13,6 +13,7 @@ import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import {
+ alwaysExactInteger,
exactInteger,
int32Number,
integerNumber,
@@ -45,6 +46,17 @@ import {
type ArrayAccess,
} from "./dense-array-access.js";
import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js";
+import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js";
+
+/** The signed integer predicates of the number compares. */
+const INTEGER_COMPARES: Record = {
+ "<": "slt",
+ "<=": "sle",
+ ">": "sgt",
+ ">=": "sge",
+ "===": "eq",
+ "!==": "ne",
+};
export function emitLiteralExpr(
host: LlvmEmitterContext,
@@ -318,15 +330,7 @@ export function emitOperatorExpr(
if (integer) return integer;
}
if (cmp[e.op] !== undefined) {
- const integerCmp: Record = {
- "<": "slt",
- "<=": "sle",
- ">": "sgt",
- ">=": "sge",
- "===": "eq",
- "!==": "ne",
- };
- B.line(`${t} = icmp ${integerCmp[e.op]} i64 ${left}, ${right}`);
+ B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`);
return { name: t, type: e.type };
}
}
@@ -345,6 +349,8 @@ export function emitOperatorExpr(
: emitCheckedRemainder(host, l, r, li, ri);
if (checked) return checked;
}
+ if (e.op === "/" && isPowerOfTwoCall(e.right))
+ return { name: emitPowerOfTwoQuotient(host, l.name, r.name), type: e.type };
if (arith[e.op] !== undefined) {
B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`);
const uint32 = emitWrappingSum(host, e, l, r, li, ri);
@@ -356,7 +362,17 @@ export function emitOperatorExpr(
if (wideInteger) value.wideInteger = wideInteger;
return value;
}
- } else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
+ } else {
+ // An operand with only a wide view, such as `i + 1` of a speculated
+ // counter: compare the integers when both always equal the doubles.
+ const left = alwaysExactInteger(host, l, li),
+ right = left === null ? null : alwaysExactInteger(host, r, ri);
+ if (left !== null && right !== null) {
+ B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`);
+ return { name: t, type: e.type };
+ }
+ B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
+ }
} else if (bit[e.op] !== undefined) {
if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") {
throw new LlvmUnsupportedError(
@@ -1076,9 +1092,10 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayState on non-array");
+ const proven = host.provenDenseIndex(e.arr, e.index);
const t = host.checkedInt32Array(e.arr)
? host.emitByElementKind(arr.name, "double", (kind) =>
- emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind)),
+ emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind), proven),
)!
: emitDenseArrayState(
host,
@@ -1087,6 +1104,7 @@ export function emitContainerExpr(
e.index,
"state",
host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem),
+ proven,
);
return { name: t, type: e.type };
}
diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts
index bca728e9d..59ab625e5 100644
--- a/packages/compiler/src/backend/llvm/expr-records.ts
+++ b/packages/compiler/src/backend/llvm/expr-records.ts
@@ -7,6 +7,7 @@ import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { f64Lit, llvmCommentText } from "./common.js";
import { borrowableInputs, emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js";
import { regexLiteralTemplate } from "./expr-primitives.js";
+import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
import { preservesRegexInputs } from "./reference-effects.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
@@ -365,6 +366,114 @@ export function emitRecordKeyGet(
return host.own({ name: t, type: e.type });
}
+/** A keyed read whose result is a scalar union (`r[k]` on a
+ * `Record`, at `number | undefined`) and whose every hit
+ * is a number or boolean arm of it, or an inline field of the same union.
+ * Projections (`??`, `!`, tag tests) read it as a private stack box, so
+ * the read allocates nothing. */
+export function scalarRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolean {
+ if (e.kind !== "recordKeyGet" || e.missNaN === true || e.type.kind !== "union") return false;
+ const resultType = e.type;
+ const def = host.unionsById.get(resultType.unionId);
+ if (!isScalarUnionDef(def) || undefinedArmTag(resultType, host.unionsById) < 0) return false;
+ const isArm = (t: IrType): boolean =>
+ (t.kind === "f64" || t.kind === "bool") && def!.arms.some((a) => typeEquals(a, t));
+ const shape = host.recordShape(e.shapeId);
+ if (e.overflowOnly !== true) {
+ for (const f of shape.fields) {
+ const inline = host.recordFieldInline(e.shapeId, f.name);
+ if (!isArm(f.type) && !(inline && typeEquals(f.type, resultType))) return false;
+ }
+ }
+ return shape.indexValue === undefined || isArm(shape.indexValue);
+}
+
+/** A scalarRecordKeyGet read as a private stack box: the tag and bits of
+ * the declared field or overflow entry the key names, or the undefined arm
+ * for a miss. The box must never reach an RC entry point. */
+export function emitStackRecordKeyGet(
+ host: LlvmEmitterContext,
+ e: IrExpr & { kind: "recordKeyGet" },
+): string {
+ const B = host.B;
+ if (e.type.kind !== "union")
+ throw new InternalCompilerError("llvm emitter bug: stack keyed read of a non-union");
+ const resultType = e.type;
+ const def = host.unionsById.get(resultType.unionId)!;
+ const armTag = (t: IrType): number => def.arms.findIndex((a) => typeEquals(a, t));
+ const obj = host.emitExpr(e.obj);
+ const key = host.emitExpr(e.key);
+ const box = B.slot();
+ B.entryAllocas.push(`${box} = alloca %ScrUnion`);
+ const tagPtr = B.tmp(),
+ bitsPtr = B.tmp();
+ B.line(`store ${host.sizeType} -1, ptr ${box}`);
+ B.line(`${tagPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 1`);
+ B.line(`${bitsPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 5`);
+ const join = B.newLabel("rks.j");
+ const storeScalar = (t: IrType, value: string): void => {
+ const bits = B.tmp();
+ if (t.kind === "f64") B.line(`${bits} = bitcast double ${value} to i64`);
+ else B.line(`${bits} = zext i1 ${value} to i64`);
+ B.line(`store i32 ${armTag(t)}, ptr ${tagPtr}`);
+ B.line(`store i64 ${bits}, ptr ${bitsPtr}`);
+ };
+ const shape = host.recordShape(e.shapeId);
+ for (const f of e.overflowOnly === true ? [] : shape.fields) {
+ const hit = B.tmp();
+ B.line(
+ `${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`,
+ );
+ const lh = B.newLabel("rks.h");
+ const ln = B.newLabel("rks.n");
+ B.condBr(hit, lh, ln);
+ B.startBlock(lh);
+ const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, f.name);
+ if (inline) {
+ const { tag, bits } = loadInlineUnion(B, ptr);
+ const present = presentBits(B, tag, bits, undefinedArmTag(type, host.unionsById));
+ B.line(`store i32 ${tag}, ptr ${tagPtr}`);
+ B.line(`store i64 ${present}, ptr ${bitsPtr}`);
+ } else {
+ storeScalar(type, host.loadRecordField(ptr, type, false));
+ }
+ B.br(join);
+ B.startBlock(ln);
+ }
+ const iv = shape.indexValue;
+ if (iv) {
+ const ovf = host.recordOvfPtr(obj.name, e.shapeId);
+ const outTy = iv.kind === "f64" ? "double" : "i8";
+ const outSlot = B.slot();
+ B.entryAllocas.push(`${outSlot} = alloca ${outTy}`);
+ B.line(`store ${outTy} ${iv.kind === "f64" ? f64Lit(0) : "0"}, ptr ${outSlot}`);
+ const getter = `scr_map_get_str_${iv.kind === "f64" ? "f64" : "bool"}`;
+ host.declare(`declare zeroext i1 @${getter}(ptr, ptr, ptr)`);
+ const found = B.tmp();
+ B.line(`${found} = call zeroext i1 @${getter}(ptr ${ovf}, ptr ${key.name}, ptr ${outSlot})`);
+ const lh = B.newLabel("rks.h");
+ const ln = B.newLabel("rks.n");
+ B.condBr(found, lh, ln);
+ B.startBlock(lh);
+ const raw = B.tmp();
+ B.line(`${raw} = load ${outTy}, ptr ${outSlot}`);
+ let value = raw;
+ if (iv.kind === "bool") {
+ value = B.tmp();
+ B.line(`${value} = trunc i8 ${raw} to i1`);
+ }
+ storeScalar(iv, value);
+ B.br(join);
+ B.startBlock(ln);
+ }
+ // A miss is the undefined arm.
+ B.line(`store i32 ${undefinedArmTag(resultType, host.unionsById)}, ptr ${tagPtr}`);
+ B.line(`store i64 0, ptr ${bitsPtr}`);
+ B.br(join);
+ B.startBlock(join);
+ return box;
+}
+
export function keyedRecordReadInto(
host: LlvmEmitterContext,
slot: string,
diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
index aee7be180..675932d47 100644
--- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
+++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
@@ -16,9 +16,11 @@ import {
KEY_ORDER_FIELD,
shapeKeyOrders,
isDynTypedRefType,
+ textCodecRecordClass,
isRefCounted,
shapeHasAccessorSlots,
typeKey,
+ type IrClassDef,
} from "../../ir/ir.js";
import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
@@ -121,12 +123,7 @@ export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): stri
if (inner.kind === "void") {
host.declare(`declare void @scr_promise_fulfill_void(ptr)`);
B.line(`call void @scr_promise_fulfill_void(ptr %dst)`);
- } else if (
- inner.kind === "f64" ||
- inner.kind === "date" ||
- inner.kind === "procStream" ||
- inner.kind === "bool"
- ) {
+ } else if (inner.kind === "f64" || inner.kind === "procStream" || inner.kind === "bool") {
const fn = inner.kind !== "bool" ? "scr_promise_fulfill_f64" : "scr_promise_fulfill_bool";
host.declare(
`declare void @${fn}(ptr, ${host.llType(inner)}${inner.kind === "bool" ? " zeroext" : ""})`,
@@ -276,9 +273,7 @@ export function streamTypedRefCommitAdapter(
);
});
}
- const symbols = new Map(
- (meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol.globalId]),
- );
+ const symbols = new Map((meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol]));
const fields = meta.def.fields.filter(
(field) =>
(isClassOwnEnumerableFieldName(field.name) || symbols.has(field.name)) &&
@@ -298,13 +293,15 @@ export function streamTypedRefCommitAdapter(
const inline = host.inlineUnions.classField(t.className, field.name);
const fieldTy = int32 ? "i32" : llFieldType(field.type, inline);
const checkTy = host.llType(field.type);
- const symbolGlobal = symbols.get(field.name);
- if (symbolGlobal) {
+ const symbolKey = symbols.get(field.name);
+ if (symbolKey) {
host.declare(`declare ptr @scr_dyn_symbol_key_get(ptr, ptr, i1 zeroext)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
+ const keyLines = symbolKeyLines(host, symbolKey, `f${index}_keyraw`);
lines.push(
- ` %f${index}_keyraw = load ptr, ptr @${mangleGlobal(symbolGlobal)}`,
+ ...keyLines.load,
` %f${index}_key = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %f${index}_keyraw)`,
+ ...keyLines.release,
);
if (field.type.kind === "symbol") {
// A base constructor can mutate the instance before this
@@ -332,7 +329,7 @@ export function streamTypedRefCommitAdapter(
lines.push(
` %${raw} = call ptr @scr_dyn_obj_get(ptr %d, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`,
);
- if (meta.def.tracksOwnFields && !symbolGlobal)
+ if (meta.def.tracksOwnFields && !symbolKey)
lines.push(
` %f${index}_present = icmp ne ptr %${raw}, null`,
` br i1 %f${index}_present, label %f${index}_read, label %${after}`,
@@ -343,7 +340,7 @@ export function streamTypedRefCommitAdapter(
` %${undef} = call ptr @scr_dyn_undefined()`,
` %${input} = select i1 %${missing}, ptr %${undef}, ptr %${raw}`,
` %${next} = call ${field.type.kind === "bool" ? "zeroext " : ""}${checkTy} @${host.dyn.dynCheckHelper(field.type)}(ptr %${input}, ptr null)`,
- ...(symbolGlobal ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []),
+ ...(symbolKey ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []),
...host.pendingTestLines(`%${pending}`),
` br i1 %${pending}, label %done, label %${store}`,
`${store}:`,
@@ -498,16 +495,18 @@ export function streamTypedRefCommitAdapter(
? `declare void @scr_dyn_symbol_key_placeholder(ptr, ptr)`
: `declare void @scr_dyn_key_delete_computed(ptr, ptr, i1 zeroext)`,
);
- [...symbols.values()].forEach((globalId, index) =>
+ [...symbols.values()].forEach((symbol, index) => {
+ const keyLines = symbolKeyLines(host, symbol, `bag_symraw${index}`);
lines.push(
- ` %bag_symraw${index} = load ptr, ptr @${mangleGlobal(globalId)}`,
+ ...keyLines.load,
` %bag_sym${index} = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %bag_symraw${index})`,
+ ...keyLines.release,
meta.def.tracksOwnFields
? ` call void @scr_dyn_symbol_key_placeholder(ptr %bag, ptr %bag_sym${index})`
: ` call void @scr_dyn_key_delete_computed(ptr %bag, ptr %bag_sym${index}, i1 zeroext false)`,
` call void @scr_dyn_release_v(ptr %bag_sym${index})`,
- ),
- );
+ );
+ });
const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES);
lines.push(
` %bag_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${index}`,
@@ -597,12 +596,21 @@ export function liveDynUnionRefAdapter(
if (!union) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of unknown union ${t.unionId}`);
}
+ // Codec records box as their own handles, by reference, through toDynHelper.
+ const codecArm = (arm: IrType): boolean =>
+ textCodecRecordClass(arm, (id) => host.recordsById.get(id)) !== null;
const mutableArms = union.arms
.map((arm, tag) => ({ arm, tag }))
.filter(
- ({ arm }) => arm.kind !== "bytes" && (streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
+ ({ arm }) =>
+ arm.kind !== "bytes" &&
+ !codecArm(arm) &&
+ (streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
);
- if (mutableArms.length === 0 && !union.arms.some((arm) => arm.kind === "bytes")) {
+ if (
+ mutableArms.length === 0 &&
+ !union.arms.some((arm) => arm.kind === "bytes" || codecArm(arm))
+ ) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of immutable union ${key}`);
}
@@ -695,6 +703,27 @@ export function liveDynUnionRefAdapter(
return sym;
}
+/** The raw ScrSym of a class's symbol field key into `%name`: a module
+ * key is borrowed from its global, a well-known one comes from the
+ * runtime's registry (+1, released by the `release` lines once the key
+ * value holds it). */
+function symbolKeyLines(
+ host: LlvmEmitterContext,
+ symbol: NonNullable[number],
+ name: string,
+): { load: string[]; release: string[] } {
+ if ("globalId" in symbol)
+ return { load: [` %${name} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`], release: [] };
+ host.declare("declare ptr @scr_sym_well_known(ptr)");
+ host.declare("declare void @scr_sym_release(ptr)");
+ return {
+ load: [
+ ` %${name} = call ptr @scr_sym_well_known(ptr ${host.internLiteral(symbol.wellKnown)})`,
+ ],
+ release: [` call void @scr_sym_release(ptr %${name})`],
+ };
+}
+
export function streamTypedRefBoxValue(
host: LlvmEmitterContext,
B: BlockBuilder,
@@ -712,7 +741,13 @@ export function streamTypedRefBoxValue(
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${value})`);
return boxed;
}
- if (t.kind === "bytes" || (!streamTypedRefEligible(t) && !isDynTypedRefType(t))) {
+ // A codec record boxes as its own handle, by reference (toDynHelper), as
+ // it does outside a live view.
+ if (
+ t.kind === "bytes" ||
+ textCodecRecordClass(t, (id) => host.recordsById.get(id)) !== null ||
+ (!streamTypedRefEligible(t) && !isDynTypedRefType(t))
+ ) {
const valueTy = host.llType(t);
B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(t)}(${valueTy} ${value})`);
return boxed;
@@ -913,8 +948,10 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (inline) fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
- B.line(`${rawKey} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`);
+ const keyLines = symbolKeyLines(host, symbol, rawKey.slice(1));
+ for (const line of keyLines.load) B.line(line.trim());
const key = host.streamTypedRefBoxValue(B, SYMBOL_T, rawKey);
+ for (const line of keyLines.release) B.line(line.trim());
const afterSymbol =
meta.def.tracksOwnFields || field.type.kind === "symbol"
? B.newLabel("symbol.after")
@@ -982,6 +1019,28 @@ export function streamTypedRefMaterializeAdapter(
: `call void @scr_error_dyn_place_name(ptr ${out}, ptr ${host.cstr(nameAfter)}, ${host.sizeType} ${Buffer.byteLength(nameAfter, "utf8")})`,
);
}
+ // A JavaScript Error subclass's property bag records, once a dynamic
+ // write has committed the view, where `name` and `message` were created
+ // (zod's error factory assigns `name` before it defines `message`); the
+ // slots hold their values. A `name` the class's construction sets is
+ // already where errorNameAfter places it among the fields.
+ if (
+ meta.def.errorBagOrder &&
+ meta.root.def.name === "%Error" &&
+ meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)
+ ) {
+ host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr, i1)`);
+ const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES);
+ const slot = B.tmp(),
+ bag = B.tmp();
+ B.line(
+ `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`,
+ );
+ B.line(`${bag} = load ptr, ptr ${slot}`);
+ B.line(
+ `call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p, i1 ${nameAfter === undefined ? "false" : "true"})`,
+ );
+ }
// A frozen, sealed or non-extensible instance's view says so.
if (withAttributes) {
host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`);
@@ -1457,9 +1516,26 @@ function liveClassOps(host: LlvmEmitterContext, snapshot: string, meta: LlClassM
// The constructor's name, as util.inspect prints before the fields.
const jsName = meta.def.jsName ?? meta.def.name.replace(/^%/, "");
const className = jsName === "" ? "null" : host.internLiteral(jsName);
+ // Its Symbol.toStringTag getter's string, for Object.prototype.toString
+ // and util.inspect.
+ let stringTag = "null";
+ if (meta.def.stringTagHelper) {
+ // The runtime lends the instance; the helper takes its own reference.
+ stringTag = `@${snapshot}_ctag`;
+ const retain = vAdapters(host.shapeHost, { kind: "object", className: meta.def.name }).retain;
+ host.resolveThunkDefs.push(
+ `define internal ptr ${stringTag}(ptr %p) ${FN_ATTRS} { ; Symbol.toStringTag of ${meta.def.name}`,
+ `entry:`,
+ ` %owned = call ptr ${retain}(ptr %p)`,
+ ` %tag = call ptr @${mangleFunction(meta.def.stringTagHelper)}(ptr %owned)`,
+ ` ret ptr %tag`,
+ `}`,
+ ``,
+ );
+ }
host.resolveThunkDefs.push(
`@${names} = internal constant [${members.length} x ptr] [${literals.join(", ")}]`,
- `@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className} }`,
+ `@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className}, ptr ${stringTag} }`,
``,
);
}
diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts
index ddbcdd8da..90954f4c6 100644
--- a/packages/compiler/src/backend/llvm/integer-values.ts
+++ b/packages/compiler/src/backend/llvm/integer-values.ts
@@ -65,6 +65,22 @@ function magnitude(range: IntegerRange): number {
return Math.max(Math.abs(range.min), Math.abs(range.max));
}
+/** The bounds of a sum and a product of two magnitude bounds, as doubles.
+ * Below 2^53 the double result is the integer; above it the result is past
+ * every exactness limit either way, and rounding moves it far less than the
+ * margin from WIDE_BOUND to the i64 range. A result of exactly 2^53 may
+ * stand for 2^53 + 1 (2^53 - 1 + 2 rounds to 2^53), so it moves to the next
+ * double up unless the sum is exactly 2^53. */
+function sumBound(a: number, b: number): number {
+ const sum = a + b;
+ return sum === EXACT_DOUBLES && EXACT_DOUBLES - a !== b ? sum + 2 : sum;
+}
+
+function productBound(a: number, b: number): number {
+ const product = a * b;
+ return product === EXACT_DOUBLES ? product + 2 : product;
+}
+
/** The wide view of `l op r` for `+`, `-` and `*` when the exact operands
* have no proven result range (their product or sum may pass 2^53).
*
@@ -98,14 +114,14 @@ export function wideArithmetic(
if (op === "*") {
if (lv.below !== Infinity || rv.below !== Infinity) return null;
below = EXACT_DOUBLES;
- bound = lv.bound * rv.bound;
+ bound = productBound(lv.bound, rv.bound);
} else if (lv.below === Infinity && rv.below === Infinity) {
below = EXACT_DOUBLES;
- bound = lv.bound + rv.bound;
+ bound = sumBound(lv.bound, rv.bound);
} else if (lv.below === Infinity || rv.below === Infinity) {
const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv];
below = Math.min(wide.below - exact.bound, EXACT_DOUBLES);
- bound = exact.bound + wide.bound;
+ bound = sumBound(exact.bound, wide.bound);
} else return null;
if (!(bound <= WIDE_BOUND) || below < INDEX_LIMIT) return null;
const operand = (value: LlValue, integer: LlInteger | null): string =>
@@ -134,6 +150,20 @@ export function indexInteger(
return wide && wide.below >= INDEX_LIMIT ? wide.name : null;
}
+/** An i64 equal to a value's double wherever the value can be: its exact
+ * integer, or a wide view whose magnitude bound is within the magnitude below
+ * which the view is exact. Every integer of magnitude at most 2^53 is a
+ * double, so a bound equal to that limit is exact too. Null otherwise. */
+export function alwaysExactInteger(
+ host: LlvmEmitterContext,
+ value: LlValue,
+ integer: LlInteger | null,
+): string | null {
+ if (integer) return widenInteger(host, integer);
+ const wide = value.wideInteger;
+ return wide && wide.bound <= wide.below ? wide.name : null;
+}
+
export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string {
if (value.type === "i64") return value.name;
const wide = host.B.tmp();
diff --git a/packages/compiler/src/backend/llvm/layouts.ts b/packages/compiler/src/backend/llvm/layouts.ts
index 8e1f0a4c1..5c426e910 100644
--- a/packages/compiler/src/backend/llvm/layouts.ts
+++ b/packages/compiler/src/backend/llvm/layouts.ts
@@ -17,7 +17,7 @@ export function emitLlvmLayouts(
host: ClassHost,
mod: IrModule,
classes: Map,
- classObjects: Map,
+ classObjects: Map,
functions: Map,
llType: (type: IrType) => string,
nullable?: NullableRefFields,
diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts
index eaf8f59f6..9cd11feb1 100644
--- a/packages/compiler/src/backend/llvm/lib-dispatch.ts
+++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts
@@ -5,11 +5,12 @@ import { emitTlsLibCall } from "./lib-tls.js";
import { emitHttp2LibCall } from "./lib-http2.js";
import { emitDatagramLibCall } from "./lib-datagram.js";
import { emitTestLibCall } from "./lib-test.js";
+import { FN_ATTRS, arrNewCall, traceArg, vAdapters } from "./shapes.js";
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js";
-import type { IrExpr, IrType } from "../../ir/ir.js";
+import { F64, type IrExpr, type IrType } from "../../ir/ir.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { emitJsLibCall } from "./jsval.js";
import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js";
@@ -25,6 +26,9 @@ import { rawBytes } from "./lib-abi.js";
import { emitInlineAtomicsOp } from "./expr-bytes.js";
import { regexLiteralCaptures } from "./regex-matchers.js";
+/** ScrDyn.v.typed_ref.type_key_len: the low 27 bits of its bit-field word. */
+const TYPE_KEY_LEN_MASK = (1 << 27) - 1;
+
export function emitAssertInspectLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "assert.shapeStr" || e.fn === "assert.shapeRe") {
@@ -236,6 +240,42 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
host.emitPendingCheck();
return output;
}
+ if (e.fn === "promise.allTuple") return emitPromiseAllTuple(host, e);
+ if (e.fn === "promise.fulfilledValue") {
+ // The payload of a fulfilled promise (borrowed operand): the scalar
+ // kinds that lower to a double (numbers, Dates, process streams) and
+ // booleans by value, strings and references +1.
+ const promise = host.emitExpr(e.args[0]!);
+ const out = B.tmp();
+ const ty = host.llType(e.type);
+ if (ty === "double") {
+ host.declare(`declare double @scr_promise_payload_f64(ptr)`);
+ B.line(`${out} = call double @scr_promise_payload_f64(ptr ${promise.name})`);
+ return { name: out, type: e.type };
+ }
+ if (ty === "i1") {
+ host.declare(`declare zeroext i1 @scr_promise_payload_bool(ptr)`);
+ B.line(`${out} = call zeroext i1 @scr_promise_payload_bool(ptr ${promise.name})`);
+ return { name: out, type: e.type };
+ }
+ if (e.type.kind === "string") {
+ host.declare(`declare ptr @scr_promise_payload_str(ptr)`);
+ B.line(`${out} = call ptr @scr_promise_payload_str(ptr ${promise.name}) ; +1`);
+ return host.own({ name: out, type: e.type });
+ }
+ host.declare(`declare ptr @scr_promise_payload_ref(ptr)`);
+ B.line(`${out} = call ptr @scr_promise_payload_ref(ptr ${promise.name}) ; +1`);
+ return host.own({ name: out, type: e.type });
+ }
+ if (e.fn === "record.sourceSpread") {
+ const built = host.emitReadReceiver(e.args[0]!);
+ const value = host.emitReadReceiver(e.args[1]!);
+ const type = e.args[0]!.type;
+ if (type.kind !== "record")
+ throw new InternalCompilerError("llvm emitter bug: record.sourceSpread of a non-record");
+ host.dyn.emitRecordSourceSpread(B, built.name, value.name, type.shapeId);
+ return { name: "", type: e.type };
+ }
if (e.fn === "dyn.typedRefIs" || e.fn === "dyn.typedRefKeyIs") {
const literal = e.args[1]!.kind === "strLit" ? e.args[1]!.value : null;
if (e.fn === "dyn.typedRefKeyIs" && literal === null)
@@ -281,6 +321,38 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
B.line(`store i1 true, ptr ${slot}`);
B.condBr(same, done, compare);
B.startBlock(compare);
+ // A capsule with another key pointer usually belongs to another
+ // class: reject it on its key length and last eight bytes before
+ // the out-of-line name comparison, so a class table's chain of
+ // probes costs a few instructions per class it skips.
+ B.line(`store i1 false, ptr ${slot}`);
+ const bytes = Buffer.from(literal, "utf8");
+ const lenSlot = B.tmp(),
+ lenWord = B.tmp(),
+ len = B.tmp(),
+ lenEq = B.tmp();
+ B.line(
+ `${lenSlot} = getelementptr inbounds i8, ptr ${value.name}, i64 ${host.abiOffset(88, 52)} ; ->v.typed_ref.type_key_len`,
+ );
+ B.line(`${lenWord} = load i32, ptr ${lenSlot}`);
+ B.line(`${len} = and i32 ${lenWord}, ${TYPE_KEY_LEN_MASK}`);
+ B.line(`${lenEq} = icmp eq i32 ${len}, ${bytes.length}`);
+ const full = B.newLabel("brand.full");
+ if (bytes.length >= 8) {
+ const tail = B.newLabel("brand.tail");
+ B.condBr(lenEq, tail, done);
+ B.startBlock(tail);
+ const tailPtr = B.tmp(),
+ word = B.tmp(),
+ wordEq = B.tmp();
+ B.line(`${tailPtr} = getelementptr inbounds i8, ptr ${keyPtr}, i64 ${bytes.length - 8}`);
+ B.line(`${word} = load i64, ptr ${tailPtr}, align 1`);
+ B.line(`${wordEq} = icmp eq i64 ${word}, ${bytes.readBigInt64LE(bytes.length - 8)}`);
+ B.condBr(wordEq, full, done);
+ } else {
+ B.condBr(lenEq, full, done);
+ }
+ B.startBlock(full);
}
}
if (e.fn === "dyn.typedRefIs") {
@@ -594,16 +666,66 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
+ // The runtime's Date functions take and answer time values: a Date
+ // operand passes its time value, read at once from the borrowed object,
+ // and a Date result is a new Date object.
const args = READ_ONLY_STRING_LIB_FNS.has(e.fn)
? emitBorrowedInputs(host, e.args)
: e.args.map((a) =>
- borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
- ? emitBorrowedInput(host, a)
- : host.emitExpr(a),
+ a.type.kind === "date"
+ ? { name: emitDateTimeArg(host, a), type: F64 }
+ : borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
+ ? emitBorrowedInput(host, a)
+ : host.emitExpr(a),
);
+ // getTime and valueOf answer the time value the argument already is.
+ if ((e.fn === "date.getTime" || e.fn === "date.valueOf") && e.args[0]?.type.kind === "date")
+ return { name: args[0]!.name, type: e.type };
// The deep comparisons' number test: computed here, with no runtime call.
if (e.fn === "assert.sameValue")
return { name: emitSameValueF64(host, args[0]!.name, args[1]!.name), type: e.type };
+ const callArgs = args;
+ if (e.type.kind === "date") {
+ const ms = emitSymbolLibCall(host, { ...e, type: F64 }, sym, callArgs);
+ host.declare(`declare ptr @scr_date_new(double)`);
+ const date = B.tmp();
+ B.line(`${date} = call ptr @scr_date_new(double ${ms.name})`);
+ return host.own({ name: date, type: e.type });
+ }
+ return emitSymbolLibCall(host, e, sym, callArgs);
+}
+
+/** The time value of a Date argument. A Date that a runtime Date function
+ * produces for this argument alone (`new Date(ms).getTime()`, a
+ * time-value local's read) passes the function's time value and creates
+ * no Date object. */
+function emitDateTimeArg(host: LlvmEmitterContext, date: IrExpr): string {
+ // A time-value local's read is its time value itself.
+ if (date.kind === "libCall" && date.fn === "date.fromTime")
+ return host.emitExpr(date.args[0]!).name;
+ if (date.kind === "libCall" && date.fn.startsWith("date."))
+ return emitGenericLibCall(host, { ...date, type: F64 }).name;
+ return emitDateTime(host, emitBorrowedInput(host, date).name);
+}
+
+/** A Date object's time value (ScrDate.ms). */
+function emitDateTime(host: LlvmEmitterContext, date: string): string {
+ const B = host.B;
+ const field = B.tmp();
+ const ms = B.tmp();
+ B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`);
+ B.line(`${ms} = load double, ptr ${field}`);
+ return ms;
+}
+
+/** The call of runtime function `sym` with evaluated arguments. */
+function emitSymbolLibCall(
+ host: LlvmEmitterContext,
+ e: LibCallExpr,
+ sym: string,
+ args: readonly LlValue[],
+): LlValue {
+ const B = host.B;
const argDecls = args.map((a) => {
const ty = host.llType(a.type);
return ty === "i1" ? "i1 zeroext" : ty;
@@ -805,6 +927,19 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return { name: t, type: e.type };
}
if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type });
+ if (e.fn === "record.isFrozen") {
+ // The frozen bit of the record's rc word (scr_rc.h SCR_RC_FROZEN) is
+ // its second-highest bit; an immortal word sets the highest one too.
+ const value = host.emitReadReceiver(e.args[0]!);
+ const S = host.sizeType;
+ const rc = host.B.tmp(),
+ top = host.B.tmp(),
+ t = host.B.tmp();
+ host.B.line(`${rc} = load ${S}, ptr ${value.name}`);
+ host.B.line(`${top} = lshr ${S} ${rc}, ${S === "i64" ? 62 : 30}`);
+ host.B.line(`${t} = icmp eq ${S} ${top}, 1`);
+ return { name: t, type: e.type };
+ }
const prefix = e.fn.slice(0, e.fn.indexOf(".")) as LibCallPrefix;
// events.once builds typed listener adapters beside the emitter calls.
if (e.fn === "events.once" || e.fn === "events.onceSignal")
@@ -913,10 +1048,12 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "blob":
case "wasm":
case "array":
+ case "collection":
case "record":
case "disposable":
case "punycode":
case "constants":
+ case "promise":
return host.emitGenericLibCall(e);
case "test":
return emitTestLibCall(host, e);
@@ -931,3 +1068,63 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
}
}
}
+
+/** Promise.all's tuple form (promise.allTuple): the combinator over the
+ * entries stores each fulfilled entry promise, and a settlement adapter on
+ * the combinator calls the builder with them and fulfills the result with
+ * the tuple inside the combinator's settlement; a rejection settles the
+ * result the same way. The combinator itself counts as handled. */
+function emitPromiseAllTuple(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
+ const B = host.B;
+ const build = e.args[1]!;
+ if (build.kind !== "closure" || e.type.kind !== "promise")
+ throw new InternalCompilerError("llvm emitter bug: promise.allTuple shape");
+ const entriesT = e.args[0]!.type;
+ const entries = host.emitExpr(e.args[0]!);
+ const tuple = e.type.inner;
+ const key = `allTuple:${build.fnName}`;
+ let adapter = host.resolveThunks.get(key);
+ if (adapter === undefined) {
+ adapter = `sc_all_tuple_${host.resolveThunks.size}`;
+ host.resolveThunks.set(key, adapter);
+ const rc = vAdapters(host.shapeHost, tuple);
+ host.declare(`declare ptr @scr_promise_payload_ref(ptr)`);
+ host.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`);
+ host.resolveThunkDefs.push(
+ `define internal void @${adapter}(ptr %dst, ptr %src) ${FN_ATTRS} { ; Promise.all tuple`,
+ `entry:`,
+ ` %entries = call ptr @scr_promise_payload_ref(ptr %src) ; +1, moves into the builder`,
+ ` %tuple = call ptr @${host.callTarget(build.fnName)}(ptr %entries)`,
+ ` call void @scr_promise_fulfill_ref(ptr %dst, ptr %tuple, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, tuple)})`,
+ ` ret void`,
+ `}`,
+ ``,
+ );
+ }
+ host.declare(`declare ptr @scr_promise_all(ptr, ptr, ptr)`);
+ host.declare(`declare void @scr_promise_all_store_promise(ptr, double, ptr)`);
+ host.declare(`declare ptr @scr_promise_new()`);
+ host.declare(`declare void @scr_promise_race_add(ptr, ptr, ptr)`);
+ host.declare(`declare void @scr_promise_mark_handled(ptr)`);
+ host.declare(`declare void @scr_promise_release(ptr)`);
+ host.declare(`declare double @scr_arr_len(ptr)`);
+ if (entriesT.kind !== "array")
+ throw new InternalCompilerError("llvm emitter bug: promise.allTuple entries");
+ const len = B.tmp();
+ const cap = B.tmp();
+ B.line(`${len} = call double @scr_arr_len(ptr ${entries.name})`);
+ B.line(`${cap} = fptoui double ${len} to ${host.sizeType}`);
+ const values = B.tmp();
+ B.line(`${values} = ${arrNewCall(host.shapeHost, entriesT.elem, cap)}`);
+ host.own({ name: values, type: entriesT });
+ const all = B.tmp();
+ B.line(
+ `${all} = call ptr @scr_promise_all(ptr ${entries.name}, ptr ${values}, ptr @scr_promise_all_store_promise)`,
+ );
+ B.line(`call void @scr_promise_mark_handled(ptr ${all})`);
+ const result = B.tmp();
+ B.line(`${result} = call ptr @scr_promise_new()`);
+ B.line(`call void @scr_promise_race_add(ptr ${result}, ptr ${all}, ptr @${adapter})`);
+ B.line(`call void @scr_promise_release(ptr ${all})`);
+ return host.own({ name: result, type: e.type });
+}
diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts
index 7d9be632d..f0b766936 100644
--- a/packages/compiler/src/backend/llvm/lib-filesystem.ts
+++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts
@@ -1,7 +1,7 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
-import { DYN, F64, STATS_T, STRING } from "../../ir/ir.js";
+import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js";
import { mangleRecordNew } from "../mangle.js";
import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
@@ -1027,6 +1027,57 @@ function emitPowerOfTwo(host: LlvmEmitterContext, n: string): string {
return out;
}
+/** `Math.pow(2, n)`, the divisor emitPowerOfTwoQuotient takes. */
+export function isPowerOfTwoCall(e: IrExpr): boolean {
+ return (
+ e.kind === "libCall" &&
+ e.fn === "math.pow" &&
+ e.args.length === 2 &&
+ e.args[0]!.kind === "numLit" &&
+ e.args[0]!.value === 2
+ );
+}
+
+/** `left / divisor` for a divisor that is usually a power of two
+ * (`x / Math.pow(2, n)`). A positive power of two from 2^-1022 to 2^1022
+ * has a reciprocal that is a power of two too, whose bits are
+ * 0x7FE0000000000000 minus the divisor's, and the product with it is the
+ * quotient for every `left`: both are `left` scaled by the same power of
+ * two, rounded once. Any other divisor divides. */
+export function emitPowerOfTwoQuotient(
+ host: LlvmEmitterContext,
+ left: string,
+ divisor: string,
+): string {
+ const B = host.B;
+ const bits = B.tmp(),
+ mantissa = B.tmp(),
+ plain = B.tmp(),
+ biased = B.tmp(),
+ offset = B.tmp(),
+ normal = B.tmp(),
+ power = B.tmp(),
+ inverseBits = B.tmp(),
+ inverse = B.tmp(),
+ scaled = B.tmp(),
+ divided = B.tmp(),
+ out = B.tmp();
+ B.line(`${bits} = bitcast double ${divisor} to i64`);
+ B.line(`${mantissa} = and i64 ${bits}, ${2n ** 52n - 1n}`);
+ B.line(`${plain} = icmp eq i64 ${mantissa}, 0`);
+ // Biased exponents 1 to 2045 with a clear sign bit.
+ B.line(`${biased} = lshr i64 ${bits}, 52`);
+ B.line(`${offset} = sub i64 ${biased}, 1`);
+ B.line(`${normal} = icmp ult i64 ${offset}, 2045`);
+ B.line(`${power} = and i1 ${plain}, ${normal}`);
+ B.line(`${inverseBits} = sub i64 ${0x7fe0000000000000n}, ${bits}`);
+ B.line(`${inverse} = bitcast i64 ${inverseBits} to double`);
+ B.line(`${scaled} = fmul double ${left}, ${inverse}`);
+ B.line(`${divided} = fdiv double ${left}, ${divisor}`);
+ B.line(`${out} = select i1 ${power}, double ${scaled}, double ${divided}`);
+ return out;
+}
+
export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "math.min" || e.fn === "math.max") {
diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts
index 3e8a9bff5..1a3973b07 100644
--- a/packages/compiler/src/backend/llvm/lib-process.ts
+++ b/packages/compiler/src/backend/llvm/lib-process.ts
@@ -1,10 +1,11 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
-import { F64, RUNTIME_ERROR_CLASSES, STRING } from "../../ir/ir.js";
+import { F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
import { f64Lit } from "./common.js";
import { mockTimerSym } from "./lib-shared.js";
+import { FN_ATTRS } from "./shapes.js";
export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
@@ -460,9 +461,58 @@ export function emitAsyncContextLibCall(host: LlvmEmitterContext, e: LibCallExpr
for (const a of e.args) host.emitExpr(a);
return { name: "", type: e.type };
}
+ if (e.fn === "als.bindCallback") {
+ // The closure moves into the bound closure, whose entry is the
+ // per-signature thunk; with nothing to bind the runtime answers the
+ // closure itself.
+ if (e.type.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: als.bindCallback on non-func");
+ const cb = host.emitExpr(e.args[0]!);
+ const always = host.emitExpr(e.args[1]!);
+ host.toHeap(cb);
+ const thunk = boundCallbackThunk(host, e.type);
+ host.declare("declare ptr @scr_als_bind_closure(ptr, ptr, i1 zeroext)");
+ const t = B.tmp();
+ B.line(
+ `${t} = call ptr @scr_als_bind_closure(ptr ${cb.name}, ptr @${thunk}, i1 ${always.name})`,
+ );
+ return host.own({ name: t, type: e.type });
+ }
return host.emitGenericLibCall(e);
}
+/** The entry of a callback bound to an AsyncLocalStorage context, one per
+ * callback signature: enters the captured context, calls the target with
+ * the same arguments (the callValue ABI) and restores the caller's
+ * context, also when the target leaves an exception pending. */
+function boundCallbackThunk(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
+ const key = `alsbound:${typeKey(cbT)}`;
+ let sym = host.resolveThunks.get(key);
+ if (sym) return sym;
+ sym = `sc_alsbound_${host.resolveThunks.size}`;
+ host.resolveThunks.set(key, sym);
+ host.declare("declare ptr @scr_als_bound_target(ptr)");
+ host.declare("declare ptr @scr_als_bound_enter(ptr)");
+ host.declare("declare void @scr_als_bound_leave(ptr, ptr)");
+ const ret = host.llType(cbT.ret);
+ const params = cbT.params.map((p, i) => `${host.llType(p)} %a${i}`);
+ const call = `call ${ret} %fn(${["ptr %cb", ...params].join(", ")})`;
+ host.resolveThunkDefs.push(
+ `define internal ${ret} @${sym}(${["ptr %self", ...params].join(", ")}) ${FN_ATTRS} { ; AsyncLocalStorage-bound callback ${typeKey(cbT)}`,
+ `entry:`,
+ ` %cb = call ptr @scr_als_bound_target(ptr %self)`,
+ ` %prev = call ptr @scr_als_bound_enter(ptr %self)`,
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
+ ` %fn = load ptr, ptr %fnp`,
+ ret === "void" ? ` ${call}` : ` %r = ${call}`,
+ ` call void @scr_als_bound_leave(ptr %prev, ptr %cb)`,
+ ret === "void" ? ` ret void` : ` ret ${ret} %r`,
+ `}`,
+ ``,
+ );
+ return sym;
+}
+
export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "process.forkTarget") {
diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts
index af32c6936..7a16c37e8 100644
--- a/packages/compiler/src/backend/llvm/lib-shared.ts
+++ b/packages/compiler/src/backend/llvm/lib-shared.ts
@@ -333,6 +333,7 @@ export const LIB_FN_SYMS: Record = {
"date.parse": "scr_date_parse",
"date.getTime": "scr_date_get_time",
"date.valueOf": "scr_date_get_time",
+ "date.fromTime": "scr_date_get_time",
"date.parseGetTime": "scr_date_parse",
"date.utc": "scr_date_utc",
"date.newLocal": "scr_date_new_local",
@@ -454,6 +455,7 @@ export const LIB_FN_SYMS: Record = {
"buffer.poolFromDyn": "scr_buffer_from_dyn_pooled",
"buffer.poolFromDynRef": "scr_buffer_from_dyn_ref_pooled",
"buffer.poolFromDynOffset": "scr_buffer_from_dyn_offset_pooled",
+ "buffer.poolFromDynRange": "scr_buffer_from_dyn_range_pooled",
"buffer.poolSize": "scr_buffer_pool_size",
"buffer.setPoolSize": "scr_buffer_set_pool_size",
"buffer.byteLenDyn": "scr_buffer_byte_length_dyn",
@@ -479,6 +481,12 @@ export const LIB_FN_SYMS: Record = {
"bytes.equalsChk": "scr_bytes_equals_chk",
"bytes.compareChk": "scr_bytes_compare_chk",
"buffer.newStringFail": "scr_buffer_new_string_fail",
+ "buffer.deprecation": "scr_buffer_deprecation",
+ "buffer.deprecationCount": "scr_buffer_deprecation_count",
+ "buffer.constructor": "scr_buffer_constructor_value",
+ "buffer.installValue": "scr_buffer_install_value",
+ "buffer.staticValue": "scr_buffer_static_value",
+ "buffer.prototypeMember": "scr_buffer_prototype_member",
"fs.toUnixTimestamp": "scr_fs_to_unix_timestamp",
"fs.existsChk": "scr_fs_exists_async",
"fs.mkdtempSyncChk": "scr_fs_mkdtemp_sync_chk",
@@ -560,12 +568,14 @@ export const LIB_FN_SYMS: Record = {
"text.decoderEncoding": "scr_text_decoder_encoding",
"text.decoderName": "scr_text_decoder_name",
"text.encodeInto": "scr_text_encode_into",
+ "text.codecIs": "scr_text_codec_is",
"buffer.isAscii": "scr_buffer_is_ascii",
"buffer.isUtf8": "scr_buffer_is_utf8",
"buffer.transcode": "scr_buffer_transcode",
"zlib.deflateSync": "scr_zlib_deflate",
"zlib.inflateSync": "scr_zlib_inflate",
"zlib.crc32": "scr_zlib_crc32",
+ "zlib.brotliInstall": "scr_brotli_install",
// The CA-store unit. get/set throw (an unknown type name, a
// certificate-free set) and take the generic path's pending check.
"tlsca.root": "scr_tls_ca_root",
@@ -649,6 +659,7 @@ export const LIB_FN_SYMS: Record = {
"process.stderrWriteBytes": "scr_process_stderr_write_bytes",
"insp.buffer": "scr_insp_buffer",
"insp.typedArray": "scr_insp_typed_array",
+ "insp.bytes": "scr_insp_bytes",
// util.inspect (scr_inspect.c): all args borrowed; string results +1;
// the begin/entry/key/moreItems/end accumulator drives the emitted
// container walks. None of these throw.
@@ -667,6 +678,8 @@ export const LIB_FN_SYMS: Record = {
"insp.moreItems": "scr_insp_more_items",
"insp.end": "scr_insp_end",
"insp.nestedBegin": "scr_insp_nested_begin",
+ "insp.mark": "scr_insp_mark",
+ "insp.unwind": "scr_insp_unwind",
"insp.nestedEnd": "scr_insp_nested_end",
// Circular references over cycle-capable composites (Node's
// seen/circular machinery — none of these throw; refWrap borrows its
@@ -677,6 +690,7 @@ export const LIB_FN_SYMS: Record = {
"insp.circular": "scr_insp_circular",
// Undeclared source properties kept beside a checked record (scr_json.c).
"record.sourceHas": "scr_record_source_has",
+ "record.sourceSpread": "scr_record_source_spread",
// WHATWG URL + URLSearchParams (scr_url.c / scr_url_params.c):
// constructions +1; url.new, the fileURLToPath pair, the win32
// pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw).
@@ -701,6 +715,9 @@ export const LIB_FN_SYMS: Record = {
"url.hash": "scr_url_hash",
"url.searchParams": "scr_url_search_params",
"url.fileURLToPathUrl": "scr_url_to_path",
+ "url.fileURLToPathValue": "scr_url_to_path_value",
+ "fs.checkPath": "scr_fs_check_path",
+ "fs.checkReadEncoding": "scr_fs_check_read_encoding",
"url.fileURLToPathChecked": "scr_url_checked_to_path",
"url.fileURLToPathOptions": "scr_url_checked_to_path_options",
"url.fileURLToPathBuffer": "scr_url_checked_to_path_buffer",
@@ -718,6 +735,7 @@ export const LIB_FN_SYMS: Record = {
"punycode.ucs2Encode": "scr_str_from_code_point",
"punycode.warn": "scr_punycode_warn",
"url.domainToUnicode": "scr_url_domain_to_unicode",
+ "url.toHttpOptions": "scr_url_to_http_options_dyn",
"url.pathToFileURL": "scr_url_from_path",
"url.pathToFileURLPlatform": "scr_url_from_path_platform",
"url.pathToFileURLWin32": "scr_url_from_path",
@@ -958,6 +976,7 @@ export const LIB_FN_SYMS: Record = {
"fetch.streamIs": "scr_fetch_stream_is",
"zlib.streamIs": "scr_zlib_stream_is",
"fetch.streamNew": "scr_fetch_stream_new",
+ "fetch.transformNew": "scr_fetch_transform_new",
"fetch.streamFrom": "scr_fetch_stream_from",
"fetch.readerRead": "scr_fetch_reader_read",
"json.parse": "scr_json_parse",
@@ -1019,6 +1038,12 @@ export const LIB_FN_SYMS: Record = {
"class.descriptorHasValue": "scr_class_descriptor_has_value",
"class.throwReadOnly": "scr_class_throw_read_only",
"class.throwNotExtensible": "scr_class_throw_not_extensible",
+ "record.freeze": "scr_record_freeze",
+ "record.throwFrozen": "scr_record_throw_frozen",
+ "array.freeze": "scr_arr_freeze",
+ "array.isFrozen": "scr_arr_is_frozen",
+ "collection.preventExtensions": "scr_map_prevent_extensions",
+ "collection.isExtensible": "scr_map_is_extensible",
"dyn.nativeSetNew": "scr_dyn_native_set_new",
"dyn.nativeSetIs": "scr_dyn_native_set_is",
"dyn.nativeMapIs": "scr_dyn_native_map_is",
@@ -1042,6 +1067,7 @@ export const LIB_FN_SYMS: Record = {
"dyn.generatorThis": "scr_gen_receiver",
"insp.dyn": "scr_insp_dyn",
"insp.dynS": "scr_insp_dyn_s",
+ "insp.dynHidden": "scr_insp_dyn_hidden",
"fs.readFileSyncDyn": "scr_fs_read_file_sync_dyn",
"fs.readFileEncoded": "scr_fs_read_file_encoded",
"fs.readFdEncoded": "scr_fs_read_fd_encoded",
@@ -1061,6 +1087,7 @@ export const LIB_FN_SYMS: Record = {
"crypto.generateKeyPairSync": "scr_crypto_generate_key_pair_sync",
"crypto.generateKeyPairCb": "scr_crypto_generate_key_pair_async",
"crypto.signOneshot": "scr_crypto_sign_oneshot",
+ "crypto.diffieHellman": "scr_crypto_diffie_hellman",
"crypto.verifyOneshot": "scr_crypto_verify_oneshot",
"crypto.createSign": "scr_crypto_create_sign",
"crypto.createVerify": "scr_crypto_create_verify",
@@ -1478,6 +1505,10 @@ export const LIB_FN_SYMS: Record = {
"als.exitRun": "scr_als_exit_run",
"als.enterWith": "scr_als_enter_with",
"als.disable": "scr_als_disable",
+ "als.newOptions": "scr_als_new_options",
+ "als.name": "scr_als_name",
+ "als.bindCallbackDyn": "scr_als_bind_dyn",
+ "als.snapshot": "scr_als_snapshot",
"dc.chanBindStore": "scr_dc_chan_bind_store",
"dc.chanUnbindStore": "scr_dc_chan_unbind_store",
"dc.chanRunStores": "scr_dc_chan_run_stores",
@@ -1641,6 +1672,7 @@ export function mockTimerSym(
export const USES_TIMERS_LIB_FNS = new Set([
"crypto.generateKeyPairCb",
+ "crypto.diffieHellman",
"crypto.checkPrimeCb",
"crypto.generatePrimeCb",
"process.emitWarning",
@@ -1666,6 +1698,8 @@ export const USES_TIMERS_LIB_FNS = new Set([
"url.legacyParse",
"url.legacyFormat",
"url.legacyResolve",
+ // The deprecated Buffer constructor reports DEP0005 on the next tick.
+ "buffer.deprecation",
// The wrappers schedule warnings and callbacks on the tick queue.
"util.deprecate",
"util.callbackify",
@@ -1691,6 +1725,7 @@ export const USES_TIMERS_LIB_FNS = new Set([
"fetch.abortAny",
"fetch.streamNew",
"fetch.streamFrom",
+ "fetch.transformNew",
"readable.new",
// fs.ReadStream / fs.WriteStream open, read and write on the worker pool.
"fs.createReadStream",
diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts
index 1c540295a..80ed2c440 100644
--- a/packages/compiler/src/backend/llvm/local-array-reads.ts
+++ b/packages/compiler/src/backend/llvm/local-array-reads.ts
@@ -797,7 +797,33 @@ export function emitLocalArrayRead(
);
return null;
});
- else if (inline)
+ else if (
+ inline &&
+ loopIndex !== null &&
+ read.element.kind === "f64" &&
+ host.provenDenseIndex(read.array, read.index)
+ ) {
+ // In a dense array loop's fast copy, the entry test proved the counter
+ // below the array's hole-free prefix: the slot holds a present value,
+ // and the read has no missing arm.
+ const dataPtr = B.tmp(),
+ data = B.tmp(),
+ valuePtr = B.tmp(),
+ value = B.tmp(),
+ width = int32 ? "i32" : "i64";
+ B.line(
+ `${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
+ );
+ host.markMemoryPointer(dataPtr, "array:header");
+ B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
+ B.line(
+ `${valuePtr} = getelementptr inbounds ${width}, ptr ${data}, ${host.sizeType} ${loopIndex}`,
+ );
+ host.markMemoryPointer(valuePtr, "array:elements");
+ B.line(`${value} = load ${width}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
+ storeValue(value);
+ B.br(join);
+ } else if (inline)
emitDenseReferenceArrayRead(
host,
array,
diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts
index 60270adda..62fdd75ec 100644
--- a/packages/compiler/src/backend/llvm/publish.ts
+++ b/packages/compiler/src/backend/llvm/publish.ts
@@ -596,10 +596,10 @@ export class PublishWalkers {
}
}
-/** Unpublishable types stored as scalars (dates are time values, process
- * streams file descriptors): no pointer to push, and always a value. */
+/** Unpublishable types stored as scalars (process streams are file
+ * descriptors): no pointer to push, and always a value. */
function scalarRefusal(t: IrType): boolean {
- return t.kind === "date" || t.kind === "procStream";
+ return t.kind === "procStream";
}
/** The Node.js implementation's refusal for a value of type `t`. */
diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts
index 5e1077ee5..2fe9dd66b 100644
--- a/packages/compiler/src/backend/llvm/receivers.ts
+++ b/packages/compiler/src/backend/llvm/receivers.ts
@@ -18,6 +18,7 @@ import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
+import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js";
/** Read a receiver whose value is consumed immediately by a field or tag
@@ -95,6 +96,8 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null;
if (narrowed) return narrowed;
if (canStackUnion(e, emitter.boxedUnionsById)) return emitStackUnion(emitter, e).value;
+ if (e.kind === "recordKeyGet" && scalarRecordKeyGet(emitter, e))
+ return { name: emitStackRecordKeyGet(emitter, e), type: e.type };
if (e.kind === "unionNarrow") {
const union = emitter.emitUnionProjection(e.value);
emitUnionNarrowGuard(emitter, union.name, e.unionId, e.tag);
@@ -410,6 +413,7 @@ export function isStackUnionSource(emitter: LlEmitter, e: IrExpr): boolean {
emitter.optionalArrayReads.get(e) !== null ||
isScalarOptionalSource(e, emitter.unionSources) ||
emitter.scalarResultCall(e) ||
- emitter.inlineFieldGet(e)
+ emitter.inlineFieldGet(e) ||
+ scalarRecordKeyGet(emitter, e)
);
}
diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts
index 6ebe1222b..43a9176d5 100644
--- a/packages/compiler/src/backend/llvm/reference-effects.ts
+++ b/packages/compiler/src/backend/llvm/reference-effects.ts
@@ -181,6 +181,9 @@ function expressionPreservesEdges(
case "bytesIntrinsic":
return (
e.method === "get" ||
+ e.method === "indexOfElem" ||
+ e.method === "lastIndexOfElem" ||
+ e.method === "includesElem" ||
e.method === "length" ||
e.method === "byteLength" ||
e.method === "byteOffset" ||
diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts
index f7327a688..7af16ab83 100644
--- a/packages/compiler/src/backend/llvm/shapes.test.ts
+++ b/packages/compiler/src/backend/llvm/shapes.test.ts
@@ -14,6 +14,7 @@ import {
type ShapeHost,
vAdapters,
} from "./shapes.js";
+import { mangleRecordRelease } from "../mangle.js";
import { NullableUnions } from "./nullable-unions.js";
import { InlineUnionFields } from "./inline-unions.js";
@@ -315,6 +316,42 @@ describe("emitted object releases", () => {
root.indexOf("call void @scr_cyc_on_release(ptr %o)"),
);
});
+
+ test("freezable records count their references without the frozen bit", () => {
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: "frozen.ts",
+ entry: "main",
+ functions: [],
+ records: [
+ { id: "plain", fields: [{ name: "n", type: F64 }] },
+ { id: "frozen", fields: [{ name: "n", type: F64 }], freezable: true },
+ ],
+ };
+ const body = (defs: string, name: string): string =>
+ new RegExp(`^define internal void @${name}\\([^]*?^}`, "m").exec(defs)?.[0] ?? "";
+ for (const [pointerBits, notFrozen, sharedBelow] of [
+ [64, "-4611686018427387905", "-4611686018427387907"],
+ [32, "-1073741825", "-1073741827"],
+ ] as const) {
+ const { host } = declarationHost(pointerBits);
+ const defs = emitRecordShapes({ ...host, objectAudit: false }, mod).defs.join("\n");
+ const S = pointerBits === 64 ? "i64" : "i32";
+ const plain = body(defs, mangleRecordRelease("plain"));
+ expect(plain).toContain(`%shared = icmp ult ${S} %rcm2, -3`);
+ expect(plain).not.toContain("%refs");
+ // The immortal word's count is ~FROZEN, which the compare sends to the
+ // immortal test, like counts 0 and 1.
+ const fast = body(defs, mangleRecordRelease("frozen"));
+ expect(fast).toContain(`%refs = and ${S} %rc, ${notFrozen}`);
+ expect(fast).toContain(`%rcm2 = sub ${S} %refs, 2`);
+ expect(fast).toContain(`%shared = icmp ult ${S} %rcm2, ${sharedBelow}`);
+ expect(fast).toContain(`%imm = icmp eq ${S} %rc, -1`);
+ const slow = body(defs, `${mangleRecordRelease("frozen")}_slow`);
+ expect(slow).toContain(`%refs = and ${S} %n, ${notFrozen}`);
+ expect(slow).toContain(`%dead = icmp eq ${S} %refs, 0`);
+ }
+ });
});
describe("live-object audit notes", () => {
diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts
index d13f80a78..b3f5084d3 100644
--- a/packages/compiler/src/backend/llvm/shapes.ts
+++ b/packages/compiler/src/backend/llvm/shapes.ts
@@ -34,6 +34,7 @@ import {
cycTypeDescriptor,
emitObjectAlloc,
emitObjectFree,
+ emitSegmentFree,
CYC_APPEND,
cycleAppendDecls,
cycleAppendHelper,
@@ -1099,6 +1100,7 @@ export function arrNewCall(
elem.kind === "stats" || // fs.Stats snapshots: scr_stats_* adapters, no trace
elem.kind === "dyn" || // native collection seeds/drains: traced REF
elem.kind === "regex" || // RegExp values: scr_regex_* adapters, no trace (no refs inside)
+ elem.kind === "date" || // Date objects: scr_date_* adapters, no trace (no refs inside)
(elem.kind === "array" && traceAdapter(host, elem) !== null);
if (!useRef) {
if (small) {
@@ -1162,7 +1164,6 @@ const SCR_ARR_OPS_RELEASE_NOTHING = 2;
export function boxNewCall(host: ShapeHost, t: IrType): string {
const plain: Partial> = {
f64: 0,
- date: 0,
procStream: 0,
bool: 1,
string: 2,
@@ -1187,11 +1188,7 @@ export function boxNewCall(host: ShapeHost, t: IrType): string {
/** Box accessor suffix (boxAccess): scalars unboxed, ref kinds pointers. */
export function boxAccess(t: IrType): "f64" | "bool" | "ref" {
- return t.kind === "f64" || t.kind === "date" || t.kind === "procStream"
- ? "f64"
- : t.kind === "bool"
- ? "bool"
- : "ref";
+ return t.kind === "f64" || t.kind === "procStream" ? "f64" : t.kind === "bool" ? "bool" : "ref";
}
/* ── record shapes ────────────────────────────────────────────────────── */
@@ -1207,7 +1204,6 @@ export function llFieldType(
if (POINTER_KINDS.has(t.kind)) return "ptr";
switch (t.kind) {
case "f64":
- case "date":
case "procStream":
return "double";
case "bool":
@@ -1405,17 +1401,23 @@ export interface DirectDestroy {
/** The release of child `value` (a loaded field, possibly NULL) inside a
* teardown whose chain length is `%d`: the child's own release for a shared
* or immortal reference, its teardown for the last one. Labels use `tag`;
- * the lines end in the open block `${tag}.next`. */
+ * the lines end in the open block `${tag}.next`.
+ *
+ * With `segment` (the child has the teardown's own type, in a segmented
+ * teardown; segmentPrologueLines), the child's teardown appends its blocks
+ * to the teardown's segment. */
export function directChildReleaseLines(
host: ShapeHost,
tag: string,
value: string,
target: DirectDestroy,
comment: string,
+ segment = false,
): string[] {
const S = host.sizeType;
const C = target.count ?? S;
- host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
+ if (segment) host.declare(`declare ptr @scr_rc_destroy_seg(ptr, ptr, ptr, ptr)`);
+ else host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const lines = [
` %${tag}.nn = icmp ne ptr ${value}, null`,
` br i1 %${tag}.nn, label %${tag}.chk, label %${tag}.next`,
@@ -1447,16 +1449,139 @@ export function directChildReleaseLines(
` br i1 %${tag}.short, label %${tag}.direct, label %${tag}.bounded`,
`${tag}.direct:`,
` %${tag}.d = add i32 %d, 1`,
- ` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
+ );
+ if (!segment) {
+ lines.push(
+ ` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
+ ` br label %${tag}.next`,
+ `${tag}.bounded:`,
+ ` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
+ ` br label %${tag}.next`,
+ `${tag}.next:`,
+ );
+ return lines;
+ }
+ lines.push(
+ ` %${tag}.t = load ptr, ptr %sg.t`,
+ ` %${tag}.dt = call ptr @${target.destroy}_s(ptr ${value}, i32 %${tag}.d, ptr %${tag}.t)`,
+ ` store ptr %${tag}.dt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.bounded:`,
- ` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
+ ` %${tag}.bt = load ptr, ptr %sg.t`,
+ ` %${tag}.rt = call ptr @scr_rc_destroy_seg(ptr ${value}, ptr @${target.destroy}_sc, ptr @${target.destroy}, ptr %${tag}.bt)`,
+ ` store ptr %${tag}.rt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.next:`,
);
return lines;
}
+/* Teardown segments. A typed teardown that frees each block onto its free
+ * list as it goes frees a tree in walk order, and the free lists, which are
+ * LIFO, hand the blocks to the next allocations in the reverse order: a tree
+ * built again in the same order as the last (each generation of a structure
+ * a loop rebuilds) is laid out mirrored, and every walk over it, the next
+ * teardown included, runs through memory backwards, which defeats the
+ * hardware prefetchers. The runtime frees large dead sets of the cycle
+ * collector in address order for the same reason (scr_mem_order_for_free).
+ *
+ * So the teardown of a type with children of its own type (the `_s` form,
+ * segmentedTeardown) keeps the blocks it frees in a segment, linked through
+ * their first words, which it builds in order through a tail pointer: the
+ * `_s` form takes the segment's last link (the first word of its last block,
+ * or the head slot of an empty segment) and returns the new one. A node
+ * first releases its own block (emitObjectFree, keeping it), then appends it
+ * ahead of its children's blocks when its address is the lower, behind them
+ * otherwise (segmentTeardownLines), and the children append theirs in field
+ * order, so a structure
+ * built parent first (object literals allocate before their fields) or
+ * children first (constructor arguments) comes back in ascending order. The
+ * chain's first teardown (`_d`) frees the whole segment onto the class's
+ * free list in one step (emitSegmentFree), and the walks over the next
+ * generation run forward. A teardown past DIRECT_DESTROY_CHAIN appends
+ * through scr_rc_destroy_seg, under the same depth budget as
+ * scr_rc_destroy. Blocks outside the calling thread's arena are freed as
+ * before; one type's blocks are all of one class. */
+
+/** Whether the teardown of a type whose reference fields have `fieldTypes`
+ * (with direct targets `targets`) and whose own teardown is `destroy` keeps
+ * a segment: a child field has its own type, and the inline allocation paths
+ * are in use. */
+export function segmentedTeardown(
+ host: ShapeHost,
+ destroy: string,
+ fieldTypes: readonly IrType[],
+ targets: ReadonlyMap,
+): boolean {
+ if (host.inlineAlloc !== true) return false;
+ return fieldTypes.some((t) => directDestroyTarget(host, t, targets)?.destroy === destroy);
+}
+
+/** The first lines of a segmented teardown, in its entry block: the
+ * segment's last link (`%sg.t`, from the `%tail` argument) and the kept own
+ * block (`%sg.own`, for emitObjectFree). */
+export function segmentPrologueLines(): string[] {
+ return [
+ ` %sg.t = alloca ptr`,
+ ` %sg.own = alloca ptr`,
+ ` store ptr %tail, ptr %sg.t`,
+ ` store ptr null, ptr %sg.own`,
+ ];
+}
+
+/** The rest of a segmented teardown after emitObjectFree kept (or freed)
+ * its own block: the block goes ahead of its children's when its address is
+ * below the first non-NULL one of `children` (loaded fields of the
+ * teardown's own type, which stand for their blocks) or there is none, and
+ * behind them otherwise; then the children's releases (`releases`, given a
+ * label prefix), and the segment's last link is returned. Each order has its
+ * own copy of the releases, so neither keeps the decision, or the block once
+ * it is appended, across the children's teardowns. */
+export function segmentTeardownLines(
+ children: readonly string[],
+ releases: (prefix: string) => string[],
+): string[] {
+ const lines = [` %sg.o = load ptr, ptr %sg.own`, ` %sg.kept = icmp ne ptr %sg.o, null`];
+ let ref = "null";
+ [...children].reverse().forEach((child, i) => {
+ lines.push(
+ ` %sg.nn${i} = icmp ne ptr ${child}, null`,
+ ` %sg.r${i} = select i1 %sg.nn${i}, ptr ${child}, ptr ${ref}`,
+ );
+ ref = `%sg.r${i}`;
+ });
+ lines.push(
+ ` %sg.leaf = icmp eq ptr ${ref}, null`,
+ ` %sg.lower = icmp ult ptr %sg.o, ${ref}`,
+ ` %sg.first = or i1 %sg.leaf, %sg.lower`,
+ ` %sg.ahead = and i1 %sg.kept, %sg.first`,
+ ` br i1 %sg.ahead, label %sg.front, label %sg.back`,
+ `sg.front:`,
+ ...segmentAppendLines("a"),
+ ...releases("ta"),
+ ` br label %sg.ret`,
+ `sg.back:`,
+ ...releases("tb"),
+ ` %sg.keep = icmp ne ptr %sg.o, null`,
+ ` br i1 %sg.keep, label %sg.last, label %sg.ret`,
+ `sg.last:`,
+ ...segmentAppendLines("l"),
+ ` br label %sg.ret`,
+ `sg.ret:`,
+ ` %sg.tr = load ptr, ptr %sg.t`,
+ ` ret ptr %sg.tr`,
+ );
+ return lines;
+}
+
+function segmentAppendLines(tag: string): string[] {
+ return [
+ ` %sg.t${tag} = load ptr, ptr %sg.t`,
+ ` store ptr %sg.o, ptr %sg.t${tag}`,
+ ` store ptr %sg.o, ptr %sg.t`,
+ ];
+}
+
/** The direct-teardown target of a reference of `type`, if `targets` (keyed
* `record:` / `object:`) has one. Nullable unions hold the arm's
* pointer, NULL or an immortal sentinel, which the child release handles. */
@@ -1499,12 +1624,42 @@ export function usedDirectTargets(
/** The teardown definitions for `destroy`: with `chained`, the body takes
* the chain length `%d` (directChildReleaseLines) and the runtime's
- * one-argument entry starts a chain. */
+ * one-argument entry starts a chain. With `segment` (the host, for a
+ * segmented teardown), the body is a segment teardown's
+ * (segmentPrologueLines to segmentTeardownLines), defined as `_s`, with
+ * `_sc` for scr_rc_destroy_seg, and `_d` starts a segment and frees it. */
export function destroyDefinitions(
destroy: string,
body: readonly string[],
chained: boolean,
+ segment: ShapeHost | null = null,
): string[] {
+ if (segment !== null)
+ return [
+ `define internal ptr @${destroy}_s(ptr %o, i32 %d, ptr %tail) ${FN_ATTRS} {`,
+ `entry:`,
+ ...body,
+ `}`,
+ `define internal ptr @${destroy}_sc(ptr %o, ptr %tail) ${FN_ATTRS} {`,
+ `entry:`,
+ ` %t = call ptr @${destroy}_s(ptr %o, i32 0, ptr %tail)`,
+ ` ret ptr %t`,
+ `}`,
+ `define internal void @${destroy}_d(ptr %o, i32 %d) ${FN_ATTRS} {`,
+ `entry:`,
+ ` %head = alloca ptr`,
+ ` store ptr null, ptr %head`,
+ ` %last = call ptr @${destroy}_s(ptr %o, i32 %d, ptr %head)`,
+ ` %first = load ptr, ptr %head`,
+ ...emitSegmentFree(segment, "sgf", "%first", "%last"),
+ ` ret void`,
+ `}`,
+ `define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
+ `entry:`,
+ ` call void @${destroy}_d(ptr %o, i32 0)`,
+ ` ret void`,
+ `}`,
+ ];
if (!chained)
return [
`define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
@@ -1527,6 +1682,17 @@ export function destroyDefinitions(
];
}
+/** The frozen bit of a freezable record's rc word (scr_rc.h SCR_RC_FROZEN):
+ * bit 62 on 64-bit targets, bit 30 on 32-bit ones. */
+function recordFrozenBit(sizeType: RcCount): bigint {
+ return 1n << (sizeType === "i64" ? 62n : 30n);
+}
+
+/** `value` as a signed LLVM integer literal of `sizeType`. */
+function sizeLiteral(sizeType: RcCount, value: bigint): string {
+ return BigInt.asIntN(sizeType === "i64" ? 64 : 32, value).toString();
+}
+
/** Common NULL/immortal/decrement skeleton for ordinary object releases.
* `freeBody` owns the zero-ref teardown and must leave the current block at
* its end; traced objects also get the possible-cycle-root branch.
@@ -1543,13 +1709,17 @@ export function releaseBody(
bounded = true,
immortal = false,
chained = false,
+ segmented = false,
+ frozen = false,
): string[] {
const S = host.sizeType;
if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const destroy = `${fnName}_destroy`;
+ // A freezable record's count is the word without its frozen bit.
+ const notFrozen = sizeLiteral(S, ~recordFrozenBit(S));
const lines = [
- ...(bounded ? destroyDefinitions(destroy, freeBody, chained) : []),
- ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal),
+ ...(bounded ? destroyDefinitions(destroy, freeBody, chained, segmented ? host : null) : []),
+ ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal, S, frozen),
`define internal void @${fnName}_slow(ptr %o) noinline ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`,
`entry:`,
` %isnull = icmp eq ptr %o, null`,
@@ -1561,7 +1731,9 @@ export function releaseBody(
`dec:`,
` %n = sub ${S} %rc, 1`,
` store ${S} %n, ptr %o`,
- ` %dead = icmp eq ${S} %n, 0`,
+ ...(frozen
+ ? [` %refs = and ${S} %n, ${notFrozen}`, ` %dead = icmp eq ${S} %refs, 0`]
+ : [` %dead = icmp eq ${S} %n, 0`]),
` br i1 %dead, label %free, label %${traced ? "root" : "done"}`,
`free:`,
...(traced ? cycleDeadLines(host, "teardown") : []),
@@ -1591,9 +1763,14 @@ export function releaseFastPath(
comment = "",
immortal = false,
count: RcCount = host.sizeType,
+ frozen = false,
): string[] {
const S = count;
if (immortal) return immortalRcBody(host, fnName, "void", comment);
+ // A freezable record compares its count without the frozen bit: an
+ // immortal word's count is then ~FROZEN, which the same compare sends to
+ // `slow`.
+ const notFrozen = ~recordFrozenBit(S);
return [
`define internal void @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (fast path)` : ""}`,
`entry:`,
@@ -1601,8 +1778,13 @@ export function releaseFastPath(
` br i1 %isnull, label %done, label %check`,
`check:`,
` %rc = load ${S}, ptr %o`,
- ` %rcm2 = sub ${S} %rc, 2`,
- ` %shared = icmp ult ${S} %rcm2, -3`,
+ ...(frozen
+ ? [
+ ` %refs = and ${S} %rc, ${sizeLiteral(S, notFrozen)}`,
+ ` %rcm2 = sub ${S} %refs, 2`,
+ ` %shared = icmp ult ${S} %rcm2, ${sizeLiteral(S, notFrozen - 2n)}`,
+ ]
+ : [` %rcm2 = sub ${S} %rc, 2`, ` %shared = icmp ult ${S} %rcm2, -3`]),
` br i1 %shared, label %dec, label %slow`,
`dec:`,
` %n = sub ${S} %rc, 1`,
@@ -1780,26 +1962,58 @@ export function emitRecordShapes(
const chained =
directTargets.has(`record:${shape.id}`) ||
refMembers.some((m) => directDestroyTarget(host, m.type, directTargets) !== null);
- for (const m of refMembers) {
- freeBody.push(
- ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
- ` %v${t} = load ptr, ptr %f${t}`,
+ const destroy = `${mangleRecordRelease(shape.id)}_destroy`;
+ const bounded = refMembers.some((member) => needsBoundedRelease(member.type));
+ const segmented =
+ bounded &&
+ segmentedTeardown(
+ host,
+ destroy,
+ refMembers.map((m) => m.type),
+ directTargets,
);
- const direct = directDestroyTarget(host, m.type, directTargets);
- if (direct) {
- freeBody.push(
- ...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(m.name)),
- );
- } else {
- freeBody.push(
- ` call void ${releaseSym(host, m.type)}(ptr %v${t}) ; ${llvmCommentText(m.name)}`,
- );
- }
- t++;
- }
// Closed layouts can carry a kept record source (record-sources.ts).
const observers = recordMayKeepSource(shape) ? "weak-or-source" : "weak";
- freeBody.push(...emitObjectFree(host, traced, observers));
+ // A segmented teardown loads its fields, releases its own block, and
+ // then releases the fields (segmentTeardownLines).
+ const loads: string[] = [];
+ const ownType: string[] = [];
+ const release = (prefix: string, m: (typeof refMembers)[number], i: number): string[] => {
+ const direct = directDestroyTarget(host, m.type, directTargets);
+ if (direct === null)
+ return [` call void ${releaseSym(host, m.type)}(ptr %v${i}) ; ${llvmCommentText(m.name)}`];
+ const own = segmented && direct.destroy === destroy;
+ return directChildReleaseLines(
+ host,
+ `${prefix}${i}`,
+ `%v${i}`,
+ direct,
+ llvmCommentText(m.name),
+ own,
+ );
+ };
+ for (const m of refMembers) {
+ const fieldLines = [
+ ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
+ ` %v${t} = load ptr, ptr %f${t}`,
+ ];
+ if (segmented) {
+ loads.push(...fieldLines);
+ if (directDestroyTarget(host, m.type, directTargets)?.destroy === destroy)
+ ownType.push(`%v${t}`);
+ } else freeBody.push(...fieldLines, ...release("td", m, t));
+ t++;
+ }
+ if (segmented)
+ freeBody.push(
+ ...segmentPrologueLines(),
+ ...loads,
+ ...emitObjectFree(host, traced, observers, "%sg.own"),
+ ...segmentTeardownLines(ownType, (prefix) =>
+ refMembers.flatMap((m, i) => release(prefix, m, i)),
+ ),
+ );
+ else freeBody.push(...emitObjectFree(host, traced, observers));
defs.push(
...releaseBody(
host,
@@ -1807,9 +2021,11 @@ export function emitRecordShapes(
traced,
freeBody,
"",
- refMembers.some((member) => needsBoundedRelease(member.type)),
+ bounded,
false,
chained,
+ segmented,
+ shape.freezable === true,
),
``,
);
diff --git a/packages/compiler/src/backend/llvm/stack-captures.ts b/packages/compiler/src/backend/llvm/stack-captures.ts
index f0812b7b0..40e1a0428 100644
--- a/packages/compiler/src/backend/llvm/stack-captures.ts
+++ b/packages/compiler/src/backend/llvm/stack-captures.ts
@@ -105,13 +105,7 @@ export class StackCaptures {
// sites, including duplicated finally bodies and versioned loops.
if (!this.locals.has(id) || this.remaining < 48) return null;
const ref = isRefCounted(type);
- if (
- !ref &&
- type.kind !== "f64" &&
- type.kind !== "date" &&
- type.kind !== "procStream" &&
- type.kind !== "bool"
- )
+ if (!ref && type.kind !== "f64" && type.kind !== "procStream" && type.kind !== "bool")
return null;
this.remaining -= 48;
const B = host.B;
diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts
index c7a58a7ea..ae2b904db 100644
--- a/packages/compiler/src/backend/llvm/stack-objects.ts
+++ b/packages/compiler/src/backend/llvm/stack-objects.ts
@@ -44,8 +44,9 @@ export type StackObjectFieldOwner = (fields: readonly StackObjectField[]) => voi
* standalone scalar class whose constructor never lets `this` escape
* (record-results.ts). The caller proves that every use of the value only
* projects it. Record fields may own references; the frame releases them
- * (stackObjectFields). Published shapes keep heap storage: their field
- * guards treat the immortal header as a frozen object. */
+ * (stackObjectFields). Published and freezable shapes keep heap storage:
+ * their field guards treat the immortal header as a frozen object, and a
+ * frozen record keeps its frozen bit in a counted header. */
export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource {
// A ThreadSlots key keeps its slot index for its lifetime and has its
// values released when it is freed: it lives on the heap.
@@ -53,6 +54,8 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO
return false;
if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true)
return false;
+ if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true)
+ return false;
// A producer with a result-slot entry builds its fresh result in the
// caller's storage.
if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee));
diff --git a/packages/compiler/src/backend/llvm/union-repr.ts b/packages/compiler/src/backend/llvm/union-repr.ts
index 959f9e51e..8c6fb4c85 100644
--- a/packages/compiler/src/backend/llvm/union-repr.ts
+++ b/packages/compiler/src/backend/llvm/union-repr.ts
@@ -8,8 +8,14 @@
import type { BlockBuilder } from "./blocks.js";
import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js";
-/** The i32 tag of a union value. */
-export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u: string): string {
+/** The i32 tag of a union value. `alias` is the tag word's alias
+ * attachment (", !tbaa !N"), when the caller has one. */
+export function emitUnionTag(
+ B: BlockBuilder,
+ nullable: PointerUnion | null,
+ u: string,
+ alias = "",
+): string {
const t = B.tmp();
if (nullable?.repr === "shapes") {
B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`);
@@ -31,7 +37,7 @@ export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u:
}
const p = B.tmp();
B.line(`${p} = getelementptr inbounds %ScrUnion, ptr ${u}, i64 0, i32 1`);
- B.line(`${t} = load i32, ptr ${p}`);
+ B.line(`${t} = load i32, ptr ${p}${alias}`);
return t;
}
@@ -81,12 +87,17 @@ export function emitNullableIsTag(
}
/** The BORROWED payload pointer of a reference arm. */
-export function emitUnionPeek(B: BlockBuilder, nullable: PointerUnion | null, u: string): string {
+export function emitUnionPeek(
+ B: BlockBuilder,
+ nullable: PointerUnion | null,
+ u: string,
+ alias = "",
+): string {
if (nullable) return u;
const p = B.tmp();
const t = B.tmp();
B.line(`${p} = getelementptr inbounds %ScrUnion, ptr ${u}, i64 0, i32 5`);
- B.line(`${t} = load ptr, ptr ${p}`);
+ B.line(`${t} = load ptr, ptr ${p}${alias}`);
return t;
}
diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts
index 331ea2199..58df3e845 100644
--- a/packages/compiler/src/backend/llvm/walkers.ts
+++ b/packages/compiler/src/backend/llvm/walkers.ts
@@ -234,6 +234,10 @@ export class LlWalkers {
this.host.declare(`declare void @scr_jb_put_json_str(ptr, ptr)`);
B.line(`call void @scr_jb_put_json_str(ptr %b, ptr %v)`);
break;
+ case "date":
+ this.host.declare(`declare void @scr_jb_put_date(ptr, ptr)`);
+ B.line(`call void @scr_jb_put_date(ptr %b, ptr %v) ; toJSON(): ISO text, null if invalid`);
+ break;
case "dyn":
// A checked-dynamic value serializes itself (runtime walker).
// Bare stringify of dyn stays frontend-fenced, and overflow
@@ -927,6 +931,17 @@ export class LlWalkers {
case "string":
this.putScrStr(B, buf, this.unionPeek(B, u, unionId));
break;
+ case "date": {
+ host.declare(`declare ptr @scr_date_object_to_string(ptr)`);
+ host.declare(`declare void @scr_str_release(ptr)`);
+ const text = B.tmp();
+ B.line(
+ `${text} = call ptr @scr_date_object_to_string(ptr ${this.unionPeek(B, u, unionId)})`,
+ );
+ this.putScrStr(B, buf, text);
+ B.line(`call void @scr_str_release(ptr ${text})`);
+ break;
+ }
case "f64": {
host.declare(`declare double @scr_union_get_f64(ptr)`);
host.declare(`declare void @scr_jb_put_number(ptr, double)`);
diff --git a/packages/compiler/src/backend/native-codegen-core.ts b/packages/compiler/src/backend/native-codegen-core.ts
index 5b9437cde..dd17466eb 100644
--- a/packages/compiler/src/backend/native-codegen-core.ts
+++ b/packages/compiler/src/backend/native-codegen-core.ts
@@ -6,19 +6,29 @@ export const NATIVE_CODEGEN_LLVM_VERSION = "22.1.8";
export type NativeCodegenOutputKind = "asm" | "obj";
-/** LLVM text per partition, and the most partitions one executable uses. */
+/** LLVM text that stays one module, LLVM text per partition, the fewest
+ * partitions (MIN_PROGRAM_PARTITIONS, of at least MIN_PARTITION_BYTES each)
+ * a divided module gets, and the most one executable uses. */
+const SINGLE_PARTITION_BYTES = 1024 * 1024;
const PROGRAM_PARTITION_BYTES = 4 * 1024 * 1024;
+const MIN_PARTITION_BYTES = 512 * 1024;
+const MIN_PROGRAM_PARTITIONS = 8;
const MAX_PROGRAM_PARTITIONS = 64;
-/** Executable objects this large are divided into partitions that the helper
- * simplifies, optimizes and compiles concurrently, importing small functions
- * across partitions for inlining, and reuses individually across builds. The
+/** Executable objects larger than SINGLE_PARTITION_BYTES are divided into
+ * partitions that the helper simplifies, optimizes and compiles concurrently,
+ * importing small functions across partitions for inlining, and reuses
+ * individually across builds. A divided module gets at least
+ * MIN_PROGRAM_PARTITIONS so that mid-size programs use several cores. The
* count is a power of two that depends only on the module's size, on every
* target, so artifacts never vary with the host and ordinary edits keep every
* function in its partition. */
export function nativeProgramPartitions(llvmBytes: number): number {
- const required = Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES);
- if (required < 2) return 1;
+ if (llvmBytes <= SINGLE_PARTITION_BYTES) return 1;
+ const required = Math.max(
+ Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES),
+ Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / MIN_PARTITION_BYTES)),
+ );
return Math.min(MAX_PROGRAM_PARTITIONS, 2 ** Math.ceil(Math.log2(required)));
}
diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts
index 545711a60..5463b868e 100644
--- a/packages/compiler/src/backend/native-codegen.test.ts
+++ b/packages/compiler/src/backend/native-codegen.test.ts
@@ -352,11 +352,13 @@ test("sanitized native artifacts fail before helper resolution", async () => {
test("program partitions depend only on module size", () => {
const megabyte = 1024 * 1024;
- expect(nativeProgramPartitions(4 * megabyte)).toBe(1);
- expect(nativeProgramPartitions(4 * megabyte + 1)).toBe(2);
+ expect(nativeProgramPartitions(megabyte)).toBe(1);
+ expect(nativeProgramPartitions(megabyte + 1)).toBe(4);
+ expect(nativeProgramPartitions(2 * megabyte)).toBe(4);
+ expect(nativeProgramPartitions(2 * megabyte + 1)).toBe(8);
+ expect(nativeProgramPartitions(32 * megabyte)).toBe(8);
// Powers of two keep every function in its partition across small edits.
- expect(nativeProgramPartitions(13 * megabyte)).toBe(4);
- expect(nativeProgramPartitions(16 * megabyte)).toBe(4);
+ expect(nativeProgramPartitions(32 * megabyte + 1)).toBe(16);
expect(nativeProgramPartitions(100 * megabyte)).toBe(32);
expect(nativeProgramPartitions(1024 * megabyte)).toBe(64);
expect(nativePartitionPaths("/build.d/program.o", 3)).toEqual([
diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts
index e018037fd..1e9994422 100644
--- a/packages/compiler/src/backend/native-link-info-core.ts
+++ b/packages/compiler/src/backend/native-link-info-core.ts
@@ -35,6 +35,8 @@ export interface NativeLinkFeatures {
* or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */
stdioExtended?: boolean;
zlib: boolean;
+ /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */
+ brotli?: boolean;
assert: boolean;
inspect: boolean;
dynInvoke: boolean;
@@ -81,6 +83,9 @@ export interface NativeLinkFeatures {
dnsPromises: boolean;
/** dgram sockets as untyped values: scr_dgram_dyn.c. */
dgramDyn: boolean;
+ /** TextEncoder and TextDecoder records as untyped values:
+ * scr_text_codec_dyn.c. */
+ textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts
index 61de82e72..40bb2352b 100644
--- a/packages/compiler/src/backend/native/contracts.ts
+++ b/packages/compiler/src/backend/native/contracts.ts
@@ -85,6 +85,9 @@ export interface CcOptions {
* compares round-trips and fixed-blob inflation, never raw deflate
* output. */
zlib?: boolean;
+ /** The program uses node:zlib's Brotli forms (with zlib): compiles
+ * scr_brotli.c and links the vendored brotli archive. */
+ brotli?: boolean;
/** The program uses node:assert (index.ts detects assert.* libCalls on
* the IR): compiles scr_assert.c — the zlib gating precedent, so
* assert-free binaries keep their exact size class. scr_regex.c calls
@@ -220,6 +223,10 @@ export interface CcOptions {
* scr_dgram_dyn.c over the dgram unit, so typed dgram binaries keep
* their exact link line. */
dgramDyn?: boolean;
+ /** A TextEncoder or TextDecoder record converts to or from an untyped
+ * value: compiles scr_text_codec_dyn.c, so typed codec binaries keep
+ * their exact link line. */
+ textCodecDyn?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index 233b9cac2..ceb162bcc 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -82,15 +82,18 @@ import {
currentVendorCacheBuildIdentity,
vendorBuildCacheRoot,
tlsArchivePath,
+ brotliArchivePath,
lreObjectPaths,
zlibObjectPaths,
stageVendorInputs,
ensureTlsArchive,
+ ensureBrotliArchive,
ensureLreObjects,
ensureZlibObjects,
vendorEngineDir,
vendorZlibDir,
vendorTlsDir,
+ vendorBrotliDir,
} from "./vendor.js";
import { execFileAsync, subprocessFailureDetail, CcCompileError } from "./process.js";
import { implicitLinkerFingerprint } from "./linker-fingerprint.js";
@@ -119,6 +122,7 @@ interface RuntimeUnitContext {
lreObjects: readonly string[];
zlibObjects: readonly string[];
tlsArchive: string | null;
+ brotliArchive: string | null;
}
/** An optional runtime input: runtime sources compiled when `when` holds,
@@ -222,6 +226,18 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
: []
: [],
),
+ // Brotli (scr_brotli.c) plugs into the zlib engine and links the
+ // vendored brotli archive.
+ unitArgs((u) =>
+ u.brotliArchive
+ ? [
+ "-I",
+ join(vendorBrotliDir(), "include"),
+ u.rt(join(u.rtDir, "scr_brotli.c")),
+ u.brotliArchive,
+ ]
+ : [],
+ ),
unit((u) => u.opts.events, "scr_events.c", "scr_readline.c"),
// readline's terminal mode: rl.terminal sets the regex unit it
// measures with.
@@ -302,6 +318,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dgram, "scr_dgram.c"),
unit((u) => u.opts.dgram && u.opts.dnsPromises, "scr_dns_promises.c"),
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
+ unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
@@ -397,6 +414,7 @@ export async function compileCInternal(
const dh = opts.dh ?? false;
const pki = opts.pki ?? false;
const mbedtls = tls || cipher || ecdh || dh || pki;
+ const brotli = (opts.zlib ?? false) && opts.brotli === true;
const driver = resolveCc();
const darwinDebugSymbols = needsDarwinDebugSymbols(
targetPlatform(driver),
@@ -636,6 +654,9 @@ export async function compileCInternal(
let tlsArchive = mbedtls
? tlsArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
: null;
+ let brotliArchive = brotli
+ ? brotliArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
+ : null;
// ours would collide; see scr_regex.c) — the standalone objects are for
// static builds only.
@@ -673,6 +694,23 @@ export async function compileCInternal(
: await stageVendorInputs(materialize, join(stageRoot, "tls"));
tlsArchive = paths[0]!;
}
+ if (brotli) {
+ const cachedArchive = brotliArchivePath(
+ sanitize,
+ driver,
+ vendorBuildIdentity,
+ materializeCacheRoot,
+ );
+ protectCachedArtifact(cacheWarmPaths, cachedArchive);
+ const materialize = async (): Promise => [
+ await ensureBrotliArchive(sanitize, driver, vendorBuildIdentity, materializeCacheRoot),
+ ];
+ const paths =
+ stageRoot === undefined
+ ? await materialize()
+ : await stageVendorInputs(materialize, join(stageRoot, "brotli"));
+ brotliArchive = paths[0]!;
+ }
if (regex) {
for (const object of lreObjectPaths(
sanitize,
@@ -759,6 +797,7 @@ export async function compileCInternal(
lreObjects,
zlibObjects,
tlsArchive,
+ brotliArchive,
}),
// A .ll program TU (the LLVM backend) deliberately carries no target
// triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET
diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts
index 6fb51fd32..c7c5bc32b 100644
--- a/packages/compiler/src/backend/native/runtime-inputs.ts
+++ b/packages/compiler/src/backend/native/runtime-inputs.ts
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-import { MBEDTLS_VERSION, QJS_COMMIT, ZLIB_VERSION } from "../vendor-inputs.js";
+import { BROTLI_VERSION, MBEDTLS_VERSION, QJS_COMMIT, ZLIB_VERSION } from "../vendor-inputs.js";
import { stableTestMemo } from "./session.js";
const implementationPath = fileURLToPath(import.meta.url);
@@ -134,6 +134,7 @@ async function runtimeFingerprintFresh(rtDir: string): Promise {
.update(QJS_COMMIT)
.update(MBEDTLS_VERSION)
.update(ZLIB_VERSION)
+ .update(BROTLI_VERSION)
.update("\0backend-recipe\0");
for (const path of NATIVE_RECIPE_IMPLEMENTATION_PATHS) {
h.update(basename(path))
@@ -164,6 +165,7 @@ async function runtimeFingerprintInputGroups(
{ label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
{ label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
{ label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
+ { label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") },
{ label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
].map(async (group) => {
const names = (await nativeSourceFiles(group.dir, true))
diff --git a/packages/compiler/src/backend/native/vendor.ts b/packages/compiler/src/backend/native/vendor.ts
index cf7b3a68d..b6084d5c4 100644
--- a/packages/compiler/src/backend/native/vendor.ts
+++ b/packages/compiler/src/backend/native/vendor.ts
@@ -6,6 +6,9 @@ import { resolvedToolIdentity } from "./tool-identity.js";
export const {
vendorEngineDir,
vendorTlsDir,
+ vendorBrotliDir,
+ brotliArchivePath,
+ ensureBrotliArchive,
vendorBuildCacheRoot,
vendorCacheTargetFlavor,
vendorCacheBuildIdentity,
diff --git a/packages/compiler/src/backend/publication.test.ts b/packages/compiler/src/backend/publication.test.ts
index 149ea4bb9..7b3e5f5cc 100644
--- a/packages/compiler/src/backend/publication.test.ts
+++ b/packages/compiler/src/backend/publication.test.ts
@@ -85,3 +85,34 @@ test("published types close over fields, containers and whole hierarchies", () =
computePublishedTypes({ ...withStores, functions: withStores.functions.slice(1) }),
).toBeNull();
});
+
+test("freezable array types carry the array write guard without a publish", () => {
+ const numbers = arrayOf(F64);
+ const push: IrStmt = {
+ kind: "exprStmt",
+ expr: {
+ kind: "arrIntrinsic",
+ method: "push",
+ receiver: ref("a", numbers),
+ args: [{ kind: "numLit", value: 1, type: F64, loc }],
+ type: F64,
+ loc,
+ },
+ loc,
+ };
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "main",
+ functions: [fn("main", []), fn("pushNumber", [push])],
+ freezableArrays: ["array"],
+ };
+ const guarded = computePublishedTypes(mod)!;
+ expect(guarded.containerGuarded(numbers)).toBe(true);
+ expect(guarded.arrayGuards(numbers)).toEqual({ published: false, frozen: true });
+ expect(guarded.containerGuarded(arrayOf(STRING))).toBe(false);
+ // A frozen array's push throws, so the function may throw.
+ expect(computeMayThrow(mod).fns.has("pushNumber")).toBe(true);
+ const { freezableArrays: _frozen, ...plain } = mod;
+ expect(computePublishedTypes(plain)).toBeNull();
+});
diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts
index 473a0cba4..38e429a47 100644
--- a/packages/compiler/src/backend/publication.ts
+++ b/packages/compiler/src/backend/publication.ts
@@ -46,6 +46,10 @@ export class PublishedTypes {
readonly records = new Set();
/** typeKeys of array, map and set types. */
readonly containers = new Set();
+ /** typeKeys of the array types the program may freeze
+ * (IrModule.freezableArrays): a frozen array refuses writes like a
+ * published one, so their stores carry the same guard. */
+ readonly frozenArrays = new Set();
/** Constructor stores into a `this` nothing else can reach yet
* (immutability.ts): never a published target, so never guarded. */
privateStores: ReadonlySet = new Set();
@@ -64,10 +68,16 @@ export class PublishedTypes {
}
containerGuarded(t: IrType): boolean {
- return (
- (t.kind === "array" || t.kind === "map" || t.kind === "set") &&
- this.containers.has(typeKey(t))
- );
+ if (t.kind !== "array" && t.kind !== "map" && t.kind !== "set") return false;
+ const key = typeKey(t);
+ return this.containers.has(key) || this.frozenArrays.has(key);
+ }
+
+ /** Whether an array of type `t` may be published, and whether it may be
+ * frozen: the two states its write guard tests. */
+ arrayGuards(t: IrType): { published: boolean; frozen: boolean } {
+ const key = typeKey(t);
+ return { published: this.containers.has(key), frozen: this.frozenArrays.has(key) };
}
}
@@ -87,7 +97,13 @@ export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): Publi
)
roots.push(node.type);
});
- if (roots.length === 0) return null;
+ const frozenArrays = mod.freezableArrays ?? [];
+ if (roots.length === 0 && frozenArrays.length === 0) return null;
+ if (roots.length === 0) {
+ const frozen = new PublishedTypes();
+ for (const key of frozenArrays) frozen.frozenArrays.add(key);
+ return frozen;
+ }
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const children = new Map();
@@ -100,6 +116,7 @@ export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): Publi
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const out = new PublishedTypes();
+ for (const key of frozenArrays) out.frozenArrays.add(key);
out.privateStores = computeConstructionFacts(mod, nodes).privateStores;
const seen = new Set();
const stack = [...roots];
diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts
index 75953be59..49165d8df 100644
--- a/packages/compiler/src/backend/runtime-pack-core.ts
+++ b/packages/compiler/src/backend/runtime-pack-core.ts
@@ -34,7 +34,7 @@ interface RuntimePackUnit {
}
interface RuntimePackArchive extends RuntimePackArtifact {
- id: "quickjs" | "libregexp" | "zlib" | "mbedtls";
+ id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls";
predicate: RuntimePredicate;
}
@@ -93,6 +93,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
builtinValues: boolean;
warningReplace: boolean;
stdioExtended: boolean;
+ brotli: boolean;
nativeFetch: boolean;
netEffective: boolean;
httpEffective: boolean;
@@ -291,6 +292,7 @@ export function effectiveRuntimeFeatures(
builtinValues: features.builtinValues === true,
warningReplace: features.warningReplace === true,
stdioExtended: features.stdioExtended === true,
+ brotli: features.brotli === true,
nativeFetch,
netEffective,
httpEffective,
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index 44c3d6290..9846e8d89 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -118,6 +118,7 @@ const features = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index e3bc999a7..e2b24f8ca 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -54,6 +54,7 @@ const BASE: NativeLinkFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/target-cpu.test.ts b/packages/compiler/src/backend/target-cpu.test.ts
new file mode 100644
index 000000000..428caee32
--- /dev/null
+++ b/packages/compiler/src/backend/target-cpu.test.ts
@@ -0,0 +1,51 @@
+import { expect, test } from "vitest";
+import {
+ isTargetCpuOption,
+ targetCpuAttribute,
+ targetCpuField,
+ targetCpuKeyParts,
+ targetCpuTargetRefusal,
+ x86Level,
+} from "./target-cpu.js";
+
+const V2 = ["cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"];
+const V3 = ["avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "abm", "movbe", "xsave"];
+const V4 = ["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"];
+const level = (flags: string[]) => x86Level((flag) => flags.includes(flag));
+
+test("a host's level is the highest one whose features and lower levels' features it has", () => {
+ expect(level([])).toBe("baseline");
+ expect(level(V2)).toBe("x86-64-v2");
+ expect(level([...V2, ...V3])).toBe("x86-64-v3");
+ expect(level([...V2, ...V3, ...V4])).toBe("x86-64-v4");
+ // AVX-512 without BMI2 is still v2.
+ expect(level([...V2, ...V3.filter((f) => f !== "bmi2"), ...V4])).toBe("x86-64-v2");
+ expect(level([...V3, ...V4])).toBe("baseline");
+});
+
+test("the baseline adds no key part, attribute or option field", () => {
+ for (const cpu of [undefined, "baseline"] as const) {
+ expect(targetCpuKeyParts(cpu)).toEqual([]);
+ expect(targetCpuAttribute(cpu)).toBe("");
+ expect(targetCpuField(cpu)).toEqual({});
+ expect(targetCpuTargetRefusal(cpu, "linux", "arm64")).toBeNull();
+ }
+ expect(targetCpuKeyParts("x86-64-v3")).toEqual(["target-cpu-x86-64-v3"]);
+ expect(targetCpuAttribute("x86-64-v4")).toBe(' "target-cpu"="x86-64-v4"');
+ expect(targetCpuField("x86-64-v2")).toEqual({ targetCpu: "x86-64-v2" });
+});
+
+test("a level is refused for targets that are not x86-64", () => {
+ expect(targetCpuTargetRefusal("x86-64-v3", "linux", "x64")).toBeNull();
+ expect(targetCpuTargetRefusal("x86-64-v3", "darwin", "arm64")).toBe(
+ "--target-cpu=x86-64-v3 requires an x86-64 target",
+ );
+ expect(targetCpuTargetRefusal("x86-64-v3", "wasi", "x64")).not.toBeNull();
+});
+
+test("the option accepts the levels and native", () => {
+ for (const value of ["baseline", "x86-64-v2", "x86-64-v3", "x86-64-v4", "native"])
+ expect(isTargetCpuOption(value)).toBe(true);
+ for (const value of ["x86-64", "haswell", "", undefined])
+ expect(isTargetCpuOption(value)).toBe(false);
+});
diff --git a/packages/compiler/src/backend/target-cpu.ts b/packages/compiler/src/backend/target-cpu.ts
new file mode 100644
index 000000000..51db87bbb
--- /dev/null
+++ b/packages/compiler/src/backend/target-cpu.ts
@@ -0,0 +1,118 @@
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+
+/** The x86-64 microarchitecture levels an executable may require
+ * (`--target-cpu`). `baseline` is the x86-64 baseline every release build
+ * targets; the other levels are LLVM's `x86-64-v2` to `x86-64-v4`. */
+export type TargetCpu = "baseline" | "x86-64-v2" | "x86-64-v3" | "x86-64-v4";
+
+/** The values `--target-cpu` accepts: a level, or `native` for the highest
+ * level the build host supports. */
+export const TARGET_CPU_OPTIONS = ["baseline", "x86-64-v2", "x86-64-v3", "x86-64-v4", "native"];
+
+export function isTargetCpuOption(value: unknown): value is TargetCpu | "native" {
+ return typeof value === "string" && TARGET_CPU_OPTIONS.includes(value);
+}
+
+/** The CPU features each level adds to the one below it, as Linux names
+ * them in /proc/cpuinfo (`pni` is SSE3, `abm` includes LZCNT). */
+const LEVEL_FLAGS: readonly [TargetCpu, readonly string[]][] = [
+ ["x86-64-v2", ["cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"]],
+ ["x86-64-v3", ["avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "abm", "movbe", "xsave"]],
+ ["x86-64-v4", ["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"]],
+];
+
+/** The macOS `machdep.cpu` spellings of the same features. */
+const DARWIN_FLAGS: Readonly> = {
+ cx16: "CX16",
+ lahf_lm: "LAHF",
+ popcnt: "POPCNT",
+ pni: "SSE3",
+ sse4_1: "SSE4.1",
+ sse4_2: "SSE4.2",
+ ssse3: "SSSE3",
+ avx: "AVX1.0",
+ avx2: "AVX2",
+ bmi1: "BMI1",
+ bmi2: "BMI2",
+ f16c: "F16C",
+ fma: "FMA",
+ abm: "LZCNT",
+ movbe: "MOVBE",
+ xsave: "XSAVE",
+ avx512f: "AVX512F",
+ avx512bw: "AVX512BW",
+ avx512cd: "AVX512CD",
+ avx512dq: "AVX512DQ",
+ avx512vl: "AVX512VL",
+};
+
+/** The highest level whose features (and every lower level's) are all in
+ * `has`. */
+export function x86Level(has: (flag: string) => boolean): TargetCpu {
+ let level: TargetCpu = "baseline";
+ for (const [next, flags] of LEVEL_FLAGS) {
+ if (!flags.every(has)) break;
+ level = next;
+ }
+ return level;
+}
+
+/** The highest level the build host supports, or null when the host is not
+ * an x86-64 Linux or macOS machine. */
+export function hostTargetCpu(
+ platform: string = process.platform,
+ arch: string = process.arch,
+): TargetCpu | null {
+ if (arch !== "x64") return null;
+ try {
+ if (platform === "linux") {
+ const info = readFileSync("/proc/cpuinfo", "utf8");
+ const line = /^flags\s*:(.*)$/m.exec(info);
+ if (!line) return null;
+ const flags = new Set(line[1]!.trim().split(/\s+/));
+ return x86Level((flag) => flags.has(flag));
+ }
+ if (platform === "darwin") {
+ const text = execFileSync(
+ "sysctl",
+ ["-n", "machdep.cpu.features", "machdep.cpu.leaf7_features", "machdep.cpu.extfeatures"],
+ { encoding: "utf8" },
+ );
+ const flags = new Set(text.trim().split(/\s+/));
+ return x86Level((flag) => flags.has(DARWIN_FLAGS[flag]!));
+ }
+ } catch {
+ return null;
+ }
+ return null;
+}
+
+/** Cache-key parts for a target level; empty for the baseline so existing
+ * keys are unchanged. */
+export function targetCpuKeyParts(cpu: TargetCpu | undefined): string[] {
+ return cpu === undefined || cpu === "baseline" ? [] : [`target-cpu-${cpu}`];
+}
+
+/** The function attribute that makes LLVM generate code for the level, or
+ * "" for the baseline. */
+export function targetCpuAttribute(cpu: TargetCpu | undefined): string {
+ return cpu === undefined || cpu === "baseline" ? "" : ` "target-cpu"="${cpu}"`;
+}
+
+/** The options field for a level: omitted for the baseline. */
+export function targetCpuField(cpu: TargetCpu | undefined): { targetCpu?: TargetCpu } {
+ return cpu === undefined || cpu === "baseline" ? {} : { targetCpu: cpu };
+}
+
+/** The error for a level on a target that is not x86-64, or null. */
+export function targetCpuTargetRefusal(
+ cpu: TargetCpu | undefined,
+ platform: string,
+ architecture: string,
+): string | null {
+ if (cpu === undefined || cpu === "baseline") return null;
+ if (architecture !== "x64" || platform === "wasi")
+ return `--target-cpu=${cpu} requires an x86-64 target`;
+ return null;
+}
diff --git a/packages/compiler/src/backend/vendor-archives.ts b/packages/compiler/src/backend/vendor-archives.ts
index daa7e5eaa..dbcaf2d36 100644
--- a/packages/compiler/src/backend/vendor-archives.ts
+++ b/packages/compiler/src/backend/vendor-archives.ts
@@ -18,6 +18,7 @@ import {
QJS_COMMIT,
MBEDTLS_VERSION,
ZLIB_VERSION,
+ BROTLI_VERSION,
LRE_SOURCES,
ZLIB_SOURCES,
} from "./vendor-inputs.js";
@@ -25,6 +26,7 @@ export {
QJS_COMMIT,
MBEDTLS_VERSION,
ZLIB_VERSION,
+ BROTLI_VERSION,
LRE_SOURCES,
ZLIB_SOURCES,
} from "./vendor-inputs.js";
@@ -418,9 +420,98 @@ export function createVendorArchives(context: VendorArchiveContext) {
return archive;
}
+ function vendorBrotliDir(): string {
+ return join(runtimeSrcDir(), "..", "vendor", "brotli", "c");
+ }
+
+ function brotliArchivePath(
+ sanitize: boolean,
+ driver: CcDriver,
+ buildIdentity: string,
+ cacheRoot: string = vendorBuildCacheRoot(),
+ ): string {
+ const flavor = `${sanitize ? "asan" : "plain"}-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
+ return join(cacheRoot, `brotli-${BROTLI_VERSION}-${flavor}`, "libbrotli.a");
+ }
+
+ /** The brotli archive for one flavor, compiled lazily on the first
+ * Brotli-using build and cached under
+ * /vendor/brotli----/libbrotli.a,
+ * with the mbedTLS archive's recipe: per-TU compiles of c/common, c/dec
+ * and c/enc against c/include, then one `ar rcs`. dec/ and enc/ share
+ * file names, so each object is named after its path (dec-static_init.o). */
+ async function ensureBrotliArchive(
+ sanitize: boolean,
+ driver: CcDriver,
+ buildIdentity: string,
+ cacheRoot: string = vendorBuildCacheRoot(),
+ ): Promise {
+ const flavor = `${sanitize ? "asan" : "plain"}-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
+ const compileArgv = isZigDriver(driver) ? driver.argv : ["clang"];
+ const arArgv = isZigDriver(driver) ? [...driver.argv.slice(0, 1), "ar"] : ["ar"];
+ const vendor = vendorBrotliDir();
+ const archive = brotliArchivePath(sanitize, driver, buildIdentity, cacheRoot);
+ if (await validVendorArtifact(archive)) return archive;
+
+ await mkdir(cacheRoot, { recursive: true });
+ const buildDir = await mkdtemp(join(tmpdir(), `scriptc-vendor-brotli-${flavor}-`));
+ try {
+ const sources = (
+ await Promise.all(
+ ["common", "dec", "enc"].map(async (dir) =>
+ (await readdir(join(vendor, dir)))
+ .filter((name) => !name.startsWith(".") && name.endsWith(".c"))
+ .map((name) => `${dir}/${name}`),
+ ),
+ )
+ )
+ .flat()
+ .sort();
+ const object = (src: string): string =>
+ join(buildDir, `${src.replaceAll("/", "-").replace(/\.c$/, "")}.o`);
+ const cflags = [
+ "-std=c11",
+ ...driver.targetArgs,
+ ...(sanitize ? ["-O1", "-fsanitize=address"] : ["-Os"]),
+ "-I",
+ join(vendor, "include"),
+ ];
+ const width = availableParallelism();
+ for (let i = 0; i < sources.length; i += width) {
+ await Promise.all(
+ sources
+ .slice(i, i + width)
+ .map((src) =>
+ execFileAsync(compileArgv[0] ?? "clang", [
+ ...compileArgv.slice(1),
+ ...cflags,
+ "-c",
+ join(vendor, src),
+ "-o",
+ object(src),
+ ]),
+ ),
+ );
+ }
+ await execFileAsync(arArgv[0] ?? "ar", [
+ ...arArgv.slice(1),
+ "rcs",
+ join(buildDir, "libbrotli.a"),
+ ...sources.map(object),
+ ]);
+ await publishVendorArtifact(join(buildDir, "libbrotli.a"), archive);
+ } finally {
+ await rm(buildDir, { recursive: true, force: true });
+ }
+ return archive;
+ }
+
return {
vendorEngineDir,
vendorTlsDir,
+ vendorBrotliDir,
+ brotliArchivePath,
+ ensureBrotliArchive,
vendorBuildCacheRoot,
vendorCacheTargetFlavor,
vendorCacheBuildIdentity,
diff --git a/packages/compiler/src/backend/vendor-inputs.ts b/packages/compiler/src/backend/vendor-inputs.ts
index 3d2710fa0..a9d13e2f0 100644
--- a/packages/compiler/src/backend/vendor-inputs.ts
+++ b/packages/compiler/src/backend/vendor-inputs.ts
@@ -1,7 +1,9 @@
-/** The pinned QuickJS, mbedTLS, and zlib inputs used by native recipes. */
+/** The pinned QuickJS, mbedTLS, zlib, and brotli inputs used by native recipes. */
export const QJS_COMMIT = "3c8f3d68953955950074c41c6e4d999562ae82a7";
export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.1";
+/** The brotli release Node v24.22.0 bundles (packages/runtime/vendor/brotli). */
+export const BROTLI_VERSION = "1.2.0";
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts
index b48cf6739..02acb5462 100644
--- a/packages/compiler/src/backend/warm-cache.ts
+++ b/packages/compiler/src/backend/warm-cache.ts
@@ -61,6 +61,7 @@ export async function warmNativeCaches(
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts
index a660d339a..608b97b67 100644
--- a/packages/compiler/src/cli/command.ts
+++ b/packages/compiler/src/cli/command.ts
@@ -22,6 +22,13 @@ import { buildEnvelope } from "../diagnostics/envelope.js";
import { renderConfigWarnings, renderWarnings } from "../diagnostics/render.js";
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
+import {
+ hostTargetCpu,
+ isTargetCpuOption,
+ TARGET_CPU_OPTIONS,
+ targetCpuField,
+ type TargetCpu,
+} from "../backend/target-cpu.js";
import { setProvenanceSources } from "../frontend/provenance-registry.js";
import type { CliHost, NativeCacheWarmProfile } from "./host.js";
import { resolveOutputOptions } from "./output-options.js";
@@ -103,6 +110,7 @@ async function main(args: string[], host: CliHost): Promise {
values.strip ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
+ values["target-cpu"] !== undefined ||
values["profile-generate"] !== undefined ||
values["profile-use"] !== undefined ||
values.sparse ||
@@ -162,6 +170,7 @@ async function main(args: string[], host: CliHost): Promise {
values.sanitize ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
+ values["target-cpu"] !== undefined ||
values["profile-generate"] !== undefined ||
values["profile-use"] !== undefined ||
(values["npm-static"] ?? []).length > 0 ||
@@ -219,6 +228,7 @@ async function main(args: string[], host: CliHost): Promise {
values.ffi !== undefined ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
+ values["target-cpu"] !== undefined ||
values["profile-generate"] !== undefined ||
values["profile-use"] !== undefined ||
(values["npm-static"] ?? []).length > 0 ||
@@ -228,7 +238,7 @@ async function main(args: string[], host: CliHost): Promise {
hasNodeIslandFlags(values)
) {
fail(
- "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
+ "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
);
}
const profilePath = resolve(profileArg);
@@ -271,6 +281,9 @@ async function main(args: string[], host: CliHost): Promise {
if (command === "coverage" && values.strip) {
fail(`--strip is a build/run option\n\n${USAGE}`);
}
+ if (command === "coverage" && values["target-cpu"] !== undefined) {
+ fail(`--target-cpu is a build/run option\n\n${USAGE}`);
+ }
if (command === "coverage" && values["detached-exit"]) {
fail(`--detached-exit is a build/run option\n\n${USAGE}`);
}
@@ -424,6 +437,19 @@ async function main(args: string[], host: CliHost): Promise {
if (windowsSubsystem !== undefined && command === "coverage") {
fail(`--windows-subsystem is only supported for executable builds\n\n${USAGE}`);
}
+ const targetCpuOption = values["target-cpu"];
+ if (targetCpuOption !== undefined && !isTargetCpuOption(targetCpuOption)) {
+ fail(
+ `unknown target CPU "${targetCpuOption}" (supported: ${TARGET_CPU_OPTIONS.join(", ")})\n\n${USAGE}`,
+ );
+ }
+ let targetCpu: TargetCpu | undefined = targetCpuOption === "native" ? undefined : targetCpuOption;
+ if (targetCpuOption === "native") {
+ const level = hostTargetCpu();
+ if (level === null)
+ fail(`--target-cpu=native needs an x86-64 Linux or macOS build host; name a level instead`);
+ targetCpu = level;
+ }
const output =
command === "coverage"
? null
@@ -557,6 +583,7 @@ async function main(args: string[], host: CliHost): Promise {
...(values.strip ? { strip: true } : {}),
...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
...(values["detached-exit"] ? { detachedExit: true } : {}),
+ ...targetCpuField(targetCpu),
// The instrumented program resolves a relative pattern against its own
// working directory; resolve it here, where the user wrote it.
...(values["profile-generate"] === undefined
diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts
index 113b11fe5..f8b6a4869 100644
--- a/packages/compiler/src/cli/usage.ts
+++ b/packages/compiler/src/cli/usage.ts
@@ -56,6 +56,12 @@ Options:
a helper process so the parent sees the exit sooner. The
helper keeps the executable mapped and its memory charged
for up to ~100 ms after exit (opt in; --emit=exe only)
+ --target-cpu
+ x86-64: compile the program's code for a CPU level
+ (default: baseline, which runs on any x86-64 CPU). The
+ executable then requires that level: v3 adds AVX2, BMI2
+ and FMA, v4 adds AVX-512. native uses the build host's
+ level. The runtime keeps its baseline code
--profile-generate
Linux: instrument the executable for profile-guided
optimization. Each run writes a raw profile to the
@@ -147,6 +153,7 @@ export const CLI_OPTIONS = {
strip: { type: "boolean", default: false },
"windows-subsystem": { type: "string" },
"detached-exit": { type: "boolean", default: false },
+ "target-cpu": { type: "string" },
"profile-generate": { type: "string" },
"profile-use": { type: "string" },
sparse: { type: "boolean", default: false },
diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts
index c4d1e3f50..ff7cedb22 100644
--- a/packages/compiler/src/compat/fetch-profile.ts
+++ b/packages/compiler/src/compat/fetch-profile.ts
@@ -900,7 +900,7 @@ export const NODE24_FETCH_COMPAT_PROFILE = {
{
name: "WritableStream/TransformStream",
reason:
- "native support currently covers readable fetch bodies; general Web Streams graphs are unsupported",
+ "native support covers readable fetch bodies and the identity `new TransformStream()`; the WritableStream constructor, transformers, and general Web Streams graphs are unsupported",
},
{
name: "Readable byte/BYOB streams",
diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts
index 39fea3acf..607fe6a51 100644
--- a/packages/compiler/src/compile-types.ts
+++ b/packages/compiler/src/compile-types.ts
@@ -1,3 +1,4 @@
+import type { TargetCpu } from "./backend/target-cpu.js";
import type { WindowsSubsystem } from "./backend/targets.js";
import type { NativeLinkInfo } from "./backend/native-link-info.js";
import type { NativeOptimization } from "./backend/optimization.js";
@@ -37,6 +38,12 @@ export interface CompileBaseOptions {
* executable mapped and the memory charged for up to ~100 ms after exit.
* Executables only; no effect on other platforms. */
detachedExit?: boolean;
+ /** The x86-64 microarchitecture level the executable's code may require
+ * (`x86-64-v2` to `x86-64-v4`). The program's functions are compiled for
+ * that level, and the executable then runs only on CPUs that support it.
+ * Unset or `baseline`, the code runs on any x86-64 CPU. x86-64 targets
+ * only; the runtime pack keeps its baseline code. */
+ targetCpu?: TargetCpu;
/** Instrument the executable for profile-guided optimization. Each run
* writes a raw profile to this path pattern (`%p` expands to the process
* id; `LLVM_PROFILE_FILE` overrides the pattern at run time). Linux
diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts
index bb5efba31..0da37f690 100644
--- a/packages/compiler/src/diagnostics/diagnostic.ts
+++ b/packages/compiler/src/diagnostics/diagnostic.ts
@@ -737,7 +737,7 @@ export function genericSignatureTypeDiag(typeText: string, loc: SrcLoc): ScrDiag
export function indexSignatureTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2006",
- message: `values of type '${typeText}' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown')`,
+ message: `values of type '${typeText}' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Dates, Promises, or 'unknown')`,
loc,
milestone: "M2",
hint: "string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering",
diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts
index 5cf8c6588..e1235d8ac 100644
--- a/packages/compiler/src/executable/executable-cache.test.ts
+++ b/packages/compiler/src/executable/executable-cache.test.ts
@@ -58,6 +58,7 @@ const native: EarlyExecutableNativeFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -235,6 +236,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, optimization: "dev" },
{ ...f.options, optimization: "speed" },
{ ...f.options, detachedExit: true },
+ { ...f.options, targetCpu: "x86-64-v3" },
+ { ...f.options, targetCpu: "x86-64-v4" },
{ ...f.options, profile: "profile-generate:/tmp/%p.profraw" },
{ ...f.options, profile: "profile-use:digest" },
{ ...f.options, checker: "tsc-ts-executable-identity" },
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index 7b7febc06..754c20648 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -18,6 +18,7 @@ import { compilerReleaseVersion } from "../library/sidecar.js";
import { nativeArtifactDependenciesStillMatch } from "../backend/native/artifact-stamps.js";
import { type NativeArtifactDependency } from "../backend/native/contracts.js";
import { optimizationKeyParts } from "../backend/optimization.js";
+import { type TargetCpu, targetCpuKeyParts } from "../backend/target-cpu.js";
import { privateSiblingPath } from "../backend/build-cache.js";
import type { CompilerImplementationDependency } from "../library/compiler-self-identity.js";
import {
@@ -62,6 +63,7 @@ export interface EarlyExecutableNativeFeatures {
warningReplace?: boolean;
stdioExtended?: boolean;
zlib: boolean;
+ brotli?: boolean;
assert: boolean;
inspect: boolean;
dynInvoke: boolean;
@@ -92,6 +94,7 @@ export interface EarlyExecutableNativeFeatures {
dgram: boolean;
dnsPromises: boolean;
dgramDyn: boolean;
+ textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -138,6 +141,8 @@ export interface EarlyExecutableCacheOptions {
windowsSubsystem?: "gui";
/** Omitted unless --detached-exit is set. */
detachedExit?: true;
+ /** Omitted for the x86-64 baseline (`--target-cpu`). */
+ targetCpu?: TargetCpu;
/** Omitted without --profile-generate/--profile-use; otherwise the
* pattern, or the digest of the profile's bytes. */
profile?: string;
@@ -235,6 +240,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgram",
"dnsPromises",
"dgramDyn",
+ "textCodecDyn",
"watch",
"foreignFfi",
"nodeTest",
@@ -279,6 +285,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.strip ? ["strip"] : []),
...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
...(options.detachedExit ? ["detached-exit"] : []),
+ ...targetCpuKeyParts(options.targetCpu),
...(options.profile !== undefined ? [options.profile] : []),
...(options.checker !== undefined ? [`checker:${options.checker}`] : []),
...(options.youngNursery ? ["young-nursery"] : []),
@@ -321,6 +328,7 @@ function routeKey(options: Omit();
+ /** While set, printing registers no recursive alias (projectable()). */
+ private dry = false;
+
constructor(private readonly checker: ts.TypeChecker) {}
+ /** Whether a type projects, without naming it in the generated file. */
+ projectable(type: ts.Type): boolean {
+ if (type.flags & ANYISH) return false;
+ const was = this.dry;
+ this.dry = true;
+ try {
+ const text = this.print(type, 0);
+ return text !== null && text !== "unknown";
+ } finally {
+ this.dry = was;
+ }
+ }
+
/** The alias naming a projectable type, or null. Top-level any/unknown
* never projects. */
project(type: ts.Type): string | null {
@@ -212,7 +228,12 @@ class TypePrinter {
if (f & ts.TypeFlags.Null) return "null";
if (f & ts.TypeFlags.Undefined) return "undefined";
if (f & ts.TypeFlags.EnumLiteral || f & ts.TypeFlags.Enum) return null;
- if (f & ts.TypeFlags.BooleanLiteral) return type.intrinsicName === "true" ? "true" : "false";
+ // A boolean literal type carries its value (`intrinsicName` is not
+ // set for it by every checker facade).
+ if (f & ts.TypeFlags.BooleanLiteral)
+ return (type as ts.BooleanLiteralType).value === true || type.intrinsicName === "true"
+ ? "true"
+ : "false";
if (f & ts.TypeFlags.StringLiteral) return JSON.stringify(String(type.value));
if (f & ts.TypeFlags.NumberLiteral) {
const v = Number(type.value);
@@ -341,7 +362,7 @@ class TypePrinter {
if (props.length > 0 && readonlyCount === props.length) return null;
const text = `{ ${members.join("; ")} }`;
if (frame.name !== null) {
- this.declarations.push(`type ${frame.name} = ${text};`);
+ if (!this.dry) this.declarations.push(`type ${frame.name} = ${text};`);
return frame.name;
}
return text;
@@ -517,9 +538,17 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
const declSf = input.declProgram.getSourceFile(file);
if (jsSf === undefined || declSf === undefined || jsSf.text !== declSf.text) continue;
const fileEdits: Edit[] = [];
- /** Candidate expressions (by js node) with their alias. */
- const exprCandidates = new Map();
+ /** Candidate expressions (by js node) with their declared type, named
+ * only for the casts that survive suppression. */
+ const exprCandidates = new Map();
const exprOrder: ts.Node[] = [];
+ /** Reads whose projectable receiver the JavaScript world types `any`:
+ * kept only when nothing types the receiver. */
+ const deferred = new Set();
+ /** Package calls whose declared type does not project. */
+ const unprojected = new Set();
+ /** The declaration-world twin of every walked node. */
+ const twins = new Map();
const considerType = (jsNode: ts.Node, declNode: ts.Node): boolean => {
if (
@@ -603,8 +632,18 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
} else if (ts.isPropertyAccessExpression(d) || ts.isElementAccessExpression(d)) {
const recvDecl = typeAt(declChecker, d.expression);
const recvJs = typeAt(jsChecker, (jsNode as ts.PropertyAccessExpression).expression);
- const recvProjectable = recvDecl !== null && printer.project(recvDecl) !== null;
+ const recvProjectable = recvDecl !== null && printer.projectable(recvDecl);
produced = !recvProjectable || (recvJs !== null && !isAnyish(recvJs));
+ // A projectable receiver the JavaScript world types `any` is
+ // usually typed by another candidate. One that is a package value
+ // nothing projects stays `any`, such as a safeParse result narrowed
+ // to its success arm (the whole result is not data: its error arm
+ // is a class): the read crosses here instead, if the suppression
+ // below confirms the receiver stays untyped.
+ if (!produced) {
+ produced = true;
+ deferred.add(jsNode);
+ }
} else if (ts.isAwaitExpression(d)) {
const inner = unwrapTransparent(d.expression);
produced =
@@ -633,6 +672,12 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
declaredInPackage(resolveAlias(raw));
}
if (!produced) return;
+ if (isCall && !deferred.has(jsNode)) {
+ // Recorded before the contextual-type test below: a package value
+ // whose type does not project stays `any` wherever it flows.
+ const t = typeAt(declChecker, d);
+ if (t === null || !printer.projectable(t)) unprojected.add(jsNode);
+ }
// A consumer that takes anything needs no conversion.
try {
const ctx = declChecker.getContextualType(d);
@@ -641,10 +686,8 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
// no contextual type
}
const declType = typeAt(declChecker, d);
- if (declType === null) return;
- const alias = printer.project(declType);
- if (alias === null) return;
- exprCandidates.set(jsNode, alias);
+ if (declType === null || !printer.projectable(declType)) return;
+ exprCandidates.set(jsNode, declType);
exprOrder.push(jsNode);
};
@@ -703,6 +746,7 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
const walk = (jsNode: ts.Node, declNode: ts.Node): void => {
if (jsNode.kind !== declNode.kind) return;
+ twins.set(jsNode, declNode);
if (ts.isTypeNode(jsNode) || ts.isExpressionWithTypeArguments(jsNode)) {
if (considerType(jsNode, declNode)) return;
} else {
@@ -749,21 +793,67 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
result = willBeTyped(decl.initializer, depth + 1);
else if (ts.isParameter(decl) && annotated.has(decl)) result = true;
else if (ts.isBindingElement(decl)) result = false;
+ else if (
+ (ts.isVariableDeclaration(decl) || ts.isParameter(decl)) &&
+ decl.type !== undefined
+ )
+ result = annotationWillBeTyped(decl.type);
}
symbolTyped.set(sym, result);
return result;
}
return false;
};
+ /** An annotation in this file whose declaration-world type projects:
+ * after the rewrite it names the projected structure (a package type
+ * is replaced by its alias, a user alias of one has its right-hand
+ * side replaced). */
+ const annotationWillBeTyped = (type: ts.TypeNode): boolean => {
+ const twin = twins.get(type);
+ if (twin === undefined) return false;
+ let declType: ts.Type | null;
+ try {
+ declType = declChecker.getTypeFromTypeNode(twin as ts.TypeNode);
+ } catch {
+ return false;
+ }
+ return declType !== null && printer.projectable(declType);
+ };
const receiverOf = (e: ts.Node): ts.Node | null => {
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) return e.expression;
if (ts.isIdentifier(e)) return null;
return null;
};
const kept: ts.Node[] = [];
+ /** A receiver that stays `any` after the rewrite: a package call whose
+ * type does not project, or a user const bound to one. */
+ const staysUntyped = (e: ts.Node): boolean => {
+ const u = unwrapTransparent(e as ts.Expression);
+ if (unprojected.has(u)) return true;
+ if (!ts.isIdentifier(u)) return false;
+ let sym: ts.Symbol | undefined;
+ try {
+ sym = jsChecker.getSymbolAtLocation(u);
+ } catch {
+ return false;
+ }
+ const decls = sym?.declarations ?? [];
+ if (decls.length !== 1) return false;
+ const decl = decls[0]!;
+ return (
+ ts.isVariableDeclaration(decl) &&
+ decl.type === undefined &&
+ decl.initializer !== undefined &&
+ decl.parent !== undefined &&
+ ts.isVariableDeclarationList(decl.parent) &&
+ (decl.parent.flags & ts.NodeFlags.Const) !== 0 &&
+ unprojected.has(unwrapTransparent(decl.initializer))
+ );
+ };
for (const e of exprOrder) {
const recv = receiverOf(e);
if (recv !== null && willBeTyped(recv)) continue;
+ if (deferred.has(e) && (recv === null || !staysUntyped(recv))) continue;
// An identifier candidate that is a user binding never gets here
// (only package imports are candidates).
kept.push(e);
@@ -800,7 +890,8 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
)
continue;
}
- const alias = exprCandidates.get(e)!;
+ const alias = printer.project(exprCandidates.get(e)!);
+ if (alias === null) continue;
const start = ts.tokenStartOfNode(e);
const stmt = e.parent;
let prefix = "(";
diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts
index 314cdfd24..f191ad942 100644
--- a/packages/compiler/src/frontend/lowering/array-values.ts
+++ b/packages/compiler/src/frontend/lowering/array-values.ts
@@ -15,6 +15,7 @@ import {
typeKey,
unionContainerArmsOk,
} from "../../ir/ir.js";
+import { everyExpr } from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
import type * as ts from "../ts7/adapter.js";
@@ -193,6 +194,81 @@ export function unionArrayValueRead(
return result;
}
+/** Expression kinds that evaluate without running other code or writing
+ * a binding. */
+const PURE_KINDS = new Set([
+ "varRef",
+ "numLit",
+ "strLit",
+ "boolLit",
+ "unitLit",
+ "bin",
+ "unary",
+ "logical",
+ "ternary",
+ "toBool",
+ "fieldGet",
+ "recordGet",
+ "arrayGet",
+ "arrayState",
+ "arrayHas",
+ "unionIsTag",
+ "unionNarrow",
+ "unionWrap",
+]);
+
+/** The locals evaluating `value` may assign, and whether it may run other
+ * code (anything but the pure kinds and the read-only element helpers),
+ * which can assign locals and fields through closures. */
+function valueEffects(lowerer: Lowerer, value: IrExpr): { writes: Set; opaque: boolean } {
+ const readers = new Set();
+ for (const [key, name] of lowerer.arrHofHelpers) if (key.startsWith("idxOr:")) readers.add(name);
+ const writes = new Set();
+ let opaque = false;
+ everyExpr(value, {
+ expr: (e) => {
+ if (e.kind === "assignExpr" || e.kind === "incDec") writes.add(e.localId);
+ else if (e.kind === "call" ? !readers.has(e.callee) : !PURE_KINDS.has(e.kind)) opaque = true;
+ return true;
+ },
+ stmt: (s) => {
+ if (s.kind === "varDecl" || s.kind === "assign") writes.add(s.localId);
+ else opaque = true;
+ return true;
+ },
+ });
+ return { writes, opaque };
+}
+
+/** Whether evaluating `e` after `value` gives what it gives before it: a
+ * literal, or a local and field read `value` cannot change. */
+function unaffectedBy(e: IrExpr, effects: { writes: Set; opaque: boolean }): boolean {
+ switch (e.kind) {
+ case "numLit":
+ case "strLit":
+ return true;
+ case "varRef":
+ return !effects.writes.has(e.localId);
+ case "bin":
+ return unaffectedBy(e.left, effects) && unaffectedBy(e.right, effects);
+ case "fieldGet":
+ case "recordGet":
+ return !effects.opaque && unaffectedBy(e.obj, effects);
+ default:
+ return false;
+ }
+}
+
+/** Whether `e` reads a `let` or `var` binding, which code the value runs
+ * can assign through a closure. */
+function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean {
+ return !everyExpr(e, {
+ expr: (x) =>
+ x.kind !== "varRef" || lowerer.ctx.locals.find((l) => l.id === x.localId)?.mutable === false,
+ stmt: () => true,
+ });
+}
+
/** Store a stabilized value without changing the array's element ABI. */
export function arrayValueStore(
lowerer: Lowerer,
@@ -210,10 +286,31 @@ export function arrayValueStore(
// owned union temporary to release on every path.
const unionId = value.type.unionId;
let stable = value;
- let prefix: IrStmt[] = [];
+ const prefix: IrStmt[] = [];
+ // The stores below evaluate the receiver and the index after the value.
+ // JavaScript evaluates them first (`out[n++] = src[n]` reads src[1]), so
+ // either may need its value held before the value runs.
+ if (value.kind !== "varRef") {
+ const valueFx = valueEffects(lowerer, value);
+ const hold = (e: IrExpr, name: string, effects: typeof valueFx): IrExpr => {
+ if (unaffectedBy(e, effects) && !(effects.opaque && readsMutableLocal(lowerer, e)))
+ return e;
+ const temp = lowerer.declareHiddenLocal(name, e.type);
+ prefix.push({ kind: "varDecl", localId: temp.id, init: e, loc });
+ return varRef(temp.id, temp.type, loc);
+ };
+ // The receiver runs before the index and the value, so the index's
+ // effects can change it too (`a[(a = b, 0)] = c[0]!`).
+ const indexFx = valueEffects(lowerer, index);
+ arr = hold(arr, "%arrayTarget", {
+ writes: new Set([...valueFx.writes, ...indexFx.writes]),
+ opaque: valueFx.opaque || indexFx.opaque,
+ });
+ index = hold(index, "%arrayIndex", valueFx);
+ }
if (value.kind !== "varRef") {
const temp = lowerer.declareHiddenLocal("%arrayValue", value.type);
- prefix = [{ kind: "varDecl", localId: temp.id, init: value, loc }];
+ prefix.push({ kind: "varDecl", localId: temp.id, init: value, loc });
stable = varRef(temp.id, temp.type, loc);
}
const write: IrStmt = {
diff --git a/packages/compiler/src/frontend/lowering/builtins/async-context.ts b/packages/compiler/src/frontend/lowering/builtins/async-context.ts
index 18bd93a86..a130df59b 100644
--- a/packages/compiler/src/frontend/lowering/builtins/async-context.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/async-context.ts
@@ -2,7 +2,7 @@ import { isBuiltinClassInstance } from "./receiver-types.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
-import { locOf } from "../../program.js";
+import { isJsSourceFile, locOf } from "../../program.js";
import {
BOOL,
DYN,
@@ -184,16 +184,89 @@ export function lowerDiagnosticsChannelMethodCall(
);
}
+/** Whether `node` names the AsyncLocalStorage class itself. */
+function isAsyncLocalStorageClass(lowerer: Lowerer, node: ts.Expression): boolean {
+ if (!ts.isIdentifier(node)) return false;
+ const symbol = lowerer.resolveValueSymbol(node);
+ return (
+ symbol !== null &&
+ lowerer.isStdlibSymbol(symbol) &&
+ ts.symbolName(symbol) === "AsyncLocalStorage"
+ );
+}
+
+/** The static AsyncLocalStorage members: bind(fn) answers fn bound to the
+ * active context (its own type for a typed function; a dyn function in
+ * JavaScript files, where the bound function's name and receiver are
+ * observable, and for dyn values); snapshot() answers a dyn function
+ * (cb, ...args) that runs cb in the context active at the snapshot. */
+function lowerAsyncLocalStorageStaticCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+): IrExpr {
+ const name = access.name.text;
+ const loc = locOf(call);
+ const always: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
+ if (name === "bind" && call.arguments.nodes.length === 1) {
+ const arg = call.arguments.nodes[0]!;
+ if (!isJsSourceFile(ts.getSourceFileOfNode(call))) {
+ const fn = lowerer.lowerExpr(arg);
+ if (fn.type.kind === "func")
+ return { kind: "libCall", fn: "als.bindCallback", args: [fn, always], type: fn.type, loc };
+ }
+ return asyncLocalStorageResult(lowerer, call, {
+ kind: "libCall",
+ fn: "als.bindCallbackDyn",
+ args: [lowerDiagnosticsMessage(lowerer, arg), always],
+ type: DYN,
+ loc,
+ });
+ }
+ if (name === "snapshot" && call.arguments.nodes.length === 0)
+ return { kind: "libCall", fn: "als.snapshot", args: [], type: DYN, loc };
+ lowerer.noLowering(
+ `AsyncLocalStorage.${name}`,
+ call,
+ "AsyncLocalStorage.bind(fn) and AsyncLocalStorage.snapshot() are the supported static members",
+ lowerer.checker.getSymbolAtLocation(access.name),
+ );
+}
+
+/** new AsyncLocalStorage(options): the options object as a dyn value
+ * (plain property literals build it member by member). */
+export function lowerAsyncLocalStorageOptions(lowerer: Lowerer, node: ts.Expression): IrExpr {
+ return lowerTracingHandlers(lowerer, node);
+}
+
+/** `.name` on AsyncLocalStorage receivers: the constructor's name option
+ * (als.name), converted to the property's type. Null otherwise. */
+export function lowerAsyncLocalStorageProperty(
+ lowerer: Lowerer,
+ access: ts.PropertyAccessExpression,
+): IrExpr | null {
+ if (access.questionDotToken || access.name.text !== "name") return null;
+ if (!isBuiltinClassInstance(lowerer, access.expression, "AsyncLocalStorage")) return null;
+ if (!lowerer.isStdlibMember(access)) return null;
+ const loc = locOf(access);
+ const receiver = lowerer.lowerExprExpecting(access.expression, F64);
+ const value: IrExpr = { kind: "libCall", fn: "als.name", args: [receiver], type: DYN, loc };
+ const expected = lowerer.mapTypeOf(lowerer.typeOf(access));
+ return !expected || expected.kind === "dyn" ? value : lowerer.coerceInto(access, value, expected);
+}
+
/** Method calls on AsyncLocalStorage receivers: run(store, fn, ...args),
* exit(fn, ...args), getStore(), enterWith(store), disable() — over the
- * f64 store handle (als.* libCalls; values cross as dyn values). Null
- * for non-ALS receivers. */
+ * f64 store handle (als.* libCalls; values cross as dyn values) — and the
+ * static bind/snapshot members. Null for other receivers. */
export function lowerAsyncLocalStorageMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
+ if (isAsyncLocalStorageClass(lowerer, access.expression))
+ return lowerAsyncLocalStorageStaticCall(lowerer, call, access);
if (!isBuiltinClassInstance(lowerer, access.expression, "AsyncLocalStorage")) return null;
if (!lowerer.isStdlibMember(access)) return null;
const name = access.name.text;
diff --git a/packages/compiler/src/frontend/lowering/builtins/compression.ts b/packages/compiler/src/frontend/lowering/builtins/compression.ts
index 717e09933..1e4d7364c 100644
--- a/packages/compiler/src/frontend/lowering/builtins/compression.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/compression.ts
@@ -13,6 +13,7 @@ import {
funcOf,
type IrExpr,
type IrLibFn,
+ type IrStmt,
type IrType,
type SrcLoc,
STRING,
@@ -40,6 +41,36 @@ const ZLIB_CALLBACK_FNS: Readonly> = {
unzip: "zlib.unzipCb",
};
+/* Node's BROTLI_DECODE and BROTLI_ENCODE modes. */
+const BROTLI_DECODE = 8;
+const BROTLI_ENCODE = 9;
+
+/** The Brotli one-shots: every form takes the options entries. */
+const BROTLI_SYNC_MEMBERS: ReadonlySet = new Set([
+ "brotliCompressSync",
+ "brotliDecompressSync",
+]);
+const BROTLI_CALLBACK_MEMBERS: ReadonlySet = new Set([
+ "brotliCompress",
+ "brotliDecompress",
+]);
+
+/** A Brotli mode connects the codec (scr_brotli.c) to the zlib engine
+ * before any module initializer runs. */
+function noteBrotliMode(lowerer: Lowerer, mode: number, loc: SrcLoc): void {
+ if (mode !== BROTLI_DECODE && mode !== BROTLI_ENCODE) return;
+ const installs = (stmt: IrStmt): boolean =>
+ stmt.kind === "exprStmt" &&
+ stmt.expr.kind === "libCall" &&
+ stmt.expr.fn === "zlib.brotliInstall";
+ if (lowerer.mainPrelude.some(installs)) return;
+ lowerer.mainPrelude.push({
+ kind: "exprStmt",
+ expr: { kind: "libCall", fn: "zlib.brotliInstall", args: [], type: VOID, loc },
+ loc,
+ });
+}
+
const ZLIB_STREAM_CLASS = "%Transform";
const ZLIB_STREAM_T: IrType = { kind: "object", className: ZLIB_STREAM_CLASS };
@@ -115,6 +146,7 @@ export function lowerZlibStreamNew(
}
const options =
args[0] !== undefined ? zlibOptionsValue(lowerer, args[0], what, loc) : dynUndefinedExpr(loc);
+ noteBrotliMode(lowerer, mode, loc);
return {
kind: "libCall",
fn: "zlib.streamNew",
@@ -154,8 +186,8 @@ function isZlibFallbackDecl(lowerer: Lowerer, d: ts.Node): boolean {
return false;
}
-/** True when `access` names a member the zlib stream base class declares
- * (bytesWritten, flush, params, close, reset). */
+/** True when `access` names a member the zlib stream base classes declare
+ * (bytesWritten, flush, close and reset on ZlibBase; params on Zlib). */
export function isZlibStreamMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean {
const symbol = lowerer.checker.getSymbolAtLocation(access.name);
if (!symbol) return false;
@@ -163,7 +195,7 @@ export function isZlibStreamMember(lowerer: Lowerer, access: ts.PropertyAccessEx
(d) =>
d.parent !== undefined &&
ts.isClassDeclaration(d.parent) &&
- d.parent.name?.text === "ZlibBase" &&
+ (d.parent.name?.text === "ZlibBase" || d.parent.name?.text === "Zlib") &&
isZlibFallbackDecl(lowerer, d.parent),
);
}
@@ -281,12 +313,17 @@ const ZLIB_ONE_SHOT_MODES: Readonly> = {
deflateRaw: 5,
inflateRaw: 6,
unzip: 7,
+ brotliDecompressSync: BROTLI_DECODE,
+ brotliCompressSync: BROTLI_ENCODE,
+ brotliDecompress: BROTLI_DECODE,
+ brotliCompress: BROTLI_ENCODE,
};
/** True for a zlib callback one-shot util.promisify converts (deflate,
- * inflate, deflateRaw, inflateRaw, gzip, gunzip and unzip). */
+ * inflate, deflateRaw, inflateRaw, gzip, gunzip, unzip, brotliCompress and
+ * brotliDecompress). */
export function isPromisifiedZlibMember(member: string): boolean {
- return ZLIB_CALLBACK_FNS[member] !== undefined;
+ return ZLIB_CALLBACK_FNS[member] !== undefined || BROTLI_CALLBACK_MEMBERS.has(member);
}
/** A call through `promisify(zlib.gzip)` and the other callback one-shots:
@@ -311,6 +348,7 @@ export function lowerPromisifiedZlibCall(
);
}
const input = zlibInput(lowerer, expr.arguments.nodes[0]!, loc);
+ noteBrotliMode(lowerer, ZLIB_ONE_SHOT_MODES[member]!, loc);
return {
kind: "libCall",
fn: input.dyn ? "zlib.promiseDyn" : "zlib.promise",
@@ -334,6 +372,8 @@ const ZLIB_STREAM_FACTORIES: Readonly> = {
createDeflateRaw: 5,
createInflateRaw: 6,
createUnzip: 7,
+ createBrotliDecompress: BROTLI_DECODE,
+ createBrotliCompress: BROTLI_ENCODE,
};
export function lowerZlibModuleCall(
@@ -356,6 +396,9 @@ export function lowerZlibModuleCall(
loc,
);
}
+ if (BROTLI_SYNC_MEMBERS.has(bi.member) || BROTLI_CALLBACK_MEMBERS.has(bi.member)) {
+ return lowerBrotliOneShot(lowerer, expr, bi.member, loc);
+ }
const syncFn = ZLIB_SYNC_FNS[bi.member];
if (syncFn !== undefined) {
if (expr.arguments.nodes.length === 0 || expr.arguments.nodes.length > 2) {
@@ -502,6 +545,57 @@ export function lowerZlibModuleCall(
throw new InternalCompilerError(`unhandled lowered zlib member ${bi.member}`);
}
+/** brotliCompressSync(data[, options]), brotliCompress(data[, options],
+ * callback) and the decompress pair: the options entries with Node's
+ * BROTLI_ENCODE or BROTLI_DECODE mode, an absent options value passing
+ * undefined. */
+function lowerBrotliOneShot(
+ lowerer: Lowerer,
+ expr: ts.CallExpression,
+ member: string,
+ loc: SrcLoc,
+): IrExpr {
+ const args = expr.arguments.nodes;
+ const mode = ZLIB_ONE_SHOT_MODES[member]!;
+ const sync = BROTLI_SYNC_MEMBERS.has(member);
+ if (sync ? args.length === 0 || args.length > 2 : args.length !== 2 && args.length !== 3) {
+ lowerer.noLowering(
+ `zlib.${member} with ${args.length} arguments`,
+ expr,
+ sync
+ ? `${member}(data) and ${member}(data, options) are supported`
+ : `${member}(data, callback) and ${member}(data, options, callback) are supported`,
+ );
+ }
+ const input = zlibInput(lowerer, args[0]!, loc);
+ const withOptions = sync ? args.length === 2 : args.length === 3;
+ const options = withOptions
+ ? zlibOptionsValue(lowerer, args[1]!, `zlib.${member}`, loc)
+ : dynUndefinedExpr(loc);
+ noteBrotliMode(lowerer, mode, loc);
+ if (sync) {
+ return {
+ kind: "libCall",
+ fn: input.dyn ? "zlib.syncOptsDyn" : "zlib.syncOpts",
+ args: [numLit(mode, loc), input.value, options],
+ type: BYTES_U8,
+ loc,
+ };
+ }
+ return {
+ kind: "libCall",
+ fn: input.dyn ? "zlib.cbOptsDyn" : "zlib.cbOpts",
+ args: [
+ numLit(mode, loc),
+ input.value,
+ options,
+ errorFirstBytesCallback(lowerer, args[withOptions ? 2 : 1]!, "zlib"),
+ ],
+ type: VOID,
+ loc,
+ };
+}
+
/** The class name of a zlib stream mode (Node's constructor name). */
export function zlibClassName(mode: number): string {
for (const [name, m] of ZLIB_STREAM_MODES) if (m === mode) return name;
diff --git a/packages/compiler/src/frontend/lowering/builtins/crypto.ts b/packages/compiler/src/frontend/lowering/builtins/crypto.ts
index 051ec18c4..15c057462 100644
--- a/packages/compiler/src/frontend/lowering/builtins/crypto.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/crypto.ts
@@ -1454,6 +1454,7 @@ function lowerPkiCall(
],
sign: ["crypto.signOneshot", 3, 3, "sign(algorithm, data, key)"],
verify: ["crypto.verifyOneshot", 4, 4, "verify(algorithm, data, key, signature)"],
+ diffieHellman: ["crypto.diffieHellman", 1, 2, "diffieHellman(options[, callback])"],
createSign: ["crypto.createSign", 1, 1, "createSign(algorithm)"],
createVerify: ["crypto.createVerify", 1, 1, "createVerify(algorithm)"],
};
@@ -1474,6 +1475,7 @@ function lowerPkiCall(
return { kind: "libCall", fn, args: values, type: VOID, loc };
const call: IrExpr = { kind: "libCall", fn, args: values, type: DYN, loc };
const result = lowerer.mapTypeOf(lowerer.typeOf(expr));
- if (result === null || result.kind === "dyn") return call;
+ // diffieHellman's callback form answers undefined.
+ if (result === null || result.kind === "dyn" || result.kind === "void") return call;
return lowerer.coerceInto(expr, call, result);
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts
index a598f4ee6..0204386ab 100644
--- a/packages/compiler/src/frontend/lowering/builtins/dates.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts
@@ -53,7 +53,13 @@ export function lowerDateCall(
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
- if (call.questionDotToken || access.questionDotToken) return null;
+ // An optional chain lowers the call again once it has narrowed the
+ // receiver (Lowerer.chainHandled).
+ if (
+ (call.questionDotToken && !lowerer.chainHandled.has(call)) ||
+ (access.questionDotToken && !lowerer.chainHandled.has(access))
+ )
+ return null;
const loc = locOf(call);
const member = lowerer.stdlibGlobalMember(access, "Date");
if (member === "now" || member === "parse" || member === "UTC")
diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts
index d147a6208..0bbc3b3fa 100644
--- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts
@@ -265,7 +265,7 @@ export function lowerFsPromisesOpenCall(
"use open(path[, stringFlags[, numericMode]])",
);
}
- const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
+ const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const defaultFlags: IrExpr = { kind: "strLit", value: "r", type: STRING, loc };
const defaultMode: IrExpr = { kind: "numLit", value: 0o666, type: F64, loc };
const flags = expr.arguments.nodes[1]
@@ -705,7 +705,7 @@ export function lowerFsPromisesEncodedRead(
)
return null;
}
- const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
+ const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const encoding: IrExpr =
optionsType.kind === "string"
? lowerer.lowerExprExpecting(options, STRING)
@@ -853,7 +853,7 @@ export function lowerFsPromisesMkdirCall(
);
}
const { recursive, modeNode } = options;
- const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
+ const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const mode: IrExpr | null = modeNode ? lowerer.lowerExprExpecting(modeNode, F64) : null;
const type: IrType = { kind: "promise", inner: VOID };
if (!recursive) {
diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts
index f988c204d..4d88cd96e 100644
--- a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts
@@ -22,7 +22,10 @@ import { builtinImportOf } from "./module-bindings.js";
* lib/internal/fs/glob.js). The pattern and options cross as dyn values so
* the runtime validates them in Node's order. The result form (strings or
* Dirent rows) comes from the call's mapped result type, so withFileTypes
- * must be statically known; the runtime checks that the option agrees. */
+ * must be statically known; the runtime checks that the option agrees.
+ * Because of that check, the form follows the checker's resolved overload
+ * even where an untyped options argument leaves the overload undecided
+ * (dynamicOverloadChoice). */
/** The interned Dirent record array (type-mapper.ts), or null. */
function direntArray(lowerer: Lowerer, type: IrType | null | undefined): boolean {
@@ -67,7 +70,7 @@ export function lowerFsGlobSyncCall(
loc: SrcLoc,
): IrExpr {
const args = globArgs(lowerer, call, "fs.globSync");
- const callType = lowerer.mapTypeOf(lowerer.typeOf(call));
+ const callType = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(call));
const types = direntArray(lowerer, callType);
if (types) fenceDirentExclude(lowerer, call, "fs.globSync");
if (!types && !(callType?.kind === "array" && callType.elem.kind === "string")) {
@@ -132,7 +135,9 @@ export function lowerFspGlobCall(lowerer: Lowerer, call: ts.CallExpression, loc:
}
const args = globArgs(lowerer, call, "fs/promises glob");
// The async iterator's yielded type (its first type argument).
- const yielded = lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0];
+ const yielded = lowerer.checker.getTypeArguments(
+ lowerer.checker.getTypeAtLocation(call) as ts.TypeReference,
+ )[0];
const element = yielded ? lowerer.mapTypeOf(yielded) : null;
const rows: IrType | null = element ? { kind: "array", elem: element } : null;
const types = direntArray(lowerer, rows);
diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts
index 38583025d..46a575388 100644
--- a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts
@@ -5,30 +5,40 @@
* conversion (url.fileURLToPathUrl). The later arguments through the last
* one with side effects are evaluated into hidden locals before the
* conversion, in source order, so they run even when it throws and each
- * reads what it reads in Node. The conversion keeps Node's error order when every
- * earlier path is a URL too and each argument Node validates before the
- * path is absent or a literal that passes that validation; other URL
- * forms fence with SC2020, as do the fs.promises functions, whose
- * conversion errors are rejections rather than throws, and existsSync,
- * which answers false for a URL it cannot convert. */
+ * reads what it reads in Node. Before the conversion, an earlier string
+ * path gets its null-byte check (copyFileSync, renameSync) and a runtime
+ * readFileSync encoding its name check, which Node runs first. existsSync
+ * and the fs.promises functions convert to a value instead
+ * (url.fileURLToPathValue) and answer false or a rejected promise for a
+ * conversion error. An argument Node validates before the path must
+ * otherwise be absent or a literal that passes that validation; other
+ * forms fence with SC2020. */
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { sideEffectFreeOptionValue } from "../surfaces.js";
import { stripTypeWrappers } from "../typed-arguments.js";
-import { type IrExpr, type IrStmt, STRING, URL_T } from "../../../ir/ir.js";
+import { boolLit, varRef } from "../../../ir/build.js";
+import { BOOL, DYN, type IrExpr, type IrStmt, STRING, URL_T, VOID } from "../../../ir/ir.js";
+import { InternalCompilerError } from "../../../errors.js";
-/** The fs functions whose path parameters accept a URL: the path
- * parameter indexes, and whether Node validates the paths before every
- * other argument (otherwise the options come first). */
-const FS_URL_PATHS: Readonly<
- Record
-> = {
+interface FsPathRow {
+ /** The path parameter indexes. */
+ readonly paths: readonly number[];
+ /** Whether Node validates the paths before every other argument
+ * (otherwise the options come first). */
+ readonly pathsFirst: boolean;
+ /** The path parameters' names in Node's errors, when there are two. */
+ readonly names?: readonly string[];
+}
+
+/** The fs functions whose path parameters accept a URL. */
+const FS_URL_PATHS: Readonly> = {
accessSync: { paths: [0], pathsFirst: true },
appendFileSync: { paths: [0], pathsFirst: false },
chmodSync: { paths: [0], pathsFirst: true },
chownSync: { paths: [0], pathsFirst: true },
- copyFileSync: { paths: [0, 1], pathsFirst: true },
+ copyFileSync: { paths: [0, 1], pathsFirst: true, names: ["src", "dest"] },
lchownSync: { paths: [0], pathsFirst: true },
lstatSync: { paths: [0], pathsFirst: true },
mkdirSync: { paths: [0], pathsFirst: false },
@@ -39,7 +49,7 @@ const FS_URL_PATHS: Readonly<
readdirSync: { paths: [0], pathsFirst: false },
realpathSync: { paths: [0], pathsFirst: false },
"realpathSync.native": { paths: [0], pathsFirst: false },
- renameSync: { paths: [0, 1], pathsFirst: true },
+ renameSync: { paths: [0, 1], pathsFirst: true, names: ["oldPath", "newPath"] },
rmSync: { paths: [0], pathsFirst: false },
rmdirSync: { paths: [0], pathsFirst: true },
statSync: { paths: [0], pathsFirst: true },
@@ -48,43 +58,66 @@ const FS_URL_PATHS: Readonly<
writeFileSync: { paths: [0], pathsFirst: false },
};
-/** The fs.promises functions with path parameters, by path indexes. */
-const FSP_PATHS: Readonly> = {
- access: [0],
- appendFile: [0],
- chmod: [0],
- chown: [0],
- copyFile: [0, 1],
- lchown: [0],
- lstat: [0],
- mkdir: [0],
- mkdtemp: [0],
- open: [0],
- opendir: [0],
- readFile: [0],
- readdir: [0],
- realpath: [0],
- rename: [0, 1],
- rm: [0],
- rmdir: [0],
- stat: [0],
- truncate: [0],
- unlink: [0],
- writeFile: [0],
+/** The fs.promises functions with path parameters. */
+const FSP_PATHS: Readonly> = {
+ access: { paths: [0], pathsFirst: true },
+ appendFile: { paths: [0], pathsFirst: false },
+ chmod: { paths: [0], pathsFirst: true },
+ chown: { paths: [0], pathsFirst: true },
+ copyFile: { paths: [0, 1], pathsFirst: true },
+ lchown: { paths: [0], pathsFirst: true },
+ lstat: { paths: [0], pathsFirst: true },
+ mkdir: { paths: [0], pathsFirst: false },
+ mkdtemp: { paths: [0], pathsFirst: false },
+ open: { paths: [0], pathsFirst: true },
+ opendir: { paths: [0], pathsFirst: true },
+ readFile: { paths: [0], pathsFirst: false },
+ readdir: { paths: [0], pathsFirst: false },
+ realpath: { paths: [0], pathsFirst: false },
+ rename: { paths: [0, 1], pathsFirst: true },
+ rm: { paths: [0], pathsFirst: true },
+ rmdir: { paths: [0], pathsFirst: true },
+ stat: { paths: [0], pathsFirst: true },
+ truncate: { paths: [0], pathsFirst: true },
+ unlink: { paths: [0], pathsFirst: true },
+ writeFile: { paths: [0], pathsFirst: false },
};
+/** What a path argument of an fs call lowers to (Lowerer.fsPathPlans). */
+export type FsPathPlan =
+ /** The first URL path: the later arguments with effects, the earlier
+ * string paths to check, and a runtime encoding to check, all before the
+ * conversion. */
+ | {
+ kind: "convert";
+ staged: ts.Expression[];
+ checks: { path: ts.Expression; name: string }[];
+ encoding: ts.Expression | null;
+ }
+ /** A string path that a later URL path checks: the local that holds it,
+ * once lowered. */
+ | { kind: "save"; local: string | null }
+ /** A URL path whose conversion error becomes false or a rejection: the
+ * local that holds the conversion's value. */
+ | { kind: "value"; local: string };
+
function isUrlArgument(lowerer: Lowerer, node: ts.Expression | undefined): boolean {
return node !== undefined && lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "url";
}
+function stripParens(node: ts.Expression): ts.Expression {
+ let e = node;
+ while (ts.isParenthesizedExpression(e)) e = e.expression;
+ return e;
+}
+
/** A literal that Node's option validation accepts before it reads the
* path: a string (the checker admits only the declared encodings, flags
* and type names), a boolean, an integer in [0, 2^31 - 1] (every numeric
* option of these functions accepts that range), undefined or null, or an
* object literal of those. */
function preValidatedLiteral(node: ts.Expression): boolean {
- let e = node;
- while (ts.isParenthesizedExpression(e)) e = e.expression;
+ const e = stripParens(node);
if (ts.isStringLiteralLike(e)) return true;
if (
e.kind === ts.SyntaxKind.TrueKeyword ||
@@ -108,39 +141,59 @@ function preValidatedLiteral(node: ts.Expression): boolean {
return false;
}
-/** SC2020 when the call passes a URL path in a form whose conversion
- * order would differ from Node's (see the file comment). */
-function checkUrlPaths(
+const HINT = "convert the URL first: fileURLToPath(url) from node:url";
+
+/** SC2020 for an argument Node validates before the paths that is neither
+ * absent nor a literal passing that validation, other than a string
+ * readFileSync encoding (returned: it is checked before the conversion).
+ * writeFileSync/appendFileSync's data is checked after the path. */
+function optionsBeforePaths(
lowerer: Lowerer,
- call: ts.CallExpression,
- module: string,
+ args: readonly ts.Expression[],
member: string,
-): void {
- const args = call.arguments.nodes;
- const name = module === "fs/promises" ? `fs.promises.${member}` : member;
- const hint = "convert the URL first: fileURLToPath(url) from node:url";
- if (module === "fs/promises") {
- if ((FSP_PATHS[member] ?? []).some((i) => isUrlArgument(lowerer, args[i])))
- lowerer.noLowering(`${name} with a URL path`, call, hint);
- return;
+ row: FsPathRow,
+ name: string,
+): ts.Expression | null {
+ if (row.pathsFirst) return null;
+ const data = /^(writeFile|appendFile)(Sync)?$/.test(member) ? 1 : -1;
+ let encoding: ts.Expression | null = null;
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i]!;
+ if (row.paths.includes(i) || i === data || preValidatedLiteral(arg)) continue;
+ if (
+ member === "readFileSync" &&
+ i === 1 &&
+ lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "string"
+ ) {
+ encoding = stripTypeWrappers(arg);
+ continue;
+ }
+ lowerer.noLowering(`${name} with a URL path and options that are not literals`, arg, HINT);
}
- if (member === "existsSync" && isUrlArgument(lowerer, args[0]))
- lowerer.noLowering(`${name} with a URL path`, call, hint);
- const row = FS_URL_PATHS[member];
- if (!row) return;
- const first = row.paths.find((i) => isUrlArgument(lowerer, args[i]));
- if (first === undefined) return;
- if (args.some(ts.isSpreadElement))
- lowerer.noLowering(`${name} with spread arguments and a URL path`, call);
- // The runtime checks a string path after the conversion, so a string
- // path Node checks first cannot precede the URL.
- if (row.paths.some((i) => i < first && args[i] !== undefined))
- lowerer.noLowering(`${name} with a string path before a URL path`, call, hint);
- // Every argument from the URL through the last one with effects is
- // evaluated ahead of the conversion, in source order: a later argument's
- // effect (an assignment, say) must not change what an earlier one read.
+ return encoding;
+}
+
+/** The arguments after the URL path that are evaluated ahead of the
+ * conversion: every one through the last with effects or that `force`
+ * names, in source order. An object or array literal among them is
+ * SC2020: its members are lowered one by one, so it cannot be evaluated
+ * ahead of the conversion as one value. */
+function stagedAfter(
+ lowerer: Lowerer,
+ args: readonly ts.Expression[],
+ first: number,
+ name: string,
+ force: (e: ts.Expression, i: number) => boolean,
+): ts.Expression[] {
+ // Every argument from the URL through the last one with effects (or that
+ // the caller forces) is evaluated ahead of the conversion, in source
+ // order: a later argument's effect (an assignment, say) must not change
+ // what an earlier one read.
let last = first;
- for (let i = first + 1; i < args.length; i++) if (!sideEffectFreeOptionValue(args[i]!)) last = i;
+ for (let i = first + 1; i < args.length; i++) {
+ const e = stripTypeWrappers(args[i]!);
+ if (!sideEffectFreeOptionValue(e) || force(e, i)) last = i;
+ }
const staged: ts.Expression[] = [];
for (let i = first + 1; i <= last; i++) {
// Keyed without type-only wrappers (`satisfies`, `as`, `!`), so a
@@ -154,56 +207,190 @@ function checkUrlPaths(
? `${name} with a URL path and a literal before a later argument that has effects`
: `${name} with a URL path and a later literal that has effects`,
e,
- hint,
+ HINT,
);
}
staged.push(e);
}
- if (staged.length > 0) {
- lowerer.stagedAfterPath.set(args[first]!, staged);
- for (const e of staged) lowerer.stagedArguments.set(e, null);
- }
- if (row.pathsFirst) return;
- // writeFileSync/appendFileSync's data is checked after the path.
- const data = member === "writeFileSync" || member === "appendFileSync" ? 1 : -1;
- for (let i = 0; i < args.length; i++) {
- if (row.paths.includes(i) || i === data || preValidatedLiteral(args[i]!)) continue;
- lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[i]!, hint);
- }
+ return staged;
}
-/** Fences URL paths that cannot convert in Node's order: the check every
- * fs and fs.promises call runs before lowering its arguments. */
+/** Plans the URL paths of a synchronous fs call (see the file comment):
+ * the check every fs call runs before lowering its arguments. */
export function checkFsUrlPaths(
lowerer: Lowerer,
call: ts.CallExpression,
bi: { module: string; member: string },
): void {
- if (bi.module === "fs" || bi.module === "fs/promises")
- checkUrlPaths(lowerer, call, bi.module, bi.member);
+ if (bi.module !== "fs") return;
+ const row = FS_URL_PATHS[bi.member];
+ if (!row) return;
+ const args = call.arguments.nodes;
+ const first = row.paths.find((i) => isUrlArgument(lowerer, args[i]));
+ if (first === undefined || lowerer.fsPathPlans.has(stripParens(args[first]!))) return;
+ const name = bi.member;
+ if (args.some(ts.isSpreadElement))
+ lowerer.noLowering(`${name} with spread arguments and a URL path`, call);
+ const checks: { path: ts.Expression; name: string }[] = [];
+ for (const i of row.paths) {
+ if (i >= first || args[i] === undefined) continue;
+ const path = stripParens(args[i]!);
+ checks.push({ path, name: row.names?.[i] ?? "path" });
+ lowerer.fsPathPlans.set(path, { kind: "save", local: null });
+ }
+ const encoding = optionsBeforePaths(lowerer, args, bi.member, row, name);
+ const staged = stagedAfter(lowerer, args, first, name, (e) => e === encoding);
+ for (const e of staged) lowerer.stagedArguments.set(e, null);
+ lowerer.fsPathPlans.set(stripParens(args[first]!), { kind: "convert", staged, checks, encoding });
+}
+
+/** existsSync and the fs.promises functions with URL paths: each URL
+ * converts to a value (url.fileURLToPathValue) after the arguments are
+ * evaluated, and the call runs on the converted paths only when every
+ * conversion produced a string; otherwise existsSync answers false and an
+ * fs.promises function a promise rejected with the first conversion's
+ * error. `lowerCall` lowers the call itself, whose URL paths then read the
+ * converted values. Null for every other call. */
+export function lowerFallibleFsUrlCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ bi: { module: string; member: string },
+ lowerCall: () => IrExpr,
+): IrExpr | null {
+ const row =
+ bi.module === "fs/promises"
+ ? FSP_PATHS[bi.member]
+ : bi.module === "fs" && bi.member === "existsSync"
+ ? ({ paths: [0], pathsFirst: true } satisfies FsPathRow)
+ : undefined;
+ if (!row) return null;
+ const args = call.arguments.nodes;
+ const urls = row.paths.filter((i) => isUrlArgument(lowerer, args[i]));
+ if (urls.length === 0 || lowerer.fallibleFsCalls.has(call)) return null;
+ const name = bi.module === "fs/promises" ? `fs.promises.${bi.member}` : bi.member;
+ if (args.some(ts.isSpreadElement))
+ lowerer.noLowering(`${name} with spread arguments and a URL path`, call);
+ const first = urls[0]!;
+ // A string path before the URL would be checked in the call itself,
+ // after the conversion.
+ if (row.paths.some((i) => i < first && args[i] !== undefined))
+ lowerer.noLowering(`${name} with a string path before a URL path`, call, HINT);
+ if (optionsBeforePaths(lowerer, args, bi.member, row, name) !== null)
+ lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[1]!, HINT);
+ const loc = locOf(call);
+ const stmts: IrStmt[] = [];
+ const values = new Map();
+ const save = (value: IrExpr, label: string, at: ts.Node): string => {
+ const slot = lowerer.declareHiddenLocal(label, value.type);
+ stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(at) });
+ return slot.id;
+ };
+ values.set(first, save(lowerer.lowerExprExpecting(args[first]!, URL_T), "%fsUrl", args[first]!));
+ const staged = stagedAfter(lowerer, args, first, name, (_e, i) => urls.includes(i));
+ for (const e of staged) {
+ // A call lowered again (an array-literal element) evaluates the
+ // argument anew; the slot an earlier lowering saved it in is gone.
+ lowerer.stagedArguments.delete(e);
+ const value = lowerer.lowerExpr(e);
+ const slot = save(value, "%fsArg", e);
+ lowerer.stagedArguments.set(e, { localId: slot, type: value.type });
+ const index = args.findIndex((arg) => stripTypeWrappers(arg) === e);
+ if (urls.includes(index)) values.set(index, slot);
+ }
+ const results: string[] = [];
+ for (const i of urls) {
+ const url = varRef(values.get(i)!, URL_T, loc);
+ const converted: IrExpr = {
+ kind: "libCall",
+ fn: "url.fileURLToPathValue",
+ args: [url],
+ type: DYN,
+ loc,
+ };
+ const local = save(converted, "%fsPathValue", args[i]!);
+ lowerer.fsPathPlans.set(stripParens(args[i]!), { kind: "value", local });
+ results.push(local);
+ }
+ lowerer.fallibleFsCalls.add(call);
+ let inner: IrExpr;
+ try {
+ inner = lowerCall();
+ } finally {
+ lowerer.fallibleFsCalls.delete(call);
+ }
+ let result = inner;
+ for (let k = results.length - 1; k >= 0; k--) {
+ const value = varRef(results[k]!, DYN, loc);
+ const failed: IrExpr =
+ bi.module === "fs/promises"
+ ? { kind: "intrinsic", name: "promise.reject", args: [value], type: inner.type, loc }
+ : boolLit(false, loc);
+ result = {
+ kind: "ternary",
+ cond: { kind: "dynTest", test: "string", value, type: BOOL, loc },
+ then: result,
+ else_: failed,
+ type: inner.type,
+ loc,
+ };
+ }
+ return { kind: "seqExpr", stmts, result, type: inner.type, loc };
}
/** An fs path argument as a string: a URL converts with fileURLToPath's
* conversion (url.fileURLToPathUrl), which throws Node's errors for other
- * schemes, hosts and encoded separators. */
+ * schemes, hosts and encoded separators, after the checks its plan names. */
export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr {
- if (!isUrlArgument(lowerer, node)) return lowerer.lowerExprExpecting(node, STRING);
const loc = locOf(node);
+ const plan = lowerer.fsPathPlans.get(stripParens(node));
+ if (plan?.kind === "value")
+ return { kind: "dynCheck", value: varRef(plan.local, DYN, loc), type: STRING, loc };
+ if (!isUrlArgument(lowerer, node)) {
+ const value = lowerer.lowerExprExpecting(node, STRING);
+ if (plan?.kind !== "save") return value;
+ const slot = lowerer.declareHiddenLocal("%fsPath", STRING);
+ plan.local = slot.id;
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "varDecl", localId: slot.id, init: value, loc }],
+ result: varRef(slot.id, STRING, loc),
+ type: STRING,
+ loc,
+ };
+ }
const url = lowerer.lowerExprExpecting(node, URL_T);
- const staged = lowerer.stagedAfterPath.get(node);
- if (staged === undefined)
+ if (plan?.kind !== "convert")
return { kind: "libCall", fn: "url.fileURLToPathUrl", args: [url], type: STRING, loc };
// The URL and the later arguments with effects, in source order, then
- // the conversion.
+ // the checks Node runs first, then the conversion.
const saved = lowerer.declareHiddenLocal("%fsUrl", URL_T);
const stmts: IrStmt[] = [{ kind: "varDecl", localId: saved.id, init: url, loc }];
- for (const arg of staged) {
+ for (const arg of plan.staged) {
lowerer.stagedArguments.delete(arg);
const value = lowerer.lowerExpr(arg);
const slot = lowerer.declareHiddenLocal("%fsArg", value.type);
stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(arg) });
lowerer.stagedArguments.set(arg, { localId: slot.id, type: value.type });
}
+ const check = (fn: "fs.checkPath" | "fs.checkReadEncoding", args: IrExpr[]): void => {
+ stmts.push({ kind: "exprStmt", expr: { kind: "libCall", fn, args, type: VOID, loc }, loc });
+ };
+ for (const { path, name } of plan.checks) {
+ const saved = lowerer.fsPathPlans.get(path);
+ if (saved?.kind !== "save" || saved.local === null)
+ throw new InternalCompilerError("an fs path checked before it was lowered");
+ check("fs.checkPath", [
+ varRef(saved.local, STRING, loc),
+ { kind: "strLit", value: name, type: STRING, loc },
+ ]);
+ }
+ if (plan.encoding !== null) {
+ const slot = lowerer.stagedArguments.get(plan.encoding);
+ if (!slot) throw new InternalCompilerError("an fs encoding checked before it was saved");
+ check("fs.checkReadEncoding", [
+ lowerer.coerceToExpected(varRef(slot.localId, slot.type, loc), STRING),
+ ]);
+ }
return {
kind: "seqExpr",
stmts,
@@ -219,7 +406,12 @@ export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr {
};
}
-/** Whether `index` is a path parameter of the fs function `member`. */
-export function isFsPathParam(member: string, index: number): boolean {
- return FS_URL_PATHS[member]?.paths.includes(index) === true;
+/** Whether `index` is a path parameter of the fs or fs.promises function
+ * `member`. */
+export function isFsPathParam(module: string, member: string, index: number): boolean {
+ if (module === "fs/promises") return FSP_PATHS[member]?.paths.includes(index) === true;
+ if (module !== "fs") return false;
+ return (
+ (member === "existsSync" && index === 0) || FS_URL_PATHS[member]?.paths.includes(index) === true
+ );
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
index e5446291f..2aaa2f80f 100644
--- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
@@ -25,7 +25,7 @@ import {
lowerFsWriteOptionsCall,
lowerFsReadPathCall,
} from "./filesystem-calls.js";
-import { checkFsUrlPaths, isFsPathParam, lowerFsPath } from "./fs-paths.js";
+import { checkFsUrlPaths, isFsPathParam, lowerFallibleFsUrlCall, lowerFsPath } from "./fs-paths.js";
import { lowerBufferEncodingCall } from "./text-codecs.js";
import { lowerUtilTypeCall } from "./util-types.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -59,6 +59,10 @@ export function lowerBuiltinModuleCall(
loc: SrcLoc,
): IrExpr {
const name = ts.getTextOfNode(expr.expression);
+ const fallible = lowerFallibleFsUrlCall(lowerer, expr, bi, () =>
+ lowerBuiltinModuleCall(lowerer, expr, bi, fn, loc),
+ );
+ if (fallible) return fallible;
checkFsUrlPaths(lowerer, expr, bi);
if (bi.module === "util/types") {
return lowerUtilTypeCall(lowerer, expr, bi, loc, name);
@@ -258,7 +262,7 @@ export function lowerBuiltinModuleCall(
bi.member === "readFile" &&
expr.arguments.nodes.length === 1
) {
- const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
+ const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
return {
kind: "libCall",
fn: "fsp.readFileBytes",
@@ -473,7 +477,7 @@ export function lowerBuiltinModuleCall(
args = lowerer.completeArgs(expr.arguments.nodes, shapes, loc, expr);
} else {
args = expr.arguments.nodes.map((a, i) =>
- bi.module === "fs" && isFsPathParam(bi.member, i)
+ isFsPathParam(bi.module, bi.member, i)
? lowerFsPath(lowerer, a)
: lowerer.lowerExprExpecting(a, fn.params[i]),
);
diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts
index 129ac73a3..e9d4322ad 100644
--- a/packages/compiler/src/frontend/lowering/builtins/promises.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts
@@ -2,7 +2,7 @@ import { keyedHelperName } from "../stable-names.js";
import { checkedPromiseAll, thenReader } from "../checked-promise-all.js";
import { InternalCompilerError } from "../../../errors.js";
import * as ts from "../../ts7/adapter.js";
-import { newFnCtx, type Lowerer } from "../lowerer.js";
+import type { Lowerer } from "../lowerer.js";
import { varRef } from "../../../ir/build.js";
import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js";
import { isJsSourceFile, locOf } from "../../program.js";
@@ -17,31 +17,23 @@ import {
UNDEFINED_T,
VOID,
arrayOf,
+ funcOf,
isUnitType,
typeEquals,
typeKey,
} from "../../../ir/ir.js";
/** `Promise.all([p, q, r])` over DIFFERENT promise types — the checker's
- * tuple overload, `Promise<[A, B, C]>`. One interned async helper per
- * entry/result signature takes the entries as arguments, awaits the
- * runtime combinator over the same promises (each widened to
- * Promise, a type-only view), so settlement is Node's exactly: the
- * first rejection in settlement order wins and every later one counts as
- * handled; then it reads each (now fulfilled) entry and returns the
- * tuple. Entries keep their identity — no value converts through the
- * checked-dynamic tree. Non-promise entries (numbers, strings, booleans,
- * unit values — never thenable) pass straight into their slot; a
- * checked-dynamic entry (a JavaScript function's result) joins through
- * PromiseResolve: the promise it holds, a promise that adopts a thenable,
- * or a promise fulfilled with its plain value. The tuple
- * fulfills one microtask turn after the combinator does. Null when the
- * result type or an entry has no typed home here. */
-function lowerTuplePromiseAll(
+ * tuple overload, `Promise<[A, B, C]>`: the result type and tuple shape,
+ * or null when the result type or an entry has no typed home here.
+ * Entries are promises, checked-dynamic values (a JavaScript function's
+ * result) and plain numbers, strings, booleans and unit values, which are
+ * never thenable. */
+function tuplePromiseAllShape(
lowerer: Lowerer,
call: ts.CallExpression,
- elems: IrExpr[],
-): IrExpr | null {
+ elems: readonly IrExpr[],
+): { resultT: IrType & { kind: "promise" }; tuple: IrType & { kind: "record" } } | null {
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
if (resultT?.kind !== "promise" || resultT.inner.kind !== "record") return null;
const shape = lowerer.shapes.get(resultT.inner.shapeId);
@@ -53,135 +45,159 @@ function lowerTuplePromiseAll(
const settles = (t: IrType): boolean => t.kind === "promise" || t.kind === "dyn";
if (!elems.some((e) => settles(e.type))) return null;
if (!elems.every((e) => settles(e.type) || plainValue(e.type))) return null;
+ return { resultT, tuple: resultT.inner };
+}
+
+/** `Promise.all([p, q, r])` over different promise types. Each entry
+ * becomes a promise in input order (a plain value through Promise.resolve,
+ * a checked-dynamic one through its PromiseResolve view: the promise it
+ * holds, a promise that adopts a thenable, or a promise fulfilled with the
+ * value), widened to Promise, a type-only view. The runtime
+ * combinator over them gives Node's settlement: the first rejection in
+ * settlement order wins and every later one counts as handled. The
+ * result settles inside the combinator's own settlement, so it and its
+ * awaits land on Node's microtask turns; a fulfillment runs one interned
+ * function per entry/result signature that reads the fulfilled entries
+ * into the tuple. Entries keep their identity: no value converts through
+ * the checked-dynamic tree. */
+function lowerTuplePromiseAll(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ elems: IrExpr[],
+): IrExpr | null {
+ const plan = tuplePromiseAllShape(lowerer, call, elems);
+ if (!plan) return null;
+ const { resultT, tuple } = plan;
const loc = locOf(call);
- const key = `${elems.map((e) => typeKey(e.type)).join(",")}->${typeKey(resultT.inner)}`;
+ const voidPromise: IrType & { kind: "promise" } = { kind: "promise", inner: VOID };
+ const entriesT = arrayOf(voidPromise);
+ const dynPromise: IrType = { kind: "promise", inner: DYN };
+ // A checked-dynamic entry's PromiseResolve can run a `then` getter, which
+ // Node runs after the whole array literal evaluated. With such an entry,
+ // every entry evaluates into a hidden local first.
+ const staged: IrStmt[] = [];
+ const values = !elems.some((e) => e.type.kind === "dyn")
+ ? elems
+ : elems.map((e): IrExpr => {
+ if (e.kind === "unitLit") return e;
+ if (isUnitType(e.type)) {
+ staged.push({ kind: "exprStmt", expr: e, loc });
+ return {
+ kind: "unitLit",
+ unit: e.type.kind === "nullT" ? "null" : "undefined",
+ type: e.type,
+ loc,
+ };
+ }
+ const local = lowerer.declareHiddenLocal("%allValue", e.type);
+ staged.push({ kind: "varDecl", localId: local.id, init: e, loc });
+ return varRef(local.id, e.type, loc);
+ });
+ const entries = values.map((e): IrExpr => {
+ let promise: IrExpr;
+ if (e.type.kind === "promise") promise = e;
+ else if (e.type.kind === "dyn")
+ promise = {
+ kind: "libCall",
+ fn: "dyn.promiseResolve",
+ args: [e, thenReader(lowerer, loc)],
+ type: dynPromise,
+ loc,
+ };
+ else if (isUnitType(e.type)) {
+ // A unit entry's value is its type's one value; the promise only
+ // takes its place in the combinator.
+ const resolved: IrExpr = {
+ kind: "intrinsic",
+ name: "promise.resolve",
+ args: [],
+ type: voidPromise,
+ loc,
+ };
+ promise =
+ e.kind === "unitLit"
+ ? resolved
+ : {
+ kind: "seqExpr",
+ stmts: [{ kind: "exprStmt", expr: e, loc }],
+ result: resolved,
+ type: voidPromise,
+ loc,
+ };
+ } else
+ promise = {
+ kind: "intrinsic",
+ name: "promise.resolve",
+ args: [e],
+ type: { kind: "promise", inner: e.type },
+ loc,
+ };
+ return promise.type.kind === "promise" && promise.type.inner.kind === "void"
+ ? promise
+ : { kind: "promiseVoidWiden", value: promise, type: voidPromise, loc };
+ });
+ const key = `${elems.map((e) => typeKey(e.type)).join(",")}->${typeKey(tuple)}`;
let name = lowerer.arrHofHelpers.get(`promise.allTuple:${key}`);
if (name === undefined) {
name = keyedHelperName(lowerer, "%promise.allTuple", `promise.allTuple:${key}`);
lowerer.arrHofHelpers.set(`promise.allTuple:${key}`, name);
- const ctx = newFnCtx(false, null, null, resultT.inner);
- ctx.isAsync = true;
- lowerer.fnStack.push(ctx);
- try {
- const params = elems.map((e, i) => ({
- localId: `entry.${i}`,
- name: `entry${i}`,
- type: e.type,
- }));
- const ref = (i: number): IrExpr => varRef(`entry.${i}`, elems[i]!.type, loc);
- const dynPromise: IrType = { kind: "promise", inner: DYN };
- const body: IrStmt[] = [];
- // Each dyn entry's promise view, in entry order: the promise it
- // holds, a promise that adopts a thenable's settlement, or a promise
- // fulfilled with the plain value (PromiseResolve's rule).
- const views = new Map();
- elems.forEach((e, i) => {
- if (e.type.kind !== "dyn") return;
- const view = lowerer.declareHiddenLocal("%allEntry", dynPromise);
- body.push({
- kind: "varDecl",
- localId: view.id,
- init: {
- kind: "libCall",
- fn: "dyn.promiseResolve",
- args: [ref(i), thenReader(lowerer, loc)],
- type: dynPromise,
- loc,
- },
- loc,
- });
- views.set(i, varRef(view.id, dynPromise, loc));
- });
- const widened: IrExpr[] = [];
- elems.forEach((e, i) => {
- const view = views.get(i);
- if (view !== undefined) {
- widened.push({
- kind: "promiseVoidWiden",
- value: view,
- type: { kind: "promise", inner: VOID },
- loc,
- });
- return;
- }
- if (e.type.kind !== "promise") return;
- widened.push(
- e.type.inner.kind === "void"
- ? ref(i)
- : {
- kind: "promiseVoidWiden",
- value: ref(i),
- type: { kind: "promise", inner: VOID },
+ const shape = lowerer.shapes.get(tuple.shapeId)!;
+ const entriesRef = varRef("entries.0", entriesT, loc);
+ // Shape fields are sorted by name ("10" before "2"); a tuple field's
+ // name is its position.
+ const fields = shape.fields.map((field) => {
+ const i = Number(field.name);
+ const type = elems[i]!.type;
+ const inner = type.kind === "promise" ? type.inner : type;
+ const value: IrExpr =
+ inner.kind === "void"
+ ? { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }
+ : isUnitType(inner)
+ ? {
+ kind: "unitLit",
+ unit: inner.kind === "nullT" ? "null" : "undefined",
+ type: inner,
loc,
- },
- );
- });
- const settled: IrExpr = {
- kind: "awaitExpr",
- value: {
- kind: "intrinsic",
- name: "promise.all",
- args: [
- {
- kind: "arrayLit",
- elems: widened,
- type: arrayOf({ kind: "promise", inner: VOID }),
- loc,
- },
- ],
- type: { kind: "promise", inner: VOID },
- loc,
- },
- type: VOID,
- loc,
- };
- // Shape fields are sorted by name ("10" before "2"); a tuple field's
- // name is its position.
- const fields = shape.fields.map((field) => {
- const i = Number(field.name);
- const entry = elems[i]!;
- let value: IrExpr;
- const view = views.get(i);
- if (view !== undefined) {
- value = { kind: "awaitExpr", value: view, type: DYN, loc };
- } else if (entry.type.kind === "promise") {
- value =
- entry.type.inner.kind === "void"
- ? { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }
- : { kind: "awaitExpr", value: ref(i), type: entry.type.inner, loc };
- } else {
- value = ref(i);
- }
- return { name: field.name, value: lowerer.coerceToExpected(value, field.type) };
- });
- body.push(
- { kind: "exprStmt", expr: settled, loc },
- {
- kind: "return",
- value: { kind: "recordLit", fields, type: resultT.inner, loc },
- loc,
- },
- );
- lowerer.liftedFns.push({
- name,
- params,
- returnType: resultT.inner,
- locals: [
- ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
- ...lowerer.ctx.locals,
- ],
- body,
- async: true,
- loc,
- });
- } catch (e) {
- lowerer.arrHofHelpers.delete(`promise.allTuple:${key}`);
- throw e;
- } finally {
- lowerer.fnStack.pop();
- }
+ }
+ : {
+ kind: "libCall",
+ fn: "promise.fulfilledValue",
+ args: [
+ {
+ kind: "arrayGet",
+ arr: entriesRef,
+ index: { kind: "numLit", value: i, type: F64, loc },
+ type: voidPromise,
+ loc,
+ },
+ ],
+ type: inner,
+ loc,
+ };
+ return { name: field.name, value: lowerer.coerceToExpected(value, field.type) };
+ });
+ lowerer.liftedFns.push({
+ name,
+ params: [{ localId: "entries.0", name: "entries", type: entriesT }],
+ returnType: tuple,
+ locals: [{ id: "entries.0", name: "entries", type: entriesT, mutable: false }],
+ body: [{ kind: "return", value: { kind: "recordLit", fields, type: tuple, loc }, loc }],
+ loc,
+ });
}
- return { kind: "call", callee: name, args: elems, type: resultT, loc };
+ const all: IrExpr = {
+ kind: "libCall",
+ fn: "promise.allTuple",
+ args: [
+ { kind: "arrayLit", elems: entries, type: entriesT, loc },
+ { kind: "closure", fnName: name, captures: [], type: funcOf([entriesT], tuple), loc },
+ ],
+ type: resultT,
+ loc,
+ };
+ return staged.length === 0
+ ? all
+ : { kind: "seqExpr", stmts: staged, result: all, type: resultT, loc };
}
/** `Promise.race([...])` on THE Promise global: the entries lower
diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts
index 45e6b091c..c7fb8d36e 100644
--- a/packages/compiler/src/frontend/lowering/call-signatures.ts
+++ b/packages/compiler/src/frontend/lowering/call-signatures.ts
@@ -611,7 +611,6 @@ export function checkDefaultParamBodyType(
if (
bodyType.kind === "void" ||
bodyType.kind === "regex" ||
- bodyType.kind === "date" ||
bodyType.kind === "dyn" ||
isUnitType(bodyType)
) {
diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
index ae8cce4ca..5e5f24e32 100644
--- a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
+++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
@@ -381,7 +381,7 @@ function esModuleExportIgnoresThis(
depth: number,
): boolean {
const moduleSym = lowerer.checker.getSymbolAtLocation(sf);
- const exported = moduleSym?.getExports().get(name as ts.__String);
+ const exported = moduleSym?.getExports().get(ts.escapedSymbolName(name));
if (exported !== undefined) {
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
index a4df8d910..d8893aa72 100644
--- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
+++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
@@ -28,8 +28,10 @@ import {
isModuleExportsAccess,
locOf,
npmStaticDepSf7,
+ requireBindingEarlyUse7,
requireSpecOf,
resolveImport,
+ splitRequireCallOf,
} from "../program.js";
import { createRequireCalleeFileOf } from "./builtins/module-bindings.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
@@ -43,6 +45,332 @@ export interface InlineRequire {
dep: ts.SourceFile;
}
+/** A top-level `var m; m = require("./x")` binding. Node evaluates the
+ * require at the assignment; the binding is alias plumbing exactly like
+ * `const m = require("./x")` for every later read. */
+export interface SplitRequireBinding {
+ decl: ts.VariableDeclaration;
+ assign: ts.BinaryExpression;
+ call: ts.CallExpression;
+ spec: string;
+ dep: ts.SourceFile;
+}
+
+function symbolOfIdentifierValue(lowerer: Lowerer, ident: ts.Identifier): ts.Symbol | undefined {
+ const parent = ident.parent;
+ // A destructuring shorthand target resolves to the property symbol at
+ // the name, but reads/writes the variable through its value symbol.
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === ident) {
+ return (
+ lowerer.checker.getShorthandAssignmentValueSymbol(parent) ??
+ lowerer.checker.getSymbolAtLocation(ident)
+ );
+ }
+ return lowerer.checker.getSymbolAtLocation(ident);
+}
+
+function destructuringContainerOf(node: ts.Node): ts.Node | null {
+ const parent = node.parent;
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return parent;
+ if (ts.isPropertyAssignment(parent) && parent.initializer === node) return parent;
+ if (ts.isSpreadElement(parent) && parent.expression === node) return parent;
+ if (ts.isSpreadAssignment(parent) && parent.expression === node) return parent;
+ return null;
+}
+
+function writeTargetOf(node: ts.Node): ts.Node | null {
+ let target = node;
+ for (;;) {
+ const parent = target.parent;
+ if (
+ ts.isParenthesizedExpression(parent) ||
+ ts.isArrayLiteralExpression(parent) ||
+ ts.isObjectLiteralExpression(parent)
+ ) {
+ target = parent;
+ continue;
+ }
+ const container = destructuringContainerOf(target);
+ if (container === null) break;
+ target = container;
+ }
+ const parent = target.parent;
+ if (
+ ts.isBinaryExpression(parent) &&
+ parent.left === target &&
+ ts.isAssignmentOperator(parent.operatorToken.kind)
+ )
+ return parent;
+ if (
+ (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
+ parent.operand === target &&
+ (parent.operator === ts.SyntaxKind.PlusPlusToken ||
+ parent.operator === ts.SyntaxKind.MinusMinusToken)
+ )
+ return parent;
+ if ((ts.isForInStatement(parent) || ts.isForOfStatement(parent)) && parent.initializer === target)
+ return parent;
+ return null;
+}
+
+/** The split form of a top-level require binding: `var m; m = require("./x")`.
+ * Exactly one write, at a top-level `m = require("")` statement.
+ * Null for the ordinary `const m = require("./x")` declaration and for
+ * every form that needs runtime module selection. */
+export function splitRequireBindingOf(
+ lowerer: Lowerer,
+ decl: ts.VariableDeclaration,
+): SplitRequireBinding | null {
+ // Cheap shape gate before the cache: ordinary declarations never enter the
+ // per-Lowerer map.
+ if (decl.initializer !== undefined || !ts.isIdentifier(decl.name)) return null;
+ const list = decl.parent as ts.VariableDeclarationList;
+ if ((list.flags & ts.NodeFlags.Const) !== 0) return null;
+ const stmt = list.parent;
+ if (!ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) return null;
+ const cached = lowerer.cjsSplitRequireBindings.get(decl);
+ if (cached !== undefined) return cached;
+ const result = computeSplitRequireBinding(lowerer, decl);
+ lowerer.cjsSplitRequireBindings.set(decl, result);
+ return result;
+}
+
+function computeSplitRequireBinding(
+ lowerer: Lowerer,
+ decl: ts.VariableDeclaration,
+): SplitRequireBinding | null {
+ const sf = ts.getSourceFileOfNode(decl);
+ if (!isCjsJsFile(sf, lowerer.program)) return null;
+ if (!sf.text.includes("require")) return null;
+ const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
+ if (symbol === undefined) return null;
+ // Merged declarations make the binding's initialization order ambiguous;
+ // keep ordinary storage instead of guessing.
+ let declarations = 0;
+ for (const other of symbol.declarations) {
+ if (!ts.isVariableDeclaration(other) || ++declarations > 1) return null;
+ }
+ if (declarations !== 1) return null;
+
+ // Find the one top-level `m = ...` statement first: most uninitialized
+ // variables never reach that shape, so they avoid the full-file write scan.
+ let assign: ts.BinaryExpression | null = null;
+ for (const stmt of sf.statements.nodes) {
+ if (!ts.isExpressionStatement(stmt)) continue;
+ const expr = stmt.expression;
+ if (
+ !ts.isBinaryExpression(expr) ||
+ expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
+ !ts.isIdentifier(expr.left) ||
+ expr.left.text !== decl.name.text ||
+ lowerer.checker.getSymbolAtLocation(expr.left) !== symbol
+ )
+ continue;
+ if (assign !== null) return null;
+ assign = expr;
+ }
+ if (assign === null) return null;
+
+ const req = inlineProgramRequireOf(lowerer, assign.right);
+ if (req === null) return null;
+ // Preflight treats the same bindings as require declarations (module edge,
+ // cycle admission, early-read fence); a binding it does not know keeps
+ // ordinary storage.
+ if (splitRequireCallOf(lowerer.program, decl) !== req.call) return null;
+
+ // Any other write makes the value depend on runtime control flow.
+ let writes = 0;
+ ts.walkPreorder(sf, (node) => {
+ if (!ts.isIdentifier(node) || node.text !== decl.name.text) return;
+ if (symbolOfIdentifierValue(lowerer, node) !== symbol) return;
+ if (writeTargetOf(node) === null) return;
+ if (++writes > 1) return "stop";
+ });
+ if (writes !== 1) return null;
+
+ return { decl, assign, call: req.call, spec: req.spec, dep: req.dep };
+}
+
+/** The split binding an identifier names, or null. */
+export function splitRequireBindingOfIdent(
+ lowerer: Lowerer,
+ ident: ts.Identifier,
+): SplitRequireBinding | null {
+ const symbol = symbolOfIdentifierValue(lowerer, ident);
+ return symbol === undefined ? null : splitBindingOfSymbol(lowerer, symbol);
+}
+
+/** The exact top-level assignment statement that initializes `expr`, or null. */
+export function splitRequireAssignmentOf(
+ lowerer: Lowerer,
+ expr: ts.Expression,
+): SplitRequireBinding | null {
+ if (
+ !ts.isBinaryExpression(expr) ||
+ expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
+ !ts.isIdentifier(expr.left)
+ )
+ return null;
+ const bindingOfIdent = splitRequireBindingOfIdent(lowerer, expr.left);
+ return bindingOfIdent !== null && bindingOfIdent.assign === expr ? bindingOfIdent : null;
+}
+
+/** The whole-export symbol a module's `module.exports = ` names,
+ * or null when the module is a table. */
+export function wholeExportSymbolOfModule(lowerer: Lowerer, dep: ts.SourceFile): ts.Symbol | null {
+ const cached = lowerer.cjsWholeExportSymbols.get(dep);
+ if (cached !== undefined) return cached;
+ if (cjsNamespaceModule(lowerer, dep)) {
+ lowerer.cjsWholeExportSymbols.set(dep, null);
+ return null;
+ }
+ const moduleSymbol = lowerer.checker.getSymbolAtLocation(dep);
+ let symbol = moduleSymbol?.getExports()?.get("export=" as ts.__String);
+ if (symbol === undefined) {
+ lowerer.cjsWholeExportSymbols.set(dep, null);
+ return null;
+ }
+ if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol);
+ lowerer.cjsWholeExportSymbols.set(dep, symbol);
+ return symbol;
+}
+
+function splitBindingOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): SplitRequireBinding | null {
+ const decl = symbol.declarations.find(ts.isVariableDeclaration);
+ return decl === undefined ? null : splitRequireBindingOf(lowerer, decl);
+}
+
+/** The target whole-export symbol behind a split-binding symbol. */
+function splitWholeExportSymbolOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null {
+ const binding = splitBindingOfSymbol(lowerer, symbol);
+ return binding === null ? null : wholeExportSymbolOfModule(lowerer, binding.dep);
+}
+
+/** True when a CommonJS module has a live member export (`exports.x` or
+ * `module.exports.x`) after all `module.exports =` replacements. Those
+ * members need a mutable whole-export root. */
+export function cjsHasLiveMemberExports(lowerer: Lowerer, sf: ts.SourceFile): boolean {
+ const cached = lowerer.cjsLiveMemberExports.get(sf);
+ if (cached !== undefined) return cached;
+ const stmts = sf.statements.nodes;
+ let lastTable = -1;
+ for (let i = 0; i < stmts.length; i++) {
+ if (cjsExportAssignmentOf(stmts[i]!)?.kind === "table") lastTable = i;
+ }
+ let live = false;
+ // Only members attached after the last replacement survive in Node.
+ if (lastTable >= 0) {
+ for (let i = lastTable + 1; i < stmts.length; i++) {
+ const stmt = stmts[i]!;
+ if (cjsExportAssignmentOf(stmt)?.kind === "member" && cjsExportDiscardReason(stmt) === null) {
+ live = true;
+ break;
+ }
+ }
+ }
+ lowerer.cjsLiveMemberExports.set(sf, live);
+ return live;
+}
+
+/** The wrapper's export= symbol when a split binding is re-exported whole
+ * (`module.exports = m`) and that assignment owns storage for later member
+ * writes. Importers must read the wrapper's storage, not the split
+ * binding's underlying value. */
+function splitWholeExportStorageSymbolOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null {
+ const binding = splitBindingOfSymbol(lowerer, symbol);
+ if (binding === null) return null;
+ if (wholeExportSymbolOfModule(lowerer, binding.dep) === null) return null;
+ const sf = ts.getSourceFileOfNode(binding.decl);
+ if (!cjsHasLiveMemberExports(lowerer, sf)) return null;
+ const whole = cjsWholeExportValueOf(sf);
+ if (whole === null) return null;
+ let rhs: ts.Expression = whole.rhs;
+ while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression;
+ if (!ts.isIdentifier(rhs) || lowerer.checker.getSymbolAtLocation(rhs) !== symbol) return null;
+ const moduleSymbol = lowerer.checker.getSymbolAtLocation(sf);
+ let exportSymbol = moduleSymbol?.getExports()?.get("export=" as ts.__String);
+ if (exportSymbol === undefined) return null;
+ if (exportSymbol.flags & ts.SymbolFlags.Alias)
+ exportSymbol = lowerer.checker.getAliasedSymbol(exportSymbol);
+ return exportSymbol;
+}
+
+/** The source file whose `module.exports =` assignment declares `symbol`. */
+function moduleExportSourceFileOf(symbol: ts.Symbol): ts.SourceFile | null {
+ for (const decl of symbol.declarations) {
+ if (
+ ts.isBinaryExpression(decl) &&
+ isModuleExportsAccess(decl.left) &&
+ ts.isExpressionStatement(decl.parent) &&
+ ts.isSourceFile(decl.parent.parent)
+ )
+ return decl.getSourceFile();
+ }
+ return null;
+}
+
+/** One hop from a wrapper's export= symbol to the value it re-exports
+ * (`module.exports = require(...)` or `= `), or null. A
+ * wrapper with live members owns storage for them, so it is not followed. */
+function wholeReexportTargetOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null {
+ const sf = moduleExportSourceFileOf(symbol);
+ if (sf === null) return null;
+ // A wrapper with live members owns storage for them; importers must keep
+ // reading that root rather than the inner value.
+ if (cjsHasLiveMemberExports(lowerer, sf)) return null;
+ const whole = cjsWholeExportValueOf(sf);
+ if (whole === null) return null;
+ const inner = inlineProgramRequireOf(lowerer, whole.rhs);
+ if (inner !== null) return wholeExportSymbolOfModule(lowerer, inner.dep);
+ let rhs: ts.Expression = whole.rhs;
+ while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression;
+ if (!ts.isIdentifier(rhs)) return null;
+ const rhsSymbol = lowerer.checker.getSymbolAtLocation(rhs);
+ return rhsSymbol === undefined ? null : splitWholeExportSymbolOfSymbol(lowerer, rhsSymbol);
+}
+
+/** Follow split bindings and whole re-exports to the ultimate value symbol. */
+function followSplitWholeTarget(lowerer: Lowerer, candidate: ts.Symbol | null): ts.Symbol | null {
+ if (candidate === null) return null;
+ let symbol = candidate;
+ const seen = new Set();
+ for (let depth = 0; depth < 32 && !seen.has(symbol); depth++) {
+ seen.add(symbol);
+ const next =
+ splitWholeExportSymbolOfSymbol(lowerer, symbol) ?? wholeReexportTargetOf(lowerer, symbol);
+ if (next === null) break;
+ symbol = next;
+ }
+ return symbol;
+}
+
+/** The target whole-export symbol behind `module.exports = `,
+ * letting importers of that wrapper resolve to the real value symbol. */
+export function splitWholeExportAliasTargetOf(
+ lowerer: Lowerer,
+ symbol: ts.Symbol,
+): ts.Symbol | null {
+ const storage = splitWholeExportStorageSymbolOf(lowerer, symbol);
+ if (storage !== null) return followSplitWholeTarget(lowerer, storage);
+ const direct = followSplitWholeTarget(lowerer, splitWholeExportSymbolOfSymbol(lowerer, symbol));
+ if (direct !== null) return direct;
+ return followSplitWholeTarget(lowerer, wholeReexportTargetOf(lowerer, symbol));
+}
+
+/** The first read of a split binding that code before its assignment can
+ * reach, using the same reachability model as ordinary require bindings. */
+export function splitRequireEarlyReadOf(
+ lowerer: Lowerer,
+ binding: SplitRequireBinding,
+): ts.Identifier | null {
+ const sf = ts.getSourceFileOfNode(binding.decl);
+ const stmt = binding.assign.parent;
+ // `binding` was recognized only from a top-level `m = require(...)`
+ // statement, so the statement index is always present.
+ const index = sf.statements.nodes.indexOf(stmt as ts.ExpressionStatement);
+ return requireBindingEarlyUse7(lowerer.program, sf, index, binding.decl, []);
+}
+
/** A `require("")` call of the CommonJS global in a CommonJS JS
* file whose target is a compiled program module (relative, aliased, or an
* opted-in --npm-static package). Builtins, JSON documents and
@@ -108,13 +436,6 @@ export function inlineNamespaceRequireOf(
/** `value` evaluated after the required module's run-once %init (and its
* module-registry link) at the require's own position. */
export function withRequireInit(lowerer: Lowerer, req: InlineRequire, value: IrExpr): IrExpr {
- if (lowerer.inlineRequireCycleCalls.has(req.call)) {
- lowerer.unsupported(
- "SC1090",
- req.call,
- `require() of '${req.spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`,
- );
- }
// Every inline program require is a preflight module edge, so its module
// has an initializer; never read its exports without evaluating it.
if (!lowerer.initNameOf.has(req.dep)) {
diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts
index 0bda0068e..3a1c7975d 100644
--- a/packages/compiler/src/frontend/lowering/class-callbacks.ts
+++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts
@@ -156,6 +156,13 @@ export function classCallbackValue(
};
}
+/** Notes a checked conversion of a replaceable method's result for the
+ * key-order witness: the result is a value the method or its replacement
+ * returns, not a record built from a dynamic value. */
+function methodResult(lowerer: Lowerer, checked: IrExpr, name: string): IrExpr {
+ return checked.kind === "dynCheck" ? lowerer.keyOrder.methodResult(checked, name) : checked;
+}
+
export function classCallbackCall(
lowerer: Lowerer,
receiver: IrExpr,
@@ -197,7 +204,7 @@ export function classCallbackCall(
// generator arm does not box) keeps its native type: the unchanged
// method's result stays as it is, and a replacement's dynamic result
// takes the checked conversion into that type.
- const converted = lowerer.coerceToExpected(call, fallback.type);
+ const converted = methodResult(lowerer, lowerer.coerceToExpected(call, fallback.type), name);
if (!typeEquals(converted.type, fallback.type)) {
lowerer.pushDiag(
unsupportedDiag(
@@ -234,7 +241,9 @@ export function classCallbackCall(
loc,
};
answer =
- fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
+ fallback.type.kind === "void"
+ ? result
+ : methodResult(lowerer, lowerer.coerceToExpected(result, fallback.type), name);
}
// Read after observedSlot built the prototype data and its originals.
const originalId = observed ? original?.originalId() : undefined;
diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
index ab3ebb34e..fc6d0a922 100644
--- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
+++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
@@ -1,4 +1,5 @@
import { primitiveConstructorOr } from "./lower-exprs.js";
+import { TO_STRING_TAG_FIELD, wellKnownSymbolOfField } from "./symbol-fields.js";
import { InternalCompilerError } from "../../errors.js";
import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js";
import { constructedValue } from "./constructor-returns.js";
@@ -26,6 +27,7 @@ import {
isUnitType,
typeEquals,
typeKey,
+ type IrClassDef,
type IrExpr,
type IrFunction,
type IrParam,
@@ -153,6 +155,41 @@ const STREAM_NUM_PROPERTIES = new Set([
const streamProperty = (name: string): boolean =>
STREAM_BOOL_PROPERTIES.has(name) || STREAM_NUM_PROPERTIES.has(name);
+/** `(instance) => string`, the string a class's inherited
+ * Symbol.toStringTag getter answers, which the runtime reads for
+ * Object.prototype.toString and util.inspect of an instance seen as
+ * `unknown`. None for a class without a string getter, or whose own tag
+ * field shadows it. */
+function stringTagHelper(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): IrFunction | null {
+ if (info.fields.has(TO_STRING_TAG_FIELD)) return null;
+ const getter = findMethodOn(lowerer, info, "get:sym:toStringTag");
+ if (getter?.sig.ret.kind !== "string") return null;
+ const type: IrType = { kind: "object", className: info.def.name };
+ const owner = getter.declarer.def.name;
+ return {
+ name: `%class.stringTag:${info.def.name}`,
+ params: [{ localId: "this", name: "this", type }],
+ locals: [{ id: "this", name: "this", type, mutable: false }],
+ returnType: STRING,
+ body: [
+ {
+ kind: "return",
+ value: accessorCall(
+ lowerer,
+ owner,
+ "get:sym:toStringTag",
+ lowerer.upcastTo(varRef("this", type, loc), owner),
+ [],
+ STRING,
+ loc,
+ ),
+ loc,
+ },
+ ],
+ loc,
+ };
+}
+
function symbolMemberKey(
lowerer: Lowerer,
info: ClassInfo,
@@ -282,6 +319,11 @@ interface PropertyDispatch {
fn: IrFunction;
classes: Set;
reflect?: boolean;
+ /** The helper's fallthrough statements as created, and the same access
+ * applied to the receiver itself: what the fallthrough reduces to for a
+ * value that is neither a class capsule nor a function, whose bag is the
+ * value. */
+ plain?: { tail: readonly IrStmt[]; access: IrStmt[] };
}
export function classInstanceOf(
@@ -712,6 +754,105 @@ export function classForInHasKey(
return { kind: "call", callee: name, args: [value, key], type: BOOL, loc };
}
+/** Box class instances at their exact class. `%dyn.class.normalize` turns a
+ * capsule of a class with subclasses into a capsule of the instance's own
+ * class before any member table reads it, so every such box was followed
+ * by a second capsule. Each conversion of a value whose static class has a
+ * normalization branch now selects the exact class at the conversion, with
+ * the same tests in the same order (%dyn.class.boxExact:C), and the
+ * normalization of the resulting capsule falls through. Only JavaScript
+ * class hierarchies, whose instances live in untyped code, take the exact
+ * box. */
+export function boxClassInstancesExactly(lowerer: Lowerer, functions: IrFunction[]): void {
+ const normalize = lowerer.liftedFnNamed("%dyn.class.normalize");
+ if (!normalize) return;
+ // Static class → its constructible descendants in normalization order.
+ const branches = new Map();
+ for (const stmt of normalize.body) {
+ if (stmt.kind !== "if" || stmt.cond.kind !== "libCall" || stmt.cond.fn !== "dyn.typedRefIs")
+ continue;
+ const key = stmt.cond.args[1];
+ if (key?.kind !== "strLit" || !key.value.startsWith("object:")) continue;
+ const children: string[] = [];
+ for (const inner of stmt.then) {
+ if (inner.kind !== "if" || inner.cond.kind !== "instanceOf") continue;
+ children.push(inner.cond.className);
+ }
+ // TypeScript hierarchies keep their static-class capsules: their boxes
+ // are rarer, and an exact box would emit each subclass's checked view.
+ const className = key.value.slice("object:".length);
+ const decl = lowerer.classes.get(className)?.decl;
+ if (!decl || !isJsSourceFile(ts.getSourceFileOfNode(decl))) continue;
+ if (children.length) branches.set(className, children);
+ }
+ if (branches.size === 0) return;
+ const loc = normalize.loc;
+ const helpers = new Map();
+ const helperFor = (className: string): string => {
+ const known = helpers.get(className);
+ if (known !== undefined) return known;
+ const name = `%dyn.class.boxExact:${className}`;
+ helpers.set(className, name);
+ const type: IrType = { kind: "object", className };
+ const value = varRef("value", type, loc);
+ functions.push({
+ name,
+ params: [{ localId: "value", name: "value", type }],
+ returnType: DYN,
+ locals: [{ id: "value", name: "value", type, mutable: false }],
+ body: [
+ ...branches.get(className)!.map((child): IrStmt => ({
+ kind: "if",
+ cond: { kind: "instanceOf", value, className: child, type: BOOL, loc },
+ then: [
+ {
+ kind: "return",
+ value: {
+ kind: "dynFrom",
+ value: {
+ kind: "downcast",
+ value,
+ type: { kind: "object", className: child },
+ loc,
+ },
+ type: DYN,
+ loc,
+ },
+ loc,
+ },
+ ],
+ else_: null,
+ loc,
+ })),
+ { kind: "return", value: { kind: "dynFrom", value, type: DYN, loc }, loc },
+ ],
+ loc,
+ });
+ return name;
+ };
+ const rewrite = (fn: IrFunction): void => {
+ fn.body = transformStmtList(fn.body, {
+ stmt: (statement) => statement,
+ expr: (expression) =>
+ expression.kind === "dynFrom" &&
+ expression.value.type.kind === "object" &&
+ branches.has(expression.value.type.className)
+ ? {
+ kind: "call",
+ callee: helperFor(expression.value.type.className),
+ args: [expression.value],
+ type: DYN,
+ loc: expression.loc,
+ }
+ : expression,
+ });
+ };
+ for (const fn of [...functions]) {
+ if (fn === normalize || fn.name.startsWith("%dyn.class.boxExact:")) continue;
+ rewrite(fn);
+ }
+}
+
/** Record field creation after the dispatch worklist closes, including writes
* in generated member tables. Native values stay in their slots; the bag
* owns only their property presence, attributes, and insertion position. */
@@ -1271,10 +1412,27 @@ export class ClassDynamicDispatch {
const info = lowerer.classes.get(className);
if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue;
if (info.symbolFields && info.def.symbolFields === undefined) {
- info.def.symbolFields = [...info.symbolFields].flatMap(([symbol, field]) => {
- const global = lowerer.globalsBySymbol.get(symbol);
- return global?.type.kind === "symbol" ? [{ field, globalId: global.id }] : [];
- });
+ info.def.symbolFields = [...info.symbolFields].flatMap(
+ ([symbol, field]): NonNullable => {
+ const global = lowerer.globalsBySymbol.get(symbol);
+ if (global?.type.kind === "symbol") return [{ field, globalId: global.id }];
+ const wellKnown = wellKnownSymbolOfField(field);
+ return wellKnown === null ? [] : [{ field, wellKnown }];
+ },
+ );
+ }
+ const tagged = (current: ClassInfo): ClassInfo[] => [
+ current,
+ ...current.subclasses.flatMap(tagged),
+ ];
+ for (const current of tagged(info)) {
+ if (current.def.stringTagHelper !== undefined) continue;
+ const fn = stringTagHelper(lowerer, current, functions[0]!.loc);
+ if (!fn) continue;
+ current.def.stringTagHelper = fn.name;
+ this.generated.add(fn);
+ lowerer.liftedFns.push(fn);
+ changed = true;
}
if (this.prototypeHelper && !this.prototypeClasses.has(className)) {
this.prototypeClasses.add(className);
@@ -1629,6 +1787,15 @@ export class ClassDynamicDispatch {
if (this.deletedNames === undefined)
this.deletedNames = deletedPropertyNames(lowerer.moduleOrder);
if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root);
+ // An Error family's bag records where `name` and `message` were
+ // created, which the error slots do not.
+ if (root.base?.def.name === "%Error") {
+ const order = (current: ClassInfo): void => {
+ current.def.errorBagOrder = true;
+ current.subclasses.forEach(order);
+ };
+ order(info);
+ }
}
const loc = this.propertyBag.loc;
const { receiver, condition } = this.propertyReceiver(plan, loc);
@@ -2724,7 +2891,28 @@ export class ClassDynamicDispatch {
loc,
});
}
- dispatch = { name, write: !!write, fn: helper, classes: new Set() };
+ const receiverAccess: IrStmt[] = read
+ ? [{ kind: "return", value: { ...read, value: varRef("p.0", DYN, loc) }, loc }]
+ : [
+ {
+ kind: "exprStmt",
+ expr: {
+ ...write!,
+ args: [varRef("p.0", DYN, loc), write!.args[1]!, varRef("p.1", DYN, loc)],
+ },
+ loc,
+ },
+ { kind: "return", value: null, loc },
+ ];
+ dispatch = {
+ name,
+ write: !!write,
+ fn: helper,
+ classes: new Set(),
+ ...(name !== "constructor" && name !== "toString"
+ ? { plain: { tail: [...helper.body], access: receiverAccess } }
+ : {}),
+ };
this.properties.set(key, dispatch);
this.generated.add(helper);
lowerer.liftedFns.push(helper);
@@ -3528,10 +3716,29 @@ export class ClassDynamicDispatch {
name,
lowerer.coerceToExpected(varRef(global.id, global.type, loc), DYN),
);
+ } else if (wellKnownSymbolOfField(name) !== null) {
+ // A field keyed by a well-known symbol (Symbol.toStringTag).
+ symbols.set(name, {
+ kind: "dynFrom",
+ type: DYN,
+ loc,
+ value: {
+ kind: "libCall",
+ fn: "sym.wellKnown",
+ args: [
+ { kind: "strLit", value: wellKnownSymbolOfField(name)!, type: STRING, loc },
+ ],
+ type: SYMBOL_T,
+ loc,
+ },
+ });
}
}
for (let owner: ClassInfo | null = info; owner; owner = owner.base) {
- for (const name of [...owner.methods.keys()])
+ for (const method of [...owner.methods.keys()]) {
+ // Accessors key by the property they define (`get [Symbol.toStringTag]`).
+ const name =
+ method.startsWith("get:") || method.startsWith("set:") ? method.slice(4) : method;
if (name.startsWith("sym:")) {
symbols.set(name, {
kind: "dynFrom",
@@ -3546,6 +3753,7 @@ export class ClassDynamicDispatch {
},
});
}
+ }
}
const branches: IrStmt[] = [];
if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) {
@@ -4896,6 +5104,7 @@ export class ClassDynamicDispatch {
);
}
if (lowerer) this.guardClassTables(lowerer);
+ if (lowerer) this.guardPropertyHelpers(lowerer);
}
/** Each tracked class's bag initializer over a typed receiver (`this`),
@@ -4985,6 +5194,87 @@ export class ClassDynamicDispatch {
}
}
+ /** Fast exits for the generated property helpers: when every class
+ * branch tests a capsule key or a class key and the fallthrough is still
+ * the bag access the helper was created with, a value of another kind
+ * (a plain object, array or primitive) normalizes to itself, has itself
+ * as its bag, and so gets the plain access directly, without the
+ * normalization, bag and static-bag calls. */
+ private guardPropertyHelpers(lowerer: Lowerer): void {
+ // Every class table the fallthrough or normalization calls must answer
+ // a plain value with the value itself: its kind exit is in place.
+ const exits = (name: string): boolean => {
+ const helper = lowerer.liftedFnNamed(name);
+ const first = helper?.body[0];
+ const param = helper?.params[0]?.localId;
+ return (
+ param !== undefined &&
+ first?.kind === "if" &&
+ first.cond.kind === "dynTest" &&
+ first.cond.negated === true &&
+ first.then.length === 1 &&
+ first.then[0]!.kind === "return" &&
+ first.then[0]!.value !== null &&
+ returnsParam(first.then[0]!.value, param)
+ );
+ };
+ const tables = new Set([
+ "%dyn.class.properties",
+ READ_PROPERTIES,
+ READ_PROPERTIES_OR_EMPTY,
+ "%dyn.class.normalize",
+ "%dyn.class.staticProperties",
+ ]);
+ const callsOnlyGuardedTables = (statements: IrStmt[]): boolean =>
+ everyStmtList(statements, {
+ stmt: () => true,
+ expr: (expression) =>
+ expression.kind !== "call" || (tables.has(expression.callee) && exits(expression.callee)),
+ });
+ for (const dispatch of this.properties.values()) {
+ const plain = dispatch.plain;
+ if (!plain) continue;
+ const fn = dispatch.fn;
+ const param = fn.params[0]?.localId;
+ if (param === undefined) continue;
+ const head = fn.body.length - plain.tail.length;
+ if (head < 0 || plain.tail.some((stmt, i) => fn.body[head + i] !== stmt)) continue;
+ let ok = true;
+ for (const stmt of fn.body.slice(0, head)) {
+ if (
+ stmt.kind === "assign" &&
+ stmt.localId === param &&
+ stmt.value.kind === "call" &&
+ stmt.value.callee === "%dyn.class.normalize"
+ )
+ continue;
+ if (stmt.kind === "if" && stmt.else_ === null && branchKind(stmt.cond, param) !== null)
+ continue;
+ ok = false;
+ break;
+ }
+ // Class branches never run for a plain value; the rest always does.
+ if (!ok || !callsOnlyGuardedTables(fn.body.filter((stmt) => stmt.kind !== "if"))) continue;
+ const loc = fn.loc;
+ const not = (test: "classCapsule" | "funcKind"): IrExpr => ({
+ kind: "dynTest",
+ test,
+ negated: true,
+ value: varRef(param, DYN, loc),
+ type: BOOL,
+ loc,
+ });
+ fn.body.unshift({
+ kind: "if",
+ cond: not("classCapsule"),
+ then: [{ kind: "if", cond: not("funcKind"), then: plain.access, else_: null, loc }],
+ else_: null,
+ loc,
+ });
+ delete dispatch.plain;
+ }
+ }
+
private normalizedDispatches(): IrFunction[] {
return [
...(this.propertyBag ? [this.propertyBag] : []),
diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts
index fd23b6db8..b57729181 100644
--- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts
+++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts
@@ -46,6 +46,13 @@ export function collectClassMethodMutations(
const typedClasses: (ts.ClassDeclaration | ts.ClassExpression)[] = [];
const symbolMethods = new Set();
let unknownKey: ts.Node | undefined;
+ /** Every write with an unknown key: each may install a replacement of any
+ * method name, and the key-order witness asks what each one installs. */
+ const unknownKeySites: ts.Node[] = [];
+ const markUnknownKey = (site: ts.Node): void => {
+ unknownKey = site;
+ unknownKeySites.push(site);
+ };
let numericKey: ts.Node | undefined;
let unknownOwnField = false;
// `sym:iterator` for `Symbol.iterator`: the name a class method declared
@@ -83,8 +90,8 @@ export function collectClassMethodMutations(
for (const key of computedMethods)
for (const name of literalValues(lowerer.typeOf(key)) ?? []) methods.add(String(name));
const markName = (name: string, site: ts.Node, arrow = false): void => {
- if (name === "prototype" || name === "__proto__") unknownKey = site;
- if (methods.has(name)) lowerer.prototypeMethodAccesses.set(name, site);
+ if (name === "prototype" || name === "__proto__") markUnknownKey(site);
+ if (methods.has(name)) lowerer.noteMethodAccess(name, site);
if (!arrow) receiverFields.add(name);
};
const unwrap = (value: ts.Expression): ts.Expression => {
@@ -125,11 +132,13 @@ export function collectClassMethodMutations(
};
// Whether no function value can inhabit `type`. Primitives qualify, and so
// do non-callable object types a function is not assignable to: one that
- // requires a property functions lack, or a weak (all-optional) type that
+ // requires a property functions lack, a weak (all-optional) type that
// shares no property with functions (TypeScript's weak-type rule rejects
- // callable sources too).
- const functionFree = (type: ts.Type): boolean => {
- if (type.isUnionType()) return ts.constituentTypes(type).every(functionFree);
+ // callable sources too), or a dictionary with no properties whose index
+ // signatures hold function-free values (functions have no index
+ // signature). `seen` stops recursive dictionary types.
+ const functionFree = (type: ts.Type, seen: Set = new Set()): boolean => {
+ if (type.isUnionType()) return ts.constituentTypes(type).every((t) => functionFree(t, seen));
if (
(type.flags &
(ts.TypeFlags.StringLike |
@@ -149,7 +158,12 @@ export function collectClassMethodMutations(
)
return false;
const properties = lowerer.checker.getPropertiesOfType(type);
- if (properties.length === 0) return false;
+ if (properties.length === 0) {
+ if (seen.has(type)) return true;
+ seen.add(type);
+ const indexes = lowerer.checker.getIndexInfosOfType(type);
+ return indexes.length > 0 && indexes.every((index) => functionFree(index.type, seen));
+ }
const required = properties.filter(
(property) => (property.flags & ts.SymbolFlags.Optional) === 0,
);
@@ -163,14 +177,32 @@ export function collectClassMethodMutations(
// A string index whose values cannot be functions excludes callable
// method slots. Class instances have no implicit index signature, so such
// a dictionary is only a class view when the class declares that index
- // signature, and then every method must be assignable to its value type.
- const functionFreeDictionary = (value: ts.Expression): boolean =>
- lowerer.checker
- .getIndexInfosOfType(lowerer.typeOf(value))
+ // signature, and then every method must be assignable to its value type,
+ // or when the dictionary keeps checked identity: it then holds the
+ // existing objects it receives, class instances included.
+ // A string-keyed dictionary that keeps checked identity holds the
+ // existing objects it receives, class instances included, so any write
+ // through it can replace a method.
+ const dictionaryView = (value: ts.Expression): boolean => {
+ const type = lowerer.typeOf(value);
+ return (
+ lowerer.checker.getPropertiesOfType(type).length === 0 &&
+ lowerer.checker
+ .getIndexInfosOfType(type)
+ .some((index) => (index.keyType.flags & ts.TypeFlags.StringLike) !== 0) &&
+ lowerer.classImplementedProtocol(type)
+ );
+ };
+ const functionFreeDictionary = (value: ts.Expression): boolean => {
+ const type = lowerer.typeOf(value);
+ if (lowerer.classImplementedProtocol(type)) return false;
+ return lowerer.checker
+ .getIndexInfosOfType(type)
.some(
(index) =>
(index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && functionFree(index.type),
);
+ };
const literalLoopKeys = (key: ts.Expression): (string | number)[] | null => {
if (!ts.isIdentifier(key)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(key);
@@ -394,11 +426,22 @@ export function collectClassMethodMutations(
(ts.isElementAccessExpression(base) &&
ts.isStringLiteralLike(base.argumentExpression) &&
base.argumentExpression.text === "prototype");
- if (prototype) {
- if (ts.isPropertyAccessExpression(node))
- lowerer.prototypeMethodAccesses.set(node.name.text, node);
+ // `Object.prototype.toString.call(value)` calls the builtin and
+ // keeps no reference to it, so it replaces nothing.
+ const builtinCall =
+ ts.isPropertyAccessExpression(node) &&
+ ts.isPropertyAccessExpression(base) &&
+ ts.isIdentifier(unwrap(base.expression)) &&
+ lowerer.isStdlibGlobal(unwrap(base.expression) as ts.Identifier, "Object") &&
+ ts.isPropertyAccessExpression(node.parent) &&
+ node.parent.expression === node &&
+ (node.parent.name.text === "call" || node.parent.name.text === "apply") &&
+ ts.isCallExpression(node.parent.parent) &&
+ node.parent.parent.expression === node.parent;
+ if (prototype && !builtinCall) {
+ if (ts.isPropertyAccessExpression(node)) lowerer.noteMethodAccess(node.name.text, node);
else if (ts.isStringLiteralLike(node.argumentExpression))
- lowerer.prototypeMethodAccesses.set(node.argumentExpression.text, node);
+ lowerer.noteMethodAccess(node.argumentExpression.text, node);
else markKey(node.argumentExpression, node);
}
}
@@ -435,7 +478,7 @@ export function collectClassMethodMutations(
? lowerer.checker.getSymbolAtLocation(access.name)
: lowerer.checker.getPropertyOfType(lowerer.typeOf(access.expression), name);
if (symbol && symbol.declarations.some(ts.isMethodDeclaration))
- lowerer.prototypeMethodAccesses.set(name, access);
+ lowerer.noteMethodAccess(name, access);
}
lowerer.checker.prefetchSymbolRoots([...globals, ...reflection]);
for (const node of globals)
@@ -443,11 +486,12 @@ export function collectClassMethodMutations(
(lowerer.isStdlibGlobal(node, "Object") || lowerer.isStdlibGlobal(node, "Reflect")) &&
(!ts.isPropertyAccessExpression(node.parent) || node.parent.expression !== node)
)
- unknownKey = node;
+ markUnknownKey(node);
for (const node of reflection) {
const objectMethod = lowerer.stdlibGlobalMember(node, "Object");
const reflectMethod = lowerer.stdlibGlobalMember(node, "Reflect");
- if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf") unknownKey = node;
+ if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf")
+ markUnknownKey(node);
const single =
objectMethod === "defineProperty" ||
reflectMethod === "defineProperty" ||
@@ -457,7 +501,7 @@ export function collectClassMethodMutations(
if (!single && !multiple) continue;
const call = node.parent;
if (!ts.isCallExpression(call) || call.expression !== node) {
- unknownKey = node;
+ markUnknownKey(node);
continue;
}
const receiver = call.arguments.nodes[0];
@@ -571,9 +615,13 @@ export function collectClassMethodMutations(
);
for (const { target, site } of propertyWrites) {
const name = target.name.text;
- if (lowerer.prototypeMethodAccesses.has(name) || functionFree(lowerer.typeOf(target))) continue;
- if (!methodOwnerCanInhabit(target.expression, name)) continue;
- lowerer.prototypeMethodAccesses.set(name, site);
+ if (
+ !dictionaryView(target.expression) &&
+ (functionFree(lowerer.typeOf(target)) || !methodOwnerCanInhabit(target.expression, name))
+ )
+ continue;
+ if (lowerer.prototypeMethodAccesses.has(name)) lowerer.noteMethodSite(name, site);
+ else lowerer.noteMethodAccess(name, site);
}
for (const { key, receiver, site } of computedWrites) {
// A class method declared with a well-known symbol key is not a write;
@@ -611,7 +659,7 @@ export function collectClassMethodMutations(
// Numeric writes can replace numeric names but not ordinary names.
numericKey = site;
} else if (!(flags & ts.TypeFlags.ESSymbolLike)) {
- unknownKey = site;
+ markUnknownKey(site);
}
}
if (numericKey) {
@@ -620,7 +668,10 @@ export function collectClassMethodMutations(
}
}
if (unknownKey) {
- for (const method of methods) lowerer.prototypeMethodAccesses.set(method, unknownKey);
+ for (const method of methods) {
+ lowerer.prototypeMethodAccesses.set(method, unknownKey);
+ for (const site of unknownKeySites) lowerer.noteMethodSite(method, site);
+ }
for (const method of symbolMethods) lowerer.replacedSymbolMethods.add(method);
}
if (!unknownKey && !unknownOwnField)
diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts
index 76091b13e..00eedb6c0 100644
--- a/packages/compiler/src/frontend/lowering/class-protocols.ts
+++ b/packages/compiler/src/frontend/lowering/class-protocols.ts
@@ -10,6 +10,9 @@ export class ClassProtocols {
private readonly accepted = new Map();
/** What markIdentityFlows learned about the program, while it runs. */
private facts: FlowFacts | null = null;
+ /** The value types of the string-keyed dictionaries that keep checked
+ * identity (see markDictionaryViews). */
+ private readonly dictionaryViews = new Set();
constructor(
private readonly checker: ts.TypeChecker,
@@ -323,6 +326,7 @@ export class ClassProtocols {
else if (names !== "all") names.add(name);
}
}
+ this.markDictionaryViews(sites, sourceNodes, observedNodes, writeNodes);
for (let i = 0; i < looseArguments.length; i++) {
const type = typeOf(looseNodes[i]!);
if (type === undefined) continue;
@@ -697,6 +701,163 @@ export class ClassProtocols {
return source !== undefined && this.isProgramClassInstance(source);
}
+ /** String-keyed dictionaries (`Record`, `{ [key: string]: V }`)
+ * that receive an existing record or class instance. Natively a
+ * dictionary is a key/value record, and converting another object into
+ * one copies its fields, so writes through the dictionary and identity
+ * checks would miss the original. When the program writes through, or
+ * observes, a value of such a dictionary type, every dictionary with that
+ * value type keeps checked identity instead (records with the same value
+ * type share one native layout, so values pass between them). A source
+ * counts when it is an existing object (a binding, member, element or
+ * `this`, not a fresh literal or call result) whose type is a program
+ * class instance or a record with declared properties, or a constant
+ * `{}`, `object` or `unknown` binding initialized with one. */
+ private markDictionaryViews(
+ sites: readonly {
+ source: ts.Expression;
+ target: () => ts.Type | undefined;
+ contextual?: ts.Expression;
+ }[],
+ sourceNodes: readonly ts.Expression[],
+ observedNodes: readonly ts.Expression[],
+ writeNodes: readonly ts.Expression[],
+ ): void {
+ // The calls the sites' arguments belong to, by callee name, built on
+ // first use.
+ let calls: Map | null = null;
+ const callsNamed = (name: string): readonly (ts.CallExpression | ts.NewExpression)[] => {
+ if (calls === null) {
+ calls = new Map();
+ for (const site of sites) {
+ const call = site.contextual?.parent;
+ if (call === undefined || (!ts.isCallExpression(call) && !ts.isNewExpression(call)))
+ continue;
+ const callee = call.expression;
+ const named = ts.isIdentifier(callee)
+ ? callee.text
+ : ts.isPropertyAccessExpression(callee)
+ ? callee.name.text
+ : undefined;
+ if (named === undefined) continue;
+ const list = calls.get(named) ?? [];
+ if (!list.includes(call)) list.push(call);
+ calls.set(named, list);
+ }
+ }
+ return calls.get(name) ?? [];
+ };
+ const candidates: { index: number; sourceType: ts.Type }[] = [];
+ for (let i = 0; i < sites.length; i++) {
+ const source = sourceNodes[i]!;
+ if (!isExistingObject(source)) continue;
+ const sourceType = attempt(() => this.checker.getTypeAtLocation(source));
+ if (sourceType !== undefined && this.dictionarySource(source, sourceType, callsNamed))
+ candidates.push({ index: i, sourceType });
+ }
+ if (candidates.length === 0) return;
+ // One batch for the contextual targets of the candidate sites.
+ attempt(() =>
+ this.checker.prefetchContextualTypes(
+ candidates.flatMap(({ index }) => {
+ const contextual = sites[index]!.contextual;
+ return contextual === undefined ? [] : [contextual];
+ }),
+ ),
+ );
+ const received = new Set();
+ for (const { index, sourceType } of candidates) {
+ const target = attempt(sites[index]!.target);
+ const parts =
+ target === undefined ? [] : target.isUnionType() ? ts.constituentTypes(target) : [target];
+ for (const part of parts) {
+ const value = this.dictionaryValueType(part);
+ // A dictionary of `unknown` or `any` values is checked-dynamic already.
+ if (
+ value !== undefined &&
+ (value.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) === 0 &&
+ this.dictionaryValueType(sourceType) !== value
+ )
+ received.add(value);
+ }
+ }
+ if (received.size === 0) return;
+ // The dictionary values the program writes through or observes.
+ const seen = new Set();
+ for (const node of [...writeNodes, ...observedNodes]) {
+ const type = attempt(() => this.checker.getTypeAtLocation(node));
+ const parts =
+ type === undefined ? [] : type.isUnionType() ? ts.constituentTypes(type) : [type];
+ for (const part of parts) {
+ const value = this.dictionaryValueType(part);
+ if (value !== undefined) seen.add(value);
+ }
+ }
+ for (const value of received) if (seen.has(value)) this.dictionaryViews.add(value);
+ }
+
+ /** Whether `source` holds an existing object that a dictionary
+ * conversion would copy. */
+ private dictionarySource(
+ source: ts.Expression,
+ type: ts.Type,
+ callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[],
+ ): boolean {
+ if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true;
+ if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false;
+ const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration;
+ if (decl === undefined) return false;
+ const holdsObject = (expr: ts.Expression): boolean => {
+ const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr)));
+ return (
+ held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held))
+ );
+ };
+ if (ts.isParameter(decl)) {
+ // A parameter holds what its callers pass: the calls of the function
+ // by name whose signature resolves to it.
+ const fn = decl.parent;
+ if (fn === undefined || !ts.isFunctionLike(fn)) return false;
+ const index = fn.parameters.nodes.indexOf(decl);
+ const name = functionName(fn);
+ if (name === undefined || index < 0) return false;
+ return callsNamed(name).some((call) => {
+ const argument = call.arguments?.nodes[index];
+ return (
+ argument !== undefined &&
+ !ts.isSpreadElement(argument) &&
+ attempt(() => this.checker.getResolvedSignature(call)?.declaration) === fn &&
+ holdsObject(argument)
+ );
+ });
+ }
+ return (
+ ts.isVariableDeclaration(decl) &&
+ decl.initializer !== undefined &&
+ ts.isVariableDeclarationList(decl.parent) &&
+ (decl.parent.flags & ts.NodeFlags.Const) !== 0 &&
+ holdsObject(decl.initializer)
+ );
+ }
+
+ /** A program object type with declared properties that is not a class
+ * instance, array, tuple, function or library type. */
+ private isRecordWithFields(type: ts.Type): boolean {
+ return this.isStructuralTarget(type) && this.checker.getPropertiesOfType(type).length > 0;
+ }
+
+ /** The value type of a string-keyed dictionary with no declared
+ * properties, or undefined for any other type. */
+ private dictionaryValueType(type: ts.Type): ts.Type | undefined {
+ if ((type.flags & ts.TypeFlags.Object) === 0) return undefined;
+ if (this.checker.isArrayType(type) || this.checker.isTupleType(type)) return undefined;
+ if (this.checker.getCallSignatures(type).length > 0) return undefined;
+ if (this.checker.getPropertiesOfType(type).length > 0) return undefined;
+ return this.checker
+ .getIndexInfosOfType(type)
+ .find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type;
+ }
+
private isProgramClassInstance(type: ts.Type): boolean {
const symbol = type.getSymbol();
return (
@@ -822,7 +983,13 @@ export class ClassProtocols {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const previous = this.answers.get(type);
if (previous !== undefined) return previous;
- const answer = this.protocol(type);
+ // A dictionary keeps checked identity when the protocols already mark
+ // it (a literal whose methods read `this`) or when it receives existing
+ // objects (markDictionaryViews).
+ const dictionaryValue = this.dictionaryValueType(type);
+ const answer =
+ this.protocol(type) ||
+ (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue));
this.answers.set(type, answer);
return answer;
}
@@ -922,6 +1089,44 @@ const IDENTITY_METHODS = new Set([
"includes",
]);
+/** An expression that denotes an object that already exists, not a fresh
+ * literal or call result. */
+function isExistingObject(node: ts.Expression): boolean {
+ return (
+ ts.isIdentifier(node) ||
+ ts.isPropertyAccessExpression(node) ||
+ ts.isElementAccessExpression(node) ||
+ node.kind === ts.SyntaxKind.ThisKeyword
+ );
+}
+
+/** `{}`, `object` and `unknown`: types whose values are checked-dynamic
+ * and keep the original object. */
+function isDynamicView(checker: ts.TypeChecker, type: ts.Type): boolean {
+ if (type.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) return true;
+ return (
+ (type.flags & ts.TypeFlags.Object) !== 0 &&
+ checker.getPropertiesOfType(type).length === 0 &&
+ checker.getIndexInfosOfType(type).length === 0 &&
+ checker.getCallSignatures(type).length === 0 &&
+ checker.getConstructSignatures(type).length === 0
+ );
+}
+
+/** The name calls use for a function, method or function-valued binding. */
+function functionName(fn: ts.Node): string | undefined {
+ if ((ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name !== undefined)
+ return ts.isIdentifier(fn.name) ? fn.name.text : undefined;
+ if (
+ (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
+ fn.parent !== undefined &&
+ ts.isVariableDeclaration(fn.parent) &&
+ ts.isIdentifier(fn.parent.name)
+ )
+ return fn.parent.name.text;
+ return undefined;
+}
+
function isNullishLiteral(node: ts.Expression): boolean {
while (ts.isParenthesizedExpression(node)) node = node.expression;
return (
diff --git a/packages/compiler/src/frontend/lowering/class-to-primitive.ts b/packages/compiler/src/frontend/lowering/class-to-primitive.ts
index 0b107ab4e..8807ac5a5 100644
--- a/packages/compiler/src/frontend/lowering/class-to-primitive.ts
+++ b/packages/compiler/src/frontend/lowering/class-to-primitive.ts
@@ -13,6 +13,7 @@ import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js";
import { isClassCallback } from "./class-callbacks.js";
+import { TO_STRING_TAG_FIELD } from "./symbol-fields.js";
import { isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { DATE_TIME_FIELD, dateRootOf } from "./builtins/date-classes.js";
import { dateValueMethod } from "./builtins/dates.js";
@@ -29,7 +30,15 @@ type ConversionMethod = "sym:toPrimitive" | "valueOf" | "toString";
* Date`, or through Object.prototype.toString ("[object Object]"). */
type Plan =
| { method: ConversionMethod; ret: IrType }
- | { method: null; date?: "toString" | "valueOf" };
+ | { method: null; date?: "toString" | "valueOf"; tag?: ToStringTag };
+
+/** Where Object.prototype.toString finds a class's Symbol.toStringTag: a
+ * string field (`[Symbol.toStringTag] = "LRUCache"`) or a getter that
+ * returns a string, declared on `owner`. */
+interface ToStringTag {
+ owner: ClassInfo;
+ getter: boolean;
+}
const PRIMITIVE_KINDS = new Set(["f64", "string", "bool", "bigint", "undefinedT", "nullT"]);
@@ -52,6 +61,17 @@ export function classToStringConversion(
);
}
+/** Object.prototype.toString of a program class instance: "[object " +
+ * its Symbol.toStringTag + "]" (an own tag field, else an inherited
+ * getter), or "[object Object]". The class's own conversion methods do not
+ * take part. Null as for classToStringConversion, and for Date subclasses. */
+export function classObjectTag(lowerer: Lowerer, value: IrExpr, node: ts.Node): IrExpr | null {
+ if (value.type.kind !== "object") return null;
+ const info = lowerer.classes.get(value.type.className);
+ if (!info || dateRootOf(info) !== null) return null;
+ return classConversion(lowerer, value, "tag", node, STRING, (tag) => tag);
+}
+
/** ToNumber(ToPrimitive(value, "number")) of a program class instance
* (`Number(x)`): `[Symbol.toPrimitive]("number")`, then `valueOf` and then
* `toString`, and NaN for "[object Object]". Null as for
@@ -122,7 +142,7 @@ function mayBeBigint(lowerer: Lowerer, type: IrType): boolean {
function classConversion(
lowerer: Lowerer,
value: IrExpr,
- hint: ConversionHint,
+ hint: ConversionHint | "tag",
node: ts.Node,
resultType: IrType,
finish: (primitive: IrExpr) => IrExpr,
@@ -131,7 +151,7 @@ function classConversion(
const info = lowerer.classes.get(value.type.className);
if (!info || !plainProgramClass(info)) return null;
const classes = subtree(lowerer, info);
- for (const cls of classes) refuseReplacedConversions(lowerer, cls, node);
+ if (hint !== "tag") for (const cls of classes) refuseReplacedConversions(lowerer, cls, node);
const plans = new Map(classes.map((cls) => [cls, planOf(lowerer, cls, hint, node)]));
// A class whose plan differs from its base's starts a group; the deepest
// groups are tested first, so each instance takes its own class's plan.
@@ -164,12 +184,52 @@ function classConversion(
};
return finish(dateValueMethod(plan.date, time, loc)!);
}
+ if (plan.method === null && plan.tag) {
+ // Object.prototype.toString: "[object " + the tag + "]".
+ const owner = plan.tag.owner.def.name;
+ const tag: IrExpr = plan.tag.getter
+ ? accessorCall(
+ lowerer,
+ owner,
+ "get:sym:toStringTag",
+ lowerer.upcastTo(object, owner),
+ [],
+ STRING,
+ loc,
+ )
+ : {
+ kind: "fieldGet",
+ obj: lowerer.upcastTo(object, owner),
+ className: owner,
+ field: TO_STRING_TAG_FIELD,
+ type: STRING,
+ loc,
+ };
+ return finish({
+ kind: "strConcat",
+ left: {
+ kind: "strConcat",
+ left: { kind: "strLit", value: "[object ", type: STRING, loc },
+ right: tag,
+ type: STRING,
+ loc,
+ },
+ right: { kind: "strLit", value: "]", type: STRING, loc },
+ type: STRING,
+ loc,
+ });
+ }
if (plan.method === null)
return finish({ kind: "strLit", value: "[object Object]", type: STRING, loc });
const args: IrExpr[] =
plan.method === "sym:toPrimitive" ? [{ kind: "strLit", value: hint, type: STRING, loc }] : [];
return finish(accessorCall(lowerer, cls.def.name, plan.method, object, args, plan.ret, loc));
};
+ // "[object Object]" reads nothing of the instance, but the receiver
+ // expression still runs, once.
+ const own = plans.get(info)!;
+ if (tops.length === 0 && own.method === null && !own.date && !own.tag && !isSafeToRepeat(value))
+ prefix.push({ kind: "exprStmt", expr: value, loc });
let result = convert(info, receiver);
for (let i = tops.length - 1; i >= 0; i--) {
const top = tops[i]!;
@@ -228,10 +288,28 @@ function depth(info: ClassInfo): number {
}
function samePlan(a: Plan, b: Plan): boolean {
- return (
- a.method === b.method &&
- (a.method !== null || b.method !== null || a.date === (b as { date?: string }).date)
- );
+ if (a.method !== null || b.method !== null) return a.method === b.method;
+ return a.date === b.date && a.tag?.owner === b.tag?.owner && a.tag?.getter === b.tag?.getter;
+}
+
+/** The class's Symbol.toStringTag, when it has one: the class at the top
+ * of the chain whose layout holds the tag field (an own property, which
+ * shadows any getter), or its getter. Other tag types would make
+ * Object.prototype.toString answer "Object" or the value depending on the
+ * run, and are refused. */
+function toStringTagOf(lowerer: Lowerer, info: ClassInfo, node: ts.Node): ToStringTag | null {
+ const type = info.fields.get(TO_STRING_TAG_FIELD);
+ if (type !== undefined) {
+ if (type.kind !== "string") refuse(lowerer, node, info, "a non-string Symbol.toStringTag");
+ let owner = info;
+ while (owner.base?.fields.has(TO_STRING_TAG_FIELD)) owner = owner.base;
+ return { owner, getter: false };
+ }
+ const getter = findMethodOn(lowerer, info, "get:sym:toStringTag");
+ if (!getter) return null;
+ if (getter.sig.ret.kind !== "string")
+ refuse(lowerer, node, info, "a non-string Symbol.toStringTag");
+ return { owner: getter.declarer, getter: true };
}
/** A conversion method the class declares or inherits (a field of that
@@ -246,7 +324,16 @@ function declared(
return found ? { ret: found.sig.ret } : null;
}
-function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: ts.Node): Plan {
+function planOf(
+ lowerer: Lowerer,
+ info: ClassInfo,
+ hint: ConversionHint | "tag",
+ node: ts.Node,
+): Plan {
+ if (hint === "tag") {
+ const tag = toStringTagOf(lowerer, info, node);
+ return tag ? { method: null, tag } : { method: null };
+ }
const exotic = declared(lowerer, info, "sym:toPrimitive");
if (exotic) {
if (!primitive(lowerer, exotic.ret))
@@ -271,7 +358,8 @@ function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: t
refuse(lowerer, node, info, `a '${method}' method that can return an object`);
return { method, ret: found.ret };
}
- return { method: null };
+ const tag = toStringTagOf(lowerer, info, node);
+ return tag ? { method: null, tag } : { method: null };
}
function primitive(lowerer: Lowerer, type: IrType): boolean {
@@ -292,8 +380,6 @@ function refuseReplacedConversions(lowerer: Lowerer, info: ClassInfo, node: ts.N
if (lowerer.prototypeMethodAccesses.has(method))
refuse(lowerer, node, info, `a '${method}' the program reaches through a prototype`);
}
- if (findMethodOn(lowerer, info, "get:sym:toStringTag") || info.fields.has("sym:toStringTag"))
- refuse(lowerer, node, info, "a Symbol.toStringTag");
}
function refuse(lowerer: Lowerer, node: ts.Node, info: ClassInfo, what: string): never {
diff --git a/packages/compiler/src/frontend/lowering/completion-callbacks.ts b/packages/compiler/src/frontend/lowering/completion-callbacks.ts
new file mode 100644
index 000000000..1f4013d5e
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/completion-callbacks.ts
@@ -0,0 +1,155 @@
+/* Completion callbacks in programs that use AsyncLocalStorage. Node runs
+ * the callback of an operation a callback-style API starts (fs.readFile,
+ * crypto.randomBytes, zlib.gzip, dns.lookup, server.listen, net.connect,
+ * http.get, stream.finished, stream.pipeline, ...) with the stores active
+ * when the call started it. The runtime invokes these callbacks from
+ * the event loop, where the context is empty, so in programs whose
+ * scheduling carries contexts (RuntimeFeatures asyncContext) each such
+ * argument is bound to the active context at the call: als.bindCallback
+ * for typed closures, als.bindCallbackDyn for dyn functions. Other
+ * programs are left unchanged. */
+import { BOOL, type IrExpr, type IrModule, type IrStmt } from "../../ir/ir.js";
+import type { IrLibFn } from "../../ir/builtin-signatures.js";
+import { mapExprChildren, mapStmtChildren } from "../../ir/traverse.js";
+import { moduleRuntimeFeatures } from "../../ir/runtime-features.js";
+
+/** The argument that is the completion callback: its index; "last" for
+ * calls whose callback is the final argument when one is passed; or
+ * "arguments" for the fs callback calls, whose argument is the call's
+ * dyn argument vector, where the runtime finds the callback. */
+type CallbackPosition = number | "last" | "arguments";
+
+const COMPLETION_CALLBACKS: ReadonlyMap = new Map<
+ IrLibFn,
+ CallbackPosition
+>([
+ ["cp.execFile", 2],
+ ["crypto.generateKeyCb", 2],
+ ["crypto.hkdfCb", 5],
+ ["crypto.pbkdf2Cb", 5],
+ ["crypto.randomBytesCb", 1],
+ ["crypto.randomFillCb", 4],
+ ["crypto.randomFillCbDyn", 4],
+ ["crypto.randomIntCb", 3],
+ ["crypto.scryptCb", 4],
+ ["dgram.bindCb", 3],
+ ["dgram.closeCb", 1],
+ ["dgram.connectCb", 3],
+ ["dns.lookupDyn", 2],
+ ["fs.callbackCall", "arguments"],
+ ["fs.callbackGlob", "arguments"],
+ ["fs.renameCb", 2],
+ ["http.requestAgentCb", "last"],
+ ["http.requestCb", "last"],
+ ["http.requestConnCb", "last"],
+ ["http.requestDynCb", "last"],
+ ["http.requestUrlCb", "last"],
+ ["net.connectCb", 2],
+ ["net.listenCb", 2],
+ ["net.listenOptsCb", 4],
+ ["net.listenOptsReusePortCb", 5],
+ ["net.listenPathCb", 2],
+ ["process.stderrWriteBytesCb", 2],
+ ["process.stdoutWriteBytesCb", 2],
+ // stream.finished and stream.pipeline callbacks, which Node binds to
+ // the context of the call (eos' and pipeline's AsyncResource.bind).
+ ["stream.finished", 1],
+ ["stream.finishedDyn", 1],
+ ["stream.finishedOpts", "last"],
+ ["stream.finishedOptsSignal", "last"],
+ ["stream.pipeline", "last"],
+ ["stream.pipelineDyn", "last"],
+ ["stream.pipelineStages", "last"],
+ ["stream.pipelineStagesDyn", "last"],
+ ["zlib.cbOpts", 3],
+ ["zlib.cbOptsDyn", 3],
+ ["zlib.deflateCb", 1],
+ ["zlib.deflateRawCb", 1],
+ ["zlib.gunzipCb", 1],
+ ["zlib.gzipCb", 1],
+ ["zlib.inflateCb", 1],
+ ["zlib.inflateRawCb", 1],
+ ["zlib.unzipCb", 1],
+]);
+
+/** Completion callbacks that fire from other contexts' code (a stream event
+ * that another context's call emits), so an empty context at the call
+ * binds too: the callback then runs with no store, as in Node. */
+const BIND_EMPTY_CONTEXT: ReadonlySet = new Set([
+ "stream.finished",
+ "stream.finishedDyn",
+ "stream.finishedOpts",
+ "stream.finishedOptsSignal",
+ "stream.pipeline",
+ "stream.pipelineDyn",
+ "stream.pipelineStages",
+ "stream.pipelineStagesDyn",
+]);
+
+/** `cb` bound to the context active where it is evaluated. Unless
+ * `bindEmpty`, an empty context leaves it unbound (`always` false): the
+ * loop already runs those callbacks with an empty context. */
+function bindToActiveContext(cb: IrExpr, bindEmpty = false): IrExpr {
+ const always: IrExpr = { kind: "boolLit", value: bindEmpty, type: BOOL, loc: cb.loc };
+ if (cb.type.kind === "func")
+ return {
+ kind: "libCall",
+ fn: "als.bindCallback",
+ args: [cb, always],
+ type: cb.type,
+ loc: cb.loc,
+ };
+ if (cb.type.kind === "dyn")
+ return {
+ kind: "libCall",
+ fn: "als.bindCallbackDyn",
+ args: [cb, always],
+ type: cb.type,
+ loc: cb.loc,
+ };
+ return cb;
+}
+
+/** An element of a dyn argument vector that can be the callback: a boxed
+ * closure binds as a typed closure; any other dyn value that is not a
+ * boxed primitive binds as a dyn function (non-functions pass through). */
+function bindArgumentElement(e: IrExpr): IrExpr {
+ if (e.kind === "dynFrom") {
+ return e.value.type.kind === "func" ? { ...e, value: bindToActiveContext(e.value) } : e;
+ }
+ return e.type.kind === "dyn" ? bindToActiveContext(e) : e;
+}
+
+function bindCall(e: IrExpr): IrExpr {
+ if (e.kind !== "libCall") return e;
+ const position = COMPLETION_CALLBACKS.get(e.fn);
+ if (position === undefined) return e;
+ if (position === "arguments") {
+ const index = e.fn === "fs.callbackCall" ? 1 : 0;
+ const vector = e.args[index];
+ if (vector === undefined || vector.kind !== "dynArrLit") return e;
+ const args = e.args.slice();
+ args[index] = {
+ kind: "dynArrLit",
+ elems: vector.elems.map(bindArgumentElement),
+ type: vector.type,
+ loc: vector.loc,
+ };
+ return { ...e, args };
+ }
+ const index = position === "last" ? e.args.length - 1 : position;
+ const cb = e.args[index];
+ if (cb === undefined || (position === "last" && cb.type.kind !== "func")) return e;
+ const args = e.args.slice();
+ args[index] = bindToActiveContext(cb, BIND_EMPTY_CONTEXT.has(e.fn));
+ return { ...e, args };
+}
+
+/** Binds the completion callbacks of `mod` when its scheduling carries
+ * AsyncLocalStorage contexts. */
+export function bindCompletionCallbacks(mod: IrModule): void {
+ if (!moduleRuntimeFeatures(mod).asyncContext) return;
+ const rewriteExpr = (e: IrExpr): IrExpr => bindCall(mapExprChildren(e, rewriteExpr, rewriteStmt));
+ const rewriteStmt = (s: IrStmt): IrStmt => mapStmtChildren(s, rewriteExpr, rewriteStmt);
+ for (const fn of mod.functions) fn.body = fn.body.map(rewriteStmt);
+}
diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
index 86f214bc1..edf65ab2b 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
@@ -2358,10 +2358,11 @@ function reduceHelper(
* the left element first. Undefined values sink without reaching the user
* comparator. The callback sequence differs from V8's TimSort, so exact
* order and count parity are not claimed; results agree for consistent
- * comparators. Without a comparator, string, number and boolean arrays use
- * native stable ordering: their String conversions cannot call user code.
- * Strings compare UTF-16 units; numbers compare their decimal spellings
- * ([10, 9, 1] → [1, 10, 9]). Other element types still need a comparator. */
+ * comparators. Without a comparator, string, number, boolean and Date
+ * arrays use native stable ordering: their String conversions cannot call
+ * user code. Strings compare UTF-16 units; numbers compare their decimal
+ * spellings ([10, 9, 1] → [1, 10, 9]); Dates compare their toString() text.
+ * Other element types still need a comparator. */
export function lowerArraySortCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -2374,7 +2375,7 @@ export function lowerArraySortCall(
const copyFirst = method === "toSorted";
if (
call.arguments.nodes.length === 0 &&
- (elem.kind === "string" || elem.kind === "f64" || elem.kind === "bool")
+ (elem.kind === "string" || elem.kind === "f64" || elem.kind === "bool" || elem.kind === "date")
) {
return {
kind: "arrIntrinsic",
@@ -2389,7 +2390,7 @@ export function lowerArraySortCall(
lowerer.noLowering(
`.${method} with ${call.arguments.nodes.length} arguments`,
call,
- "default ordering supports string[], number[] and boolean[]; other element types need a comparator",
+ "default ordering supports string[], number[], boolean[] and Date[]; other element types need a comparator",
);
}
const receiver = lowerer.lowerExpr(access.expression);
diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts
index 1f10640c0..1ca51f526 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts
@@ -1,4 +1,5 @@
import { keyedHelperName } from "../stable-names.js";
+import { indexedUnionArms, lowerIndexedUnionToArray } from "../indexed-unions.js";
import { generatorDrain } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { liftedFreshSpread } from "../coercions/fresh-spread.js";
@@ -1054,6 +1055,10 @@ export function lowerArrayFromCall(
loc,
};
}
+ // A union of typed arrays and number arrays (indexed-unions.ts).
+ const indexedArms = indexedUnionArms(lowerer, src.type);
+ if (indexedArms && args.length === 1)
+ return lowerIndexedUnionToArray(lowerer, src, indexedArms, loc, true);
if (src.type.kind === "set" && args.length === 1) {
return {
kind: "setIntrinsic",
diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts
index 3ab2e84e2..4ef203edd 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts
@@ -1081,14 +1081,25 @@ export function lowerArrayMethodCall(
(lowerer.unions
.get(elem.unionId)
?.arms.every(
- (a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a),
+ (a) =>
+ a.kind === "f64" ||
+ a.kind === "string" ||
+ a.kind === "bool" ||
+ a.kind === "date" ||
+ isUnitType(a),
) ??
false);
- if (elem.kind !== "f64" && elem.kind !== "string" && elem.kind !== "bool" && !joinableUnion) {
+ if (
+ elem.kind !== "f64" &&
+ elem.kind !== "string" &&
+ elem.kind !== "bool" &&
+ elem.kind !== "date" &&
+ !joinableUnion
+ ) {
lowerer.unsupported(
"SC1090",
call,
- "'.join()' on arrays of this element type (number, string, and boolean arrays join — unions of those with undefined/null arms too, the units printing empty like JS)",
+ "'.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS)",
);
}
const receiver = lowerer.lowerExpr(access.expression);
diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts
index 995cb177a..a8890c431 100644
--- a/packages/compiler/src/frontend/lowering/containers/bytes.ts
+++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts
@@ -1,7 +1,9 @@
import { keyedHelperName } from "../stable-names.js";
-import { boolLit, dynUndefinedExpr, numLit, varRef } from "../../../ir/build.js";
+import { allBytesArms, indexedUnionArms, lowerIndexedUnionMethod } from "../indexed-unions.js";
+import { boolLit, dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js";
import { BUF_NUM_METHODS } from "./buffer-numeric-methods.js";
import * as ts from "../../ts7/adapter.js";
+import { constituentTypes } from "../../ts7/checker.js";
import {
BIGINT_T,
BOOL,
@@ -15,6 +17,7 @@ import {
type IrLibFn,
type IrLocal,
type IrParam,
+ type IrFunction,
type IrStmt,
type IrType,
STRING,
@@ -30,7 +33,8 @@ import {
import { isJsSourceFile, locOf } from "../../program.js";
import { stdlibGlobalNameOf } from "../surfaces.js";
import type { Lowerer } from "../lowerer.js";
-import { newFnCtx, own } from "../lowerer.js";
+import { ENTRY_NAME, newFnCtx, own } from "../lowerer.js";
+import { everyStmt } from "../../../ir/traverse.js";
import { buildBytesSortFn, closureKeyOrder } from "../lower-array-sort.js";
import { isSafeToDiscard } from "../expressions/evaluation-safety.js";
import { lowerDynObjectLiteral } from "../expressions/object-literals.js";
@@ -40,7 +44,7 @@ import {
lowerStaticallyUndefinedArgument,
} from "../optional-arguments.js";
import { lowerArraySpreadItems, lowerCheckedArrayFrom } from "./array-construction.js";
-import { lowerToNumberArgument } from "../lower-exprs.js";
+import { lowerOptionalNumber, lowerToNumberArgument } from "../lower-exprs.js";
import { lowerBytesIteratorValue } from "../iterator-adapters.js";
import { lowerArrayCallback } from "./callback-arguments.js";
import { callbackSite, findPredicateOk, predicateToBoolean } from "./array-callbacks.js";
@@ -903,6 +907,126 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
return lowerArrayBufferView(lowerer, args, "dv", locOf(expr));
}
+/** `receiver.set(src, offset?)` on a lowered typed-array receiver. */
+function bytesSetIntrinsic(
+ lowerer: Lowerer,
+ receiver: IrExpr,
+ argNodes: readonly ts.Expression[],
+ loc: SrcLoc,
+): IrExpr {
+ const src = lowerer.lowerExpr(argNodes[0]!);
+ const method = src.type.kind === "bytes" ? "setFrom" : "setFromDyn";
+ const args = [src.type.kind === "bytes" ? src : lowerer.coerceInto(argNodes[0]!, src, DYN)];
+ if (argNodes.length === 2)
+ args.push(
+ lowerOptionalArgument(lowerer, argNodes[1]!, F64, {
+ kind: "numLit",
+ value: 0,
+ type: F64,
+ loc,
+ }),
+ );
+ return { kind: "bytesIntrinsic", method, receiver, args, type: VOID, loc };
+}
+
+/** `Uint8Array.prototype.set.call(target, src, offset?)`, through any typed
+ * array constructor's prototype (they share %TypedArray%.prototype.set). A
+ * target the checker types as a typed array or Buffer lowers like
+ * `target.set(src, offset)`. Any other target, a DataView included, is
+ * checked at run time: a typed array calls its `set`, and
+ * anything else throws Node's TypeError "this is not a typed array.",
+ * after every argument is evaluated. */
+export function lowerBytesPrototypeSetCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+): IrExpr | null {
+ if (
+ call.questionDotToken ||
+ access.questionDotToken ||
+ access.name.text !== "call" ||
+ !ts.isPropertyAccessExpression(access.expression)
+ )
+ return null;
+ const method = access.expression;
+ const proto = method.expression;
+ if (
+ method.questionDotToken ||
+ method.name.text !== "set" ||
+ !ts.isPropertyAccessExpression(proto) ||
+ proto.questionDotToken ||
+ proto.name.text !== "prototype" ||
+ !ts.isIdentifier(proto.expression) ||
+ !lowerer.isStdlibMember(method)
+ )
+ return null;
+ const ctor = lowerer.resolveValueSymbol(proto.expression);
+ if (!ctor || !lowerer.isStdlibSymbol(ctor) || !own(BYTES_CTORS, ts.symbolName(ctor))) return null;
+ const argNodes = call.arguments.nodes;
+ if (argNodes.length < 2 || argNodes.length > 3 || argNodes.some(ts.isSpreadElement)) return null;
+ const loc = locOf(call);
+ const target = lowerer.lowerExpr(argNodes[0]!);
+ // A DataView lowers to the same bytes type, so the checker type decides:
+ // only a typed array (or a Buffer) takes the method's path.
+ const targetSymbol = lowerer.typeOf(argNodes[0]!).getSymbol();
+ if (
+ target.type.kind === "bytes" &&
+ targetSymbol !== undefined &&
+ lowerer.isStdlibSymbol(targetSymbol) &&
+ (own(BYTES_CTORS, ts.symbolName(targetSymbol)) !== undefined ||
+ ts.symbolName(targetSymbol) === "Buffer")
+ )
+ return bytesSetIntrinsic(lowerer, target, argNodes.slice(1), loc);
+ const slot = (value: IrExpr): { decl: IrStmt; read: IrExpr } => {
+ const local = lowerer.declareHiddenLocal("%setArg", DYN);
+ return {
+ decl: { kind: "varDecl", localId: local.id, init: value, loc },
+ read: varRef(local.id, DYN, loc),
+ };
+ };
+ const slots = [
+ slot(lowerer.coerceInto(argNodes[0]!, target, DYN)),
+ ...argNodes.slice(1).map((arg) => slot(lowerer.lowerExprExpecting(arg, DYN))),
+ ];
+ const isTypedArray: IrExpr = {
+ kind: "libCall",
+ fn: "util.typeIs",
+ args: [slots[0]!.read, { kind: "strLit", value: "isTypedArray", type: STRING, loc }],
+ type: BOOL,
+ loc,
+ };
+ return {
+ kind: "seqExpr",
+ stmts: [
+ ...slots.map((s) => s.decl),
+ {
+ kind: "if",
+ cond: { kind: "unary", op: "!", operand: isTypedArray, type: BOOL, loc },
+ then: [
+ {
+ kind: "exprStmt",
+ expr: nodeThrowExpr(1, "", "this is not a typed array.", VOID, loc),
+ loc,
+ },
+ ],
+ else_: null,
+ loc,
+ },
+ ],
+ result: {
+ kind: "dynInvoke",
+ recv: slots[0]!.read,
+ method: "set",
+ calleeName: ts.getTextOfNode(method),
+ args: slots.slice(1).map((s) => s.read),
+ type: DYN,
+ loc,
+ },
+ type: DYN,
+ loc,
+ };
+}
+
/** Method calls on typed-array/Buffer receivers: slice (copy), subarray
* (shared view), toReversed, copyWithin, set(src,
* offset?), and the u8-only Buffer surface: toString(enc?) plus the
@@ -910,6 +1034,119 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
* variable-width read/writeUIntLE quartet — BUF_NUM_METHODS). Everything
* else the lib declares (fill, indexOf, reverse, ...) falls through to
* the SC2020 member fence. Null when this isn't a bytes method call. */
+/** The [needle, fromIndex] arguments of TypedArray indexOf, lastIndexOf
+ * and includes. A present fromIndex converts even when it is undefined
+ * (NaN, which ToIntegerOrInfinity makes 0); an absent one is 0, or
+ * +Infinity for lastIndexOf, which starts at the last element. */
+function elementSearchArgs(lowerer: Lowerer, call: ts.CallExpression, name: string): IrExpr[] {
+ const nodes = call.arguments.nodes;
+ const loc = locOf(call);
+ const needle = lowerer.lowerExprExpecting(nodes[0]!, F64);
+ const from: IrExpr = nodes[1]
+ ? lowerOptionalNumber(lowerer, lowerer.lowerExpr(nodes[1]), locOf(nodes[1]), nodes[1])
+ : ({
+ kind: "numLit",
+ value: name === "lastIndexOf" ? Infinity : 0,
+ type: F64,
+ loc,
+ } satisfies IrExpr);
+ // An undefined fromIndex is NaN once converted, which starts at 0.
+ if (from.kind === "unitLit") return [needle, { kind: "numLit", value: NaN, type: F64, loc }];
+ if (from.type.kind === "undefinedT" || from.type.kind === "void")
+ return [
+ needle,
+ {
+ kind: "seqExpr",
+ stmts: [{ kind: "exprStmt", expr: from, loc }],
+ result: { kind: "numLit", value: NaN, type: F64, loc },
+ type: F64,
+ loc,
+ },
+ ];
+ if (from.type.kind !== "f64") lowerer.noLowering(`.${name} with a non-number fromIndex`, call);
+ return [needle, from];
+}
+
+const ELEMENT_SEARCHES: Readonly> = {
+ indexOf: "indexOfElem",
+ lastIndexOf: "lastIndexOfElem",
+ includes: "includesElem",
+};
+
+/** Whether a union has a Buffer arm, under any alias name: each arm's own
+ * symbol names it. */
+function hasBufferArm(type: ts.Type): boolean {
+ const arms = type.flags & ts.TypeFlags.Union ? constituentTypes(type) : [type];
+ return arms.some((arm) => {
+ const symbol = arm.getSymbol();
+ return symbol !== undefined && ts.symbolName(symbol) === "Buffer";
+ });
+}
+
+/** The element-kind-generic typed-array methods on a union of typed
+ * arrays (indexed-unions.ts): the arguments lower as for one typed array,
+ * and each arm runs the method on its own receiver. Null for the methods
+ * that need a narrowing to one kind first. */
+function lowerUnionBytesMethodCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+ name: string,
+): IrExpr | null {
+ if (isJsSourceFile(ts.getSourceFileOfNode(call))) return null;
+ // A Buffer arm's methods differ from a typed array's (slice aliases, fill
+ // checks its range, searches take strings and wrap numbers), so such a
+ // union narrows to its arms first.
+ if (hasBufferArm(lowerer.typeOf(access.expression))) return null;
+ const nodes = call.arguments.nodes;
+ if (nodes.some(ts.isSpreadElement)) return null;
+ const loc = locOf(call);
+ const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
+ const optional = (i: number, fallback: IrExpr, type: IrType = F64): IrExpr =>
+ nodes[i] ? lowerOptionalArgument(lowerer, nodes[i]!, type, fallback) : fallback;
+ let method: IrBytesIntrinsicMethod;
+ let args: () => IrExpr[];
+ let result: "receiver" | IrType;
+ if ((name === "slice" || name === "subarray") && nodes.length <= 2) {
+ method = name;
+ args = () => [optional(0, num(0)), optional(1, num(Infinity))];
+ result = "receiver";
+ } else if (name === "fill" && nodes.length <= 3) {
+ method = "fillElem";
+ args = () => [optional(0, num(0)), optional(1, num(0)), optional(2, num(Infinity))];
+ result = "receiver";
+ } else if (name === "copyWithin" && nodes.length <= 3) {
+ method = "copyWithin";
+ args = () => [optional(0, num(0)), optional(1, num(0)), optional(2, num(Infinity))];
+ result = "receiver";
+ } else if (name === "toReversed" && nodes.length === 0) {
+ method = "toReversed";
+ args = () => [];
+ result = "receiver";
+ } else if (Object.hasOwn(ELEMENT_SEARCHES, name) && (nodes.length === 1 || nodes.length === 2)) {
+ method = ELEMENT_SEARCHES[name]!;
+ args = () => elementSearchArgs(lowerer, call, name);
+ result = name === "includes" ? BOOL : F64;
+ } else if (name === "join" && nodes.length <= 1) {
+ method = "join";
+ args = () => [optional(0, { kind: "strLit", value: ",", type: STRING, loc }, STRING)];
+ result = STRING;
+ } else if (name === "set" && (nodes.length === 1 || nodes.length === 2)) {
+ const source = lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!));
+ method = source?.kind === "bytes" ? "setFrom" : "setFromDyn";
+ args = () => {
+ const src = lowerer.lowerExpr(nodes[0]!);
+ const first = src.type.kind === "bytes" ? src : lowerer.coerceInto(nodes[0]!, src, DYN);
+ return nodes.length === 2 ? [first, optional(1, num(0))] : [first];
+ };
+ result = VOID;
+ } else return null;
+ const receiver = lowerer.lowerExpr(access.expression);
+ const arms = indexedUnionArms(lowerer, receiver.type);
+ if (!arms || !allBytesArms(arms)) return null;
+ return lowerIndexedUnionMethod(lowerer, receiver, arms, method, args(), result, loc);
+}
+
export function lowerBytesMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -918,6 +1155,9 @@ export function lowerBytesMethodCall(
if (lowerer.chainBlocked(access, call)) return null;
const name = access.name.text;
const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(access.expression));
+ const unionArms = receiverIr ? indexedUnionArms(lowerer, receiverIr) : null;
+ if (unionArms && allBytesArms(unionArms) && lowerer.isStdlibMember(access))
+ return lowerUnionBytesMethodCall(lowerer, call, access, name);
if (receiverIr?.kind !== "bytes") return null;
if (!lowerer.isStdlibMember(access)) return null;
const loc = locOf(call);
@@ -964,6 +1204,29 @@ export function lowerBytesMethodCall(
if (value) return value;
}
}
+ // TypedArray indexOf / lastIndexOf / includes (the Buffer-declared forms,
+ // which also take string and Buffer needles, lowered above).
+ if (
+ Object.hasOwn(ELEMENT_SEARCHES, name) &&
+ !lowerer.checker
+ .getSymbolAtLocation(access.name)
+ ?.declarations.some(
+ (d) => ts.isInterfaceDeclaration(d.parent) && d.parent.name.text === "Buffer",
+ )
+ ) {
+ if (nArgs < 1 || nArgs > 2 || call.arguments.nodes.some(ts.isSpreadElement)) {
+ lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call);
+ }
+ const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
+ return {
+ kind: "bytesIntrinsic",
+ method: ELEMENT_SEARCHES[name]!,
+ receiver,
+ args: elementSearchArgs(lowerer, call, name),
+ type: name === "includes" ? BOOL : F64,
+ loc,
+ };
+ }
if (name === "toReversed") {
if (nArgs !== 0) {
lowerer.noLowering(`.toReversed with ${nArgs} arguments on typed arrays`, call);
@@ -1106,21 +1369,7 @@ export function lowerBytesMethodCall(
lowerer.noLowering(`.set with ${nArgs} arguments on typed arrays`, call);
}
const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
- const src = lowerer.lowerExpr(call.arguments.nodes[0]!);
- const method = src.type.kind === "bytes" ? "setFrom" : "setFromDyn";
- const args = [
- src.type.kind === "bytes" ? src : lowerer.coerceInto(call.arguments.nodes[0]!, src, DYN),
- ];
- if (nArgs === 2)
- args.push(
- lowerOptionalArgument(lowerer, call.arguments.nodes[1]!, F64, {
- kind: "numLit",
- value: 0,
- type: F64,
- loc,
- }),
- );
- return { kind: "bytesIntrinsic", method, receiver, args, type: VOID, loc };
+ return bytesSetIntrinsic(lowerer, receiver, call.arguments.nodes, loc);
}
if (name === "toString") {
// Buffer's toString(encoding?) — utf8 by default. A 0-arg toString
@@ -1271,6 +1520,8 @@ export function lowerBytesMethodCall(
"the numeric families read/write Buffer bytes",
);
}
+ if (!numericArguments(lowerer, call))
+ return dynNumericCall(lowerer, call, access, name, receiverIr, loc);
const write = name.startsWith("write");
const required = write ? 1 : 0; // value; offset defaults to 0
if (nArgs < required || nArgs > required + 1) {
@@ -1448,6 +1699,39 @@ const BUF_BIGINT_METHODS: Record lowerer.mapTypeOf(lowerer.typeOf(a))?.kind === "f64");
+}
+
+/** A numeric read/write with an argument that is not a number (a string
+ * offset from untyped JavaScript): the checked-dynamic Buffer method runs
+ * Node's argument validation at runtime. */
+function dynNumericCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+ name: string,
+ receiverIr: IrType,
+ loc: SrcLoc,
+): IrExpr {
+ const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
+ return {
+ kind: "dynCheck",
+ value: {
+ kind: "dynInvoke",
+ recv: { kind: "dynFrom", value: receiver, type: DYN, loc, liveRef: true },
+ method: name,
+ calleeName: ts.getTextOfNode(access),
+ args: call.arguments.nodes.map((a) => lowerer.lowerExprExpecting(a, DYN)),
+ type: DYN,
+ loc,
+ },
+ type: F64,
+ loc,
+ };
+}
+
/** The variable-width quartet (offset + byteLength) → sign/endian token. */
const BUF_NUM_VAR_METHODS: Record = {
readUIntBE: "ube",
@@ -1807,7 +2091,12 @@ function knownBufferProducer(
* or empty encoding and throws ERR_UNKNOWN_ENCODING for an unknown name; an
* ArrayBuffer takes the second argument as its byte offset; other sources
* ignore it. */
-function dynBufferFromParts(source: IrExpr, encoding: IrExpr, loc: SrcLoc): IrExpr {
+function dynBufferFromParts(
+ source: IrExpr,
+ encoding: IrExpr,
+ loc: SrcLoc,
+ length?: IrExpr,
+): IrExpr {
const utf8: IrExpr = { kind: "strLit", value: "utf8", type: STRING, loc };
const isString = (x: IrExpr): IrExpr => ({
kind: "dynTest",
@@ -1848,20 +2137,48 @@ function dynBufferFromParts(source: IrExpr, encoding: IrExpr, loc: SrcLoc): IrEx
type: BYTES_U8,
loc,
},
- // An ArrayBuffer takes the second argument as its byte offset
- // (scr_buffer_from_dyn_offset_pooled); other values convert with utf8.
- else_: {
- kind: "libCall",
- fn: "buffer.poolFromDynOffset",
- args: [source, encoding],
- type: BYTES_U8,
- loc,
- },
+ // An ArrayBuffer takes the second argument as its byte offset and the
+ // third as its length (scr_buffer_from_dyn_range_pooled); other values
+ // convert with utf8.
+ else_: length
+ ? {
+ kind: "libCall",
+ fn: "buffer.poolFromDynRange",
+ args: [source, encoding, length],
+ type: BYTES_U8,
+ loc,
+ }
+ : {
+ kind: "libCall",
+ fn: "buffer.poolFromDynOffset",
+ args: [source, encoding],
+ type: BYTES_U8,
+ loc,
+ },
type: BYTES_U8,
loc,
};
}
+/** Buffer.from(arrayBuffer, byteOffset[, length]): each argument in order
+ * as a checked-dynamic value, read by the runtime as Node's
+ * fromArrayBuffer reads them. */
+function bufferFromArrayBufferRange(
+ lowerer: Lowerer,
+ args: readonly ts.Expression[],
+ loc: SrcLoc,
+ first?: IrExpr,
+): IrExpr {
+ const operands = [0, 1, 2].map((i) =>
+ i === 0 && first
+ ? first
+ : args[i]
+ ? lowerer.lowerExprExpecting(args[i]!, DYN)
+ : dynUndefinedExpr(loc),
+ );
+ return { kind: "libCall", fn: "buffer.poolFromDynRange", args: operands, type: BYTES_U8, loc };
+}
+
/** Lowers `source` and `encNode` (or undefined) into hidden dyn locals, in
* source order, and builds the result from their references. */
function withDynBufferArguments(
@@ -1909,52 +2226,81 @@ function dynBufferFromRuntimeEncoding(
);
}
-/** The deprecated `new Buffer(arg[, encoding])` in a dependency's
- * JavaScript, through the global or an alias of it (buffer-crc32 and yazl
- * keep it as the fallback for runtimes without Buffer.alloc/Buffer.from).
- * Node never emits DEP0005 for calls from node_modules, so the construction
- * is exactly Node's: a number allocates zero-filled bytes (and throws
- * ERR_INVALID_ARG_TYPE beside a string second argument), anything else
- * converts as Buffer.from does. Null elsewhere: other callers owe the
- * deprecation warning. */
-export function lowerNodeModulesBufferConstruct(
+/** The deprecated `Buffer(arg[, encodingOrOffset[, length]])`, called or
+ * constructed through the global or an alias of it, as Node's Buffer
+ * function runs it: the arguments evaluate in order, DEP0005 is reported
+ * on the next tick (once, and never for a call made from node_modules,
+ * which only counts toward Node's 10000-call check), then a number
+ * allocates zero-filled bytes (and throws ERR_INVALID_ARG_TYPE beside a
+ * string second argument) and anything else converts as Buffer.from does.
+ * Null for spread arguments and more than three. */
+export function lowerBufferConstruct(
lowerer: Lowerer,
- expr: ts.NewExpression,
+ expr: ts.NewExpression | ts.CallExpression,
): IrExpr | null {
- const sf = expr.getSourceFile();
- if (!isJsSourceFile(sf) || !/[\\/]node_modules[\\/]/.test(sf.fileName)) return null;
+ if (ts.isCallExpression(expr) && expr.questionDotToken) return null;
const args = expr.arguments?.nodes ?? [];
- if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) return null;
+ if (args.length > 3 || args.some(ts.isSpreadElement)) return null;
if (stdlibGlobalNameOf(lowerer, expr.expression) !== "Buffer") return null;
const loc = locOf(expr);
- const value = withDynBufferArguments(
- lowerer,
- lowerer.lowerExpr(args[0]!),
- args[1],
- loc,
- (source, encoding) => ({
+ const locals = [0, 1, 2].slice(0, Math.max(args.length, 2)).map((i) => {
+ const local = lowerer.declareHiddenLocal("%bufferArgument", DYN);
+ const node = args[i];
+ const init = node === undefined ? dynUndefinedExpr(loc) : lowerer.lowerExprExpecting(node, DYN);
+ return { local, init };
+ });
+ const [source, second, third] = locals.map(({ local }) => varRef(local.id, DYN, loc)) as [
+ IrExpr,
+ IrExpr,
+ IrExpr | undefined,
+ ];
+ const inNodeModules = /[\\/]node_modules[\\/]/.test(expr.getSourceFile().fileName);
+ const value: IrExpr = {
+ kind: "ternary",
+ cond: { kind: "dynTest", test: "number", value: source, type: BOOL, loc },
+ then: {
kind: "ternary",
- cond: { kind: "dynTest", test: "number", value: source, type: BOOL, loc },
- then: {
- kind: "ternary",
- cond: { kind: "dynTest", test: "string", value: encoding, type: BOOL, loc },
- then: { kind: "libCall", fn: "buffer.newStringFail", args: [source], type: BYTES_U8, loc },
- else_: {
- kind: "libCall",
- fn: "buffer.alloc",
- args: [{ kind: "dynCheck", value: source, type: F64, loc }],
- type: BYTES_U8,
- loc,
- },
+ cond: { kind: "dynTest", test: "string", value: second, type: BOOL, loc },
+ then: { kind: "libCall", fn: "buffer.newStringFail", args: [source], type: BYTES_U8, loc },
+ else_: {
+ kind: "libCall",
+ fn: "buffer.alloc",
+ args: [{ kind: "dynCheck", value: source, type: F64, loc }],
type: BYTES_U8,
loc,
},
- else_: dynBufferFromParts(source, encoding, loc),
type: BYTES_U8,
loc,
- }),
- );
- return { kind: "libCall", fn: "buffer.brand", args: [value], type: BYTES_U8, loc };
+ },
+ else_: dynBufferFromParts(source, second, loc, third),
+ type: BYTES_U8,
+ loc,
+ };
+ return {
+ kind: "seqExpr",
+ stmts: [
+ ...locals.map(({ local, init }): IrStmt => ({
+ kind: "varDecl",
+ localId: local.id,
+ init,
+ loc,
+ })),
+ {
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: inNodeModules ? "buffer.deprecationCount" : "buffer.deprecation",
+ args: [],
+ type: VOID,
+ loc,
+ },
+ loc,
+ },
+ ],
+ result: { kind: "libCall", fn: "buffer.brand", args: [value], type: BYTES_U8, loc },
+ type: BYTES_U8,
+ loc,
+ };
}
/** Buffer.from(array or bytes, extra): Node copies an array-like source and
@@ -2018,7 +2364,16 @@ function lowerBufferStaticValue(
lowerer.typeOf(args[0]!).getSymbol(),
)
) {
- return lowerArrayBufferView(lowerer, args, "u8", loc);
+ // A byte offset or length follows Node's fromArrayBuffer (its
+ // conversions and ERR_BUFFER_OUT_OF_BOUNDS), not the typed-array view.
+ if (args.length === 1) return lowerArrayBufferView(lowerer, args, "u8", loc);
+ return bufferFromArrayBufferRange(lowerer, args, loc);
+ }
+ // Buffer.from(value, byteOffset, length) over an untyped value: an
+ // ArrayBuffer's range, or the two-argument conversion of other values.
+ if (args.length === 3 && !args.some(ts.isSpreadElement)) {
+ const first = lowerer.lowerExpr(args[0]!);
+ if (first.type.kind === "dyn") return bufferFromArrayBufferRange(lowerer, args, loc, first);
}
// Buffer.from(x.buffer[, byteOffset[, length]]): a u8 VIEW sharing
// x's storage (Node shares the ArrayBuffer; length is in BYTES).
@@ -2445,11 +2800,101 @@ function lowerBufferStaticValue(
const s = lowerer.lowerExprExpecting(args[0]!, STRING);
return { kind: "libCall", fn: "buffer.isEncoding", args: [s], type: BOOL, loc };
}
+ // Any other single argument: Node answers false for non-strings.
+ if (args.length === 1 && !ts.isSpreadElement(args[0]!)) {
+ const value = lowerer.declareHiddenLocal("%bufferEncoding", DYN);
+ const ref = varRef(value.id, DYN, loc);
+ return {
+ kind: "seqExpr",
+ stmts: [
+ {
+ kind: "varDecl",
+ localId: value.id,
+ init: lowerer.lowerExprExpecting(args[0]!, DYN),
+ loc,
+ },
+ ],
+ result: {
+ kind: "logical",
+ op: "&&",
+ left: { kind: "dynTest", test: "string", value: ref, type: BOOL, loc },
+ right: {
+ kind: "libCall",
+ fn: "buffer.isEncoding",
+ args: [{ kind: "dynCheck", value: ref, type: STRING, loc }],
+ type: BOOL,
+ loc,
+ },
+ type: BOOL,
+ loc,
+ },
+ type: BOOL,
+ loc,
+ };
+ }
lowerer.noLowering(
"Buffer.isEncoding with this argument shape",
call,
- "one string-typed argument is the lowered form",
+ "one argument is the lowered form",
);
}
return null; // of, copyBytesFrom, ... → the SC2020 member fence
}
+
+/** `Buffer.prototype.