mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Merge base branch and mark p12 as a race
This commit is contained in:
+36
-2
@@ -6,6 +6,42 @@ All notable changes to scriptc will be documented in this file.
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Fixes:**
|
||||
- Release builds read a typed array's length without a header load that could read a freed view.
|
||||
- `net.Socket` `write()` returns Node's backpressure boolean and fires `'drain'`.
|
||||
- Super calls in JavaScript method values call the declared base method.
|
||||
- Calling a non-function through an asserted callee throws Node's TypeError.
|
||||
- **Faster programs:**
|
||||
- Exceptions propagate by unwinding from directly called functions on Linux, instead of status checks after every call.
|
||||
- Typed-array loops prove their bounds and keep their views disjoint in more cases, including views over shared memory in worker programs; integer loops stay in integers across nested counted loops, pure helper calls, powers of two and byte reads.
|
||||
- Lone array callbacks run as loops with their bodies inlined, and row sums run two rows per pass.
|
||||
- Number-key maps look keys up on their integer view and write updates through the entry the read found; small number-key maps use a direct index.
|
||||
- Strings: base64 decodes eight characters at a time; Buffers fill, swap byte order and decode `utf16le` a word at a time; readline splits input in one scan; string scans over non-ASCII strings use their UTF-16 units.
|
||||
- Untyped code: property caches also cache absent properties, object literal members go through the store caches, calls on plain arrays skip the method ladder, and dynamic values dispose without key-cache walks.
|
||||
- JSON parses into discriminated unions, and dynamic values convert into nested unions, in one pass.
|
||||
- BigInt division settles quotients from the top limbs, and limb shifts are vectorized.
|
||||
- **Faster builds:**
|
||||
- Function bodies are emitted on up to eight workers, including in dev builds, and repeated work in LLVM module emission is cut.
|
||||
- Linux executables link with lld when the driver finds it, and unchanged executables are reused without selecting the runtime pack.
|
||||
- Dev builds reuse their frontend output after edits that keep token positions, and unchanged frontend inputs are checked by file identity before hashing.
|
||||
- Lowering fetches only the syntax trees it reads, and the large compiler tables are built on first use.
|
||||
- Release builds of the native compiler use a profile collected on the release commit.
|
||||
- Type checking can use a tsc-ts server shared by later builds (opt-in).
|
||||
- **Node.js parity:**
|
||||
- Crypto: RSA key generation lengths, RSA-PSS keys, encrypted private keys, `X509Certificate` fields and methods, and TLS certificates and `tls.Server` contexts.
|
||||
- zlib: Brotli compression and decompression.
|
||||
- Dates are mutable objects with Node's setters and conversions; URL hosts and paths parse as the URL Standard does, including non-ASCII hosts with UTS #46.
|
||||
- `Math`, `JSON` and `Reflect` are real objects in JavaScript; objects can inherit from JavaScript class prototypes; typed arrays held as untyped values support named properties and `Symbol.species`.
|
||||
- Error messages: `JSON.parse` syntax errors use V8's messages and positions; `Object.getPrototypeOf` of an Error answers `Error.prototype`.
|
||||
- `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node.
|
||||
- **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses.
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Highlights
|
||||
@@ -29,8 +65,6 @@ All notable changes to scriptc will be documented in this file.
|
||||
- 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.
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -32,6 +32,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
|
||||
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
|
||||
| `regex-replacers` | cpu | regex `replace`/`replaceAll` with inline callbacks whose parameters have no annotations: dictionary placeholders, snake_case to camelCase, date reordering, optional captures, capitalization, and a string search that reads the offset |
|
||||
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
|
||||
| `string-iteration` | cpu | code point iteration over text that mixes ASCII, Latin-1 letters, CJK and emoji: a for-of word tokenizer, spread reversal, and `Array.from` over words |
|
||||
| `template-render` | cpu | template literals, escaping, `map`/`join` |
|
||||
| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative |
|
||||
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"entry": "workloads/string-iteration.ts",
|
||||
"args": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Code point iteration over mixed text: a word tokenizer over for-of, a
|
||||
// character histogram, reversal through spread, and Array.from over
|
||||
// words. The text mixes ASCII, Latin-1 letters, CJK and emoji.
|
||||
let seed = 11;
|
||||
function random(): number {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
||||
return seed / 4294967296;
|
||||
}
|
||||
const pieces = [
|
||||
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog",
|
||||
"café", "naïve", "über", "señor", "straße", "déjà", "vu",
|
||||
"日本", "語", "テキスト", "中文", "한국어",
|
||||
"😀", "🚀", "👍", "🎉",
|
||||
];
|
||||
const punctuation = [" ", " ", " ", ", ", ". ", "! ", "\n"];
|
||||
const scale = Number(process.argv[2] ?? "1");
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < Math.floor(4000 * scale); i++) {
|
||||
let line = "";
|
||||
const words = 6 + Math.floor(random() * 10);
|
||||
for (let w = 0; w < words; w++) {
|
||||
line += pieces[Math.floor(random() * pieces.length)]!;
|
||||
line += punctuation[Math.floor(random() * punctuation.length)]!;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
function isWordChar(c: string): boolean {
|
||||
const code = c.codePointAt(0)!;
|
||||
return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) || code >= 0xc0;
|
||||
}
|
||||
|
||||
let tokens = 0;
|
||||
let tokenUnits = 0;
|
||||
let reversedLength = 0;
|
||||
let fromLength = 0;
|
||||
const histogram = new Map<string, number>();
|
||||
for (let round = 0; round < 30; round++) {
|
||||
for (const line of lines) {
|
||||
let word = "";
|
||||
for (const c of line) {
|
||||
if (isWordChar(c)) {
|
||||
word += c;
|
||||
continue;
|
||||
}
|
||||
if (word !== "") {
|
||||
tokens++;
|
||||
tokenUnits += word.length;
|
||||
if (round === 0) histogram.set(word, (histogram.get(word) ?? 0) + 1);
|
||||
fromLength += Array.from(word).length;
|
||||
word = "";
|
||||
}
|
||||
}
|
||||
reversedLength += [...line].reverse().join("").length;
|
||||
}
|
||||
}
|
||||
const top = [...histogram].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 5);
|
||||
console.log(top.map(([word, count]) => `${word}:${count}`).join(" "));
|
||||
console.log("tokens", tokens, "units", tokenUnits, "reversed", reversedLength, "from", fromLength);
|
||||
@@ -141,6 +141,39 @@ CommonJS packages compile with Node.js's module semantics. Each `require()` eval
|
||||
|
||||
A JavaScript package can contain operations scriptc can't compile, often in functions your program never calls. These don't fail the build: each one compiles to code that throws a runtime error if it ever runs. Coverage reports list them as deferred sites, so review the list and test the parts of the package your program uses. Sites in a module's top-level code are listed under `runs at startup`: that code runs when the module loads, at startup for a module the program imports.
|
||||
|
||||
## Fix a package's types
|
||||
|
||||
scriptc trusts declared types: a package's `.d.ts` files, its `@types/` package, and the JSDoc types in its JavaScript. A value that contradicts its declared type throws a `TypeError` instead of flowing on, so a package whose declarations are wrong can stop a program that runs under Node.js. A common case is a member declared `string` or `number` that is sometimes `undefined`.
|
||||
|
||||
Fix such a declaration in your project with a type patch in `package.json`:
|
||||
|
||||
```json:package.json
|
||||
{
|
||||
"scriptc": {
|
||||
"typePatches": {
|
||||
"commander@^15.0.0": {
|
||||
"declarations": {
|
||||
"HelpContext.helpWidth": "| undefined"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each key names a package and a semver range of the installed versions the patch describes. `declarations` maps a declaration to a type:
|
||||
|
||||
<dl>
|
||||
<dt>`"| undefined"`</dt>
|
||||
<dd>A type that starts with `|` widens the declared type, here to `number | undefined`.</dd>
|
||||
<dt>`"string | null"`</dt>
|
||||
<dd>Any other type replaces the declared type.</dd>
|
||||
</dl>
|
||||
|
||||
A declaration is named by its path: `Options.name` for a member of a type, `parse()` for a function's return type, `parse(text)` for a parameter, `list()[]` for an array's elements, and `Env[string]` for an index signature. `export=()` names the return type of the function the module exports, `default` its default export, and a file prefix such as `index.js:parse()` names a declaration inside one of the package's files. One patch applies to the package's own declarations, its JSDoc, and its `@types/` package. The patched type then holds everywhere, so your code has to handle the `undefined` that a widened declaration allows. An optional `"note"` string can link the upstream issue.
|
||||
|
||||
Patches change only declarations, never the package's code, and values are still checked against the patched types. A build warns when a patch doesn't apply as written: when no range covers the installed version, when a declaration path matches nothing, when the declaration already has the type, or when a path is ambiguous. Report wrong declarations to the package too, so the patch can be removed later.
|
||||
|
||||
## Provenance sources (experimental)
|
||||
|
||||
`--provenance-sources` compiles a package from the source at the commit its npm provenance attestation names, instead of from its published JavaScript. This lets packages written in TypeScript compile from their original TypeScript. Packages without a usable attestation compile from their installed files as usual.
|
||||
|
||||
@@ -31,22 +31,25 @@
|
||||
`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, Ed25519, and X25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
|
||||
RSA, RSA-PSS, 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, and `paramEncoding: 'explicit'`, throw a coded SC2020 error.
|
||||
RSA keys generate with modulus lengths from 512 to 8192 bits, an odd length from 2048 bits yielding one bit less as in Node, and odd public exponents up to 4294967295; longer moduli 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 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.
|
||||
RSA-PSS keys follow their RSASSA-PSS-params as Node does: PSS padding only, the key's digest and MGF1 digest, and salts of at least the key's length, with OpenSSL's errors; their details name the parameters, they export as PKCS#8 and SubjectPublicKeyInfo only, and digests other than SHA-1 and SHA-2 in their options throw a coded SC2020 error.
|
||||
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.
|
||||
X509Certificate supports `publicKey`, `verify()`, `checkIssued()`, `checkPrivateKey()`, `checkHost()`, `checkEmail()`, `checkIP()`, `toLegacyObject()`, `toString()`, and `toJSON()`, and `String()`, template literals, and `+` give its PEM text as `toString()` does.
|
||||
`publicKey`, `verify()`, and `checkPrivateKey()` are refused in wasm32-wasi builds, and an Ed25519 certificate's `verify()` needs the `ed25519` literal like the other Ed25519 operations.
|
||||
Own-key walks over a certificate (`Object.keys()`, `Object.entries()`, spread, `for...in`, and `hasOwnProperty`) are refused, since Node keeps its fields as prototype getters.
|
||||
`JSON.stringify()` and `console.log()` of a certificate, a certificate stored in an `unknown` or `any` slot, and its methods on a union-typed value are also refused.
|
||||
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`, `x448`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused.
|
||||
Private keys export encrypted (`cipher`, `passphrase`) with aes-128, aes-192, and aes-256 in CBC and ECB modes and with des-ede3-cbc, PKCS#8 as PBES2 with PBKDF2-HMAC-SHA256 and PKCS#1 and SEC1 as traditional PEM, and the ciphers OpenSSL refuses for a form throw its errors; a cipher option can also be an OID; other ciphers Node encrypts with throw a coded SC2020 error.
|
||||
Encrypted private keys import with a `passphrase` from PBES2 with HMAC-SHA PRFs and the CBC and ECB ciphers, and from traditional PEM with the CBC ciphers, with Node's errors for a missing or wrong passphrase; other schemes and ciphers throw a coded SC2020 error once a passphrase is given.
|
||||
The other key types (`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-darwin-arm64",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for darwin-arm64",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-darwin-x64",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for darwin-x64",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-linux-arm64-gnu",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for linux-arm64-gnu",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-linux-arm64-musl",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for linux-arm64-musl",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-linux-x64-gnu",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for linux-x64-gnu",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-linux-x64-musl",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for linux-x64-musl",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/cli-win32-x64-msvc",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Native scriptc compiler for win32-x64-msvc",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "scriptc",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"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",
|
||||
|
||||
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
|
||||
minimum_os: "14.0",
|
||||
},
|
||||
program: { object, entry_symbol: "main" },
|
||||
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
|
||||
runtime_abi: { version: 45, marker: "scr_runtime_abi_v45" },
|
||||
runtime_pack: {
|
||||
kind: "precompiled",
|
||||
package: "@scriptc/runtime-darwin-arm64",
|
||||
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
|
||||
() => null,
|
||||
(failure: { stderr?: string }) => failure,
|
||||
);
|
||||
expect(error?.stderr).toContain("scr_runtime_abi_v44");
|
||||
expect(error?.stderr).toContain("scr_runtime_abi_v45");
|
||||
});
|
||||
});
|
||||
|
||||
+41
-9
@@ -2914,6 +2914,8 @@ declare module "crypto" {
|
||||
export interface AsymmetricKeyExportOptions<T extends KeyFormat> {
|
||||
type: "pkcs1" | "spki" | "pkcs8" | "sec1";
|
||||
format: T;
|
||||
cipher?: string | undefined;
|
||||
passphrase?: string | Buffer | undefined;
|
||||
}
|
||||
export interface AsymmetricKeyDetails {
|
||||
modulusLength?: number;
|
||||
@@ -2938,6 +2940,7 @@ declare module "crypto" {
|
||||
format?: "pem" | "der" | undefined;
|
||||
type?: "pkcs1" | "pkcs8" | "sec1" | undefined;
|
||||
encoding?: string | undefined;
|
||||
passphrase?: string | Buffer | undefined;
|
||||
}
|
||||
export interface PublicKeyInput {
|
||||
key: string | Buffer;
|
||||
@@ -2974,12 +2977,22 @@ declare module "crypto" {
|
||||
modulusLength: number;
|
||||
publicExponent?: number | undefined;
|
||||
publicKeyEncoding: { type: "pkcs1" | "spki"; format: PubF };
|
||||
privateKeyEncoding: { type: "pkcs1" | "pkcs8"; format: PrivF };
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs1" | "pkcs8";
|
||||
format: PrivF;
|
||||
cipher?: string | undefined;
|
||||
passphrase?: string | undefined;
|
||||
};
|
||||
}
|
||||
export interface ECKeyPairOptions<PubF extends KeyFormat, PrivF extends KeyFormat> {
|
||||
namedCurve: string;
|
||||
publicKeyEncoding: { type: "pkcs1" | "spki"; format: PubF };
|
||||
privateKeyEncoding: { type: "sec1" | "pkcs8"; format: PrivF };
|
||||
privateKeyEncoding: {
|
||||
type: "sec1" | "pkcs8";
|
||||
format: PrivF;
|
||||
cipher?: string | undefined;
|
||||
passphrase?: string | undefined;
|
||||
};
|
||||
}
|
||||
export function generateKeyPairSync(
|
||||
type: "rsa",
|
||||
@@ -3007,7 +3020,12 @@ declare module "crypto" {
|
||||
): KeyPairKeyObjectResult;
|
||||
export interface ED25519KeyPairOptions<PubF extends KeyFormat, PrivF extends KeyFormat> {
|
||||
publicKeyEncoding: { type: "spki"; format: PubF };
|
||||
privateKeyEncoding: { type: "pkcs8"; format: PrivF };
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8";
|
||||
format: PrivF;
|
||||
cipher?: string | undefined;
|
||||
passphrase?: string | undefined;
|
||||
};
|
||||
}
|
||||
export function generateKeyPairSync(
|
||||
type: "ed25519",
|
||||
@@ -3020,7 +3038,12 @@ declare module "crypto" {
|
||||
export function generateKeyPairSync(type: "ed25519", options?: {}): KeyPairKeyObjectResult;
|
||||
export interface X25519KeyPairOptions<PubF extends KeyFormat, PrivF extends KeyFormat> {
|
||||
publicKeyEncoding: { type: "spki"; format: PubF };
|
||||
privateKeyEncoding: { type: "pkcs8"; format: PrivF };
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8";
|
||||
format: PrivF;
|
||||
cipher?: string | undefined;
|
||||
passphrase?: string | undefined;
|
||||
};
|
||||
}
|
||||
export function generateKeyPairSync(
|
||||
type: "x25519",
|
||||
@@ -5322,10 +5345,10 @@ declare module "node:zlib" {
|
||||
* with the real port discoverable as address().port (the composed read
|
||||
* is the lowered form — bare address() has no lowering); close([cb])
|
||||
* firing once connections drain; socket write/end/destroy/pipe and the
|
||||
* data/end/close/error/connect events (on and once — listeners take at
|
||||
* most the declared parameter). write/end return void here (Node returns
|
||||
* the backpressure boolean / the socket); on/once return void (chaining
|
||||
* is fenced). connect's host is a numeric IP or "localhost" (pinned to
|
||||
* data/end/close/error/connect/drain events (on and once — listeners
|
||||
* take at most the declared parameter). write returns Node's
|
||||
* backpressure boolean; end returns void here (Node returns the socket);
|
||||
* on/once return void (chaining is fenced). connect's host is a numeric IP or "localhost" (pinned to
|
||||
* 127.0.0.1 — SEMANTICS.md); every failure is the async 'error' event,
|
||||
* and an 'error' with no listener exits 1 like an unhandled EventEmitter
|
||||
* 'error'. */
|
||||
@@ -5357,7 +5380,13 @@ declare module "net" {
|
||||
readonly bytesWritten: number;
|
||||
/* write's callback runs once the data is flushed, with null; end's
|
||||
* once the socket finishes. */
|
||||
write(data: string | Uint8Array, callback?: (err?: Error | null) => void): void;
|
||||
/* false when the bytes this write left unsent reach the 65536-byte
|
||||
* high-water mark, as in Node; 'drain' fires when they are all sent. */
|
||||
write(data: string | Uint8Array, callback?: (err?: Error | null) => void): boolean;
|
||||
/* the bytes written and not yet sent */
|
||||
readonly writableLength: number;
|
||||
/* a write answered false and 'drain' has not fired yet */
|
||||
readonly writableNeedDrain: boolean;
|
||||
end(callback?: () => void): void;
|
||||
end(data: string | Uint8Array, callback?: () => void): void;
|
||||
destroy(): void;
|
||||
@@ -5405,6 +5434,7 @@ declare module "net" {
|
||||
| "connect"
|
||||
| "ready"
|
||||
| "timeout"
|
||||
| "drain"
|
||||
| "readable"
|
||||
| "finish"
|
||||
| "secureConnect"
|
||||
@@ -5429,6 +5459,7 @@ declare module "net" {
|
||||
| "connect"
|
||||
| "ready"
|
||||
| "timeout"
|
||||
| "drain"
|
||||
| "readable"
|
||||
| "finish"
|
||||
| "secureConnect"
|
||||
@@ -5452,6 +5483,7 @@ declare module "net" {
|
||||
| "connect"
|
||||
| "ready"
|
||||
| "timeout"
|
||||
| "drain"
|
||||
| "readable"
|
||||
| "finish"
|
||||
| "secureConnect"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/compiler",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -9,15 +9,25 @@ import type { LlvmEmitterContext } from "./expr-context.js";
|
||||
* receiver slots loop-invariant. Untagged accesses and calls still alias
|
||||
* 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. */
|
||||
* Header loads that only bound or address an access to the view are also
|
||||
* `!invariant.load`: no store or call between the construction of a view
|
||||
* and its release changes them, and their values reach only that access,
|
||||
* which runs while the view is alive. 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 {
|
||||
const tag = host.memoryAliasAttachment("bytes:header");
|
||||
return tag === "" ? "" : `${tag}, !invariant.load !{}`;
|
||||
}
|
||||
|
||||
/** A header load whose value the program reads (`length`, `byteLength`)
|
||||
* keeps the alias tag only. The value can outlive the view, and LLVM may
|
||||
* move an invariant load to its use, past the release that frees the
|
||||
* view. */
|
||||
export function bytesHeaderValueAlias(host: LlvmEmitterContext): string {
|
||||
return host.memoryAliasAttachment("bytes:header");
|
||||
}
|
||||
|
||||
/** Element storage's tag, and the alias scope of the access being emitted
|
||||
* in a loop version's fast copy (bytesAccessScope). */
|
||||
export function bytesElementAlias(host: LlvmEmitterContext): string {
|
||||
|
||||
@@ -453,7 +453,11 @@ export function emitDenseArrayRead(
|
||||
B.br(join);
|
||||
B.startBlock(slow);
|
||||
const slowValue = B.tmp();
|
||||
const attrs = acc === "ref" && retain ? " cold" : READ_FALLBACK;
|
||||
// ToNumber of a foreign element (SCR_ARR_FOREIGN) can run user code.
|
||||
const attrs =
|
||||
(acc === "ref" && retain) || (getter === "scr_arr_get_number" && host.mayHoldForeign(element))
|
||||
? " cold"
|
||||
: READ_FALLBACK;
|
||||
B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`);
|
||||
let slowInt: string | null = null;
|
||||
if (fastInt !== null) {
|
||||
@@ -610,6 +614,28 @@ export function emitDenseArraySet(
|
||||
B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
|
||||
}
|
||||
};
|
||||
if ((acc === "f64" || kind === "ref") && host.mayHoldForeign(element)) {
|
||||
// Past the hole-free prefix a slot may hold a foreign element, whose
|
||||
// value the runtime setter releases; below it every slot is a value.
|
||||
B.br(slow);
|
||||
B.startBlock(filledLabel);
|
||||
const filledValuePtr = valuePointer(host, guard, kind);
|
||||
let filledOld: string | null = null;
|
||||
if (kind === "ref") {
|
||||
filledOld = B.tmp();
|
||||
B.line(
|
||||
`${filledOld} = load ptr, ptr ${filledValuePtr}${host.fieldAliasAttachment(filledValuePtr)}`,
|
||||
);
|
||||
}
|
||||
storeValue(filledValuePtr);
|
||||
if (filledOld) host.releaseHeap(filledOld, element);
|
||||
B.br(join);
|
||||
B.startBlock(slow);
|
||||
call(" cold");
|
||||
B.br(join);
|
||||
B.startBlock(join);
|
||||
return;
|
||||
}
|
||||
const stated = B.newLabel("arr.set.states"),
|
||||
plain = B.newLabel("arr.set.plain"),
|
||||
plainStore = B.newLabel("arr.set.values"),
|
||||
|
||||
@@ -1980,6 +1980,87 @@ export class LlDyn {
|
||||
/** `sc_dc_<n>(ptr d, ptr path) -> T` — validate the checked-dynamic tree against T and
|
||||
* BUILD the typed value (+1), or throw the catchable path-annotated
|
||||
* TypeError and return a dummy with the pending flag set. */
|
||||
/** The array a live view's snapshot commits back into, for element types
|
||||
* whose arrays can hold foreign elements (LlEmitter.mayHoldForeign):
|
||||
* like dynCheckHelper's array case on a checked array `%d`, except that
|
||||
* an element the element type cannot hold is stored as a foreign element
|
||||
* (undefined as a present undefined) instead of failing the conversion.
|
||||
* Boolean arrays, whose slots cannot hold one, keep the failing check. */
|
||||
dynViewArrayHelper(t: IrType & { kind: "array" }): string {
|
||||
const builderKey = `view:${typeKey(t)}`;
|
||||
const existing = this.dynBuilders.get(builderKey);
|
||||
if (existing) return existing;
|
||||
const name = `sc_dva_${this.dynBuilders.size}`;
|
||||
this.dynBuilders.set(builderKey, name);
|
||||
const host = this.host;
|
||||
const elem = t.elem;
|
||||
const check = this.dynCheckHelper(elem);
|
||||
const match = this.dynMatchHelper(elem);
|
||||
const B = new BlockBuilder();
|
||||
const n = this.lenOf(B, "%d");
|
||||
const a = B.tmp();
|
||||
B.line(`${a} = ${arrNewCall(host, elem, n)}`);
|
||||
const items = this.itemsOf(B, "%d");
|
||||
const acc = elemAccess(elem);
|
||||
const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
|
||||
host.declare(
|
||||
`declare double @scr_arr_push_${acc}(ptr, ${acc === "bool" ? "i1 zeroext" : accTy})`,
|
||||
);
|
||||
host.declare(`declare zeroext i1 @scr_dyn_arr_has_index(ptr, ${this.S})`);
|
||||
host.declare(`declare void @scr_arr_set_len(ptr, double)`);
|
||||
host.declare(`declare zeroext i1 @scr_arr_set_foreign(ptr, double, ptr)`);
|
||||
this.i64Loop(B, "dva", n, (i) => {
|
||||
const index = B.tmp();
|
||||
B.line(`${index} = ${sizeToDouble(this.S)} ${this.S} ${i} to double`);
|
||||
const present = B.tmp();
|
||||
B.line(`${present} = call zeroext i1 @scr_dyn_arr_has_index(ptr %d, ${this.S} ${i})`);
|
||||
const valueLabel = B.newLabel("dva.present");
|
||||
const holeLabel = B.newLabel("dva.hole");
|
||||
const typedLabel = B.newLabel("dva.typed");
|
||||
const foreignLabel = B.newLabel("dva.foreign");
|
||||
const doneLabel = B.newLabel("dva.done");
|
||||
B.condBr(present, valueLabel, holeLabel);
|
||||
B.startBlock(holeLabel);
|
||||
const length = B.tmp();
|
||||
B.line(`${length} = fadd double ${index}, 1.0`);
|
||||
B.line(`call void @scr_arr_set_len(ptr ${a}, double ${length})`);
|
||||
B.br(doneLabel);
|
||||
B.startBlock(valueLabel);
|
||||
const e = this.itemAt(B, items, i);
|
||||
const fits = B.tmp();
|
||||
B.line(`${fits} = call zeroext i1 @${match}(ptr ${e})`);
|
||||
B.condBr(fits, typedLabel, foreignLabel);
|
||||
B.startBlock(foreignLabel);
|
||||
const kept = B.tmp();
|
||||
B.line(`${kept} = call zeroext i1 @scr_arr_set_foreign(ptr ${a}, double ${index}, ptr ${e})`);
|
||||
B.condBr(kept, doneLabel, typedLabel);
|
||||
B.startBlock(typedLabel);
|
||||
const v = B.tmp();
|
||||
B.line(`${v} = call ${this.valTy(elem)} @${check}(ptr ${e}, ptr null)`);
|
||||
this.pendingBail(
|
||||
B,
|
||||
"dva",
|
||||
() => {
|
||||
host.declare(`declare void @scr_arr_release(ptr)`);
|
||||
B.line(`call void @scr_arr_release(ptr ${a})`);
|
||||
},
|
||||
"ptr null",
|
||||
);
|
||||
const pushed = B.tmp();
|
||||
B.line(`${pushed} = call double @scr_arr_push_${acc}(ptr ${a}, ${accTy} ${v})`);
|
||||
B.br(doneLabel);
|
||||
B.startBlock(doneLabel);
|
||||
});
|
||||
B.terminate(`ret ptr ${a}`);
|
||||
this.defs.push(
|
||||
`define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} { ; view commit ${typeKey(t)}`,
|
||||
B.render(),
|
||||
`}`,
|
||||
``,
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
dynCheckHelper(t: IrType, preserveRefs = false): string {
|
||||
// A `(T | undefined)[]` stored as T elements also takes undefined ones.
|
||||
const key = checkedConversionKey(t);
|
||||
@@ -3686,7 +3767,25 @@ export class LlDyn {
|
||||
B.br(doneLabel);
|
||||
B.startBlock(presentLabel);
|
||||
B.line(`${undefinedState} = fcmp oeq double ${state}, 2.0`); // SCR_ARR_UNDEFINED
|
||||
B.terminate(`br i1 ${undefinedState}, label %${undefinedLabel}, label %${valueLabel}`);
|
||||
if (host.mayHoldForeign(t.elem)) {
|
||||
// A foreign element (SCR_ARR_FOREIGN) holds its own value.
|
||||
const typedLabel = B.newLabel("tda.typed");
|
||||
const foreignLabel = B.newLabel("tda.foreign");
|
||||
B.terminate(`br i1 ${undefinedState}, label %${undefinedLabel}, label %${typedLabel}`);
|
||||
B.startBlock(typedLabel);
|
||||
const foreign = B.tmp();
|
||||
B.line(`${foreign} = fcmp oeq double ${state}, 3.0`);
|
||||
B.condBr(foreign, foreignLabel, valueLabel);
|
||||
B.startBlock(foreignLabel);
|
||||
host.declare(`declare ptr @scr_arr_get_foreign(ptr, double)`);
|
||||
host.declare(`declare void @scr_dyn_arr_push(ptr, ptr)`);
|
||||
const payload = B.tmp();
|
||||
B.line(`${payload} = call ptr @scr_arr_get_foreign(ptr %v, double ${i}) ; +1`);
|
||||
B.line(`call void @scr_dyn_arr_push(ptr ${d}, ptr ${payload})`);
|
||||
B.br(doneLabel);
|
||||
} else {
|
||||
B.terminate(`br i1 ${undefinedState}, label %${undefinedLabel}, label %${valueLabel}`);
|
||||
}
|
||||
B.startBlock(undefinedLabel);
|
||||
const retained = this.retainDyn(B, this.undef(B));
|
||||
B.line(`call void @scr_dyn_arr_push(ptr ${d}, ptr ${retained})`);
|
||||
|
||||
@@ -618,6 +618,23 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
|
||||
regexMatchers.set(key, code);
|
||||
for (const decl of code.decls) emitter.declare(decl);
|
||||
}
|
||||
// Replacers of global substitutions with a literal replacement; the call
|
||||
// sites register only patterns inside the compiled subset.
|
||||
const regexReplacers: RegexMatcherCode[] = [];
|
||||
for (const rr of emitter.regexReplacers.values()) {
|
||||
const code = compileRegexMatcher(
|
||||
rr.sym,
|
||||
rr.pattern,
|
||||
rr.flags,
|
||||
emitter.sizeType,
|
||||
!emitter.wasi,
|
||||
rr.replacement,
|
||||
);
|
||||
if (code === null)
|
||||
throw new InternalCompilerError(`llvm emitter bug: replacer ${rr.sym} did not compile`);
|
||||
regexReplacers.push(code);
|
||||
for (const decl of code.decls) emitter.declare(decl);
|
||||
}
|
||||
|
||||
const out: string[] = [
|
||||
`; Generated by scriptc (LLVM backend) from ${emitter.mod.sourceFile}. Do not edit.`,
|
||||
@@ -901,6 +918,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
|
||||
}
|
||||
if (emitter.regexInstances.size > 0) out.push(``);
|
||||
for (const code of regexMatchers.values()) out.push(...code.lines, ``);
|
||||
for (const code of regexReplacers) out.push(...code.lines, ``);
|
||||
for (const [, inst] of emitter.templateStringsInstances) {
|
||||
// One immortal ScrArr per tagged-template site: a data global of
|
||||
// interned cooked-string literals, and the ScrArr header over it
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
} from "./string-construction.js";
|
||||
import { emitStringInputs } from "./string-lifetimes.js";
|
||||
import { LOOPS } from "./loop-state.js";
|
||||
import { loopAccessesTypedArrays } from "../loop-polls.js";
|
||||
|
||||
/** A callback loop version copies the loop; keep the copies small. */
|
||||
const MAX_CALLBACK_VERSION_COST = 400;
|
||||
@@ -1211,9 +1212,68 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
|
||||
B.line(`store ptr ${fresh}, ptr ${slot}`);
|
||||
}
|
||||
};
|
||||
const workerBudget = emitter.workerLoopBudget(s);
|
||||
// A counted loop over typed arrays, or a loop to a typed array's
|
||||
// length, polls between blocks of WORKER_POLL_BLOCK iterations instead
|
||||
// of counting a budget in its body: the inner loop over a block then
|
||||
// has no exit besides its own bound, which LLVM can vectorize. A typed
|
||||
// array's length is fixed, so each block can take it as its limit when
|
||||
// the loop does not assign the array's binding.
|
||||
const stripMined =
|
||||
capturedLets.length === 0 &&
|
||||
integerSlot !== null &&
|
||||
((countedLoop !== null &&
|
||||
countedLimit !== null &&
|
||||
Math.abs(countedLoop.step) * WORKER_POLL_BLOCK <= 2 ** 52) ||
|
||||
(integerLoop !== null && integerArrayLoop === null && integerLoop.stableReceiver)) &&
|
||||
emitter.workerLoopPolls(s) &&
|
||||
loopAccessesTypedArrays(s);
|
||||
const workerBudget = stripMined ? null : emitter.workerLoopBudget(s);
|
||||
// One slot for every copy of the loop: a speculative copy resumes an
|
||||
// iteration in another copy's body, which then reads this bound.
|
||||
let blockBound: string | null = null;
|
||||
if (stripMined) {
|
||||
blockBound = B.slot();
|
||||
B.entryAllocas.push(
|
||||
`${blockBound} = alloca ${countedLoop ? "i64" : emitter.sizeType} ; cancellation poll block bound`,
|
||||
);
|
||||
}
|
||||
freshenBindings();
|
||||
emitter.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => {
|
||||
if (stripMined) {
|
||||
const counter: StripMinedCounter = countedLoop
|
||||
? {
|
||||
slot: integerSlot!,
|
||||
type: "i64",
|
||||
signed: true,
|
||||
step: countedLoop.step,
|
||||
followers: countedLoop.followers.map((f, i) => ({
|
||||
slot: followerSlots[i]!,
|
||||
step: f.step,
|
||||
})),
|
||||
limit: () => countedLimit!,
|
||||
}
|
||||
: {
|
||||
slot: integerSlot!,
|
||||
type: emitter.sizeType,
|
||||
signed: false,
|
||||
step: 1,
|
||||
followers: [],
|
||||
limit: () => {
|
||||
const receiver = emitter.emitStableReceiver(integerLoop!.limitReceiver, []);
|
||||
const lenPtr = B.tmp();
|
||||
const len = B.tmp();
|
||||
B.line(
|
||||
`${lenPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver.name}, i64 0, i32 1`,
|
||||
);
|
||||
B.line(
|
||||
`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`,
|
||||
);
|
||||
return len;
|
||||
},
|
||||
};
|
||||
emitStripMinedCountedLoop(emitter, s, counter, blockBound!, exit);
|
||||
return;
|
||||
}
|
||||
const lc = B.newLabel("loop.c");
|
||||
const lb = B.newLabel("loop.b");
|
||||
const lu =
|
||||
@@ -2565,6 +2625,102 @@ function emitInt32KindLoop(
|
||||
|
||||
/** At the top of a loop copy's header: lets a speculative loop find its
|
||||
* ordinary copy's header, and keeps its third copy out of the loop. */
|
||||
/** Iterations between cancellation polls of a strip-mined counted loop. */
|
||||
const WORKER_POLL_BLOCK = 64;
|
||||
|
||||
/** The counter of a strip-mined loop: its slot, the LLVM integer type and
|
||||
* signedness of its values, its step, the counters stepped beside it, and
|
||||
* the limit, emitted in the outer header before each block. */
|
||||
interface StripMinedCounter {
|
||||
slot: string;
|
||||
type: string;
|
||||
signed: boolean;
|
||||
step: number;
|
||||
followers: { slot: string; step: number }[];
|
||||
limit: () => string;
|
||||
}
|
||||
|
||||
/** A counted `for` loop of a worker program as blocks of WORKER_POLL_BLOCK
|
||||
* iterations, with the cancellation poll between blocks: the outer header
|
||||
* tests the counter against the loop's limit, each block runs the body up
|
||||
* to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll
|
||||
* follows the block. It polls as often as the budget it replaces, and the
|
||||
* inner loop's only exit besides `break` and `return` is its bound. */
|
||||
function emitStripMinedCountedLoop(
|
||||
emitter: LlEmitter,
|
||||
s: Extract<IrStmt, { kind: "for" }>,
|
||||
counter: StripMinedCounter,
|
||||
boundSlot: string,
|
||||
exit: string,
|
||||
): void {
|
||||
const B = emitter.B;
|
||||
const { slot, type, signed, step } = counter;
|
||||
const ascending = step > 0;
|
||||
const compare = !signed ? "ult" : ascending ? "slt" : "sgt";
|
||||
const saturating = `llvm.${signed ? "sadd" : "uadd"}.sat.${type}`;
|
||||
const nearer = `llvm.${!signed ? "umin" : ascending ? "smin" : "smax"}.${type}`;
|
||||
const wrap = signed ? "nsw" : "nuw";
|
||||
const lc = B.newLabel("loop.c");
|
||||
const block = B.newLabel("loop.block");
|
||||
const bc = B.newLabel("loop.bc");
|
||||
const lb = B.newLabel("loop.b");
|
||||
const lu = B.newLabel("loop.u");
|
||||
const poll = B.newLabel("loop.poll");
|
||||
emitter.declare(`declare ${type} @${saturating}(${type}, ${type})`);
|
||||
emitter.declare(`declare ${type} @${nearer}(${type}, ${type})`);
|
||||
B.br(lc);
|
||||
B.startBlock(lc);
|
||||
enterLoopHeader(emitter, lc);
|
||||
const start = B.tmp();
|
||||
const open = B.tmp();
|
||||
B.line(`${start} = load ${type}, ptr ${slot}`);
|
||||
const limit = counter.limit();
|
||||
B.line(`${open} = icmp ${compare} ${type} ${start}, ${limit}`);
|
||||
B.condBr(open, block, exit);
|
||||
B.startBlock(block);
|
||||
const stepped = B.tmp();
|
||||
const bound = B.tmp();
|
||||
B.line(
|
||||
`${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * WORKER_POLL_BLOCK})`,
|
||||
);
|
||||
B.line(`${bound} = call ${type} @${nearer}(${type} ${stepped}, ${type} ${limit})`);
|
||||
B.line(`store ${type} ${bound}, ptr ${boundSlot}`);
|
||||
B.br(bc);
|
||||
B.startBlock(bc);
|
||||
const index = B.tmp();
|
||||
const end = B.tmp();
|
||||
const inBlock = B.tmp();
|
||||
B.line(`${index} = load ${type}, ptr ${slot}`);
|
||||
B.line(`${end} = load ${type}, ptr ${boundSlot}`);
|
||||
B.line(`${inBlock} = icmp ${compare} ${type} ${index}, ${end}`);
|
||||
B.condBr(inBlock, lb, poll);
|
||||
B.startBlock(lb);
|
||||
emitter.jumpTargets.push({
|
||||
kind: "loop",
|
||||
brkLabel: exit,
|
||||
contLabel: lu,
|
||||
labels: s.labels,
|
||||
frameDepth: emitter.frames.length,
|
||||
scopeDepth: emitter.scopes.length,
|
||||
finallyDepth: emitter.finallyStack.length,
|
||||
});
|
||||
emitter.emitBlock(s.body);
|
||||
emitter.jumpTargets.pop();
|
||||
B.br(lu);
|
||||
B.startBlock(lu);
|
||||
for (const { slot: stepSlot, step: by } of [{ slot, step }, ...counter.followers]) {
|
||||
const old = B.tmp();
|
||||
const next = B.tmp();
|
||||
B.line(`${old} = load ${type}, ptr ${stepSlot}`);
|
||||
B.line(`${next} = add ${wrap} ${type} ${old}, ${by}`);
|
||||
B.line(`store ${type} ${next}, ptr ${stepSlot}`);
|
||||
}
|
||||
B.br(bc);
|
||||
B.startBlock(poll);
|
||||
emitter.emitWorkerPoll();
|
||||
B.br(lc);
|
||||
}
|
||||
|
||||
function enterLoopHeader(emitter: LlEmitter, header: string): void {
|
||||
const dispatch = emitter.speculationHeader;
|
||||
emitter.speculationHeader = null;
|
||||
|
||||
@@ -1307,6 +1307,12 @@ export class LlEmitter {
|
||||
string,
|
||||
{ sym: string; src: string; fl: string; pattern: string; flags: string }
|
||||
>();
|
||||
/** Build-time replacers: one per (pattern, flags, replacement) of a
|
||||
* global substitution with a literal replacement (regex-matchers.ts). */
|
||||
readonly regexReplacers = new Map<
|
||||
string,
|
||||
{ sym: string; pattern: string; flags: string; replacement: string }
|
||||
>();
|
||||
/** Interned tagged-template strings objects: per-site key → { symbol,
|
||||
* interned cooked-literal refs }. One immortal ScrArr of string slots
|
||||
* per template SITE (the spec's per-occurrence identity — the C
|
||||
@@ -1637,6 +1643,9 @@ export class LlEmitter {
|
||||
* commit back into their objects. Library builds keep their commits:
|
||||
* their values cross a native boundary. */
|
||||
readonly liveViewsReadOnly: boolean;
|
||||
/** IrModule.foreignElementKeys: element keys whose arrays can hold
|
||||
* SCR_ARR_FOREIGN elements. */
|
||||
readonly foreignElementKeys: ReadonlySet<string>;
|
||||
/** The program freezes, seals or prevents extensions of untyped values
|
||||
* (IrModule.untypedIntegrity): the commits of class views store the
|
||||
* view's integrity state in the instance's integrity word. */
|
||||
@@ -1929,6 +1938,7 @@ export class LlEmitter {
|
||||
!this.untypedIntegrity &&
|
||||
!dynamicStores.all &&
|
||||
dynamicStores.names.size === 0;
|
||||
this.foreignElementKeys = new Set(mod.foreignElementKeys ?? []);
|
||||
this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map();
|
||||
this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []);
|
||||
this.programLifetime = new Set(mod.contracts?.programLifetime ?? []);
|
||||
@@ -2224,6 +2234,7 @@ export class LlEmitter {
|
||||
declare: (decl) => this.declare(decl),
|
||||
needOom: () => this.needOom(),
|
||||
needBadTag: () => this.needBadTag(),
|
||||
mayHoldForeign: (elem) => this.mayHoldForeign(elem),
|
||||
internLiteral: (text) => this.internLiteral(text),
|
||||
cstr: (text) => this.cstr(text),
|
||||
mapOps: (operands) => this.mapOps(operands),
|
||||
@@ -3934,13 +3945,19 @@ export class LlEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `loop` needs a cancellation poll: in a worker program, unless
|
||||
* the loop already runs a pending test on every iteration, which observes
|
||||
* a terminate request itself (a separate poll would only add work). */
|
||||
workerLoopPolls(loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>): boolean {
|
||||
return (
|
||||
this.mod.workers === true && !loopTestsEveryIteration(loop, (e) => this.callTestsPending(e))
|
||||
);
|
||||
}
|
||||
|
||||
workerLoopBudget(
|
||||
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>,
|
||||
): string | null {
|
||||
if (!this.mod.workers) return null;
|
||||
// A loop that runs a pending test on every iteration observes a
|
||||
// terminate request through it; a separate poll would only add work.
|
||||
if (loopTestsEveryIteration(loop, (e) => this.callTestsPending(e))) return null;
|
||||
if (!this.workerLoopPolls(loop)) return null;
|
||||
const slot = this.B.slot();
|
||||
this.B.entryAllocas.push(`${slot} = alloca i32 ; worker cancellation budget`);
|
||||
this.B.line(`store i32 0, ptr ${slot}`);
|
||||
@@ -4058,14 +4075,19 @@ export class LlEmitter {
|
||||
B.condBr(due, poll, next);
|
||||
B.startBlock(poll);
|
||||
B.line(`store i32 63, ptr ${slot}`);
|
||||
// Calls and throwing operations retain their own checks. Amortize the
|
||||
// extra cancellation poll across bounded loop batches so scalar work
|
||||
// does not cross the runtime ABI on every iteration.
|
||||
this.emitPendingCheck(undefined, true);
|
||||
this.emitWorkerPoll();
|
||||
B.br(next);
|
||||
B.startBlock(next);
|
||||
}
|
||||
|
||||
/** The cancellation poll itself. Calls and throwing operations retain
|
||||
* their own checks. Loops amortize this extra poll across bounded
|
||||
* batches of iterations so scalar work does not cross the runtime ABI on
|
||||
* every iteration. */
|
||||
emitWorkerPoll(): void {
|
||||
this.emitPendingCheck(undefined, true);
|
||||
}
|
||||
|
||||
/** Moves an already-evaluated value into the runtime's exception cell —
|
||||
* the `throw` statement's kind dispatch (stmts.ts's), shared with
|
||||
* every synthetic thrower. Ownership of a refcounted payload must have
|
||||
@@ -5433,6 +5455,8 @@ export class LlEmitter {
|
||||
return addNumbered(this.threadSlotsBlocks, key, value as string, "@sc_slots_", names);
|
||||
case "regex":
|
||||
return addNumberedSymbol(this.regexInstances, key, value as never, "sc_re_", names);
|
||||
case "regexReplacer":
|
||||
return addNumberedSymbol(this.regexReplacers, key, value as never, "sc_rr_", names);
|
||||
case "templateStrings":
|
||||
return addNumberedSymbol(
|
||||
this.templateStringsInstances,
|
||||
@@ -6710,6 +6734,12 @@ export class LlEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Arrays of this element type can hold SCR_ARR_FOREIGN elements. */
|
||||
mayHoldForeign(elem: IrType): boolean {
|
||||
if (this.foreignElementKeys.size === 0) return false;
|
||||
return this.foreignElementKeys.has("*") || this.foreignElementKeys.has(typeKey(elem));
|
||||
}
|
||||
|
||||
/** Lifetime regions in modules with lifetimeRegions (llvm/regions.ts).
|
||||
* Created with the class graph; null elsewhere. */
|
||||
regions: LifetimeRegions | null = null;
|
||||
|
||||
@@ -8,7 +8,7 @@ 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";
|
||||
import { bytesElementAlias, bytesHeaderAlias } from "./bytes-alias.js";
|
||||
import { bytesElementAlias, bytesHeaderAlias, bytesHeaderValueAlias } from "./bytes-alias.js";
|
||||
|
||||
const BYTES_NUM_KIND: Record<string, { kind: number; le: boolean } | undefined> = {
|
||||
u8: { kind: 0, le: false },
|
||||
@@ -160,12 +160,19 @@ export function emitBytesIndex(
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** The element count of a typed array, in the target's size type. */
|
||||
export function emitBytesHeaderLength(host: LlvmEmitterContext, receiver: string): string {
|
||||
/** The element count of a typed array, in the target's size type. `value`:
|
||||
* the program reads the count itself (bytesHeaderValueAlias), rather than
|
||||
* only bounding an access. */
|
||||
export function emitBytesHeaderLength(
|
||||
host: LlvmEmitterContext,
|
||||
receiver: string,
|
||||
value = false,
|
||||
): string {
|
||||
const p = host.B.tmp();
|
||||
const len = host.B.tmp();
|
||||
const alias = value ? bytesHeaderValueAlias(host) : bytesHeaderAlias(host);
|
||||
host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 1`);
|
||||
host.B.line(`${len} = load ${host.sizeType}, ptr ${p}${bytesHeaderAlias(host)}`);
|
||||
host.B.line(`${len} = load ${host.sizeType}, ptr ${p}${alias}`);
|
||||
return len;
|
||||
}
|
||||
|
||||
@@ -184,7 +191,7 @@ export function emitBytesLength(
|
||||
bytes: boolean,
|
||||
): LlValue {
|
||||
const B = host.B;
|
||||
const len = emitBytesHeaderLength(host, receiver);
|
||||
const len = emitBytesHeaderLength(host, receiver, true);
|
||||
const size = BYTES_ELEMENT_SIZE[elem];
|
||||
const count = bytes && size !== 1 ? B.tmp() : len;
|
||||
if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dynDesc } from "../../ir/analysis.js";
|
||||
/* Focused LLVM expression emission extracted from emitter.ts. */
|
||||
import { arraySearchHelperName, arraySearchKind } from "./array-search.js";
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
@@ -49,11 +50,13 @@ import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js";
|
||||
import { emitScratchStringSlice, emitStringSliceRead } from "./string-slices.js";
|
||||
import {
|
||||
STRING_CHAR_AT_DECL,
|
||||
STRING_CP_AT_BYTE_DECL,
|
||||
STRING_CHAR_AT_INT_MARKER,
|
||||
STRING_CHAR_CODE_AT_DECL,
|
||||
STRING_CHAR_CODE_AT_INT_MARKER,
|
||||
emitAsciiChar,
|
||||
emitAsciiCharCode,
|
||||
emitStringByteLength,
|
||||
emitUnitCharCode,
|
||||
STRING_UTF16_LEN_DECL,
|
||||
} from "./string-reads.js";
|
||||
@@ -564,16 +567,30 @@ export function emitStrIntrinsic(
|
||||
return call("scr_str_is_well_formed", "zeroext i1 (ptr)", `ptr ${r.name}`, "i1", false);
|
||||
case "toWellFormed":
|
||||
return call("scr_str_to_well_formed", "ptr (ptr)", `ptr ${r.name}`, "ptr", true);
|
||||
case "cpAt":
|
||||
// The code point AT an index as a one-code-point string (+1) —
|
||||
// the string-for-of desugar's read.
|
||||
return call(
|
||||
"scr_str_cp_at",
|
||||
"ptr (ptr, double)",
|
||||
`ptr ${r.name}, double ${args[0]!.name}`,
|
||||
"ptr",
|
||||
true,
|
||||
);
|
||||
case "byteLength": {
|
||||
const t = B.tmp();
|
||||
// A byte count fits in a signed size, whose conversion is one instruction.
|
||||
B.line(`${t} = sitofp ${host.sizeType} ${emitStringByteLength(host, r.name)} to double`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
case "cpAtByte": {
|
||||
// sc_str_cp_at_byte (string-reads.ts) answers ASCII bytes from the
|
||||
// charAt table inline. The cursor is a character start, an exact
|
||||
// integer below the byte count.
|
||||
host.declare(STRING_CHAR_AT_DECL);
|
||||
host.declare(STRING_CP_AT_BYTE_DECL);
|
||||
const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
|
||||
let wide: string;
|
||||
if (index) {
|
||||
wide = widenInteger(host, index);
|
||||
} else {
|
||||
wide = B.tmp();
|
||||
B.line(`${wide} = fptosi double ${args[0]!.name} to i64`);
|
||||
}
|
||||
const t = B.tmp();
|
||||
B.line(`${t} = call ptr @sc_str_cp_at_byte(ptr ${r.name}, i64 ${wide})`);
|
||||
return host.own({ name: t, type: e.type });
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = method;
|
||||
void _exhaustive;
|
||||
@@ -790,6 +807,9 @@ export function emitArrIntrinsic(
|
||||
case "shift": {
|
||||
host.emitPublishedArrayGuard(r.name, e.receiver.type, method === "pop" ? 2 : 6);
|
||||
const checkedValue = elem.kind === "dyn" && e.type.kind === "dyn";
|
||||
// A foreign element (SCR_ARR_FOREIGN) cannot be the typed result:
|
||||
// removing one throws the checked conversion's TypeError.
|
||||
const foreignElements = !checkedValue && host.mayHoldForeign(elem);
|
||||
if (!checkedValue && e.type.kind !== "union")
|
||||
throw new InternalCompilerError("llvm emitter bug: array removal result is not a union");
|
||||
const def = e.type.kind === "union" ? host.unionsById.get(e.type.unionId) : undefined;
|
||||
@@ -832,7 +852,25 @@ export function emitArrIntrinsic(
|
||||
);
|
||||
B.line(`${slowRaw} = load i64, ptr ${rawSlot}`);
|
||||
B.line(`${has} = icmp eq i8 ${state}, 1`);
|
||||
B.condBr(has, lp, la);
|
||||
const lf = foreignElements ? B.newLabel("remove.f") : la;
|
||||
B.condBr(has, lp, lf);
|
||||
if (foreignElements) {
|
||||
// Only the runtime call removes a foreign element; its value moves
|
||||
// out with the slot, and the throw releases it.
|
||||
B.startBlock(lf);
|
||||
const foreign = B.tmp();
|
||||
const throwLabel = B.newLabel("remove.foreign");
|
||||
B.line(`${foreign} = icmp eq i8 ${state}, 3`);
|
||||
B.condBr(foreign, throwLabel, la);
|
||||
B.startBlock(throwLabel);
|
||||
const payload = B.tmp();
|
||||
B.line(`${payload} = inttoptr i64 ${slowRaw} to ptr`);
|
||||
host.declare(`declare void @scr_dyn_foreign_throw(ptr, ptr)`);
|
||||
const want = host.cstr(dynDesc(e.type, host.recordsById, host.unionsById));
|
||||
B.line(`call void @scr_dyn_foreign_throw(ptr ${payload}, ptr ${want})`);
|
||||
host.emitAlwaysPendingUnwind();
|
||||
B.br(la);
|
||||
}
|
||||
B.startBlock(lp);
|
||||
let raw = slowRaw;
|
||||
const value = B.tmp();
|
||||
|
||||
@@ -661,6 +661,29 @@ export function regexLiteralTemplate(
|
||||
return `@${re.sym}`;
|
||||
}
|
||||
|
||||
/** The build-time replacer of a global substitution of regex literal `e`
|
||||
* with the literal `replacement` (regex-matchers.ts), registered on first
|
||||
* use. */
|
||||
export function regexLiteralReplacer(
|
||||
host: LlvmEmitterContext,
|
||||
e: IrExpr & { kind: "regexLit" },
|
||||
replacement: string,
|
||||
): string {
|
||||
const key = `${e.flags}/${e.pattern}\0${replacement}`;
|
||||
let rr = host.regexReplacers.get(key);
|
||||
if (!rr) {
|
||||
rr = {
|
||||
sym: `sc_rr_${host.regexReplacers.size}`,
|
||||
pattern: e.pattern,
|
||||
flags: e.flags,
|
||||
replacement,
|
||||
};
|
||||
host.regexReplacers.set(key, rr);
|
||||
}
|
||||
host.journal?.entry("regexReplacer", key, rr);
|
||||
return `@${rr.sym}`;
|
||||
}
|
||||
|
||||
/** A string equality or comparison between `s.charAt(i)` and a literal of
|
||||
* exactly one UTF-16 code unit: the charAt call, the unit, and whether the
|
||||
* charAt is the left operand. */
|
||||
|
||||
@@ -6,7 +6,8 @@ import { LlvmUnsupportedError } from "./unsupported.js";
|
||||
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 { regexLiteralReplacer, regexLiteralTemplate } from "./expr-primitives.js";
|
||||
import { canReplaceWith, regexLiteralCompiles } from "./regex-matchers.js";
|
||||
import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
|
||||
import { preservesRegexInputs } from "./reference-effects.js";
|
||||
|
||||
@@ -160,6 +161,39 @@ function directRegexOperandMethod(method: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** The build-time replacer of a substitution: a regex literal running on
|
||||
* its template, with a literal replacement the replacer can write as is.
|
||||
* Null when the substitution takes the runtime's loop. */
|
||||
function literalReplacer(
|
||||
host: LlvmEmitterContext,
|
||||
literal: IrExpr | null,
|
||||
template: string | null,
|
||||
replacement: IrExpr,
|
||||
): string | null {
|
||||
if (literal === null || template === null || literal.kind !== "regexLit") return null;
|
||||
if (replacement.kind !== "strLit") return null;
|
||||
if (!canReplaceWith(literal.pattern, literal.flags, replacement.value)) return null;
|
||||
if (!regexLiteralCompiles(literal.pattern, literal.flags)) return null;
|
||||
return regexLiteralReplacer(host, literal, replacement.value);
|
||||
}
|
||||
|
||||
/** scr_regex_replace_with: the runtime runs `replacer` on an ASCII subject
|
||||
* and its own loop on any other. */
|
||||
function emitReplaceWith(
|
||||
host: LlvmEmitterContext,
|
||||
e: IrExpr,
|
||||
r: LlValue,
|
||||
args: LlValue[],
|
||||
replacer: string,
|
||||
): LlValue {
|
||||
host.declare(`declare ptr @scr_regex_replace_with(ptr, ptr, ptr, ptr)`);
|
||||
const t = host.B.tmp();
|
||||
host.B.line(
|
||||
`${t} = call ptr @scr_regex_replace_with(ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${replacer})`,
|
||||
);
|
||||
return host.own({ name: t, type: e.type });
|
||||
}
|
||||
|
||||
export function emitRegexIntrinsic(
|
||||
host: LlvmEmitterContext,
|
||||
e: IrExpr & { kind: "regexIntrinsic" },
|
||||
@@ -234,11 +268,16 @@ export function emitRegexIntrinsic(
|
||||
"scr_regex_match_all_captures_into",
|
||||
`ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}`,
|
||||
);
|
||||
case "replaceAll":
|
||||
case "replaceAll": {
|
||||
// A global literal (the only kind a replacer is built for) never takes
|
||||
// replaceAll's non-global TypeError.
|
||||
const replacer = literalReplacer(host, literal, template, e.args[1]!);
|
||||
if (replacer !== null) return emitReplaceWith(host, e, r, args, replacer);
|
||||
return fallible(
|
||||
"scr_regex_replace_all",
|
||||
`ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}`,
|
||||
);
|
||||
}
|
||||
case "split":
|
||||
host.declare(`declare ptr @scr_regex_split_limit(ptr, ptr, double)`);
|
||||
{
|
||||
@@ -323,6 +362,8 @@ export function emitRegexIntrinsic(
|
||||
return host.own({ name: t, type: e.type });
|
||||
}
|
||||
case "replace": {
|
||||
const replacer = literalReplacer(host, literal, template, e.args[1]!);
|
||||
if (replacer !== null) return emitReplaceWith(host, e, r, args, replacer);
|
||||
host.declare(`declare ptr @scr_regex_replace(ptr, ptr, ptr)`);
|
||||
const t = B.tmp();
|
||||
B.line(
|
||||
|
||||
@@ -184,7 +184,11 @@ export function streamTypedRefCommitAdapter(
|
||||
}
|
||||
if (t.kind === "array") {
|
||||
const commit = `${snapshot}_commit`;
|
||||
const check = host.dyn.dynCheckHelper(t);
|
||||
// A value the element type cannot hold stays in the array as a foreign
|
||||
// element where the program can store one (LlEmitter.mayHoldForeign).
|
||||
const check = host.mayHoldForeign(t.elem)
|
||||
? host.dyn.dynViewArrayHelper(t)
|
||||
: host.dyn.dynCheckHelper(t);
|
||||
host.declare(`declare void @scr_arr_release(ptr)`);
|
||||
host.declare(`declare void @scr_arr_outline(ptr)`);
|
||||
host.declare(`declare void @scr_arr_adopt_view_properties(ptr, ptr, ptr)`);
|
||||
@@ -1235,6 +1239,21 @@ export function streamTypedRefMaterializeAdapter(
|
||||
B.line(`call void @scr_dyn_arr_push_hole(ptr ${out})`);
|
||||
B.br(doneLabel);
|
||||
B.startBlock(undefinedLabel);
|
||||
if (host.mayHoldForeign(elem)) {
|
||||
// A foreign element (SCR_ARR_FOREIGN) holds its own value.
|
||||
const foreign = B.tmp();
|
||||
const foreignLabel = B.newLabel("live.array.foreign");
|
||||
const presentUndefined = B.newLabel("live.array.present.undefined");
|
||||
B.line(`${foreign} = fcmp oeq double ${state}, 3.0`);
|
||||
B.condBr(foreign, foreignLabel, presentUndefined);
|
||||
B.startBlock(foreignLabel);
|
||||
host.declare(`declare ptr @scr_arr_get_foreign(ptr, double)`);
|
||||
const payload = B.tmp();
|
||||
B.line(`${payload} = call ptr @scr_arr_get_foreign(ptr %p, double ${index}) ; +1`);
|
||||
B.line(`call void @scr_dyn_arr_push(ptr ${out}, ptr ${payload})`);
|
||||
B.br(doneLabel);
|
||||
B.startBlock(presentUndefined);
|
||||
}
|
||||
const undefinedValue = B.tmp();
|
||||
const retained = B.tmp();
|
||||
B.line(`${undefinedValue} = call ptr @scr_dyn_undefined()`);
|
||||
@@ -1333,7 +1352,16 @@ function liveArrayOps(
|
||||
const refuse = S.newLabel("live.array.refuse");
|
||||
S.condBr(fits, store, refuse);
|
||||
S.startBlock(refuse);
|
||||
S.terminate(`ret i1 false`);
|
||||
if (host.mayHoldForeign(elem)) {
|
||||
// The array keeps a value its element type cannot hold as a foreign
|
||||
// element, and undefined as a present undefined.
|
||||
host.declare(`declare zeroext i1 @scr_arr_set_foreign(ptr, double, ptr)`);
|
||||
const kept = S.tmp();
|
||||
S.line(`${kept} = call zeroext i1 @scr_arr_set_foreign(ptr %p, double %i, ptr %v)`);
|
||||
S.terminate(`ret i1 ${kept}`);
|
||||
} else {
|
||||
S.terminate(`ret i1 false`);
|
||||
}
|
||||
S.startBlock(store);
|
||||
const next = S.tmp();
|
||||
S.line(
|
||||
|
||||
@@ -406,9 +406,13 @@ function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string)
|
||||
inlineLoad(host, receiver.name, ic, INLINE_WAYS, INLINE_PROTO, (cell) => cell, hits, hit, probe);
|
||||
B.startBlock(probe);
|
||||
// The read's probe also answers keys the receiver's plain chain lacks.
|
||||
host.declare(`declare ptr @scr_js_get_probe(ptr, ptr, ptr)`);
|
||||
// Only `length` and `next` reads can be answered for a native array or
|
||||
// iterator, so only those sites take the probe that tests them.
|
||||
const probeFn =
|
||||
key === "length" || key === "next" ? "scr_js_get_probe_native" : "scr_js_get_probe";
|
||||
host.declare(`declare ptr @${probeFn}(ptr, ptr, ptr)`);
|
||||
const probed = B.tmp();
|
||||
B.line(`${probed} = call ptr @scr_js_get_probe(ptr ${receiver.name}, ptr ${name}, ptr ${ic})`);
|
||||
B.line(`${probed} = call ptr @${probeFn}(ptr ${receiver.name}, ptr ${name}, ptr ${ic})`);
|
||||
const found = B.tmp();
|
||||
B.line(`${found} = icmp ne ptr ${probed}, null`);
|
||||
const probeEnd = B.currentLabel();
|
||||
|
||||
@@ -1143,6 +1143,8 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
case "blob":
|
||||
case "wasm":
|
||||
case "array":
|
||||
if (e.fn === "array.foreignThrow") return emitArrayForeignThrow(host, e);
|
||||
return host.emitGenericLibCall(e);
|
||||
case "collection":
|
||||
case "record":
|
||||
case "disposable":
|
||||
@@ -1166,6 +1168,29 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
}
|
||||
}
|
||||
|
||||
/** `array.foreignThrow`: the checked conversion's TypeError for a foreign
|
||||
* element read into a typed result. Always throws; the result is a dummy
|
||||
* of the replaced read's type (the error.argTypeThrow pattern). */
|
||||
function emitArrayForeignThrow(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
const B = host.B;
|
||||
const arr = host.emitExpr(e.args[0]!);
|
||||
const index = host.emitExpr(e.args[1]!);
|
||||
const want = host.emitExpr(e.args[2]!);
|
||||
host.declare(`declare void @scr_arr_foreign_throw(ptr, double, ptr)`);
|
||||
B.line(
|
||||
`call void @scr_arr_foreign_throw(ptr ${arr.name}, double ${index.name}, ptr ${want.name})`,
|
||||
);
|
||||
const ty = host.llType(e.type);
|
||||
if (ty === "void") {
|
||||
host.emitAlwaysPendingUnwind();
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
const dummy = ty === "double" ? "0.0" : ty === "i1" ? "false" : "null";
|
||||
const out = host.own({ name: dummy, type: e.type });
|
||||
host.emitAlwaysPendingUnwind();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The settlement adapter of Promise.all's tuple form for the builder
|
||||
* `fnName`, which fulfills the result with the builder's tuple. */
|
||||
function allTupleAdapterFor(host: LlvmEmitterContext, fnName: string, tuple: IrType): string {
|
||||
|
||||
@@ -309,6 +309,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr)
|
||||
e.fn === "net.sockOnClose" ||
|
||||
e.fn === "net.sockOnConnect" ||
|
||||
e.fn === "net.sockOnTimeout" ||
|
||||
e.fn === "net.sockOnDrain" ||
|
||||
e.fn === "net.sockOnReadable" ||
|
||||
e.fn === "net.sockOnReady" ||
|
||||
e.fn === "http.reqOnEnd" ||
|
||||
@@ -329,6 +330,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr)
|
||||
"net.sockOnClose": "scr_net_sock_on_close",
|
||||
"net.sockOnConnect": "scr_net_sock_on_connect",
|
||||
"net.sockOnTimeout": "scr_net_sock_on_timeout",
|
||||
"net.sockOnDrain": "scr_net_sock_on_drain",
|
||||
"net.sockOnReadable": "scr_net_sock_on_readable",
|
||||
"net.sockOnReady": "scr_net_sock_on_ready",
|
||||
"http.reqOnEnd": "scr_http_req_on_end",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"str.localeCase": "scr_str_locale_case",
|
||||
"str.replaceLiteral": "scr_str_replace_literal",
|
||||
"str.replaceChars": "scr_str_replace_chars",
|
||||
"str.codePoints": "scr_str_code_points",
|
||||
"num.fromString": "scr_string_to_number",
|
||||
"math.round": "scr_math_round",
|
||||
"math.sin": "sin",
|
||||
@@ -1339,6 +1340,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"net.sockResetAndDestroy": "scr_net_sock_reset_and_destroy",
|
||||
"net.sockAddress": "scr_net_sock_address",
|
||||
"net.sockBytesWritten": "scr_net_sock_bytes_written",
|
||||
"net.sockWritableLength": "scr_net_sock_writable_length",
|
||||
"net.sockWritableNeedDrain": "scr_net_sock_writable_need_drain",
|
||||
"net.sockAddrField": "scr_net_sock_addr_field",
|
||||
"net.sockBytesRead": "scr_net_sock_bytes_read",
|
||||
"net.sockConnecting": "scr_net_sock_connecting",
|
||||
@@ -1663,6 +1666,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"regex.groupParseInt": "scr_regex_group_parse_int",
|
||||
"regex.resultProp": "scr_regex_result_prop",
|
||||
"array.namedKeys": "scr_arr_named_keys",
|
||||
"array.foreign": "scr_arr_get_foreign",
|
||||
"array.hasNamedKey": "scr_arr_has_named_key",
|
||||
"array.hasProperties": "scr_arr_has_properties",
|
||||
"array.undefinedKey": "scr_arr_undefined_key",
|
||||
|
||||
@@ -107,6 +107,7 @@ const INERT_LIB_CALLS = new Set([
|
||||
"regex.groupParseInt",
|
||||
"regex.resultProp",
|
||||
"array.namedKeys",
|
||||
"array.foreign",
|
||||
"array.hasNamedKey",
|
||||
"array.hasProperties",
|
||||
"process.cwd",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
canReplaceWith,
|
||||
compileRegexMatcher,
|
||||
regexLiteralBailReason,
|
||||
regexLiteralCaptures,
|
||||
@@ -152,3 +153,46 @@ describe("regex literal matcher code", () => {
|
||||
expect(text).not.toContain("constant [128 x i8]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("regex literal replacers", () => {
|
||||
test("take global, non-sticky, non-plain-text patterns and ASCII replacements without $", () => {
|
||||
expect(canReplaceWith("a[bc]", "g", "<2>")).toBe(true);
|
||||
expect(canReplaceWith("a[bc]", "gi", "")).toBe(true);
|
||||
expect(canReplaceWith("a[bc]", "", "x")).toBe(false);
|
||||
expect(canReplaceWith("a[bc]", "gy", "x")).toBe(false);
|
||||
expect(canReplaceWith("a[bc]", "g", "$1")).toBe(false);
|
||||
expect(canReplaceWith("a[bc]", "g", "é")).toBe(false);
|
||||
// Plain text is a string search at run time.
|
||||
expect(canReplaceWith("&", "g", "&")).toBe(false);
|
||||
expect(canReplaceWith("a\\.b", "g", "x")).toBe(false);
|
||||
// Letters under i are not plain text.
|
||||
expect(canReplaceWith("ab", "gi", "x")).toBe(true);
|
||||
});
|
||||
|
||||
test("append the gap and the replacement and continue the search", () => {
|
||||
const code = compileRegexMatcher("r", "<[^>]*>", "g", "i64", true, "|");
|
||||
const text = code!.lines.join("\n");
|
||||
expect(text).toContain('@r.rep = private unnamed_addr constant [1 x i8] c"|"');
|
||||
expect(text).toContain("call void @scr_re_replace_out_reserve(ptr %out, i64 %m.need)");
|
||||
expect(text).toContain("load <16 x i8>, ptr %m.src, align 1");
|
||||
expect(text).toMatch(/br i1 %m\.past, label %nomatch, label %scan/);
|
||||
expect(text).not.toContain("m.ret:");
|
||||
expect(text).toContain("ret i32 %n.found");
|
||||
// The search after a match is a 16-byte scan, not a memchr call.
|
||||
expect(text).not.toContain("@memchr");
|
||||
expect(code!.decls).toContain("declare void @scr_re_replace_out_reserve(ptr, i64)");
|
||||
// Without vectors the gap is a memcpy.
|
||||
const scalar = compileRegexMatcher("r", "<[^>]*>", "g", "i64", false, "|")!.lines.join("\n");
|
||||
expect(scalar).not.toContain("<16 x i8>");
|
||||
expect(scalar).toContain(
|
||||
"call void @llvm.memcpy.p0.p0.i64(ptr %m.dst, ptr %m.src, i64 %m.gapw, i1 false)",
|
||||
);
|
||||
});
|
||||
|
||||
test("escape the replacement bytes and skip the copy of an empty one", () => {
|
||||
const quoted = compileRegexMatcher("r", "a[bc]", "g", "i64", true, '"\\\n')!.lines.join("\n");
|
||||
expect(quoted).toContain('c"\\22\\5C\\0A"');
|
||||
const empty = compileRegexMatcher("r", "a[bc]", "g", "i64", true, "")!.lines.join("\n");
|
||||
expect(empty).not.toContain("@r.rep");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
* `len`, it returns the capture count (whole match included) so the runtime
|
||||
* can check it against the compiled bytecode. */
|
||||
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
|
||||
const ASCII = 128;
|
||||
const INF = -1;
|
||||
/** Larger patterns keep the interpreter (binary size). */
|
||||
@@ -1853,10 +1855,13 @@ export function compileRegexMatcher(
|
||||
flags: string,
|
||||
sizeType: string,
|
||||
vector: boolean,
|
||||
replacement: string | null = null,
|
||||
): RegexMatcherCode | null {
|
||||
const parsed = parseSubset(pattern, flags);
|
||||
if (parsed === null) return null;
|
||||
const sticky = flags.includes("y");
|
||||
if (replacement !== null && !canReplaceWith(pattern, flags, replacement))
|
||||
throw new InternalCompilerError(`llvm emitter bug: no replacer for /${pattern}/${flags}`);
|
||||
const { root, ncap } = parsed;
|
||||
const gen = new RxGen(sym, ncap, vector);
|
||||
const end: FirstInfo = { set: emptySet(), nullable: true, bol: false, uncond: true };
|
||||
@@ -1873,6 +1878,12 @@ export function compileRegexMatcher(
|
||||
fn.push(` %stk = alloca ptr`);
|
||||
fn.push(` %sbuf = alloca [64 x i32]`);
|
||||
for (let r = 0; r < gen.regs; r++) fn.push(` %r${r} = alloca i32`);
|
||||
if (replacement !== null) {
|
||||
fn.push(` %next = alloca i32`);
|
||||
fn.push(` %nfound = alloca i32`);
|
||||
fn.push(` store i32 0, ptr %next`);
|
||||
fn.push(` store i32 0, ptr %nfound`);
|
||||
}
|
||||
fn.push(` store ptr %sbuf, ptr %stk`);
|
||||
fn.push(` store i32 64, ptr %scap`);
|
||||
fn.push(` store i32 %start, ptr %at`);
|
||||
@@ -1900,7 +1911,9 @@ export function compileRegexMatcher(
|
||||
} else {
|
||||
fn.push(` br label %seek`);
|
||||
}
|
||||
if (firstCount === 1) {
|
||||
// A replacer seeks again after every match, often a few bytes on: the
|
||||
// 16-byte scan avoids a memchr call per match.
|
||||
if (firstCount === 1 && replacement === null) {
|
||||
fn.push(`seek:`);
|
||||
const byte = intervals(first.set)[0]![0]!;
|
||||
fn.push(` %rest = sub i32 %len, %a`);
|
||||
@@ -1944,6 +1957,98 @@ export function compileRegexMatcher(
|
||||
fn.push(` br i1 %over, label %nomatch, label %scan`);
|
||||
}
|
||||
|
||||
if (replacement !== null) {
|
||||
emitReplaceMatch(fn, sym, replacement, vector);
|
||||
} else {
|
||||
emitReturnMatch(fn, ncap);
|
||||
}
|
||||
|
||||
const result = replacement !== null ? "%n.found" : "0";
|
||||
fn.push(`nomatch:`);
|
||||
if (replacement !== null) {
|
||||
fn.push(` %n.next = load i32, ptr %next`);
|
||||
fn.push(` %n.nextp = getelementptr inbounds i8, ptr %out, i64 16`);
|
||||
fn.push(` %n.nextw = sext i32 %n.next to i64`);
|
||||
fn.push(` store i64 %n.nextw, ptr %n.nextp`);
|
||||
fn.push(` %n.found = load i32, ptr %nfound`);
|
||||
}
|
||||
fn.push(` %n.stk = load ptr, ptr %stk`);
|
||||
fn.push(` %n.heap = icmp ne ptr %n.stk, %sbuf`);
|
||||
fn.push(` br i1 %n.heap, label %n.free, label %n.ret`);
|
||||
fn.push(`n.free:`);
|
||||
fn.push(` call void @scr_regex_stack_free(ptr %n.stk)`);
|
||||
fn.push(` br label %n.ret`);
|
||||
fn.push(`n.ret:`);
|
||||
fn.push(` ret i32 ${result}`);
|
||||
|
||||
gen.emitTail();
|
||||
for (const l of gen.body) fn.push(l);
|
||||
fn.push(`}`);
|
||||
|
||||
const decls = REGEX_MATCHER_DECLS.slice();
|
||||
if (gen.body.some((l) => l.includes("@llvm.cttz.i16")))
|
||||
decls.push(`declare i16 @llvm.cttz.i16(i16, i1 immarg)`);
|
||||
if (!sticky && !first.nullable && firstCount === 1 && replacement === null)
|
||||
decls.push(`declare ptr @memchr(ptr, i32, ${sizeType})`);
|
||||
if (replacement !== null) {
|
||||
decls.push(`declare void @scr_re_replace_out_reserve(ptr, i64)`);
|
||||
decls.push(`declare void @llvm.memcpy.p0.p0.i64(ptr, ptr, i64, i1 immarg)`);
|
||||
}
|
||||
const globals = [...gen.globals];
|
||||
if (replacement !== null && replacement.length > 0)
|
||||
globals.push(
|
||||
`@${sym}.rep = private unnamed_addr constant [${replacement.length} x i8] c"${llvmBytes(replacement)}"`,
|
||||
);
|
||||
return { lines: [...globals, ...fn], decls };
|
||||
}
|
||||
|
||||
/** Whether a substitution can run through a build-time replacer: a global,
|
||||
* non-sticky pattern that is not plain text (the runtime searches those as
|
||||
* strings) and an ASCII replacement without `$` patterns. */
|
||||
export function canReplaceWith(pattern: string, flags: string, replacement: string): boolean {
|
||||
if (!flags.includes("g") || flags.includes("y") || isPlainTextPattern(pattern, flags))
|
||||
return false;
|
||||
for (let i = 0; i < replacement.length; i++) {
|
||||
const c = replacement.charCodeAt(i);
|
||||
if (c >= ASCII || c === 0x24) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** scr_regex_plain_text's rule: a pattern of literal characters and escaped
|
||||
* metacharacters (no letters under `i`) that the runtime searches as text. */
|
||||
function isPlainTextPattern(pattern: string, flags: string): boolean {
|
||||
const fold = flags.includes("i");
|
||||
if (pattern.length === 0 || pattern.length > 256) return false;
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
let c = pattern[i]!;
|
||||
if (c === "\\") {
|
||||
if (i + 1 >= pattern.length) return false;
|
||||
c = pattern[++i]!;
|
||||
if (!"^$\\.*+?()[]{}|/".includes(c)) return false;
|
||||
} else {
|
||||
const code = c.charCodeAt(0);
|
||||
if (code < 0x20 || code >= 0x7f || "^$.*+?()[]{}|".includes(c)) return false;
|
||||
}
|
||||
if (fold && /[a-z]/i.test(c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function llvmBytes(text: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text.charCodeAt(i);
|
||||
out +=
|
||||
c >= 0x20 && c < 0x7f && c !== 0x22 && c !== 0x5c
|
||||
? text[i]
|
||||
: `\\${c.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The match block of a matcher: store the capture pointers, return 1. */
|
||||
function emitReturnMatch(fn: string[], ncap: number): void {
|
||||
fn.push(`match:`);
|
||||
fn.push(` %m.end = load i32, ptr %pos`);
|
||||
fn.push(` %m.start = load i32, ptr %at`);
|
||||
@@ -1969,25 +2074,72 @@ export function compileRegexMatcher(
|
||||
fn.push(` br label %m.ret`);
|
||||
fn.push(`m.ret:`);
|
||||
fn.push(` ret i32 1`);
|
||||
}
|
||||
|
||||
fn.push(`nomatch:`);
|
||||
fn.push(` %n.stk = load ptr, ptr %stk`);
|
||||
fn.push(` %n.heap = icmp ne ptr %n.stk, %sbuf`);
|
||||
fn.push(` br i1 %n.heap, label %n.free, label %n.ret`);
|
||||
fn.push(`n.free:`);
|
||||
fn.push(` call void @scr_regex_stack_free(ptr %n.stk)`);
|
||||
fn.push(` br label %n.ret`);
|
||||
fn.push(`n.ret:`);
|
||||
fn.push(` ret i32 0`);
|
||||
|
||||
gen.emitTail();
|
||||
for (const l of gen.body) fn.push(l);
|
||||
fn.push(`}`);
|
||||
|
||||
const decls = REGEX_MATCHER_DECLS.slice();
|
||||
if (gen.body.some((l) => l.includes("@llvm.cttz.i16")))
|
||||
decls.push(`declare i16 @llvm.cttz.i16(i16, i1 immarg)`);
|
||||
if (!sticky && !first.nullable && firstCount === 1)
|
||||
decls.push(`declare ptr @memchr(ptr, i32, ${sizeType})`);
|
||||
return { lines: [...gen.globals, ...fn], decls };
|
||||
/** The match block of a replacer: append the gap since the previous match
|
||||
* and the replacement to the output (ScrReReplaceOut: len, cap, next at
|
||||
* 0, 8 and 16, then data), and continue the search at the match end, or one past it for an
|
||||
* empty match, as the runtime's replace loop does. */
|
||||
function emitReplaceMatch(fn: string[], sym: string, replacement: string, vector: boolean): void {
|
||||
const n = replacement.length;
|
||||
fn.push(`match:`);
|
||||
fn.push(` %m.end = load i32, ptr %pos`);
|
||||
fn.push(` %m.start = load i32, ptr %at`);
|
||||
fn.push(` %m.next = load i32, ptr %next`);
|
||||
fn.push(` %m.gap = sub i32 %m.start, %m.next`);
|
||||
fn.push(` %m.gapw = zext i32 %m.gap to i64`);
|
||||
fn.push(` %m.lenp = getelementptr inbounds i8, ptr %out, i64 0`);
|
||||
fn.push(` %m.capp = getelementptr inbounds i8, ptr %out, i64 8`);
|
||||
fn.push(` %m.datap = getelementptr inbounds i8, ptr %out, i64 24`);
|
||||
fn.push(` %m.len0 = load i64, ptr %m.lenp`);
|
||||
fn.push(` %m.cap = load i64, ptr %m.capp`);
|
||||
fn.push(` %m.room = sub i64 %m.cap, %m.len0`);
|
||||
// 16 bytes of slack past the gap and the replacement for the 16-byte copy.
|
||||
fn.push(` %m.need = add i64 %m.gapw, ${n + 32}`);
|
||||
fn.push(` %m.fits = icmp uge i64 %m.room, %m.need`);
|
||||
fn.push(` br i1 %m.fits, label %m.put, label %m.grow`);
|
||||
fn.push(`m.grow:`);
|
||||
fn.push(` call void @scr_re_replace_out_reserve(ptr %out, i64 %m.need)`);
|
||||
fn.push(` br label %m.put`);
|
||||
fn.push(`m.put:`);
|
||||
fn.push(` %m.data = load ptr, ptr %m.datap`);
|
||||
fn.push(` %m.len = load i64, ptr %m.lenp`);
|
||||
fn.push(` %m.dst = getelementptr inbounds i8, ptr %m.data, i64 %m.len`);
|
||||
fn.push(` %m.nextw = sext i32 %m.next to i64`);
|
||||
fn.push(` %m.src = getelementptr inbounds i8, ptr %s, i64 %m.nextw`);
|
||||
if (vector) {
|
||||
// A gap of at most 16 bytes with 16 readable subject bytes is one
|
||||
// unaligned 16-byte copy.
|
||||
fn.push(` %m.small = icmp ule i32 %m.gap, 16`);
|
||||
fn.push(` %m.rest = sub i32 %len, %m.next`);
|
||||
fn.push(` %m.readable = icmp sge i32 %m.rest, 16`);
|
||||
fn.push(` %m.fast = and i1 %m.small, %m.readable`);
|
||||
fn.push(` br i1 %m.fast, label %m.copy16, label %m.copy`);
|
||||
fn.push(`m.copy16:`);
|
||||
fn.push(` %m.v = load <16 x i8>, ptr %m.src, align 1`);
|
||||
fn.push(` store <16 x i8> %m.v, ptr %m.dst, align 1`);
|
||||
fn.push(` br label %m.rep`);
|
||||
} else {
|
||||
fn.push(` br label %m.copy`);
|
||||
}
|
||||
fn.push(`m.copy:`);
|
||||
fn.push(` call void @llvm.memcpy.p0.p0.i64(ptr %m.dst, ptr %m.src, i64 %m.gapw, i1 false)`);
|
||||
fn.push(` br label %m.rep`);
|
||||
fn.push(`m.rep:`);
|
||||
fn.push(` %m.rdst = getelementptr inbounds i8, ptr %m.dst, i64 %m.gapw`);
|
||||
if (n > 0)
|
||||
fn.push(` call void @llvm.memcpy.p0.p0.i64(ptr %m.rdst, ptr @${sym}.rep, i64 ${n}, i1 false)`);
|
||||
fn.push(` %m.len1 = add i64 %m.len, %m.gapw`);
|
||||
fn.push(` %m.len2 = add i64 %m.len1, ${n}`);
|
||||
fn.push(` store i64 %m.len2, ptr %m.lenp`);
|
||||
fn.push(` store i32 %m.end, ptr %next`);
|
||||
fn.push(` %m.found0 = load i32, ptr %nfound`);
|
||||
fn.push(` %m.found1 = add i32 %m.found0, 1`);
|
||||
fn.push(` store i32 %m.found1, ptr %nfound`);
|
||||
fn.push(` %m.empty = icmp eq i32 %m.end, %m.start`);
|
||||
fn.push(` %m.after = add i32 %m.end, 1`);
|
||||
fn.push(` %m.at = select i1 %m.empty, i32 %m.after, i32 %m.end`);
|
||||
fn.push(` store i32 %m.at, ptr %at`);
|
||||
fn.push(` %m.past = icmp sgt i32 %m.at, %len`);
|
||||
fn.push(` br i1 %m.past, label %nomatch, label %scan`);
|
||||
}
|
||||
|
||||
@@ -146,11 +146,19 @@ describe("LLVM runtime RC symbols", () => {
|
||||
// The dyn fast path buffers surviving values by kind, as scr_dyn_release does.
|
||||
const ir = emitInlineRcHelpers(host).join("\n");
|
||||
expect(ir).toContain("call void @scr_dyn_release_survivor(ptr %o)");
|
||||
expect(ir).toMatch(
|
||||
/switch i32 %kind, label %done \[ i32 4, label %container i32 5, label %container i32 8, label %root/,
|
||||
// Every kind that can be a candidate takes one path; scalars stay inline.
|
||||
expect(ir).toContain(
|
||||
"switch i32 %kind, label %done [ i32 4, label %candidate i32 5, label %candidate i32 8, label %candidate i32 9, label %candidate i32 12, label %candidate i32 13, label %candidate ]",
|
||||
);
|
||||
// Arrays and objects not yet buffered ask the runtime, which skips intact documents.
|
||||
// Values not yet buffered ask the runtime, which decides per value.
|
||||
expect(ir).toMatch(/br i1 %cqueued, label %done, label %survivor/);
|
||||
// The runtime buffers dyn candidates, so the dyn helper has no inline append.
|
||||
const dynHelper = ir.slice(
|
||||
ir.indexOf("@sc_rc_release_dyn("),
|
||||
ir.indexOf("\n}", ir.indexOf("@sc_rc_release_dyn(")),
|
||||
);
|
||||
expect(dynHelper).not.toContain("sc_cyc_append");
|
||||
expect(dynHelper).not.toContain("scr_cyc_on_release");
|
||||
});
|
||||
|
||||
test("sanitized builds call the fast-path helpers out of line", () => {
|
||||
@@ -208,36 +216,42 @@ describe("LLVM runtime RC symbols", () => {
|
||||
});
|
||||
|
||||
test("optimized builds buffer candidates through the module's append helper", () => {
|
||||
const emit = (inlineRc: true | "release", extra: Partial<ShapeHost>) => {
|
||||
const emit = (inlineRc: true | "release", extra: Partial<ShapeHost>, kind: "union" | "dyn") => {
|
||||
const { host } = declarationHost(64, inlineRc);
|
||||
const configured: ShapeHost = { ...host, inlineAlloc: true, ...extra };
|
||||
releaseSym(configured, { kind: "dyn" });
|
||||
releaseSym(configured, kind === "union" ? ({ kind, unionId: "u0" } as never) : { kind });
|
||||
return {
|
||||
ir: emitInlineRcHelpers(configured).join("\n"),
|
||||
decls: inlineRcDecls(configured),
|
||||
};
|
||||
};
|
||||
const { ir, decls } = emit(true, {}, "union");
|
||||
expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)");
|
||||
expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline");
|
||||
// The young test reads the generation byte at obj-5; the append fills
|
||||
// scr_cyc_buffers' nursery buffer and the header's position at obj-4.
|
||||
expect(ir).toContain("getelementptr i8, ptr %o, i64 -5");
|
||||
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96");
|
||||
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104");
|
||||
expect(ir).toContain("load i64, ptr @scr_cyc_live");
|
||||
expect(ir).toContain("or i32 %i, -2147483648");
|
||||
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
|
||||
expect(decls).toContain(
|
||||
"@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }",
|
||||
);
|
||||
expect(decls).toContain("@scr_cyc_live = external global i64");
|
||||
// The dyn release buffers through scr_dyn_release_survivor, so a module
|
||||
// whose only headered release is dyn's has no append helper.
|
||||
for (const mode of [true, "release"] as const) {
|
||||
const { ir, decls } = emit(mode, {});
|
||||
expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)");
|
||||
expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline");
|
||||
// The young test reads the generation byte at obj-5; the append fills
|
||||
// scr_cyc_buffers' nursery buffer and the header's position at obj-4.
|
||||
expect(ir).toContain("getelementptr i8, ptr %o, i64 -5");
|
||||
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96");
|
||||
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104");
|
||||
expect(ir).toContain("load i64, ptr @scr_cyc_live");
|
||||
expect(ir).toContain("or i32 %i, -2147483648");
|
||||
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
|
||||
expect(decls).toContain(
|
||||
"@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }",
|
||||
);
|
||||
expect(decls).toContain("@scr_cyc_live = external global i64");
|
||||
const dyn = emit(mode, {}, "dyn");
|
||||
expect(dyn.ir).not.toContain("sc_cyc_append");
|
||||
expect(dyn.ir).not.toContain("scr_cyc_on_release");
|
||||
expect(dyn.decls.join("\n")).not.toContain("scr_cyc_buffers");
|
||||
}
|
||||
// Worker executables and targets without inline allocation call
|
||||
// scr_cyc_on_release, which tries the same append first.
|
||||
for (const extra of [{ threadLocalAlloc: true }, { inlineAlloc: false }]) {
|
||||
const { ir, decls } = emit(true, extra);
|
||||
const { ir, decls } = emit(true, extra, "union");
|
||||
expect(ir).not.toContain("sc_cyc_append");
|
||||
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
|
||||
expect(decls.join("\n")).not.toContain("scr_cyc_buffers");
|
||||
|
||||
@@ -589,9 +589,10 @@ interface InlineRcFamily {
|
||||
* repeats its own checks, so the fast path never duplicates it.
|
||||
*
|
||||
* The dyn release buffers a surviving value by kind (scr_dyn_release):
|
||||
* the fast path decides the always and never cases and calls
|
||||
* scr_dyn_release_survivor for kinds whose candidacy is per value,
|
||||
* including arrays and objects of intact documents (ScrDynRegion).
|
||||
* the fast path skips scalars and values already in a candidate buffer or
|
||||
* never candidates, and calls scr_dyn_release_survivor for the rest, which
|
||||
* decides per value (intact documents, ScrDynRegion; capsules and handles
|
||||
* without edges).
|
||||
*
|
||||
* The array release inlines the decrement of an array that stays alive
|
||||
* and is not headered (`a->cyc`): those are most array releases in
|
||||
@@ -698,9 +699,10 @@ function requestRcHelper(host: ShapeHost, family: string, op: RcOp): string {
|
||||
for (const decl of rcHelperDecls(family, op)) host.declare(decl);
|
||||
// Its body is written last (emitInlineRcHelpers), after the requests are
|
||||
// collected: a release that can buffer a candidate requests the append
|
||||
// helper now. The jsval row (JS_RC_ROW) has no collector header; its
|
||||
// cells release through dyn's helper, which makes its own request.
|
||||
if (op === "release" && row.cycle === "always") requestCycleAppend(host);
|
||||
// helper now. The dyn release buffers through scr_dyn_release_survivor
|
||||
// instead. The jsval row (JS_RC_ROW) has no collector header; its cells
|
||||
// release through dyn's helper.
|
||||
if (op === "release" && row.cycle === "always" && family !== "dyn") requestCycleAppend(host);
|
||||
return `@${inlineRcName(family, op)}`;
|
||||
}
|
||||
|
||||
@@ -820,8 +822,11 @@ function rcHelperDecls(family: string, op: RcOp): string[] {
|
||||
if (op === "retain" || row.release === null) return [];
|
||||
return [
|
||||
`declare void @${row.release}(ptr)`,
|
||||
...(row.cycle === "always" ? [`declare void @scr_cyc_on_release(ptr)`] : []),
|
||||
...(family === "dyn" ? [`declare void @scr_dyn_release_survivor(ptr)`] : []),
|
||||
...(family === "dyn"
|
||||
? [`declare void @scr_dyn_release_survivor(ptr)`]
|
||||
: row.cycle === "always"
|
||||
? [`declare void @scr_cyc_on_release(ptr)`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -931,21 +936,21 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st
|
||||
// scr_dyn_release buffers a surviving value by kind: always for
|
||||
// functions and proxies, per value for arrays and objects (not
|
||||
// while their document is intact, ScrDynRegion), capsules and
|
||||
// handles (scr_dyn_release_survivor), never for scalars. An
|
||||
// array or object already in a candidate buffer stays inline.
|
||||
// handles, never for scalars. Every kind that can be buffered
|
||||
// takes one path, which keeps each inlined site small: a value
|
||||
// already in a candidate buffer (or never one) stays inline, and
|
||||
// scr_dyn_release_survivor decides the rest.
|
||||
` %kp = getelementptr i8, ptr %o, ${S} ${S === "i64" ? 8 : 4} ; ScrDyn.kind`,
|
||||
` %kind = load i32, ptr %kp`,
|
||||
// SCR_DYN_ARR 4, OBJ 5, FUNC 8, PROXY 13; HANDLE 9, TYPED_REF 12.
|
||||
` switch i32 %kind, label %done [ i32 4, label %container i32 5, label %container i32 8, label %root i32 13, label %root i32 9, label %survivor i32 12, label %survivor ]`,
|
||||
`container:`,
|
||||
// SCR_DYN_ARR 4, OBJ 5, FUNC 8, HANDLE 9, TYPED_REF 12, PROXY 13.
|
||||
` switch i32 %kind, label %done [ i32 4, label %candidate i32 5, label %candidate i32 8, label %candidate i32 9, label %candidate i32 12, label %candidate i32 13, label %candidate ]`,
|
||||
`candidate:`,
|
||||
` %cbufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`,
|
||||
` %cbuf = load i32, ptr %cbufp`,
|
||||
` %cqueued = icmp slt i32 %cbuf, 0`,
|
||||
` br i1 %cqueued, label %done, label %survivor`,
|
||||
`survivor:`,
|
||||
` call void @scr_dyn_release_survivor(ptr %o)`,
|
||||
` br label %done`,
|
||||
`root:`,
|
||||
]
|
||||
: family === "box"
|
||||
? [
|
||||
@@ -964,7 +969,9 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st
|
||||
`root:`,
|
||||
]
|
||||
: []),
|
||||
...(cycle === "always" ? cycleRootLines(host, "done") : [` br label %done`]),
|
||||
...(cycle === "always" && family !== "dyn"
|
||||
? cycleRootLines(host, "done")
|
||||
: [` br label %done`]),
|
||||
`done:`,
|
||||
` ret void`,
|
||||
`}`,
|
||||
|
||||
@@ -341,7 +341,7 @@ const methodCases: {
|
||||
{ method: "normalize", args: [ref("argument")], result: STRING, target: "normalize" },
|
||||
{ method: "isWellFormed", args: [], result: BOOL, target: "is_well_formed" },
|
||||
{ method: "toWellFormed", args: [], result: STRING, target: "to_well_formed" },
|
||||
{ method: "cpAt", args: [num(1)], result: STRING, target: "cp_at" },
|
||||
{ method: "cpAtByte", args: [num(1)], result: STRING, target: "cp_at_byte", inline: true },
|
||||
];
|
||||
|
||||
test.each(methodCases)(
|
||||
|
||||
@@ -30,7 +30,8 @@ export function borrowsStringInputs(method: IrStrIntrinsicMethod): boolean {
|
||||
case "normalize":
|
||||
case "isWellFormed":
|
||||
case "toWellFormed":
|
||||
case "cpAt":
|
||||
case "byteLength":
|
||||
case "cpAtByte":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -43,6 +43,10 @@ export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int";
|
||||
export const STRING_CHAR_AT_DECL = "declare ptr @scr_str_char_at(ptr, double)";
|
||||
/** Requests the integer-index charAt helper, as the marker above does. */
|
||||
export const STRING_CHAR_AT_INT_MARKER = "; uses @sc_str_char_at_int";
|
||||
/** The code-point character starting at a byte offset (the string
|
||||
* iterator's step). Requested together with STRING_CHAR_AT_DECL, which
|
||||
* defines the ASCII table. */
|
||||
export const STRING_CP_AT_BYTE_DECL = "declare ptr @scr_str_cp_at_byte(ptr, i64)";
|
||||
|
||||
/** The proven-ASCII flag of string `s`, as an i1. */
|
||||
export function emitStringAsciiFlag(host: LlvmEmitterContext, s: string): string {
|
||||
@@ -351,6 +355,29 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
// The string iterator's step at byte `%b`, a character start below the
|
||||
// byte count: an ASCII byte is the table entry for it; any other lead
|
||||
// byte takes the runtime, which copies the character's bytes.
|
||||
decl: STRING_CP_AT_BYTE_DECL,
|
||||
define: (sz, attrs) => [
|
||||
`define internal ptr @sc_str_cp_at_byte(ptr %s, i64 %b) ${attrs} {`,
|
||||
`entry:`,
|
||||
` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
|
||||
` %p = getelementptr inbounds i8, ptr %data, i64 %b`,
|
||||
` %byte = load i8, ptr %p`,
|
||||
` %ascii = icmp sge i8 %byte, 0`,
|
||||
` br i1 %ascii, label %read, label %slow`,
|
||||
`read:`,
|
||||
` %code = zext i8 %byte to i64`,
|
||||
` %char = getelementptr inbounds [128 x { ${sz}, ${sz}, ${sz}, [2 x i8] }], ptr @sc_ascii_chars, i64 0, i64 %code`,
|
||||
` ret ptr %char`,
|
||||
`slow:`,
|
||||
` %r = call ptr @scr_str_cp_at_byte(ptr %s, i64 %b) nounwind willreturn`,
|
||||
` ret ptr %r`,
|
||||
`}`,
|
||||
],
|
||||
},
|
||||
{
|
||||
// An exact integer index, as for sc_str_char_code_at_int. Requested
|
||||
// together with STRING_CHAR_AT_DECL, which defines the table.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
llFieldType,
|
||||
loadStructField,
|
||||
releaseSym,
|
||||
retainSym,
|
||||
traceAdapter,
|
||||
type ShapeHost,
|
||||
} from "./shapes.js";
|
||||
@@ -41,6 +42,8 @@ import { undefinedArmTag } from "../../ir/analysis.js";
|
||||
* scr_error-style label texts). */
|
||||
export interface WalkerHost extends ShapeHost {
|
||||
readonly unionsById: Map<string, IrUnionDef>;
|
||||
/** Arrays of this element type can hold SCR_ARR_FOREIGN elements. */
|
||||
mayHoldForeign(elem: IrType): boolean;
|
||||
/** `@`-ref of an interned immortal ScrStr literal. */
|
||||
internLiteral(text: string): string;
|
||||
/** `@`-ref of an interned NUL-terminated byte-array constant. */
|
||||
@@ -325,9 +328,7 @@ export class LlWalkers {
|
||||
if (i > 0) this.putc(B, "%b", "44"); // ','
|
||||
if (edgeable(f.type)) this.jbEdgeIdx(B, String(i));
|
||||
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
|
||||
B.line(
|
||||
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; [${llvmCommentText(f.name)}]`,
|
||||
);
|
||||
this.writeField(B, v, f.type, `[${llvmCommentText(f.name)}]`);
|
||||
});
|
||||
this.putc(B, "%b", "93"); // ']'
|
||||
if (cyclic) this.jbLeave(B);
|
||||
@@ -359,9 +360,7 @@ export class LlWalkers {
|
||||
this.puts(B, "%b", `${i > 0 ? "," : ""}${jsonObjectKeyLabel(f.name)}`);
|
||||
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
|
||||
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
|
||||
B.line(
|
||||
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
|
||||
);
|
||||
this.writeField(B, v, f.type, `${llvmCommentText(f.name)}`);
|
||||
});
|
||||
} else {
|
||||
const first = B.slot();
|
||||
@@ -396,9 +395,7 @@ export class LlWalkers {
|
||||
comma();
|
||||
this.puts(B, "%b", jsonObjectKeyLabel(f.name));
|
||||
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
|
||||
B.line(
|
||||
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
|
||||
);
|
||||
this.writeField(B, v, f.type, `${llvmCommentText(f.name)}`);
|
||||
if (skip !== null) {
|
||||
B.br(skip);
|
||||
B.startBlock(skip);
|
||||
@@ -520,9 +517,7 @@ export class LlWalkers {
|
||||
B.startBlock(lKey);
|
||||
this.puts(B, "%b", jsonObjectKeyLabel(fieldName));
|
||||
if (cyclic && traceAdapter(this.host, f.type) !== null) this.jbEdgeProp(B, fieldName);
|
||||
B.line(
|
||||
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(fieldName)}`,
|
||||
);
|
||||
this.writeField(B, v, f.type, `${llvmCommentText(fieldName)}`);
|
||||
B.terminate("ret i1 true");
|
||||
});
|
||||
B.startBlock(lSkip);
|
||||
@@ -539,14 +534,15 @@ export class LlWalkers {
|
||||
|
||||
/** Whether the walker for `t` can leave an exception pending: cyclic
|
||||
* values, records serialized through a kept checked source, and
|
||||
* checked-dynamic values (toJSON methods, getters, BigInts). */
|
||||
* checked-dynamic values (toJSON methods, getters, BigInts), including
|
||||
* the foreign elements of arrays that can hold them. */
|
||||
jsonWriteMayThrow(t: IrType, seen = new Set<string>()): boolean {
|
||||
if (traceAdapter(this.host, t) !== null) return true;
|
||||
switch (t.kind) {
|
||||
case "dyn":
|
||||
return true;
|
||||
case "array":
|
||||
return this.jsonWriteMayThrow(t.elem, seen);
|
||||
return this.host.mayHoldForeign(t.elem) || this.jsonWriteMayThrow(t.elem, seen);
|
||||
case "union":
|
||||
return (this.host.unionsById.get(t.unionId)?.arms ?? []).some((arm) =>
|
||||
this.jsonWriteMayThrow(arm, seen),
|
||||
@@ -678,6 +674,47 @@ export class LlWalkers {
|
||||
B.line(`call void @scr_arr_release(ptr ${ks})`);
|
||||
}
|
||||
|
||||
/** Serializing a value of `t` can call user code that changes the
|
||||
* arrays or records holding it: it reaches an array that can hold
|
||||
* foreign elements (their toJSON and getters), or a value whose own
|
||||
* serialization may (a class instance, a checked-dynamic value), in a
|
||||
* program that has foreign elements at all. */
|
||||
private serializationMayCallUserCode(t: IrType, seen = new Set<string>()): boolean {
|
||||
const host = this.host;
|
||||
if (!host.mayHoldForeign({ kind: "f64" }) && !host.mayHoldForeign({ kind: "string" }))
|
||||
return false;
|
||||
const key = typeKey(t);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
switch (t.kind) {
|
||||
case "dyn":
|
||||
case "object":
|
||||
return true;
|
||||
case "array":
|
||||
return host.mayHoldForeign(t.elem) || this.serializationMayCallUserCode(t.elem, seen);
|
||||
case "record":
|
||||
return (host.recordsById.get(t.shapeId)?.fields ?? []).some((field) =>
|
||||
this.serializationMayCallUserCode(field.type, seen),
|
||||
);
|
||||
case "union":
|
||||
return (host.unionsById.get(t.unionId)?.arms ?? []).some((arm) =>
|
||||
this.serializationMayCallUserCode(arm, seen),
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a field value loaded borrowed from its record. When its
|
||||
* serialization can call user code that replaces the field, the value
|
||||
* is held across the write. */
|
||||
private writeField(B: BlockBuilder, v: string, t: IrType, comment: string): void {
|
||||
const held = isRefCounted(t) && this.serializationMayCallUserCode(t);
|
||||
if (held) B.line(`call ptr ${retainSym(this.host, t)}(ptr ${v})`);
|
||||
B.line(`call void @${this.jsonWriteHelper(t)}(ptr %b, ${this.valTy(t)} ${v}) ; ${comment}`);
|
||||
if (held) B.line(`call void ${releaseSym(this.host, t)}(ptr ${v})`);
|
||||
}
|
||||
|
||||
private emitArrayWriter(B: BlockBuilder, elem: IrType, cyclic: boolean): void {
|
||||
const host = this.host;
|
||||
const w = this.jsonWriteHelper(elem);
|
||||
@@ -687,7 +724,12 @@ export class LlWalkers {
|
||||
if (cyclic) this.jbEnter(B, true);
|
||||
this.putc(B, "%b", "91"); // '['
|
||||
const finish = B.newLabel("jwa.end");
|
||||
if (elem.kind !== "bool") this.emitDenseArrayElements(B, elem, w, cyclic, finish);
|
||||
// The dense walk borrows the storage and each element. When an
|
||||
// element's serialization can call user code, which can grow or
|
||||
// shrink the array or replace the element, the general walk below
|
||||
// reads each index afresh and holds its element instead.
|
||||
if (elem.kind !== "bool" && !this.serializationMayCallUserCode(elem))
|
||||
this.emitDenseArrayElements(B, elem, w, cyclic, finish);
|
||||
const len = B.tmp();
|
||||
B.line(`${len} = call double @scr_arr_len(ptr %v)`);
|
||||
B.countedLoop(len, (i) => {
|
||||
@@ -710,6 +752,25 @@ export class LlWalkers {
|
||||
B.line(`${present} = fcmp oeq double ${state}, ${f64Lit(1)}`);
|
||||
B.condBr(present, valueBlock, missingBlock);
|
||||
B.startBlock(missingBlock);
|
||||
if (host.mayHoldForeign(elem)) {
|
||||
// A foreign element (SCR_ARR_FOREIGN) serializes its own value,
|
||||
// after its toJSON runs with the index as its key.
|
||||
const foreign = B.tmp();
|
||||
const foreignBlock = B.newLabel("jwa.foreign");
|
||||
const nullBlock = B.newLabel("jwa.null");
|
||||
B.line(`${foreign} = fcmp oeq double ${state}, ${f64Lit(3)}`);
|
||||
B.condBr(foreign, foreignBlock, nullBlock);
|
||||
B.startBlock(foreignBlock);
|
||||
host.declare(`declare ptr @scr_arr_get_foreign(ptr, double)`);
|
||||
host.declare(`declare void @scr_dyn_release(ptr)`);
|
||||
const payload = B.tmp();
|
||||
B.line(`${payload} = call ptr @scr_arr_get_foreign(ptr %v, double ${i}) ; +1`);
|
||||
host.declare(`declare void @scr_jb_put_dyn_element(ptr, ptr, double)`);
|
||||
B.line(`call void @scr_jb_put_dyn_element(ptr %b, ptr ${payload}, double ${i})`);
|
||||
B.line(`call void @scr_dyn_release(ptr ${payload})`);
|
||||
B.br(doneBlock);
|
||||
B.startBlock(nullBlock);
|
||||
}
|
||||
host.declare(`declare void @scr_jb_puts(ptr, ptr)`);
|
||||
B.line(`call void @scr_jb_puts(ptr %b, ptr ${host.cstr("null")})`);
|
||||
B.br(doneBlock);
|
||||
@@ -911,7 +972,28 @@ export class LlWalkers {
|
||||
const lvalue = B.newLabel("uj.p");
|
||||
B.line(`${state} = call double @scr_arr_state(ptr %a, double ${i})`);
|
||||
B.line(`${isValue} = fcmp oeq double ${state}, ${f64Lit(1)}`);
|
||||
B.condBr(isValue, lvalue, next);
|
||||
if (host.mayHoldForeign({ kind: "union", unionId })) {
|
||||
// A foreign element (SCR_ARR_FOREIGN) joins as its ToString, like
|
||||
// every element but undefined and null.
|
||||
const notValue = B.newLabel("uj.f");
|
||||
const foreignLabel = B.newLabel("uj.foreign");
|
||||
B.condBr(isValue, lvalue, notValue);
|
||||
B.startBlock(notValue);
|
||||
const foreign = B.tmp();
|
||||
B.line(`${foreign} = fcmp oeq double ${state}, ${f64Lit(3)}`);
|
||||
B.condBr(foreign, foreignLabel, next);
|
||||
B.startBlock(foreignLabel);
|
||||
host.declare(`declare ptr @scr_arr_get_foreign(ptr, double)`);
|
||||
host.declare(`declare void @scr_dyn_release(ptr)`);
|
||||
const payload = B.tmp();
|
||||
B.line(`${payload} = call ptr @scr_arr_get_foreign(ptr %a, double ${i}) ; +1`);
|
||||
host.declare(`declare void @scr_jb_join_dyn(ptr, ptr)`);
|
||||
B.line(`call void @scr_jb_join_dyn(ptr ${buf}, ptr ${payload})`);
|
||||
B.line(`call void @scr_dyn_release(ptr ${payload})`);
|
||||
B.br(next);
|
||||
} else {
|
||||
B.condBr(isValue, lvalue, next);
|
||||
}
|
||||
B.startBlock(lvalue);
|
||||
const u = B.tmp();
|
||||
B.line(`${u} = call ptr @scr_arr_peek_ref(ptr %a, double ${i}) ; borrowed scalar union`);
|
||||
|
||||
@@ -106,3 +106,34 @@ export function loopTestsEveryIteration(loop: Loop, tested: (call: IrExpr) => bo
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the body of `loop`, outside its nested loops and closures,
|
||||
* reads or writes a typed-array element: the loops whose cancellation poll
|
||||
* the emitter moves between blocks of iterations, so that LLVM can
|
||||
* vectorize them. Other loops keep the budget in their body, which costs
|
||||
* less in a loop of a few iterations. */
|
||||
export function loopAccessesTypedArrays(loop: Loop): boolean {
|
||||
const inExpr = (e: IrExpr): boolean => {
|
||||
if (e.kind === "bytesIntrinsic" && e.method === "get") return true;
|
||||
let found = false;
|
||||
everyExprChild(
|
||||
e,
|
||||
(child) => !(found = inExpr(child)),
|
||||
(child) => !(found = inStmt(child)),
|
||||
);
|
||||
return found;
|
||||
};
|
||||
const inStmt = (s: IrStmt): boolean => {
|
||||
if (s.kind === "bytesSet") return true;
|
||||
if (s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf")
|
||||
return false;
|
||||
let found = false;
|
||||
everyStmtChild(
|
||||
s,
|
||||
(child) => !(found = inExpr(child)),
|
||||
(child) => !(found = inStmt(child)),
|
||||
);
|
||||
return found;
|
||||
};
|
||||
return loop.body.some(inStmt);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* of the link contract: a program object keeps this symbol undefined and the
|
||||
* matching runtime defines it, so incompatible manual links fail before the
|
||||
* program can start. */
|
||||
export const RUNTIME_ABI_VERSION = 44 as const;
|
||||
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v44" as const;
|
||||
export const RUNTIME_ABI_VERSION = 45 as const;
|
||||
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v45" as const;
|
||||
|
||||
/** Object consumption is useful today, but the complete scr_* surface may
|
||||
* still change before 1.0. The versioned marker prevents accidental mixing;
|
||||
|
||||
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
|
||||
object_format: target.objectFormat,
|
||||
minimum_os: target.minimumOs,
|
||||
},
|
||||
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
|
||||
runtime_abi: { version: 45, marker: "scr_runtime_abi_v45" },
|
||||
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
|
||||
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
|
||||
flavors: {
|
||||
|
||||
@@ -129,7 +129,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
|
||||
object_format: target.objectFormat,
|
||||
minimum_os: target.minimumOs,
|
||||
},
|
||||
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
|
||||
runtime_abi: { version: 45, marker: "scr_runtime_abi_v45" },
|
||||
compiler: {
|
||||
command: "clang",
|
||||
identity: "fixture clang",
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
} from "../node-islands/policy.js";
|
||||
import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js";
|
||||
import { buildEnvelope } from "../diagnostics/envelope.js";
|
||||
import { renderConfigWarnings, renderWarnings } from "../diagnostics/render.js";
|
||||
import {
|
||||
renderConfigWarnings,
|
||||
renderTypePatchWarnings,
|
||||
renderWarnings,
|
||||
} from "../diagnostics/render.js";
|
||||
import { isTypePatchCode } from "../diagnostics/diagnostic.js";
|
||||
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
|
||||
import { isNativeOptimization } from "../backend/optimization.js";
|
||||
import {
|
||||
@@ -645,20 +650,30 @@ async function main(args: string[], host: CliHost): Promise<number> {
|
||||
// keeps stderr for the program's own output.
|
||||
const color = process.stderr.isTTY ?? false;
|
||||
const config = warnings.filter((d) => d.code === "SC8004");
|
||||
const divergences = warnings.filter((d) => !isPerformanceCode(d.code) && d.code !== "SC8004");
|
||||
const patches = warnings.filter((d) => isTypePatchCode(d.code));
|
||||
const divergences = warnings.filter(
|
||||
(d) => !isPerformanceCode(d.code) && d.code !== "SC8004" && !isTypePatchCode(d.code),
|
||||
);
|
||||
const performance = warnings.filter((d) => isPerformanceCode(d.code));
|
||||
if (config.length > 0)
|
||||
process.stderr.write(
|
||||
renderConfigWarnings(config, result.sourceTexts ?? new Map(), { color }) + "\n",
|
||||
);
|
||||
if (divergences.length > 0) {
|
||||
if (patches.length > 0) {
|
||||
if (config.length > 0) process.stderr.write("\n");
|
||||
process.stderr.write(
|
||||
renderTypePatchWarnings(patches, result.sourceTexts ?? new Map(), { color }) + "\n",
|
||||
);
|
||||
}
|
||||
if (divergences.length > 0) {
|
||||
if (config.length > 0 || patches.length > 0) process.stderr.write("\n");
|
||||
process.stderr.write(
|
||||
renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n",
|
||||
);
|
||||
}
|
||||
if (performance.length > 0) {
|
||||
if (divergences.length > 0 || config.length > 0) process.stderr.write("\n");
|
||||
if (divergences.length > 0 || config.length > 0 || patches.length > 0)
|
||||
process.stderr.write("\n");
|
||||
process.stderr.write(renderPerformanceWarnings(performance) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@
|
||||
* required to be native (SC8003) or every island refused
|
||||
* (SC8002) while outside the native subset, and the R2
|
||||
* object-model constructs in user JavaScript (SC8101–SC8105,
|
||||
* behind --strict-user-js); and programs that call into
|
||||
* Node.js where the build finds no usable libnode pack
|
||||
* (SC8005), or cannot collect the modules a call loads (SC8006)
|
||||
* behind --strict-user-js); programs that call into Node.js
|
||||
* where the build finds no usable libnode pack (SC8005), or
|
||||
* cannot collect the modules a call loads (SC8006); and type
|
||||
* patches from `scriptc.typePatches` that did not apply as
|
||||
* written (SC8030–SC8033, warnings)
|
||||
* SC9xxx internal compiler errors (still source-anchored)
|
||||
*/
|
||||
import type { SrcLoc } from "../ir/ir.js";
|
||||
@@ -289,6 +291,25 @@ export function nodeIslandsOffModuleDiag(
|
||||
};
|
||||
}
|
||||
|
||||
/** SC8030–SC8033 — a type patch (`scriptc.typePatches`) that did not
|
||||
* apply as written: no range covers the installed copy (SC8030), its
|
||||
* selector matches nothing (SC8031), the declaration already has the type
|
||||
* (SC8032), or the selector is ambiguous or not patchable (SC8033).
|
||||
* Warnings: the build goes on with the declarations it has. */
|
||||
export function typePatchDiag(
|
||||
code: "SC8030" | "SC8031" | "SC8032" | "SC8033",
|
||||
message: string,
|
||||
hint: string,
|
||||
loc: SrcLoc,
|
||||
): ScrDiagnostic {
|
||||
return { code, message, loc, hint };
|
||||
}
|
||||
|
||||
/** True for the type patch warnings (SC8030–SC8033). */
|
||||
export function isTypePatchCode(code: string): boolean {
|
||||
return /^SC803[0-3]$/.test(code);
|
||||
}
|
||||
|
||||
/** SC8003 — a package marked "native" is outside the native subset. */
|
||||
export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* version; renaming or removing a field, or changing a field's meaning,
|
||||
* bumps it. */
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { ScrDiagnostic } from "./diagnostic.js";
|
||||
import { isTypePatchCode, type ScrDiagnostic } from "./diagnostic.js";
|
||||
import { isPerformanceCode } from "./dynamic-tier.js";
|
||||
import type { WasmModuleReport } from "../wasm/discover.js";
|
||||
|
||||
@@ -217,6 +217,7 @@ export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
|
||||
if (code === "SC0002" || code === "SC0004") return "environment";
|
||||
if (/^SC[35]\d{3}$/.test(code)) return "environment";
|
||||
if (code === "SC8004" || code === "SC8005" || code === "SC8012") return "environment";
|
||||
if (isTypePatchCode(code)) return "environment";
|
||||
if (/^SC9\d{3}$/.test(code)) return "internal";
|
||||
return "unsupported";
|
||||
}
|
||||
@@ -335,7 +336,10 @@ export function locateDiagnostic(
|
||||
code: diag.code,
|
||||
category: categoryOf(diag),
|
||||
severity:
|
||||
isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || diag.code === "SC8004"
|
||||
isDivergenceCode(diag.code) ||
|
||||
isPerformanceCode(diag.code) ||
|
||||
diag.code === "SC8004" ||
|
||||
isTypePatchCode(diag.code)
|
||||
? "warning"
|
||||
: "error",
|
||||
scope,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* longer than FRAME_MAX_WIDTH (minified sources) shows a window of that
|
||||
* width around the diagnostic's column, with "…" where text is cut.
|
||||
*/
|
||||
import type { ScrDiagnostic } from "./diagnostic.js";
|
||||
import { isTypePatchCode, type ScrDiagnostic } from "./diagnostic.js";
|
||||
|
||||
export interface RenderOptions {
|
||||
color?: boolean;
|
||||
@@ -157,7 +157,20 @@ export function renderDiagnostics(
|
||||
* settings in the project's scriptc configuration (SC8004), which builds do
|
||||
* not use. */
|
||||
export function isWarningCode(code: string): boolean {
|
||||
return /^SC6\d{3}$/.test(code) || code === "SC8004";
|
||||
return /^SC6\d{3}$/.test(code) || code === "SC8004" || isTypePatchCode(code);
|
||||
}
|
||||
|
||||
/** A build's type patch warnings (SC8030–SC8033). */
|
||||
export function renderTypePatchWarnings(
|
||||
diags: readonly ScrDiagnostic[],
|
||||
sourceTextByFile: Map<string, string>,
|
||||
opts: RenderOptions = {},
|
||||
): string {
|
||||
const n = diags.length;
|
||||
return (
|
||||
renderDiagnostics([...diags], sourceTextByFile, opts) +
|
||||
`\n\n${n} warning${n === 1 ? "" : "s"}: ${n === 1 ? "this type patch" : "these type patches"} did not apply as written; the build uses the package's own declarations there.`
|
||||
);
|
||||
}
|
||||
|
||||
/** A build's SC8004 warnings: the unknown scriptc settings it ignored. */
|
||||
|
||||
@@ -58,7 +58,33 @@ export function arrayNumberRead(arr: IrExpr, index: IrExpr, loc: SrcLoc): IrExpr
|
||||
};
|
||||
}
|
||||
|
||||
/** Read one stabilized receiver/index pair. Both a hole and present undefined yield undefined. */
|
||||
/** A foreign element read into a typed result: it never fits, so the read
|
||||
* throws the checked conversion's TypeError (array.foreignThrow). */
|
||||
export function foreignElementThrow(
|
||||
lowerer: Lowerer,
|
||||
arr: IrExpr,
|
||||
index: IrExpr,
|
||||
type: IrType,
|
||||
loc: SrcLoc,
|
||||
): IrExpr {
|
||||
return {
|
||||
kind: "libCall",
|
||||
fn: "array.foreignThrow",
|
||||
args: [arr, index, { kind: "strLit", value: lowerer.fmt(type), type: STRING, loc }],
|
||||
type,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
/** Arrays of this element type can hold elements outside it, stored
|
||||
* through dynamic views (Lowerer.foreignElementKeys). */
|
||||
export function elementMayBeForeign(lowerer: Lowerer, elem: IrType): boolean {
|
||||
const keys = lowerer.foreignElementKeys;
|
||||
return keys.size > 0 && elem.kind !== "dyn" && (keys.has("*") || keys.has(typeKey(elem)));
|
||||
}
|
||||
|
||||
/** Read one stabilized receiver/index pair. Both a hole and present undefined yield undefined;
|
||||
* a foreign element (state 3) takes the checked conversion into the typed result. */
|
||||
export function arrayValueRead(
|
||||
lowerer: Lowerer,
|
||||
arr: IrExpr,
|
||||
@@ -68,8 +94,25 @@ export function arrayValueRead(
|
||||
): IrExpr {
|
||||
const type = arrayValueType(lowerer, elem);
|
||||
const read: IrExpr = { kind: "arrayGet", arr, index, type: elem, loc };
|
||||
const missing: IrExpr =
|
||||
let missing: IrExpr =
|
||||
type.kind === "dyn" ? dynUndefinedExpr(loc) : lowerer.wrappedUndefined(type, loc)!;
|
||||
if (elementMayBeForeign(lowerer, elem)) {
|
||||
missing = {
|
||||
kind: "ternary",
|
||||
cond: {
|
||||
kind: "bin",
|
||||
op: "===",
|
||||
left: { kind: "arrayState", arr, index, type: F64, loc },
|
||||
right: { kind: "numLit", value: 3, type: F64, loc },
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: foreignElementThrow(lowerer, arr, index, type, loc),
|
||||
else_: missing,
|
||||
type,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "ternary",
|
||||
cond: {
|
||||
@@ -684,6 +727,9 @@ export function tryLowerNumericIndexRead(
|
||||
operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(F64)}`)
|
||||
)
|
||||
return null;
|
||||
// ToNumber of a foreign element differs from its own value under `+`,
|
||||
// comparisons and ToBoolean.
|
||||
if (elementMayBeForeign(lowerer, F64)) return null;
|
||||
const [arr, index] = operand.args;
|
||||
if (
|
||||
operand.args.length !== 2 ||
|
||||
@@ -777,6 +823,8 @@ export function tryFuseIndexRead(
|
||||
if (arr?.type.kind !== "array" || index?.type.kind !== "f64") return null;
|
||||
const elem = arr.type.elem;
|
||||
if (elem.kind === "union" || elem.kind === "void") return null;
|
||||
// A foreign element is neither the present value nor the missing one.
|
||||
if (elementMayBeForeign(lowerer, elem)) return null;
|
||||
if (operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(elem)}`)) return null;
|
||||
if (arr.kind !== "varRef" || (index.kind !== "varRef" && index.kind !== "numLit")) return null;
|
||||
const loc = operand.loc;
|
||||
|
||||
@@ -12,7 +12,10 @@ export class CoercionState {
|
||||
// Provenance for checked extraction; consumers must not infer it from names.
|
||||
readonly checkedNarrows = new Set<string>();
|
||||
// The extracted arm of each checked (non-defaulting) extraction helper.
|
||||
readonly checkedNarrowArms = new Map<string, { unionId: string; tag: number; target: IrType }>();
|
||||
readonly checkedNarrowArms = new Map<
|
||||
string,
|
||||
{ unionId: string; tag: number; target: IrType; message?: string }
|
||||
>();
|
||||
readonly retags = new Map<string, string>();
|
||||
readonly narrows = new Map<string, string>();
|
||||
}
|
||||
|
||||
@@ -394,18 +394,21 @@ export function unionRetagHelper(
|
||||
* the catchable TypeError — divergence 38's lying-assertion stance (an
|
||||
* unchecked unionNarrow would misread the payload where JS lets the
|
||||
* impossible value flow on). Null when the target isn't a non-unit arm
|
||||
* of the union — those uses keep their erasure/fences. */
|
||||
* of the union — those uses keep their erasure/fences. `message` replaces
|
||||
* the failure message (the TypeError of the operation consuming the
|
||||
* value, such as a call's "f is not a function"). */
|
||||
export function narrowedArmHelper(
|
||||
lowerer: Lowerer,
|
||||
fromId: string,
|
||||
target: IrType,
|
||||
loc: SrcLoc,
|
||||
message?: string,
|
||||
): string | null {
|
||||
const from = lowerer.unions.get(fromId);
|
||||
if (!from || isUnitType(target)) return null;
|
||||
const tag = lowerer.armTag(fromId, target);
|
||||
if (tag < 0) return null;
|
||||
const key = `${fromId}:${tag}`;
|
||||
const key = message === undefined ? `${fromId}:${tag}` : `${fromId}:${tag}:${message}`;
|
||||
const existing = lowerer.coercions.narrows.get(key);
|
||||
if (existing) {
|
||||
lowerer.coercions.checkedNarrows.add(existing);
|
||||
@@ -414,9 +417,22 @@ export function narrowedArmHelper(
|
||||
const name = keyedHelperName(lowerer, "%union.narrow", key);
|
||||
lowerer.coercions.narrows.set(key, name);
|
||||
lowerer.coercions.checkedNarrows.add(name);
|
||||
const fn = buildUnionNarrow(name, from, target, loc, (type) => lowerer.fmt(type));
|
||||
const fn = buildUnionNarrow(
|
||||
name,
|
||||
from,
|
||||
target,
|
||||
loc,
|
||||
(type) => lowerer.fmt(type),
|
||||
null,
|
||||
message ?? null,
|
||||
);
|
||||
if (!fn) throw new InternalCompilerError("lowerer bug: invalid checked union extraction");
|
||||
lowerer.coercions.checkedNarrowArms.set(name, { unionId: fromId, tag, target: from.arms[tag]! });
|
||||
lowerer.coercions.checkedNarrowArms.set(name, {
|
||||
unionId: fromId,
|
||||
tag,
|
||||
target: from.arms[tag]!,
|
||||
...(message === undefined ? {} : { message }),
|
||||
});
|
||||
lowerer.liftedFns.push(fn);
|
||||
return name;
|
||||
}
|
||||
@@ -458,7 +474,8 @@ export function checkedNarrowCall(
|
||||
let failure: IrExpr | null = null;
|
||||
for (let tag = from.arms.length - 1; tag >= 0; tag--) {
|
||||
if (tag === arm.tag) continue;
|
||||
const message = unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
|
||||
const message =
|
||||
arm.message ?? unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
|
||||
const thrown = nodeThrowExpr(1, "", message, type, loc);
|
||||
failure =
|
||||
failure === null
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
arrayValueStore,
|
||||
arrayValueType,
|
||||
currentArrayIndexPresent,
|
||||
elementMayBeForeign,
|
||||
foreignElementThrow,
|
||||
primitiveElement,
|
||||
} from "../array-values.js";
|
||||
import { typeKey } from "../../type-mapper.js";
|
||||
@@ -2531,6 +2533,9 @@ export function lowerArraySortCall(
|
||||
loc,
|
||||
native,
|
||||
keyed && { order, key: reads !== null, keys: keyed.length },
|
||||
elementMayBeForeign(lowerer, elem)
|
||||
? (arr, index) => foreignElementThrow(lowerer, arr, index, elem, loc)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyedHelperName } from "../stable-names.js";
|
||||
import { identityPreservingWidening } from "../coercions/identity.js";
|
||||
import { elementMayBeForeign, foreignElementThrow } from "../array-values.js";
|
||||
import { countedFor, numLit, varRef } from "../../../ir/build.js";
|
||||
import {
|
||||
BOOL,
|
||||
@@ -131,7 +132,33 @@ export function arrayConcatHelper(
|
||||
loc,
|
||||
},
|
||||
then: [{ kind: "arraySetUndefined", arr: out, index: destination, loc }],
|
||||
else_: null,
|
||||
else_: elementMayBeForeign(lowerer, sourceElem)
|
||||
? [
|
||||
{
|
||||
// A foreign element takes the checked conversion's TypeError.
|
||||
kind: "if",
|
||||
cond: {
|
||||
kind: "bin",
|
||||
op: "===",
|
||||
left: { kind: "arrayState", arr: source, index, type: F64, loc },
|
||||
right: numLit(3, loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: [
|
||||
{
|
||||
kind: "arraySet",
|
||||
arr: out,
|
||||
index: destination,
|
||||
value: foreignElementThrow(lowerer, source, index, elem, loc),
|
||||
loc,
|
||||
},
|
||||
],
|
||||
else_: null,
|
||||
loc,
|
||||
},
|
||||
]
|
||||
: null,
|
||||
loc,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1081,9 +1081,7 @@ export function lowerArrayFromCall(
|
||||
if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) {
|
||||
const source = lowerer.lowerExpr(args[0]!);
|
||||
let src =
|
||||
source.type.kind === "string" && args.length === 2
|
||||
? strCharsCall(lowerer, source, loc)
|
||||
: source;
|
||||
source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source;
|
||||
if (src.type.kind === "array") {
|
||||
const arrT = src.type;
|
||||
if (args.length === 1) {
|
||||
@@ -1171,7 +1169,7 @@ export function lowerArrayFromCall(
|
||||
}
|
||||
// String iteration advances by code point; the same helper serves
|
||||
// `[...s]` and both Array.from(s) forms.
|
||||
if (src.type.kind === "string" && args.length === 1) return strCharsCall(lowerer, src, loc);
|
||||
if (src.type.kind === "string" && args.length === 1) return strCharsCall(src, loc);
|
||||
if (src.type.kind === "dyn" && args.length === 1) {
|
||||
return lowerCheckedArrayFrom(lowerer, src, loc);
|
||||
}
|
||||
@@ -1318,112 +1316,9 @@ function buildArrayFromArrayFn(
|
||||
}
|
||||
|
||||
/** `Array.from(s)` / `[...s]` on a STRING: the code-point split into a
|
||||
* fresh string[], through one interned helper per module. */
|
||||
export function strCharsCall(lowerer: Lowerer, src: IrExpr, loc: SrcLoc): IrExpr {
|
||||
const key = "strChars";
|
||||
let helper = lowerer.arrHofHelpers.get(key);
|
||||
if (!helper) {
|
||||
helper = "%str.chars";
|
||||
lowerer.arrHofHelpers.set(key, helper);
|
||||
lowerer.liftedFns.push(buildStrCharsFn(helper, loc));
|
||||
}
|
||||
return { kind: "call", callee: helper, args: [src], type: arrayOf(STRING), loc };
|
||||
}
|
||||
|
||||
/** The code-point split, from existing IR nodes — the string for-of
|
||||
* desugar's UTF-16 cursor as a function:
|
||||
*
|
||||
* out = [];
|
||||
* i = 0;
|
||||
* while (i < s.length) { ch = cpAt(s, i); i += ch.length; out.push(ch); }
|
||||
* return out;
|
||||
*/
|
||||
function buildStrCharsFn(name: string, loc: SrcLoc): IrFunction {
|
||||
const outT = arrayOf(STRING);
|
||||
|
||||
const sLen = (recv: IrExpr): IrExpr => ({
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
receiver: recv,
|
||||
args: [],
|
||||
type: F64,
|
||||
loc,
|
||||
});
|
||||
const body: IrStmt[] = [
|
||||
{
|
||||
kind: "varDecl",
|
||||
localId: "out.0",
|
||||
init: { kind: "arrayLit", elems: [], type: outT, loc },
|
||||
loc,
|
||||
},
|
||||
{ kind: "varDecl", localId: "i.0", init: { kind: "numLit", value: 0, type: F64, loc }, loc },
|
||||
{
|
||||
kind: "while",
|
||||
cond: {
|
||||
kind: "bin",
|
||||
op: "<",
|
||||
left: varRef("i.0", F64, loc),
|
||||
right: sLen(varRef("s.0", STRING, loc)),
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
body: [
|
||||
{
|
||||
kind: "varDecl",
|
||||
localId: "ch.0",
|
||||
init: {
|
||||
kind: "strIntrinsic",
|
||||
method: "cpAt",
|
||||
receiver: varRef("s.0", STRING, loc),
|
||||
args: [varRef("i.0", F64, loc)],
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
{
|
||||
kind: "assign",
|
||||
localId: "i.0",
|
||||
value: {
|
||||
kind: "bin",
|
||||
op: "+",
|
||||
left: varRef("i.0", F64, loc),
|
||||
right: sLen(varRef("ch.0", STRING, loc)),
|
||||
type: F64,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
{
|
||||
kind: "exprStmt",
|
||||
expr: {
|
||||
kind: "arrIntrinsic",
|
||||
method: "push",
|
||||
receiver: varRef("out.0", outT, loc),
|
||||
args: [varRef("ch.0", STRING, loc)],
|
||||
type: F64,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
},
|
||||
{ kind: "return", value: varRef("out.0", outT, loc), loc },
|
||||
];
|
||||
return {
|
||||
name,
|
||||
params: [{ localId: "s.0", name: "s", type: STRING }],
|
||||
returnType: outT,
|
||||
locals: [
|
||||
{ id: "s.0", name: "s", type: STRING, mutable: true },
|
||||
{ id: "out.0", name: "out", type: outT, mutable: false },
|
||||
{ id: "i.0", name: "i", type: F64, mutable: true },
|
||||
{ id: "ch.0", name: "ch", type: STRING, mutable: false },
|
||||
],
|
||||
body,
|
||||
loc,
|
||||
};
|
||||
* fresh string[] (scr_str_code_points). */
|
||||
export function strCharsCall(src: IrExpr, loc: SrcLoc): IrExpr {
|
||||
return { kind: "libCall", fn: "str.codePoints", args: [src], type: arrayOf(STRING), loc };
|
||||
}
|
||||
|
||||
/** The lowered `length` value of the one-property source literal, or null
|
||||
|
||||
@@ -1886,17 +1886,27 @@ function arraySearchHelper(
|
||||
},
|
||||
loc,
|
||||
};
|
||||
const visitPresent: IrStmt[] = [
|
||||
{ kind: "varDecl", localId: "value.0", init: value, loc },
|
||||
{
|
||||
kind: "if",
|
||||
cond: match,
|
||||
then: [{ kind: "return", value: method === "includes" ? boolLit(true, loc) : indexRef, loc }],
|
||||
else_: null,
|
||||
loc,
|
||||
},
|
||||
advance,
|
||||
];
|
||||
const found: IrStmt = {
|
||||
kind: "if",
|
||||
cond: directNeedle ? equal : match,
|
||||
then: [{ kind: "return", value: method === "includes" ? boolLit(true, loc) : indexRef, loc }],
|
||||
else_: null,
|
||||
loc,
|
||||
};
|
||||
// A direct needle reads only value slots: an undefined or foreign
|
||||
// element written through a dynamic view has no element-typed payload.
|
||||
const visitPresent: IrStmt[] = directNeedle
|
||||
? [
|
||||
{
|
||||
kind: "if",
|
||||
cond: { kind: "bin", op: "===", left: stateRef, right: numLit(1, loc), type: BOOL, loc },
|
||||
then: [{ kind: "varDecl", localId: "value.0", init: value, loc }, found],
|
||||
else_: null,
|
||||
loc,
|
||||
},
|
||||
advance,
|
||||
]
|
||||
: [{ kind: "varDecl", localId: "value.0", init: value, loc }, found, advance];
|
||||
const skipHole: IrStmt[] =
|
||||
method === "lastIndexOf"
|
||||
? [advance]
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
arrayValueRead,
|
||||
arrayValueStore,
|
||||
arrayValueType,
|
||||
elementMayBeForeign,
|
||||
} from "../array-values.js";
|
||||
import type { Lowerer } from "../lowerer.js";
|
||||
|
||||
@@ -156,7 +157,8 @@ const PURE_STRING = new Set<string>([
|
||||
"toUpperCase",
|
||||
"isWellFormed",
|
||||
"toWellFormed",
|
||||
"cpAt",
|
||||
"byteLength",
|
||||
"cpAtByte",
|
||||
]);
|
||||
|
||||
function primitive(t: IrType): boolean {
|
||||
@@ -1643,7 +1645,9 @@ class FusedLoop {
|
||||
];
|
||||
}
|
||||
const valueT = arrayValueType(this.lowerer, arrT.elem);
|
||||
if (stage.fast) {
|
||||
// A foreign element is neither the plain element nor undefined: the
|
||||
// ordinary read below converts it.
|
||||
if (stage.fast && !elementMayBeForeign(this.lowerer, arrT.elem)) {
|
||||
// The split visit: a value goes on as the plain element, a present
|
||||
// undefined as the undefined arm.
|
||||
const missing = this.lowerer.wrappedUndefined(valueT, loc);
|
||||
|
||||
@@ -1135,7 +1135,7 @@ function setFromSeedValue(
|
||||
if (source.type.kind === "set" && typeEquals(source.type, setT)) {
|
||||
return { kind: "setIntrinsic", method: "clone", receiver: source, args: [], type: setT, loc };
|
||||
} else if (source.type.kind === "string" && setT.elem.kind === "string") {
|
||||
seed = strCharsCall(lowerer, source, loc);
|
||||
seed = strCharsCall(source, loc);
|
||||
} else if (source.type.kind === "array" && typeEquals(source.type.elem, setT.elem)) {
|
||||
seed = source;
|
||||
} else if (
|
||||
|
||||
@@ -504,7 +504,7 @@ export function lowerArrayLiteral(
|
||||
// interned helper as Array.from(s); the result rides the array
|
||||
// machinery below like any string[] source.
|
||||
if (src.type.kind === "string") {
|
||||
src = strCharsCall(lowerer, src, locOf(el));
|
||||
src = strCharsCall(src, locOf(el));
|
||||
}
|
||||
// `[...typedArray]`: represented typed arrays are dense numeric
|
||||
// iterables, so drain their elements into the fresh number[] that
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
arrayValueRead,
|
||||
arrayValueStore,
|
||||
arrayValueType,
|
||||
elementMayBeForeign,
|
||||
lowerSafeBytesRead,
|
||||
lowerSafeIndexRead,
|
||||
primitiveElement,
|
||||
@@ -1623,11 +1624,14 @@ export function lowerElementCompoundParts(
|
||||
const indexRef = (): IrExpr => varRef(indexLocal.id, F64, loc);
|
||||
const oldValue: IrExpr =
|
||||
receiver.type.kind === "array"
|
||||
? receiver.type.elem.kind === "f64"
|
||||
? receiver.type.elem.kind === "f64" &&
|
||||
!(op === "+" && elementMayBeForeign(lowerer, receiver.type.elem))
|
||||
? // Every numeric operator applies ToNumber to the old value, so a
|
||||
// missing element is NaN without materializing `number | undefined`.
|
||||
arrayNumberRead(receiverRef(), indexRef(), locOf(target))
|
||||
: receiver.type.elem.kind === "string" && op === "+"
|
||||
: receiver.type.elem.kind === "string" &&
|
||||
op === "+" &&
|
||||
!elementMayBeForeign(lowerer, receiver.type.elem)
|
||||
? arrayStringOperand(receiverRef(), indexRef(), locOf(target))
|
||||
: arrayValueRead(lowerer, receiverRef(), indexRef(), receiver.type.elem, locOf(target))
|
||||
: {
|
||||
|
||||
@@ -875,8 +875,15 @@ function checkedStaleArm(
|
||||
|
||||
/** V8's spelling of a callee in "is not a function": a string-literal
|
||||
* element key reads as a property name (`o["a"]` is `o.a`), other keys
|
||||
* keep their brackets. */
|
||||
* keep their brackets, parentheses and non-null assertions disappear, and
|
||||
* a call result reads `f(...)`. */
|
||||
export function v8CalleeText(node: ts.Node): string {
|
||||
// `!` is type-only syntax: the running JavaScript has no trace of it.
|
||||
if (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node))
|
||||
return v8CalleeText(node.expression);
|
||||
// A call result prints with its arguments elided.
|
||||
if (ts.isCallExpression(node) && !node.questionDotToken)
|
||||
return `${v8CalleeText(node.expression)}(...)`;
|
||||
if (ts.isElementAccessExpression(node)) {
|
||||
const key = node.argumentExpression;
|
||||
const object = v8CalleeText(node.expression);
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
import { varRef } from "../../../ir/build.js";
|
||||
import { locOf } from "../../program.js";
|
||||
import type { Lowerer } from "../lowerer.js";
|
||||
import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-values.js";
|
||||
import {
|
||||
arrayNumberRead,
|
||||
arrayValueRead,
|
||||
arrayValueStore,
|
||||
elementMayBeForeign,
|
||||
} from "../array-values.js";
|
||||
import { fenceNodeModuleMutation } from "../lower-node-module.js";
|
||||
import { globalSymbolKey } from "./global-symbols.js";
|
||||
import { tryLowerExpression } from "./try-lower-expression.js";
|
||||
@@ -185,7 +190,12 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp
|
||||
const element = storage;
|
||||
write = (value) => arrayValueStore(lowerer, receiver, key, value, element, loc);
|
||||
const elementLoc = locOf(target);
|
||||
if (nullish && NON_NULLISH_ELEMENTS.has(element.kind)) {
|
||||
// A foreign element can be any value, nullish or not.
|
||||
if (
|
||||
nullish &&
|
||||
NON_NULLISH_ELEMENTS.has(element.kind) &&
|
||||
!elementMayBeForeign(lowerer, element)
|
||||
) {
|
||||
// Only a missing element is nullish: keep the present value or
|
||||
// store the right side, without the optional element value.
|
||||
rhs = lowerer.lowerExprExpecting(expr.right, element);
|
||||
|
||||
@@ -577,10 +577,11 @@ export function lowerBuiltinDelegation(
|
||||
loc,
|
||||
});
|
||||
} else if (sourceT.kind === "string") {
|
||||
// A byte cursor over the WTF-8 data, as for-of over a string walks.
|
||||
const ch = lowerer.declareHiddenLocal("%delegateElement", STRING);
|
||||
const length = (receiver: IrExpr): IrExpr => ({
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
method: "byteLength",
|
||||
receiver,
|
||||
args: [],
|
||||
type: F64,
|
||||
@@ -595,7 +596,7 @@ export function lowerBuiltinDelegation(
|
||||
localId: ch.id,
|
||||
init: {
|
||||
kind: "strIntrinsic",
|
||||
method: "cpAt",
|
||||
method: "cpAtByte",
|
||||
receiver: src(),
|
||||
args: [at()],
|
||||
type: STRING,
|
||||
|
||||
@@ -743,7 +743,7 @@ export function lowerStringIteratorValue(
|
||||
left: index,
|
||||
right: {
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
method: "byteLength",
|
||||
receiver: text,
|
||||
args: [],
|
||||
type: F64,
|
||||
@@ -758,7 +758,7 @@ export function lowerStringIteratorValue(
|
||||
localId: "point.0",
|
||||
init: {
|
||||
kind: "strIntrinsic",
|
||||
method: "cpAt",
|
||||
method: "cpAtByte",
|
||||
receiver: text,
|
||||
args: [index],
|
||||
type: STRING,
|
||||
@@ -775,7 +775,7 @@ export function lowerStringIteratorValue(
|
||||
left: index,
|
||||
right: {
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
method: "byteLength",
|
||||
receiver: point,
|
||||
args: [],
|
||||
type: F64,
|
||||
|
||||
@@ -926,6 +926,10 @@ export function buildArraySortFn(
|
||||
loc: SrcLoc,
|
||||
native = false,
|
||||
keyed: { order: number; key: boolean; keys: number } | null = null,
|
||||
/** The throw for a foreign element (SCR_ARR_FOREIGN) at `index` of
|
||||
* `arr`, which the comparator's element type cannot hold; for element
|
||||
* types whose arrays can hold one. */
|
||||
checkForeign: ((arr: IrExpr, index: IrExpr) => IrExpr) | null = null,
|
||||
): IrFunction {
|
||||
const b = new SortIr(loc);
|
||||
const arrT = arrayOf(elem),
|
||||
@@ -1035,7 +1039,21 @@ export function buildArraySortFn(
|
||||
b.branch(
|
||||
b.eq(state, b.num(1)),
|
||||
[compactValue()],
|
||||
[b.branch(b.eq(state, b.num(2)), [b.step(undefinedCount)])],
|
||||
[
|
||||
b.branch(
|
||||
b.eq(state, b.num(2)),
|
||||
[b.step(undefinedCount)],
|
||||
checkForeign
|
||||
? [
|
||||
// A foreign element does not fit the comparator's element
|
||||
// type: the checked conversion's TypeError.
|
||||
b.branch(b.eq(state, b.num(3)), [
|
||||
{ kind: "exprStmt", expr: checkForeign(src, index), loc },
|
||||
]),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
// Sort only the compacted prefix. The tail is overwritten with undefined
|
||||
|
||||
@@ -89,7 +89,7 @@ import { classSymbolKeyOf } from "./symbol-fields.js";
|
||||
import { lowerTypeofExpr } from "./expressions/typeof.js";
|
||||
import { lowerIdentifierExpr } from "./expressions/identifiers.js";
|
||||
import { lowerFieldCompoundValue, lowerIncDec } from "./expressions/updates.js";
|
||||
import { lowerTernary } from "./expressions/narrowing.js";
|
||||
import { lowerTernary, v8CalleeText } from "./expressions/narrowing.js";
|
||||
import {
|
||||
classFieldTarget,
|
||||
lowerPropertyAccessExpr,
|
||||
@@ -433,7 +433,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
const operand = assertionOperand(lowerer, expr, inner, target ?? null, loc);
|
||||
if (operand) return operand;
|
||||
if (target && target.kind !== "union" && !typeEquals(target, inner.type)) {
|
||||
const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc);
|
||||
const helper = lowerer.narrowedArmHelper(
|
||||
inner.type.unionId,
|
||||
target,
|
||||
loc,
|
||||
assertedCalleeMessage(expr, target),
|
||||
);
|
||||
if (helper) {
|
||||
return lowerer.checkedNarrowCall(helper, inner, target, loc);
|
||||
}
|
||||
@@ -1160,6 +1165,19 @@ const NUMERIC_COMPOUNDS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
||||
]);
|
||||
|
||||
/** The TypeError Node raises when an asserted callee (`t[k]!(…)`, `o.f!()`)
|
||||
* holds no function: `!` is erased, so the call itself throws "<callee> is
|
||||
* not a function" in V8's spelling of the callee. */
|
||||
function assertedCalleeMessage(expr: ts.NonNullExpression, target: IrType): string | undefined {
|
||||
if (target.kind !== "func") return undefined;
|
||||
let child: ts.Expression = expr;
|
||||
while (ts.isParenthesizedExpression(child.parent)) child = child.parent;
|
||||
const call = child.parent;
|
||||
if (!ts.isCallExpression(call) || call.expression !== child || call.questionDotToken)
|
||||
return undefined;
|
||||
return `${v8CalleeText(expr.expression)} is not a function`;
|
||||
}
|
||||
|
||||
/** Whether the use of `t[i]!` tells undefined from NaN: a converting use
|
||||
* that takes the value itself (see convertingUseOf), or a destination that
|
||||
* holds a missing element (see assertedElementKeepsUndefined) other than a
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyedHelperName } from "./stable-names.js";
|
||||
import { perKeyOrder } from "./key-order-witness.js";
|
||||
import { elementMayBeForeign } from "./array-values.js";
|
||||
import { classPrototypeView } from "./class-prototypes.js";
|
||||
import {
|
||||
dynUndefinedExpr,
|
||||
@@ -1421,11 +1422,29 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
|
||||
const i = (): IrExpr => varRef("i.0", F64, loc);
|
||||
const next = (): IrExpr => varRef("next.0", F64, loc);
|
||||
const hasMore = (): IrExpr => lt(i(), n());
|
||||
// A foreign element (state 3) holds a value outside the element type.
|
||||
const foreignElements = elementMayBeForeign(lowerer, t.elem);
|
||||
const foreign = (): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "array.foreign",
|
||||
args: [v(), i()],
|
||||
type: DYN,
|
||||
loc,
|
||||
});
|
||||
const isNumber = (): IrExpr => ({
|
||||
kind: "ternary",
|
||||
cond: eq(state(i()), numLit(1, loc)),
|
||||
then: isNumberFlag(lowerer, t.elem, () => at(i()), loc),
|
||||
else_: boolLit(false, loc),
|
||||
else_: foreignElements
|
||||
? {
|
||||
kind: "ternary",
|
||||
cond: eq(state(i()), numLit(3, loc)),
|
||||
then: isNumberFlag(lowerer, DYN, foreign, loc),
|
||||
else_: boolLit(false, loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
}
|
||||
: boolLit(false, loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
});
|
||||
@@ -1504,7 +1523,16 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
|
||||
kind: "ternary",
|
||||
cond: eq(state(i()), numLit(1, loc)),
|
||||
then: child(t.elem, at(i())),
|
||||
else_: strLit("undefined", loc),
|
||||
else_: foreignElements
|
||||
? {
|
||||
kind: "ternary",
|
||||
cond: eq(state(i()), numLit(3, loc)),
|
||||
then: child(DYN, foreign()),
|
||||
else_: strLit("undefined", loc),
|
||||
type: STRING,
|
||||
loc,
|
||||
}
|
||||
: strLit("undefined", loc),
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
|
||||
@@ -2425,7 +2425,9 @@ function lowerNetSocketMethodCall(
|
||||
};
|
||||
}
|
||||
if (name === "write" || name === "end") {
|
||||
requireStatementPosition(lowerer, call, `socket.${name}(...)`);
|
||||
// write() answers Node's backpressure boolean; end() stays a statement
|
||||
if (name === "end") requireStatementPosition(lowerer, call, `socket.${name}(...)`);
|
||||
const resultT = name === "write" ? BOOL : VOID;
|
||||
// write(chunk, encoding) — the two-argument encoding form: 'buffer'
|
||||
// beside a string chunk is Writable's encoding check (a synchronous
|
||||
// ERR_UNKNOWN_ENCODING TypeError, "Unknown encoding: buffer" — the
|
||||
@@ -2451,7 +2453,7 @@ function lowerNetSocketMethodCall(
|
||||
const socket = coerceToHandle(lowerer, access.expression, NETSOCKET_T);
|
||||
const chunk = lowerer.lowerExpr(args[0]!);
|
||||
const fn: IrLibFn = chunk.type.kind === "string" ? "net.sockWrite" : "net.sockWriteBytes";
|
||||
return { kind: "libCall", fn, args: [socket, chunk], type: VOID, loc };
|
||||
return { kind: "libCall", fn, args: [socket, chunk], type: BOOL, loc };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2510,7 +2512,7 @@ function lowerNetSocketMethodCall(
|
||||
NARROW_DATA_HINT,
|
||||
);
|
||||
}
|
||||
main = { kind: "libCall", fn, args: [receiver(), data], type: VOID, loc };
|
||||
main = { kind: "libCall", fn, args: [receiver(), data], type: resultT, loc };
|
||||
}
|
||||
const cb = lowerer.lowerExpr(last);
|
||||
let register: IrExpr;
|
||||
@@ -2531,8 +2533,24 @@ function lowerNetSocketMethodCall(
|
||||
loc,
|
||||
};
|
||||
}
|
||||
stmts.push({ kind: "exprStmt", expr: main, loc });
|
||||
return { kind: "seqExpr", stmts, result: register, type: VOID, loc };
|
||||
if (resultT.kind === "void") {
|
||||
stmts.push({ kind: "exprStmt", expr: main, loc });
|
||||
return { kind: "seqExpr", stmts, result: register, type: VOID, loc };
|
||||
}
|
||||
// write(data, cb): the data is written, the callback registered, and
|
||||
// the write's answer is the value
|
||||
const okLocal = lowerer.declareHiddenLocal("%sockWriteOk", BOOL);
|
||||
stmts.push(
|
||||
{ kind: "varDecl", localId: okLocal.id, init: main, loc },
|
||||
{ kind: "exprStmt", expr: register, loc },
|
||||
);
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts,
|
||||
result: { kind: "varRef", localId: okLocal.id, type: BOOL, loc },
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
const maxArgs = name === "write" ? 1 : 1;
|
||||
const minArgs = name === "write" ? 1 : 0;
|
||||
@@ -2552,17 +2570,17 @@ function lowerNetSocketMethodCall(
|
||||
const data = lowerer.lowerExpr(args[0]!);
|
||||
if (data.type.kind === "string") {
|
||||
const fn: IrLibFn = name === "write" ? "net.sockWrite" : "net.sockEndStr";
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc };
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc };
|
||||
}
|
||||
if (data.type.kind === "bytes" && data.type.elem === "u8") {
|
||||
const fn: IrLibFn = name === "write" ? "net.sockWriteBytes" : "net.sockEndBytes";
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc };
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc };
|
||||
}
|
||||
if (data.type.kind === "dyn") {
|
||||
// An untyped JS payload (a helper's parameter): the runtime
|
||||
// dispatches STR/BYTES and throws Node's chunk TypeError otherwise.
|
||||
const fn: IrLibFn = name === "write" ? "net.sockWriteDyn" : "net.sockEndDyn";
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc };
|
||||
return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc };
|
||||
}
|
||||
lowerer.noLowering(
|
||||
`${name} of '${lowerer.fmt(data.type)}' data`,
|
||||
@@ -2944,6 +2962,7 @@ function lowerNetSocketMethodCall(
|
||||
event === "connect" ||
|
||||
event === "ready" ||
|
||||
event === "timeout" ||
|
||||
event === "drain" ||
|
||||
event === "readable"
|
||||
) {
|
||||
const { cb } = lowerCallbackArg(
|
||||
@@ -2962,11 +2981,13 @@ function lowerNetSocketMethodCall(
|
||||
? "net.sockOnClose"
|
||||
: event === "timeout"
|
||||
? "net.sockOnTimeout"
|
||||
: event === "readable"
|
||||
? "net.sockOnReadable"
|
||||
: event === "ready"
|
||||
? "net.sockOnReady"
|
||||
: "net.sockOnConnect";
|
||||
: event === "drain"
|
||||
? "net.sockOnDrain"
|
||||
: event === "readable"
|
||||
? "net.sockOnReadable"
|
||||
: event === "ready"
|
||||
? "net.sockOnReady"
|
||||
: "net.sockOnConnect";
|
||||
return { kind: "libCall", fn, args: [receiver, cb, once], type: VOID, loc };
|
||||
}
|
||||
if (event === "lookup") {
|
||||
@@ -3957,6 +3978,18 @@ export function lowerServerProperty(
|
||||
const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T);
|
||||
return { kind: "libCall", fn: "net.sockBytesWritten", args: [receiver], type: F64, loc };
|
||||
}
|
||||
if (
|
||||
recvKind === "netSocket" &&
|
||||
lowerer.isStdlibMember(expr) &&
|
||||
(expr.name.text === "writableLength" || expr.name.text === "writableNeedDrain")
|
||||
) {
|
||||
// The write buffer's unsent bytes, and whether a write answered false
|
||||
// and 'drain' has not fired yet.
|
||||
const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T);
|
||||
return expr.name.text === "writableLength"
|
||||
? { kind: "libCall", fn: "net.sockWritableLength", args: [receiver], type: F64, loc }
|
||||
: { kind: "libCall", fn: "net.sockWritableNeedDrain", args: [receiver], type: BOOL, loc };
|
||||
}
|
||||
if (recvKind === "netSocket" && lowerer.isStdlibMember(expr) && expr.name.text === "readable") {
|
||||
// true until the read half is done (peer FIN / destroy).
|
||||
const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T);
|
||||
|
||||
@@ -2668,7 +2668,7 @@ export function lowerBindingPattern(
|
||||
lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts");
|
||||
const loc = locOf(el);
|
||||
let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc, (text) =>
|
||||
strCharsCall(lowerer, text, loc),
|
||||
strCharsCall(text, loc),
|
||||
);
|
||||
if (!value)
|
||||
lowerer.unsupported(
|
||||
@@ -2798,7 +2798,7 @@ export function lowerBindingPattern(
|
||||
}
|
||||
// STRING sources (`const [a, b] = str`): array destructuring walks
|
||||
// the STRING ITERATOR — code points, astral characters whole —
|
||||
// which is Array.from(string)'s own machinery (%str.chars), so the
|
||||
// which is Array.from(string)'s own machinery (str.codePoints), so the
|
||||
// source splits once into a hidden chars array and the pattern
|
||||
// lowers as an array pattern over string[]. Positions, holes,
|
||||
// defaults (with the bounds test), rest (the remaining code points
|
||||
@@ -2817,7 +2817,7 @@ export function lowerBindingPattern(
|
||||
out.push({
|
||||
kind: "varDecl",
|
||||
localId: chars.id,
|
||||
init: strCharsCall(lowerer, srcRef(), loc),
|
||||
init: strCharsCall(srcRef(), loc),
|
||||
loc,
|
||||
});
|
||||
lowerer.lowerBindingPattern(
|
||||
@@ -9826,7 +9826,7 @@ function lowerArrayAssignInto(
|
||||
let srcType = tmpRef().type;
|
||||
// A STRING source (`[a, b] = s`): the declaration path's rule in
|
||||
// assignment position — the string iterator's code-point split
|
||||
// (%str.chars, astral characters whole) into a hidden chars array,
|
||||
// (str.codePoints, astral characters whole) into a hidden chars array,
|
||||
// then the pattern proceeds as an array pattern over string[]
|
||||
// (defaults with the bounds test, rest packing the remaining code
|
||||
// points, elisions; positions past the last code point inherit the
|
||||
@@ -9839,7 +9839,7 @@ function lowerArrayAssignInto(
|
||||
out.push({
|
||||
kind: "varDecl",
|
||||
localId: chars.id,
|
||||
init: strCharsCall(lowerer, tmpRef(), loc),
|
||||
init: strCharsCall(tmpRef(), loc),
|
||||
loc,
|
||||
});
|
||||
tmpRef = () => ({ kind: "varRef", localId: chars.id, type: charsT, loc });
|
||||
@@ -11383,16 +11383,18 @@ function lowerForOfMatchAll(
|
||||
/** `for (const ch of s)` over a STRING: JS's string iterator, which walks
|
||||
* code POINTS — each pass binds the whole character (a two-unit string
|
||||
* for astral chars, where charAt/slice would return one surrogate half).
|
||||
* Desugars over the UTF-16 cursor machinery: a hidden unit
|
||||
* index reads the character with the cpAt intrinsic and advances by ITS
|
||||
* length before the body runs (so `continue` never skips the advance):
|
||||
* Desugars over a byte cursor into the WTF-8 data: a hidden byte offset
|
||||
* reads the character that starts there with the cpAtByte intrinsic and
|
||||
* advances by ITS byte count before the body runs (so `continue` never
|
||||
* skips the advance):
|
||||
*
|
||||
* { const %s = <expr>; let %i = 0;
|
||||
* while (%i < %s.length) { const ch = cpAt(%s, %i); %i += ch.length; <body> } }
|
||||
* while (%i < byteLength(%s)) { const ch = cpAtByte(%s, %i); %i += byteLength(ch); <body> } }
|
||||
*
|
||||
* Strings are immutable, so the length is stable and there is no
|
||||
* Strings are immutable, so the byte count is stable and there is no
|
||||
* iterator state to unwind on throw; a lone surrogate is a one-unit
|
||||
* character, so every step lands on a character start. */
|
||||
* character with its own three-byte encoding, so every step lands on a
|
||||
* character start. */
|
||||
/** for-of over a CLASS ITERABLE (classIteratorOf's shape) — the
|
||||
* iterator protocol as ordinary method calls:
|
||||
*
|
||||
@@ -12087,7 +12089,7 @@ function lowerForOfString(
|
||||
try {
|
||||
const s = lowerer.declareHiddenLocal("%strof", STRING);
|
||||
const i = lowerer.declareHiddenLocal("%iterof", F64);
|
||||
i.mutable = true; // the unit index reassigns (hidden locals default const)
|
||||
i.mutable = true; // the byte cursor reassigns (hidden locals default const)
|
||||
const sRef = (): IrExpr => ({ kind: "varRef", localId: s.id, type: STRING, loc });
|
||||
const iRef = (): IrExpr => ({ kind: "varRef", localId: i.id, type: F64, loc });
|
||||
// `for (var c of str)`: the character mechanics stay on a hidden
|
||||
@@ -12118,7 +12120,7 @@ function lowerForOfString(
|
||||
localId: ch.id,
|
||||
init: {
|
||||
kind: "strIntrinsic",
|
||||
method: "cpAt",
|
||||
method: "cpAtByte",
|
||||
receiver: sRef(),
|
||||
args: [iRef()],
|
||||
type: STRING,
|
||||
@@ -12145,7 +12147,7 @@ function lowerForOfString(
|
||||
left: iRef(),
|
||||
right: {
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
method: "byteLength",
|
||||
receiver: chRef,
|
||||
args: [],
|
||||
type: F64,
|
||||
@@ -12177,7 +12179,7 @@ function lowerForOfString(
|
||||
left: iRef(),
|
||||
right: {
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
method: "byteLength",
|
||||
receiver: sRef(),
|
||||
args: [],
|
||||
type: F64,
|
||||
|
||||
@@ -2400,6 +2400,11 @@ export class Lowerer {
|
||||
/** for-of loops over arrays that can hold neither holes nor undefined:
|
||||
* each pass reads the element payload directly. */
|
||||
readonly presentElementLoops = new Set<ts.ForOfStatement>();
|
||||
/** Element keys (typeKey of the element type) of native arrays that can
|
||||
* hold elements outside their element type, stored through dynamic views
|
||||
* (SCR_ARR_FOREIGN); "*" for every array. Empty unless the program stores
|
||||
* values through a dynamic value that can view a native array. */
|
||||
foreignElementKeys: ReadonlySet<string> = new Set();
|
||||
/** The fs/promises glob() call lowerForOf is lowering as a for await
|
||||
* head; other glob() calls have no lowering. */
|
||||
fspGlobHead: ts.Node | null = null;
|
||||
@@ -6486,6 +6491,20 @@ export class Lowerer {
|
||||
let dynamicViews: Set<string> | null = null;
|
||||
// Element ABIs of the native arrays that an `any` or `unknown` value is
|
||||
// converted to, including arrays nested in the converted value.
|
||||
// Assignments, including the logical ones (`view ??= numbers`).
|
||||
const assignmentOperators = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.EqualsToken,
|
||||
ts.SyntaxKind.QuestionQuestionEqualsToken,
|
||||
ts.SyntaxKind.BarBarEqualsToken,
|
||||
ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
||||
]);
|
||||
// Initializers of class properties, parameter defaults and
|
||||
// destructuring defaults, which convert to the binding's type.
|
||||
const initializedBinding = (
|
||||
node: ts.Node,
|
||||
): node is ts.PropertyDeclaration | ts.ParameterDeclaration | ts.BindingElement =>
|
||||
(ts.isPropertyDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) &&
|
||||
node.initializer !== undefined;
|
||||
const collectDynamicViews = (): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
const dynamicValue = (node: ts.Expression): boolean => {
|
||||
@@ -6536,14 +6555,18 @@ export class Lowerer {
|
||||
dynamicValue(node.initializer)
|
||||
)
|
||||
target(typeOrNull(node.name));
|
||||
else if (initializedBinding(node) && dynamicValue(node.initializer!))
|
||||
target(typeOrNull(node.name ?? node));
|
||||
else if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
assignmentOperators.has(node.operatorToken.kind) &&
|
||||
dynamicValue(node.right)
|
||||
)
|
||||
target(typeOrNull(node.left));
|
||||
else if (ts.isReturnStatement(node) && node.expression && dynamicValue(node.expression))
|
||||
target(contextualOrNull(node.expression));
|
||||
else if (ts.isYieldExpression(node) && node.expression && dynamicValue(node.expression))
|
||||
target(contextualOrNull(node.expression));
|
||||
else if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
||||
for (const arg of node.arguments?.nodes ?? [])
|
||||
if (!ts.isSpreadElement(arg) && dynamicValue(arg)) target(contextualOrNull(arg));
|
||||
@@ -6572,6 +6595,237 @@ export class Lowerer {
|
||||
});
|
||||
return keys;
|
||||
};
|
||||
// Element ABIs of the native arrays a value converted to a dynamic type
|
||||
// can reach: the conversion keeps each such array as a live view, so
|
||||
// stores through the dynamic value write the native storage.
|
||||
// Standard library functions that only read the arguments they take as
|
||||
// `any` or `unknown`: the console methods, String, Number, Boolean,
|
||||
// Array.isArray, Object.is, JSON.stringify without a replacer function,
|
||||
// and util's inspect and format. Any other function can keep an
|
||||
// argument (Promise.resolve, Map.prototype.set, push onto an
|
||||
// `unknown[]`) or pass it to a callback (a JSON.stringify replacer, an
|
||||
// emitted event), which can write through it.
|
||||
const readerGlobals = new Map<string, ReadonlySet<string> | null>([
|
||||
["console", null],
|
||||
["JSON", new Set(["stringify"])],
|
||||
["Array", new Set(["isArray"])],
|
||||
["Object", new Set(["is"])],
|
||||
]);
|
||||
const readerFunctions = new Set(["String", "Number", "Boolean"]);
|
||||
const utilReaders = new Set(["inspect", "format"]);
|
||||
const standardLibraryReader = (node: ts.CallExpression | ts.NewExpression): boolean =>
|
||||
panicSafe(() => {
|
||||
if (!ts.isCallExpression(node)) return false;
|
||||
const callee = peel(node.expression);
|
||||
if (ts.isIdentifier(callee))
|
||||
return [...readerFunctions].some((name) => this.isStdlibGlobal(callee, name));
|
||||
if (!ts.isPropertyAccessExpression(callee)) return false;
|
||||
const member = callee.name.text;
|
||||
for (const [global, members] of readerGlobals) {
|
||||
if (!this.isStdlibGlobal(callee.expression, global)) continue;
|
||||
if (members !== null && !members.has(member)) return false;
|
||||
if (global !== "JSON") return true;
|
||||
const replacer = node.arguments.nodes[1];
|
||||
return (
|
||||
replacer === undefined ||
|
||||
ts.isArrayLiteralExpression(peel(replacer)) ||
|
||||
peel(replacer).kind === ts.SyntaxKind.NullKeyword
|
||||
);
|
||||
}
|
||||
if (!utilReaders.has(member)) return false;
|
||||
const declaration = this.checker.getResolvedSignature(node)?.declaration;
|
||||
if (declaration === undefined) return false;
|
||||
const file = ts.getSourceFileOfNode(declaration);
|
||||
return this.isStdlibFile(file) && /[\\/]util\.d\.ts$/.test(file.fileName);
|
||||
}, false);
|
||||
const collectNativeViews = (): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const collect = (type: IrType | null): void => {
|
||||
if (!type) return;
|
||||
const id = typeKey(type);
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
if (type.kind === "array") {
|
||||
if (type.elem.kind !== "dyn") keys.add(typeKey(type.elem));
|
||||
collect(type.elem);
|
||||
} else if (type.kind === "union") {
|
||||
for (const arm of this.unions.get(type.unionId)?.arms ?? []) collect(arm);
|
||||
} else if (type.kind === "record") {
|
||||
for (const field of this.shapes.get(type.shapeId)?.fields ?? []) collect(field.type);
|
||||
} else if (type.kind === "object") {
|
||||
for (const field of this.classes.get(type.className)?.fields.values() ?? [])
|
||||
collect(field);
|
||||
} else if (type.kind === "map") {
|
||||
collect(type.key);
|
||||
collect(type.value);
|
||||
} else if (type.kind === "set") collect(type.elem);
|
||||
else if (type.kind === "promise") collect(type.inner);
|
||||
};
|
||||
const holdsDynamic = (type: IrType | null, depth: number): boolean => {
|
||||
if (!type || depth > 4) return false;
|
||||
if (type.kind === "dyn") return true;
|
||||
if (type.kind === "array" || type.kind === "set") return holdsDynamic(type.elem, depth + 1);
|
||||
if (type.kind === "map") return holdsDynamic(type.value, depth + 1);
|
||||
if (type.kind === "promise") return holdsDynamic(type.inner, depth + 1);
|
||||
if (type.kind === "union")
|
||||
return (this.unions.get(type.unionId)?.arms ?? []).some((arm) =>
|
||||
holdsDynamic(arm, depth + 1),
|
||||
);
|
||||
if (type.kind === "record")
|
||||
return (this.shapes.get(type.shapeId)?.fields ?? []).some((field) =>
|
||||
holdsDynamic(field.type, depth + 1),
|
||||
);
|
||||
return false;
|
||||
};
|
||||
const mapped = (type: ts.Type | null | undefined): IrType | null =>
|
||||
type ? panicSafe(() => this.mapTypeOf(type), null) : null;
|
||||
const flow = (source: ts.Expression, target: ts.Type | null | undefined): void => {
|
||||
if (target === undefined) return;
|
||||
const into = target === null ? null : mapped(target);
|
||||
// An unanswerable target may be dynamic.
|
||||
if (target !== null && into !== null && !holdsDynamic(into, 0)) return;
|
||||
const from = typeOrNull(source);
|
||||
if (from === null) keys.add("*");
|
||||
else collect(mapped(from));
|
||||
};
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isAsExpression(node) || ts.isTypeAssertion(node))
|
||||
flow(node.expression, typeOrNull(node));
|
||||
else if (ts.isVariableDeclaration(node) && node.type && node.initializer)
|
||||
flow(node.initializer, typeOrNull(node.name));
|
||||
else if (initializedBinding(node)) flow(node.initializer!, typeOrNull(node.name ?? node));
|
||||
else if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind))
|
||||
flow(node.right, typeOrNull(node.left));
|
||||
else if (ts.isReturnStatement(node) && node.expression)
|
||||
flow(node.expression, contextualOrNull(node.expression));
|
||||
else if (ts.isYieldExpression(node) && node.expression)
|
||||
flow(node.expression, contextualOrNull(node.expression));
|
||||
// A thrown value reaches a catch binding, which is dynamic.
|
||||
else if (ts.isThrowStatement(node)) flow(node.expression, null);
|
||||
else if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
||||
if (!standardLibraryReader(node))
|
||||
for (const arg of node.arguments?.nodes ?? [])
|
||||
flow(ts.isSpreadElement(arg) ? arg.expression : arg, contextualOrNull(arg));
|
||||
} else if (ts.isPropertyAssignment(node))
|
||||
flow(node.initializer, contextualOrNull(node.initializer));
|
||||
else if (ts.isShorthandPropertyAssignment(node)) {
|
||||
const name = node.name as ts.Expression;
|
||||
flow(name, contextualOrNull(name));
|
||||
} else if (ts.isArrayLiteralExpression(node)) {
|
||||
for (const element of node.elements.nodes)
|
||||
if (!ts.isSpreadElement(element) && !ts.isOmittedExpression(element))
|
||||
flow(element, contextualOrNull(element));
|
||||
} else if (ts.isArrowFunction(node) && !ts.isBlock(node.body))
|
||||
flow(node.body as ts.Expression, contextualOrNull(node.body as ts.Expression));
|
||||
};
|
||||
for (const sf of sourceFiles)
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
// A checker panic hides this conversion: any array.
|
||||
if (!panicSafe(() => (visit(node), true), false)) keys.add("*");
|
||||
return undefined;
|
||||
});
|
||||
return keys;
|
||||
};
|
||||
// A receiver typed `any`, `unknown` or an array of either can be a live
|
||||
// view of a native array. A receiver of another type (`Worker[]`,
|
||||
// `Record<string, unknown>`) is one only after a lying assertion, even
|
||||
// when its values are dynamic.
|
||||
const dynamicType = (type: ts.Type): boolean => {
|
||||
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true;
|
||||
if (!this.checker.isArrayType(type) || this.checker.isTupleType(type)) return false;
|
||||
const element = this.checker.getTypeArguments(type as ts.TypeReference)[0];
|
||||
return (
|
||||
element !== undefined && (element.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0
|
||||
);
|
||||
};
|
||||
// A binding that only ever holds the array its declaration creates (a
|
||||
// const variable, or a readonly property with an initializer) is never
|
||||
// a view of a native array, even when its elements are dynamic.
|
||||
const freshDynamicStorage = (node: ts.Expression, depth: number): boolean => {
|
||||
const e = peel(node);
|
||||
if (ts.isArrayLiteralExpression(e)) return true;
|
||||
if (ts.isNewExpression(e) || ts.isCallExpression(e)) {
|
||||
const callee = peel(e.expression);
|
||||
if (ts.isIdentifier(callee)) return this.isStdlibGlobal(callee, "Array");
|
||||
return (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
(callee.name.text === "of" || callee.name.text === "from") &&
|
||||
this.isStdlibGlobal(callee.expression, "Array")
|
||||
);
|
||||
}
|
||||
if (depth > 4 || (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e))) return false;
|
||||
// A typed array along the way is the array a view shows.
|
||||
const type = typeOrNull(e);
|
||||
if (type === null || !dynamicType(type)) return false;
|
||||
const symbol = panicSafe(
|
||||
() => this.checker.getSymbolAtLocation(ts.isIdentifier(e) ? e : e.name),
|
||||
undefined,
|
||||
);
|
||||
const declaration = symbol?.valueDeclaration;
|
||||
if (!symbol || !declaration) return false;
|
||||
// Variables and class properties only: parameters take any argument,
|
||||
// and object literals fill interface properties.
|
||||
if (
|
||||
!(ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name)) &&
|
||||
!ts.isPropertyDeclaration(declaration)
|
||||
)
|
||||
return false;
|
||||
const assigned = assignedValues(symbol);
|
||||
if (assigned === null) return false;
|
||||
const values = [
|
||||
...(declaration.initializer ? [declaration.initializer] : []),
|
||||
...(assigned ?? []),
|
||||
];
|
||||
return values.length > 0 && values.every((value) => freshDynamicStorage(value, depth + 1));
|
||||
};
|
||||
// Every value `=` assigns to each variable or property of the program,
|
||||
// by symbol; null for one also written another way (compound or
|
||||
// destructuring assignment). Built on first use.
|
||||
let assignments: Map<ts.Symbol, ts.Expression[] | null> | null = null;
|
||||
const assignedValues = (symbol: ts.Symbol): ts.Expression[] | null | undefined => {
|
||||
if (assignments === null) {
|
||||
const found = new Map<ts.Symbol, ts.Expression[] | null>();
|
||||
const add = (target: ts.Expression, value: ts.Expression | null): void => {
|
||||
const e = peel(target);
|
||||
if (ts.isArrayLiteralExpression(e) || ts.isObjectLiteralExpression(e)) {
|
||||
// Destructuring: every target is written with a part of the value.
|
||||
ts.forEachChild(e, (child) => {
|
||||
if (ts.isIdentifier(child) || ts.isPropertyAccessExpression(child)) add(child, null);
|
||||
else if (ts.isSpreadElement(child)) add(child.expression, null);
|
||||
else if (ts.isPropertyAssignment(child)) add(child.initializer, null);
|
||||
else if (ts.isShorthandPropertyAssignment(child))
|
||||
add(child.name as ts.Expression, null);
|
||||
else if (ts.isBinaryExpression(child)) add(child.left, null);
|
||||
return undefined;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const name = ts.isIdentifier(e) ? e : ts.isPropertyAccessExpression(e) ? e.name : null;
|
||||
const written = name
|
||||
? panicSafe(() => this.checker.getSymbolAtLocation(name), undefined)
|
||||
: undefined;
|
||||
if (!written) return;
|
||||
const list = found.get(written);
|
||||
if (list === null) return;
|
||||
if (value === null) found.set(written, null);
|
||||
else if (list) list.push(value);
|
||||
else found.set(written, [value]);
|
||||
};
|
||||
for (const sf of sourceFiles)
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
if (ts.isBinaryExpression(node)) {
|
||||
const op = node.operatorToken.kind;
|
||||
if (op === ts.SyntaxKind.EqualsToken) add(node.left, node.right);
|
||||
else if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment)
|
||||
add(node.left, null);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
assignments = found;
|
||||
}
|
||||
return assignments.get(symbol);
|
||||
};
|
||||
// `t[i]!` on a typed array, stored into an array, keeps an invalid
|
||||
// index's undefined (assertedReadObservesUndefined): `!` performs no
|
||||
// runtime check. A read whose consumers treat undefined like NaN stores
|
||||
@@ -6589,6 +6843,11 @@ export class Lowerer {
|
||||
);
|
||||
};
|
||||
const elementStates = new ArrayElementStates({
|
||||
isDynamicArray: (node) => {
|
||||
const type = typeOrNull(node);
|
||||
return (type === null || dynamicType(type)) && !freshDynamicStorage(node, 0);
|
||||
},
|
||||
nativeViewKeys: collectNativeViews,
|
||||
elementAdmitsUndefined: (key) => {
|
||||
siteDependencies?.read(key);
|
||||
return optionalElementKeys.has(key);
|
||||
@@ -7509,6 +7768,7 @@ export class Lowerer {
|
||||
}
|
||||
for (const [site, present] of presentSites) if (present) this.presentElementCalls.add(site);
|
||||
for (const [loop, present] of presentLoops) if (present) this.presentElementLoops.add(loop);
|
||||
this.foreignElementKeys = elementStates.foreignKeys();
|
||||
const globalsById = new Map(this.globalsList.map((global) => [global.id, global]));
|
||||
for (const [symbol, field] of staticFieldsBySymbol) {
|
||||
if (!optionalSymbols.has(symbol)) continue;
|
||||
@@ -8062,6 +8322,9 @@ export class Lowerer {
|
||||
...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}),
|
||||
...(this.usesNodeIsland ? { nodeIsland: this.nodeIslandRecord() } : {}),
|
||||
...(this.declBoundary ? { declBoundary: true as const } : {}),
|
||||
...(this.foreignElementKeys.size > 0
|
||||
? { foreignElementKeys: [...this.foreignElementKeys].sort() }
|
||||
: {}),
|
||||
...(this.threadSlotKeys.size > 0
|
||||
? { threadSlotKeys: [...this.threadSlotKeys].sort() }
|
||||
: {}),
|
||||
@@ -10644,8 +10907,8 @@ export class Lowerer {
|
||||
return unionRetagHelper(this, fromId, toId, loc, trappable);
|
||||
}
|
||||
|
||||
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc): string | null {
|
||||
return narrowedArmHelper(this, fromId, target, loc);
|
||||
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc, message?: string): string | null {
|
||||
return narrowedArmHelper(this, fromId, target, loc, message);
|
||||
}
|
||||
|
||||
checkedNarrowCall(helper: string, value: IrExpr, type: IrType, loc: SrcLoc): IrExpr {
|
||||
|
||||
@@ -43,8 +43,17 @@ export interface ElementStateHost {
|
||||
ownerOf(node: ts.Expression): ts.Symbol | null;
|
||||
/** The owning slot a newly built array is stored into. */
|
||||
allocationOwner(node: ts.Expression): ts.Symbol | null;
|
||||
/** `node` is a dynamic value (`any`, `unknown`, `unknown[]`): when it is
|
||||
* a live view of a native array, a store through it writes that array's
|
||||
* storage. */
|
||||
isDynamicArray(node: ts.Expression): boolean;
|
||||
/** Element keys of the native arrays a value converted to a dynamic type
|
||||
* can reach, as live views ("*" for any array). */
|
||||
nativeViewKeys(): ReadonlySet<string>;
|
||||
/** Debug provenance for a new fact. */
|
||||
note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null;
|
||||
note:
|
||||
| ((fact: "undefined" | "holes" | "foreign", key: string, site: ts.Node | undefined) => void)
|
||||
| null;
|
||||
/** The scan read, or added to, the facts of an element key, of an owning
|
||||
* slot, or of every array (ANY_ARRAY), for dependency tracking
|
||||
* (SiteDependencies). */
|
||||
@@ -54,6 +63,16 @@ export interface ElementStateHost {
|
||||
|
||||
/** The fact name of the facts shared by every array. */
|
||||
const ANY_ARRAY = "*";
|
||||
/** The fact name of holes stored through dynamic views. */
|
||||
const VIEW_HOLES = "%view";
|
||||
/** The fact name of values stored through dynamic views. */
|
||||
const VIEW_VALUES = "%view-values";
|
||||
|
||||
/** Element keys whose arrays store a value written through a dynamic view
|
||||
* that the element type cannot hold, as a foreign element: `number[]` and
|
||||
* `string[]`. Arrays of other element types reject such a write with the
|
||||
* checked conversion's TypeError. */
|
||||
const FOREIGN_ELEMENT_KEYS: ReadonlySet<string> = new Set(["f64", "string"]);
|
||||
|
||||
/** What an array value can contain besides present values. */
|
||||
interface Contents {
|
||||
@@ -87,6 +106,13 @@ export class ArrayElementStates {
|
||||
* whose element ABI is decided per generic instantiation. */
|
||||
private anyUndefined = false;
|
||||
private anyHoley = false;
|
||||
/** A store through a dynamic value can leave holes; they land in every
|
||||
* native array such a value views. */
|
||||
private viewHoles = false;
|
||||
/** A store through a dynamic value can write any value, which the
|
||||
* viewed native arrays then hold as undefined or foreign elements. */
|
||||
private viewValues = false;
|
||||
private viewKeys: ReadonlySet<string> | null = null;
|
||||
/** Facts of arrays owned by one storage slot. */
|
||||
private readonly undefinedOwners = new Set<ts.Symbol>();
|
||||
private readonly holeyOwners = new Set<ts.Symbol>();
|
||||
@@ -336,14 +362,65 @@ export class ArrayElementStates {
|
||||
this.host.read?.(ANY_ARRAY);
|
||||
if (keys === null) return null;
|
||||
for (const key of keys) this.host.read?.(key);
|
||||
this.host.read?.(VIEW_HOLES);
|
||||
this.host.read?.(VIEW_VALUES);
|
||||
return {
|
||||
undefined:
|
||||
this.anyUndefined ||
|
||||
keys.some((key) => this.undefinedKeys.has(key) || this.host.elementAdmitsUndefined(key)),
|
||||
holes: this.anyHoley || keys.some((key) => this.holeyKeys.has(key)),
|
||||
keys.some(
|
||||
(key) =>
|
||||
this.undefinedKeys.has(key) ||
|
||||
this.host.elementAdmitsUndefined(key) ||
|
||||
this.viewedValues(key),
|
||||
),
|
||||
holes: this.anyHoley || keys.some((key) => this.holeyKeys.has(key) || this.viewed(key)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Holes stored through a dynamic view can be in arrays of this key. */
|
||||
private viewed(key: string): boolean {
|
||||
return this.viewHoles && this.isViewed(key);
|
||||
}
|
||||
|
||||
/** Values of any type stored through a dynamic view can be in arrays of
|
||||
* this key. */
|
||||
private viewedValues(key: string): boolean {
|
||||
return this.viewValues && FOREIGN_ELEMENT_KEYS.has(key) && this.isViewed(key);
|
||||
}
|
||||
|
||||
private isViewed(key: string): boolean {
|
||||
this.viewKeys ??= this.host.nativeViewKeys();
|
||||
return this.viewKeys.has(key) || this.viewKeys.has(ANY_ARRAY);
|
||||
}
|
||||
|
||||
/** Element keys whose arrays can hold elements outside their element
|
||||
* type, stored through dynamic views (of FOREIGN_ELEMENT_KEYS). Final
|
||||
* once the fixed point is reached. */
|
||||
foreignKeys(): ReadonlySet<string> {
|
||||
if (!this.viewValues) return new Set();
|
||||
return new Set([...FOREIGN_ELEMENT_KEYS].filter((key) => this.isViewed(key)));
|
||||
}
|
||||
|
||||
/** A store through `receiver`, which has no native element key, can
|
||||
* write a value of any type into a viewed native array. */
|
||||
private addViewValues(receiver: ts.Expression): boolean {
|
||||
if (this.viewValues || !this.host.isDynamicArray(receiver)) return false;
|
||||
this.viewValues = true;
|
||||
this.host.changed?.(VIEW_VALUES);
|
||||
this.host.note?.("foreign", VIEW_VALUES, this.site);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A store through `receiver`, which has no native element key, can
|
||||
* leave holes. Only a dynamic value can be a view of a native array. */
|
||||
private addViewHoles(receiver: ts.Expression): boolean {
|
||||
if (this.viewHoles || !this.host.isDynamicArray(receiver)) return false;
|
||||
this.viewHoles = true;
|
||||
this.host.changed?.(VIEW_HOLES);
|
||||
this.host.note?.("holes", VIEW_HOLES, this.site);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reading every index of `node` (a spread, an iteration, `Array.from`)
|
||||
* can produce `undefined`. Only native arrays can: other iterables reject
|
||||
* an undefined value when it is stored. */
|
||||
@@ -437,7 +514,11 @@ export class ArrayElementStates {
|
||||
/** A store through `receiver` can leave holes. */
|
||||
private addHolesTo(receiver: ts.Expression): boolean {
|
||||
const owner = this.host.ownerOf(receiver);
|
||||
if (owner === null) return this.addHoles(this.host.arrayKeys(receiver));
|
||||
if (owner === null) {
|
||||
const keys = this.host.arrayKeys(receiver);
|
||||
if (keys !== null && keys.length === 0) return this.addViewHoles(receiver);
|
||||
return this.addHoles(keys);
|
||||
}
|
||||
if (this.holeyOwners.has(owner)) return false;
|
||||
this.holeyOwners.add(owner);
|
||||
this.host.changed?.(owner);
|
||||
@@ -497,10 +578,18 @@ export class ArrayElementStates {
|
||||
op === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
||||
op === ts.SyntaxKind.BarBarEqualsToken ||
|
||||
op === ts.SyntaxKind.AmpersandAmpersandEqualsToken;
|
||||
const keys = this.host.arrayKeys(target.expression);
|
||||
if (keys !== null && keys.length === 0) {
|
||||
// Through a dynamic value: any element write can store a value the
|
||||
// viewed native array's element type does not hold.
|
||||
if (namesProperty(target)) return false;
|
||||
let changed = false;
|
||||
if (holes && this.addViewHoles(target.expression)) changed = true;
|
||||
if (this.addViewValues(target.expression)) changed = true;
|
||||
return changed;
|
||||
}
|
||||
const undefinedValue = storesValue && this.host.mayBeUndefined(node.right);
|
||||
if (!holes && !undefinedValue) return false;
|
||||
const keys = this.host.arrayKeys(target.expression);
|
||||
if (keys !== null && keys.length === 0) return false;
|
||||
let changed = false;
|
||||
if (holes && this.addHolesTo(target.expression)) changed = true;
|
||||
if (undefinedValue && this.addUndefinedTo(target.expression)) changed = true;
|
||||
@@ -511,8 +600,6 @@ export class ArrayElementStates {
|
||||
target.name.text === "length" &&
|
||||
!isShrinkingLength(node, target)
|
||||
) {
|
||||
const keys = this.host.arrayKeys(target.expression);
|
||||
if (keys !== null && keys.length === 0) return false;
|
||||
return this.addHolesTo(target.expression);
|
||||
}
|
||||
return false;
|
||||
@@ -563,7 +650,14 @@ export class ArrayElementStates {
|
||||
return changed;
|
||||
}
|
||||
const receiver = this.host.arrayKeys(callee.expression);
|
||||
if (receiver !== null && receiver.length === 0) return changed;
|
||||
if (receiver !== null && receiver.length === 0) {
|
||||
if (
|
||||
(stored.length > 0 || callee.name.text === "fill") &&
|
||||
this.addViewValues(callee.expression)
|
||||
)
|
||||
changed = true;
|
||||
return changed;
|
||||
}
|
||||
for (const value of stored)
|
||||
if (this.valueMayBeUndefined(value) && this.addUndefinedTo(callee.expression)) changed = true;
|
||||
return changed;
|
||||
@@ -596,10 +690,17 @@ export class ArrayElementStates {
|
||||
));
|
||||
if (!reflective) return false;
|
||||
let changed = false;
|
||||
for (const arg of args) {
|
||||
// The written object: `Reflect.apply`'s receiver argument, otherwise
|
||||
// the first argument.
|
||||
const written = this.host.isGlobal(owner, "Reflect") && method === "apply" ? 1 : 0;
|
||||
for (const [index, arg] of args.entries()) {
|
||||
const target = ts.isSpreadElement(arg) ? arg.expression : arg;
|
||||
const keys = this.host.arrayKeys(target);
|
||||
if (keys !== null && keys.length === 0) continue;
|
||||
if (keys !== null && keys.length === 0) {
|
||||
if (index === written && this.addViewHoles(target)) changed = true;
|
||||
if (index === written && this.addViewValues(target)) changed = true;
|
||||
continue;
|
||||
}
|
||||
if (this.addUndefinedTo(target)) changed = true;
|
||||
if (this.addHolesTo(target)) changed = true;
|
||||
}
|
||||
@@ -783,6 +884,14 @@ function isAppendIndex(target: ts.ElementAccessExpression): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** `o["name"]`: a string literal key that is not an array index writes a
|
||||
* named property, never an element. */
|
||||
function namesProperty(target: ts.ElementAccessExpression): boolean {
|
||||
const key = peel(target.argumentExpression);
|
||||
if (!ts.isStringLiteral(key) && !ts.isNoSubstitutionTemplateLiteral(key)) return false;
|
||||
return !/^(0|[1-9][0-9]*)$/.test(key.text) || Number(key.text) >= 2 ** 32 - 1;
|
||||
}
|
||||
|
||||
/** `xs.length = 0`, `xs.length = xs.length - k` and `xs.length -= k`
|
||||
* truncate; truncation never leaves holes. */
|
||||
function isShrinkingLength(
|
||||
|
||||
@@ -97,3 +97,22 @@ test("a deferred read requires a real undefined tag", () => {
|
||||
const value: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
|
||||
expect(buildUnionNarrow("bad", from, BOOL, loc, () => "", value)).toBeNull();
|
||||
});
|
||||
|
||||
test("a failure message replaces every other tag's TypeError", () => {
|
||||
const fn = buildUnionNarrow(
|
||||
"call",
|
||||
union,
|
||||
STRING,
|
||||
loc,
|
||||
(type) => type.kind,
|
||||
null,
|
||||
"f is not a function",
|
||||
)!;
|
||||
const thrown = branches(fn).flatMap((branch) => {
|
||||
const statement = branch.then[0]!;
|
||||
return statement.kind === "throw" ? [statement.value] : [];
|
||||
});
|
||||
expect(thrown).toHaveLength(4);
|
||||
for (const value of thrown)
|
||||
expect(value).toMatchObject({ kind: "libCall", args: [{ value: "f is not a function" }] });
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@ export function unionNarrowFailureMessage(
|
||||
*
|
||||
* Deferred scalar fields can supply the established undefined default:
|
||||
* false for booleans or NaN for numbers. No other tag receives that default,
|
||||
* and reference-bearing targets always use checked extraction. */
|
||||
* and reference-bearing targets always use checked extraction.
|
||||
*
|
||||
* `failureMessage` replaces the TypeError message of every other tag (the
|
||||
* error Node raises for the operation consuming the value). */
|
||||
export function buildUnionNarrow(
|
||||
name: string,
|
||||
from: IrUnionDef,
|
||||
@@ -28,6 +31,7 @@ export function buildUnionNarrow(
|
||||
loc: SrcLoc,
|
||||
formatType: (type: IrType) => string,
|
||||
undefinedDefault: IrExpr | null = null,
|
||||
failureMessage: string | null = null,
|
||||
): IrFunction | null {
|
||||
if (isUnitType(target) || target.kind === "void") return null;
|
||||
const targetTag = from.arms.findIndex((arm) => typeEquals(arm, target));
|
||||
@@ -66,7 +70,7 @@ export function buildUnionNarrow(
|
||||
args: [
|
||||
{
|
||||
kind: "strLit",
|
||||
value: unionNarrowFailureMessage(arm, formatType),
|
||||
value: failureMessage ?? unionNarrowFailureMessage(arm, formatType),
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* package's declaration entry and runtime entry, copies the declaration
|
||||
* files the entry reaches, and builds the runtime entry's typedefs. */
|
||||
import { dirname, relative, resolve as resolvePath } from "node:path";
|
||||
import { trackedFileExists, trackedReadFile } from "./input-tracker.js";
|
||||
import { trackedFileExists } from "./input-tracker.js";
|
||||
import { packageSourceText } from "./type-patches/layer.js";
|
||||
import { resolveBareModule } from "./resolve.js";
|
||||
import { isJsSourceFileName } from "./tsc-codes.js";
|
||||
import { NPM_STATIC_TYPES_SUFFIX } from "./npm-static.js";
|
||||
@@ -78,7 +79,7 @@ export function npmStaticTypeExportPlan(
|
||||
const parse = (file: string): ts.SourceFile | null => {
|
||||
let sf = parsed.get(file);
|
||||
if (sf === undefined) {
|
||||
const text = trackedReadFile(file);
|
||||
const text = packageSourceText(file);
|
||||
sf = text === null ? null : services.parse(file, text, "ts");
|
||||
parsed.set(file, sf);
|
||||
}
|
||||
@@ -212,7 +213,7 @@ export function npmStaticTypeExportPlan(
|
||||
continue;
|
||||
const runtime = resolveBareModule(entryPath, pkg, "js-only");
|
||||
if (runtime === null || !isJsSourceFileName(runtime.typesFile)) continue;
|
||||
const runtimeText = trackedReadFile(runtime.typesFile);
|
||||
const runtimeText = packageSourceText(runtime.typesFile);
|
||||
if (runtimeText === null) continue;
|
||||
const { types, values } = typesOf(declared.typesFile, new Set());
|
||||
const declaredByRuntime = runtimeTypeNames(services, runtime.typesFile, runtimeText);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
trackedRealpath,
|
||||
} from "./input-tracker.js";
|
||||
import { npmBuiltinAliasOf } from "./builtin-modules.js";
|
||||
import { packageSourceText } from "./type-patches/layer.js";
|
||||
|
||||
let activePackages: ReadonlySet<string> = new Set();
|
||||
let declarationOverloads: ReadonlyMap<string, NpmStaticDeclarationOverloads> = new Map();
|
||||
@@ -82,7 +83,7 @@ export function setNpmStaticTypeExports(
|
||||
function withTypeExportTypedefs(path: string, text: string | null): string | null {
|
||||
const typedefs = typeExportTypedefs.get(path.split("\\").join("/"));
|
||||
if (typedefs === undefined) return text;
|
||||
const source = text ?? trackedReadFile(path);
|
||||
const source = text ?? packageSourceText(path);
|
||||
return source === null ? text : `${source}\n${typedefs}\n`;
|
||||
}
|
||||
|
||||
@@ -467,7 +468,11 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
|
||||
if (hit !== undefined) return hit ?? undefined;
|
||||
let rewritten: string | null = null;
|
||||
try {
|
||||
let original = trackedReadFile(path);
|
||||
// A project type patch (type-patches/layer.ts) edits the text
|
||||
// as the package wrote it, before any rewrite reads its JSDoc
|
||||
// (unrewritten files fall through to the host, which serves the
|
||||
// patched text too).
|
||||
let original = packageSourceText(path);
|
||||
// A bundle's CommonJS chunks move to their own modules first, so
|
||||
// every later pass (and this shadow, for each chunk) sees plain
|
||||
// CommonJS module text.
|
||||
|
||||
@@ -48,6 +48,7 @@ import { NODE_API_MODULE } from "./node-api-import.js";
|
||||
import { erasedTypeOnlyImport } from "./type-only-imports.js";
|
||||
import { EXPERIMENTAL_MODULE } from "./experimental-import.js";
|
||||
import { readProjectConfig } from "../project-config.js";
|
||||
import { typePatchDiagnostics } from "./type-patches/layer.js";
|
||||
import { packageDirOf } from "../node-islands/origin.js";
|
||||
import {
|
||||
scanFiles,
|
||||
@@ -741,10 +742,14 @@ function loadFrontend(
|
||||
const projectConfig = readProjectConfig(entryPath);
|
||||
const configDiagnostics = projectConfig.diagnostics;
|
||||
const report = options.nodeIslands === true;
|
||||
// Type patches that did not apply as written (SC8030–SC8033) warn; the
|
||||
// build goes on with the packages' own declarations there.
|
||||
const patchDiagnostics = typePatchDiagnostics()?.diagnostics ?? [];
|
||||
const buildWarnings = [...(report ? [] : configDiagnostics), ...patchDiagnostics];
|
||||
return {
|
||||
preflight:
|
||||
report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight,
|
||||
...(!report && configDiagnostics.length > 0 ? { warnings: configDiagnostics } : {}),
|
||||
...(buildWarnings.length > 0 ? { warnings: buildWarnings } : {}),
|
||||
entryText: () => finalLoad.entry.text,
|
||||
entryExports: () => entryFunctionExports(finalLoad.entry),
|
||||
// The contract scans the PROGRAM's source files, not the runtime
|
||||
@@ -779,9 +784,10 @@ function loadFrontend(
|
||||
...finalLoad.program.getImplementationSourceFiles().filter((sf) => !sf.isDeclarationFile),
|
||||
].map((sf) => [sf.fileName, sf.text]),
|
||||
);
|
||||
// SC8004 diagnostics point into the project's package.json.
|
||||
// SC8004 and type patch diagnostics point into the project's
|
||||
// package.json.
|
||||
if (
|
||||
configDiagnostics.length > 0 &&
|
||||
(configDiagnostics.length > 0 || patchDiagnostics.length > 0) &&
|
||||
projectConfig.manifest !== null &&
|
||||
projectConfig.manifestText !== null
|
||||
)
|
||||
|
||||
@@ -140,6 +140,7 @@ import {
|
||||
} from "./type-only-imports.js";
|
||||
import { moduleSourceCandidates } from "./module-source-candidates.js";
|
||||
import { planDeclProjection, type DeclProjection } from "./decl-projection.js";
|
||||
import { configureTypePatches, packageSourceText } from "./type-patches/layer.js";
|
||||
import { reportShapes } from "./shape-inference/index.js";
|
||||
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
|
||||
import {
|
||||
@@ -477,7 +478,7 @@ function visitNpmStaticDeclarationClosure(
|
||||
if (normalized !== packageRoot && !normalized.startsWith(packageRoot + "/")) return;
|
||||
if (seen.has(path)) return;
|
||||
seen.add(path);
|
||||
const source = trackedReadFile(path);
|
||||
const source = packageSourceText(path);
|
||||
if (source === null) return;
|
||||
onSource(path, source);
|
||||
for (const specifier of npmStaticDeclarationReexports(services.parse(path, source, "ts"))) {
|
||||
@@ -1068,6 +1069,9 @@ export function loadProgram(
|
||||
// JavaScript, even when named. A registry entry's `sourceVersions`
|
||||
// (chalk 6) compile from their own source.
|
||||
configurePackageShims(entryPath);
|
||||
// The project's type patches (type-patches/layer.ts), read by every host
|
||||
// this load creates; nothing at all without `scriptc.typePatches`.
|
||||
configureTypePatches(entryPath, (path, text, kind) => services.parse(path, text, kind));
|
||||
const npmStaticPackages = [...new Set(opts?.npmStatic ?? [])].filter(
|
||||
(name) => !npmStaticOmitsPackage(entryPath, name),
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
trackedRealpath,
|
||||
} from "../input-tracker.js";
|
||||
import { packageShimDeclarationText } from "../package-shims.js";
|
||||
import { typePatchedText } from "../type-patches/layer.js";
|
||||
import { ambientDtsPath, fallbackDtsPath, overridesDtsPath } from "../dts-paths.js";
|
||||
import { reusedLibDiagnostics } from "./lib-diagnostics.js";
|
||||
import { ts7Diagnostic } from "./diagnostic.js";
|
||||
@@ -160,7 +161,7 @@ export class Ts7Host {
|
||||
const virtualFiles = this.virtualFiles;
|
||||
const shadow = options?.fsShadow ?? null;
|
||||
// A virtual file, a shadow's replacement, a package shim's declaration
|
||||
// entry or the file on disk; null when shadowed out of existence or
|
||||
// entry, a type-patched package file or the file on disk; null when shadowed out of existence or
|
||||
// missing.
|
||||
this.readSource = (fileName) => {
|
||||
const virtual = virtualFiles.get(tsgoPath(fileName));
|
||||
@@ -175,6 +176,9 @@ export class Ts7Host {
|
||||
// program the host creates.
|
||||
const shim = packageShimDeclarationText(fileName);
|
||||
if (shim !== undefined) return shim;
|
||||
// A package file a project type patch edits (type-patches/layer.ts).
|
||||
const patched = typePatchedText(fileName);
|
||||
if (patched !== undefined) return stripSourceBom(patched);
|
||||
const source = trackedReadFile(fileName);
|
||||
return source === null ? null : stripSourceBom(source);
|
||||
};
|
||||
|
||||
@@ -4948,6 +4948,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
const record = mapRecordType(widened, ctx);
|
||||
if (record) return record;
|
||||
}
|
||||
// An intersection whose constituents all map to ONE representation is
|
||||
// that representation. An intersection that restates an optional member
|
||||
// as required (`Opts & { files: Array<{ path: string }> }` over
|
||||
// `files?: Array<{ path: string }>`) gives the member the type `A[] & B[]`
|
||||
// with two separately written but structurally equal element literals;
|
||||
// the checker keeps both, while their arrays (tuples, maps, ...) intern
|
||||
// to one IR type. Reached only after every other intersection rule
|
||||
// declined, so intersections that already mapped keep their mapping. A
|
||||
// dynamic result stays unmapped: two opaque types are not one type.
|
||||
if (widened.isIntersectionType()) {
|
||||
const same = mapEqualConstituentsIntersection(widened, ctx);
|
||||
if (same) return same;
|
||||
}
|
||||
// Unions. `boolean` is internally `true | false`, and narrowing produces
|
||||
// literal unions like `"a" | "b"` — parts that map to the SAME IR type
|
||||
// collapse (deduplicated by typeKey), so a single surviving arm is just
|
||||
@@ -6813,6 +6826,19 @@ export function withUnitArm(
|
||||
* assigned keys, which is also Node's answer for Object.assign(fn, obj)
|
||||
* (a function's own length/name are non-enumerable). Null whenever the
|
||||
* shape doesn't fit — the caller falls through to the plain-func mapping. */
|
||||
/** The IR type every constituent of an intersection maps to, or null when a
|
||||
* constituent is unmappable or dynamic, or two constituents differ. */
|
||||
function mapEqualConstituentsIntersection(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
let same: IrType | null = null;
|
||||
for (const part of ts.constituentTypes(widened)) {
|
||||
const mapped = mapType(part, ctx);
|
||||
if (mapped === null || mapped.kind === "dyn") return null;
|
||||
if (same === null) same = mapped;
|
||||
else if (!typeEquals(same, mapped)) return null;
|
||||
}
|
||||
return same;
|
||||
}
|
||||
|
||||
function mapHybridCallableIntersection(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
const { checker, shapes } = ctx;
|
||||
if (!widened.isIntersectionType()) return null;
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/* Type patches: project-owned fixes for wrong npm declarations.
|
||||
*
|
||||
* `scriptc.typePatches` in the entry's package.json maps
|
||||
* `"<package>@<range>"` to declaration patches (project-config.ts):
|
||||
*
|
||||
* "commander@^15.0.0": { "declarations": { "HelpContext.helpWidth": "| undefined" } }
|
||||
*
|
||||
* The patches are applied to file TEXT, in one place every consumer reads
|
||||
* through: the program host's source reads (ts7/program-host.ts), which
|
||||
* serve both checker worlds (the lowering world that types package
|
||||
* JavaScript from its JSDoc, and the declaration world that types user
|
||||
* code at the declaration boundary), and the npm-static passes that read
|
||||
* package files themselves (`packageSourceText`). Downstream, the patched
|
||||
* declaration simply IS the declaration: scriptc enforces it exactly as it
|
||||
* enforced the original, so a patch can never make a check weaker than the
|
||||
* declaration it replaces.
|
||||
*
|
||||
* A patch applies to an installed copy whose version satisfies its range;
|
||||
* an `@types/<name>` twin is matched by the runtime package's name and
|
||||
* installed version. Without patches nothing here runs: `typePatchedText`
|
||||
* answers undefined from one null test, so every program reads the same
|
||||
* bytes as before (the zero-cost rule).
|
||||
*
|
||||
* See scriptc-perf/results/design-type-patches/DESIGN.md. */
|
||||
|
||||
import { dirname, relative } from "node:path";
|
||||
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
|
||||
import { typePatchDiag } from "../../diagnostics/diagnostic.js";
|
||||
import { readProjectConfig, type TypePatchEntry } from "../../project-config.js";
|
||||
import { trackedReadFile } from "../input-tracker.js";
|
||||
import type { SourceFile } from "../ts7/syntax.js";
|
||||
import { applyPatchEdits, patchFile, type PatchMatch, type PatchRequest } from "./locate.js";
|
||||
import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js";
|
||||
import { satisfies } from "./semver.js";
|
||||
|
||||
export type TypePatchParse = (path: string, text: string, kind: "ts" | "js") => SourceFile;
|
||||
|
||||
interface Request extends PatchRequest {
|
||||
entry: TypePatchEntry;
|
||||
/** Offset of the selector in the manifest. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface CopyInfo {
|
||||
/** The runtime package's name (for an `@types` twin, the package it
|
||||
* types). */
|
||||
pkg: string;
|
||||
version: string | null;
|
||||
root: string;
|
||||
/** Requests whose entry's range the version satisfies. */
|
||||
requests: Request[];
|
||||
}
|
||||
|
||||
interface MatchRecord extends PatchMatch {
|
||||
file: string;
|
||||
pkg: string;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
manifest: string;
|
||||
/** The directory of the project's package.json. */
|
||||
projectDir: string;
|
||||
manifestText: string;
|
||||
parse: TypePatchParse;
|
||||
byPackage: Map<string, Request[]>;
|
||||
copies: Map<string, CopyInfo | null>;
|
||||
files: Map<string, string | null>;
|
||||
/** Package copies whose version no patch range covers. */
|
||||
unmatchedCopies: Map<string, { pkg: string; version: string | null; entries: TypePatchEntry[] }>;
|
||||
/** Requests applicable to a copy this load read files of. */
|
||||
touched: Set<Request>;
|
||||
matches: MatchRecord[];
|
||||
problems: Map<Request, { message: string; always: boolean }>;
|
||||
/** Patched spans per served file (patched-text coordinates). */
|
||||
spans: Map<string, { start: number; end: number }[]>;
|
||||
}
|
||||
|
||||
let state: State | null = null;
|
||||
|
||||
/** Registers the project's type patches for one program load (or clears
|
||||
* them). Called once per load, like configurePackageShims. */
|
||||
export function configureTypePatches(entryPath: string, parse: TypePatchParse): void {
|
||||
const config = readProjectConfig(entryPath);
|
||||
if (config.typePatches.length === 0 || config.manifest === null) {
|
||||
state = null;
|
||||
return;
|
||||
}
|
||||
const byPackage = new Map<string, Request[]>();
|
||||
let id = 0;
|
||||
for (const entry of config.typePatches) {
|
||||
for (const decl of entry.declarations) {
|
||||
const selector = parseSelector(decl.selector);
|
||||
const op = parseTypeText(decl.type);
|
||||
if ("error" in selector || "error" in op) continue; // reported as SC8004
|
||||
const list = byPackage.get(entry.pkg) ?? [];
|
||||
list.push({
|
||||
id: id++,
|
||||
selector: selector as Selector,
|
||||
op: op as TypePatchOp,
|
||||
entry,
|
||||
offset: decl.offset,
|
||||
});
|
||||
byPackage.set(entry.pkg, list);
|
||||
}
|
||||
}
|
||||
state = {
|
||||
manifest: config.manifest,
|
||||
projectDir: dirname(config.manifest),
|
||||
manifestText: config.manifestText ?? "",
|
||||
parse,
|
||||
byPackage,
|
||||
copies: new Map(),
|
||||
files: new Map(),
|
||||
unmatchedCopies: new Map(),
|
||||
touched: new Set(),
|
||||
matches: [],
|
||||
problems: new Map(),
|
||||
spans: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/** True while the current load has type patches. */
|
||||
export function typePatchesActive(): boolean {
|
||||
return state !== null;
|
||||
}
|
||||
|
||||
const PATCHABLE = /\.(?:[cm]?js|d\.[cm]?ts|[cm]?ts)$/;
|
||||
|
||||
/** `@types/foo__bar` types `@foo/bar`. */
|
||||
function unmangleTypesName(name: string): string {
|
||||
const bare = name.slice("@types/".length);
|
||||
const i = bare.indexOf("__");
|
||||
return i > 0 ? `@${bare.slice(0, i)}/${bare.slice(i + 2)}` : bare;
|
||||
}
|
||||
|
||||
function versionAt(dir: string): string | null {
|
||||
const text = trackedReadFile(`${dir}/package.json`);
|
||||
if (text === null) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { version?: unknown };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The version of the runtime package `pkg` an `@types` twin under
|
||||
* `nodeModules` describes: the copy installed beside it, else the one the
|
||||
* project resolves (pnpm keeps each package in its own directory, so a
|
||||
* twin has no runtime package beside it), else null. */
|
||||
function twinRuntimeVersion(s: State, nodeModules: string, pkg: string): string | null {
|
||||
const beside = versionAt(nodeModules + pkg);
|
||||
if (beside !== null) return beside;
|
||||
for (let dir = s.projectDir; ;) {
|
||||
const version = versionAt(`${dir}/node_modules/${pkg}`);
|
||||
if (version !== null) return version;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed package copy a file belongs to, with the requests that
|
||||
* apply to it; null for files outside node_modules or of unpatched
|
||||
* packages. */
|
||||
function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null {
|
||||
const marker = "/node_modules/";
|
||||
const at = path.lastIndexOf(marker);
|
||||
if (at < 0) return null;
|
||||
const after = path.slice(at + marker.length);
|
||||
const parts = after.split("/");
|
||||
const nameParts = parts[0]!.startsWith("@") ? 2 : 1;
|
||||
if (parts.length <= nameParts) return null;
|
||||
const name = parts.slice(0, nameParts).join("/");
|
||||
const root = path.slice(0, at + marker.length) + name;
|
||||
const rel = parts.slice(nameParts).join("/");
|
||||
let copy = s.copies.get(root);
|
||||
if (copy === undefined) {
|
||||
copy = null;
|
||||
const pkg = name.startsWith("@types/") ? unmangleTypesName(name) : name;
|
||||
const requests = s.byPackage.get(pkg);
|
||||
if (requests !== undefined) {
|
||||
// A twin is matched by the runtime package's version, never its own.
|
||||
const version = name.startsWith("@types/")
|
||||
? twinRuntimeVersion(s, path.slice(0, at + marker.length), pkg)
|
||||
: versionAt(root);
|
||||
const applicable = requests.filter(
|
||||
(r) => version !== null && satisfies(version, r.entry.range),
|
||||
);
|
||||
// Later entries win on the same selector.
|
||||
const bySelector = new Map<string, Request>();
|
||||
for (const r of applicable) bySelector.set(r.selector.text, r);
|
||||
const chosen = [...bySelector.values()];
|
||||
if (chosen.length === 0) {
|
||||
const entries = [...new Set(requests.map((r) => r.entry))];
|
||||
s.unmatchedCopies.set(root, { pkg, version, entries });
|
||||
}
|
||||
copy = chosen.length === 0 ? null : { pkg, version, root, requests: chosen };
|
||||
}
|
||||
s.copies.set(root, copy);
|
||||
}
|
||||
if (copy === null) return null;
|
||||
for (const r of copy.requests) s.touched.add(r);
|
||||
return { copy, rel };
|
||||
}
|
||||
|
||||
/** Names a request's root can appear under in a file's text. */
|
||||
function rootWords(sel: Selector): string[] {
|
||||
if (sel.root === "export=") return ["exports", "export"];
|
||||
if (sel.root === "default") return ["default"];
|
||||
return [sel.root];
|
||||
}
|
||||
|
||||
/** The patched text of a package file, or undefined when no patch applies
|
||||
* to it (callers then read the file as usual). */
|
||||
export function typePatchedText(fileName: string): string | undefined {
|
||||
const s = state;
|
||||
if (s === null) return undefined;
|
||||
const path = fileName.split("\\").join("/");
|
||||
if (!PATCHABLE.test(path)) return undefined;
|
||||
const cached = s.files.get(path);
|
||||
if (cached !== undefined) return cached ?? undefined;
|
||||
let result: string | null = null;
|
||||
const located = copyOf(s, path);
|
||||
if (located !== null) {
|
||||
const text = trackedReadFile(path);
|
||||
const requests = located.copy.requests.filter(
|
||||
(r) =>
|
||||
(r.selector.file === null || r.selector.file === located.rel) &&
|
||||
rootWords(r.selector).some((w) => text?.includes(w)),
|
||||
);
|
||||
if (text !== null && requests.length > 0) {
|
||||
const kind = /\.[cm]?ts$/.test(path) ? "ts" : "js";
|
||||
let out: ReturnType<typeof patchFile> | null = null;
|
||||
try {
|
||||
out = patchFile(located.rel, text, s.parse(path, text, kind), requests);
|
||||
} catch {
|
||||
out = null; // unparseable: served unpatched, like other npm-static passes
|
||||
}
|
||||
if (out !== null) {
|
||||
const byId = new Map(requests.map((r) => [r.id, r]));
|
||||
for (const m of out.matches) {
|
||||
s.matches.push({
|
||||
...m,
|
||||
file: path,
|
||||
pkg: located.copy.pkg,
|
||||
version: located.copy.version,
|
||||
});
|
||||
}
|
||||
for (const p of out.problems) {
|
||||
const r = byId.get(p.id);
|
||||
const prior = r === undefined ? undefined : s.problems.get(r);
|
||||
if (r !== undefined && (prior === undefined || (p.always === true && !prior.always)))
|
||||
s.problems.set(r, {
|
||||
message: `${p.message} (${located.rel})`,
|
||||
always: p.always === true,
|
||||
});
|
||||
}
|
||||
if (out.edits.length > 0) {
|
||||
result = applyPatchEdits(text, out.edits);
|
||||
s.spans.set(path, patchedSpans(out.edits));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.files.set(path, result);
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
/** Edit spans in the patched text's coordinates. */
|
||||
function patchedSpans(
|
||||
edits: readonly { start: number; end: number; text: string }[],
|
||||
): { start: number; end: number }[] {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const out: { start: number; end: number }[] = [];
|
||||
let shift = 0;
|
||||
for (const e of sorted) {
|
||||
const start = e.start + shift;
|
||||
out.push({ start, end: start + e.text.length });
|
||||
shift += e.text.length - (e.end - e.start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Text a package file is read as by passes that read package files
|
||||
* themselves (npm-static): the patched text, or the file. */
|
||||
export function packageSourceText(path: string): string | null {
|
||||
return typePatchedText(path) ?? trackedReadFile(path);
|
||||
}
|
||||
|
||||
/** The spans a type patch edited in a served file (patched-text offsets),
|
||||
* for passes that rewrite package JSDoc and must keep patched comments
|
||||
* (the shape overlay). Empty when nothing was patched. */
|
||||
export function typePatchedSpans(fileName: string): readonly { start: number; end: number }[] {
|
||||
return state?.spans.get(fileName.split("\\").join("/")) ?? [];
|
||||
}
|
||||
|
||||
/** Every applied match of the current load (for `scriptc types patch
|
||||
* --list` and tests). */
|
||||
export function typePatchMatches(): readonly MatchRecord[] {
|
||||
return state?.matches ?? [];
|
||||
}
|
||||
|
||||
function loc(s: State, offset: number): { file: string; start: number; end: number } {
|
||||
return { file: s.manifest, start: offset, end: offset };
|
||||
}
|
||||
|
||||
/** The current load's type patch warnings: SC8030 (no range covers an
|
||||
* installed copy), SC8031 (a selector matched nothing), SC8032 (redundant:
|
||||
* the declaration already has the type), SC8033 (ambiguous or not
|
||||
* patchable as written). Only packages the program loads are reported. */
|
||||
export function typePatchDiagnostics(): {
|
||||
diagnostics: ScrDiagnostic[];
|
||||
manifest: string;
|
||||
manifestText: string;
|
||||
} | null {
|
||||
const s = state;
|
||||
if (s === null) return null;
|
||||
const out: ScrDiagnostic[] = [];
|
||||
for (const [root, info] of s.unmatchedCopies) {
|
||||
const shown = info.version ?? "an unknown version";
|
||||
for (const entry of info.entries) {
|
||||
out.push(
|
||||
typePatchDiag(
|
||||
"SC8030",
|
||||
`type patch '${entry.key}' does not apply to ${info.pkg} ${shown} at ${relativeToManifest(s, root)}; its declarations are used unpatched`,
|
||||
info.version === null
|
||||
? "the installed package has no version"
|
||||
: `check whether the declarations of ${info.pkg} ${info.version} still need the patch, then widen the range or remove it`,
|
||||
loc(s, entry.offset),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const r of s.touched) {
|
||||
const mine = s.matches.filter((m) => m.id === r.id);
|
||||
// A selector that matched somewhere (the .d.ts) is not a problem where
|
||||
// it cannot apply (undocumented JavaScript), unless a declaration it
|
||||
// names was left unpatched.
|
||||
const problem = s.problems.get(r);
|
||||
if (problem !== undefined && (mine.length === 0 || problem.always)) {
|
||||
out.push(
|
||||
typePatchDiag(
|
||||
"SC8033",
|
||||
`type patch '${r.entry.key}': ${problem.message}`,
|
||||
"name one declaration, or patch the declared type it refers to",
|
||||
loc(s, r.offset),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (mine.length === 0) {
|
||||
out.push(
|
||||
typePatchDiag(
|
||||
"SC8031",
|
||||
`type patch '${r.entry.key}': '${r.selector.text}' matches no declaration in the files of ${r.entry.pkg} this program loads`,
|
||||
"check the spelling against the package's declarations and JSDoc; the package may have renamed it",
|
||||
loc(s, r.offset),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (mine.every((m) => m.redundant))
|
||||
out.push(
|
||||
typePatchDiag(
|
||||
"SC8032",
|
||||
`type patch '${r.entry.key}': '${r.selector.text}' is already declared as ${mine[0]!.before}`,
|
||||
"the package's declarations already say this; remove the patch",
|
||||
loc(s, r.offset),
|
||||
),
|
||||
);
|
||||
}
|
||||
return { diagnostics: out, manifest: s.manifest, manifestText: s.manifestText };
|
||||
}
|
||||
|
||||
function relativeToManifest(s: State, path: string): string {
|
||||
return relative(dirname(s.manifest), path).split("\\").join("/");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { nodeFrontendServices } from "../services-node.js";
|
||||
import { applyPatchEdits, patchFile, type PatchRequest } from "./locate.js";
|
||||
import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js";
|
||||
|
||||
function patch(file: string, text: string, patches: Record<string, string>) {
|
||||
const requests: PatchRequest[] = Object.entries(patches).map(([sel, type], id) => ({
|
||||
id,
|
||||
selector: parseSelector(sel) as Selector,
|
||||
op: parseTypeText(type) as TypePatchOp,
|
||||
}));
|
||||
const kind = /\.[cm]?ts$/.test(file) ? "ts" : "js";
|
||||
const sf = nodeFrontendServices().parse(`/pkg/${file}`, text, kind);
|
||||
const result = patchFile(file, text, sf, requests);
|
||||
const out = applyPatchEdits(text, result.edits);
|
||||
expect(out.split("\n").length).toBe(text.split("\n").length);
|
||||
return { ...result, text: out };
|
||||
}
|
||||
|
||||
const COMMANDER_LIKE = `class Command {
|
||||
/**
|
||||
* @typedef HelpContext
|
||||
* @type {object}
|
||||
* @property {boolean} error
|
||||
* @property {number} helpWidth
|
||||
* @property {function} write - includes stripColor if needed
|
||||
*
|
||||
* @returns {HelpContext}
|
||||
* @private
|
||||
*/
|
||||
_getOutputContext(contextOptions) {
|
||||
let helpWidth;
|
||||
return { error: false, write: () => {}, helpWidth };
|
||||
}
|
||||
}
|
||||
module.exports = { Command };
|
||||
`;
|
||||
|
||||
const MS_LIKE = `/**
|
||||
* @param {String|Number} val
|
||||
* @param {Object} [options]
|
||||
* @return {String|Number}
|
||||
*/
|
||||
module.exports = function (val, options) {
|
||||
return parse(val);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {String} str
|
||||
* @return {Number}
|
||||
*/
|
||||
function parse(str) {
|
||||
if (str.length > 100) {
|
||||
return;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
`;
|
||||
|
||||
describe("JSDoc", () => {
|
||||
test("a @typedef property widens", () => {
|
||||
const r = patch("lib/command.js", COMMANDER_LIKE, { "HelpContext.helpWidth": "| undefined" });
|
||||
expect(r.text).toContain("@property {number | undefined} helpWidth");
|
||||
expect(r.text).toContain("@returns {HelpContext}");
|
||||
expect(r.matches).toMatchObject([
|
||||
{ line: 6, before: "number", after: "number | undefined", redundant: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns of export= and of a file-local function widen", () => {
|
||||
const r = patch("index.js", MS_LIKE, {
|
||||
"export=()": "| undefined",
|
||||
"index.js:parse()": "| undefined",
|
||||
});
|
||||
expect(r.text).toContain("@return {String|Number | undefined}\n */\nmodule.exports");
|
||||
expect(r.text).toContain("@return {Number | undefined}\n */\nfunction parse");
|
||||
expect(r.problems).toEqual([]);
|
||||
});
|
||||
|
||||
test("a file prefix restricts the root to that file", () => {
|
||||
const r = patch("other.js", MS_LIKE, { "index.js:parse()": "| undefined" });
|
||||
expect(r.matches).toEqual([]);
|
||||
expect(r.text).toBe(MS_LIKE);
|
||||
});
|
||||
|
||||
test("parameters, optional parameters and redundancy", () => {
|
||||
const r = patch("index.js", MS_LIKE, {
|
||||
"export=(options)": "| undefined",
|
||||
"parse(str)": "string | undefined",
|
||||
});
|
||||
expect(r.matches[0]).toMatchObject({ redundant: true });
|
||||
expect(r.text).toContain("@param {string | undefined} str");
|
||||
});
|
||||
|
||||
test("a replacement on an undocumented declaration inserts a tag", () => {
|
||||
const text =
|
||||
"/** Adds. */\nfunction add(a, b) { return a + b; }\nfunction sub(a, b) { return a - b; }\n";
|
||||
const r = patch("index.js", text, { "add()": "number", "sub(a)": "number" });
|
||||
expect(r.text).toBe(
|
||||
"/** Adds. @returns {number} */\nfunction add(a, b) { return a + b; }\n/** @param {number} a */ function sub(a, b) { return a - b; }\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("a widening needs a written type", () => {
|
||||
const r = patch("index.js", "function f() { return 1; }\n", { "f()": "| undefined" });
|
||||
expect(r.problems[0]!.message).toContain("no written type to widen");
|
||||
});
|
||||
|
||||
test("members of exported objects, classes and constructor assignments", () => {
|
||||
const text = `class Box {
|
||||
constructor() {
|
||||
/** @type {string} */
|
||||
this.label = "";
|
||||
}
|
||||
/** @returns {number} */
|
||||
size() { return 1; }
|
||||
}
|
||||
module.exports = {
|
||||
/** @returns {string} */
|
||||
name() { return ""; },
|
||||
Box,
|
||||
};
|
||||
`;
|
||||
const r = patch("index.js", text, {
|
||||
"Box.label": "| undefined",
|
||||
"Box.size()": "| null",
|
||||
"export=.name()": "| undefined",
|
||||
});
|
||||
expect(r.text).toContain("@type {string | undefined} */\n this.label");
|
||||
expect(r.text).toContain("@returns {number | null} */\n size()");
|
||||
expect(r.text).toContain("@returns {string | undefined} */\n name()");
|
||||
});
|
||||
|
||||
test("function types are parenthesized before widening", () => {
|
||||
const r = patch("index.js", "/** @type {(a: string) => void} */\nvar cb = null;\n", {
|
||||
cb: "| undefined",
|
||||
});
|
||||
expect(r.text).toContain("@type {((a: string) => void) | undefined}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("declaration files", () => {
|
||||
const DTS = `export interface Options {
|
||||
name: string;
|
||||
retries?: number;
|
||||
env: Env;
|
||||
}
|
||||
export interface Env {
|
||||
[key: string]: string;
|
||||
}
|
||||
export declare function fetchUser(id: string): Promise<{ email: string; id: string }>;
|
||||
export declare function load(): User;
|
||||
interface User { name: string; tags: string[] }
|
||||
declare function ms(value: string): number;
|
||||
declare function ms(value: number): string;
|
||||
declare namespace ms { const version: string; }
|
||||
export = ms;
|
||||
`;
|
||||
|
||||
test("members, optional members, index signatures and navigation", () => {
|
||||
const r = patch("index.d.ts", DTS, {
|
||||
"Options.name": "| undefined",
|
||||
"Options.retries": "| undefined",
|
||||
"Env[string]": "| undefined",
|
||||
"fetchUser().email": "string | null",
|
||||
"load().tags[]": "| undefined",
|
||||
"Options.env[string]": "| null",
|
||||
});
|
||||
expect(r.text).toContain(" name: string | undefined;");
|
||||
expect(r.text).toContain("[key: string]: string | undefined | null;");
|
||||
expect(r.text).toContain("Promise<{ email: string | null; id: string }>");
|
||||
expect(r.text).toContain("tags: (string | undefined)[]");
|
||||
expect(r.matches.find((m) => m.id === 1)).toMatchObject({ redundant: true });
|
||||
});
|
||||
|
||||
test("export= widens every overload, and a replacement of several is refused", () => {
|
||||
const r = patch("index.d.ts", DTS, { "export=()": "| undefined" });
|
||||
expect(r.text).toContain("declare function ms(value: string): number | undefined;");
|
||||
expect(r.text).toContain("declare function ms(value: number): string | undefined;");
|
||||
const refused = patch("index.d.ts", DTS, { "export=()": "number" });
|
||||
expect(refused.problems[0]!.message).toContain("matches 2 declarations");
|
||||
expect(refused.text).toBe(DTS);
|
||||
});
|
||||
|
||||
test("namespace members and generic types", () => {
|
||||
const r = patch("index.d.ts", DTS, { "ms.version": "| undefined" });
|
||||
expect(r.text).toContain("const version: string | undefined;");
|
||||
const generic = patch(
|
||||
"index.d.ts",
|
||||
"export declare function f(): Map<string, { a: number }>;\n",
|
||||
{ "f().a": "| undefined" },
|
||||
);
|
||||
expect(generic.problems[0]!.message).toContain("cannot follow");
|
||||
});
|
||||
|
||||
test("multi-line types keep their line count when replaced", () => {
|
||||
const text = "export interface A {\n b: {\n c: number;\n };\n}\n";
|
||||
const r = patch("index.d.ts", text, { "A.b": "string" });
|
||||
expect(r.text.startsWith("export interface A {\n b: string\n\n;")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,970 @@
|
||||
/* Finds the declarations a type patch selector names in one package file
|
||||
* and computes the text edits that apply the patch.
|
||||
*
|
||||
* Declaration files (and TypeScript sources) are matched on their syntax
|
||||
* tree: the edit replaces or widens a written type node. JavaScript is
|
||||
* matched on its JSDoc: the edit replaces or widens the `{...}` of a
|
||||
* `@typedef`'s `@property`, a `@returns`, a `@param` or a `@type` tag.
|
||||
* Executable code is never edited, and no edit adds or removes a line
|
||||
* break, so every later pass and diagnostic keeps its line. */
|
||||
|
||||
import * as ts from "../ts7/syntax.js";
|
||||
import { tokenStartOfNode } from "../ts7/node-functions.js";
|
||||
import {
|
||||
normalizeType,
|
||||
splitUnion,
|
||||
type Selector,
|
||||
type SelectorSegment,
|
||||
type TypePatchOp,
|
||||
} from "./selector.js";
|
||||
|
||||
export interface PatchRequest {
|
||||
/** Caller's id, echoed in matches and problems. */
|
||||
id: number;
|
||||
selector: Selector;
|
||||
op: TypePatchOp;
|
||||
}
|
||||
|
||||
export interface PatchMatch {
|
||||
id: number;
|
||||
/** 1-based line of the patched type. */
|
||||
line: number;
|
||||
before: string;
|
||||
after: string;
|
||||
/** The declaration already has the patched type: nothing was edited. */
|
||||
redundant: boolean;
|
||||
}
|
||||
|
||||
export interface PatchProblem {
|
||||
id: number;
|
||||
message: string;
|
||||
/** Reported even when the selector matched elsewhere (an ambiguous
|
||||
* replacement left a declaration unpatched). */
|
||||
always?: true;
|
||||
}
|
||||
|
||||
export interface PatchEdit {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FilePatchResult {
|
||||
edits: PatchEdit[];
|
||||
matches: PatchMatch[];
|
||||
problems: PatchProblem[];
|
||||
}
|
||||
|
||||
/** A place a type is (or can be) written. */
|
||||
interface Slot {
|
||||
/** The written type's span, or null when there is none. */
|
||||
span: { start: number; end: number } | null;
|
||||
/** The declaration is optional (`x?:`, `[x]`), so it already admits
|
||||
* undefined. */
|
||||
optional: boolean;
|
||||
/** Where a replacement goes when nothing is written: the inserted text
|
||||
* is `prefix + type + suffix`. */
|
||||
insert: { at: number; prefix: string; suffix: string } | null;
|
||||
/** A JSDoc slot inside a comment (one-line edits only). */
|
||||
jsdoc: boolean;
|
||||
/** The type is an array element (`T[]`): a union must be wrapped. */
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
type Step = { slots: Slot[] } | { problem: string };
|
||||
|
||||
/** Type texts that need parentheses before `| T` is appended. */
|
||||
function needsParens(text: string): boolean {
|
||||
return /=>|\?|\bextends\b|^\s*new\b|^\s*function\b|^\s*!/.test(text);
|
||||
}
|
||||
|
||||
function lineOf(text: string, pos: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < pos && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
||||
return line;
|
||||
}
|
||||
|
||||
function editsFor(
|
||||
text: string,
|
||||
req: PatchRequest,
|
||||
slot: Slot,
|
||||
): { edits: PatchEdit[]; match: PatchMatch } | { problem: string } {
|
||||
const op = req.op;
|
||||
if (slot.span === null) {
|
||||
if (op.kind === "widen" || slot.insert === null)
|
||||
return {
|
||||
problem: `'${req.selector.text}' has no written type to widen; give the whole type instead`,
|
||||
};
|
||||
const ins = slot.insert;
|
||||
return {
|
||||
edits: [{ start: ins.at, end: ins.at, text: ins.prefix + op.text + ins.suffix }],
|
||||
match: {
|
||||
id: req.id,
|
||||
line: lineOf(text, ins.at),
|
||||
before: "(none)",
|
||||
after: op.text,
|
||||
redundant: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { start, end } = slot.span;
|
||||
const original = text.slice(start, end);
|
||||
const line = lineOf(text, start);
|
||||
if (op.kind === "widen") {
|
||||
const present = new Set(splitUnion(original).map(normalizeType));
|
||||
if (slot.optional) present.add("undefined");
|
||||
const add = op.arms.filter((a) => !present.has(normalizeType(a)));
|
||||
if (add.length === 0)
|
||||
return {
|
||||
edits: [],
|
||||
match: { id: req.id, line, before: original, after: original, redundant: true },
|
||||
};
|
||||
// `(() => void) | undefined`; an array element wraps the whole union:
|
||||
// `(string | undefined)[]`.
|
||||
const inner = needsParens(original);
|
||||
const outer = slot.wrap === true;
|
||||
const suffix = ` | ${add.join(" | ")}`;
|
||||
const open = (outer ? "(" : "") + (inner ? "(" : "");
|
||||
const close = (inner ? ")" : "") + suffix + (outer ? ")" : "");
|
||||
const edits: PatchEdit[] =
|
||||
open === ""
|
||||
? [{ start: end, end, text: close }]
|
||||
: [
|
||||
{ start, end: start, text: open },
|
||||
{ start: end, end, text: close },
|
||||
];
|
||||
const after = `${open}${original}${close}`;
|
||||
return { edits, match: { id: req.id, line, before: original, after, redundant: false } };
|
||||
}
|
||||
if (normalizeType(original) === normalizeType(op.text))
|
||||
return {
|
||||
edits: [],
|
||||
match: { id: req.id, line, before: original, after: original, redundant: true },
|
||||
};
|
||||
const breaks = original.split("\n").length - 1;
|
||||
if (breaks > 0 && slot.jsdoc)
|
||||
return {
|
||||
problem: `'${req.selector.text}' names a JSDoc type that spans lines; replace it in one line upstream or widen it`,
|
||||
};
|
||||
const replacement =
|
||||
slot.wrap === true && (splitUnion(op.text).length > 1 || needsParens(op.text))
|
||||
? `(${op.text})`
|
||||
: op.text;
|
||||
return {
|
||||
edits: [{ start, end, text: replacement + "\n".repeat(breaks) }],
|
||||
match: { id: req.id, line, before: original, after: replacement, redundant: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies a file's requests. `sf` is the file's syntax tree, parsed as
|
||||
* TypeScript for declaration files and TypeScript sources and as
|
||||
* JavaScript otherwise. */
|
||||
export function patchFile(
|
||||
relPath: string,
|
||||
text: string,
|
||||
sf: ts.SourceFile,
|
||||
requests: readonly PatchRequest[],
|
||||
): FilePatchResult {
|
||||
const js = !/\.(d\.)?[mc]?ts$/.test(relPath);
|
||||
const resolver = js ? new JsResolver(text, sf) : new TsResolver(text, sf);
|
||||
const result: FilePatchResult = { edits: [], matches: [], problems: [] };
|
||||
for (const req of requests) {
|
||||
if (req.selector.file !== null && req.selector.file !== relPath) continue;
|
||||
const step = resolver.resolve(req.selector);
|
||||
if ("problem" in step) {
|
||||
result.problems.push({ id: req.id, message: step.problem });
|
||||
continue;
|
||||
}
|
||||
if (step.slots.length === 0) continue;
|
||||
if (req.op.kind === "replace" && step.slots.length > 1) {
|
||||
result.problems.push({
|
||||
id: req.id,
|
||||
message: `'${req.selector.text}' matches ${step.slots.length} declarations in ${relPath}; a replacement must name one (widen them, or replace the declaration file)`,
|
||||
always: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const slot of step.slots) {
|
||||
const out = editsFor(text, req, slot);
|
||||
if ("problem" in out) {
|
||||
result.problems.push({ id: req.id, message: out.problem });
|
||||
continue;
|
||||
}
|
||||
result.edits.push(...out.edits);
|
||||
result.matches.push(out.match);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Applies edits (sorted by position; at one position, in request order).
|
||||
* An edit overlapping an earlier replacement is dropped. */
|
||||
export function applyPatchEdits(text: string, edits: readonly PatchEdit[]): string {
|
||||
const sorted = edits
|
||||
.map((e, i) => ({ e, i }))
|
||||
.sort((a, b) => a.e.start - b.e.start || a.e.end - b.e.end || a.i - b.i)
|
||||
.map((x) => x.e);
|
||||
let out = "";
|
||||
let at = 0;
|
||||
for (const e of sorted) {
|
||||
if (e.start < at) continue;
|
||||
out += text.slice(at, e.start) + e.text;
|
||||
at = e.end;
|
||||
}
|
||||
return out + text.slice(at);
|
||||
}
|
||||
|
||||
function nameText(name: ts.Node | undefined): string | null {
|
||||
if (name === undefined) return null;
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isPrivateIdentifier(name))
|
||||
return name.text;
|
||||
if (ts.isNumericLiteral(name)) return name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasDefaultModifier(node: ts.Node): boolean {
|
||||
return (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
||||
}
|
||||
|
||||
/* ── declaration files ─────────────────────────────────────────────── */
|
||||
|
||||
type TsPos = { node: ts.Node; optional: boolean; element?: boolean };
|
||||
|
||||
class TsResolver {
|
||||
/** Top-level declarations by name, ambient module bodies included. */
|
||||
private readonly roots = new Map<string, ts.Node[]>();
|
||||
private exportEquals: string | null = null;
|
||||
private exportDefault: ts.Node[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly text: string,
|
||||
sf: ts.SourceFile,
|
||||
) {
|
||||
this.collect(sf.statements.nodes, true);
|
||||
}
|
||||
|
||||
private collect(statements: readonly ts.Statement[], top: boolean): void {
|
||||
for (const s of statements) {
|
||||
if (ts.isExportAssignment(s)) {
|
||||
if (ts.isIdentifier(s.expression)) {
|
||||
if (s.isExportEquals) this.exportEquals = s.expression.text;
|
||||
else this.exportDefault.push(...this.rootsNamed(s.expression.text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ts.isModuleDeclaration(s) && ts.isStringLiteral(s.name)) {
|
||||
// `declare module "pkg" { ... }`: its statements are the module's.
|
||||
if (top && s.body !== undefined && ts.isModuleBlock(s.body))
|
||||
this.collect(s.body.statements.nodes, false);
|
||||
continue;
|
||||
}
|
||||
if (ts.isVariableStatement(s)) {
|
||||
for (const d of s.declarationList.declarations.nodes) this.add(nameText(d.name), d);
|
||||
continue;
|
||||
}
|
||||
const named =
|
||||
ts.isFunctionDeclaration(s) ||
|
||||
ts.isClassDeclaration(s) ||
|
||||
ts.isInterfaceDeclaration(s) ||
|
||||
ts.isTypeAliasDeclaration(s) ||
|
||||
ts.isModuleDeclaration(s) ||
|
||||
ts.isEnumDeclaration(s);
|
||||
if (!named) continue;
|
||||
this.add(nameText((s as ts.FunctionDeclaration).name), s);
|
||||
if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && hasDefaultModifier(s))
|
||||
this.exportDefault.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
private add(name: string | null, node: ts.Node): void {
|
||||
if (name === null) return;
|
||||
const list = this.roots.get(name);
|
||||
if (list === undefined) this.roots.set(name, [node]);
|
||||
else list.push(node);
|
||||
}
|
||||
|
||||
private rootsNamed(name: string): ts.Node[] {
|
||||
return this.roots.get(name) ?? [];
|
||||
}
|
||||
|
||||
resolve(sel: Selector): Step {
|
||||
let start: ts.Node[];
|
||||
if (sel.root === "export=")
|
||||
start = this.exportEquals === null ? [] : this.rootsNamed(this.exportEquals);
|
||||
else if (sel.root === "default") start = this.exportDefault;
|
||||
else start = this.rootsNamed(sel.root);
|
||||
let positions: TsPos[] = start.map((node) => ({ node, optional: false }));
|
||||
// A position that cannot continue is a problem only when no other
|
||||
// position of the same name matches (a function merged with a
|
||||
// namespace, an export and its declaration).
|
||||
let problem: string | null = null;
|
||||
for (const seg of sel.segments) {
|
||||
const next: TsPos[] = [];
|
||||
for (const pos of positions) {
|
||||
const step = this.step(pos, seg);
|
||||
if (typeof step === "string") problem ??= step;
|
||||
else next.push(...step);
|
||||
}
|
||||
positions = next;
|
||||
if (positions.length === 0)
|
||||
return problem === null ? { slots: [] } : { problem: `'${sel.text}' ${problem}` };
|
||||
}
|
||||
const slots: Slot[] = [];
|
||||
for (const pos of positions) {
|
||||
const slot = this.slotOf(pos);
|
||||
if (typeof slot === "string") problem ??= slot;
|
||||
else slots.push(slot);
|
||||
}
|
||||
if (slots.length === 0 && problem !== null) return { problem: `'${sel.text}' ${problem}` };
|
||||
return { slots };
|
||||
}
|
||||
|
||||
private typeSlot(type: ts.TypeNode | undefined, optional: boolean): Slot {
|
||||
return {
|
||||
span: type === undefined ? null : { start: tokenStartOfNode(type), end: type.end },
|
||||
optional,
|
||||
insert: null,
|
||||
jsdoc: false,
|
||||
};
|
||||
}
|
||||
|
||||
private slotOf(pos: TsPos): Slot | string {
|
||||
const n = pos.node;
|
||||
if (ts.isTypeNode(n)) return { ...this.typeSlot(n, pos.optional), wrap: pos.element === true };
|
||||
if (ts.isPropertySignature(n) || ts.isPropertyDeclaration(n))
|
||||
return this.typeSlot(
|
||||
n.type,
|
||||
pos.optional || n.postfixToken?.kind === ts.SyntaxKind.QuestionToken,
|
||||
);
|
||||
if (
|
||||
ts.isVariableDeclaration(n) ||
|
||||
ts.isTypeAliasDeclaration(n) ||
|
||||
ts.isIndexSignatureDeclaration(n)
|
||||
)
|
||||
return this.typeSlot(n.type, false);
|
||||
if (ts.isParameter(n))
|
||||
return this.typeSlot(n.type, n.questionToken !== undefined || n.initializer !== undefined);
|
||||
if (ts.isGetAccessor(n)) return this.typeSlot(n.type, false);
|
||||
if (this.isFunctionLike(n))
|
||||
return "names a function; add '()' for its return type or '(name)' for a parameter";
|
||||
return "names a declaration without a type of its own; name one of its members";
|
||||
}
|
||||
|
||||
private isFunctionLike(n: ts.Node): boolean {
|
||||
return (
|
||||
ts.isFunctionDeclaration(n) ||
|
||||
ts.isMethodSignatureDeclaration(n) ||
|
||||
ts.isMethodDeclaration(n) ||
|
||||
ts.isCallSignatureDeclaration(n) ||
|
||||
ts.isFunctionTypeNode(n)
|
||||
);
|
||||
}
|
||||
|
||||
/** The type a position stands for when a segment navigates into it. */
|
||||
private typeOf(n: ts.Node): ts.TypeNode | undefined {
|
||||
if (
|
||||
ts.isPropertySignature(n) ||
|
||||
ts.isPropertyDeclaration(n) ||
|
||||
ts.isVariableDeclaration(n) ||
|
||||
ts.isTypeAliasDeclaration(n) ||
|
||||
ts.isParameter(n)
|
||||
)
|
||||
return n.type;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Object-like members a type node holds, following parentheses, unions,
|
||||
* intersections, `Promise<T>` and references to declarations in this
|
||||
* file. Null when it passes through a type it cannot follow. */
|
||||
private containers(type: ts.TypeNode, depth = 0): ts.Node[] | null {
|
||||
if (depth > 8) return null;
|
||||
if (ts.isTypeLiteralNode(type)) return [type];
|
||||
if (ts.isParenthesizedTypeNode(type)) return this.containers(type.type, depth + 1);
|
||||
if (ts.isUnionTypeNode(type) || ts.isIntersectionTypeNode(type)) {
|
||||
const out: ts.Node[] = [];
|
||||
for (const arm of type.types.nodes) {
|
||||
if (arm.kind === ts.SyntaxKind.LiteralType || this.isKeywordType(arm)) continue;
|
||||
const c = this.containers(arm, depth + 1);
|
||||
if (c === null) return null;
|
||||
out.push(...c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
|
||||
const name = type.typeName.text;
|
||||
const args = type.typeArguments?.nodes ?? [];
|
||||
if ((name === "Promise" || name === "PromiseLike") && args.length === 1)
|
||||
return this.containers(args[0]!, depth + 1);
|
||||
if (args.length > 0) return null;
|
||||
const decls = this.rootsNamed(name);
|
||||
if (decls.length === 0) return null;
|
||||
const out: ts.Node[] = [];
|
||||
for (const d of decls) {
|
||||
if (ts.isTypeAliasDeclaration(d)) {
|
||||
const c = this.containers(d.type, depth + 1);
|
||||
if (c === null) return null;
|
||||
out.push(...c);
|
||||
} else out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isKeywordType(n: ts.Node): boolean {
|
||||
return (
|
||||
n.kind === ts.SyntaxKind.NullKeyword ||
|
||||
n.kind === ts.SyntaxKind.UndefinedKeyword ||
|
||||
n.kind === ts.SyntaxKind.StringKeyword ||
|
||||
n.kind === ts.SyntaxKind.NumberKeyword ||
|
||||
n.kind === ts.SyntaxKind.BooleanKeyword
|
||||
);
|
||||
}
|
||||
|
||||
private membersOf(n: ts.Node): readonly ts.Node[] | null {
|
||||
if (ts.isInterfaceDeclaration(n) || ts.isTypeLiteralNode(n) || ts.isClassDeclaration(n))
|
||||
return n.members.nodes;
|
||||
if (ts.isModuleDeclaration(n))
|
||||
return n.body !== undefined && ts.isModuleBlock(n.body) ? n.body.statements.nodes : [];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Expands a position into the object-like nodes that hold members. */
|
||||
private holders(pos: TsPos): ts.Node[] | string {
|
||||
const n = pos.node;
|
||||
if (this.membersOf(n) !== null) return [n];
|
||||
if (ts.isTypeAliasDeclaration(n) || ts.isTypeNode(n) || this.typeOf(n) !== undefined) {
|
||||
const type = ts.isTypeNode(n) ? n : this.typeOf(n)!;
|
||||
const c = this.containers(type);
|
||||
if (c === null)
|
||||
return "passes through a type it cannot follow (a generic, mapped, conditional or imported type); name the declared type itself";
|
||||
return c;
|
||||
}
|
||||
if (ts.isFunctionDeclaration(n) || ts.isEnumDeclaration(n)) return [];
|
||||
return "has a member segment after a declaration without members";
|
||||
}
|
||||
|
||||
private signaturesOf(pos: TsPos): ts.Node[] | string {
|
||||
const n = pos.node;
|
||||
if (this.isFunctionLike(n)) return [n];
|
||||
if (ts.isInterfaceDeclaration(n) || ts.isTypeLiteralNode(n))
|
||||
return n.members.nodes.filter((m) => ts.isCallSignatureDeclaration(m));
|
||||
const type = ts.isTypeNode(n) ? n : this.typeOf(n);
|
||||
if (type !== undefined) {
|
||||
let t: ts.TypeNode = type;
|
||||
while (ts.isParenthesizedTypeNode(t)) t = t.type;
|
||||
if (ts.isFunctionTypeNode(t)) return [t];
|
||||
const c = this.containers(t);
|
||||
if (c === null)
|
||||
return "passes through a type it cannot follow; name the declared type itself";
|
||||
return c.flatMap((h) =>
|
||||
ts.isInterfaceDeclaration(h) || ts.isTypeLiteralNode(h)
|
||||
? h.members.nodes.filter((m) => ts.isCallSignatureDeclaration(m))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (ts.isModuleDeclaration(n) || ts.isClassDeclaration(n)) return [];
|
||||
return "has '()' after a declaration that is not a function";
|
||||
}
|
||||
|
||||
private step(pos: TsPos, seg: SelectorSegment): TsPos[] | string {
|
||||
switch (seg.kind) {
|
||||
case "member": {
|
||||
const holders = this.holders(pos);
|
||||
if (typeof holders === "string") return holders;
|
||||
const out: TsPos[] = [];
|
||||
for (const h of holders) {
|
||||
const members = this.membersOf(h) ?? [];
|
||||
for (const m of members) {
|
||||
if (ts.isVariableStatement(m)) {
|
||||
for (const d of m.declarationList.declarations.nodes)
|
||||
if (nameText(d.name) === seg.name) out.push({ node: d, optional: false });
|
||||
continue;
|
||||
}
|
||||
if (nameText((m as ts.PropertySignatureDeclaration).name) === seg.name)
|
||||
out.push({ node: m, optional: false });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "return":
|
||||
case "param": {
|
||||
const sigs = this.signaturesOf(pos);
|
||||
if (typeof sigs === "string") return sigs;
|
||||
const out: TsPos[] = [];
|
||||
for (const s of sigs) {
|
||||
const sig = s as ts.FunctionDeclaration;
|
||||
if (seg.kind === "return") {
|
||||
if (sig.type !== undefined) out.push({ node: sig.type, optional: false });
|
||||
else return "has no written return type";
|
||||
} else {
|
||||
for (const p of sig.parameters.nodes)
|
||||
if (nameText(p.name) === seg.name) out.push({ node: p, optional: false });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "element": {
|
||||
let t: ts.TypeNode | undefined = ts.isTypeNode(pos.node) ? pos.node : this.typeOf(pos.node);
|
||||
while (t !== undefined && ts.isParenthesizedTypeNode(t)) t = t.type;
|
||||
if (t !== undefined && ts.isArrayTypeNode(t))
|
||||
return [{ node: t.elementType, optional: false, element: true }];
|
||||
if (
|
||||
t !== undefined &&
|
||||
ts.isTypeReferenceNode(t) &&
|
||||
ts.isIdentifier(t.typeName) &&
|
||||
(t.typeName.text === "Array" || t.typeName.text === "ReadonlyArray") &&
|
||||
t.typeArguments?.nodes.length === 1
|
||||
)
|
||||
return [{ node: t.typeArguments.nodes[0]!, optional: false }];
|
||||
return "has '[]' after a type that is not an array";
|
||||
}
|
||||
case "index": {
|
||||
const holders = this.holders(pos);
|
||||
if (typeof holders === "string") return holders;
|
||||
const want =
|
||||
seg.key === "string" ? ts.SyntaxKind.StringKeyword : ts.SyntaxKind.NumberKeyword;
|
||||
const out: TsPos[] = [];
|
||||
for (const h of holders)
|
||||
for (const m of this.membersOf(h) ?? [])
|
||||
if (ts.isIndexSignatureDeclaration(m) && m.parameters.nodes[0]?.type?.kind === want)
|
||||
out.push({ node: m, optional: false });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── JSDoc in JavaScript ───────────────────────────────────────────── */
|
||||
|
||||
interface JsDocTag {
|
||||
tag: string;
|
||||
/** Inner span of the `{...}` type, or null. */
|
||||
type: { start: number; end: number } | null;
|
||||
/** Parameter or property name (brackets and defaults removed). */
|
||||
name: string | null;
|
||||
optional: boolean;
|
||||
/** Offset of the `@`. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface JsDocComment {
|
||||
start: number;
|
||||
end: number;
|
||||
tags: JsDocTag[];
|
||||
}
|
||||
|
||||
function parseJsDoc(text: string, start: number, end: number): JsDocComment {
|
||||
const tags: JsDocTag[] = [];
|
||||
const re = /(^|[\s*])@([A-Za-z]+)/g;
|
||||
re.lastIndex = start + 3;
|
||||
const body = text.slice(0, end - 2);
|
||||
for (let m = re.exec(body); m !== null; m = re.exec(body)) {
|
||||
const at = m.index + m[1]!.length;
|
||||
const tag = m[2]!;
|
||||
let i = at + 1 + tag.length;
|
||||
while (i < body.length && (body[i] === " " || body[i] === "\t")) i++;
|
||||
let type: JsDocTag["type"] = null;
|
||||
if (body[i] === "{") {
|
||||
let depth = 0;
|
||||
let j = i;
|
||||
for (; j < body.length; j++) {
|
||||
if (body[j] === "{") depth++;
|
||||
else if (body[j] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
if (j >= body.length) continue;
|
||||
type = { start: i + 1, end: j };
|
||||
// Trim the inner span to the type text.
|
||||
while (type.start < type.end && /\s/.test(text[type.start]!)) type.start++;
|
||||
while (type.end > type.start && /\s/.test(text[type.end - 1]!)) type.end--;
|
||||
i = j + 1;
|
||||
re.lastIndex = i;
|
||||
}
|
||||
let name: string | null = null;
|
||||
let optional = false;
|
||||
const rest = /^[ \t]*(\[)?[ \t]*([A-Za-z_$][\w$.]*)/.exec(body.slice(i, i + 200));
|
||||
if (rest !== null) {
|
||||
name = rest[2]!;
|
||||
optional = rest[1] !== undefined;
|
||||
}
|
||||
if (type !== null && text[type.end - 1] === "=") optional = true;
|
||||
tags.push({ tag, type, name, optional, at });
|
||||
}
|
||||
return { start, end, tags };
|
||||
}
|
||||
|
||||
/** The spans of a file's string, template and regular expression literal
|
||||
* tokens. */
|
||||
function literalSpans(sf: ts.SourceFile): { start: number; end: number }[] {
|
||||
const out: { start: number; end: number }[] = [];
|
||||
const kinds = new Set([
|
||||
ts.SyntaxKind.StringLiteral,
|
||||
ts.SyntaxKind.NoSubstitutionTemplateLiteral,
|
||||
ts.SyntaxKind.TemplateHead,
|
||||
ts.SyntaxKind.TemplateMiddle,
|
||||
ts.SyntaxKind.TemplateTail,
|
||||
ts.SyntaxKind.RegularExpressionLiteral,
|
||||
]);
|
||||
const visit = (n: ts.Node): void => {
|
||||
if (kinds.has(n.kind)) out.push({ start: tokenStartOfNode(n), end: n.end });
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return out.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
const RETURN_TAGS = new Set(["returns", "return"]);
|
||||
const PROPERTY_TAGS = new Set(["property", "prop"]);
|
||||
|
||||
/** A declaration in JavaScript a selector can stand on. */
|
||||
type JsPos =
|
||||
| { kind: "decl"; node: ts.Node; owner: ts.Node; fn: ts.Node | null; members: ts.Node | null }
|
||||
| { kind: "typedef"; comment: JsDocComment; tagIndex: number; prefix: string }
|
||||
| { kind: "slot"; slot: Slot };
|
||||
|
||||
class JsResolver {
|
||||
private readonly comments: JsDocComment[] = [];
|
||||
private readonly roots = new Map<string, JsPos[]>();
|
||||
private readonly exportEquals: JsPos[] = [];
|
||||
private readonly exportDefault: JsPos[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly text: string,
|
||||
sf: ts.SourceFile,
|
||||
) {
|
||||
// JSDoc comments, not text inside string, template or regular
|
||||
// expression literals that looks like one.
|
||||
const literals = literalSpans(sf);
|
||||
const re = /\/\*\*(?!\/)[\s\S]*?\*\//g;
|
||||
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
||||
const inside = literals.find((l) => l.start <= m!.index && m!.index < l.end);
|
||||
if (inside !== undefined) {
|
||||
re.lastIndex = inside.end;
|
||||
continue;
|
||||
}
|
||||
this.comments.push(parseJsDoc(text, m.index, m.index + m[0].length));
|
||||
}
|
||||
for (const c of this.comments)
|
||||
c.tags.forEach((t, i) => {
|
||||
if ((t.tag === "typedef" || t.tag === "callback") && t.name !== null)
|
||||
this.add(t.name, { kind: "typedef", comment: c, tagIndex: i, prefix: "" });
|
||||
});
|
||||
// Declarations first: `exports.name = name` may come before the
|
||||
// function declaration it exports, which is hoisted.
|
||||
for (const s of sf.statements.nodes) if (!ts.isExpressionStatement(s)) this.collect(s);
|
||||
for (const s of sf.statements.nodes) if (ts.isExpressionStatement(s)) this.collect(s);
|
||||
}
|
||||
|
||||
private add(name: string, pos: JsPos): void {
|
||||
const list = this.roots.get(name);
|
||||
if (list === undefined) this.roots.set(name, [pos]);
|
||||
else list.push(pos);
|
||||
}
|
||||
|
||||
/** The position of a declaration whose value is `value` (or none). */
|
||||
private declPos(node: ts.Node, owner: ts.Node, value: ts.Node | undefined): JsPos {
|
||||
let fn: ts.Node | null = null;
|
||||
let members: ts.Node | null = null;
|
||||
if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) fn = node;
|
||||
else if (ts.isClassDeclaration(node)) members = node;
|
||||
if (value !== undefined) {
|
||||
let v = value;
|
||||
while (ts.isParenthesizedExpression(v)) v = v.expression;
|
||||
if (ts.isFunctionExpression(v) || ts.isArrowFunction(v)) fn = v;
|
||||
else if (ts.isClassExpression(v) || ts.isObjectLiteralExpression(v)) members = v;
|
||||
}
|
||||
return { kind: "decl", node, owner, fn, members };
|
||||
}
|
||||
|
||||
private collect(s: ts.Statement): void {
|
||||
if (ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) {
|
||||
const pos = this.declPos(s, s, undefined);
|
||||
const name = nameText(s.name);
|
||||
if (name !== null) this.add(name, pos);
|
||||
if (hasDefaultModifier(s)) this.exportDefault.push(pos);
|
||||
return;
|
||||
}
|
||||
if (ts.isVariableStatement(s)) {
|
||||
const decls = s.declarationList.declarations.nodes;
|
||||
for (const d of decls) {
|
||||
const name = nameText(d.name);
|
||||
if (name !== null)
|
||||
this.add(name, this.declPos(d, decls.length === 1 ? s : d, d.initializer));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ts.isExportAssignment(s)) {
|
||||
if (!s.isExportEquals) this.exportDefault.push(this.declPos(s, s, s.expression));
|
||||
return;
|
||||
}
|
||||
if (!ts.isExpressionStatement(s)) return;
|
||||
const e = s.expression;
|
||||
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return;
|
||||
const target = exportTarget(e.left);
|
||||
if (target === null) return;
|
||||
if (target === "export=") {
|
||||
this.exportEquals.push(this.declPos(s, s, e.right));
|
||||
// `module.exports = name`: the named declaration is the export.
|
||||
if (ts.isIdentifier(e.right)) {
|
||||
const named = this.roots.get(e.right.text);
|
||||
if (named !== undefined) this.exportEquals.push(...named);
|
||||
}
|
||||
} else if (ts.isIdentifier(e.right) && this.roots.has(e.right.text)) {
|
||||
// `exports.name = local`: the named declaration is the export (and
|
||||
// already is under its own name).
|
||||
if (target !== e.right.text)
|
||||
for (const pos of [...this.roots.get(e.right.text)!])
|
||||
if (pos.kind === "decl") this.add(target, pos);
|
||||
} else this.add(target, this.declPos(s, s, e.right));
|
||||
}
|
||||
|
||||
/** The last JSDoc comment directly before a node's first token. */
|
||||
private docOf(owner: ts.Node): JsDocComment | null {
|
||||
const start = tokenStartOfNode(owner);
|
||||
for (let i = this.comments.length - 1; i >= 0; i--) {
|
||||
const c = this.comments[i]!;
|
||||
if (c.end > start) continue;
|
||||
return /^\s*$/.test(this.text.slice(c.end, start)) ? c : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Tags of a comment that belong to the declaration it documents, not
|
||||
* to a `@typedef` or `@callback` block inside it. */
|
||||
private ownTags(c: JsDocComment): JsDocTag[] {
|
||||
const out: JsDocTag[] = [];
|
||||
let inBlock = false;
|
||||
for (const t of c.tags) {
|
||||
if (t.tag === "typedef" || t.tag === "callback") {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (inBlock && (PROPERTY_TAGS.has(t.tag) || t.tag === "type")) continue;
|
||||
if (inBlock && t.tag !== "param" && !RETURN_TAGS.has(t.tag)) inBlock = false;
|
||||
if (inBlock) continue;
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private tagSlot(tag: JsDocTag): Slot {
|
||||
return { span: tag.type, optional: tag.optional, insert: null, jsdoc: true };
|
||||
}
|
||||
|
||||
/** A slot for a tag of the declaration `owner` documents; when it has no
|
||||
* such tag, a replacement inserts one. */
|
||||
private docSlot(owner: ts.Node, tagNames: ReadonlySet<string>, name: string | null): Slot {
|
||||
const doc = this.docOf(owner);
|
||||
if (doc !== null) {
|
||||
const tag = this.ownTags(doc).find(
|
||||
(t) => tagNames.has(t.tag) && (name === null || t.name === name),
|
||||
);
|
||||
if (tag !== undefined && tag.type !== null) return this.tagSlot(tag);
|
||||
}
|
||||
const tagName = [...tagNames][0]!;
|
||||
const named = name === null ? "" : ` ${name}`;
|
||||
if (doc !== null) {
|
||||
const at = doc.end - 2;
|
||||
const pad = /\s/.test(this.text[at - 1] ?? "") ? "" : " ";
|
||||
return {
|
||||
span: null,
|
||||
optional: false,
|
||||
insert: { at, prefix: `${pad}@${tagName} {`, suffix: `}${named} ` },
|
||||
jsdoc: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
span: null,
|
||||
optional: false,
|
||||
insert: { at: tokenStartOfNode(owner), prefix: `/** @${tagName} {`, suffix: `}${named} */ ` },
|
||||
jsdoc: true,
|
||||
};
|
||||
}
|
||||
|
||||
resolve(sel: Selector): Step {
|
||||
let positions: JsPos[];
|
||||
if (sel.root === "export=") positions = this.exportEquals;
|
||||
else if (sel.root === "default") positions = this.exportDefault;
|
||||
else positions = this.roots.get(sel.root) ?? [];
|
||||
let problem: string | null = null;
|
||||
for (const seg of sel.segments) {
|
||||
const next: JsPos[] = [];
|
||||
for (const pos of positions) {
|
||||
const step = this.step(pos, seg);
|
||||
if (typeof step === "string") problem ??= step;
|
||||
else next.push(...step);
|
||||
}
|
||||
positions = next;
|
||||
if (positions.length === 0)
|
||||
return problem === null ? { slots: [] } : { problem: `'${sel.text}' ${problem}` };
|
||||
}
|
||||
const slots: Slot[] = [];
|
||||
for (const pos of positions) {
|
||||
const slot = this.slotOf(pos);
|
||||
if (typeof slot === "string") problem ??= slot;
|
||||
else if (slot !== null) slots.push(slot);
|
||||
}
|
||||
if (slots.length === 0 && problem !== null) return { problem: `'${sel.text}' ${problem}` };
|
||||
return { slots };
|
||||
}
|
||||
|
||||
private slotOf(pos: JsPos): Slot | string | null {
|
||||
if (pos.kind === "slot") return pos.slot;
|
||||
if (pos.kind === "typedef") {
|
||||
const own = pos.comment.tags[pos.tagIndex]!;
|
||||
if (pos.prefix === "") {
|
||||
// A typedef's own type: only when it is not the `object` that
|
||||
// its @property tags describe.
|
||||
const typeTag = this.typedefTypeTag(pos);
|
||||
if (typeTag === null || /^object$/i.test(this.text.slice(typeTag.start, typeTag.end)))
|
||||
return "names a @typedef of an object; name one of its properties";
|
||||
return { span: typeTag, optional: false, insert: null, jsdoc: true };
|
||||
}
|
||||
void own;
|
||||
return null;
|
||||
}
|
||||
if (pos.fn !== null && pos.members === null)
|
||||
return "names a function; add '()' for its return type or '(name)' for a parameter";
|
||||
if (pos.members !== null && !ts.isObjectLiteralExpression(pos.members))
|
||||
return "names a class; name one of its members";
|
||||
return this.docSlot(pos.owner, new Set(["type"]), null);
|
||||
}
|
||||
|
||||
private typedefTypeTag(pos: {
|
||||
comment: JsDocComment;
|
||||
tagIndex: number;
|
||||
}): { start: number; end: number } | null {
|
||||
const tags = pos.comment.tags;
|
||||
const own = tags[pos.tagIndex]!;
|
||||
if (own.type !== null) return own.type;
|
||||
for (let i = pos.tagIndex + 1; i < tags.length; i++) {
|
||||
const t = tags[i]!;
|
||||
if (t.tag === "type") return t.type;
|
||||
if (!PROPERTY_TAGS.has(t.tag)) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private step(pos: JsPos, seg: SelectorSegment): JsPos[] | string {
|
||||
// A JSDoc type is not navigated (a nested @property path continues
|
||||
// from its typedef position instead).
|
||||
if (pos.kind === "slot") return [];
|
||||
if (pos.kind === "typedef") {
|
||||
const tags = pos.comment.tags;
|
||||
const own = tags[pos.tagIndex]!;
|
||||
if (own.tag === "callback") {
|
||||
if (seg.kind !== "return" && seg.kind !== "param")
|
||||
return "has a member segment after a @callback";
|
||||
for (let i = pos.tagIndex + 1; i < tags.length; i++) {
|
||||
const t = tags[i]!;
|
||||
if (t.tag === "typedef" || t.tag === "callback") break;
|
||||
if (
|
||||
seg.kind === "return"
|
||||
? RETURN_TAGS.has(t.tag)
|
||||
: t.tag === "param" && t.name === seg.name
|
||||
)
|
||||
return t.type === null ? [] : [{ kind: "slot", slot: this.tagSlot(t) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (seg.kind !== "member") return "can only name properties of a @typedef";
|
||||
const name = pos.prefix === "" ? seg.name : `${pos.prefix}.${seg.name}`;
|
||||
for (let i = pos.tagIndex + 1; i < tags.length; i++) {
|
||||
const t = tags[i]!;
|
||||
if (t.tag === "typedef" || t.tag === "callback") break;
|
||||
if (!PROPERTY_TAGS.has(t.tag) && t.tag !== "type") break;
|
||||
if (PROPERTY_TAGS.has(t.tag) && t.name === name && t.type !== null) {
|
||||
// A nested property path continues from here.
|
||||
return [
|
||||
{ kind: "slot", slot: this.tagSlot(t) },
|
||||
{ ...pos, prefix: name },
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
switch (seg.kind) {
|
||||
case "return":
|
||||
if (pos.fn === null) return "has '()' after a declaration that is not a function";
|
||||
return [{ kind: "slot", slot: this.docSlot(this.docOwner(pos), RETURN_TAGS, null) }];
|
||||
case "param": {
|
||||
if (pos.fn === null) return "has '(name)' after a declaration that is not a function";
|
||||
const fn = pos.fn as ts.FunctionExpression;
|
||||
if (!fn.parameters.nodes.some((p) => nameText(p.name) === seg.name)) return [];
|
||||
return [
|
||||
{ kind: "slot", slot: this.docSlot(this.docOwner(pos), new Set(["param"]), seg.name) },
|
||||
];
|
||||
}
|
||||
case "member": {
|
||||
if (pos.members === null) return [];
|
||||
return this.membersNamed(pos.members, seg.name);
|
||||
}
|
||||
default:
|
||||
return "navigates into a JSDoc type; name the declared type itself";
|
||||
}
|
||||
}
|
||||
|
||||
/** The node whose leading comment documents a declaration. */
|
||||
private docOwner(pos: { owner: ts.Node }): ts.Node {
|
||||
return pos.owner;
|
||||
}
|
||||
|
||||
private membersNamed(holder: ts.Node, name: string): JsPos[] {
|
||||
const out: JsPos[] = [];
|
||||
if (ts.isObjectLiteralExpression(holder)) {
|
||||
for (const p of holder.properties.nodes) {
|
||||
if (nameText((p as ts.PropertyAssignment).name) !== name) continue;
|
||||
if (ts.isPropertyAssignment(p)) out.push(this.declPos(p, p, p.initializer));
|
||||
else if (ts.isMethodDeclaration(p)) out.push(this.declPos(p, p, undefined));
|
||||
// `{ parse }`: the declaration of that name.
|
||||
else if (ts.isShorthandPropertyAssignment(p)) out.push(...(this.roots.get(name) ?? []));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const cls = holder as ts.ClassDeclaration;
|
||||
for (const m of cls.members.nodes) {
|
||||
if (ts.isConstructorDeclaration(m) && m.body !== undefined) {
|
||||
// `this.name = value` in the constructor, documented by its comment.
|
||||
for (const s of m.body.statements.nodes) {
|
||||
if (!ts.isExpressionStatement(s) || !ts.isBinaryExpression(s.expression)) continue;
|
||||
const e = s.expression;
|
||||
if (
|
||||
e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
ts.isPropertyAccessExpression(e.left) &&
|
||||
e.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
||||
nameText(e.left.name) === name
|
||||
)
|
||||
out.push(this.declPos(s, s, e.right));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (nameText((m as ts.MethodDeclaration).name) !== name) continue;
|
||||
if (ts.isMethodDeclaration(m)) out.push(this.declPos(m, m, undefined));
|
||||
else if (ts.isPropertyDeclaration(m)) out.push(this.declPos(m, m, m.initializer));
|
||||
else if (ts.isGetAccessor(m))
|
||||
out.push({ kind: "decl", node: m, owner: m, fn: m, members: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** `module.exports` → "export="; `exports.x` / `module.exports.x` → "x". */
|
||||
function exportTarget(left: ts.Expression): string | null {
|
||||
if (!ts.isPropertyAccessExpression(left)) return null;
|
||||
const obj = left.expression;
|
||||
const prop = nameText(left.name);
|
||||
if (prop === null) return null;
|
||||
if (ts.isIdentifier(obj) && obj.text === "module" && prop === "exports") return "export=";
|
||||
if (ts.isIdentifier(obj) && obj.text === "exports") return prop;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(obj) &&
|
||||
ts.isIdentifier(obj.expression) &&
|
||||
obj.expression.text === "module" &&
|
||||
nameText(obj.name) === "exports"
|
||||
)
|
||||
return prop;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseSelector, parseTypeText, splitUnion } from "./selector.js";
|
||||
|
||||
describe("parseSelector", () => {
|
||||
test("members, returns, parameters, elements and index signatures", () => {
|
||||
expect(parseSelector("HelpContext.helpWidth")).toEqual({
|
||||
file: null,
|
||||
root: "HelpContext",
|
||||
segments: [{ kind: "member", name: "helpWidth" }],
|
||||
text: "HelpContext.helpWidth",
|
||||
});
|
||||
expect(parseSelector("index.js:parse()")).toMatchObject({
|
||||
file: "index.js",
|
||||
root: "parse",
|
||||
segments: [{ kind: "return" }],
|
||||
});
|
||||
expect(parseSelector("export=()")).toMatchObject({
|
||||
root: "export=",
|
||||
segments: [{ kind: "return" }],
|
||||
});
|
||||
expect(parseSelector("fetchUser(id)")).toMatchObject({
|
||||
segments: [{ kind: "param", name: "id" }],
|
||||
});
|
||||
expect(parseSelector("list()[].name")).toMatchObject({
|
||||
segments: [{ kind: "return" }, { kind: "element" }, { kind: "member", name: "name" }],
|
||||
});
|
||||
expect(parseSelector("Env[string]")).toMatchObject({
|
||||
segments: [{ kind: "index", key: "string" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed selectors say why", () => {
|
||||
expect(parseSelector("")).toEqual({ error: "is empty" });
|
||||
expect(parseSelector("../x.js:f()")).toEqual({ error: "names a file outside the package" });
|
||||
expect(parseSelector("1abc")).toHaveProperty("error");
|
||||
expect(parseSelector("a.")).toHaveProperty("error");
|
||||
expect(parseSelector("a(1)")).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTypeText", () => {
|
||||
test("a leading bar widens, anything else replaces", () => {
|
||||
expect(parseTypeText("| undefined")).toEqual({ kind: "widen", arms: ["undefined"] });
|
||||
expect(parseTypeText("| undefined | null")).toEqual({
|
||||
kind: "widen",
|
||||
arms: ["undefined", "null"],
|
||||
});
|
||||
expect(parseTypeText("string | null")).toEqual({ kind: "replace", text: "string | null" });
|
||||
expect(parseTypeText("a\nb")).toEqual({ error: "must be one line" });
|
||||
});
|
||||
|
||||
test("splitUnion keeps nested unions and arrows together", () => {
|
||||
expect(splitUnion("(a | b) | (() => c | d) | { x: e | f }")).toEqual([
|
||||
"(a | b)",
|
||||
"(() => c | d)",
|
||||
"{ x: e | f }",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/* Type patch selectors: names of declarations in a package, written the
|
||||
* same way for JSDoc in its JavaScript and for its declaration files.
|
||||
*
|
||||
* selector := [ file ":" ] root segment*
|
||||
* root := identifier | "export=" | "default"
|
||||
* segment := "." name member (interface, type alias, @typedef,
|
||||
* class, namespace, object type)
|
||||
* | "()" return type
|
||||
* | "(" name ")" type of the named parameter
|
||||
* | "[]" element type of an array type
|
||||
* | "[string]" | "[number]" value type of an index signature
|
||||
*
|
||||
* Examples: `HelpContext.helpWidth`, `index.js:parse()`, `export=()`,
|
||||
* `fetchUser().email`, `Env[string]`. */
|
||||
|
||||
export type SelectorSegment =
|
||||
| { kind: "member"; name: string }
|
||||
| { kind: "return" }
|
||||
| { kind: "param"; name: string }
|
||||
| { kind: "element" }
|
||||
| { kind: "index"; key: "string" | "number" };
|
||||
|
||||
export interface Selector {
|
||||
/** Package-relative file the root must be declared in, or null. */
|
||||
file: string | null;
|
||||
/** A declared name, `export=` or `default`. */
|
||||
root: string;
|
||||
segments: readonly SelectorSegment[];
|
||||
/** The selector as written. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
const IDENT = /^[A-Za-z_$][\w$]*/;
|
||||
|
||||
/** The parsed selector, or a message saying what is wrong with it. */
|
||||
export function parseSelector(text: string): Selector | { error: string } {
|
||||
let rest = text.trim();
|
||||
if (rest === "") return { error: "is empty" };
|
||||
let file: string | null = null;
|
||||
const colon = rest.indexOf(":");
|
||||
if (colon >= 0) {
|
||||
file = rest.slice(0, colon).replace(/^\.\//, "");
|
||||
rest = rest.slice(colon + 1);
|
||||
if (file === "" || file.startsWith("/") || file.split("/").includes(".."))
|
||||
return { error: "names a file outside the package" };
|
||||
}
|
||||
let root: string;
|
||||
if (rest.startsWith("export=")) {
|
||||
root = "export=";
|
||||
rest = rest.slice("export=".length);
|
||||
} else {
|
||||
const m = IDENT.exec(rest);
|
||||
if (m === null) return { error: "must start with a declared name, 'export=' or 'default'" };
|
||||
root = m[0];
|
||||
rest = rest.slice(root.length);
|
||||
}
|
||||
const segments: SelectorSegment[] = [];
|
||||
while (rest !== "") {
|
||||
if (rest.startsWith(".")) {
|
||||
const m = IDENT.exec(rest.slice(1));
|
||||
if (m === null) return { error: `has no member name after '.' at '${rest}'` };
|
||||
segments.push({ kind: "member", name: m[0] });
|
||||
rest = rest.slice(1 + m[0].length);
|
||||
} else if (rest.startsWith("()")) {
|
||||
segments.push({ kind: "return" });
|
||||
rest = rest.slice(2);
|
||||
} else if (rest.startsWith("(")) {
|
||||
const m = /^\(\s*([A-Za-z_$][\w$]*)\s*\)/.exec(rest);
|
||||
if (m === null) return { error: `has a malformed parameter at '${rest}'` };
|
||||
segments.push({ kind: "param", name: m[1]! });
|
||||
rest = rest.slice(m[0].length);
|
||||
} else if (rest.startsWith("[]")) {
|
||||
segments.push({ kind: "element" });
|
||||
rest = rest.slice(2);
|
||||
} else if (rest.startsWith("[string]") || rest.startsWith("[number]")) {
|
||||
segments.push({ kind: "index", key: rest.startsWith("[string]") ? "string" : "number" });
|
||||
rest = rest.slice("[string]".length);
|
||||
} else {
|
||||
return { error: `has an unexpected '${rest}'` };
|
||||
}
|
||||
}
|
||||
return { file, root, segments, text: text.trim() };
|
||||
}
|
||||
|
||||
/** A widening (`| undefined`) or a replacement type. */
|
||||
export type TypePatchOp =
|
||||
| { kind: "widen"; arms: readonly string[] }
|
||||
| { kind: "replace"; text: string };
|
||||
|
||||
/** The operation a patch's type text stands for, or a message. */
|
||||
export function parseTypeText(text: string): TypePatchOp | { error: string } {
|
||||
if (/[\r\n]/.test(text)) return { error: "must be one line" };
|
||||
const t = text.trim();
|
||||
if (t === "") return { error: "is empty" };
|
||||
if (t.startsWith("|")) {
|
||||
const arms = splitUnion(t.slice(1));
|
||||
if (arms.some((a) => a === "")) return { error: "has an empty union member" };
|
||||
return { kind: "widen", arms };
|
||||
}
|
||||
return { kind: "replace", text: t };
|
||||
}
|
||||
|
||||
/** The top-level union members of a type text, trimmed. */
|
||||
export function splitUnion(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
||||
else if (ch === "(" || ch === "[" || ch === "{" || ch === "<") depth++;
|
||||
else if (ch === ")" || ch === "]" || ch === "}" || ch === ">") {
|
||||
// `=>` is not a closing bracket.
|
||||
if (!(ch === ">" && text[i - 1] === "=")) depth--;
|
||||
} else if (ch === "|" && depth === 0) {
|
||||
out.push(text.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(start).trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whitespace-insensitive text of a type for comparisons. */
|
||||
export function normalizeType(text: string): string {
|
||||
return text.replace(/\s+/g, "");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isValidRange, satisfies } from "./semver.js";
|
||||
|
||||
describe("satisfies", () => {
|
||||
test.each([
|
||||
["15.0.0", "^15.0.0", true],
|
||||
["15.4.2", "^15.0.0", true],
|
||||
["16.0.0", "^15.0.0", false],
|
||||
["0.2.5", "^0.2.3", true],
|
||||
["0.3.0", "^0.2.3", false],
|
||||
["0.0.3", "^0.0.3", true],
|
||||
["0.0.4", "^0.0.3", false],
|
||||
["1.2.9", "~1.2.3", true],
|
||||
["1.3.0", "~1.2.3", false],
|
||||
["1.9.0", "~1", true],
|
||||
["2.1.3", "2.1.x", true],
|
||||
["2.2.0", "2.1.x", false],
|
||||
["2.1.3", "2", true],
|
||||
["3.0.0", "2", false],
|
||||
["2.1.3", "*", true],
|
||||
["1.2.3", "1.2.3", true],
|
||||
["1.2.4", "=1.2.3", false],
|
||||
["1.5.0", ">=1.2.0 <2.0.0", true],
|
||||
["2.0.0", ">=1.2.0 <2.0.0", false],
|
||||
["2.0.0", ">= 1.2.0", true],
|
||||
["1.3.0", ">1.2", true],
|
||||
["1.2.9", ">1.2", false],
|
||||
["1.2.9", "<=1.2", true],
|
||||
["1.3.0", "<=1.2", false],
|
||||
["2.3.9", "1.2.3 - 2.3", true],
|
||||
["2.4.0", "1.2.3 - 2.3", false],
|
||||
["3.1.0", "^1.0.0 || ^3.0.0", true],
|
||||
["2.1.0", "^1.0.0 || ^3.0.0", false],
|
||||
["2.0.0-beta.1", "^1.0.0", false],
|
||||
["2.0.0-beta.1", "^2.0.0", false],
|
||||
["2.0.0-beta.2", "^2.0.0-beta.1", true],
|
||||
["2.0.0-alpha", "^2.0.0-beta.1", false],
|
||||
["2.1.0-beta.2", "^2.0.0-beta.1", false],
|
||||
])("%s satisfies %s: %s", (version, range, expected) => {
|
||||
expect(satisfies(version, range)).toBe(expected);
|
||||
});
|
||||
|
||||
test("malformed input never satisfies", () => {
|
||||
expect(satisfies("not-a-version", "^1.0.0")).toBe(false);
|
||||
expect(isValidRange("^banana")).toBe(false);
|
||||
expect(isValidRange(">=1 <2 || 3.x")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/* The npm semver range subset type patches key on (`commander@^15.0.0`):
|
||||
* exact versions, x-ranges, `^`, `~`, comparators, hyphen ranges and `||`.
|
||||
* A prerelease version satisfies a comparator set only when one of the
|
||||
* set's comparators names a prerelease of the same major.minor.patch, as
|
||||
* npm does. */
|
||||
|
||||
interface Version {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
pre: readonly (string | number)[];
|
||||
}
|
||||
|
||||
interface Comparator {
|
||||
op: "<" | "<=" | ">" | ">=" | "=";
|
||||
version: Version;
|
||||
}
|
||||
|
||||
/** A partial version: missing or wildcard parts are null. */
|
||||
interface Partial {
|
||||
major: number | null;
|
||||
minor: number | null;
|
||||
patch: number | null;
|
||||
pre: readonly (string | number)[];
|
||||
}
|
||||
|
||||
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
function preParts(text: string | undefined): (string | number)[] {
|
||||
if (text === undefined || text === "") return [];
|
||||
return text.split(".").map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||
}
|
||||
|
||||
/** A full version, or null. */
|
||||
export function parseVersion(text: string): Version | null {
|
||||
const m = VERSION.exec(text.trim());
|
||||
if (m === null) return null;
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: preParts(m[4]) };
|
||||
}
|
||||
|
||||
function parsePartial(text: string): Partial | null {
|
||||
const m =
|
||||
/^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
||||
text,
|
||||
);
|
||||
if (m === null) return null;
|
||||
const part = (s: string | undefined): number | null =>
|
||||
s === undefined || s === "x" || s === "X" || s === "*" ? null : Number(s);
|
||||
const major = part(m[1]);
|
||||
const minor = major === null ? null : part(m[2]);
|
||||
const patch = minor === null ? null : part(m[3]);
|
||||
return { major, minor, patch, pre: patch === null ? [] : preParts(m[4]) };
|
||||
}
|
||||
|
||||
function v(
|
||||
major: number,
|
||||
minor: number,
|
||||
patch: number,
|
||||
pre: readonly (string | number)[] = [],
|
||||
): Version {
|
||||
return { major, minor, patch, pre };
|
||||
}
|
||||
|
||||
/** `-0` marks the lowest prerelease, so `<2.0.0-0` excludes 2.0.0's
|
||||
* prereleases as npm's desugaring does. */
|
||||
const LOWEST: readonly (string | number)[] = [0];
|
||||
|
||||
function comparePre(a: readonly (string | number)[], b: readonly (string | number)[]): number {
|
||||
if (a.length === 0 && b.length === 0) return 0;
|
||||
if (a.length === 0) return 1;
|
||||
if (b.length === 0) return -1;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
if (x === y) continue;
|
||||
if (typeof x === "number" && typeof y === "number") return x < y ? -1 : 1;
|
||||
if (typeof x === "number") return -1;
|
||||
if (typeof y === "number") return 1;
|
||||
return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareVersions(a: Version, b: Version): number {
|
||||
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
|
||||
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
|
||||
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
|
||||
return comparePre(a.pre, b.pre);
|
||||
}
|
||||
|
||||
/** The comparators a partial with an operator stands for. */
|
||||
function desugar(op: string, p: Partial): Comparator[] | null {
|
||||
const { major, minor, patch } = p;
|
||||
if (op === "^") {
|
||||
if (major === null) return [];
|
||||
const lo = v(major, minor ?? 0, patch ?? 0, p.pre);
|
||||
let hi: Version;
|
||||
if (major > 0 || minor === null) hi = v(major + 1, 0, 0, LOWEST);
|
||||
else if (minor > 0 || patch === null) hi = v(0, minor + 1, 0, LOWEST);
|
||||
else hi = v(0, 0, patch + 1, LOWEST);
|
||||
return [
|
||||
{ op: ">=", version: lo },
|
||||
{ op: "<", version: hi },
|
||||
];
|
||||
}
|
||||
if (op === "~") {
|
||||
if (major === null) return [];
|
||||
const lo = v(major, minor ?? 0, patch ?? 0, p.pre);
|
||||
const hi = minor === null ? v(major + 1, 0, 0, LOWEST) : v(major, minor + 1, 0, LOWEST);
|
||||
return [
|
||||
{ op: ">=", version: lo },
|
||||
{ op: "<", version: hi },
|
||||
];
|
||||
}
|
||||
if (op === "" || op === "=") {
|
||||
if (major === null) return [];
|
||||
if (minor === null)
|
||||
return [
|
||||
{ op: ">=", version: v(major, 0, 0) },
|
||||
{ op: "<", version: v(major + 1, 0, 0, LOWEST) },
|
||||
];
|
||||
if (patch === null)
|
||||
return [
|
||||
{ op: ">=", version: v(major, minor, 0) },
|
||||
{ op: "<", version: v(major, minor + 1, 0, LOWEST) },
|
||||
];
|
||||
return [{ op: "=", version: v(major, minor, patch, p.pre) }];
|
||||
}
|
||||
if (major === null)
|
||||
return op === "<" || op === ">" ? [{ op: "<", version: v(0, 0, 0, LOWEST) }] : [];
|
||||
if (op === ">") {
|
||||
if (minor === null) return [{ op: ">=", version: v(major + 1, 0, 0) }];
|
||||
if (patch === null) return [{ op: ">=", version: v(major, minor + 1, 0) }];
|
||||
return [{ op: ">", version: v(major, minor, patch, p.pre) }];
|
||||
}
|
||||
if (op === ">=") return [{ op: ">=", version: v(major, minor ?? 0, patch ?? 0, p.pre) }];
|
||||
if (op === "<") {
|
||||
if (minor === null) return [{ op: "<", version: v(major, 0, 0, LOWEST) }];
|
||||
if (patch === null) return [{ op: "<", version: v(major, minor, 0, LOWEST) }];
|
||||
return [{ op: "<", version: v(major, minor, patch, p.pre) }];
|
||||
}
|
||||
if (op === "<=") {
|
||||
if (minor === null) return [{ op: "<", version: v(major + 1, 0, 0, LOWEST) }];
|
||||
if (patch === null) return [{ op: "<", version: v(major, minor + 1, 0, LOWEST) }];
|
||||
return [{ op: "<=", version: v(major, minor, patch, p.pre) }];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One `||` alternative: a list of comparators, or null when malformed. */
|
||||
function parseSet(text: string): Comparator[] | null {
|
||||
const t = text.trim();
|
||||
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(t);
|
||||
if (hyphen !== null) {
|
||||
const lo = parsePartial(hyphen[1]!);
|
||||
const hi = parsePartial(hyphen[2]!);
|
||||
if (lo === null || hi === null) return null;
|
||||
return [...(desugar(">=", lo) ?? []), ...(desugar("<=", hi) ?? [])];
|
||||
}
|
||||
if (t === "") return [];
|
||||
const out: Comparator[] = [];
|
||||
// `>= 1.2.3` may carry a space between the operator and the version.
|
||||
const tokens = t.replace(/(<=|>=|<|>|=|\^|~)\s+/g, "$1").split(/\s+/);
|
||||
for (const token of tokens) {
|
||||
const m = /^(<=|>=|<|>|=|\^|~>?|)(.*)$/.exec(token);
|
||||
if (m === null) return null;
|
||||
const op = m[1] === "~>" ? "~" : m[1]!;
|
||||
const p = parsePartial(m[2]!);
|
||||
if (p === null) return null;
|
||||
const cs = desugar(op, p);
|
||||
if (cs === null) return null;
|
||||
out.push(...cs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The parsed range, or null when it is not a semver range. */
|
||||
export function parseRange(range: string): Comparator[][] | null {
|
||||
const sets: Comparator[][] = [];
|
||||
for (const alt of range.split("||")) {
|
||||
const set = parseSet(alt);
|
||||
if (set === null) return null;
|
||||
sets.push(set);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
|
||||
export function isValidRange(range: string): boolean {
|
||||
return parseRange(range) !== null;
|
||||
}
|
||||
|
||||
function test(c: Comparator, version: Version): boolean {
|
||||
const cmp = compareVersions(version, c.version);
|
||||
switch (c.op) {
|
||||
case "<":
|
||||
return cmp < 0;
|
||||
case "<=":
|
||||
return cmp <= 0;
|
||||
case ">":
|
||||
return cmp > 0;
|
||||
case ">=":
|
||||
return cmp >= 0;
|
||||
default:
|
||||
return cmp === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `version` satisfies `range` (false for malformed input). */
|
||||
export function satisfies(version: string, range: string): boolean {
|
||||
const ver = parseVersion(version);
|
||||
const sets = parseRange(range);
|
||||
if (ver === null || sets === null) return false;
|
||||
return sets.some((set) => {
|
||||
if (!set.every((c) => test(c, ver))) return false;
|
||||
if (ver.pre.length === 0) return true;
|
||||
// A prerelease only matches a set that names a prerelease of the same
|
||||
// version tuple (the LOWEST markers of upper bounds do not count).
|
||||
return set.some(
|
||||
(c) =>
|
||||
c.version.pre.length > 0 &&
|
||||
c.version.pre !== LOWEST &&
|
||||
c.version.major === ver.major &&
|
||||
c.version.minor === ver.minor &&
|
||||
c.version.patch === ver.patch,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -52,7 +52,7 @@ import { InternalCompilerError } from "./errors.js";
|
||||
import { frontendProcessExecArgv } from "./frontend-process-args.js";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
@@ -121,6 +121,7 @@ import {
|
||||
nativeCodegenDiag,
|
||||
nodeIslandNodeDiag,
|
||||
nodeIslandModuleDiag,
|
||||
isTypePatchCode,
|
||||
type ScrDiagnostic,
|
||||
} from "./diagnostics/diagnostic.js";
|
||||
import { libnodeCacheKey, libnodeRecord } from "./node-islands/libnode.js";
|
||||
@@ -489,12 +490,31 @@ interface CompileWarningSink {
|
||||
sourceTexts: Map<string, string> | null;
|
||||
}
|
||||
|
||||
/** The package.json texts type patch warnings point into, re-read for a
|
||||
* cache hit's code frames (performance warnings need none). */
|
||||
function cachedWarningSources(warnings: readonly ScrDiagnostic[]): Map<string, string> {
|
||||
const texts = new Map<string, string>();
|
||||
for (const w of warnings) {
|
||||
if (!isTypePatchCode(w.code) || texts.has(w.loc.file)) continue;
|
||||
try {
|
||||
texts.set(w.loc.file, readFileSync(w.loc.file, "utf8"));
|
||||
} catch {
|
||||
// The frame is omitted; the message stands alone.
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/** The build's performance warnings (SC61xx), stored with its cache entry
|
||||
* so a cache hit repeats them. */
|
||||
function performanceWarnings(sink: CompileWarningSink | undefined): {
|
||||
performance?: ScrDiagnostic[];
|
||||
} {
|
||||
const performance = (sink?.warnings ?? []).filter((d) => isPerformanceCode(d.code));
|
||||
// Type patch warnings (SC8030–SC8033) describe the same unchanged inputs,
|
||||
// so a hit repeats them too.
|
||||
const performance = (sink?.warnings ?? []).filter(
|
||||
(d) => isPerformanceCode(d.code) || isTypePatchCode(d.code),
|
||||
);
|
||||
return performance.length === 0 ? {} : { performance };
|
||||
}
|
||||
|
||||
@@ -1657,7 +1677,7 @@ async function compileTracked(
|
||||
// names is just as untyped as when it was built.
|
||||
if (warningSink !== undefined && earlyHit.performance !== undefined) {
|
||||
warningSink.warnings = earlyHit.performance;
|
||||
warningSink.sourceTexts = new Map();
|
||||
warningSink.sourceTexts = cachedWarningSources(earlyHit.performance);
|
||||
}
|
||||
const executableCacheOptions = earlyCacheOptions;
|
||||
// Route/proof metadata is independently evictable. A full-compiler
|
||||
|
||||
@@ -882,6 +882,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
|
||||
"net.setAutoSelTimeout",
|
||||
"error.argTypeThrow",
|
||||
"error.propTypeThrow",
|
||||
"array.foreignThrow",
|
||||
"emitter.setMaxChk",
|
||||
"emitter.setDefaultMaxChk",
|
||||
"fs.readFileSyncBytes",
|
||||
@@ -1083,6 +1084,7 @@ export const ALWAYS_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
|
||||
"error.nodeThrow",
|
||||
"error.argTypeThrow",
|
||||
"error.propTypeThrow",
|
||||
"array.foreignThrow",
|
||||
"fs.mkdtempChk",
|
||||
"fs.readFileChk",
|
||||
"fs.opendirChk",
|
||||
|
||||
@@ -857,6 +857,7 @@ function buildLibFnSignatures() {
|
||||
// (subject, characters, NUL-separated replacement texts): the fused
|
||||
// chain of global one-character replacements.
|
||||
"str.replaceChars": { argTypes: [STRING, STRING, STRING], result: STRING },
|
||||
"str.codePoints": { argTypes: [STRING], result: arrayOf(STRING) },
|
||||
/* ToNumber(string) — ECMA-262 7.1.4.1 StringToNumber (scr_string.c):
|
||||
* trim the JS StrWhiteSpace set, empty/whitespace-only → +0, then the
|
||||
* whole span must be one StrNumericLiteral — signed decimal (Infinity
|
||||
@@ -1556,15 +1557,18 @@ function buildLibFnSignatures() {
|
||||
* true } and a callback taking an address string or list, then dials
|
||||
* the answers in order. MAY THROW (host validation, a lookup throw). */
|
||||
"net.connectLookupDyn": { argTypes: [F64, STRING, DYN], result: NETSOCKET_T },
|
||||
"net.sockWrite": { argTypes: [NETSOCKET_T, STRING], result: VOID },
|
||||
"net.sockWriteBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: VOID },
|
||||
/** socket.write: Node's Writable answer — false when the writes not yet
|
||||
* completely sent, each counted in full, reach the 65536-byte
|
||||
* high-water mark ('drain' follows), and on an ended or closed socket. */
|
||||
"net.sockWrite": { argTypes: [NETSOCKET_T, STRING], result: BOOL },
|
||||
"net.sockWriteBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: BOOL },
|
||||
"net.sockEnd": { argTypes: [NETSOCKET_T], result: VOID },
|
||||
"net.sockEndStr": { argTypes: [NETSOCKET_T, STRING], result: VOID },
|
||||
"net.sockEndBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: VOID },
|
||||
/** write/end with a CHECKED-DYNAMIC chunk (an untyped JS payload into a
|
||||
* typed socket): the runtime dispatches STR/BYTES and throws Node's
|
||||
* ERR_INVALID_ARG_TYPE chunk TypeError on any other kind. */
|
||||
"net.sockWriteDyn": { argTypes: [NETSOCKET_T, DYN], result: VOID },
|
||||
"net.sockWriteDyn": { argTypes: [NETSOCKET_T, DYN], result: BOOL },
|
||||
"net.sockEndDyn": { argTypes: [NETSOCKET_T, DYN], result: VOID },
|
||||
"net.sockDestroy": { argTypes: [NETSOCKET_T], result: VOID },
|
||||
"net.sockPipe": { argTypes: [NETSOCKET_T, NETSOCKET_T], result: VOID },
|
||||
@@ -2022,6 +2026,12 @@ function buildLibFnSignatures() {
|
||||
argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }, BOOL],
|
||||
result: VOID,
|
||||
},
|
||||
/** on('drain') / once('drain'): fires after a write answered false, once
|
||||
* the write buffer empties. */
|
||||
"net.sockOnDrain": {
|
||||
argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }, BOOL],
|
||||
result: VOID,
|
||||
},
|
||||
"net.sockRemoteAddress": { argTypes: [NETSOCKET_T], result: VOID },
|
||||
/** socket.encrypted — `boolean | undefined`: the true arm iff the
|
||||
* socket carries a TLS transport (Node types `encrypted: true` on
|
||||
@@ -2148,6 +2158,12 @@ function buildLibFnSignatures() {
|
||||
/** socket.address(): { address, family, port } or {} as a dyn. */
|
||||
"net.sockAddress": { argTypes: [NETSOCKET_T], result: DYN },
|
||||
"net.sockBytesWritten": { argTypes: [NETSOCKET_T], result: F64 },
|
||||
/** socket.writableLength: Node's state.length, the length of every
|
||||
* write not yet completely sent. */
|
||||
"net.sockWritableLength": { argTypes: [NETSOCKET_T], result: F64 },
|
||||
/** socket.writableNeedDrain: a write answered false and 'drain' has not
|
||||
* fired, with the write half open. */
|
||||
"net.sockWritableNeedDrain": { argTypes: [NETSOCKET_T], result: BOOL },
|
||||
/** remotePort (0), remoteFamily (1), localAddress (2), localPort (3),
|
||||
* localFamily (4): dyn values, undefined when unknown. */
|
||||
"net.sockAddrField": { argTypes: [NETSOCKET_T, F64], result: DYN },
|
||||
@@ -3983,6 +3999,15 @@ function buildLibFnSignatures() {
|
||||
// The string keys `for...in` visits on an array (args[0], any element
|
||||
// type) after its indices. Borrowed; never throws.
|
||||
"array.namedKeys": { argTypes: [null], result: arrayOf(STRING) },
|
||||
// The value of the foreign element (SCR_ARR_FOREIGN) at args[1] of an
|
||||
// array (args[0], any element type); callers test the element state
|
||||
// first. Borrowed; never throws.
|
||||
"array.foreign": { argTypes: [null, F64], result: DYN },
|
||||
/** The checked conversion's TypeError for the foreign element at args[1]
|
||||
* of an array (args[0]) read where a value described by args[2] is
|
||||
* expected. ALWAYS THROWS; polymorphic result (the error.nodeThrow
|
||||
* pattern). May-throw seed. */
|
||||
"array.foreignThrow": { argTypes: [null, F64, STRING], result: VOID },
|
||||
// Whether the array (args[0]) still has the key (args[1]) from its
|
||||
// namedKeys list. Borrowed; never throws.
|
||||
"array.hasNamedKey": { argTypes: [null, STRING], result: BOOL },
|
||||
|
||||
@@ -221,6 +221,7 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, Role> = {
|
||||
"wasmUntranslated[].sha256": "other",
|
||||
"wasmUntranslated[].reason": "other",
|
||||
"threadSlotKeys[]": "other",
|
||||
"foreignElementKeys[]": "other",
|
||||
"freezableArrays[]": "other",
|
||||
"nodeIsland.libnode": "other",
|
||||
"nodeIsland.nodeVersion": "other",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { preservesArrayLength } from "./array-length-stability.js";
|
||||
export interface IntegerBytesForLoop {
|
||||
localId: string;
|
||||
limitReceiver: IrExpr;
|
||||
/** The receiver is a local of the function that the body never assigns
|
||||
* (a typed array's length is then the same on every iteration). */
|
||||
stableReceiver: boolean;
|
||||
}
|
||||
|
||||
export interface IntegerCountedForLoop {
|
||||
@@ -682,7 +685,12 @@ export function matchIntegerBytesForLoop(
|
||||
if (!isUnitIncrement(stmt.update, init.localId)) return null;
|
||||
if (writesLocal(stmt.body, init.localId)) return null;
|
||||
|
||||
return { localId: init.localId, limitReceiver: cond.right.receiver };
|
||||
return {
|
||||
localId: init.localId,
|
||||
limitReceiver: cond.right.receiver,
|
||||
stableReceiver:
|
||||
receiverLocal !== undefined && !writesLocal(stmt.body, cond.right.receiver.localId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Array lengths are uint32, so zero-based loops and nested `j = i + 1`
|
||||
@@ -729,5 +737,11 @@ export function matchIntegerArrayForLoop(
|
||||
writesLocal(stmt.body, local.id)
|
||||
)
|
||||
return null;
|
||||
return { localId: local.id, limitReceiver: cond.right.receiver };
|
||||
return {
|
||||
localId: local.id,
|
||||
limitReceiver: cond.right.receiver,
|
||||
stableReceiver:
|
||||
locals.has(cond.right.receiver.localId) &&
|
||||
!writesLocal(stmt.body, cond.right.receiver.localId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -867,6 +867,8 @@ export function analyzeIntegerRangeFacts(
|
||||
);
|
||||
if (e.type.kind === "f64") {
|
||||
if (e.method === "length") range = { min: 0, max: STRING_MAX_UNITS };
|
||||
// A string holds at most SCR_STR_MAX_CAP (0x7fffffff) bytes.
|
||||
else if (e.method === "byteLength") range = { min: 0, max: STRING_MAX_UNITS };
|
||||
// An empty needle is found at the clamped position, up to the length.
|
||||
else if (e.method === "indexOf") range = { min: -1, max: STRING_MAX_UNITS };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export const STR_INTRINSIC_SIGS: Record<
|
||||
normalize: { argTypes: [STRING], minArgs: 1, result: STRING },
|
||||
isWellFormed: { argTypes: [], minArgs: 0, result: BOOL },
|
||||
toWellFormed: { argTypes: [], minArgs: 0, result: STRING },
|
||||
cpAt: { argTypes: [F64], minArgs: 1, result: STRING },
|
||||
byteLength: { argTypes: [], minArgs: 0, result: F64 },
|
||||
cpAtByte: { argTypes: [F64], minArgs: 1, result: STRING },
|
||||
};
|
||||
|
||||
/** Per-method signature for regexIntrinsic. test/source/flags take the
|
||||
|
||||
@@ -1079,6 +1079,10 @@ export interface IrModule {
|
||||
* ProgramLifetime instance lives in a counted region (the region.*
|
||||
* intrinsics; backend/llvm/regions.ts). */
|
||||
lifetimeRegions?: true;
|
||||
/** Element keys (typeKey of the element type) of native arrays that can
|
||||
* hold elements outside their element type (SCR_ARR_FOREIGN), stored
|
||||
* through dynamic views; "*" for every array. Absent when none can. */
|
||||
foreignElementKeys?: string[];
|
||||
/** Classes used as ThreadSlots keys (@scriptc/experimental): the root of
|
||||
* each one's hierarchy carries a slot word (the slots.* intrinsics;
|
||||
* backend/llvm/thread-slots.ts). */
|
||||
@@ -2471,10 +2475,13 @@ export type IrStrIntrinsicMethod =
|
||||
// surrogate, and the copy with each one replaced by U+FFFD.
|
||||
| "isWellFormed"
|
||||
| "toWellFormed"
|
||||
// The string iterator's step: the full code-POINT character at a UTF-16
|
||||
// index (astral chars come back whole where charAt truncates) — for-of
|
||||
// over strings desugars onto it, advancing by the result's length.
|
||||
| "cpAt";
|
||||
// The string iterator over the WTF-8 bytes: the byte count of a string,
|
||||
// and the code-point character that starts at a byte offset (a lone
|
||||
// surrogate is a one-unit character). for-of, spread and Array.from of a
|
||||
// string walk a byte cursor with them, advancing by the character's
|
||||
// byte count; the cursor is always a character start below the count.
|
||||
| "byteLength"
|
||||
| "cpAtByte";
|
||||
|
||||
/** The typed-array/Buffer method surface (bytesIntrinsic). Receiver/arg
|
||||
* conventions (validated): `length`/`byteLength` are property reads → f64;
|
||||
|
||||
@@ -884,10 +884,11 @@ const RUNTIME_FEATURE_RULES = {
|
||||
mod.functions.some(
|
||||
(fn) => fn.async === true && fn.generator === undefined && fn.returnType.kind === "dyn",
|
||||
),
|
||||
// The legacy url functions, crypto.createCipheriv and
|
||||
// ecdh.setPublicKey emit their deprecation warnings through the
|
||||
// dyn-async unit (WebCrypto's encrypt and decrypt link scr_cipher.c
|
||||
// too); scr_wasm.c boxes its handles and promises through it.
|
||||
// The legacy url functions, crypto.createCipheriv,
|
||||
// ecdh.setPublicKey and generateKeyPair's rsa-pss options emit their
|
||||
// deprecation warnings through the dyn-async unit (WebCrypto's
|
||||
// encrypt and decrypt link scr_cipher.c too); scr_wasm.c boxes its
|
||||
// handles and promises through it.
|
||||
fns: [
|
||||
...DYN_ASYNC_LIB_FNS,
|
||||
...READABLE_DYN_ASYNC_LIB_FNS,
|
||||
@@ -898,6 +899,8 @@ const RUNTIME_FEATURE_RULES = {
|
||||
"webcrypto.wrapKey",
|
||||
"webcrypto.unwrapKey",
|
||||
"crypto.ecdhNew",
|
||||
"crypto.generateKeyPairCb",
|
||||
"crypto.generateKeyPairSync",
|
||||
],
|
||||
prefixes: [...READABLE_DYN_ASYNC_PREFIXES, "wasm."],
|
||||
expr: (node, tables) =>
|
||||
|
||||
@@ -4313,7 +4313,9 @@ function validateFunction(
|
||||
if (
|
||||
e.fn === "array.namedKeys" ||
|
||||
e.fn === "array.hasNamedKey" ||
|
||||
e.fn === "array.hasProperties"
|
||||
e.fn === "array.hasProperties" ||
|
||||
e.fn === "array.foreign" ||
|
||||
e.fn === "array.foreignThrow"
|
||||
) {
|
||||
if (e.args[0]?.type.kind !== "array") {
|
||||
err(`libCall ${e.fn} arg 0: expected an array, got ${e.args[0]?.type.kind}`, e.loc);
|
||||
|
||||
@@ -164,3 +164,80 @@ test("package setting keys named like Object.prototype members are unknown setti
|
||||
]);
|
||||
expect(c.packages.has("debug")).toBe(false);
|
||||
});
|
||||
|
||||
test("typePatches: entries keyed by package and range", () => {
|
||||
const c = config({
|
||||
typePatches: {
|
||||
"commander@^15.0.0": { declarations: { "HelpContext.helpWidth": "| undefined" } },
|
||||
"@scope/lib@1.x || 2.x": {
|
||||
declarations: { "Options.name": "string | null" },
|
||||
note: "https://example.invalid/issue/1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(c.diagnostics).toEqual([]);
|
||||
expect(
|
||||
c.typePatches.map(({ key, pkg, range, declarations }) => ({
|
||||
key,
|
||||
pkg,
|
||||
range,
|
||||
declarations: declarations.map((d) => [d.selector, d.type]),
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
key: "commander@^15.0.0",
|
||||
pkg: "commander",
|
||||
range: "^15.0.0",
|
||||
declarations: [["HelpContext.helpWidth", "| undefined"]],
|
||||
},
|
||||
{
|
||||
key: "@scope/lib@1.x || 2.x",
|
||||
pkg: "@scope/lib",
|
||||
range: "1.x || 2.x",
|
||||
declarations: [["Options.name", "string | null"]],
|
||||
},
|
||||
]);
|
||||
expect(c.typePatches[0]!.declarations[0]!.offset).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("typePatches: malformed keys, ranges, selectors and types are SC8004", () => {
|
||||
const c = config({
|
||||
typePatches: {
|
||||
commander: { declarations: { "A.b": "| undefined" } },
|
||||
"ms@^banana": { declarations: {} },
|
||||
"@types/node@*": { declarations: {} },
|
||||
"typescript@5": { declarations: {} },
|
||||
"lib@^1.0.0": {
|
||||
declarations: {
|
||||
"1bad": "| undefined",
|
||||
"A.b": 3,
|
||||
"A.c": "string\nnumber",
|
||||
"A.d": "| undefined",
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
"other@^1.0.0": [],
|
||||
},
|
||||
});
|
||||
expect(c.diagnostics.every((d) => d.code === "SC8004")).toBe(true);
|
||||
expect(c.diagnostics.map((d) => d.message)).toEqual([
|
||||
"package.json 'scriptc.typePatches.commander' must name a package and a version range",
|
||||
`package.json 'scriptc.typePatches.ms@^banana' has the version range "^banana", which is not a semver range`,
|
||||
"package.json 'scriptc.typePatches.@types/node@*' patches @types/node, whose declarations ship with scriptc or TypeScript",
|
||||
"package.json 'scriptc.typePatches.typescript@5' patches typescript, whose declarations ship with scriptc or TypeScript",
|
||||
"package.json 'scriptc.typePatches.lib@^1.0.0.files' is not a known type patch setting",
|
||||
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.1bad' is not a selector: it must start with a declared name, 'export=' or 'default'",
|
||||
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.b' must map to a type string, not 3",
|
||||
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.c' has a type that must be one line",
|
||||
"package.json 'scriptc.typePatches.other@^1.0.0' must be an object, not an array",
|
||||
]);
|
||||
// The valid selector of an entry with an unknown key is dropped with it;
|
||||
// entries with only bad selectors keep their valid ones.
|
||||
expect(c.typePatches).toEqual([]);
|
||||
const partial = config({
|
||||
typePatches: {
|
||||
"lib@^1.0.0": { declarations: { "1bad": "| undefined", "A.d": "| undefined" } },
|
||||
},
|
||||
});
|
||||
expect(partial.typePatches[0]!.declarations.map((d) => d.selector)).toEqual(["A.d"]);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
* "proper-lockfile": { "run": "node", "evaluate": "lazy" }
|
||||
* },
|
||||
* "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] },
|
||||
* "wasm": ["build/module.wasm"]
|
||||
* "wasm": ["build/module.wasm"],
|
||||
* "typePatches": { // frontend/type-patches/layer.ts
|
||||
* "commander@^15.0.0": {
|
||||
* "declarations": { "HelpContext.helpWidth": "| undefined" }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Node islands are report-only in this version: the policy decides which
|
||||
@@ -23,6 +28,8 @@
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js";
|
||||
import { trackedReadFile } from "./frontend/input-tracker.js";
|
||||
import { parseSelector, parseTypeText } from "./frontend/type-patches/selector.js";
|
||||
import { isValidRange } from "./frontend/type-patches/semver.js";
|
||||
|
||||
/** How a package runs: compiled natively, in a Node island, or decided by
|
||||
* the detector. */
|
||||
@@ -57,6 +64,25 @@ export interface NodeIslandsSetting {
|
||||
speculativeStart: boolean;
|
||||
}
|
||||
|
||||
/** One declaration patch: a selector and the type text it maps to. */
|
||||
export interface TypePatchDeclaration {
|
||||
selector: string;
|
||||
type: string;
|
||||
/** Offset of the selector key in the manifest, for diagnostics. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** One `scriptc.typePatches["<package>@<range>"]` entry. */
|
||||
export interface TypePatchEntry {
|
||||
/** The key as written. */
|
||||
key: string;
|
||||
pkg: string;
|
||||
range: string;
|
||||
declarations: TypePatchDeclaration[];
|
||||
/** Offset of the key in the manifest. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
/** The package.json the configuration came from, or null. */
|
||||
manifest: string | null;
|
||||
@@ -66,6 +92,8 @@ export interface ProjectConfig {
|
||||
nodeIslands: NodeIslandsSetting;
|
||||
/** `scriptc.wasm`: module paths, resolved against the manifest. */
|
||||
wasm: string[];
|
||||
/** `scriptc.typePatches`, in manifest order. */
|
||||
typePatches: TypePatchEntry[];
|
||||
/** SC8004 for every unknown key or value. */
|
||||
diagnostics: ScrDiagnostic[];
|
||||
}
|
||||
@@ -117,6 +145,7 @@ export function emptyProjectConfig(): ProjectConfig {
|
||||
packages: new Map<string, PackageSetting>(),
|
||||
nodeIslands: defaultNodeIslands(),
|
||||
wasm: [],
|
||||
typePatches: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -299,6 +328,9 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
|
||||
}
|
||||
}
|
||||
|
||||
const patches = root["typePatches"];
|
||||
if (patches !== undefined) readTypePatches(patches, config, problem, locate);
|
||||
|
||||
// `scriptc.wasm` keeps its lenient reading: string entries of an array.
|
||||
const wasm = root["wasm"];
|
||||
if (Array.isArray(wasm)) {
|
||||
@@ -309,6 +341,136 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
|
||||
return config;
|
||||
}
|
||||
|
||||
const TYPE_PATCH_KEYS = ["declarations", "note"];
|
||||
/** Packages whose declarations ship with scriptc or TypeScript: a wrong
|
||||
* one is a bug to report, not a project-local patch. */
|
||||
const UNPATCHABLE = /^(?:@types\/node|typescript|@typescript\/.*)$/;
|
||||
const PACKAGE_NAME = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
|
||||
|
||||
/** Reads and validates `scriptc.typePatches`. */
|
||||
function readTypePatches(
|
||||
patches: unknown,
|
||||
config: ProjectConfig,
|
||||
problem: (path: string[], detail: string, hint: string) => void,
|
||||
locate: JsonLocator,
|
||||
): void {
|
||||
if (!isRecord(patches)) {
|
||||
problem(
|
||||
["typePatches"],
|
||||
`must be an object mapping "<package>@<range>" to patches, not ${shown(patches)}`,
|
||||
`write "typePatches": { "<package>@^1.0.0": { "declarations": { "Type.member": "| undefined" } } }`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(patches)) {
|
||||
const at = key.lastIndexOf("@");
|
||||
const pkg = at > 0 ? key.slice(0, at) : key;
|
||||
const range = at > 0 ? key.slice(at + 1).trim() : "";
|
||||
if (at <= 0 || range === "" || !PACKAGE_NAME.test(pkg)) {
|
||||
problem(
|
||||
["typePatches", key],
|
||||
"must name a package and a version range",
|
||||
`write "${at > 0 ? pkg : key}@^1.0.0": the range says which installed versions the patch describes`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!isValidRange(range)) {
|
||||
problem(
|
||||
["typePatches", key],
|
||||
`has the version range "${range}", which is not a semver range`,
|
||||
`use a range such as "^${/^\d/.test(range) ? range.replace(/[^\d.]/g, "") : "1.0.0"}" or "1.2.x"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (UNPATCHABLE.test(pkg)) {
|
||||
problem(
|
||||
["typePatches", key],
|
||||
`patches ${pkg}, whose declarations ship with scriptc or TypeScript`,
|
||||
"report a wrong declaration there instead of patching it",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const value = patches[key];
|
||||
if (!isRecord(value)) {
|
||||
problem(
|
||||
["typePatches", key],
|
||||
`must be an object, not ${shown(value)}`,
|
||||
`write { "declarations": { "Type.member": "| undefined" } }`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const entry: TypePatchEntry = {
|
||||
key,
|
||||
pkg,
|
||||
range,
|
||||
declarations: [],
|
||||
offset: locate.offsetOf(["scriptc", "typePatches", key]),
|
||||
};
|
||||
let valid = true;
|
||||
for (const k of Object.keys(value)) {
|
||||
if (!TYPE_PATCH_KEYS.includes(k)) {
|
||||
problem(
|
||||
["typePatches", key, k],
|
||||
"is not a known type patch setting",
|
||||
`the settings are ${TYPE_PATCH_KEYS.join(", ")}`,
|
||||
);
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
const note = value["note"];
|
||||
if (note !== undefined && typeof note !== "string") {
|
||||
problem(
|
||||
["typePatches", key, "note"],
|
||||
`must be a string, not ${shown(note)}`,
|
||||
"write a short note, such as a link to the upstream issue",
|
||||
);
|
||||
valid = false;
|
||||
}
|
||||
const decls = value["declarations"];
|
||||
if (decls !== undefined && !isRecord(decls)) {
|
||||
problem(
|
||||
["typePatches", key, "declarations"],
|
||||
`must be an object mapping selectors to types, not ${shown(decls)}`,
|
||||
`write "declarations": { "Type.member": "| undefined" }`,
|
||||
);
|
||||
valid = false;
|
||||
} else if (decls !== undefined) {
|
||||
for (const selector of Object.keys(decls)) {
|
||||
const path = ["typePatches", key, "declarations", selector];
|
||||
const parsed = parseSelector(selector);
|
||||
if ("error" in parsed) {
|
||||
problem(
|
||||
path,
|
||||
`is not a selector: it ${parsed.error}`,
|
||||
`name a declaration such as "Type.member", "fn()", "fn(param)", "file.js:local()" or "export=()"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const type = decls[selector];
|
||||
if (typeof type !== "string") {
|
||||
problem(
|
||||
path,
|
||||
`must map to a type string, not ${shown(type)}`,
|
||||
`write "| undefined" to widen, or the whole type to replace it`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const op = parseTypeText(type);
|
||||
if ("error" in op) {
|
||||
problem(
|
||||
path,
|
||||
`has a type that ${op.error}`,
|
||||
`write "| undefined" to widen, or the whole type on one line`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
entry.declarations.push({ selector, type, offset: locate.offsetOf(["scriptc", ...path]) });
|
||||
}
|
||||
}
|
||||
if (valid) config.typePatches.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
let cachedManifest: string | null = null;
|
||||
let cachedText: string | null = null;
|
||||
let cachedConfig: ProjectConfig | null = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"compilerVersion": "0.4.4",
|
||||
"compilerVersion": "0.4.5",
|
||||
"coverage": [
|
||||
"Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.",
|
||||
"Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule, validateModule } from "../src/index.js";
|
||||
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
|
||||
import { type IrModule } from "../src/ir/ir.js";
|
||||
|
||||
async function lower(source: string): Promise<IrModule> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-bytes-header-"));
|
||||
try {
|
||||
const entry = join(dir, "main.ts"),
|
||||
output = join(dir, "main.ir.json");
|
||||
await writeFile(entry, source);
|
||||
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
|
||||
const module = deserializeModule(await readFile(output, "utf8"));
|
||||
expect(validateModule(module)).toEqual([]);
|
||||
return module;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function body(llvm: string, symbol: string): string {
|
||||
const found = new RegExp(`^define internal [^\\n]*@${symbol}(?:\\.i32)?\\([^]*?^}`, "m").exec(
|
||||
llvm,
|
||||
);
|
||||
expect(found, symbol).not.toBeNull();
|
||||
return found![0];
|
||||
}
|
||||
|
||||
const INVARIANT_HEADER = /load (?:i64|i32|ptr), ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m;
|
||||
|
||||
test("a typed array's length read as a value is an ordinary load; access bounds stay invariant", async () => {
|
||||
const module = await lower(`
|
||||
class Table {
|
||||
slots: Int32Array = new Int32Array(4);
|
||||
swap(): number {
|
||||
const slots = this.slots;
|
||||
this.slots = new Int32Array(1);
|
||||
return slots.length + slots.byteLength;
|
||||
}
|
||||
}
|
||||
function sum(values: Float64Array, scale: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < values.length; i++) total += values[i]! * scale;
|
||||
return total;
|
||||
}
|
||||
const table = new Table();
|
||||
console.log(table.swap(), sum(new Float64Array([1, 2]), 2));
|
||||
`);
|
||||
const llvm = emitLlvmModule(module);
|
||||
// The length outlives the view: the release must not be crossed.
|
||||
const swap = body(llvm, "sc_bf__x25_Table_swap");
|
||||
expect(swap).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+$/m);
|
||||
expect(swap).not.toContain("!invariant.load");
|
||||
expect(body(llvm, "sc_bf_sum")).toMatch(INVARIANT_HEADER);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user