mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
Merge main
This commit is contained in:
@@ -539,6 +539,8 @@ jobs:
|
||||
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
|
||||
name: test (tsgo and tsc-ts give the same IR)
|
||||
runs-on: ubuntu-24.04
|
||||
# The two corpus compiles run back to back and take close to 30 minutes on a
|
||||
# slow hosted runner, so a 30-minute limit cancels otherwise healthy runs.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -633,8 +635,9 @@ jobs:
|
||||
# Shard 1 additionally runs package/install and link-parity contracts
|
||||
# before its LLVM differential slice. The slice itself can take ~10
|
||||
# minutes on a cold hosted runner, so 20 minutes cancels an otherwise
|
||||
# healthy job before the differential completes.
|
||||
timeout-minutes: 35
|
||||
# healthy job before the differential completes. Slow runners need more
|
||||
# headroom than that, so the limit is 50 minutes.
|
||||
timeout-minutes: 50
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Prune caches
|
||||
|
||||
# Pull request caches are scoped to that pull request, so once it is closed no
|
||||
# run can use them again. Deleting them keeps the repository under its cache
|
||||
# size limit, so caches that main and open pull requests use are not evicted.
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Delete caches of closed pull requests
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read both lists in full before deleting anything: deleting while the cache
|
||||
# listing pages shifts later pages, and a failed request must stop the job
|
||||
# instead of leaving open pull requests out of the list.
|
||||
open=$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" --jq '.[].number')
|
||||
caches=$(gh api --paginate "repos/$REPO/actions/caches?per_page=100" \
|
||||
--jq '.actions_caches[] | select(.ref | startswith("refs/pull/")) | "\(.id) \(.ref)"')
|
||||
while read -r id ref; do
|
||||
[ -n "$id" ] || continue
|
||||
number=$(echo "$ref" | cut -d/ -f3)
|
||||
if ! grep -qx "$number" <<<"$open"; then
|
||||
gh api --method DELETE "repos/$REPO/actions/caches/$id" >/dev/null
|
||||
echo "deleted cache $id ($ref)"
|
||||
fi
|
||||
done <<<"$caches"
|
||||
@@ -10,6 +10,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
|
||||
| `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs |
|
||||
| `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any |
|
||||
| `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` |
|
||||
| `date-math` | cpu | schedules built with Date setters (local and UTC), weekday and month-end rules through the getters, Dates sorted by time value |
|
||||
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
|
||||
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
|
||||
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
|
||||
@@ -30,6 +31,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
|
||||
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks |
|
||||
| `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"entry": "workloads/date-math.ts",
|
||||
"args": [
|
||||
"40"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Calendar arithmetic on Dates: schedules built by adjusting Dates with the
|
||||
// UTC and local setters, weekday and month-end rules read back through the
|
||||
// getters, Dates compared and sorted by time value, and ISO text for a
|
||||
// sample. Argument: the number of rounds.
|
||||
process.env.TZ = "America/New_York";
|
||||
|
||||
const rounds = Number(process.argv[2] ?? "40");
|
||||
let seed = 2026;
|
||||
function random(): number {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
||||
return seed / 4294967296;
|
||||
}
|
||||
|
||||
/** The next business day at 09:00 local time on or after `from`. */
|
||||
function nextBusinessDay(from: Date): Date {
|
||||
const day = new Date(from.getTime());
|
||||
day.setHours(9, 0, 0, 0);
|
||||
if (day < from) day.setDate(day.getDate() + 1);
|
||||
while (day.getDay() === 0 || day.getDay() === 6) day.setDate(day.getDate() + 1);
|
||||
return day;
|
||||
}
|
||||
|
||||
/** The last day of `date`'s month, in UTC. */
|
||||
function monthEnd(date: Date): number {
|
||||
const end = new Date(date.getTime());
|
||||
end.setUTCDate(1);
|
||||
end.setUTCMonth(end.getUTCMonth() + 1);
|
||||
end.setUTCDate(0);
|
||||
return end.getUTCDate();
|
||||
}
|
||||
|
||||
let checksum = 0;
|
||||
let sample = "";
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
const due: Date[] = [];
|
||||
const cursor = new Date(Date.UTC(2020 + (round % 7), 0, 1, 12));
|
||||
for (let i = 0; i < 4000; i++) {
|
||||
cursor.setUTCMinutes(cursor.getUTCMinutes() + Math.floor(random() * 4000));
|
||||
const next = nextBusinessDay(cursor);
|
||||
checksum += next.getDate() + monthEnd(next) + next.getHours();
|
||||
if (i % 3 === 0) next.setMonth(next.getMonth() + 1, 15);
|
||||
due.push(next);
|
||||
}
|
||||
due.sort((a, b) => +a - +b);
|
||||
let previous = due[0]!;
|
||||
for (const date of due) {
|
||||
checksum += (+date - +previous) / 3600000;
|
||||
previous = date;
|
||||
}
|
||||
sample = due[due.length >> 1]!.toISOString();
|
||||
}
|
||||
console.log(Math.round(checksum), sample);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"entry": "workloads/throw-chains.ts",
|
||||
"args": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Exceptions that cross many frames: throws from helpers below a recursion,
|
||||
// caught 2, 5 and 20 recursive calls up (direct calls, then calls through a
|
||||
// callback), with each frame holding an array across its call, and a
|
||||
// backtracking parser that uses exceptions for control flow.
|
||||
class ParseError extends Error {
|
||||
readonly at: number;
|
||||
constructor(at: number, message: string) {
|
||||
super(message);
|
||||
this.at = at;
|
||||
}
|
||||
}
|
||||
|
||||
/** A thrown value that is not an Error (no stack to capture). */
|
||||
class Stop {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const held: number[][] = [];
|
||||
function fail(n: number, plain: boolean): number {
|
||||
if (plain) throw new Stop(n);
|
||||
throw new RangeError("chain " + n);
|
||||
}
|
||||
function check(n: number, plain: boolean): number {
|
||||
return (n & 3) === 0 ? fail(n, plain) : n;
|
||||
}
|
||||
function validate(n: number, plain: boolean): number {
|
||||
return check(n, plain) + (n & 1);
|
||||
}
|
||||
function bottom(n: number, plain: boolean): number {
|
||||
const box = [n, -1];
|
||||
const r = validate(n, plain);
|
||||
held[0] = box;
|
||||
return r + box.length;
|
||||
}
|
||||
function walk(n: number, depth: number, plain: boolean): number {
|
||||
const box = [n, depth];
|
||||
const r = depth === 0 ? bottom(n, plain) : walk(n, depth - 1, plain);
|
||||
held[depth] = box;
|
||||
return r + box.length;
|
||||
}
|
||||
|
||||
function chains(rounds: number): string {
|
||||
const out: string[] = [];
|
||||
for (const depth of [2, 5, 20])
|
||||
for (const plain of [false, true]) {
|
||||
let caught = 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
try {
|
||||
sum += walk(i, depth - 2, plain);
|
||||
} catch (error) {
|
||||
caught++;
|
||||
sum += error instanceof Stop ? error.code & 7 : 1;
|
||||
}
|
||||
}
|
||||
out.push(`depth ${depth} ${plain ? "object" : "error"}: caught ${caught} sum ${sum}`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// The same chains through a callback: each level visits the next with a
|
||||
// closure, as tree visitors do.
|
||||
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
|
||||
const box = [n, depth];
|
||||
const r = depth === 0 ? bottom(n, false) : each(n, depth - 1);
|
||||
held[depth] = box;
|
||||
return r + box.length;
|
||||
}
|
||||
function visits(rounds: number): string {
|
||||
const out: string[] = [];
|
||||
const each = (n: number, depth: number): number => visit(n, depth, each);
|
||||
for (const depth of [2, 5, 20]) {
|
||||
let caught = 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
try {
|
||||
sum += visit(i, depth - 2, each);
|
||||
} catch (error) {
|
||||
caught++;
|
||||
sum += error instanceof RangeError ? 1 : 0;
|
||||
}
|
||||
}
|
||||
out.push(`visit depth ${depth}: caught ${caught} sum ${sum}`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// expr := term (("+" | "-") term)*; term := atom ("*" atom)*;
|
||||
// atom := number | "(" expr ")" | name "(" expr ("," expr)* ")" | name.
|
||||
// A call and a name start alike: the parser tries the call first and
|
||||
// backtracks when it fails.
|
||||
let text = "";
|
||||
let pos = 0;
|
||||
function peek(): string {
|
||||
return pos < text.length ? text[pos]! : "";
|
||||
}
|
||||
function expect(c: string): void {
|
||||
if (peek() !== c) throw new ParseError(pos, `expected ${c}`);
|
||||
pos++;
|
||||
}
|
||||
function name(): string {
|
||||
const start = pos;
|
||||
while (pos < text.length && text.charCodeAt(pos) >= 97 && text.charCodeAt(pos) <= 122) pos++;
|
||||
if (pos === start) throw new ParseError(pos, "expected a name");
|
||||
return text.slice(start, pos);
|
||||
}
|
||||
function call(): number {
|
||||
const callee = name();
|
||||
expect("(");
|
||||
let value = expr() + callee.length;
|
||||
while (peek() === ",") {
|
||||
pos++;
|
||||
value += expr();
|
||||
}
|
||||
expect(")");
|
||||
return value;
|
||||
}
|
||||
function atom(): number {
|
||||
const c = peek();
|
||||
if (c >= "0" && c <= "9") {
|
||||
let value = 0;
|
||||
while (peek() >= "0" && peek() <= "9") value = value * 10 + (text.charCodeAt(pos++) - 48);
|
||||
return value;
|
||||
}
|
||||
if (c === "(") {
|
||||
pos++;
|
||||
const value = expr();
|
||||
expect(")");
|
||||
return value;
|
||||
}
|
||||
const start = pos;
|
||||
try {
|
||||
return call();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ParseError)) throw error;
|
||||
pos = start;
|
||||
return name().length;
|
||||
}
|
||||
}
|
||||
function term(): number {
|
||||
let value = atom();
|
||||
while (peek() === "*") {
|
||||
pos++;
|
||||
value = (value * atom()) % 1000003;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function expr(): number {
|
||||
let value = term();
|
||||
while (peek() === "+" || peek() === "-") {
|
||||
const op = text[pos++];
|
||||
value = op === "+" ? value + term() : value - term();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let seed = 4242;
|
||||
function random(): number {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
||||
return seed / 4294967296;
|
||||
}
|
||||
function source(depth: number): string {
|
||||
const r = random();
|
||||
if (depth > 6 || r < 0.25) return String(Math.floor(random() * 100));
|
||||
if (r < 0.45) return "abcdefgh".slice(0, 1 + Math.floor(random() * 8));
|
||||
if (r < 0.6) return `(${source(depth + 1)})`;
|
||||
if (r < 0.8) return `${source(depth + 1)}${random() < 0.5 ? "+" : "*"}${source(depth + 1)}`;
|
||||
return `f${"xyz".slice(0, Math.floor(random() * 3))}(${source(depth + 1)},${source(depth + 1)})`;
|
||||
}
|
||||
|
||||
function parse(count: number): string {
|
||||
let total = 0;
|
||||
let failed = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Every tenth input lacks its last character, which fails deep inside it.
|
||||
const full = source(0);
|
||||
text = i % 10 === 0 ? full.slice(0, -1) : full;
|
||||
pos = 0;
|
||||
try {
|
||||
total = (total + expr()) % 1000003;
|
||||
if (pos !== text.length) throw new ParseError(pos, "trailing input");
|
||||
} catch (error) {
|
||||
if (!(error instanceof ParseError)) throw error;
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return `parsed ${count}: total ${total} failed ${failed}`;
|
||||
}
|
||||
|
||||
const scale = Number(process.argv[2] ?? "1");
|
||||
console.log(chains(Math.floor(60000 * scale)));
|
||||
console.log(visits(Math.floor(60000 * scale)));
|
||||
console.log(parse(Math.floor(40000 * scale)));
|
||||
@@ -3,6 +3,8 @@
|
||||
When `strictEqual` compares function values, `actual` and `expected` read as `undefined`; when `assert.throws` or `assert.rejects` reports an error that is not an instance of the expected class, or that does not match an expected object, `expected` reads as `undefined`.
|
||||
Values whose type can be cyclic, such as recursive records, also read as `undefined`.
|
||||
`assert.AssertionError` used as a value outside `instanceof` throws `SC2020`.
|
||||
The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument.
|
||||
`throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`.
|
||||
`assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused.
|
||||
`assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals.
|
||||
Checked-dynamic values compared deeply have no cycle detection.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported.
|
||||
Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
|
||||
Unions of a Date with other non-nullish types are unsupported.
|
||||
Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
|
||||
- **Date:** Spread constructor arguments and locale formatters are unsupported.
|
||||
Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
|
||||
Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them.
|
||||
Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile.
|
||||
Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint.
|
||||
A Date converted to `unknown` is the same Date object.
|
||||
See [Dates and timezones](#dates-and-timezones) for timezone data.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
- **Prototypes:** Named properties and methods on program class prototypes support replacement, inheritance, and deletion.
|
||||
Bare prototype reflection, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported.
|
||||
A module-level JavaScript class's prototype can become the prototype of other objects: `Object.create(C.prototype)`, `Object.setPrototypeOf(F.prototype, C.prototype)`, `Object.setPrototypeOf(object, C.prototype)`, and `util.inherits(F, C)` with an ordinary constructor `F`.
|
||||
The inherited methods and accessors run with the inheriting object as `this`.
|
||||
Methods that use `#private` members or `super` throw a TypeError for a receiver that is not an instance of the class.
|
||||
`C.prototype` also reads as a value for `Object.getPrototypeOf` and for `===` and `!==` comparisons.
|
||||
Changing the prototype of a class prototype (`Object.setPrototypeOf(C.prototype, X)`, `util.inherits(C, F)`, or `Object.setPrototypeOf` on a class prototype reached through an instance) is unsupported.
|
||||
Other bare prototype reflection, TypeScript class prototypes as values, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported.
|
||||
Plain objects, arrays, functions, class instances, and Date, RegExp, Map, Set, WeakMap, WeakSet, and Promise values inherit `constructor`, `valueOf`, `toString`, `hasOwnProperty`, and `propertyIsEnumerable` from `Object.prototype` as in Node, so `value.constructor === Object` and `value.valueOf === Object.prototype.valueOf` compare identities exactly.
|
||||
Named reads such as `value.valueOf` answer these inherited members; computed-key reads such as `value[key]` do not.
|
||||
`Object.prototype`'s own members read as values; its mutation and other reflection, such as `isPrototypeOf` and `__proto__`, are unsupported.
|
||||
The constructors `Object`, `Function`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, and `Promise` read as function values; through a stored value only `Object` can be called.
|
||||
`new` through a stored `Object`, `Date`, `Map`, `Set`, `WeakMap`, or `WeakSet` builds the same value as the `new` expression.
|
||||
`new` through a stored `RegExp` or `Promise`, `new` of a stored `Object` with a primitive argument, and `instanceof` against these constructors are unsupported.
|
||||
`instanceof Object` and `instanceof Function` on untyped values follow the prototype chain as in Node, so an object built with `Object.create(null)` is not an `Object`.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "llvm/CodeGen/MachineFunction.h"
|
||||
#include "llvm/CodeGen/MachineFunctionPass.h"
|
||||
#include "llvm/CodeGen/MachineModuleInfo.h"
|
||||
#include "llvm/IR/DiagnosticHandler.h"
|
||||
#include "llvm/IR/DiagnosticInfo.h"
|
||||
#include "llvm/IR/DiagnosticPrinter.h"
|
||||
#include "llvm/IR/LegacyPassManager.h"
|
||||
@@ -41,11 +42,13 @@
|
||||
#include "llvm/Support/SourceMgr.h"
|
||||
#include "llvm/Support/ThreadPool.h"
|
||||
#include "llvm/Support/Threading.h"
|
||||
#include "llvm/Support/JSON.h"
|
||||
#include "llvm/Support/raw_ostream.h"
|
||||
#include "llvm/Target/TargetMachine.h"
|
||||
#include "llvm/TargetParser/Triple.h"
|
||||
#include "llvm/Transforms/Utils/SplitModule.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -187,6 +190,108 @@ struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
|
||||
static bool isRequired() { return true; }
|
||||
};
|
||||
|
||||
// How the --profile-use profile matched the functions it annotated, summed
|
||||
// over partitions: functions that received its counts, functions whose
|
||||
// control flow changed since it was collected (hash mismatch), and functions
|
||||
// it has no record of (new, or never run in training: the merged profile is
|
||||
// sparse). A partition reused from the cache without its counts is counted
|
||||
// in Unknown.
|
||||
struct ProfileMatch {
|
||||
std::atomic<uint64_t> Annotated{0};
|
||||
std::atomic<uint64_t> Mismatched{0};
|
||||
std::atomic<uint64_t> Missing{0};
|
||||
std::atomic<uint64_t> Unknown{0};
|
||||
|
||||
void add(const ProfileMatch &Other) {
|
||||
Annotated += Other.Annotated;
|
||||
Mismatched += Other.Mismatched;
|
||||
Missing += Other.Missing;
|
||||
}
|
||||
|
||||
std::string serialize() const {
|
||||
return formatv("{0} {1} {2}", Annotated.load(), Mismatched.load(),
|
||||
Missing.load())
|
||||
.str();
|
||||
}
|
||||
|
||||
bool deserialize(StringRef Text) {
|
||||
SmallVector<StringRef, 3> Parts;
|
||||
Text.split(Parts, ' ');
|
||||
uint64_t Values[3];
|
||||
if (Parts.size() != 3)
|
||||
return false;
|
||||
for (size_t I = 0; I < 3; ++I)
|
||||
if (Parts[I].getAsInteger(10, Values[I]))
|
||||
return false;
|
||||
Annotated += Values[0];
|
||||
Mismatched += Values[1];
|
||||
Missing += Values[2];
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Counts the profile annotation's hash-mismatch and missing-function
|
||||
// warnings instead of printing them. Every other diagnostic takes the
|
||||
// context's default handling.
|
||||
class ProfileMatchHandler : public DiagnosticHandler {
|
||||
public:
|
||||
explicit ProfileMatchHandler(ProfileMatch &Match) : Match(Match) {}
|
||||
|
||||
bool handleDiagnostics(const DiagnosticInfo &Info) override {
|
||||
if (Info.getKind() != DK_PGOProfile)
|
||||
return false;
|
||||
std::string Message;
|
||||
raw_string_ostream Stream(Message);
|
||||
DiagnosticPrinterRawOStream Printer(Stream);
|
||||
Info.print(Printer);
|
||||
if (StringRef(Message).contains("hash mismatch"))
|
||||
++Match.Mismatched;
|
||||
else if (StringRef(Message).contains_insensitive("no profile data"))
|
||||
++Match.Missing;
|
||||
else
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
ProfileMatch &Match;
|
||||
};
|
||||
|
||||
// The annotation warns about functions the profile does not describe only
|
||||
// when asked; the match counts need those warnings. Process-wide, set once.
|
||||
void reportMissingProfiles() {
|
||||
static const bool Reported = [] {
|
||||
if (cl::Option *Option =
|
||||
cl::getRegisteredOptions().lookup("pgo-warn-missing-function"))
|
||||
Option->addOccurrence(0, "pgo-warn-missing-function", "true");
|
||||
return true;
|
||||
}();
|
||||
(void)Reported;
|
||||
}
|
||||
|
||||
// Functions of `M` that the profile annotated with counts.
|
||||
uint64_t annotatedFunctions(const Module &M) {
|
||||
uint64_t Count = 0;
|
||||
for (const Function &F : M) {
|
||||
if (F.isDeclaration())
|
||||
continue;
|
||||
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
|
||||
if (Entry && !Entry->isSynthetic())
|
||||
++Count;
|
||||
}
|
||||
return Count;
|
||||
}
|
||||
|
||||
// Prints the profile match as the successful emit's one line of JSON.
|
||||
void reportProfileMatch(const ProfileMatch &Match) {
|
||||
json::Object Profile{{"annotated", Match.Annotated.load()},
|
||||
{"mismatched", Match.Mismatched.load()},
|
||||
{"missing", Match.Missing.load()},
|
||||
{"unknown_partitions", Match.Unknown.load()}};
|
||||
json::Object Response{{"ok", true}, {"profile", std::move(Profile)}};
|
||||
outs() << formatv("{0}\n", json::Value(std::move(Response)));
|
||||
}
|
||||
|
||||
// Reads the profile's header once, before any partition needs it: it must
|
||||
// hold IR-level counts.
|
||||
std::optional<EmitFailure> inspectProfile(const EmitOptions &Options) {
|
||||
@@ -208,6 +313,7 @@ std::optional<EmitFailure> inspectProfile(const EmitOptions &Options) {
|
||||
|
||||
// Owns the analysis managers a pipeline needs for one module.
|
||||
struct Pipeline {
|
||||
PassInstrumentationCallbacks Callbacks;
|
||||
LoopAnalysisManager LAM;
|
||||
FunctionAnalysisManager FAM;
|
||||
CGSCCAnalysisManager CGAM;
|
||||
@@ -216,13 +322,26 @@ struct Pipeline {
|
||||
|
||||
Pipeline(TargetMachine &Machine, OptimizationLevel Level,
|
||||
std::optional<PGOOptions> Profile = std::nullopt)
|
||||
: PB(&Machine, tuningFor(Level), std::move(Profile)) {
|
||||
: PB(&Machine, tuningFor(Level), std::move(Profile), &Callbacks) {
|
||||
PB.registerModuleAnalyses(MAM);
|
||||
PB.registerCGSCCAnalyses(CGAM);
|
||||
PB.registerFunctionAnalyses(FAM);
|
||||
PB.registerLoopAnalyses(LAM);
|
||||
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
|
||||
}
|
||||
|
||||
// Adds to Count the functions the profile annotation gave counts to, as
|
||||
// soon as that pass has run: inlining and dead code elimination later in
|
||||
// the pipeline delete annotated functions.
|
||||
void countAnnotated(std::atomic<uint64_t> &Count) {
|
||||
Callbacks.registerAfterPassCallback(
|
||||
[&Count](StringRef PassID, Any IR, const PreservedAnalyses &) {
|
||||
if (PassID != "PGOInstrumentationUse")
|
||||
return;
|
||||
if (const auto *M = llvm::any_cast<const Module *>(&IR))
|
||||
Count += annotatedFunctions(**M);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
std::optional<EmitFailure> verify(Module &M, StringRef Code) {
|
||||
@@ -404,8 +523,15 @@ std::optional<EmitFailure> publish(StringRef TemporaryPath,
|
||||
std::optional<EmitFailure>
|
||||
emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
|
||||
std::optional<PGOOptions> Profile, CodeGenFileType Type,
|
||||
StringRef OutputPath, std::vector<SmallString<256>> &Temporaries) {
|
||||
StringRef OutputPath, std::vector<SmallString<256>> &Temporaries,
|
||||
ProfileMatch &Match) {
|
||||
bool Annotating = Profile && Profile->Action == PGOOptions::IRUse;
|
||||
if (Annotating)
|
||||
M.getContext().setDiagnosticHandler(
|
||||
std::make_unique<ProfileMatchHandler>(Match));
|
||||
Pipeline P(Machine, Level, std::move(Profile));
|
||||
if (Annotating)
|
||||
P.countAnnotated(Match.Annotated);
|
||||
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
|
||||
if (std::optional<EmitFailure> Failure =
|
||||
verify(M, "post_optimization_verification_failed"))
|
||||
@@ -572,20 +698,32 @@ std::optional<EmitFailure>
|
||||
compileOptimizedPartitions(const EmitOptions &Options,
|
||||
const PartitionPlan &Plan, OptimizationLevel Level,
|
||||
StringRef Salt, const ArtifactCache &Cache,
|
||||
std::vector<SmallVector<char, 0>> &Objects) {
|
||||
std::vector<SmallVector<char, 0>> &Objects,
|
||||
ProfileMatch &Match) {
|
||||
size_t Count = Plan.Modules.size();
|
||||
bool Annotating = !Options.ProfileUse.empty();
|
||||
std::vector<std::unique_ptr<MemoryBuffer>> Summaries(Count);
|
||||
SharedFailure Failures;
|
||||
forEachPartition(Count, [&](size_t I) {
|
||||
std::string Key = hashParts({Salt, "summary", Plan.Modules[I]});
|
||||
if ((Summaries[I] = Cache.lookup(Key, "bc")))
|
||||
if ((Summaries[I] = Cache.lookup(Key, "bc"))) {
|
||||
if (Annotating) {
|
||||
std::unique_ptr<MemoryBuffer> Counts = Cache.lookup(Key, "match");
|
||||
if (!Counts || !Match.deserialize(Counts->getBuffer()))
|
||||
++Match.Unknown;
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::string LookupError;
|
||||
std::unique_ptr<TargetMachine> Machine =
|
||||
createTargetMachine(Options.Target, Options.OptLevel, LookupError);
|
||||
if (!Machine)
|
||||
return Failures.set({"target_machine_failed", LookupError});
|
||||
LLVMContext Context;
|
||||
ProfileMatch PartitionMatch;
|
||||
if (Annotating)
|
||||
Context.setDiagnosticHandler(
|
||||
std::make_unique<ProfileMatchHandler>(PartitionMatch));
|
||||
Expected<std::unique_ptr<Module>> Loaded =
|
||||
loadPartition(Options, Plan.Modules[I], I, Context, *Machine);
|
||||
if (!Loaded)
|
||||
@@ -609,6 +747,8 @@ compileOptimizedPartitions(const EmitOptions &Options,
|
||||
// Profile instrumentation or annotation happens here, before the thin
|
||||
// link; the post-link optimization reads the annotated weights.
|
||||
Pipeline P(*Machine, Level, profileOptions(Options));
|
||||
if (Annotating)
|
||||
P.countAnnotated(PartitionMatch.Annotated);
|
||||
ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level);
|
||||
MPM.addPass(UnnameStructTypes());
|
||||
MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false,
|
||||
@@ -616,6 +756,11 @@ compileOptimizedPartitions(const EmitOptions &Options,
|
||||
/*EmitModuleHash=*/true));
|
||||
MPM.run(Mod, P.MAM);
|
||||
}
|
||||
if (Annotating) {
|
||||
Match.add(PartitionMatch);
|
||||
// Stored before the summary, so a reused summary finds its counts.
|
||||
Cache.store(Key, "match", PartitionMatch.serialize());
|
||||
}
|
||||
StringRef Contents(Bitcode.data(), Bitcode.size());
|
||||
Cache.store(Key, "bc", Contents);
|
||||
Summaries[I] = MemoryBuffer::getMemBufferCopy(Contents);
|
||||
@@ -762,7 +907,8 @@ compileOptimizedPartitions(const EmitOptions &Options,
|
||||
|
||||
std::optional<EmitFailure>
|
||||
compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan,
|
||||
std::vector<SmallVector<char, 0>> &Objects) {
|
||||
std::vector<SmallVector<char, 0>> &Objects,
|
||||
ProfileMatch &Match) {
|
||||
// Everything besides a partition's own bytes that determines its
|
||||
// artifacts. The caller's cache directory already identifies the exact
|
||||
// helper build; source content and partition count arrive per module.
|
||||
@@ -796,7 +942,7 @@ compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan,
|
||||
return compileUnoptimizedPartitions(Options, Plan, Salt, Cache, Objects);
|
||||
return compileOptimizedPartitions(Options, Plan,
|
||||
optimizationLevel(Options.OptLevel), Salt,
|
||||
Cache, Objects);
|
||||
Cache, Objects, Match);
|
||||
}
|
||||
|
||||
// Publishes in-memory partition objects through private sibling files.
|
||||
@@ -837,6 +983,8 @@ int emitPartitions(const EmitOptions &Options) {
|
||||
std::vector<SmallVector<char, 0>> Objects;
|
||||
std::optional<EmitFailure> Failure;
|
||||
bool Compiled = false;
|
||||
// A failed attempt's counts are discarded with its objects.
|
||||
auto Match = std::make_unique<ProfileMatch>();
|
||||
{
|
||||
ErrorOr<std::unique_ptr<MemoryBuffer>> Input =
|
||||
MemoryBuffer::getFile(Options.Input, /*IsText=*/false,
|
||||
@@ -848,15 +996,16 @@ int emitPartitions(const EmitOptions &Options) {
|
||||
Input->reset();
|
||||
PartitionPlan Plan{std::move(Text->Sources),
|
||||
std::move(Text->PublicSymbols)};
|
||||
Compiled = !compilePartitions(Options, Plan, Objects);
|
||||
Compiled = !compilePartitions(Options, Plan, Objects, *Match);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Compiled) {
|
||||
Match = std::make_unique<ProfileMatch>();
|
||||
PartitionPlan Plan;
|
||||
Failure = splitParsedModule(Options, Plan);
|
||||
if (!Failure)
|
||||
Failure = compilePartitions(Options, Plan, Objects);
|
||||
Failure = compilePartitions(Options, Plan, Objects, *Match);
|
||||
}
|
||||
std::vector<SmallString<256>> Temporaries;
|
||||
if (!Failure)
|
||||
@@ -869,6 +1018,8 @@ int emitPartitions(const EmitOptions &Options) {
|
||||
return reportError(Failure->Code, Failure->Message,
|
||||
Options.DiagnosticFormat);
|
||||
}
|
||||
if (!Options.ProfileUse.empty())
|
||||
reportProfileMatch(*Match);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -913,6 +1064,8 @@ int emit(const EmitOptions &Options) {
|
||||
return reportError("invalid_import",
|
||||
"runtime bitcode import requires an optimized build",
|
||||
Options.DiagnosticFormat);
|
||||
if (!Options.ProfileUse.empty())
|
||||
reportMissingProfiles();
|
||||
if (std::optional<EmitFailure> Failure = inspectProfile(Options))
|
||||
return reportError(Failure->Code, Failure->Message,
|
||||
Options.DiagnosticFormat);
|
||||
@@ -975,9 +1128,10 @@ int emit(const EmitOptions &Options) {
|
||||
? CodeGenFileType::ObjectFile
|
||||
: CodeGenFileType::AssemblyFile;
|
||||
std::vector<SmallString<256>> Temporaries;
|
||||
ProfileMatch Match;
|
||||
std::optional<EmitFailure> Failure =
|
||||
emitModule(*Mod, *Machine, Level, profileOptions(Options), Type,
|
||||
Options.Outputs[0], Temporaries);
|
||||
Options.Outputs[0], Temporaries, Match);
|
||||
if (!Failure)
|
||||
Failure = publish(Temporaries[0], Options.Outputs[0]);
|
||||
if (Failure) {
|
||||
@@ -986,6 +1140,8 @@ int emit(const EmitOptions &Options) {
|
||||
return reportError(Failure->Code, Failure->Message,
|
||||
Options.DiagnosticFormat);
|
||||
}
|
||||
if (!Options.ProfileUse.empty())
|
||||
reportProfileMatch(Match);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,3 +70,24 @@ interface PromiseConstructor {
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/* Carriers of the types the lowerer gives untyped JavaScript collections
|
||||
* whose uses prove them (js-collection-types.ts). Nothing reads them at run
|
||||
* time. */
|
||||
declare namespace __scriptcInferredCollections {
|
||||
const map_string_string: Map<string, string>;
|
||||
const map_string_number: Map<string, number>;
|
||||
const map_string_boolean: Map<string, boolean>;
|
||||
const map_number_string: Map<number, string>;
|
||||
const map_number_number: Map<number, number>;
|
||||
const map_number_boolean: Map<number, boolean>;
|
||||
const map_boolean_string: Map<boolean, string>;
|
||||
const map_boolean_number: Map<boolean, number>;
|
||||
const map_boolean_boolean: Map<boolean, boolean>;
|
||||
const set_string: Set<string>;
|
||||
const set_number: Set<number>;
|
||||
const set_boolean: Set<boolean>;
|
||||
const array_string: string[];
|
||||
const array_number: number[];
|
||||
const array_boolean: boolean[];
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -21,6 +21,7 @@ import {
|
||||
import { type NativeArtifactDependency } from "./native/contracts.js";
|
||||
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
|
||||
import type { NativeLinkPlan } from "./link-plan.js";
|
||||
import { lldLinkerArgs } from "./lld.js";
|
||||
import {
|
||||
createDarwinDebugSymbols,
|
||||
installDarwinDebugSymbols,
|
||||
@@ -240,8 +241,11 @@ export async function linkNativeExecutable(
|
||||
try {
|
||||
const staged = await stageRuntimePackArtifacts(plan.runtimePack);
|
||||
stagedRoot = staged.root;
|
||||
// Linux links with lld when the default driver finds it.
|
||||
const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : [];
|
||||
const args = [
|
||||
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
|
||||
...lld,
|
||||
...plan.driverFlags,
|
||||
...plan.inputs.map((input) => staged.replacements.get(input) ?? input),
|
||||
...plan.systemLibraries.map((name) => `-l${name}`),
|
||||
@@ -276,7 +280,15 @@ export async function linkNativeExecutable(
|
||||
...(await snapshotDependencies([...toolchain, ...additionalDependencyPaths])),
|
||||
])
|
||||
.catch(() => null);
|
||||
await execFileAsync(linker, args);
|
||||
await execFileAsync(linker, args).catch(async (error: unknown) => {
|
||||
// A failed link with lld runs again with the driver's default linker,
|
||||
// whose error (if any) is the one reported.
|
||||
if (lld.length === 0) throw error;
|
||||
await execFileAsync(
|
||||
linker,
|
||||
args.filter((arg) => !lld.includes(arg)),
|
||||
);
|
||||
});
|
||||
const output = await stat(privateOut);
|
||||
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
|
||||
if (plan.darwinDebugSymbols) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { driverFindsLld, lldLinkerArgs } from "./lld.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tool(path: string): string {
|
||||
writeFileSync(path, "#!/bin/sh\n");
|
||||
chmodSync(path, 0o755);
|
||||
return path;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "scriptc-lld-"));
|
||||
directories.push(root);
|
||||
const llvm = join(root, "llvm", "bin");
|
||||
const usr = join(root, "usr", "bin");
|
||||
const other = join(root, "other");
|
||||
for (const directory of [llvm, usr, other]) mkdirSync(directory, { recursive: true });
|
||||
return { root, llvm, usr, other };
|
||||
}
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"a driver finds ld.lld beside itself, beside its real executable, or on PATH",
|
||||
() => {
|
||||
const f = fixture();
|
||||
const clang = tool(join(f.llvm, "clang"));
|
||||
// Without ld.lld anywhere.
|
||||
expect(driverFindsLld(clang, { PATH: f.other })).toBe(false);
|
||||
// Beside the driver.
|
||||
tool(join(f.llvm, "ld.lld"));
|
||||
expect(driverFindsLld(clang, { PATH: f.other })).toBe(true);
|
||||
// A PATH entry that links to the driver: clang looks beside its real executable.
|
||||
symlinkSync(clang, join(f.usr, "clang"));
|
||||
expect(driverFindsLld("clang", { PATH: f.usr })).toBe(true);
|
||||
// A driver elsewhere finds ld.lld on PATH, and not otherwise.
|
||||
const gcc = tool(join(f.other, "gcc"));
|
||||
expect(driverFindsLld(gcc, { PATH: f.other })).toBe(false);
|
||||
expect(driverFindsLld(gcc, { PATH: [f.other, f.llvm].join(":") })).toBe(true);
|
||||
expect(driverFindsLld("missing-driver", { PATH: f.llvm })).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"Linux links with the default driver use lld when the driver finds it",
|
||||
() => {
|
||||
const f = fixture();
|
||||
const clang = tool(join(f.llvm, "clang"));
|
||||
tool(join(f.llvm, "ld.lld"));
|
||||
const env = { PATH: f.other };
|
||||
expect(lldLinkerArgs("linux", clang, env)).toEqual(["-fuse-ld=lld"]);
|
||||
expect(lldLinkerArgs("darwin", clang, env)).toEqual([]);
|
||||
expect(lldLinkerArgs("windows", clang, env)).toEqual([]);
|
||||
expect(lldLinkerArgs("linux", clang, { ...env, SCRIPTC_LINKER: clang })).toEqual([]);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { realpathSync, statSync } from "node:fs";
|
||||
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The executable `command` names: a path, or the first match on PATH. */
|
||||
function commandFile(command: string, env: NodeJS.ProcessEnv): string | null {
|
||||
if (command.includes("/")) return isFile(command) ? resolve(command) : null;
|
||||
for (const directory of (env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) {
|
||||
const candidate = join(directory || ".", command);
|
||||
if (isFile(candidate)) return isAbsolute(candidate) ? candidate : resolve(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the compiler driver `driver` finds ld.lld for `-fuse-ld=lld`: in
|
||||
* the directory of the driver's executable (where clang looks first) or on
|
||||
* PATH. Linking still falls back to the driver's default linker when a link
|
||||
* with lld fails.
|
||||
*/
|
||||
export function driverFindsLld(driver: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const file = commandFile(driver, env);
|
||||
if (file === null) return false;
|
||||
try {
|
||||
for (const directory of new Set([dirname(file), dirname(realpathSync(file))]))
|
||||
if (isFile(join(directory, "ld.lld"))) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return commandFile("ld.lld", env) !== null;
|
||||
}
|
||||
|
||||
/** `-fuse-ld=lld` for Linux links whose driver finds ld.lld. */
|
||||
export function lldLinkerArgs(
|
||||
platform: string,
|
||||
driver: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
return platform === "linux" && env["SCRIPTC_LINKER"] === undefined && driverFindsLld(driver, env)
|
||||
? ["-fuse-ld=lld"]
|
||||
: [];
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
* code after it); here the lines are DROPPED instead, and emitStmts skips
|
||||
* whole statements once the current block is terminated, so no dropped
|
||||
* definition can leak into a later block. */
|
||||
import { UNWIND_RESUME, UNWIND_SLOT } from "./unwind-abi.js";
|
||||
|
||||
export class BlockBuilder {
|
||||
private readonly blocks: { label: string; lines: string[]; term: string | null }[] = [];
|
||||
@@ -86,7 +87,18 @@ export class BlockBuilder {
|
||||
|
||||
terminate(s: string): void {
|
||||
if (this.cur.term === null) {
|
||||
if (this.returnEpilogue && s.startsWith("ret ")) this.line(this.returnEpilogue);
|
||||
if (
|
||||
this.returnEpilogue &&
|
||||
(s.startsWith("ret ") ||
|
||||
s.startsWith("call void @scr_unwind_raise()") ||
|
||||
s === UNWIND_RESUME)
|
||||
)
|
||||
this.line(this.returnEpilogue);
|
||||
if (s === UNWIND_RESUME) {
|
||||
const exception = this.tmp();
|
||||
this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`);
|
||||
s = `resume { ptr, i32 } ${exception}`;
|
||||
}
|
||||
this.cur.term = ` ${this.withDebugLocation(s)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,7 +580,9 @@ test("the guard tests a string's ASCII bit and the fast copy reads bytes", () =>
|
||||
const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
|
||||
const guard = body.slice(0, body.search(/^bytes\.version\.slow\d*:/m));
|
||||
expect(guard).toMatch(/load i32, ptr %\S+\n\s+%\S+ = icmp slt i32 %\S+, 0/);
|
||||
const fast = body.slice(body.lastIndexOf("bytes.version.fast"));
|
||||
// The byte copy, up to the units copy that follows it.
|
||||
const units = body.search(/^bytes\.version\.fast\.units\d+:/m);
|
||||
const fast = body.slice(body.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units);
|
||||
expect(fast).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/);
|
||||
expect(fast).not.toContain("@sc_str_char_code_at_int");
|
||||
// The checked copy keeps the inline helper and its fallback.
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
} from "./counted-loops.js";
|
||||
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
|
||||
import { f64Lit } from "./common.js";
|
||||
import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js";
|
||||
import {
|
||||
emitStringAsciiFlag,
|
||||
emitStringByteLength,
|
||||
STRING_CHAR_CODE_AT_DECL,
|
||||
stringScanType,
|
||||
} from "./string-reads.js";
|
||||
import { unitSafeLoop } from "./string-unit-versions.js";
|
||||
|
||||
type Node = IrExpr | IrStmt;
|
||||
type VarRef = IrExpr & { kind: "varRef" };
|
||||
@@ -73,6 +79,14 @@ export interface BytesLoopVersion {
|
||||
receivers: { ref: VarRef; global: boolean; string: boolean }[];
|
||||
/** Receivers the guard requires to have no shared storage. */
|
||||
ownedReceivers: number[];
|
||||
/** The string reads among `bounds`. */
|
||||
stringReads: ReadonlySet<Node>;
|
||||
/** The version's one string receiver, when the loop may also run over
|
||||
* its UTF-16 units if it is not ASCII (string-unit-versions.ts). */
|
||||
unitsReceiver: number | null;
|
||||
/** The units receiver's `length` reads in the loop. Both fast copies
|
||||
* read the length the guard took (its UTF-16 length) in their place. */
|
||||
unitLengths: ReadonlySet<IrExpr>;
|
||||
fixed: FixedCheck[];
|
||||
affine: AffineCheck[];
|
||||
/** While loops: accesses at a counter below a loop-invariant bound. */
|
||||
@@ -114,6 +128,9 @@ export interface BytesLoopFacts {
|
||||
/** String bindings held as a slice of another string (string-slices.ts):
|
||||
* they have no string header for the guard to read. */
|
||||
scalarStrings: ReadonlySet<string>;
|
||||
/** The functions a loop over a string's UTF-16 units may call; absent
|
||||
* or null where emitted code does not read the runtime's scan receiver. */
|
||||
unitSafeFunctions?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
const MAX_COST = 400;
|
||||
@@ -670,6 +687,19 @@ export function matchBytesLoopVersion(
|
||||
// Renumber the receivers the kept checks use.
|
||||
const order = [...used].sort((a, b) => a - b);
|
||||
const renumber = new Map(order.map((old, index) => [old, index]));
|
||||
const stringReads = new Set<Node>();
|
||||
for (const group of [...fixedKept, ...affineKept])
|
||||
if (receivers[group.check.receiver]!.string)
|
||||
for (const node of group.nodes) stringReads.add(node);
|
||||
for (const group of boundedKept)
|
||||
if (receivers[group.check.receiver]!.string)
|
||||
for (const { node } of group.nodes) stringReads.add(node);
|
||||
const strings = order.filter((index) => receivers[index]!.string);
|
||||
const unitLengths =
|
||||
facts.unitSafeFunctions != null && strings.length === 1
|
||||
? unitSafeLoop(loop, receivers[strings[0]!]!.ref.localId, facts.unitSafeFunctions)
|
||||
: null;
|
||||
const unitsReceiver = unitLengths !== null ? renumber.get(strings[0]!)! : null;
|
||||
const needsBound =
|
||||
affineKept.length > 0 &&
|
||||
counted !== null &&
|
||||
@@ -679,6 +709,9 @@ export function matchBytesLoopVersion(
|
||||
owned: ownedNodes,
|
||||
receivers: order.map((index) => receivers[index]!),
|
||||
ownedReceivers: ownedKept.map(([receiver]) => renumber.get(receiver)!),
|
||||
stringReads,
|
||||
unitsReceiver,
|
||||
unitLengths: unitLengths ?? new Set<IrExpr>(),
|
||||
fixed: fixedKept.map((g) => ({
|
||||
receiver: renumber.get(g.check.receiver)!,
|
||||
end: g.check.end,
|
||||
@@ -1018,13 +1051,22 @@ function findWhileCounterAccesses(
|
||||
/** Branches to `fast` when every access the version proves is in bounds for
|
||||
* every iteration and every view it proves thread-owned has no shared
|
||||
* storage, and to `slow` otherwise (including a counted loop whose
|
||||
* integer form is not established, or a module receiver not yet set). */
|
||||
* integer form is not established, or a module receiver not yet set).
|
||||
*
|
||||
* With `unitsFast`, the version's units receiver may instead be a string
|
||||
* that is not ASCII: the guard has the runtime publish it as the scan
|
||||
* receiver (one read), tests the same accesses against its UTF-16 length,
|
||||
* and branches to `unitsFast`. It returns the units pointer that copy
|
||||
* reads, which stays valid while the loop runs (string-unit-versions.ts),
|
||||
* and for a version with a units receiver, the receiver's UTF-16 length
|
||||
* on both fast paths (an i64). */
|
||||
export function emitBytesLoopVersionGuard(
|
||||
host: LlvmEmitterContext,
|
||||
version: BytesLoopVersion,
|
||||
fast: string,
|
||||
slow: string,
|
||||
): void {
|
||||
unitsFast?: string,
|
||||
): { units: string | null; length: string | null } | null {
|
||||
const B = host.B;
|
||||
const next = (cond: string): void => {
|
||||
if (cond === "true") return;
|
||||
@@ -1043,19 +1085,36 @@ export function emitBytesLoopVersionGuard(
|
||||
}
|
||||
return value;
|
||||
});
|
||||
const lengths = receivers.map((receiver, index) => {
|
||||
const len = version.receivers[index]!.string
|
||||
? emitStringByteLength(host, receiver)
|
||||
: emitBytesHeaderLength(host, receiver);
|
||||
const unitsIndex = unitsFast !== undefined ? version.unitsReceiver : null;
|
||||
const units: { read: { pointer: string; taken: string; published: string } | null } = {
|
||||
read: null,
|
||||
};
|
||||
const widen = (len: string): string => {
|
||||
if (host.sizeType === "i64") return len;
|
||||
const wide = B.tmp();
|
||||
B.line(`${wide} = zext ${host.sizeType} ${len} to i64`);
|
||||
return wide;
|
||||
};
|
||||
const lengths = receivers.map((receiver, index) => {
|
||||
if (index === unitsIndex) {
|
||||
const read = emitUnitsLength(host, receiver);
|
||||
units.read = read;
|
||||
return read.length;
|
||||
}
|
||||
return widen(
|
||||
version.receivers[index]!.string
|
||||
? emitStringByteLength(host, receiver)
|
||||
: emitBytesHeaderLength(host, receiver),
|
||||
);
|
||||
});
|
||||
const conditions: string[] = [];
|
||||
// The published count is the receiver's UTF-16 length only while the
|
||||
// runtime publishes the receiver itself.
|
||||
if (units.read) conditions.push(units.read.published);
|
||||
// A string's byte length is its UTF-16 length when it is ASCII.
|
||||
version.receivers.forEach(({ string }, index) => {
|
||||
if (string) conditions.push(emitStringAsciiFlag(host, receivers[index]!));
|
||||
if (string && index !== unitsIndex)
|
||||
conditions.push(emitStringAsciiFlag(host, receivers[index]!));
|
||||
});
|
||||
for (const index of version.ownedReceivers) {
|
||||
const owned = B.tmp();
|
||||
@@ -1158,7 +1217,7 @@ export function emitBytesLoopVersionGuard(
|
||||
// Unreachable: every term has a proven range. Stay checked.
|
||||
B.br(slow);
|
||||
B.startBlock(B.newLabel("bytes.version.dead"));
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const wide = widenInteger(host, integer);
|
||||
const sum = B.tmp();
|
||||
@@ -1215,8 +1274,70 @@ export function emitBytesLoopVersionGuard(
|
||||
valid = all;
|
||||
}
|
||||
}
|
||||
if (valid === "true") B.br(fast);
|
||||
else B.condBr(valid, fast, slow);
|
||||
const length = version.unitsReceiver !== null ? lengths[version.unitsReceiver]! : null;
|
||||
const taken = units.read;
|
||||
const target = taken ? B.newLabel("bytes.version.units") : fast;
|
||||
if (valid === "true") B.br(target);
|
||||
else B.condBr(valid, target, slow);
|
||||
if (!taken) return { units: null, length };
|
||||
B.startBlock(target);
|
||||
B.condBr(taken.taken, unitsFast!, fast);
|
||||
return { units: taken.pointer, length };
|
||||
}
|
||||
|
||||
/** The length of a version's units receiver `s` in UTF-16 units: its byte
|
||||
* length when it is ASCII; otherwise, after one runtime read publishes it
|
||||
* as the scan receiver, the published unit count (0 when the runtime does
|
||||
* not hold its units). Also the units pointer, whether the loop is to
|
||||
* read it, and whether the published receiver is `s`. */
|
||||
function emitUnitsLength(
|
||||
host: LlvmEmitterContext,
|
||||
s: string,
|
||||
): { length: string; pointer: string; taken: string; published: string } {
|
||||
const B = host.B;
|
||||
const sz = host.sizeType;
|
||||
const type = stringScanType(sz);
|
||||
const ascii = emitStringAsciiFlag(host, s);
|
||||
const bytes = emitStringByteLength(host, s);
|
||||
const from = B.currentLabel();
|
||||
const publish = B.newLabel("bytes.version.publish"),
|
||||
join = B.newLabel("bytes.version.length");
|
||||
B.condBr(ascii, join, publish);
|
||||
B.startBlock(publish);
|
||||
host.declare(STRING_CHAR_CODE_AT_DECL);
|
||||
const read = B.tmp(),
|
||||
receiver = B.tmp(),
|
||||
same = B.tmp(),
|
||||
pointerSlot = B.tmp(),
|
||||
pointer = B.tmp(),
|
||||
countSlot = B.tmp(),
|
||||
count = B.tmp(),
|
||||
published = B.tmp();
|
||||
B.line(`${read} = call double @scr_str_char_code_at(ptr ${s}, double 0.0)`);
|
||||
B.line(`${receiver} = load ptr, ptr @scr_str_scan`);
|
||||
B.line(`${same} = icmp eq ptr ${receiver}, ${s}`);
|
||||
B.line(`${pointerSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 1`);
|
||||
B.line(`${pointer} = load ptr, ptr ${pointerSlot}`);
|
||||
B.line(`${countSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 2`);
|
||||
B.line(`${count} = load ${sz}, ptr ${countSlot}`);
|
||||
B.line(`${published} = select i1 ${same}, ${sz} ${count}, ${sz} 0`);
|
||||
const end = B.currentLabel();
|
||||
B.br(join);
|
||||
B.startBlock(join);
|
||||
const length = B.tmp(),
|
||||
units = B.tmp(),
|
||||
taken = B.tmp();
|
||||
B.line(`${length} = phi ${sz} [ ${bytes}, %${from} ], [ ${published}, %${end} ]`);
|
||||
B.line(`${units} = phi ptr [ null, %${from} ], [ ${pointer}, %${end} ]`);
|
||||
B.line(`${taken} = phi i1 [ false, %${from} ], [ true, %${end} ]`);
|
||||
const exact = B.tmp();
|
||||
B.line(`${exact} = phi i1 [ true, %${from} ], [ ${same}, %${end} ]`);
|
||||
let wide = length;
|
||||
if (sz !== "i64") {
|
||||
wide = B.tmp();
|
||||
B.line(`${wide} = zext ${sz} ${length} to i64`);
|
||||
}
|
||||
return { length: wide, pointer: units, taken, published: exact };
|
||||
}
|
||||
|
||||
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
|
||||
|
||||
@@ -50,8 +50,8 @@ export function emitCountedLoopLimit(
|
||||
}
|
||||
|
||||
/** The run-time test of a guarded counted loop: an unproven limit is within
|
||||
* the integer form's finite range and an unproven start is an exact safe
|
||||
* integer. Returns `true` when nothing needs testing. */
|
||||
* the integer form's finite range and an unproven start is an exact integer
|
||||
* within the loop's start bound. Returns `true` when nothing needs testing. */
|
||||
export function emitCountedLoopSafety(
|
||||
host: LlvmEmitterContext,
|
||||
loop: IntegerCountedForLoop,
|
||||
@@ -65,7 +65,11 @@ export function emitCountedLoopSafety(
|
||||
`${safe} = fcmp ${loop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(loop.guardLimit)}`,
|
||||
);
|
||||
}
|
||||
if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start));
|
||||
if (loop.startGuarded) {
|
||||
const start = host.emitExpr(loop.start);
|
||||
const bound = loop.startBound;
|
||||
safe = andSafe(host, safe, emitExactIntegerTest(host, start.name, -bound, bound).ok);
|
||||
}
|
||||
for (const follower of loop.followers) {
|
||||
if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start));
|
||||
if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower));
|
||||
|
||||
@@ -122,6 +122,7 @@ const counted = (
|
||||
guarded: false,
|
||||
limitGuarded: false,
|
||||
startGuarded: false,
|
||||
startBound: Number.MAX_SAFE_INTEGER,
|
||||
guardLimit: 0,
|
||||
followers: [],
|
||||
...fields,
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
isDynTypedRefType,
|
||||
isRefCounted,
|
||||
isUnitType,
|
||||
recordTextCodecClass,
|
||||
recordHandleCodecClass,
|
||||
textCodecRecordClass,
|
||||
typeKey,
|
||||
} from "../../ir/ir.js";
|
||||
@@ -98,14 +98,16 @@ export const DYN_HANDLE_TAG_NUM: Record<string, number> = {
|
||||
|
||||
/** The handle tags of TextEncoder and TextDecoder records boxed as dyn
|
||||
* (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */
|
||||
export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = {
|
||||
export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder" | "StringDecoder", number> = {
|
||||
TextDecoder: 76,
|
||||
TextEncoder: 77,
|
||||
StringDecoder: 78,
|
||||
};
|
||||
|
||||
/** The decoder record fields scr_text_decoder_to_dyn reads, in its
|
||||
* parameter order. */
|
||||
const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const;
|
||||
const STRING_DECODER_FIELDS = ["%enc", "%pending"] as const;
|
||||
|
||||
/** Handle kinds whose runtime unit boxes them through its own function,
|
||||
* which installs the tag's ops on first use (scr_crypto.h). Programs that
|
||||
@@ -1825,11 +1827,18 @@ export class LlDyn {
|
||||
const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id));
|
||||
if (codec !== null) {
|
||||
// The record the codec's handle box holds (+1), or the path TypeError.
|
||||
host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
|
||||
const checked = B.tmp();
|
||||
B.line(
|
||||
`${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
|
||||
);
|
||||
if (codec === "StringDecoder") {
|
||||
host.declare(`declare ptr @scr_string_decoder_from_dyn(ptr, ptr, ptr)`);
|
||||
B.line(
|
||||
`${checked} = call ptr @scr_string_decoder_from_dyn(ptr %d, ptr %path, ptr ${want})`,
|
||||
);
|
||||
} else {
|
||||
host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
|
||||
B.line(
|
||||
`${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
|
||||
);
|
||||
}
|
||||
B.terminate(`ret ptr ${checked}`);
|
||||
this.defs.push(
|
||||
`define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`,
|
||||
@@ -2917,14 +2926,14 @@ export class LlDyn {
|
||||
return matched;
|
||||
}
|
||||
|
||||
/** A TextEncoder or TextDecoder record boxes as its handle (+1), which
|
||||
* holds the record by reference. A decoder passes the byte offsets of the
|
||||
* fields its boxed decode reads. */
|
||||
/** A TextEncoder, TextDecoder or StringDecoder record boxes as its
|
||||
* handle (+1), which holds the record by reference. The decoders pass
|
||||
* the byte offsets of the fields their boxed methods read. */
|
||||
private textCodecToDyn(
|
||||
B: BlockBuilder,
|
||||
t: IrType & { kind: "record" },
|
||||
shape: IrRecordShape,
|
||||
codec: "TextEncoder" | "TextDecoder",
|
||||
codec: "TextEncoder" | "TextDecoder" | "StringDecoder",
|
||||
): void {
|
||||
const host = this.host;
|
||||
const rc = vAdapters(host, t);
|
||||
@@ -2939,10 +2948,12 @@ export class LlDyn {
|
||||
const base = host.nullableUnions.fieldBase(t.shapeId);
|
||||
const start = B.tmp();
|
||||
B.line(`${start} = ptrtoint ptr %v to ${this.S}`);
|
||||
const offsets = TEXT_DECODER_FIELDS.map((field) => {
|
||||
const fields: readonly string[] =
|
||||
codec === "StringDecoder" ? STRING_DECODER_FIELDS : TEXT_DECODER_FIELDS;
|
||||
const offsets = fields.map((field) => {
|
||||
const index = shape.fields.findIndex((f) => f.name === field);
|
||||
if (index < 0)
|
||||
throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`);
|
||||
throw new InternalCompilerError(`llvm emitter bug: ${codec} record lacks ${field}`);
|
||||
const p = B.tmp();
|
||||
const at = B.tmp();
|
||||
const offset = B.tmp();
|
||||
@@ -2953,11 +2964,11 @@ export class LlDyn {
|
||||
B.line(`${offset} = sub ${this.S} ${at}, ${start}`);
|
||||
return `${this.S} ${offset}`;
|
||||
});
|
||||
host.declare(
|
||||
`declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`,
|
||||
);
|
||||
const fn =
|
||||
codec === "StringDecoder" ? "scr_string_decoder_to_dyn" : "scr_text_decoder_to_dyn";
|
||||
host.declare(`declare ptr @${fn}(ptr, ptr, ptr, ${fields.map(() => this.S).join(", ")})`);
|
||||
B.line(
|
||||
`${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
|
||||
`${r} = call ptr @${fn}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
|
||||
);
|
||||
}
|
||||
B.terminate(`ret ptr ${r}`);
|
||||
@@ -3137,7 +3148,7 @@ export class LlDyn {
|
||||
const shape = host.recordsById.get(t.shapeId);
|
||||
if (!shape)
|
||||
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
|
||||
const codec = recordTextCodecClass(shape);
|
||||
const codec = recordHandleCodecClass(shape);
|
||||
if (codec !== null) {
|
||||
this.textCodecToDyn(B, t, shape, codec);
|
||||
break;
|
||||
|
||||
@@ -56,11 +56,14 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js";
|
||||
import { beginFunctionLoops, LOOPS } from "./loop-state.js";
|
||||
import { runningExtremumUpdates } from "./running-extrema.js";
|
||||
import { findIntegerOrNaNLocals } from "./wrapping-sums.js";
|
||||
import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js";
|
||||
|
||||
export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
const B = new BlockBuilder();
|
||||
emitter.B = B;
|
||||
emitter.currentFunctionName = fn.name;
|
||||
emitter.currentUnwindAbi = emitter.unwindAbi.has(fn.name);
|
||||
emitter.usesLandingPads = false;
|
||||
emitter.findInt32Allocations(fn);
|
||||
emitter.currentTier = fn.tier;
|
||||
// Fiber switches restore the active exception cell before control
|
||||
@@ -136,6 +139,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator;
|
||||
beginFunctionLoops(emitter, fn, numericFn);
|
||||
emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled;
|
||||
emitter.loopCopiesEnabled = true;
|
||||
emitter.speculativeFunction = fn.name;
|
||||
emitter.speculation = null;
|
||||
emitter.speculationResume = null;
|
||||
@@ -159,6 +163,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
emitter.optionalArrayReads,
|
||||
emitter.referenceWrites,
|
||||
emitter.fnByName,
|
||||
emitter.nodeIndex,
|
||||
)
|
||||
: new Set();
|
||||
emitter.localArrayReads = findLocalArrayReads(
|
||||
@@ -324,7 +329,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
|
||||
const paramIds = new Set(fn.params.map((p) => p.localId));
|
||||
if (emitter.debug === null) {
|
||||
for (const id of findIntegerViews(numericFn)) {
|
||||
for (const id of findIntegerViews(numericFn, emitter.nodeIndex)) {
|
||||
// An i32 slot is its own view.
|
||||
if (emitter.int32Locals.has(id)) continue;
|
||||
const slot = B.slot();
|
||||
@@ -595,6 +600,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
B.terminate(cleanup.terminator);
|
||||
}
|
||||
|
||||
if (emitter.usesLandingPads && emitter.currentUnwindAbi)
|
||||
B.entryAllocas.push(`${UNWIND_SLOT} = alloca { ptr, i32 }`);
|
||||
if (emitter.logArgSlots > 0) {
|
||||
B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`);
|
||||
}
|
||||
@@ -635,7 +642,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
|
||||
const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`;
|
||||
const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name);
|
||||
emitter.privateGlobalSlots.clear();
|
||||
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
|
||||
// Landing pads (emitDirectCall) need the personality.
|
||||
const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : "";
|
||||
if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`);
|
||||
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`;
|
||||
if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi);
|
||||
if (emitter.integerClones.members.has(fn.name)) {
|
||||
body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true);
|
||||
|
||||
@@ -61,7 +61,7 @@ import { hasLoneSurrogate, wtf8Bytes } from "../literals.js";
|
||||
import { undefinedArmTag } from "../../ir/analysis.js";
|
||||
import { NULLABLE_ABSENT, NULLABLE_NULL } from "./nullable-unions.js";
|
||||
import { literalEqualityDefinition } from "./string-literal-equality.js";
|
||||
import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js";
|
||||
import { STRING_READ_HELPERS, stringScanDecl, stringScanInline } from "./string-reads.js";
|
||||
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
import { targetCpuAttribute } from "../target-cpu.js";
|
||||
@@ -1615,9 +1615,7 @@ export function helperDefs(emitter: LlEmitter): string[] {
|
||||
// A scan's receiver reads inline too (ScrStrScan): from ordinary global
|
||||
// storage, or, in worker executables, from the thread's static TLS block,
|
||||
// one thread-pointer-relative load (see executableTls).
|
||||
const scanInline =
|
||||
emitter.executableTls ||
|
||||
(emitter.mod.workers !== true && emitter.mod.lib?.threadInstances !== true);
|
||||
const scanInline = stringScanInline(emitter);
|
||||
let scanDeclared = false;
|
||||
for (const helper of STRING_READ_HELPERS) {
|
||||
if (!emitter.decls.has(helper.decl)) continue;
|
||||
|
||||
@@ -994,7 +994,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
|
||||
LOOPS.of(emitter).denseLoopsEnabled = false;
|
||||
try {
|
||||
B.startBlock(slow);
|
||||
emitter.emitStmt(s);
|
||||
emitSlowCopy(emitter, s);
|
||||
B.br(done);
|
||||
B.startBlock(fast);
|
||||
LOOPS.of(emitter).denseLoop = dense;
|
||||
@@ -1018,7 +1018,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
|
||||
LOOPS.of(emitter).byteWindowsEnabled = false;
|
||||
try {
|
||||
B.startBlock(slow);
|
||||
emitter.emitStmt(s);
|
||||
emitSlowCopy(emitter, s);
|
||||
B.br(done);
|
||||
B.startBlock(fast);
|
||||
emitter.integerRanges = window.ranges;
|
||||
@@ -1072,7 +1072,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
|
||||
B.condBr(safe, fast, slow);
|
||||
B.startBlock(slow);
|
||||
LOOPS.of(emitter).countedLoopsEnabled = false;
|
||||
emitter.emitStmt(s);
|
||||
emitSlowCopy(emitter, s);
|
||||
LOOPS.of(emitter).countedLoopsEnabled = true;
|
||||
B.br(countedJoin);
|
||||
B.startBlock(fast);
|
||||
@@ -2049,7 +2049,7 @@ function emitCallbackVersionedLoop(
|
||||
emitter.callbackVersioning.add(s);
|
||||
try {
|
||||
B.startBlock(slow);
|
||||
emitter.emitStmt(s);
|
||||
emitSlowCopy(emitter, s);
|
||||
B.br(done);
|
||||
B.startBlock(fast);
|
||||
for (const { ref } of bindings) emitter.knownCallbacks.add(ref.localId);
|
||||
@@ -2062,6 +2062,20 @@ function emitCallbackVersionedLoop(
|
||||
B.startBlock(done);
|
||||
}
|
||||
|
||||
/** The copy of a versioned loop that runs when its guard fails. Its loops
|
||||
* get no speculative or int32 kind copies, which would emit each loop again
|
||||
* for a path that rarely runs. Proven integer loops, which emit one copy,
|
||||
* are kept. */
|
||||
function emitSlowCopy(emitter: LlEmitter, s: IrStmt): void {
|
||||
const copies = emitter.loopCopiesEnabled;
|
||||
emitter.loopCopiesEnabled = false;
|
||||
try {
|
||||
emitter.emitStmt(s);
|
||||
} finally {
|
||||
emitter.loopCopiesEnabled = copies;
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries in a row whose fast copy bailed before a speculative loop site
|
||||
* stops trying it. */
|
||||
const SPECULATION_BAILS = 8;
|
||||
@@ -2082,28 +2096,56 @@ function emitVersionedLoop(
|
||||
const fast = B.newLabel("bytes.version.fast"),
|
||||
slow = B.newLabel("bytes.version.slow"),
|
||||
done = B.newLabel("bytes.version.done");
|
||||
emitBytesLoopVersionGuard(emitter, version, fast, slow);
|
||||
const unitsFast =
|
||||
version.unitsReceiver !== null ? B.newLabel("bytes.version.fast.units") : undefined;
|
||||
const guard = emitBytesLoopVersionGuard(emitter, version, fast, slow, unitsFast);
|
||||
const units = guard?.units ?? null;
|
||||
const originalBounds = emitter.bytesBounds,
|
||||
originalOwned = emitter.bytesOwned,
|
||||
originalRanges = emitter.integerRanges;
|
||||
originalRanges = emitter.integerRanges,
|
||||
originalUnitReads = emitter.stringUnitReads,
|
||||
originalLengthReads = emitter.stringLengthReads;
|
||||
LOOPS.of(emitter).bytesLoopVersioning.add(s);
|
||||
try {
|
||||
B.startBlock(slow);
|
||||
emitter.emitStmt(s);
|
||||
emitSlowCopy(emitter, s);
|
||||
B.br(done);
|
||||
B.startBlock(fast);
|
||||
emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]);
|
||||
if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
|
||||
if (version.ranges.size > 0)
|
||||
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
|
||||
if (version.counted?.guarded || version.paired?.loop.guarded)
|
||||
LOOPS.of(emitter).provenCountedGuards.add(s);
|
||||
B.startBlock(fast);
|
||||
emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]);
|
||||
// Both fast copies read the string's length the guard took.
|
||||
const length = guard?.length;
|
||||
if (length != null && version.unitLengths.size > 0)
|
||||
emitter.stringLengthReads = new Map([
|
||||
...originalLengthReads,
|
||||
...[...version.unitLengths].map((node) => [node, length] as const),
|
||||
]);
|
||||
emitter.emitStmt(s);
|
||||
B.br(done);
|
||||
if (unitsFast !== undefined && units !== null) {
|
||||
// The string reads take the receiver's UTF-16 units.
|
||||
B.startBlock(unitsFast);
|
||||
emitter.bytesBounds = new Set([
|
||||
...originalBounds,
|
||||
...[...version.bounds].filter((node) => !version.stringReads.has(node)),
|
||||
]);
|
||||
emitter.stringUnitReads = new Map([
|
||||
...originalUnitReads,
|
||||
...[...version.stringReads].map((node) => [node, units] as const),
|
||||
]);
|
||||
emitter.emitStmt(s);
|
||||
B.br(done);
|
||||
}
|
||||
} finally {
|
||||
emitter.bytesBounds = originalBounds;
|
||||
emitter.bytesOwned = originalOwned;
|
||||
emitter.integerRanges = originalRanges;
|
||||
emitter.stringUnitReads = originalUnitReads;
|
||||
emitter.stringLengthReads = originalLengthReads;
|
||||
LOOPS.of(emitter).provenCountedGuards.delete(s);
|
||||
LOOPS.of(emitter).bytesLoopVersioning.delete(s);
|
||||
}
|
||||
@@ -2120,6 +2162,7 @@ export function emitLoopCopies(
|
||||
const plan =
|
||||
allowed &&
|
||||
emitter.speculativeLoopsEnabled &&
|
||||
emitter.loopCopiesEnabled &&
|
||||
emitter.speculation === null &&
|
||||
emitter.speculationResume === null
|
||||
? planSpeculativeLoop(
|
||||
@@ -2147,6 +2190,7 @@ export function emitLoopCopies(
|
||||
const kinds =
|
||||
allowed &&
|
||||
emitter.speculativeLoopsEnabled &&
|
||||
emitter.loopCopiesEnabled &&
|
||||
emitter.int32Slots.checkedInt32Arrays.size > 0 &&
|
||||
emitter.int32Kinds === null &&
|
||||
emitter.speculation === null &&
|
||||
@@ -2437,7 +2481,7 @@ function planProvenLoop(
|
||||
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" }>,
|
||||
): ProvenLoopPlan | null {
|
||||
if (
|
||||
loop.kind !== "for" ||
|
||||
loop.kind === "doWhile" ||
|
||||
!emitter.speculativeLoopsEnabled ||
|
||||
emitter.speculation !== null ||
|
||||
emitter.speculationResume !== null ||
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "./emit-journal.js";
|
||||
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
|
||||
import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
|
||||
import { inlineExpressionFunctions } from "../../ir/expression-inlining.js";
|
||||
import {
|
||||
allocatedLiteral,
|
||||
fillAllocationLength,
|
||||
@@ -109,6 +110,8 @@ import { dropUnwrittenPropertyBags } from "../../ir/property-bags.js";
|
||||
import { freezeInitOnlyGlobals } from "../../ir/init-only-globals.js";
|
||||
import { hoistFinalFieldReads } from "../../ir/field-values.js";
|
||||
import { holdCharLocalsAsUnits } from "../../ir/char-unit-locals.js";
|
||||
import { unitSafeFunctions } from "./string-unit-versions.js";
|
||||
import { stringScanInline } from "./string-reads.js";
|
||||
import {
|
||||
findInt32Abis,
|
||||
INT32_ABI_SUFFIX,
|
||||
@@ -128,6 +131,16 @@ import {
|
||||
type FfiCallbackAdapter,
|
||||
} from "../ffi-callbacks.js";
|
||||
import { computeMayThrow } from "../may-throw.js";
|
||||
import {
|
||||
UNWIND_PERSONALITY,
|
||||
UNWIND_RAISE,
|
||||
UNWIND_RAISE_DECLARATION,
|
||||
UNWIND_RESUME,
|
||||
UNWIND_SLOT,
|
||||
UnwindAbiViolation,
|
||||
bridgePlainUnwindCalls,
|
||||
unwindAbiFunctions,
|
||||
} from "./unwind-abi.js";
|
||||
import {
|
||||
mangleAsyncInlineAwaitCall,
|
||||
mangleAsyncSpawn,
|
||||
@@ -176,7 +189,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js";
|
||||
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
|
||||
import { emitBorrowedInputs } from "./borrowed-inputs.js";
|
||||
import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
|
||||
import { IrNodeIndex } from "../../ir/node-index.js";
|
||||
import { eachNodeOfKinds, IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
|
||||
import { lastUseRead } from "./local-extents.js";
|
||||
import { findReboundParameters } from "./rebound-parameters.js";
|
||||
import { ReferenceEffects } from "./reference-effects.js";
|
||||
@@ -439,6 +452,12 @@ export interface ForkPrimitives {
|
||||
export interface LlvmTargetOptions {
|
||||
/** C ABI target; omitted direct emission uses the host ABI. */
|
||||
targetTriple?: string;
|
||||
/** Linux (glibc and musl) executables: functions reached only by direct
|
||||
* calls propagate exceptions by unwinding the native stack (unwind-abi.ts). */
|
||||
unwindAbi?: boolean;
|
||||
/** Functions kept on the pending-flag ABI under `unwindAbi`: the ones an
|
||||
* earlier emission found breaking its rules (UnwindAbiViolation). */
|
||||
unwindExcluded?: ReadonlySet<string>;
|
||||
/** Emit the function bodies in this many contiguous ranges, each by its
|
||||
* own emitter, and merge them in function order (emit-journal.ts). The
|
||||
* output is the same as one emitter's. Debug builds (`debugSources`)
|
||||
@@ -562,13 +581,15 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
|
||||
scalarizeNumericRecords(
|
||||
specializeCallbackCalls(
|
||||
specializeNumericCalls(
|
||||
foldConstantGlobals(
|
||||
copyStableCaptures(
|
||||
resolveSelfCaptures(
|
||||
hoistFinalFieldReads(
|
||||
freezeInitOnlyGlobals(
|
||||
holdCharLocalsAsUnits(
|
||||
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
|
||||
inlineExpressionFunctions(
|
||||
foldConstantGlobals(
|
||||
copyStableCaptures(
|
||||
resolveSelfCaptures(
|
||||
hoistFinalFieldReads(
|
||||
freezeInitOnlyGlobals(
|
||||
holdCharLocalsAsUnits(
|
||||
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -590,16 +611,36 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
|
||||
return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null);
|
||||
}
|
||||
|
||||
/** Emits the module's parts, emitting it again with the functions an
|
||||
* UnwindAbiViolation names on the pending-flag ABI. */
|
||||
function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] {
|
||||
for (let excluded = options.unwindExcluded ?? new Set<string>(); ;) {
|
||||
const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded });
|
||||
try {
|
||||
return emitter.emitParts();
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnwindAbiViolation)) throw error;
|
||||
const names = new Set(excluded);
|
||||
for (const name of emitter.unwindAbi)
|
||||
for (const symbol of [mangleFunction(name), mangleBorrowedFunction(name)])
|
||||
if (error.symbols.has(symbol) || error.symbols.has(symbol + INT32_ABI_SUFFIX))
|
||||
names.add(name);
|
||||
if (names.size === excluded.size) throw error;
|
||||
excluded = names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
|
||||
return moduleEmitter(input, options).emit();
|
||||
return emitModuleText(input, options).join("\n");
|
||||
}
|
||||
|
||||
/** emitLlvmModule's text as parts that concatenate to it, without the copy
|
||||
* that joining them makes: a large program's text is 150 MB. */
|
||||
export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] {
|
||||
return moduleEmitter(input, options)
|
||||
.emitParts()
|
||||
.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
|
||||
return emitModuleText(input, options).flatMap((part, index) =>
|
||||
index === 0 ? [part] : ["\n", part],
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep large translation units below the host's single-string limit. */
|
||||
@@ -609,7 +650,7 @@ export function emitLlvmModuleSource(
|
||||
): string | readonly string[] {
|
||||
// IR validation, contracts and the frontend's disposal run after lowering without checks.
|
||||
relieveHeapPressure(true);
|
||||
const parts = moduleEmitter(input, options).emitParts();
|
||||
const parts = emitModuleText(input, options);
|
||||
const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1));
|
||||
if (length <= 256 * 1024 * 1024) return parts.join("\n");
|
||||
return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
|
||||
@@ -621,6 +662,11 @@ export function llStrBytes(text: string): string {
|
||||
|
||||
const NO_BORROWED: ReadonlySet<number> = new Set();
|
||||
|
||||
/** The nodes that write a binding by its id (numberGlobalWriters). */
|
||||
const GLOBAL_WRITE_KINDS: readonly IrNodeKind[] = ["assign", "varDecl", "assignExpr", "incDec"];
|
||||
/** Runtime library calls (readsLoopMetrics). */
|
||||
const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"];
|
||||
|
||||
/** [A-Za-z0-9_] */
|
||||
function isIdentifierByte(code: number): boolean {
|
||||
return (
|
||||
@@ -1248,6 +1294,10 @@ export class LlEmitter {
|
||||
/** May-throw analysis (computeMayThrow): pending
|
||||
* checks are emitted only after calls that can actually raise. */
|
||||
readonly mayThrow: Set<string>;
|
||||
/** The functions that propagate exceptions by unwinding (unwind-abi.ts). */
|
||||
readonly unwindAbi: Set<string>;
|
||||
/** Whether the function being emitted is one of unwindAbi. */
|
||||
currentUnwindAbi = false;
|
||||
readonly indirectMayThrow: boolean;
|
||||
/** Worker programs: the functions that poll for termination on entry
|
||||
* (workerTerminationPolls); null outside worker programs. */
|
||||
@@ -1456,6 +1506,9 @@ export class LlEmitter {
|
||||
integerTempsEnabled = false;
|
||||
/** Speculative integer loops (speculative-integers.ts) run in this function. */
|
||||
speculativeLoopsEnabled = false;
|
||||
/** False inside the copy of a versioned loop that runs when its guard
|
||||
* fails: loops there get no speculative or int32 kind copies. */
|
||||
loopCopiesEnabled = true;
|
||||
/** The fast copy of a speculative integer loop being emitted: its
|
||||
* integer-computed writes and where each resumes in the ordinary copy. */
|
||||
speculation: {
|
||||
@@ -1510,6 +1563,12 @@ export class LlEmitter {
|
||||
* (one-unit tests and switches, charAtUnitRead), with the charAt each
|
||||
* stands for: a loop version's proof for the charAt covers its read. */
|
||||
readonly charAtUnitReads = new Map<IrExpr, IrExpr>();
|
||||
/** String reads a loop version proved in bounds of a string's UTF-16
|
||||
* units, with the units pointer to read (string-unit-versions.ts). */
|
||||
stringUnitReads: ReadonlyMap<IrExpr | IrStmt, string> = new Map();
|
||||
/** `length` reads of a loop version's string, with the i64 UTF-16
|
||||
* length its guard took (bytes-loop-versions.ts). */
|
||||
stringLengthReads: ReadonlyMap<IrExpr, string> = new Map();
|
||||
/** Worker programs: typed-array accesses whose views an enclosing version
|
||||
* guard proved thread-owned. */
|
||||
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
|
||||
@@ -1706,6 +1765,16 @@ export class LlEmitter {
|
||||
nodes: this.nodeIndex,
|
||||
});
|
||||
this.mayThrow = mt.fns;
|
||||
this.unwindAbi =
|
||||
options.unwindAbi === true &&
|
||||
!this.wasi &&
|
||||
mod.lib === undefined &&
|
||||
options.debugSources === undefined &&
|
||||
(options.targetTriple
|
||||
? /-linux-(?:gnu|musl)/.test(options.targetTriple)
|
||||
: process.platform === "linux")
|
||||
? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded)
|
||||
: new Set();
|
||||
this.workerEntryPolls = mt.workerEntryPolls ?? null;
|
||||
this.stackChecks = mt.stackChecks ?? null;
|
||||
// A stackless call's eager prefix borrows its caller's stack; the
|
||||
@@ -1841,10 +1910,13 @@ export class LlEmitter {
|
||||
: undefined,
|
||||
)
|
||||
: NO_INTEGER_CLONES;
|
||||
// Integer clones and their dispatchers call each other through their
|
||||
// own lowering, which keeps plain calls and flag tests.
|
||||
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
|
||||
this.stackCallbacks = new StackCallbacks(this.fnByName);
|
||||
this.receiverReaders = new AmbientReceiverReaders(this.fnByName);
|
||||
this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex);
|
||||
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
|
||||
this.lazyCaptures = new LazyCaptures(this.fnByName);
|
||||
this.lazyCaptures = new LazyCaptures(this.fnByName, this.nodeIndex);
|
||||
const traced = computeTraced(mod, dynamicStores, cycleMutability);
|
||||
this.tracedShapes = traced.shapes;
|
||||
this.tracedUnions = traced.unions;
|
||||
@@ -2120,7 +2192,8 @@ export class LlEmitter {
|
||||
return this.emitParts().join("\n");
|
||||
}
|
||||
emitParts(): string[] {
|
||||
return emitParts(this);
|
||||
const parts = emitParts(this);
|
||||
return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
|
||||
}
|
||||
|
||||
/** The dispose-observer gates the emitted frees and record writers test
|
||||
@@ -3231,6 +3304,13 @@ export class LlEmitter {
|
||||
terminator = `br label %${target.label}`;
|
||||
} else if (this.currentCoro !== null) {
|
||||
terminator = `br label %${this.currentCoro.finalLabel}`;
|
||||
} else if (this.currentUnwindAbi && this.inLandingPad) {
|
||||
// The unwind that entered the landing pad goes on to the caller's.
|
||||
terminator = UNWIND_RESUME;
|
||||
} else if (this.currentUnwindAbi) {
|
||||
// The caller's invoke takes the exception at its landing pad.
|
||||
this.declare(UNWIND_RAISE_DECLARATION);
|
||||
terminator = UNWIND_RAISE;
|
||||
} else {
|
||||
const t = this.llType(this.currentReturnType);
|
||||
terminator =
|
||||
@@ -3261,6 +3341,7 @@ export class LlEmitter {
|
||||
passThrough !== undefined &&
|
||||
!target &&
|
||||
this.currentCoro === null &&
|
||||
!this.currentUnwindAbi &&
|
||||
!this.currentInt32Result
|
||||
) {
|
||||
const t = this.llType(this.currentReturnType);
|
||||
@@ -3447,6 +3528,46 @@ export class LlEmitter {
|
||||
B.startBlock(B.newLabel("exc.dead"));
|
||||
}
|
||||
|
||||
/** Emits `line`, a `call` of `callee`, and reports whether the call
|
||||
* needs no pending check after it: a call of an unwinding-ABI function
|
||||
* becomes an `invoke` whose landing pad runs this frame's unwind (the
|
||||
* same releases and handler as emitPendingCheck's unwind path), and the
|
||||
* normal path continues in a fresh block. Coroutine bodies keep plain
|
||||
* calls (unwindAbiFunctions excludes their callees). */
|
||||
emitDirectCall(line: string, callee: string): boolean {
|
||||
const B = this.B;
|
||||
if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) {
|
||||
B.line(line);
|
||||
return false;
|
||||
}
|
||||
const ok = B.newLabel("inv.ok");
|
||||
const pad = B.newLabel("inv.lp");
|
||||
this.usesLandingPads = true;
|
||||
// A trailing comment goes after the invoke's labels.
|
||||
const comment = line.indexOf(" ; ");
|
||||
const text = comment < 0 ? line : line.slice(0, comment);
|
||||
B.terminate(
|
||||
`${text.replace(/^((?:%[\w.]+ = )?)call /, "$1invoke ")} to label %${ok} unwind label %${pad}`,
|
||||
);
|
||||
B.startBlock(pad);
|
||||
const exception = B.tmp();
|
||||
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
|
||||
if (this.currentUnwindAbi) B.line(`store { ptr, i32 } ${exception}, ptr ${UNWIND_SLOT}`);
|
||||
this.inLandingPad = true;
|
||||
this.emitUnwind();
|
||||
this.inLandingPad = false;
|
||||
B.startBlock(ok);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether emitUnwind runs for a landing pad, whose unwind goes on with
|
||||
* `resume` instead of raising again. */
|
||||
private inLandingPad = false;
|
||||
|
||||
/** Whether the function being emitted has a landing pad (its definition
|
||||
* needs the personality). */
|
||||
usesLandingPads = false;
|
||||
|
||||
/** A recursive function's entry check: below the running stack's guard,
|
||||
* throw Node's RangeError and unwind like any other pending exception. */
|
||||
emitStackCheck(): void {
|
||||
@@ -3622,15 +3743,14 @@ export class LlEmitter {
|
||||
set.add(fn.name);
|
||||
writers.set(localId, set);
|
||||
};
|
||||
everyStmtList(fn.body, {
|
||||
stmt: (s) => {
|
||||
if (s.kind === "assign" || s.kind === "varDecl") note(s.localId);
|
||||
return true;
|
||||
},
|
||||
expr: (e) => {
|
||||
if (e.kind === "assignExpr" || e.kind === "incDec") note(e.localId);
|
||||
return true;
|
||||
},
|
||||
eachNodeOfKinds(fn, this.nodeIndex.scan(GLOBAL_WRITE_KINDS), (node) => {
|
||||
if (
|
||||
node.kind === "assign" ||
|
||||
node.kind === "varDecl" ||
|
||||
node.kind === "assignExpr" ||
|
||||
node.kind === "incDec"
|
||||
)
|
||||
note(node.localId);
|
||||
});
|
||||
}
|
||||
this.globalWriters = writers;
|
||||
@@ -4364,11 +4484,21 @@ export class LlEmitter {
|
||||
workers: this.mod.workers === true,
|
||||
owned: this.bytesOwned,
|
||||
scalarStrings: new Set(this.stringSlices.keys()),
|
||||
unitSafeFunctions: this.unitSafeFunctions(),
|
||||
},
|
||||
paired,
|
||||
);
|
||||
}
|
||||
|
||||
private unitSafe: ReadonlySet<string> | null | undefined;
|
||||
/** The functions a loop over a string's UTF-16 units may call, or null
|
||||
* where emitted reads do not use the runtime's scan receiver. */
|
||||
private unitSafeFunctions(): ReadonlySet<string> | null {
|
||||
if (this.unitSafe === undefined)
|
||||
this.unitSafe = stringScanInline(this) ? unitSafeFunctions(this.mod) : null;
|
||||
return this.unitSafe;
|
||||
}
|
||||
|
||||
/** The alias tag of a whole-slot load of a binding (module globals only). */
|
||||
slotAlias(id: string): string {
|
||||
const binding = this.binding(id);
|
||||
@@ -5218,7 +5348,22 @@ export class LlEmitter {
|
||||
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
|
||||
const ret = this.llType(fn.returnType);
|
||||
const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`;
|
||||
B.line(ret === "void" ? call : `%result = ${call}`);
|
||||
// The owned entry of an unwinding-ABI body releases the borrowed
|
||||
// parameters on the unwind too, then resumes it to its caller.
|
||||
const unwinding = this.unwindAbi.has(fn.name) && kept.size === 0;
|
||||
if (unwinding) {
|
||||
const ok = B.newLabel("adapt.ok");
|
||||
const pad = B.newLabel("adapt.lp");
|
||||
B.terminate(
|
||||
`${ret === "void" ? "" : "%result = "}invoke ${call.slice(5)} to label %${ok} unwind label %${pad}`,
|
||||
);
|
||||
B.startBlock(pad);
|
||||
const exception = B.tmp();
|
||||
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
|
||||
for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type);
|
||||
B.terminate(`resume { ptr, i32 } ${exception}`);
|
||||
B.startBlock(ok);
|
||||
} else B.line(ret === "void" ? call : `%result = ${call}`);
|
||||
// A borrowed result is reachable from the parameters: own it first. A
|
||||
// throwing body returns a null dummy, which needs no owner (and string
|
||||
// or array retains do not accept NULL).
|
||||
@@ -5244,7 +5389,9 @@ export class LlEmitter {
|
||||
B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`);
|
||||
const symbol =
|
||||
kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name);
|
||||
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`;
|
||||
if (unwinding) this.declare(`declare i32 @__gcc_personality_v0(...)`);
|
||||
const personality = unwinding ? ` ${UNWIND_PERSONALITY}` : "";
|
||||
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS}${personality} {\n${B.render()}\n}`;
|
||||
}
|
||||
canBorrowCallArgument(value: IrExpr): boolean {
|
||||
return canBorrowCallArgument(this, value);
|
||||
@@ -5417,13 +5564,21 @@ export class LlEmitter {
|
||||
private loopMetricsRead: boolean | undefined;
|
||||
readsLoopMetrics(): boolean {
|
||||
if (this.loopMetricsRead !== undefined) return this.loopMetricsRead;
|
||||
const visitor = {
|
||||
expr: (e: IrExpr): boolean =>
|
||||
!(e.kind === "libCall" && (e.fn === "perf.loopStart" || e.fn === "perf.idleTime")),
|
||||
stmt: (): boolean => true,
|
||||
};
|
||||
this.loopMetricsRead = !this.mod.functions.every((fn) => everyStmtList(fn.body, visitor));
|
||||
return this.loopMetricsRead;
|
||||
let found = false;
|
||||
const scan = this.nodeIndex.scan(LIB_CALL_KINDS);
|
||||
for (const fn of this.mod.functions) {
|
||||
eachNodeOfKinds(fn, scan, (node) => {
|
||||
if (
|
||||
node.kind === "libCall" &&
|
||||
(node.fn === "perf.loopStart" || node.fn === "perf.idleTime")
|
||||
)
|
||||
found = true;
|
||||
return !found;
|
||||
});
|
||||
if (found) break;
|
||||
}
|
||||
this.loopMetricsRead = found;
|
||||
return found;
|
||||
}
|
||||
|
||||
private lastUseMovesAllowed(): boolean {
|
||||
|
||||
@@ -244,38 +244,46 @@ export function emitCallExpr(
|
||||
? INTEGER_DISPATCH_SUFFIX
|
||||
: "";
|
||||
const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}${dispatch}`;
|
||||
// A call of an unwinding-ABI callee is an invoke (emitDirectCall):
|
||||
// its landing pad unwinds, so no pending check follows it.
|
||||
if (e.type.kind === "void") {
|
||||
B.line(`call void ${target}(${argList})`);
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
const invoked = host.emitDirectCall(`call void ${target}(${argList})`, e.callee);
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
if (borrowed) host.releaseTemps(host.frames.pop()!);
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
const t = B.tmp();
|
||||
if (abi?.union) {
|
||||
B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`);
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
const invoked = host.emitDirectCall(
|
||||
`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`,
|
||||
e.callee,
|
||||
);
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
if (borrowed) host.releaseTemps(host.frames.pop()!);
|
||||
if (stackResult) return host.stackScalarUnion(t, e.type);
|
||||
return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type });
|
||||
}
|
||||
if (abi?.result) {
|
||||
B.line(`${t} = call i32 ${target}(${argList})`);
|
||||
const invoked = host.emitDirectCall(`${t} = call i32 ${target}(${argList})`, e.callee);
|
||||
const out = host.int32Result(t, e.type);
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
|
||||
if (borrowed) host.releaseTemps(host.frames.pop()!);
|
||||
return out;
|
||||
}
|
||||
B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`);
|
||||
const invoked = host.emitDirectCall(
|
||||
`${t} = call ${host.llType(e.type)} ${target}(${argList})`,
|
||||
e.callee,
|
||||
);
|
||||
let out: LlValue;
|
||||
if (borrowed && host.borrowedReturns.has(e.callee)) {
|
||||
// A borrowed-return body hands back +0 (a null dummy when it threw):
|
||||
// test the exception first, then own the result before the
|
||||
// argument snapshots that keep it reachable are released.
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
out = host.own({ name: host.retainFrame(t, e.type), type: e.type });
|
||||
} else {
|
||||
out = host.own({ name: t, type: e.type });
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
|
||||
}
|
||||
if (borrowed) {
|
||||
host.toFrame(out);
|
||||
@@ -1366,10 +1374,11 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call
|
||||
)
|
||||
.join(", ");
|
||||
const t = host.B.tmp();
|
||||
host.B.line(
|
||||
const invoked = host.emitDirectCall(
|
||||
`${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}(${argList}) ; borrowed result`,
|
||||
e.callee,
|
||||
);
|
||||
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
STRING_CHAR_CODE_AT_INT_MARKER,
|
||||
emitAsciiChar,
|
||||
emitAsciiCharCode,
|
||||
emitUnitCharCode,
|
||||
STRING_UTF16_LEN_DECL,
|
||||
} from "./string-reads.js";
|
||||
import { exactInteger, widenInteger } from "./integer-values.js";
|
||||
@@ -321,6 +322,15 @@ export function charAtUnitRead(
|
||||
return read;
|
||||
}
|
||||
|
||||
/** The UTF-16 units an enclosing loop version proved the index of `e`
|
||||
* below the count of (string-unit-versions.ts). */
|
||||
function provenUnitsRead(host: LlvmEmitterContext, e: IrExpr): string | null {
|
||||
const units = host.stringUnitReads.get(e);
|
||||
if (units !== undefined) return units;
|
||||
const origin = host.charAtUnitReads.get(e);
|
||||
return origin !== undefined ? (host.stringUnitReads.get(origin) ?? null) : null;
|
||||
}
|
||||
|
||||
/** An enclosing loop version proved the string of `e` ASCII and its index
|
||||
* below the length (bytes-loop-versions.ts). */
|
||||
function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean {
|
||||
@@ -363,9 +373,14 @@ export function emitStrIntrinsic(
|
||||
const method = e.method;
|
||||
switch (method) {
|
||||
case "length": {
|
||||
const t = B.tmp();
|
||||
const length = host.stringLengthReads.get(e);
|
||||
if (length !== undefined) {
|
||||
B.line(`${t} = sitofp i64 ${length} to double`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
// sc_str_utf16_len (string-reads.ts) answers proven-ASCII strings inline.
|
||||
host.declare(STRING_UTF16_LEN_DECL);
|
||||
const t = B.tmp();
|
||||
B.line(`${t} = call double @sc_str_utf16_len(ptr ${r.name})`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
@@ -375,6 +390,8 @@ export function emitStrIntrinsic(
|
||||
const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
|
||||
if (index) {
|
||||
const wide = widenInteger(host, index);
|
||||
const units = provenUnitsRead(host, e);
|
||||
if (units !== null) return { name: emitUnitCharCode(host, units, wide), type: e.type };
|
||||
if (provenStringRead(host, e))
|
||||
return { name: emitAsciiCharCode(host, r.name, wide), type: e.type };
|
||||
host.declare(STRING_CHAR_CODE_AT_INT_MARKER);
|
||||
@@ -1460,6 +1477,7 @@ export function emitMapLikeIntrinsic(
|
||||
: emitStringMapGetScalar(host, r.name, stringKey!);
|
||||
found = scalar.found;
|
||||
if (stringKey !== null) rememberMapEntry(host, e, r.name, stringKey, scalar.entry);
|
||||
else rememberMapEntry(host, e, r.name, numberKey!, scalar.entry, "double");
|
||||
readHit = () => {
|
||||
const hit = B.tmp();
|
||||
B.line(
|
||||
@@ -1531,7 +1549,13 @@ export function emitMapLikeIntrinsic(
|
||||
const vAcc = elemAccess(receiverType.value);
|
||||
if (vAcc === "ref") host.toHeap(v);
|
||||
const ordinary = (): void => host.mapSet(r.name, kAcc, vAcc, k.name, v.name);
|
||||
if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) {
|
||||
if (
|
||||
(kAcc === "str" || kAcc === "f64") &&
|
||||
vAcc !== "ref" &&
|
||||
emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary)
|
||||
) {
|
||||
// Written through the read's entry, or inserted.
|
||||
} else if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) {
|
||||
const bits = B.tmp();
|
||||
B.line(
|
||||
vAcc === "f64"
|
||||
@@ -1539,12 +1563,7 @@ export function emitMapLikeIntrinsic(
|
||||
: `${bits} = zext i1 ${v.name} to i64`,
|
||||
);
|
||||
emitNumberMapSetScalar(host, r.name, k.name, bits, ordinary);
|
||||
} else if (
|
||||
kAcc !== "str" ||
|
||||
vAcc === "ref" ||
|
||||
!emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary)
|
||||
)
|
||||
ordinary();
|
||||
} else ordinary();
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
case "add": {
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface WideInteger {
|
||||
name: string;
|
||||
below: number;
|
||||
bound: number;
|
||||
/** The double is the integer rounded once: the result of one operation on
|
||||
* operands that always equal their doubles. */
|
||||
single?: true;
|
||||
}
|
||||
|
||||
export type ExprOf<K extends IrExpr["kind"]> = Extract<IrExpr, { kind: K }>;
|
||||
|
||||
@@ -89,7 +89,7 @@ export function emitDynamicExpr(
|
||||
// Bytes already box their shared mutable storage directly. A second
|
||||
// capsule would split identity between checked and native views.
|
||||
const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className);
|
||||
// A TextEncoder or TextDecoder record boxes as its own handle, which
|
||||
// A TextEncoder, TextDecoder or StringDecoder record boxes as its own handle, which
|
||||
// keeps the record's identity (toDynHelper).
|
||||
const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null;
|
||||
if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
|
||||
|
||||
@@ -13,13 +13,14 @@ import { emitStringInputs } from "./string-lifetimes.js";
|
||||
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
|
||||
import { emitBorrowedInputs } from "./borrowed-inputs.js";
|
||||
import {
|
||||
alwaysExactInteger,
|
||||
exactCompareOperands,
|
||||
exactInteger,
|
||||
int32Number,
|
||||
integerNumber,
|
||||
narrowInteger,
|
||||
wideArithmetic,
|
||||
widenInteger,
|
||||
type LlInteger,
|
||||
} from "./integer-values.js";
|
||||
import {
|
||||
bitwiseIdentityOperand,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
slotWidth,
|
||||
type ArrayAccess,
|
||||
} from "./dense-array-access.js";
|
||||
import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js";
|
||||
import { emitWrappingSum, integerOrNaNRange, markWrappingOperands } from "./wrapping-sums.js";
|
||||
import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js";
|
||||
|
||||
/** The signed integer predicates of the number compares. */
|
||||
@@ -58,6 +59,63 @@ const INTEGER_COMPARES: Record<string, string> = {
|
||||
"!==": "ne",
|
||||
};
|
||||
|
||||
const UNSIGNED_COMPARES: Record<string, string> = {
|
||||
"<": "ult",
|
||||
"<=": "ule",
|
||||
">": "ugt",
|
||||
">=": "uge",
|
||||
"===": "eq",
|
||||
"!==": "ne",
|
||||
};
|
||||
|
||||
/** A number compare of operands that are each an exact integer or an
|
||||
* integer-or-NaN value (integerOrNaNRange: integer typed-array elements,
|
||||
* `charCodeAt` results, sums of those), all within int32 or all within
|
||||
* uint32: their 32-bit views compared, and NaN comparing false (unequal for
|
||||
* `!==`). The 32-bit view is the value's ToUint32, which holds its bits
|
||||
* whenever it is not NaN. Null when an operand has no such view. */
|
||||
function emitIntegerOrNaNCompare(
|
||||
host: LlvmEmitterContext,
|
||||
e: ExprOf<"bin">,
|
||||
l: LlValue,
|
||||
li: LlInteger | null,
|
||||
r: LlValue,
|
||||
ri: LlInteger | null,
|
||||
): string | null {
|
||||
const lr = li?.range ?? integerOrNaNRange(host, e.left),
|
||||
rr = ri?.range ?? integerOrNaNRange(host, e.right);
|
||||
if (!lr || !rr) return null;
|
||||
const signed = withinInt32(lr) && withinInt32(rr);
|
||||
if (!signed && !(withinUint32(lr) && withinUint32(rr))) return null;
|
||||
const bits = (value: LlValue, integer: LlInteger | null): string | undefined =>
|
||||
integer ? narrowInteger(host, integer) : value.uint32;
|
||||
const left = bits(l, li),
|
||||
right = bits(r, ri);
|
||||
if (left === undefined || right === undefined) return null;
|
||||
const B = host.B;
|
||||
const compare = B.tmp();
|
||||
B.line(
|
||||
`${compare} = icmp ${(signed ? INTEGER_COMPARES : UNSIGNED_COMPARES)[e.op]} i32 ${left}, ${right}`,
|
||||
);
|
||||
let result = compare;
|
||||
for (const [value, integer] of [
|
||||
[l, li],
|
||||
[r, ri],
|
||||
] as const) {
|
||||
if (integer) continue;
|
||||
const ordered = B.tmp(),
|
||||
combined = B.tmp();
|
||||
B.line(`${ordered} = fcmp ord double ${value.name}, 0.0`);
|
||||
if (e.op === "!==") {
|
||||
const unordered = B.tmp();
|
||||
B.line(`${unordered} = xor i1 ${ordered}, true`);
|
||||
B.line(`${combined} = or i1 ${result}, ${unordered}`);
|
||||
} else B.line(`${combined} = and i1 ${result}, ${ordered}`);
|
||||
result = combined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function emitLiteralExpr(
|
||||
host: LlvmEmitterContext,
|
||||
e: ExprOf<"numLit" | "boolLit" | "strLit" | "moduleNsRef" | "unitLit" | "varRef">,
|
||||
@@ -363,14 +421,16 @@ export function emitOperatorExpr(
|
||||
return value;
|
||||
}
|
||||
} else {
|
||||
// An operand with only a wide view, such as `i + 1` of a speculated
|
||||
// counter: compare the integers when both always equal the doubles.
|
||||
const left = alwaysExactInteger(host, l, li),
|
||||
right = left === null ? null : alwaysExactInteger(host, r, ri);
|
||||
if (left !== null && right !== null) {
|
||||
B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`);
|
||||
// An operand with only a wide view, such as `i + 1` or `2 * i + 1`
|
||||
// of a speculated counter: compare the integers when that gives the
|
||||
// doubles' result.
|
||||
const operands = exactCompareOperands(host, l, li, r, ri);
|
||||
if (operands) {
|
||||
B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${operands[0]}, ${operands[1]}`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
const nanCoded = emitIntegerOrNaNCompare(host, e, l, li, r, ri);
|
||||
if (nanCoded) return { name: nanCoded, type: e.type };
|
||||
B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
|
||||
}
|
||||
} else if (bit[e.op] !== undefined) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { typedRefConstructor } from "./shapes.js";
|
||||
/* Focused LLVM expression emission extracted from emitter.ts. */
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
|
||||
import { streamRooted, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
|
||||
import {
|
||||
type IrRecordShape,
|
||||
type IrType,
|
||||
@@ -843,8 +843,7 @@ export function streamTypedRefMaterializeAdapter(
|
||||
host.declare(`declare ptr @scr_error_dyn_fields(ptr)`);
|
||||
B.line(`${out} = call ptr @scr_error_dyn_fields(ptr %p)`);
|
||||
} else if (
|
||||
meta.def.runtime &&
|
||||
RUNTIME_STREAM_CLASSES.has(t.className) &&
|
||||
((meta.def.runtime && RUNTIME_STREAM_CLASSES.has(t.className)) || streamRooted(meta)) &&
|
||||
!meta.def.instancePrototypeHelper &&
|
||||
!meta.def.prototypeDataHelper
|
||||
) {
|
||||
|
||||
@@ -111,13 +111,17 @@ export function wideArithmetic(
|
||||
rv = view(r, ri);
|
||||
if (!lv || !rv) return null;
|
||||
let below: number, bound: number;
|
||||
let single = false;
|
||||
if (op === "*") {
|
||||
if (lv.below !== Infinity || rv.below !== Infinity) return null;
|
||||
below = EXACT_DOUBLES;
|
||||
bound = productBound(lv.bound, rv.bound);
|
||||
// Scaling by a power of two never rounds a double that stays finite.
|
||||
below = powerOfTwo(li) || powerOfTwo(ri) ? Infinity : EXACT_DOUBLES;
|
||||
single = true;
|
||||
} else if (lv.below === Infinity && rv.below === Infinity) {
|
||||
below = EXACT_DOUBLES;
|
||||
bound = sumBound(lv.bound, rv.bound);
|
||||
single = true;
|
||||
} else if (lv.below === Infinity || rv.below === Infinity) {
|
||||
const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv];
|
||||
below = Math.min(wide.below - exact.bound, EXACT_DOUBLES);
|
||||
@@ -132,7 +136,50 @@ export function wideArithmetic(
|
||||
host.B.line(
|
||||
`${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`,
|
||||
);
|
||||
return { name, below, bound };
|
||||
return single ? { name, below, bound, single } : { name, below, bound };
|
||||
}
|
||||
|
||||
/** Whether an exact integer is the constant 2^k or -2^k. */
|
||||
function powerOfTwo(integer: LlInteger | null): boolean {
|
||||
if (integer === null || integer.range.min !== integer.range.max) return false;
|
||||
const value = Math.abs(integer.range.min);
|
||||
// Math.log2 can round to an integer for a value that is not a power of
|
||||
// two (2^52 - 1), so the candidate power is compared with the value.
|
||||
return value >= 1 && value <= 2 ** 52 && 2 ** Math.round(Math.log2(value)) === value;
|
||||
}
|
||||
|
||||
/** The i64 operands of a number compare that gives the doubles' result:
|
||||
* both equal their doubles wherever they can be, or one is an exact integer
|
||||
* of magnitude at most 2^53 - 1 and the other's double is its integer
|
||||
* rounded once. Rounding is monotonic, and b - 1, b and b + 1 are doubles
|
||||
* for a safe integer b, so an integer rounded once compares with b as the
|
||||
* integer does. Null for any other pair. */
|
||||
export function exactCompareOperands(
|
||||
host: LlvmEmitterContext,
|
||||
l: LlValue,
|
||||
li: LlInteger | null,
|
||||
r: LlValue,
|
||||
ri: LlInteger | null,
|
||||
): [string, string] | null {
|
||||
const describe = (value: LlValue, integer: LlInteger | null) => {
|
||||
if (integer) return { exact: true, single: true, bound: magnitude(integer.range) };
|
||||
const wide = value.wideInteger;
|
||||
if (!wide) return null;
|
||||
return { exact: wide.bound <= wide.below, single: wide.single === true, bound: wide.bound };
|
||||
};
|
||||
const a = describe(l, li),
|
||||
b = describe(r, ri);
|
||||
if (!a || !b) return null;
|
||||
const safe = (d: { exact: boolean; bound: number }): boolean =>
|
||||
d.exact && d.bound <= Number.MAX_SAFE_INTEGER;
|
||||
const ok =
|
||||
(a.exact && b.exact) ||
|
||||
(safe(a) && (b.exact || b.single)) ||
|
||||
(safe(b) && (a.exact || a.single));
|
||||
if (!ok) return null;
|
||||
const operand = (value: LlValue, integer: LlInteger | null): string =>
|
||||
integer ? widenInteger(host, integer) : value.wideInteger!.name;
|
||||
return [operand(l, li), operand(r, ri)];
|
||||
}
|
||||
|
||||
/** An i64 index for a consumer that compares it, unsigned, with a length or
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
|
||||
import { byteNumberAccess } from "../../ir/byte-numbers.js";
|
||||
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
|
||||
|
||||
const LOOP_KINDS: readonly IrNodeKind[] = ["for", "forOf", "while", "doWhile"];
|
||||
|
||||
/** Keep a computed ToUint32 view beside an ordinary number when integer
|
||||
* consumers in loops can reuse it. The number remains authoritative for floating
|
||||
@@ -14,9 +17,16 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js";
|
||||
* compare converts the stored number back, and LLVM moves that number's
|
||||
* conversion into the join of a NaN-coded typed-array read (`phi [sitofp x,
|
||||
* 0.0]`), where nothing folds it back to the integer. */
|
||||
export function findIntegerViews(fn: IrFunction): Set<string> {
|
||||
export function findIntegerViews(fn: IrFunction, nodes?: IrNodeIndex): Set<string> {
|
||||
const wanted = new Set<string>();
|
||||
if (fn.async || fn.generator) return wanted;
|
||||
// Every view is wanted inside a loop: a body without one wants none.
|
||||
let loops = false;
|
||||
eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => {
|
||||
if (statement && LOOP_KINDS.includes(node.kind)) loops = true;
|
||||
return !loops;
|
||||
});
|
||||
if (!loops) return wanted;
|
||||
const initialized = new Set(fn.params.map((p) => p.localId));
|
||||
const excluded = new Set(
|
||||
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
|
||||
import { everyStmtList } from "../../ir/traverse.js";
|
||||
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
|
||||
|
||||
const CLOSURE_KINDS: readonly IrNodeKind[] = ["closure"];
|
||||
|
||||
/** The local a node rebinds. Writes through the bound reference, such as a
|
||||
* field store or an array push, leave the binding unchanged. Catch bindings
|
||||
@@ -37,7 +40,11 @@ export class LazyCaptures {
|
||||
private readonly pending = new Set<IrFunction>();
|
||||
private sites: Map<string, { owner: string; captures: string[] }[]> | null = null;
|
||||
|
||||
constructor(private readonly functions: ReadonlyMap<string, IrFunction>) {}
|
||||
constructor(
|
||||
private readonly functions: ReadonlyMap<string, IrFunction>,
|
||||
/** The module's node index, read instead of walking the bodies it holds. */
|
||||
private readonly nodes?: IrNodeIndex,
|
||||
) {}
|
||||
|
||||
/** The captures of closure body `fnName` that hold one value for the
|
||||
* closure's whole life: at every creation site the captured binding is
|
||||
@@ -103,16 +110,15 @@ export class LazyCaptures {
|
||||
private creationSites(): Map<string, { owner: string; captures: string[] }[]> {
|
||||
if (this.sites !== null) return this.sites;
|
||||
const sites = new Map<string, { owner: string; captures: string[] }[]>();
|
||||
const scan = this.nodes?.scan(CLOSURE_KINDS);
|
||||
for (const fn of this.functions.values()) {
|
||||
const visit = (node: IrStmt | IrExpr): boolean => {
|
||||
eachNodeOfKinds(fn, scan, (node) => {
|
||||
if (node.kind === "closure") {
|
||||
let list = sites.get(node.fnName);
|
||||
if (!list) sites.set(node.fnName, (list = []));
|
||||
list.push({ owner: fn.name, captures: node.captures });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
everyStmtList(fn.body, { stmt: visit, expr: visit });
|
||||
});
|
||||
}
|
||||
this.sites = sites;
|
||||
return sites;
|
||||
|
||||
@@ -649,6 +649,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
|
||||
if (MAY_THROW_LIB_FNS.has(e.fn) && LIB_FN_SYMS[e.fn] === undefined) {
|
||||
throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
|
||||
}
|
||||
// A setter's write: the time value into the Date object (ScrDate.ms).
|
||||
if (e.fn === "date.store") {
|
||||
const date = emitBorrowedInput(host, e.args[0]!).name;
|
||||
const ms = host.emitExpr(e.args[1]!).name;
|
||||
const field = B.tmp();
|
||||
B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`);
|
||||
B.line(`store double ${ms}, ptr ${field}`);
|
||||
return { name: ms, type: e.type };
|
||||
}
|
||||
// queueMicrotask's checked-dynamic form carries the AsyncLocalStorage
|
||||
// context like the static one (lib-process.ts).
|
||||
const sym =
|
||||
|
||||
@@ -119,6 +119,13 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr
|
||||
B.line(`call void @scr_child_on_error(ptr ${child.name}, ptr ${cb.name}, ptr @${adapter})`);
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
if (e.fn === "child.hasChannel") {
|
||||
const child = host.emitExpr(e.args[0]!);
|
||||
host.declare(`declare zeroext i1 @scr_child_ipc_present(ptr)`);
|
||||
const out = B.tmp();
|
||||
B.line(`${out} = call i1 @scr_child_ipc_present(ptr ${child.name})`);
|
||||
return { name: out, type: e.type };
|
||||
}
|
||||
if (e.fn === "child.connected") {
|
||||
const child = host.emitExpr(e.args[0]!);
|
||||
host.declare(`declare zeroext i1 @scr_child_ipc_connected(ptr)`);
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap<string, number | null> = new Ma
|
||||
|
||||
export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"assert.expectsErrDyn": "scr_assert_expects_err_dyn",
|
||||
"assert.throwsValidate": "scr_assert_throws_validate",
|
||||
"insp.jsonDyn": "scr_util_format_j",
|
||||
"http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw",
|
||||
"intl.segmenterNew": "scr_intl_segmenter_new",
|
||||
@@ -82,6 +83,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"util.isDeepStrictEqual": "scr_util_is_deep_strict_equal",
|
||||
"util.styleText": "scr_util_style_text",
|
||||
"util.inherits": "scr_util_inherits",
|
||||
"util.inheritsPrototype": "scr_util_inherits_prototype",
|
||||
"util.deprecate": "scr_util_deprecate",
|
||||
"util.callbackify": "scr_util_callbackify",
|
||||
"util.promisifyDyn": "scr_util_promisify",
|
||||
@@ -334,6 +336,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"date.getTime": "scr_date_get_time",
|
||||
"date.valueOf": "scr_date_get_time",
|
||||
"date.fromTime": "scr_date_get_time",
|
||||
"date.setFields": "scr_date_set_fields",
|
||||
"date.timeClip": "scr_date_new_ms",
|
||||
"date.parseGetTime": "scr_date_parse",
|
||||
"date.utc": "scr_date_utc",
|
||||
"date.newLocal": "scr_date_new_local",
|
||||
@@ -399,6 +403,9 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"assert.eqBool": "scr_assert_eq_bool",
|
||||
"assert.eqSym": "scr_assert_eq_sym",
|
||||
"assert.eqDyn": "scr_assert_eq_dyn",
|
||||
"assert.messageValue": "scr_assert_message_value",
|
||||
"assert.methodValue": "scr_assert_method_value",
|
||||
"assert.messageClear": "scr_assert_message_clear",
|
||||
"assert.looseResult": "scr_assert_loose_result",
|
||||
"assert.deepResult": "scr_assert_deep_result",
|
||||
"assert.looseDeepResult": "scr_assert_loose_deep_result",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { F64, isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
|
||||
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
|
||||
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
|
||||
import type { CallLifetimes } from "./call-lifetimes.js";
|
||||
import type { ReferenceEffects } from "./reference-effects.js";
|
||||
import type { OptionalArrayReads } from "./local-array-reads.js";
|
||||
@@ -57,6 +58,8 @@ export function dynHoleRead(value: IrExpr): (IrExpr & { kind: "arrayGet" }) | nu
|
||||
return same(cond.left.arr, then.arr) && same(cond.left.index, then.index) ? then : null;
|
||||
}
|
||||
|
||||
const LOOP_KINDS: readonly IrNodeKind[] = ["forOf", "for", "while", "doWhile"];
|
||||
|
||||
/** Limit a borrow to a loop whose complete iteration preserves heap edges.
|
||||
* Initialization and unrelated work elsewhere in the function may mutate.
|
||||
* The array remains owned by its unchanged local or global binding; the
|
||||
@@ -72,6 +75,8 @@ export function findLoopArrayBorrows(
|
||||
optionalReads?: OptionalArrayReads,
|
||||
writes?: ReferenceWrites | null,
|
||||
functions?: ReadonlyMap<string, IrFunction>,
|
||||
/** The module's node index, read instead of walking for the loops. */
|
||||
nodes?: IrNodeIndex,
|
||||
): ReadonlySet<IrStmt> {
|
||||
const result = new Set<IrStmt>();
|
||||
if (fn.async || fn.generator) return result;
|
||||
@@ -88,9 +93,10 @@ export function findLoopArrayBorrows(
|
||||
(!local.boxed && !local.tdz && (bindings?.has(local.id) || parameters.has(local.id)))
|
||||
);
|
||||
};
|
||||
everyStmtList(fn.body, {
|
||||
expr: () => true,
|
||||
stmt: (loop) => {
|
||||
eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => {
|
||||
if (!statement) return true;
|
||||
const loop = node as IrStmt;
|
||||
{
|
||||
if (loop.kind === "forOf") {
|
||||
const local = locals.get(loop.localId);
|
||||
if (
|
||||
@@ -187,7 +193,7 @@ export function findLoopArrayBorrows(
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -137,12 +137,20 @@ test("a read followed by a write of the same map pairs, and the write reuses the
|
||||
}
|
||||
});
|
||||
|
||||
test("number keys keep the write-after-read lookup instead of pairing", () => {
|
||||
// findReadNumberMapWrites stores such writes into the found entry through
|
||||
// a read-only probe that LLVM merges with the read's.
|
||||
const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(F64))], F64, F64);
|
||||
expect(pairs(mod)).toBe(0);
|
||||
expect(body(mod)).not.toContain("mapfuse.hit");
|
||||
test("number keys pair, and the write compares the key's bits with the read's", () => {
|
||||
for (const value of [F64, BOOL]) {
|
||||
const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(value))], F64, value);
|
||||
expect(pairs(mod)).toBe(1);
|
||||
for (const bits of [32, 64] as const) {
|
||||
const ir = body(mod, bits);
|
||||
expect(ir).toContain("mapfuse.hit");
|
||||
expect(ir).toContain("alloca double ; fused map read: key");
|
||||
expect(ir).toMatch(/icmp eq i64 %t\d+, %t\d+/);
|
||||
// The miss still inserts through the runtime.
|
||||
expect(ir).toMatch(/call void @scr_map_set_f64_(?:f64|bool)\(/);
|
||||
expect(ir).toContain("@sc_map_entry_f64(");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the single-statement form pairs an unconditional nested read", () => {
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* key match by identity implies the SameValueZero match the set would find.
|
||||
*
|
||||
* Only scalar values (number, boolean) fuse: overwriting them releases
|
||||
* nothing, exactly like the runtime's own overwrite. Only string-keyed
|
||||
* typed maps fuse here: a number-key write after a read of the same key
|
||||
* already probes through the read-only entry lookup that LLVM merges with
|
||||
* the read's (findReadNumberMapWrites, map-read-lifetimes.ts).
|
||||
* nothing, exactly like the runtime's own overwrite. String-keyed and
|
||||
* number-keyed typed maps fuse. A number key matches when its bits are the
|
||||
* read's: such keys are SameValueZero-equal, and a key that is equal with
|
||||
* other bits (-0 for 0) takes the ordinary set.
|
||||
*/
|
||||
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
|
||||
@@ -30,7 +30,7 @@ type MapOp = Extract<IrExpr, { kind: "mapIntrinsic" }>;
|
||||
/** One fused read/write pair. The slots exist once the read emits through
|
||||
* an entry path; a write whose read took another path stays ordinary. */
|
||||
export interface MapEntryPair {
|
||||
slots: { map: string; key: string; entry: string } | null;
|
||||
slots: { map: string; key: string; entry: string; keyType: "ptr" | "double" } | null;
|
||||
}
|
||||
|
||||
export interface MapEntryFusion {
|
||||
@@ -38,13 +38,14 @@ export interface MapEntryFusion {
|
||||
writes: Map<IrExpr, MapEntryPair>;
|
||||
}
|
||||
|
||||
/** A typed string-keyed Map read/write with a scalar value. */
|
||||
/** A typed string- or number-keyed Map read/write with a scalar value. */
|
||||
function fusableOp(e: IrExpr, method: "get" | "set"): e is MapOp {
|
||||
if (e.kind !== "mapIntrinsic" || e.method !== method) return false;
|
||||
const receiver = e.receiver.type;
|
||||
if (receiver.kind !== "map") return false;
|
||||
if (receiver.kind !== "map" || receiver.key.kind === "union") return false;
|
||||
if (receiver.value.kind !== "f64" && receiver.value.kind !== "bool") return false;
|
||||
if (mapKeyAccess(receiver.key) !== "str") return false;
|
||||
const access = mapKeyAccess(receiver.key);
|
||||
if (access !== "str" && access !== "f64") return false;
|
||||
return method === "get" ? borrowsMapReadInputs(e) : borrowsMapMutationReceiver(e);
|
||||
}
|
||||
|
||||
@@ -219,23 +220,25 @@ export function findMapEntryFusions(fn: IrFunction): MapEntryFusion {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** After a fused read resolved `entry` for the string `key` in `map`: park
|
||||
* all three for the paired write. */
|
||||
/** After a fused read resolved `entry` for `key` in `map`: park all three
|
||||
* for the paired write. `keyType` is the key's LLVM type: a string's ptr or
|
||||
* a number's double. */
|
||||
export function rememberMapEntry(
|
||||
host: LlvmEmitterContext,
|
||||
read: IrExpr,
|
||||
map: string,
|
||||
key: string,
|
||||
entry: string,
|
||||
keyType: "ptr" | "double" = "ptr",
|
||||
): void {
|
||||
const pair = host.mapEntryFusion?.reads.get(read);
|
||||
if (!pair) return;
|
||||
const B = host.B;
|
||||
if (pair.slots === null) {
|
||||
const slots = { map: B.slot(), key: B.slot(), entry: B.slot() };
|
||||
const slots = { map: B.slot(), key: B.slot(), entry: B.slot(), keyType };
|
||||
B.entryAllocas.push(
|
||||
`${slots.map} = alloca ptr ; fused map read: map`,
|
||||
`${slots.key} = alloca ptr ; fused map read: key`,
|
||||
`${slots.key} = alloca ${keyType} ; fused map read: key`,
|
||||
`${slots.entry} = alloca ptr ; fused map read: entry`,
|
||||
`store ptr null, ptr ${slots.entry}`,
|
||||
);
|
||||
@@ -243,7 +246,7 @@ export function rememberMapEntry(
|
||||
}
|
||||
const slots = pair.slots;
|
||||
B.line(`store ptr ${map}, ptr ${slots.map}`);
|
||||
B.line(`store ptr ${key}, ptr ${slots.key}`);
|
||||
B.line(`store ${slots.keyType} ${key}, ptr ${slots.key}`);
|
||||
B.line(`store ptr ${entry}, ptr ${slots.entry}`);
|
||||
}
|
||||
|
||||
@@ -270,13 +273,20 @@ export function emitFusedMapSet(
|
||||
both = B.tmp(),
|
||||
reuse = B.tmp();
|
||||
B.line(`${parkedMap} = load ptr, ptr ${slots.map}`);
|
||||
B.line(`${parkedKey} = load ptr, ptr ${slots.key}`);
|
||||
B.line(`${parkedKey} = load ${slots.keyType}, ptr ${slots.key}`);
|
||||
B.line(`${entry} = load ptr, ptr ${slots.entry}`);
|
||||
// The entry is valid only between its read and this write.
|
||||
B.line(`store ptr null, ptr ${slots.entry}`);
|
||||
B.line(`${found} = icmp ne ptr ${entry}, null`);
|
||||
B.line(`${sameMap} = icmp eq ptr ${parkedMap}, ${map}`);
|
||||
B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`);
|
||||
if (slots.keyType === "ptr") B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`);
|
||||
else {
|
||||
const parkedBits = B.tmp(),
|
||||
keyBits = B.tmp();
|
||||
B.line(`${parkedBits} = bitcast double ${parkedKey} to i64`);
|
||||
B.line(`${keyBits} = bitcast double ${key} to i64`);
|
||||
B.line(`${sameKey} = icmp eq i64 ${parkedBits}, ${keyBits}`);
|
||||
}
|
||||
B.line(`${both} = and i1 ${sameMap}, ${sameKey}`);
|
||||
B.line(`${reuse} = select i1 ${found}, i1 ${both}, i1 false`);
|
||||
const hit = B.newLabel("mapfuse.hit"),
|
||||
|
||||
@@ -314,6 +314,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
|
||||
// Booleans are stored as 0/1, already the widened union payload.
|
||||
const scalar = emitNumberMapGetScalar(host, receiver.name, number);
|
||||
found = scalar.found;
|
||||
rememberMapEntry(host, read.expr, receiver.name, number, scalar.entry, "double");
|
||||
B.line(`store i64 ${scalar.bits}, ptr ${payload}`);
|
||||
}
|
||||
} else if (string !== null && !isRefCounted(read.value)) {
|
||||
|
||||
@@ -75,6 +75,19 @@ export function emitAsciiCharCode(host: LlvmEmitterContext, s: string, index: st
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Unit `index` (an i64, below their count) of a string's UTF-16 `units`
|
||||
* (ScrStrScan): one 16-bit load, as a double. */
|
||||
export function emitUnitCharCode(host: LlvmEmitterContext, units: string, index: string): string {
|
||||
const B = host.B;
|
||||
const pointer = B.tmp(),
|
||||
unit = B.tmp(),
|
||||
code = B.tmp();
|
||||
B.line(`${pointer} = getelementptr inbounds i16, ptr ${units}, i64 ${index}`);
|
||||
B.line(`${unit} = load i16, ptr ${pointer}`);
|
||||
B.line(`${code} = uitofp i16 ${unit} to double`);
|
||||
return code;
|
||||
}
|
||||
|
||||
/** The one-character string of unit `index` (an i64) of a proven-ASCII
|
||||
* string `s` whose length covers it: the immortal `@sc_ascii_chars` entry
|
||||
* for its byte, as the inline charAt helpers answer. */
|
||||
@@ -114,6 +127,15 @@ function loadLength(sz: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether emitted reads use the runtime's scan receiver: from ordinary
|
||||
* global storage, or, in worker executables, from the thread's static TLS
|
||||
* block (executableTls). */
|
||||
export function stringScanInline(host: LlvmEmitterContext): boolean {
|
||||
return (
|
||||
host.executableTls || (host.mod.workers !== true && host.mod.lib?.threadInstances !== true)
|
||||
);
|
||||
}
|
||||
|
||||
/** The runtime's ScrStrScan record and the declaration naming it. */
|
||||
export function stringScanType(sz: string): string {
|
||||
return `{ ptr, ptr, ${sz} }`;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule } from "../../index.js";
|
||||
import type { IrModule, IrStmt } from "../../ir/ir.js";
|
||||
import { unitSafeFunctions, unitSafeLoop } from "./string-unit-versions.js";
|
||||
|
||||
async function lower(source: string): Promise<IrModule> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-versions-"));
|
||||
try {
|
||||
const entry = join(dir, "main.ts");
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
await writeFile(entry, source);
|
||||
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
return deserializeModule(await readFile(outPath, "utf8"));
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const loopOf = (mod: IrModule, name: string): IrStmt & { kind: "for" } =>
|
||||
mod.functions.find((f) => f.name === name)!.body.find((s) => s.kind === "for") as IrStmt & {
|
||||
kind: "for";
|
||||
};
|
||||
|
||||
test("loops over a string's units call only functions that read no string", async () => {
|
||||
const mod = await lower(`
|
||||
function lower(c: number): boolean {
|
||||
return c >= 97 && c <= 122;
|
||||
}
|
||||
function word(c: number): boolean {
|
||||
return lower(c) || (c >= 48 && c <= 57) || Math.abs(c - 200) < 50;
|
||||
}
|
||||
function codeOf(t: string, i: number): number {
|
||||
return t.charCodeAt(i);
|
||||
}
|
||||
function viaCodeOf(t: string): number {
|
||||
return codeOf(t, 0);
|
||||
}
|
||||
function words(s: string): number {
|
||||
let n = 0;
|
||||
const starts = [0];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (word(s.charCodeAt(i))) n++;
|
||||
else starts.push(i);
|
||||
}
|
||||
return n + starts.length;
|
||||
}
|
||||
function pairs(s: string, t: string): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + codeOf(t, i);
|
||||
return n;
|
||||
}
|
||||
function both(s: string, t: string): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + t.charCodeAt(i);
|
||||
return n;
|
||||
}
|
||||
function concatenates(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) out += "-";
|
||||
return out;
|
||||
}
|
||||
console.log(words("ab c"), pairs("ab", "cd"), both("ab", "cd"), concatenates("a b"), viaCodeOf("x"));
|
||||
`);
|
||||
const safe = unitSafeFunctions(mod);
|
||||
expect(safe.has("lower")).toBe(true);
|
||||
expect(safe.has("word")).toBe(true);
|
||||
// A string read, directly or through a call, makes a function unsafe.
|
||||
expect(safe.has("codeOf")).toBe(false);
|
||||
expect(safe.has("viaCodeOf")).toBe(false);
|
||||
|
||||
const param = (fn: string, index = 0): string =>
|
||||
mod.functions.find((f) => f.name === fn)!.params[index]!.localId;
|
||||
// A unit-safe loop answers its receiver's length reads: here the
|
||||
// condition's `s.length`.
|
||||
const lengths = unitSafeLoop(loopOf(mod, "words"), param("words"), safe);
|
||||
expect(lengths?.size).toBe(1);
|
||||
expect([...lengths!].every((e) => e.kind === "strIntrinsic" && e.method === "length")).toBe(true);
|
||||
expect(unitSafeLoop(loopOf(mod, "pairs"), param("pairs"), safe)).toBeNull();
|
||||
expect(unitSafeLoop(loopOf(mod, "both"), param("both"), safe)).toBeNull();
|
||||
expect(unitSafeLoop(loopOf(mod, "concatenates"), param("concatenates"), safe)).toBeNull();
|
||||
});
|
||||
|
||||
test("loops that sort with a comparator or copy an untyped source get no units copy", async () => {
|
||||
// Either can run program code that indexes other strings, which evicts
|
||||
// the receiver's units while the loop reads them.
|
||||
const mod = await lower(`
|
||||
function sorts(s: string, values: Float64Array, compare: (a: number, b: number) => number): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
n += s.charCodeAt(i);
|
||||
if (i === 3) values.sort(compare);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function copies(s: string, values: Float64Array, source: any): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
n += s.charCodeAt(i);
|
||||
if (i === 3) values.set(source);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function sortsNumbers(s: string, values: Float64Array): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
n += s.charCodeAt(i);
|
||||
if (i === 3) values.sort();
|
||||
}
|
||||
return n;
|
||||
}
|
||||
const values = new Float64Array([2, 1]);
|
||||
console.log(sorts("abcdef", values, (a, b) => a - b), copies("abcdef", values, [4, 3]), sortsNumbers("abcdef", values));
|
||||
`);
|
||||
const safe = unitSafeFunctions(mod);
|
||||
const param = (fn: string): string =>
|
||||
mod.functions.find((f) => f.name === fn)!.params[0]!.localId;
|
||||
expect(unitSafeLoop(loopOf(mod, "sorts"), param("sorts"), safe)).toBeNull();
|
||||
expect(unitSafeLoop(loopOf(mod, "copies"), param("copies"), safe)).toBeNull();
|
||||
// A numeric sort calls no program code.
|
||||
expect(unitSafeLoop(loopOf(mod, "sortsNumbers"), param("sortsNumbers"), safe)).not.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { IrBytesIntrinsicMethod, IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
|
||||
|
||||
/* Loop versions over the UTF-16 units of a string that is not ASCII.
|
||||
*
|
||||
* The runtime publishes the receiver of an indexed scan with its UTF-16
|
||||
* units (ScrStrScan, string-reads.ts), and drops them when the string is
|
||||
* released or appended to in place, or when its index entry is evicted:
|
||||
* the runtime indexes another non-ASCII string. A loop version's string
|
||||
* receiver is a binding the loop does not write, which keeps it alive and
|
||||
* unappended for the whole loop. Its fast copy over units therefore only
|
||||
* needs a body that indexes no other string: the operations below, and
|
||||
* calls of functions made of the same operations without string reads
|
||||
* (`unitSafeFunctions`). */
|
||||
|
||||
const ARRAY_METHODS = new Set(["push", "pop", "length"]);
|
||||
|
||||
/* The typed-array and Buffer methods a units copy may run: those that
|
||||
* call no program code and read no checked-dynamic value or string. A
|
||||
* comparator sort (sortCompare) calls back into the program, and a copy
|
||||
* from a checked-dynamic source (setFromDyn) reads its elements through
|
||||
* getters; either can index another string, which evicts the receiver's
|
||||
* units while the loop reads them. */
|
||||
const UNIT_SAFE_BYTES_METHODS: ReadonlySet<IrBytesIntrinsicMethod> = new Set([
|
||||
"buffer",
|
||||
"length",
|
||||
"byteLength",
|
||||
"byteOffset",
|
||||
"get",
|
||||
"slice",
|
||||
"subarray",
|
||||
"copyWithin",
|
||||
"sortNumeric",
|
||||
"toReversed",
|
||||
"with",
|
||||
"toArray",
|
||||
"setFrom",
|
||||
"readNum",
|
||||
"writeNum",
|
||||
"readNumVar",
|
||||
"writeNumVar",
|
||||
"equals",
|
||||
"compareBuf",
|
||||
"indexOfNum",
|
||||
"lastIndexOfNum",
|
||||
"includesNum",
|
||||
"indexOfElem",
|
||||
"lastIndexOfElem",
|
||||
"includesElem",
|
||||
"fill",
|
||||
"fillNum",
|
||||
"fillElem",
|
||||
"copy",
|
||||
"swap16",
|
||||
"swap32",
|
||||
"swap64",
|
||||
"dataViewNew",
|
||||
"dvGetUint8",
|
||||
"dvGetInt8",
|
||||
"dvGetUint16",
|
||||
"dvGetInt16",
|
||||
"dvGetUint32",
|
||||
"dvGetInt32",
|
||||
"dvGetFloat16",
|
||||
"dvGetFloat32",
|
||||
"dvGetFloat64",
|
||||
"dvGetBigUint64Number",
|
||||
"dvGetBigInt64Number",
|
||||
"dvSetUint8",
|
||||
"dvSetInt8",
|
||||
"dvSetUint16",
|
||||
"dvSetInt16",
|
||||
"dvSetUint32",
|
||||
"dvSetInt32",
|
||||
"dvSetFloat16",
|
||||
"dvSetFloat32",
|
||||
"dvSetFloat64",
|
||||
]);
|
||||
|
||||
/** Whether `e` (or statement `s`) uses only operations that index no
|
||||
* string: numbers, booleans, locals, records and class fields, typed
|
||||
* arrays, number arrays and Math. `reads` admits the string reads a
|
||||
* version covers; `calls` the callees. */
|
||||
function unitSafe(
|
||||
node: { e: IrExpr } | { s: IrStmt },
|
||||
reads: (e: IrExpr & { kind: "strIntrinsic" }) => boolean,
|
||||
calls: (callee: string) => boolean,
|
||||
): boolean {
|
||||
const expr = (e: IrExpr): boolean => {
|
||||
switch (e.kind) {
|
||||
case "varRef":
|
||||
case "numLit":
|
||||
case "boolLit":
|
||||
case "bin":
|
||||
case "unary":
|
||||
case "logical":
|
||||
case "ternary":
|
||||
case "toBool":
|
||||
case "seqExpr":
|
||||
case "assignExpr":
|
||||
case "incDec":
|
||||
case "recordGet":
|
||||
case "fieldGet":
|
||||
break;
|
||||
case "bytesIntrinsic":
|
||||
if (!UNIT_SAFE_BYTES_METHODS.has(e.method)) return false;
|
||||
break;
|
||||
case "arrayGet":
|
||||
if (e.type.kind !== "f64" && e.type.kind !== "bool") return false;
|
||||
break;
|
||||
case "arrIntrinsic": {
|
||||
// Growing or shrinking an array of numbers allocates; it reads no
|
||||
// string.
|
||||
const array = e.receiver.type;
|
||||
if (
|
||||
!ARRAY_METHODS.has(e.method) ||
|
||||
array.kind !== "array" ||
|
||||
(array.elem.kind !== "f64" && array.elem.kind !== "bool")
|
||||
)
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
case "libCall":
|
||||
if (!e.fn.startsWith("math.")) return false;
|
||||
break;
|
||||
case "call":
|
||||
if (!calls(e.callee)) return false;
|
||||
break;
|
||||
case "strIntrinsic":
|
||||
if (!reads(e)) return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return everyExprChild(e, expr, stmt);
|
||||
};
|
||||
const stmt = (s: IrStmt): boolean => {
|
||||
switch (s.kind) {
|
||||
case "varDecl":
|
||||
case "assign":
|
||||
case "exprStmt":
|
||||
case "if":
|
||||
case "block":
|
||||
case "while":
|
||||
case "doWhile":
|
||||
case "for":
|
||||
case "break":
|
||||
case "continue":
|
||||
case "return":
|
||||
case "recordSet":
|
||||
case "fieldSet":
|
||||
case "bytesSet":
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return everyStmtChild(s, expr, stmt);
|
||||
};
|
||||
return "e" in node ? expr(node.e) : stmt(node.s);
|
||||
}
|
||||
|
||||
/** The module's functions a units version may call: synchronous
|
||||
* functions without captures whose bodies use only unit-safe operations,
|
||||
* read no string, and call only such functions. */
|
||||
export function unitSafeFunctions(mod: IrModule): ReadonlySet<string> {
|
||||
const candidates = new Map(
|
||||
mod.functions
|
||||
.filter((fn) => !fn.async && !fn.generator && !(fn.captures?.length ?? 0))
|
||||
.map((fn) => [fn.name, fn]),
|
||||
);
|
||||
const safe = new Set(candidates.keys());
|
||||
for (let settled = false; !settled;) {
|
||||
settled = true;
|
||||
for (const name of safe) {
|
||||
const fn = candidates.get(name)!;
|
||||
const ok = fn.body.every((s) =>
|
||||
unitSafe(
|
||||
{ s },
|
||||
() => false,
|
||||
(callee) => safe.has(callee),
|
||||
),
|
||||
);
|
||||
if (!ok) {
|
||||
safe.delete(name);
|
||||
settled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/** The `length` reads of the binding `receiver` in a loop whose every
|
||||
* part is unit-safe, reading only charCodeAt and length of `receiver` and
|
||||
* calling only `safe` functions; null for any other loop. */
|
||||
export function unitSafeLoop(
|
||||
loop: IrStmt & { kind: "for" | "while" },
|
||||
receiver: string,
|
||||
safe: ReadonlySet<string>,
|
||||
): ReadonlySet<IrExpr> | null {
|
||||
const lengths = new Set<IrExpr>();
|
||||
const reads = (e: IrExpr & { kind: "strIntrinsic" }): boolean => {
|
||||
if (e.receiver.kind !== "varRef" || e.receiver.localId !== receiver) return false;
|
||||
if (e.method === "length") lengths.add(e);
|
||||
return e.method === "charCodeAt" || e.method === "length";
|
||||
};
|
||||
const calls = (callee: string): boolean => safe.has(callee);
|
||||
return unitSafe({ s: loop }, reads, calls) ? lengths : null;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule } from "../../index.js";
|
||||
import { validateModule } from "../../ir/validate.js";
|
||||
import { emitLlvmModule } from "./emitter.js";
|
||||
import { UnwindAbiViolation, bridgePlainUnwindCalls } from "./unwind-abi.js";
|
||||
|
||||
async function moduleFor(source: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-unwind-abi-"));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The definition of the source function `name` (its i32 or borrowing entry when it has one). */
|
||||
function body(ir: string, name: string): string {
|
||||
return new RegExp(
|
||||
`^define internal [^\\n]*@sc_b?f_[^\\n(]*${name}(?:\\.i32)?\\([^]*?^}`,
|
||||
"m",
|
||||
).exec(ir)![0];
|
||||
}
|
||||
|
||||
const SOURCE = `
|
||||
function leaf(n: number): number {
|
||||
if (n === 3) throw new RangeError("three");
|
||||
return n * 2;
|
||||
}
|
||||
function first(n: number): number {
|
||||
return leaf(n) + 1;
|
||||
}
|
||||
function second(n: number): number {
|
||||
return first(n) * 3;
|
||||
}
|
||||
function third(n: number): number {
|
||||
return second(n) - 2;
|
||||
}
|
||||
function mid(n: number): number {
|
||||
const parts: string[] = ["a" + n];
|
||||
const r = third(n);
|
||||
parts.push("b");
|
||||
return r + parts.length;
|
||||
}
|
||||
class Counter {
|
||||
total = 0;
|
||||
add(n: number): void { this.total += mid(n); }
|
||||
}
|
||||
function outer(n: number): string {
|
||||
try { return "ok " + mid(n); } catch (e) { return "caught " + (e as Error).message; }
|
||||
}
|
||||
const counter = new Counter();
|
||||
for (let i = 0; i < 5; i++) { console.log(outer(i)); try { counter.add(i); } catch { console.log("add", i); } }
|
||||
const f = (x: number) => mid(x);
|
||||
console.log(f(1), counter.total);
|
||||
`;
|
||||
|
||||
const LINUX = { targetTriple: "x86_64-unknown-linux-gnu", unwindAbi: true } as const;
|
||||
|
||||
test("directly called functions raise, and their callers invoke them without a pending check", async () => {
|
||||
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
|
||||
// leaf throws: the exception starts on the pending flag, and the three
|
||||
// calls above it test the flag too; mid raises it on.
|
||||
for (const name of ["leaf", "first", "second", "third"])
|
||||
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
|
||||
const mid = body(ir, "mid");
|
||||
expect(mid).toMatch(/= call double @sc_b?f_[^(]*third\(/);
|
||||
expect(mid).toContain("call void @scr_unwind_raise()");
|
||||
// outer invokes mid; the landing pad takes the exception to its handler.
|
||||
const outer = body(ir, "outer");
|
||||
expect(outer).toMatch(
|
||||
/= invoke double @sc_b?f_[^(]*mid[^\n]* to label %inv\.ok\d+ unwind label %inv\.lp\d+/,
|
||||
);
|
||||
expect(outer).toMatch(/inv\.lp\d+:\n\s+%t\d+ = landingpad \{ ptr, i32 \} cleanup/);
|
||||
expect(outer).toContain("personality ptr @__gcc_personality_v0");
|
||||
expect(ir).toContain("declare i32 @__gcc_personality_v0(...)");
|
||||
expect(ir).toContain("declare void @scr_unwind_raise() cold");
|
||||
});
|
||||
|
||||
test("a method of a class outside any hierarchy is on the unwinding ABI; closures keep the flag", async () => {
|
||||
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
|
||||
// Counter.add invokes mid and resumes the unwind from its landing pad; the
|
||||
// closure over mid is a pointer target and returns with the flag.
|
||||
const add = body(ir, "Counter_add");
|
||||
expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/);
|
||||
expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/);
|
||||
expect(add).not.toContain("@scr_unwind_raise");
|
||||
const closures = ir
|
||||
.split(/\n(?=define )/)
|
||||
.filter((def) => /^define internal [^\n]*@sc_b?f_[^\n(]*fn_/.test(def));
|
||||
expect(closures.length).toBeGreaterThan(0);
|
||||
for (const def of closures) expect(def).not.toContain("call void @scr_unwind_raise()");
|
||||
});
|
||||
|
||||
test("recursive functions keep the pending flag, also when they recurse through a callback", async () => {
|
||||
const source = `
|
||||
function leaf(n: number): number {
|
||||
if (n < 0) throw new RangeError("negative");
|
||||
return n;
|
||||
}
|
||||
function one(n: number): number { return leaf(n) + 1; }
|
||||
function two(n: number): number { return one(n) + 1; }
|
||||
function three(n: number): number { return two(n) + 1; }
|
||||
function four(n: number): number {
|
||||
const parts: string[] = ["a" + n];
|
||||
const r = three(n);
|
||||
return r + parts.length;
|
||||
}
|
||||
function walk(n: number, depth: number): number {
|
||||
const parts: string[] = ["w" + n];
|
||||
const r = depth === 0 ? four(n) : walk(n, depth - 1);
|
||||
return r + parts.length;
|
||||
}
|
||||
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
|
||||
const parts: string[] = ["v" + n];
|
||||
const r = depth === 0 ? four(n) : each(n, depth - 1);
|
||||
return r + parts.length;
|
||||
}
|
||||
const each = (n: number, depth: number): number => visit(n, depth, each);
|
||||
for (let i = -1; i < 2; i++) {
|
||||
try { console.log(walk(i, 3), visit(i, 3, each)); } catch (e) { console.log((e as Error).message); }
|
||||
}
|
||||
`;
|
||||
const ir = emitLlvmModule(await moduleFor(source), LINUX);
|
||||
expect(body(ir, "four")).toContain("call void @scr_unwind_raise()");
|
||||
for (const name of ["walk", "visit"]) {
|
||||
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
|
||||
expect(body(ir, name)).toMatch(/invoke double @sc_b?f_[^(]*four/);
|
||||
}
|
||||
});
|
||||
|
||||
test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => {
|
||||
const mod = await moduleFor(SOURCE);
|
||||
for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) {
|
||||
const ir = emitLlvmModule(mod, options);
|
||||
expect(ir).not.toContain("invoke ");
|
||||
expect(ir).not.toContain("@scr_unwind_raise");
|
||||
expect(ir).not.toContain("personality");
|
||||
}
|
||||
});
|
||||
|
||||
test("bridgePlainUnwindCalls keeps the pending-flag contract at plain calls of raising functions", () => {
|
||||
const raising = [
|
||||
"define internal ptr @sc_bf_raises(ptr %p) #0 { ; raises",
|
||||
"entry:",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"}",
|
||||
].join("\n");
|
||||
const caller = [
|
||||
"define internal ptr @sc_f_adapter(ptr %p0) #0 {",
|
||||
"entry:",
|
||||
" %result = call ptr @sc_bf_raises(ptr %p0) ; borrowed result",
|
||||
" br label %next",
|
||||
"next:",
|
||||
" %v = phi ptr [ %result, %entry ]",
|
||||
" ret ptr %v",
|
||||
"}",
|
||||
].join("\n");
|
||||
const [out] = bridgePlainUnwindCalls([`${raising}\n${caller}`]);
|
||||
expect(out).toContain(
|
||||
"%result.ub = invoke ptr @sc_bf_raises(ptr %p0) to label %ub.n.0 unwind label %ub.lp.0",
|
||||
);
|
||||
expect(out).toContain("%result = phi ptr [ %result.ub, %ub.n.0 ], [ null, %ub.lp.0 ]");
|
||||
// The split block's successor now comes from its last part.
|
||||
expect(out).toContain("%v = phi ptr [ %result, %ub.ok.0 ]");
|
||||
expect(out).toMatch(
|
||||
/define internal ptr @sc_f_adapter\(ptr %p0\) #0 personality ptr @__gcc_personality_v0 \{/,
|
||||
);
|
||||
expect(out).toContain("declare i32 @__gcc_personality_v0(...)");
|
||||
});
|
||||
|
||||
test("an invoked wrapper of a raising function resumes the unwind; its address must not escape", () => {
|
||||
const raising = [
|
||||
"define internal double @sc_bf_body.i32(i32 %a) #0 {",
|
||||
"entry:",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"}",
|
||||
].join("\n");
|
||||
const wrapper = [
|
||||
"define internal double @sc_bf_body(double %a) #0 {",
|
||||
"entry:",
|
||||
" %i = fptosi double %a to i32",
|
||||
" %r = call double @sc_bf_body.i32(i32 %i)",
|
||||
" ret double %r",
|
||||
"}",
|
||||
].join("\n");
|
||||
const caller = [
|
||||
"define internal double @sc_bf_caller(double %a) #0 personality ptr @__gcc_personality_v0 {",
|
||||
"entry:",
|
||||
" %r = invoke double @sc_bf_body(double %a) to label %ok unwind label %lp",
|
||||
"lp:",
|
||||
" %x = landingpad { ptr, i32 } cleanup",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"ok:",
|
||||
" ret double %r",
|
||||
"}",
|
||||
].join("\n");
|
||||
const [out] = bridgePlainUnwindCalls([[raising, wrapper, caller].join("\n")]);
|
||||
expect(out).toContain(
|
||||
"%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0",
|
||||
);
|
||||
expect(out).toContain("resume { ptr, i32 } %ub.v.0");
|
||||
expect(() =>
|
||||
bridgePlainUnwindCalls([
|
||||
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
|
||||
]),
|
||||
).toThrow(UnwindAbiViolation);
|
||||
try {
|
||||
bridgePlainUnwindCalls([
|
||||
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
|
||||
]);
|
||||
} catch (error) {
|
||||
expect([...(error as UnwindAbiViolation).symbols]).toEqual(["sc_bf_body.i32"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("an invoked function that never tests the pending flag needs no raise", () => {
|
||||
const raising = [
|
||||
"define internal void @sc_bf_raises() #0 {",
|
||||
"entry:",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"}",
|
||||
].join("\n");
|
||||
const leaf = [
|
||||
"define internal double @sc_bf_leaf() #0 {",
|
||||
"entry:",
|
||||
" ret double 1.0",
|
||||
"}",
|
||||
].join("\n");
|
||||
const caller = (callee: string) =>
|
||||
[
|
||||
"define internal double @sc_bf_caller() #0 personality ptr @__gcc_personality_v0 {",
|
||||
"entry:",
|
||||
` %r = invoke double @${callee}() to label %ok unwind label %lp`,
|
||||
"lp:",
|
||||
" %x = landingpad { ptr, i32 } cleanup",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"ok:",
|
||||
" ret double %r",
|
||||
"}",
|
||||
].join("\n");
|
||||
expect(() =>
|
||||
bridgePlainUnwindCalls([[raising, leaf, caller("sc_bf_leaf")].join("\n")]),
|
||||
).not.toThrow();
|
||||
const testing = leaf.replace(
|
||||
" ret double 1.0",
|
||||
" %f = load i32, ptr @scr_exc_flag\n ret double 1.0",
|
||||
);
|
||||
expect(() =>
|
||||
bridgePlainUnwindCalls([[raising, testing, caller("sc_bf_leaf")].join("\n")]),
|
||||
).toThrow(UnwindAbiViolation);
|
||||
});
|
||||
|
||||
test("calls that cannot unwind are marked nounwind", () => {
|
||||
const raising = [
|
||||
"define internal void @sc_bf_raises(ptr %b) #0 {",
|
||||
"entry:",
|
||||
" %n = call i64 @scr_bytes_length(ptr %b) ; length",
|
||||
" call void @scr_bytes_release(ptr %b), !dbg !4",
|
||||
" %r = call ptr %b(ptr %b)",
|
||||
" call void @scr_unwind_raise()",
|
||||
" unreachable",
|
||||
"}",
|
||||
].join("\n");
|
||||
const [out] = bridgePlainUnwindCalls([raising]);
|
||||
expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length");
|
||||
expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4");
|
||||
expect(out).toContain("%r = call ptr %b(ptr %b) nounwind");
|
||||
expect(out).toContain(" call void @scr_unwind_raise()\n");
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
/* The functions that propagate exceptions by unwinding instead of the
|
||||
* pending flag (see unwindAbiFunctions). */
|
||||
import type { IrExpr, IrModule } from "../../ir/ir.js";
|
||||
import { everyStmtList } from "../../ir/traverse.js";
|
||||
|
||||
/** How many calls above a `throw` functions keep the pending flag. */
|
||||
const THROW_LEVELS = 3;
|
||||
/** The call graph's node for calls through values. */
|
||||
const ANY_FUNCTION = " any function";
|
||||
|
||||
/** The nodes of `edges` on a cycle (a recursion), by Tarjan's algorithm
|
||||
* without recursion of its own: a large module's call chains are deeper
|
||||
* than the host stack. */
|
||||
function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<string> {
|
||||
const index = new Map<string, number>();
|
||||
const low = new Map<string, number>();
|
||||
const stack: string[] = [];
|
||||
const onStack = new Set<string>();
|
||||
const result = new Set<string>();
|
||||
let counter = 0;
|
||||
const enter = (name: string): { name: string; next: number } => {
|
||||
index.set(name, counter);
|
||||
low.set(name, counter++);
|
||||
stack.push(name);
|
||||
onStack.add(name);
|
||||
return { name, next: 0 };
|
||||
};
|
||||
for (const root of edges.keys()) {
|
||||
if (index.has(root)) continue;
|
||||
const work = [enter(root)];
|
||||
while (work.length > 0) {
|
||||
const top = work[work.length - 1]!;
|
||||
const out = edges.get(top.name) ?? [];
|
||||
if (top.next < out.length) {
|
||||
const callee = out[top.next++]!;
|
||||
if (!index.has(callee)) work.push(enter(callee));
|
||||
else if (onStack.has(callee))
|
||||
low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!));
|
||||
continue;
|
||||
}
|
||||
work.pop();
|
||||
const parent = work[work.length - 1];
|
||||
if (parent !== undefined)
|
||||
low.set(parent.name, Math.min(low.get(parent.name)!, low.get(top.name)!));
|
||||
if (low.get(top.name) !== index.get(top.name)) continue;
|
||||
const members: string[] = [];
|
||||
let member: string;
|
||||
do {
|
||||
member = stack.pop()!;
|
||||
onStack.delete(member);
|
||||
members.push(member);
|
||||
} while (member !== top.name);
|
||||
if (members.length > 1 || out.includes(top.name)) for (const m of members) result.add(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The may-throw functions whose exceptions reach their callers by
|
||||
* unwinding the native stack (scr_unwind_raise) instead of returning with
|
||||
* the pending flag set. Their callers call them with `invoke`, so the call
|
||||
* needs no flag test; the landing pad runs the frame's unwind.
|
||||
*
|
||||
* A function qualifies only when every way it can be reached is a direct
|
||||
* `call` from emitted code that can lower it as an `invoke`: it is not
|
||||
* async or a generator, not a constructor, not a method a virtual call can
|
||||
* reach or one a vtable holds (an overridden method), not a closure
|
||||
* target, not the entry or a library export, not named by any node other
|
||||
* than a `call`'s callee, and not called from an async or generator body
|
||||
* (coroutine bodies keep plain calls).
|
||||
*
|
||||
* A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a
|
||||
* few nanoseconds for a flag test, so the functions an exception is likely to
|
||||
* cross many of keep the flag: those with a `throw` or rethrow of their own
|
||||
* and those up to THROW_LEVELS calls above one (a handler near the throw
|
||||
* takes it without a native unwind), and the recursive ones (deep stacks
|
||||
* are recursions). */
|
||||
export function unwindAbiFunctions(
|
||||
mod: IrModule,
|
||||
mayThrow: ReadonlySet<string>,
|
||||
excluded: ReadonlySet<string> = new Set(),
|
||||
): Set<string> {
|
||||
const names = new Set(mod.functions.map((fn) => fn.name));
|
||||
const blocked = new Set<string>(excluded);
|
||||
blocked.add(mod.entry);
|
||||
// The runtime calls the error property readers through the pointers the
|
||||
// module installs (emit-module.ts).
|
||||
blocked.add("%error.name.read");
|
||||
blocked.add("%error.message.read");
|
||||
for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName);
|
||||
// Methods a virtual call can reach, and the overridden methods of class
|
||||
// hierarchies (whose vtables list them), are called through pointers; the
|
||||
// others are called directly, like functions. A virtual call reaches its listed
|
||||
// targets, or else the method in its receiver class's hierarchy; a
|
||||
// receiver that is no class of the module (an interface) may reach any
|
||||
// class's method of that name.
|
||||
const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls]));
|
||||
const anyClass = new Set<string>();
|
||||
for (const fn of mod.functions)
|
||||
everyStmtList(fn.body, {
|
||||
expr: (e: IrExpr) => {
|
||||
if (e.kind !== "virtualCall") return true;
|
||||
if (e.targets !== undefined) for (const target of e.targets) blocked.add(target);
|
||||
else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`);
|
||||
else anyClass.add(e.method);
|
||||
return true;
|
||||
},
|
||||
stmt: () => true,
|
||||
});
|
||||
// A hierarchy's vtables hold the methods declared by more than one of its
|
||||
// classes (an override and what it overrides).
|
||||
const rootOf = (name: string): string => {
|
||||
let cls = classes.get(name);
|
||||
while (cls?.base !== undefined && classes.has(cls.base)) cls = classes.get(cls.base);
|
||||
return cls?.name ?? name;
|
||||
};
|
||||
const declarers = new Map<string, number>();
|
||||
for (const cls of mod.classes ?? [])
|
||||
for (const m of cls.methods ?? []) {
|
||||
const key = `${rootOf(cls.name)}\0${m}`;
|
||||
declarers.set(key, (declarers.get(key) ?? 0) + 1);
|
||||
}
|
||||
for (const cls of mod.classes ?? []) {
|
||||
blocked.add(`%${cls.name}.constructor`);
|
||||
blocked.add(`%${cls.name}.construct`);
|
||||
const root = rootOf(cls.name);
|
||||
for (const m of cls.methods ?? [])
|
||||
if (anyClass.has(m) || declarers.get(`${root}\0${m}`)! > 1) blocked.add(`%${cls.name}.${m}`);
|
||||
}
|
||||
// Any function a node names other than as a call's callee may be reached
|
||||
// through a pointer.
|
||||
const referenced = new Set<string>();
|
||||
const noteNames = (node: object, skip: string | undefined): void => {
|
||||
for (const key in node) {
|
||||
if (key === skip) continue;
|
||||
const value = (node as Record<string, unknown>)[key];
|
||||
if (typeof value === "string" && names.has(value)) referenced.add(value);
|
||||
}
|
||||
};
|
||||
// The call graph, for the functions near a throw and the recursive ones.
|
||||
// Calls through values (closures, class values, dynamic calls, callbacks
|
||||
// passed to the runtime) reach ANY_FUNCTION, which reaches every function
|
||||
// a value can hold.
|
||||
const throws = new Set<string>();
|
||||
const callers = new Map<string, string[]>();
|
||||
const callees = new Map<string, string[]>();
|
||||
const edge = (from: string, to: string): void => {
|
||||
let list = callers.get(to);
|
||||
if (list === undefined) callers.set(to, (list = []));
|
||||
list.push(from);
|
||||
let out = callees.get(from);
|
||||
if (out === undefined) callees.set(from, (out = []));
|
||||
out.push(to);
|
||||
};
|
||||
const methodsNamed = new Map<string, string[]>();
|
||||
for (const cls of mod.classes ?? [])
|
||||
for (const m of cls.methods ?? []) {
|
||||
let list = methodsNamed.get(m);
|
||||
if (list === undefined) methodsNamed.set(m, (list = []));
|
||||
list.push(`%${cls.name}.${m}`);
|
||||
}
|
||||
for (const fn of mod.functions) {
|
||||
const coroutine = fn.async === true || fn.generator !== undefined;
|
||||
everyStmtList(fn.body, {
|
||||
expr: (e: IrExpr) => {
|
||||
const kind = e.kind as string;
|
||||
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
|
||||
if (e.kind === "call") {
|
||||
edge(fn.name, e.callee);
|
||||
// The entry's callees (the module initializers) run once: on the
|
||||
// flag they keep landing pads out of the top-level code inlined
|
||||
// into the entry.
|
||||
if (coroutine || fn.name === mod.entry) blocked.add(e.callee);
|
||||
noteNames(e, "callee");
|
||||
return true;
|
||||
}
|
||||
noteNames(e, undefined);
|
||||
if (e.kind === "virtualCall")
|
||||
for (const target of e.targets ?? methodsNamed.get(e.method) ?? []) edge(fn.name, target);
|
||||
else if (e.kind === "new") {
|
||||
edge(fn.name, `%${e.className}.constructor`);
|
||||
edge(fn.name, `%${e.className}.construct`);
|
||||
} else if (kind === "callValue" || kind === "dynCall" || kind === "newValue")
|
||||
edge(fn.name, ANY_FUNCTION);
|
||||
else if ("args" in e) {
|
||||
const args: unknown = e.args;
|
||||
if (Array.isArray(args))
|
||||
for (const arg of args as IrExpr[])
|
||||
if (arg.type.kind === "func") edge(fn.name, ANY_FUNCTION);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
stmt: (s) => {
|
||||
const kind = s.kind as string;
|
||||
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
|
||||
noteNames(s, undefined);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const name of referenced) {
|
||||
blocked.add(name);
|
||||
edge(ANY_FUNCTION, name);
|
||||
}
|
||||
// A throw starts on the pending flag, and so do the functions up to
|
||||
// THROW_LEVELS calls above it. The union narrowing helpers' throws (a
|
||||
// failed narrowing) don't count for their callers.
|
||||
for (const name of throws) blocked.add(name);
|
||||
let level = [...throws].filter((name) => !name.startsWith("%union."));
|
||||
const near = new Set(level);
|
||||
for (let i = 0; i < THROW_LEVELS; i++) {
|
||||
const next: string[] = [];
|
||||
for (const name of level)
|
||||
for (const caller of callers.get(name) ?? [])
|
||||
if (!near.has(caller)) {
|
||||
near.add(caller);
|
||||
next.push(caller);
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
for (const name of near) blocked.add(name);
|
||||
for (const name of recursiveFunctions(callees)) blocked.add(name);
|
||||
const result = new Set<string>();
|
||||
for (const fn of mod.functions) {
|
||||
if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue;
|
||||
if (fn.async === true || fn.generator !== undefined) continue;
|
||||
result.add(fn.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** What an unwinding-ABI function's unwind ends with when no handler of
|
||||
* its own takes the exception. */
|
||||
export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable";
|
||||
/** What the unwind of a landing pad ends with when no handler of the
|
||||
* function takes the exception: the unwind goes on (`resume`) with the
|
||||
* exception the landing pad stored in UNWIND_SLOT. The block builder
|
||||
* expands it (BlockBuilder.terminate). */
|
||||
export const UNWIND_RESUME = "resume { ptr, i32 } <unwind slot>";
|
||||
/** The function's slot for the exception its landing pads take. */
|
||||
export const UNWIND_SLOT = "%unw.exc";
|
||||
/** scr_unwind_raise's declaration. It is not `noreturn`: sanitized builds
|
||||
* would call the sanitizer's no-return hook before it, which fails on fiber
|
||||
* stacks; scr_unwind_raise clears the unwound frames' shadow itself. */
|
||||
export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold";
|
||||
|
||||
/** The personality of functions with landing pads: the landing pads are
|
||||
* cleanups that scr_unwind_raise's forced unwind runs. */
|
||||
export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0";
|
||||
|
||||
/** An unwind leaving the function: a raise, or a landing pad's `resume`. */
|
||||
const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /;
|
||||
/** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */
|
||||
const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/;
|
||||
|
||||
/** Functions emission found breaking the unwinding ABI's rules
|
||||
* (bridgePlainUnwindCalls), by symbol: the caller emits the module again
|
||||
* with them on the pending-flag ABI. */
|
||||
export class UnwindAbiViolation extends Error {
|
||||
readonly symbols: ReadonlySet<string>;
|
||||
constructor(symbols: ReadonlySet<string>) {
|
||||
super(`unwinding ABI: ${[...symbols].slice(0, 3).join(", ")} must keep the pending flag`);
|
||||
this.symbols = symbols;
|
||||
}
|
||||
}
|
||||
const DEFINE_NAME = /^define [^@\n]*@([\w.$%-]+)\(/;
|
||||
const PLAIN_CALL = /^(\s*)(%[\w.$-]+) = call (.*?)@([\w.$%-]+)\(|^(\s*)call (.*?)@([\w.$%-]+)\(/;
|
||||
const RETURN_ATTRIBUTES =
|
||||
/\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g;
|
||||
|
||||
/** A call instruction's line. */
|
||||
const CALL_LINE = /^\s*(?:%[\w.$-]+ = )?(?:(?:tail|musttail|notail) )?call /;
|
||||
/** A call's callee (a function or a register) and the parenthesis that
|
||||
* opens its arguments. */
|
||||
const CALLEE = /[@%][\w.$%-]+\(/;
|
||||
|
||||
/** The index of the parenthesis that closes the argument list opened at `open`. */
|
||||
function argumentsEnd(line: string, open: number): number {
|
||||
let depth = 0;
|
||||
let close = open;
|
||||
for (; close < line.length; close++) {
|
||||
const c = line.charAt(close);
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")" && --depth === 0) break;
|
||||
}
|
||||
return close;
|
||||
}
|
||||
|
||||
/** A call that cannot unwind, marked so: only scr_unwind_raise and the
|
||||
* raising functions propagate an unwind (runtime code and every function
|
||||
* reached through a pointer return normally), so every other call is
|
||||
* `nounwind`, and the inliner keeps it a call where it inlines a raising
|
||||
* function at an invoke. */
|
||||
function markNounwind(line: string): string {
|
||||
const call = line.indexOf("call ");
|
||||
const callee = CALLEE.exec(line.slice(call));
|
||||
if (callee === null || callee[0] === "@scr_unwind_raise(") return line;
|
||||
const close = argumentsEnd(line, call + callee.index + callee[0].length - 1);
|
||||
return close >= line.length
|
||||
? line
|
||||
: `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`;
|
||||
}
|
||||
|
||||
/** The value a bridged call yields on its landing pad: the dummy a
|
||||
* pending-flag callee returns with an exception pending. */
|
||||
function dummyValue(type: string): string {
|
||||
if (type === "ptr") return "null";
|
||||
if (type === "double" || type === "float") return "0.0";
|
||||
if (/^i\d+$/.test(type)) return "0";
|
||||
return "zeroinitializer";
|
||||
}
|
||||
|
||||
/** Rewrites every remaining plain call of an unwinding-ABI function (one
|
||||
* that raises, scr_unwind_raise, or resumes an unwind) in `parts`, the module's text, into an
|
||||
* invoke whose landing pad continues after the call with the exception
|
||||
* pending and the call's dummy result: the pending-flag contract those
|
||||
* call sites were emitted for, whichever lowering emitted them. Calls of
|
||||
* these functions that emitDirectCall lowered are invokes already. */
|
||||
export function bridgePlainUnwindCalls(parts: string[]): string[] {
|
||||
const raisers = new Set<string>();
|
||||
const split = parts.map((part) => part.split(/\n(?=define )/));
|
||||
for (const chunks of split)
|
||||
for (const chunk of chunks) {
|
||||
const m = DEFINE_NAME.exec(chunk);
|
||||
if (m !== null && UNWINDS.test(chunk)) raisers.add(m[1]!);
|
||||
}
|
||||
if (raisers.size === 0) return parts;
|
||||
// An invoke's callee must raise or never test the pending flag: one that
|
||||
// returned with the flag pending would pass the exception by its invoking
|
||||
// caller unseen. A wrapper an invoke reaches (an int32 or borrowed-ABI
|
||||
// entry forwarding to the body) propagates its callee's unwind instead
|
||||
// (`resume`), so it raises too. A raising function must be reached only by
|
||||
// calls: a pointer to it (a vtable or table entry, a runtime callback)
|
||||
// could be called without a landing pad. Functions that break either rule
|
||||
// go back to the pending-flag ABI (UnwindAbiViolation).
|
||||
const violations = new Set<string>();
|
||||
const invoked = new Set<string>();
|
||||
const testsFlag = new Set<string>();
|
||||
for (const chunks of split)
|
||||
for (const chunk of chunks) {
|
||||
const m = DEFINE_NAME.exec(chunk);
|
||||
if (m !== null && PENDING_TEST.test(chunk)) testsFlag.add(m[1]!);
|
||||
for (const c of chunk.matchAll(/ invoke [^@\n]*@([\w.$%-]+)\(/g)) invoked.add(c[1]!);
|
||||
}
|
||||
const resumers = new Set<string>();
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const chunks of split)
|
||||
for (const chunk of chunks) {
|
||||
const m = DEFINE_NAME.exec(chunk);
|
||||
const name = m?.[1];
|
||||
if (name === undefined || raisers.has(name) || !invoked.has(name)) continue;
|
||||
if (![...chunk.matchAll(/ call [^@\n]*@([\w.$%-]+)\(/g)].some((c) => raisers.has(c[1]!)))
|
||||
continue;
|
||||
if (testsFlag.has(name)) continue;
|
||||
raisers.add(name);
|
||||
resumers.add(name);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name);
|
||||
for (const chunks of split)
|
||||
for (const chunk of chunks)
|
||||
for (const line of chunk.split("\n")) {
|
||||
if (line.startsWith("define ")) continue;
|
||||
for (const m of line.matchAll(/@([\w.$%-]+)/g)) {
|
||||
if (!raisers.has(m[1]!)) continue;
|
||||
const at = m.index!;
|
||||
if (
|
||||
/(?:call|invoke) [^@]*$/.test(line.slice(0, at)) &&
|
||||
line.charAt(at + m[0].length) === "("
|
||||
)
|
||||
continue;
|
||||
violations.add(m[1]!);
|
||||
}
|
||||
}
|
||||
if (violations.size > 0) throw new UnwindAbiViolation(violations);
|
||||
let personality = false;
|
||||
let counter = 0;
|
||||
const out = split.map((chunks) =>
|
||||
chunks
|
||||
.map((chunk) => {
|
||||
if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk;
|
||||
const lines = chunk.split("\n");
|
||||
const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!);
|
||||
const result: string[] = [];
|
||||
// The label a block's terminator now follows, for phi operands.
|
||||
const moved = new Map<string, string>();
|
||||
let block = "";
|
||||
let changed = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
const label = /^([\w.$-]+):/.exec(line);
|
||||
if (label !== null) block = label[1]!;
|
||||
const m = PLAIN_CALL.exec(line);
|
||||
const callee = m === null ? undefined : (m[4] ?? m[7]);
|
||||
if (m === null || callee === undefined || !raisers.has(callee)) {
|
||||
const marked = CALL_LINE.test(line) ? markNounwind(line) : line;
|
||||
changed ||= marked !== line;
|
||||
result.push(marked);
|
||||
continue;
|
||||
}
|
||||
// The call's text from its callee to the end of the argument list.
|
||||
const close = argumentsEnd(line, line.indexOf(`@${callee}(`) + callee.length + 1);
|
||||
const head = line.slice(line.indexOf("call ") + 5, close + 1);
|
||||
const tail = line.slice(close + 1);
|
||||
const meta = /,\s*!/.exec(tail);
|
||||
const attrs = (
|
||||
meta === null ? tail.replace(/;.*$/, "") : tail.slice(0, meta.index)
|
||||
).trimEnd();
|
||||
const dbg = meta === null ? "" : tail.slice(meta.index).replace(/\s;.*$/, "");
|
||||
const n = counter++;
|
||||
if (block === "") {
|
||||
result.splice(1, 0, "entry.ub:");
|
||||
block = "entry.ub";
|
||||
}
|
||||
const indent = m[1] ?? m[5] ?? " ";
|
||||
const ok = `ub.ok.${n}`;
|
||||
const pad = `ub.lp.${n}`;
|
||||
if (resume) {
|
||||
// The wrapper holds nothing to release: the unwind goes on to
|
||||
// its caller's landing pad.
|
||||
const assign = m[2] !== undefined ? `${m[2]} = ` : "";
|
||||
result.push(
|
||||
`${indent}${assign}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
|
||||
);
|
||||
result.push(
|
||||
`${pad}:`,
|
||||
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
|
||||
`${indent}resume { ptr, i32 } %ub.v.${n}`,
|
||||
);
|
||||
result.push(`${ok}:`);
|
||||
} else if (m[2] !== undefined) {
|
||||
const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim();
|
||||
const normal = `ub.n.${n}`;
|
||||
result.push(
|
||||
`${indent}${m[2]}.ub = invoke ${head}${attrs} to label %${normal} unwind label %${pad}${dbg}`,
|
||||
);
|
||||
result.push(`${normal}:`, `${indent}br label %${ok}`);
|
||||
result.push(
|
||||
`${pad}:`,
|
||||
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
|
||||
`${indent}br label %${ok}`,
|
||||
);
|
||||
result.push(
|
||||
`${ok}:`,
|
||||
`${indent}${m[2]} = phi ${type} [ ${m[2]}.ub, %${normal} ], [ ${dummyValue(type)}, %${pad} ]`,
|
||||
);
|
||||
} else {
|
||||
result.push(
|
||||
`${indent}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
|
||||
);
|
||||
result.push(
|
||||
`${pad}:`,
|
||||
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
|
||||
`${indent}br label %${ok}`,
|
||||
);
|
||||
result.push(`${ok}:`);
|
||||
}
|
||||
moved.set(block, ok);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return chunk;
|
||||
if (moved.size === 0) return result.join("\n");
|
||||
personality = true;
|
||||
const header = result[0]!;
|
||||
const brace = header.lastIndexOf(" {");
|
||||
if (!header.includes("personality") && brace >= 0) {
|
||||
// Before the function's metadata attachments (`!dbg`), if any.
|
||||
const dbg = header.slice(0, brace).search(/\s!dbg !\d+$/);
|
||||
const at = dbg >= 0 ? dbg : brace;
|
||||
result[0] = `${header.slice(0, at)} ${UNWIND_PERSONALITY}${header.slice(at)}`;
|
||||
}
|
||||
// A split block's successors now come from its last part.
|
||||
if (moved.size > 0)
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const line = result[i]!;
|
||||
if (!line.includes(" phi ")) continue;
|
||||
result[i] = line.replace(/, %([\w.$-]+) \]/g, (all, name: string) => {
|
||||
const to = moved.get(name);
|
||||
return to === undefined ? all : `, %${to} ]`;
|
||||
});
|
||||
}
|
||||
return result.join("\n");
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)")))
|
||||
out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`;
|
||||
return out;
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import { integerArithmeticRange, type IntegerRange } from "../../ir/integer-rang
|
||||
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
|
||||
import type { LlInteger } from "./integer-values.js";
|
||||
|
||||
/** Marks the `+` and `-` operands whose number only reaches ToInt32/ToUint32
|
||||
* consumers (`| 0`, the other bitwise operators, `Math.imul`, `Math.clz32`),
|
||||
* directly or through further sums, in the emitter's `wrappingSums`. Called
|
||||
* by the consumer before its operands emit. */
|
||||
/** Marks the `+`, `-` and `*` operands whose number only reaches
|
||||
* ToInt32/ToUint32 consumers (`| 0`, the other bitwise operators,
|
||||
* `Math.imul`, `Math.clz32`), directly or through further sums and
|
||||
* products, in the emitter's `wrappingSums`. Called by the consumer before
|
||||
* its operands emit. */
|
||||
export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrExpr[]): void {
|
||||
for (const operand of operands) {
|
||||
if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-")) continue;
|
||||
if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-" && operand.op !== "*"))
|
||||
continue;
|
||||
if (operand.type.kind !== "f64" || host.wrappingSums.has(operand)) continue;
|
||||
host.wrappingSums.add(operand);
|
||||
markWrappingOperands(host, operand.left, operand.right);
|
||||
@@ -128,12 +130,15 @@ function wrappingOperand(
|
||||
return { low, valid };
|
||||
}
|
||||
|
||||
/** ToUint32 of `left op right` (`+` or `-`) in 32-bit arithmetic when the
|
||||
* expression only reaches ToInt32/ToUint32 consumers and both operands are
|
||||
* exact integers or NaN with an exact result. Both conversions are
|
||||
* arithmetic mod 2^32, so the wrapping i32 sum of the operands' low halves
|
||||
* is the converted exact sum; a NaN operand makes the sum NaN, which
|
||||
* converts to 0. Returns undefined, emitting nothing, for any other sum. */
|
||||
/** ToUint32 of `left op right` (`+`, `-` or `*`) in 32-bit arithmetic when
|
||||
* the expression only reaches ToInt32/ToUint32 consumers and both operands
|
||||
* are exact integers or NaN with an exact result. Both conversions are
|
||||
* arithmetic mod 2^32, so the wrapping i32 sum or product of the operands'
|
||||
* low halves is the converted exact result; a NaN operand makes the result
|
||||
* NaN, which converts to 0. A product is exact when the operands' magnitude
|
||||
* bounds multiply to at most 2^53; it may be -0 (`0 * -1`), which converts
|
||||
* to 0 like the integer 0. Returns undefined, emitting nothing, for any
|
||||
* other expression. */
|
||||
export function emitWrappingSum(
|
||||
host: LlvmEmitterContext,
|
||||
e: IrExpr & { kind: "bin" },
|
||||
@@ -142,15 +147,23 @@ export function emitWrappingSum(
|
||||
li: LlInteger | null,
|
||||
ri: LlInteger | null,
|
||||
): string | undefined {
|
||||
if ((e.op !== "+" && e.op !== "-") || !host.wrappingSums.has(e)) return undefined;
|
||||
if ((e.op !== "+" && e.op !== "-" && e.op !== "*") || !host.wrappingSums.has(e)) return undefined;
|
||||
const leftRange = li ? li.range : integerOrNaNRange(host, e.left);
|
||||
const rightRange = leftRange && (ri ? ri.range : integerOrNaNRange(host, e.right));
|
||||
if (!rightRange || !integerArithmeticRange(e.op, leftRange!, rightRange)) return undefined;
|
||||
if (!rightRange) return undefined;
|
||||
if (
|
||||
e.op === "*"
|
||||
? !exactProduct(leftRange!, rightRange)
|
||||
: !integerArithmeticRange(e.op, leftRange!, rightRange)
|
||||
)
|
||||
return undefined;
|
||||
const B = host.B;
|
||||
const left = wrappingOperand(host, l, li, leftRange!);
|
||||
const right = wrappingOperand(host, r, ri, rightRange);
|
||||
const sum = B.tmp();
|
||||
B.line(`${sum} = ${e.op === "+" ? "add" : "sub"} i32 ${left.low}, ${right.low}`);
|
||||
B.line(
|
||||
`${sum} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} i32 ${left.low}, ${right.low}`,
|
||||
);
|
||||
let valid = left.valid ?? right.valid;
|
||||
if (valid === null) return sum;
|
||||
if (left.valid && right.valid) {
|
||||
@@ -162,6 +175,17 @@ export function emitWrappingSum(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether every product of a value in `left` and one in `right` is an
|
||||
* integer of magnitude below 2^53, which the double product holds exactly.
|
||||
* The bound's own product is computed in a double: a bound of at least
|
||||
* 2^53 never rounds below 2^53, while 2^53 + 1 (3 * 3002399751580331)
|
||||
* rounds to 2^53, so the test is strict. */
|
||||
function exactProduct(left: IntegerRange, right: IntegerRange): boolean {
|
||||
const magnitude = (range: IntegerRange): number =>
|
||||
Math.max(Math.abs(range.min), Math.abs(range.max));
|
||||
return magnitude(left) * magnitude(right) < 2 ** 53;
|
||||
}
|
||||
|
||||
/** The number locals of `fn` that only hold exact integers (never -0) or NaN, with the range of
|
||||
* those integers: locals declared once with an initializer, neither parameters nor captured or
|
||||
* boxed, whose initializer and every later write `integerOrNaNRange` covers. A local joins once
|
||||
|
||||
@@ -30,6 +30,8 @@ import { compilerReleaseVersion } from "../library/sidecar.js";
|
||||
import {
|
||||
nativeProfileHelperArgs,
|
||||
nativeProfileKey,
|
||||
parseNativeProfileMatch,
|
||||
type NativeProfileMatch,
|
||||
type NativeProfileOptions,
|
||||
} from "./native-profile.js";
|
||||
import { writeTextParts } from "../text-parts.js";
|
||||
@@ -69,6 +71,8 @@ export interface NativeCodegenArtifact {
|
||||
dependencies: NativeArtifactDependency[];
|
||||
/** Every emitted file, starting with the requested output path. */
|
||||
outputPaths: string[];
|
||||
/** How the build's profile matched, when it used one and the helper ran. */
|
||||
profileMatch?: NativeProfileMatch;
|
||||
}
|
||||
|
||||
const resolvedHelperCache = new Map<string, Promise<ResolvedHelper>>();
|
||||
@@ -458,7 +462,7 @@ export async function emitNativeArtifact(
|
||||
try {
|
||||
if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input);
|
||||
else await writeLlvmSource(input, options.llvm, 0o600);
|
||||
await invoke(helper.binaryPath, [
|
||||
const { stdout } = await invoke(helper.binaryPath, [
|
||||
"emit",
|
||||
"--input",
|
||||
input,
|
||||
@@ -502,7 +506,9 @@ export async function emitNativeArtifact(
|
||||
for (let index = 0; index < stages.length; index++)
|
||||
await rename(stages[index]!, outputs[index]!);
|
||||
await pruneBuildCache(root);
|
||||
return artifact;
|
||||
const profileMatch =
|
||||
options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout);
|
||||
return profileMatch === null ? artifact : { ...artifact, profileMatch };
|
||||
} finally {
|
||||
await Promise.all([
|
||||
...stages.map((stage) => rm(stage, { force: true }).catch(() => undefined)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { executableLinkInputs } from "./link-plan-core.js";
|
||||
import {
|
||||
parseNativeProfileMatch,
|
||||
nativeProfileHelperArgs,
|
||||
nativeProfileKey,
|
||||
nativeProfileRefusal,
|
||||
@@ -71,3 +72,15 @@ test("only the instrumented link adds the profile runtime", () => {
|
||||
"-fprofile-generate",
|
||||
);
|
||||
});
|
||||
|
||||
test("the helper's profile match is read from its output", () => {
|
||||
expect(
|
||||
parseNativeProfileMatch(
|
||||
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":1}}\n',
|
||||
),
|
||||
).toEqual({ annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 1 });
|
||||
// A helper that predates the report prints nothing.
|
||||
expect(parseNativeProfileMatch("")).toBeNull();
|
||||
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
|
||||
expect(parseNativeProfileMatch('{"ok":true,"profile":{"annotated":-1}}\n')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -44,6 +44,45 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine
|
||||
return [];
|
||||
}
|
||||
|
||||
/** How an optimized build's profile matched the program's functions, as the
|
||||
* helper reports it: functions that took the profile's counts, functions
|
||||
* whose control flow changed since the profile was collected, functions the
|
||||
* profile has no record of (new, or never run while it was collected), and
|
||||
* partitions reused from the cache without their counts. */
|
||||
export interface NativeProfileMatch {
|
||||
annotated: number;
|
||||
mismatched: number;
|
||||
missing: number;
|
||||
unknownPartitions: number;
|
||||
}
|
||||
|
||||
/** The profile match in a successful helper emit's output, or null when the
|
||||
* helper reports none (a helper that predates the report). */
|
||||
export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | null {
|
||||
for (const line of stdout.split("\n")) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const profile = (parsed as { profile?: Record<string, unknown> } | null)?.profile;
|
||||
if (typeof profile !== "object" || profile === null) continue;
|
||||
const count = (key: string): number | null => {
|
||||
const value = profile[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
||||
};
|
||||
const annotated = count("annotated");
|
||||
const mismatched = count("mismatched");
|
||||
const missing = count("missing");
|
||||
const unknownPartitions = count("unknown_partitions");
|
||||
if (annotated === null || mismatched === null || missing === null || unknownPartitions === null)
|
||||
continue;
|
||||
return { annotated, mismatched, missing, unknownPartitions };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cache-key part; empty without a profile so other keys are unchanged. */
|
||||
export function nativeProfileKey(profile: NativeProfileOptions | undefined): string {
|
||||
if (profile?.generate !== undefined) return `profile-generate:${profile.generate}`;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
cacheTargetIdentity,
|
||||
isZigDriver,
|
||||
isMuslTarget,
|
||||
type CcDriver,
|
||||
} from "./driver.js";
|
||||
import {
|
||||
resolveProgramShardMergeIdentity,
|
||||
@@ -821,6 +822,7 @@ export async function compileCInternal(
|
||||
// generated/native input that references inflate symbols. Cross
|
||||
// and targetless Zig builds use vendored zlib objects in the input section above.
|
||||
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
|
||||
...unwinderLinkArgs(driver),
|
||||
// glibc keeps libm separate from libc. This must trail the generated
|
||||
// program and every native FFI input because GNU ld resolves archives
|
||||
// from left to right.
|
||||
@@ -1068,6 +1070,7 @@ export async function compileCInternal(
|
||||
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
|
||||
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
|
||||
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
|
||||
...unwinderLinkArgs(driver),
|
||||
...driver.linkArgs,
|
||||
...executableLinkFlags,
|
||||
];
|
||||
@@ -1080,6 +1083,7 @@ export async function compileCInternal(
|
||||
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
|
||||
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
|
||||
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
|
||||
...unwinderLinkArgs(driver),
|
||||
...driver.linkArgs,
|
||||
...executableLinkFlags,
|
||||
];
|
||||
@@ -1689,3 +1693,11 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Linux runtimes raise through the native unwinder (scr_unwind_raise):
|
||||
* Zig's libunwind, or the toolchain's libgcc_eh. Both are static archives,
|
||||
* so programs load no shared library for them. */
|
||||
function unwinderLinkArgs(driver: CcDriver): string[] {
|
||||
if (targetPlatform(driver) !== "linux") return [];
|
||||
return isZigDriver(driver) ? ["-lunwind"] : ["-lgcc_eh"];
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
|
||||
"scr_object.c",
|
||||
"scr_union.c",
|
||||
"scr_exception.c",
|
||||
"scr_unwind.c",
|
||||
"scr_error.c",
|
||||
"scr_console.c",
|
||||
"scr_lib.c",
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { nativeCodegenDiag } from "../diagnostics/diagnostic.js";
|
||||
import { compilerReleaseVersion } from "../library/sidecar.js";
|
||||
import type { NativeLinkFeatures } from "./native-link-info.js";
|
||||
import { RuntimePackError } from "./runtime-pack-core.js";
|
||||
import { selectNativeRuntimePack } from "./runtime-pack-native.js";
|
||||
import { WASM32_WASI_TARGET } from "./targets.js";
|
||||
import { RuntimePackError, type RuntimePackManifest } from "./runtime-pack-core.js";
|
||||
import {
|
||||
runtimePackManifest,
|
||||
selectNativeRuntimePack,
|
||||
stageNativeRuntimeSelection,
|
||||
type NativeRuntimeSelection,
|
||||
type RuntimePackStore,
|
||||
} from "./runtime-pack-native.js";
|
||||
import { MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js";
|
||||
|
||||
vi.mock("node:crypto", async (original) => {
|
||||
const crypto = await original<typeof import("node:crypto")>();
|
||||
return { ...crypto, createHash: vi.fn(crypto.createHash) };
|
||||
});
|
||||
vi.mock("node:fs", async (original) => {
|
||||
const fs = await original<typeof import("node:fs")>();
|
||||
return { ...fs, readFileSync: vi.fn(fs.readFileSync) };
|
||||
});
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(async () =>
|
||||
@@ -45,3 +72,171 @@ test("a missing native helper keeps the reinstall hint", () => {
|
||||
"reinstall scriptc with optional dependencies enabled for this host",
|
||||
);
|
||||
});
|
||||
|
||||
/** A runtime pack with one runtime object and a license, and a store that
|
||||
* keeps entries in memory. */
|
||||
async function packFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-store-"));
|
||||
dirs.push(root);
|
||||
const target = MACOS_ARM64_TARGET;
|
||||
const version = compilerReleaseVersion();
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: target.runtimePackPackage, version }),
|
||||
);
|
||||
mkdirSync(join(root, "artifacts"));
|
||||
const object = join(root, "artifacts", "base.o");
|
||||
writeFileSync(object, "base object");
|
||||
writeFileSync(join(root, "license.txt"), "license");
|
||||
const unit = {
|
||||
source: "scr_base.c",
|
||||
predicate: true,
|
||||
variants: [
|
||||
{
|
||||
id: "default",
|
||||
when: {},
|
||||
defines: [],
|
||||
path: "artifacts/base.o",
|
||||
sha256: createHash("sha256").update("base object").digest("hex"),
|
||||
size: Buffer.byteLength("base object"),
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifest: RuntimePackManifest = {
|
||||
schema: "scriptc.runtime-pack.v1",
|
||||
format: 1,
|
||||
package: target.runtimePackPackage,
|
||||
version,
|
||||
target: {
|
||||
name: target.name,
|
||||
llvm_triple: target.llvmTriple,
|
||||
architecture: target.architecture,
|
||||
object_format: target.objectFormat,
|
||||
minimum_os: target.minimumOs,
|
||||
},
|
||||
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
|
||||
compiler: { command: "clang", identity: "fixture clang", target: target.llvmTriple },
|
||||
macros: {
|
||||
executable: [],
|
||||
excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT"],
|
||||
sanitizer: "external-toolchain-required",
|
||||
},
|
||||
flavors: {
|
||||
release: { optimization: "-O2", runtime_units: [unit] },
|
||||
dev: { optimization: "-O0", runtime_units: [unit] },
|
||||
},
|
||||
archives: [],
|
||||
system_libraries: [{ name: "System", predicate: true }],
|
||||
licenses: [{ path: "license.txt", license: "fixture" }],
|
||||
};
|
||||
const manifestText = JSON.stringify(manifest);
|
||||
writeFileSync(join(root, "runtime-pack.json"), manifestText);
|
||||
const entries = new Map<string, string>();
|
||||
const store: RuntimePackStore = {
|
||||
read: (key) => entries.get(key) ?? null,
|
||||
write: (key, text) => void entries.set(key, text),
|
||||
};
|
||||
const select = () =>
|
||||
selectNativeRuntimePack(
|
||||
root,
|
||||
target,
|
||||
version,
|
||||
{} as NativeLinkFeatures,
|
||||
"release",
|
||||
"executable",
|
||||
store,
|
||||
);
|
||||
let stages = 0;
|
||||
const stage = (selection: NativeRuntimeSelection) => {
|
||||
const directory = join(root, `stage-${stages++}`);
|
||||
mkdirSync(directory);
|
||||
return stageNativeRuntimeSelection(selection, directory, store);
|
||||
};
|
||||
return { root, object, manifestText, entries, select, stage };
|
||||
}
|
||||
|
||||
test("a stored selection answers while the pack's files keep their identities", async () => {
|
||||
const f = await packFixture();
|
||||
const first = f.select();
|
||||
expect(first.selected.systemLibraries).toEqual(["System"]);
|
||||
expect(f.entries.size).toBe(1);
|
||||
// The next selection reads the stored answer instead of the manifest.
|
||||
const [key, text] = [...f.entries][0]!;
|
||||
const stored = JSON.parse(text);
|
||||
stored.selected.systemLibraries = ["Stored"];
|
||||
f.entries.set(key, JSON.stringify(stored));
|
||||
const second = f.select();
|
||||
expect(second.selected.systemLibraries).toEqual(["Stored"]);
|
||||
expect(second.licenses).toEqual(first.licenses);
|
||||
expect(second.manifest).toBeUndefined();
|
||||
expect(runtimePackManifest(second).flavors.release).toEqual(first.manifest!.flavors.release);
|
||||
// Writing the manifest again, with the same text, gives it a new identity.
|
||||
writeFileSync(join(f.root, "runtime-pack.json"), f.manifestText);
|
||||
expect(f.select().selected.systemLibraries).toEqual(["System"]);
|
||||
});
|
||||
|
||||
test.each(["replaced", "rewritten in place"])(
|
||||
"staging hashes an artifact again when it is %s, even at the same size and mtime",
|
||||
async (change) => {
|
||||
const f = await packFixture();
|
||||
const selection = f.select();
|
||||
f.stage(selection);
|
||||
const before = statSync(f.object);
|
||||
const changed = change === "replaced" ? f.object + ".new" : f.object;
|
||||
writeFileSync(changed, "x".repeat(before.size));
|
||||
utimesSync(changed, before.atime, before.mtime);
|
||||
if (changed !== f.object) renameSync(changed, f.object);
|
||||
expect(() => f.stage(selection)).toThrow(
|
||||
"runtime pack artifact hash mismatch: artifacts/base.o",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("staging does not hash an unchanged artifact again", async () => {
|
||||
const f = await packFixture();
|
||||
const selection = f.select();
|
||||
const hashes = () => vi.mocked(createHash).mock.calls.length;
|
||||
let start = hashes();
|
||||
f.stage(selection);
|
||||
const firstStage = hashes() - start;
|
||||
start = hashes();
|
||||
const staged = f.stage(selection);
|
||||
// Only the store key is hashed; the staged copy has the verified bytes.
|
||||
expect(hashes() - start).toBe(firstStage - 1);
|
||||
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"staging reads an artifact's bytes from the file whose identity it checks",
|
||||
async () => {
|
||||
const f = await packFixture();
|
||||
// The artifact path is a symbolic link to the verified file.
|
||||
const real = f.object + ".real";
|
||||
renameSync(f.object, real);
|
||||
symlinkSync(real, f.object);
|
||||
const selection = f.select();
|
||||
f.stage(selection);
|
||||
// During the next read the link names another file of the same size,
|
||||
// and it names the verified file again before the read ends.
|
||||
const other = f.object + ".other";
|
||||
writeFileSync(other, "x".repeat(statSync(real).size));
|
||||
const read = vi.mocked(readFileSync);
|
||||
const original = read.getMockImplementation()!;
|
||||
read.mockImplementation(((source: Parameters<typeof readFileSync>[0], ...rest: unknown[]) => {
|
||||
if (typeof source === "string" && !source.endsWith("base.o"))
|
||||
return (original as (...args: unknown[]) => unknown)(source, ...rest);
|
||||
read.mockImplementation(original);
|
||||
unlinkSync(f.object);
|
||||
symlinkSync(other, f.object);
|
||||
try {
|
||||
return (original as (...args: unknown[]) => unknown)(source, ...rest);
|
||||
} finally {
|
||||
unlinkSync(f.object);
|
||||
symlinkSync(real, f.object);
|
||||
}
|
||||
}) as typeof readFileSync);
|
||||
const staged = f.stage(selection);
|
||||
expect(read.getMockImplementation()).toBe(original);
|
||||
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/** Synchronous runtime-pack host for the statically compiled driver. Each
|
||||
* selected artifact is hashed and copied from the same read into private
|
||||
* storage. This host has no persistent cache or mutable installed link inputs. */
|
||||
* selected artifact is verified and copied from the same read into private
|
||||
* storage. With a store, a pack's selections and the identities of the
|
||||
* artifacts it verified are kept between builds: an artifact whose file
|
||||
* identity is unchanged since it was hashed is not hashed again. */
|
||||
import type { NativeOptimization } from "./optimization.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeSync,
|
||||
type Stats,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { NativeLinkFeatures } from "./native-link-info.js";
|
||||
@@ -34,13 +39,66 @@ export interface NativeRuntimePack {
|
||||
|
||||
export interface NativeRuntimeSelection {
|
||||
root: string;
|
||||
manifest: RuntimePackManifest;
|
||||
/** The parsed manifest; a selection read from a store has none until
|
||||
* runtimePackManifest parses it. */
|
||||
manifest?: RuntimePackManifest;
|
||||
licenses: RuntimePackManifest["licenses"];
|
||||
packageText: string;
|
||||
manifestText: string;
|
||||
selected: RuntimePackArtifacts;
|
||||
flavor: NativeOptimization;
|
||||
}
|
||||
|
||||
/** Text entries kept between builds (the native compiler's cache). */
|
||||
export interface RuntimePackStore {
|
||||
read(key: string): string | null;
|
||||
write(key: string, text: string): void;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
}
|
||||
|
||||
function fileIdentity(path: string): FileIdentity {
|
||||
return statsIdentity(statSync(path));
|
||||
}
|
||||
|
||||
function statsIdentity(info: Stats): FileIdentity {
|
||||
return {
|
||||
dev: info.dev,
|
||||
ino: info.ino,
|
||||
size: info.size,
|
||||
mtime: info.mtimeMs,
|
||||
ctime: info.ctimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentity(a: FileIdentity, b: FileIdentity): boolean {
|
||||
return (
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.size === b.size &&
|
||||
a.mtime === b.mtime &&
|
||||
a.ctime === b.ctime
|
||||
);
|
||||
}
|
||||
|
||||
function storeKey(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
/** A stored selection: what selecting from the pack's files, with these
|
||||
* identities, returned. */
|
||||
interface StoredSelection {
|
||||
schema: 1;
|
||||
licenses: RuntimePackManifest["licenses"];
|
||||
selected: RuntimePackArtifacts;
|
||||
}
|
||||
|
||||
export function selectNativeRuntimePack(
|
||||
root: string,
|
||||
target: NativeTargetSpec,
|
||||
@@ -48,38 +106,204 @@ export function selectNativeRuntimePack(
|
||||
features: NativeLinkFeatures,
|
||||
flavor: NativeOptimization,
|
||||
mode: RuntimePackMode = "executable",
|
||||
store?: RuntimePackStore,
|
||||
): NativeRuntimeSelection {
|
||||
if (!existsSync(join(root, "package.json"))) {
|
||||
const packagePath = join(root, "package.json");
|
||||
const manifestPath = join(root, "runtime-pack.json");
|
||||
if (!existsSync(packagePath)) {
|
||||
throw new RuntimePackError(
|
||||
`runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`,
|
||||
"missing",
|
||||
"scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project",
|
||||
);
|
||||
}
|
||||
const packageText = readFileSync(join(root, "package.json"), "utf8");
|
||||
const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8");
|
||||
// The pack's files by identity, with everything else selection reads.
|
||||
let key: string | null = null;
|
||||
let identities: FileIdentity[] = [];
|
||||
if (store !== undefined) {
|
||||
try {
|
||||
identities = [fileIdentity(packagePath), fileIdentity(manifestPath)];
|
||||
key = storeKey(
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
root,
|
||||
identities,
|
||||
target: target.name,
|
||||
compilerVersion,
|
||||
features,
|
||||
flavor,
|
||||
mode,
|
||||
}),
|
||||
);
|
||||
const stored = readStoredSelection(store.read(key));
|
||||
if (stored !== null) {
|
||||
const packageText = readFileSync(packagePath, "utf8");
|
||||
const manifestText = readFileSync(manifestPath, "utf8");
|
||||
// The texts read are the ones with the stored identities.
|
||||
if (
|
||||
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
|
||||
sameIdentity(fileIdentity(manifestPath), identities[1]!)
|
||||
) {
|
||||
return {
|
||||
root,
|
||||
licenses: stored.licenses,
|
||||
packageText,
|
||||
manifestText,
|
||||
selected: stored.selected,
|
||||
flavor,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
key = null;
|
||||
}
|
||||
}
|
||||
const packageText = readFileSync(packagePath, "utf8");
|
||||
const manifestText = readFileSync(manifestPath, "utf8");
|
||||
const identity = JSON.parse(packageText) as { name?: string; version?: string };
|
||||
const manifest = parseRuntimePackManifest(JSON.parse(manifestText));
|
||||
validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion);
|
||||
const selected = selectRuntimePackArtifacts(manifest, features, flavor, process.env, mode);
|
||||
return { root, manifest, packageText, manifestText, selected, flavor };
|
||||
if (store !== undefined && key !== null) {
|
||||
try {
|
||||
// Stored only when the files selected from still have the identities
|
||||
// in the key.
|
||||
if (
|
||||
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
|
||||
sameIdentity(fileIdentity(manifestPath), identities[1]!)
|
||||
) {
|
||||
const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected };
|
||||
store.write(key, JSON.stringify(entry));
|
||||
}
|
||||
} catch {
|
||||
/* A failed write is a later miss. */
|
||||
}
|
||||
}
|
||||
return {
|
||||
root,
|
||||
manifest,
|
||||
licenses: manifest.licenses,
|
||||
packageText,
|
||||
manifestText,
|
||||
selected,
|
||||
flavor,
|
||||
};
|
||||
}
|
||||
|
||||
function stageArtifact(root: string, stage: string, artifact: RuntimePackArtifact): string {
|
||||
/** The selection's parsed manifest. */
|
||||
export function runtimePackManifest(selection: NativeRuntimeSelection): RuntimePackManifest {
|
||||
return (selection.manifest ??= parseRuntimePackManifest(JSON.parse(selection.manifestText)));
|
||||
}
|
||||
|
||||
function readStoredSelection(text: string | null): StoredSelection | null {
|
||||
if (text === null) return null;
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
const entry = value as Partial<StoredSelection>;
|
||||
if (
|
||||
entry.schema !== 1 ||
|
||||
!Array.isArray(entry.licenses) ||
|
||||
entry.selected === undefined ||
|
||||
entry.selected === null ||
|
||||
typeof entry.selected !== "object" ||
|
||||
!Array.isArray(entry.selected.runtime) ||
|
||||
!Array.isArray(entry.selected.archives) ||
|
||||
!Array.isArray(entry.selected.systemLibraries)
|
||||
)
|
||||
return null;
|
||||
return entry as StoredSelection;
|
||||
}
|
||||
|
||||
/** An artifact path's identity when its bytes last matched the manifest's
|
||||
* size and digest. */
|
||||
interface VerifiedArtifact {
|
||||
path: string;
|
||||
sha256: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
}
|
||||
|
||||
interface StoredVerification {
|
||||
schema: number;
|
||||
artifacts: VerifiedArtifact[];
|
||||
}
|
||||
|
||||
function readVerified(
|
||||
store: RuntimePackStore | undefined,
|
||||
key: string,
|
||||
): Map<string, VerifiedArtifact> {
|
||||
const verified = new Map<string, VerifiedArtifact>();
|
||||
try {
|
||||
const text = store?.read(key) ?? null;
|
||||
if (text === null) return verified;
|
||||
const stored = JSON.parse(text) as StoredVerification;
|
||||
if (stored.schema === 1)
|
||||
for (const artifact of stored.artifacts) verified.set(artifact.path, artifact);
|
||||
} catch {
|
||||
/* A malformed entry verifies everything again. */
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
|
||||
function stageArtifact(
|
||||
root: string,
|
||||
stage: string,
|
||||
artifact: RuntimePackArtifact,
|
||||
verified: Map<string, VerifiedArtifact>,
|
||||
newlyVerified: VerifiedArtifact[],
|
||||
): string {
|
||||
const source = join(root, artifact.path);
|
||||
const destination = join(stage, artifact.path);
|
||||
let bytes: Buffer;
|
||||
let before: FileIdentity;
|
||||
let after: FileIdentity;
|
||||
try {
|
||||
bytes = readFileSync(source);
|
||||
// The identities are those of the open file the bytes come from, also
|
||||
// when the path is a symbolic link that changes during the read.
|
||||
const input = openSync(source, constants.O_RDONLY);
|
||||
try {
|
||||
before = statsIdentity(fstatSync(input));
|
||||
bytes = readFileSync(input);
|
||||
after = statsIdentity(fstatSync(input));
|
||||
} finally {
|
||||
closeSync(input);
|
||||
}
|
||||
} catch {
|
||||
throw new RuntimePackError(`runtime pack artifact is missing: ${artifact.path}`, "invalid");
|
||||
}
|
||||
// The bytes are those last hashed when the file kept the identity it had
|
||||
// then for the whole read.
|
||||
const known = verified.get(artifact.path);
|
||||
const unchanged = sameIdentity(before, after);
|
||||
const trusted =
|
||||
known !== undefined &&
|
||||
known.sha256 === artifact.sha256 &&
|
||||
known.size === artifact.size &&
|
||||
unchanged &&
|
||||
known.dev === before.dev &&
|
||||
known.ino === before.ino &&
|
||||
known.size === before.size &&
|
||||
known.mtime === before.mtime &&
|
||||
known.ctime === before.ctime;
|
||||
if (
|
||||
bytes.length !== artifact.size ||
|
||||
createHash("sha256").update(bytes).digest("hex") !== artifact.sha256
|
||||
(!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256)
|
||||
) {
|
||||
throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid");
|
||||
}
|
||||
if (!trusted && unchanged)
|
||||
newlyVerified.push({
|
||||
path: artifact.path,
|
||||
sha256: artifact.sha256,
|
||||
dev: before.dev,
|
||||
ino: before.ino,
|
||||
size: before.size,
|
||||
mtime: before.mtime,
|
||||
ctime: before.ctime,
|
||||
});
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
const fd = openSync(
|
||||
destination,
|
||||
@@ -123,15 +347,19 @@ export function stageNativeRuntimePack(
|
||||
export function stageNativeRuntimeSelection(
|
||||
selection: NativeRuntimeSelection,
|
||||
stageRoot: string,
|
||||
store?: RuntimePackStore,
|
||||
): NativeRuntimePack {
|
||||
const { root, manifest, selected, packageText, manifestText } = selection;
|
||||
const { root, licenses, selected, packageText, manifestText } = selection;
|
||||
const packagePath = join(root, "package.json");
|
||||
const manifestPath = join(root, "runtime-pack.json");
|
||||
const runtimeObjects = selected.runtime.map((artifact) =>
|
||||
stageArtifact(root, stageRoot, artifact),
|
||||
);
|
||||
const archives = selected.archives.map((artifact) => stageArtifact(root, stageRoot, artifact));
|
||||
for (const license of manifest.licenses) {
|
||||
const key = storeKey(JSON.stringify({ schema: 1, verified: root }));
|
||||
const verified = readVerified(store, key);
|
||||
const newlyVerified: VerifiedArtifact[] = [];
|
||||
const stage = (artifact: RuntimePackArtifact): string =>
|
||||
stageArtifact(root, stageRoot, artifact, verified, newlyVerified);
|
||||
const runtimeObjects = selected.runtime.map(stage);
|
||||
const archives = selected.archives.map(stage);
|
||||
for (const license of licenses) {
|
||||
try {
|
||||
readFileSync(join(root, license.path));
|
||||
} catch {
|
||||
@@ -144,5 +372,14 @@ export function stageNativeRuntimeSelection(
|
||||
) {
|
||||
throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid");
|
||||
}
|
||||
if (store !== undefined && newlyVerified.length > 0) {
|
||||
for (const artifact of newlyVerified) verified.set(artifact.path, artifact);
|
||||
const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] };
|
||||
try {
|
||||
store.write(key, JSON.stringify(entry));
|
||||
} catch {
|
||||
/* A failed write only means hashing again next time. */
|
||||
}
|
||||
}
|
||||
return { runtimeObjects, archives, systemLibraries: selected.systemLibraries };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TargetCpu } from "./backend/target-cpu.js";
|
||||
import type { WindowsSubsystem } from "./backend/targets.js";
|
||||
import type { NativeLinkInfo } from "./backend/native-link-info.js";
|
||||
import type { NativeOptimization } from "./backend/optimization.js";
|
||||
import type { NativeProfileMatch } from "./backend/native-profile.js";
|
||||
import type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
|
||||
import type { CoverageInput } from "./coverage/report.js";
|
||||
import type { TierMode } from "./frontend/tier-analysis.js";
|
||||
@@ -100,6 +101,8 @@ export type CompileArtifact =
|
||||
path: string;
|
||||
translationUnitPath: string;
|
||||
backend: "llvm";
|
||||
/** How a `profileUse` build's profile matched the program's functions. */
|
||||
profileMatch?: NativeProfileMatch;
|
||||
};
|
||||
|
||||
export type CompileFailure = {
|
||||
|
||||
@@ -622,7 +622,7 @@ export function unsupportedTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnosti
|
||||
code: "SC2001",
|
||||
message:
|
||||
`values of type '${typeText}' cannot be compiled yet ` +
|
||||
`(supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown')`,
|
||||
`(supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown')`,
|
||||
loc,
|
||||
milestone: "M2",
|
||||
};
|
||||
|
||||
@@ -270,3 +270,49 @@ test("synchronous tracking restores parents after throws and propagates unstable
|
||||
trackedFileExists(join(dir, "outside.ts"));
|
||||
expect(parent.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
test("a settled file is checked by its identity, and any write invalidates it", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
|
||||
scratch.push(dir);
|
||||
const source = join(dir, "entry.ts");
|
||||
await writeFile(source, "export const answer = 1;\n");
|
||||
const written = fs.statSync(source);
|
||||
// The file was last changed long enough before the read.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.now() + 5000);
|
||||
const tracker = new FrontendInputTracker();
|
||||
try {
|
||||
tracker.run(() => trackedReadFile(source));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
const snapshot = tracker.snapshot();
|
||||
const probe = snapshot.probes.find((entry) => entry.op === "file");
|
||||
expect(probe?.op === "file" && probe.identity !== undefined).toBe(true);
|
||||
expect(validFrontendInputSnapshot(snapshot)).toBe(true);
|
||||
const reads = vi.mocked(fs.readFileSync);
|
||||
reads.mockClear();
|
||||
expect(frontendInputsStillMatch(snapshot)).toBe(true);
|
||||
expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(0);
|
||||
// A same-size rewrite that restores the modification time still changes
|
||||
// the change time.
|
||||
await writeFile(source, "export const answer = 2;\n");
|
||||
fs.utimesSync(source, written.atime, written.mtime);
|
||||
expect(frontendInputsStillMatch(snapshot)).toBe(false);
|
||||
});
|
||||
|
||||
test("a file changed shortly before the read is hashed on every check", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
|
||||
scratch.push(dir);
|
||||
const source = join(dir, "entry.ts");
|
||||
await writeFile(source, "export const answer = 1;\n");
|
||||
const tracker = new FrontendInputTracker();
|
||||
tracker.run(() => trackedReadFile(source));
|
||||
const snapshot = tracker.snapshot();
|
||||
const probe = snapshot.probes.find((entry) => entry.op === "file");
|
||||
expect(probe?.op === "file" && probe.identity).toBeUndefined();
|
||||
const reads = vi.mocked(fs.readFileSync);
|
||||
reads.mockClear();
|
||||
expect(frontendInputsStillMatch(snapshot)).toBe(true);
|
||||
expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ import { observeSourceText } from "./ts7/ast-node.js";
|
||||
* behind an old resolution answer.
|
||||
*/
|
||||
export type FrontendInputProbe =
|
||||
| { op: "file"; path: string; digest: string }
|
||||
/** `identity` (fileIdentity) lets a later check skip hashing the file. */
|
||||
| { op: "file"; path: string; digest: string; identity?: string }
|
||||
| { op: "read-error"; path: string }
|
||||
| { op: "kind"; path: string; kind: "file" | "directory" | "other" | "missing" }
|
||||
| { op: "entries"; path: string; files: string[]; directories: string[] }
|
||||
@@ -53,6 +54,36 @@ function frontendSourceDigest(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
/** How long a file must have been unchanged before a read for its identity
|
||||
* to stand for its content: a later write then gets a newer change time
|
||||
* even on file systems whose timestamps tick once a second or every two. */
|
||||
const IDENTITY_SETTLE_MS = 2000;
|
||||
|
||||
/** The device, inode, size, modification time and change time of `path`. A
|
||||
* write changes the change time, which no tool can set back, so an equal
|
||||
* identity means the content is the one hashed with it. */
|
||||
function fileIdentity(path: string): string | null {
|
||||
try {
|
||||
const info = statSync(path);
|
||||
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of `path` with its identity when the identity was the same before
|
||||
* and after the read and the file had been unchanged for IDENTITY_SETTLE_MS. */
|
||||
function readWithIdentity(path: string): { text: string; identity: string | null } {
|
||||
const readAt = Date.now();
|
||||
const before = statSync(path);
|
||||
const text = readFileSync(path, "utf8");
|
||||
const after = fileIdentity(path);
|
||||
const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`;
|
||||
const settled =
|
||||
before.mtimeMs <= readAt - IDENTITY_SETTLE_MS && before.ctimeMs <= readAt - IDENTITY_SETTLE_MS;
|
||||
return { text, identity: settled && after === identity ? identity : null };
|
||||
}
|
||||
|
||||
function pathKind(path: string): Extract<FrontendInputProbe, { op: "kind" }>["kind"] {
|
||||
try {
|
||||
const info = statSync(path);
|
||||
@@ -199,8 +230,11 @@ export function trackedReadFile(path: string): string | null {
|
||||
const virtual = virtualSources.get(path);
|
||||
if (virtual !== undefined) return virtual;
|
||||
try {
|
||||
const text = readFileSync(path, "utf8");
|
||||
record({ op: "file", path, digest: frontendSourceDigest(text) });
|
||||
const { text, identity } = readWithIdentity(path);
|
||||
const digest = frontendSourceDigest(text);
|
||||
record(
|
||||
identity === null ? { op: "file", path, digest } : { op: "file", path, digest, identity },
|
||||
);
|
||||
return text;
|
||||
} catch {
|
||||
record({ op: "read-error", path });
|
||||
@@ -337,6 +371,9 @@ export function frontendInputsStillMatch(
|
||||
}
|
||||
switch (probe.op) {
|
||||
case "file": {
|
||||
// An unchanged identity stands for the content hashed with it.
|
||||
if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity)
|
||||
return true;
|
||||
try {
|
||||
return frontendSourceDigest(readFileSync(probe.path, "utf8")) === probe.digest;
|
||||
} catch {
|
||||
@@ -443,6 +480,11 @@ export function frontendInputsSemanticallyMatch(
|
||||
probes.push(probe);
|
||||
continue;
|
||||
}
|
||||
if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity) {
|
||||
currentSources.set(probe.path, previous);
|
||||
probes.push(probe);
|
||||
continue;
|
||||
}
|
||||
let current: string;
|
||||
try {
|
||||
current = readFileSync(probe.path, "utf8");
|
||||
@@ -460,7 +502,7 @@ export function frontendInputsSemanticallyMatch(
|
||||
if (rawText.has(probe.path) || !isSemanticEquivalent(probe.path, previous, current))
|
||||
return null;
|
||||
changed.push({ path: probe.path, previous, current });
|
||||
probes.push({ ...probe, digest: currentDigest });
|
||||
probes.push({ op: "file", path: probe.path, digest: currentDigest });
|
||||
}
|
||||
const adjusted: FrontendInputSnapshot = { ...snapshot, probes };
|
||||
if (!frontendInputsStillMatch(adjusted, exclusions)) return null;
|
||||
@@ -488,7 +530,11 @@ export function validFrontendInputSnapshot(snapshot: unknown): snapshot is Front
|
||||
if (typeof value.path !== "string" || typeof value.op !== "string") return false;
|
||||
switch (value.op) {
|
||||
case "file":
|
||||
return typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest);
|
||||
return (
|
||||
typeof value.digest === "string" &&
|
||||
/^[0-9a-f]{64}$/.test(value.digest) &&
|
||||
(value.identity === undefined || typeof value.identity === "string")
|
||||
);
|
||||
case "read-error":
|
||||
return true;
|
||||
case "kind":
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DYN,
|
||||
DYN_HANDLE_KINDS,
|
||||
F64,
|
||||
STRING,
|
||||
type IrExpr,
|
||||
type IrStmt,
|
||||
type IrType,
|
||||
@@ -285,6 +286,43 @@ export function lowerAbstractEquality(
|
||||
};
|
||||
return negated ? { kind: "unary", op: "!", operand: result, type: BOOL, loc } : result;
|
||||
}
|
||||
// Two Dates are equal when they are the same object, as with ===.
|
||||
if (left.type.kind === "date" && right.type.kind === "date")
|
||||
return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc };
|
||||
// A Date and a string or number compare through the Date's default
|
||||
// primitive, its toString() text.
|
||||
if (left.type.kind === "date" || right.type.kind === "date") {
|
||||
const otherType = left.type.kind === "date" ? right.type : left.type;
|
||||
if (otherType.kind === "string" || otherType.kind === "f64") {
|
||||
// Both operands evaluate before the Date converts.
|
||||
const l = lowerer.declareHiddenLocal("%looseA", left.type);
|
||||
const r = lowerer.declareHiddenLocal("%looseB", right.type);
|
||||
const stmts: IrStmt[] = [
|
||||
{ kind: "varDecl", localId: l.id, init: left, loc },
|
||||
{ kind: "varDecl", localId: r.id, init: right, loc },
|
||||
];
|
||||
const primitive = (value: IrExpr): IrExpr => {
|
||||
if (value.type.kind !== "date") return value;
|
||||
const text: IrExpr = {
|
||||
kind: "libCall",
|
||||
fn: "date.toString",
|
||||
args: [value],
|
||||
type: STRING,
|
||||
loc,
|
||||
};
|
||||
return otherType.kind === "string"
|
||||
? text
|
||||
: { kind: "libCall", fn: "num.fromString", args: [text], type: F64, loc };
|
||||
};
|
||||
const a = primitive(varRef(l.id, left.type, loc));
|
||||
const b = primitive(varRef(r.id, right.type, loc));
|
||||
const result: IrExpr =
|
||||
otherType.kind === "string"
|
||||
? { kind: "strEq", negated, left: a, right: b, type: BOOL, loc }
|
||||
: { kind: "bin", op: negated ? "!==" : "===", left: a, right: b, type: BOOL, loc };
|
||||
return { kind: "seqExpr", stmts, result, type: BOOL, loc };
|
||||
}
|
||||
}
|
||||
if (!supportsPair(lowerer, left.type, right.type)) return null;
|
||||
if (left.kind === "strLit" && right.kind === "strLit") {
|
||||
return { kind: "boolLit", value: (left.value === right.value) !== negated, type: BOOL, loc };
|
||||
|
||||
@@ -2,7 +2,13 @@ import * as ts from "../../ts7/adapter.js";
|
||||
import { type Lowerer } from "../lowerer.js";
|
||||
import { locOf } from "../../program.js";
|
||||
import { type ClassInfo } from "../lower-classes.js";
|
||||
import { DATE_METHOD_HINT, dateJsonType, dateValueMethod } from "./dates.js";
|
||||
import {
|
||||
DATE_METHOD_HINT,
|
||||
dateJsonType,
|
||||
dateSetterTime,
|
||||
dateValueMethod,
|
||||
isDateSetter,
|
||||
} from "./dates.js";
|
||||
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
|
||||
import {
|
||||
BOOL,
|
||||
@@ -248,6 +254,22 @@ export function lowerInheritedDateMethodCall(
|
||||
type: result.type,
|
||||
loc,
|
||||
});
|
||||
if (isDateSetter(name) && name !== "setTime") {
|
||||
// The field setters change the Date the class's time slot holds once
|
||||
// the arguments are evaluated: an argument that calls setTime on the
|
||||
// instance gives the slot a new Date.
|
||||
const date = lowerer.declareHiddenLocal("%dateTime", DATE_T);
|
||||
const current = varRef(date.id, DATE_T, loc);
|
||||
const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [current], type: F64, loc };
|
||||
const next = dateSetterTime(lowerer, call, name, stmts, time);
|
||||
stmts.push({
|
||||
kind: "varDecl",
|
||||
localId: date.id,
|
||||
init: timeOf(lowerer, value, root, loc),
|
||||
loc,
|
||||
});
|
||||
return done({ kind: "libCall", fn: "date.store", args: [current, next], type: F64, loc });
|
||||
}
|
||||
if (name === "setTime") {
|
||||
// Every argument is evaluated before setTime converts the first one.
|
||||
let ms: IrExpr = { kind: "numLit", value: Number.NaN, type: F64, loc };
|
||||
|
||||
@@ -2,12 +2,15 @@ import * as ts from "../../ts7/adapter.js";
|
||||
import { type Lowerer, own } from "../lowerer.js";
|
||||
import { locOf } from "../../program.js";
|
||||
import { tryLowerExpression } from "../expressions/try-lower-expression.js";
|
||||
import { lowerToNumberArguments } from "../lower-exprs.js";
|
||||
import { programDeclaresDateClass } from "./date-classes.js";
|
||||
import {
|
||||
BOOL,
|
||||
DATE_T,
|
||||
F64,
|
||||
type IrExpr,
|
||||
type IrLibFn,
|
||||
type IrLocal,
|
||||
type IrStmt,
|
||||
type IrType,
|
||||
NULL_T,
|
||||
@@ -36,6 +39,32 @@ const DATE_GETTER_FNS: Readonly<Record<string, IrLibFn>> = {
|
||||
getTimezoneOffset: "date.getTimezoneOffset",
|
||||
};
|
||||
|
||||
/** The setters that change calendar fields: the field code
|
||||
* scr_date_set_fields takes (scr_number.h SCR_DATE_SET_*; 8 marks the UTC
|
||||
* forms) and the most arguments each takes. */
|
||||
const DATE_FIELD_SETTERS: ReadonlyMap<string, { code: number; max: number }> = new Map([
|
||||
["setMilliseconds", { code: 0, max: 1 }],
|
||||
["setSeconds", { code: 1, max: 2 }],
|
||||
["setMinutes", { code: 2, max: 3 }],
|
||||
["setHours", { code: 3, max: 4 }],
|
||||
["setDate", { code: 4, max: 1 }],
|
||||
["setMonth", { code: 5, max: 2 }],
|
||||
["setFullYear", { code: 6, max: 3 }],
|
||||
["setUTCMilliseconds", { code: 8, max: 1 }],
|
||||
["setUTCSeconds", { code: 9, max: 2 }],
|
||||
["setUTCMinutes", { code: 10, max: 3 }],
|
||||
["setUTCHours", { code: 11, max: 4 }],
|
||||
["setUTCDate", { code: 12, max: 1 }],
|
||||
["setUTCMonth", { code: 13, max: 2 }],
|
||||
["setUTCFullYear", { code: 14, max: 3 }],
|
||||
]);
|
||||
|
||||
/** Whether `name` is a Date setter scriptc lowers: setTime or a field
|
||||
* setter. */
|
||||
export function isDateSetter(name: string): boolean {
|
||||
return name === "setTime" || DATE_FIELD_SETTERS.has(name);
|
||||
}
|
||||
|
||||
const DATE_TEXT_FNS: ReadonlyMap<string, IrLibFn> = new Map([
|
||||
["toString", "date.toString"],
|
||||
["toDateString", "date.toDateString"],
|
||||
@@ -43,11 +72,11 @@ const DATE_TEXT_FNS: ReadonlyMap<string, IrLibFn> = new Map([
|
||||
]);
|
||||
|
||||
export const DATE_METHOD_HINT =
|
||||
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering";
|
||||
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering";
|
||||
|
||||
/** The Date slice: statics plus read-only Date values backed by one
|
||||
* TimeClip'd millisecond scalar. Construction lives in lowerNew;
|
||||
* identity and mutating methods remain fenced. */
|
||||
/** Date statics and methods on Date objects (a TimeClip'd millisecond
|
||||
* time value with identity): getters, text forms and setters.
|
||||
* Construction lives in lowerNew. */
|
||||
export function lowerDateCall(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
@@ -67,16 +96,18 @@ export function lowerDateCall(
|
||||
if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date") return null;
|
||||
if (!lowerer.isStdlibMember(access)) return null;
|
||||
const name = access.name.text;
|
||||
// A Date a JavaScript file constructs is a mutable object; setTime on
|
||||
// it takes the dynamic method call. So does every Date method on a
|
||||
// A Date a JavaScript file constructs is a checked-dynamic value; a
|
||||
// setter on it takes the dynamic method call, which changes the Date the
|
||||
// handle holds. So does every Date method on a
|
||||
// checked-dynamic receiver when the program declares a class that extends
|
||||
// Date: the value can be an instance, whose class may declare the method.
|
||||
if (
|
||||
(name === "setTime" || programDeclaresDateClass(lowerer)) &&
|
||||
(isDateSetter(name) || programDeclaresDateClass(lowerer)) &&
|
||||
tryLowerExpression(lowerer, access.expression)?.type.kind === "dyn"
|
||||
)
|
||||
return null;
|
||||
if (name === "toJSON") return lowerDateToJson(lowerer, call, access);
|
||||
if (isDateSetter(name)) return lowerDateSetter(lowerer, call, access, name);
|
||||
if (call.arguments.nodes.length !== 0) {
|
||||
lowerer.noLowering(`Date.prototype.${name} with arguments`, call, DATE_METHOD_HINT);
|
||||
}
|
||||
@@ -253,3 +284,151 @@ export function isDateJsonSource(lowerer: Lowerer, node: ts.Node): boolean {
|
||||
lowerer.isStdlibMember(access)
|
||||
);
|
||||
}
|
||||
|
||||
/** `d.setTime(t)` and the field setters (`d.setHours(h, m?, s?, ms?)` ...)
|
||||
* on a Date object: the receiver, then the arguments in order, then the
|
||||
* new time value (scr_date_set_fields over the time value the Date holds
|
||||
* once the arguments have run) stored into the Date and answered. Missing
|
||||
* optional arguments keep their fields; arguments past a setter's last
|
||||
* one are evaluated and ignored. A Date local that holds its time value
|
||||
* (date-values.ts) takes the new time value itself. */
|
||||
function lowerDateSetter(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
access: ts.PropertyAccessExpression,
|
||||
name: string,
|
||||
): IrExpr {
|
||||
const loc = locOf(call);
|
||||
const nodes = call.arguments.nodes;
|
||||
if (nodes.some((node) => ts.isSpreadElement(node)))
|
||||
lowerer.noLowering(`Date.prototype.${name} with spread arguments`, call, DATE_METHOD_HINT);
|
||||
const timeLocal = timeValueLocalOf(lowerer, access.expression);
|
||||
const stmts: IrStmt[] = [];
|
||||
let receiver: IrExpr | null = null;
|
||||
if (timeLocal === null) {
|
||||
// A checked-dynamic receiver takes the dynamic method call
|
||||
// (lowerDateCall), so the value here is a Date object.
|
||||
const value = lowerer.lowerExpr(access.expression);
|
||||
if (value.type.kind !== "date")
|
||||
lowerer.badType(access.expression, lowerer.typeOf(access.expression));
|
||||
const date = lowerer.declareHiddenLocal("%date", DATE_T);
|
||||
stmts.push({ kind: "varDecl", localId: date.id, init: value, loc });
|
||||
receiver = { kind: "varRef", localId: date.id, type: DATE_T, loc };
|
||||
}
|
||||
const time: IrExpr =
|
||||
receiver === null
|
||||
? { kind: "varRef", localId: timeLocal!.id, type: F64, loc }
|
||||
: { kind: "libCall", fn: "date.getTime", args: [receiver], type: F64, loc };
|
||||
const next = dateSetterTime(lowerer, call, name, stmts, time);
|
||||
if (receiver === null) {
|
||||
stmts.push({ kind: "assign", localId: timeLocal!.id, value: next, loc });
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts,
|
||||
result: { kind: "varRef", localId: timeLocal!.id, type: F64, loc },
|
||||
type: F64,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts,
|
||||
result: { kind: "libCall", fn: "date.store", args: [receiver, next], type: F64, loc },
|
||||
type: F64,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
/** The new time value of setter `name` called with `call`'s arguments on a
|
||||
* Date whose time value `time` reads: the arguments evaluate into
|
||||
* temporaries appended to `stmts` (ToNumber of each, an undefined one is
|
||||
* NaN), and the result reads `time` after them. A missing first argument
|
||||
* is NaN; missing optional arguments keep their fields; arguments past a
|
||||
* setter's last one are evaluated and ignored. */
|
||||
export function dateSetterTime(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
name: string,
|
||||
stmts: IrStmt[],
|
||||
time: IrExpr,
|
||||
): IrExpr {
|
||||
const loc = locOf(call);
|
||||
const converted = lowerToNumberArguments(
|
||||
lowerer,
|
||||
call.arguments.nodes as readonly ts.Expression[],
|
||||
);
|
||||
stmts.push(...converted.stmts);
|
||||
const args: IrExpr[] = converted.values.map((value) => {
|
||||
if (value.kind === "numLit") return value;
|
||||
const arg = lowerer.declareHiddenLocal("%dateArg", F64);
|
||||
stmts.push({ kind: "varDecl", localId: arg.id, init: value, loc });
|
||||
return { kind: "varRef", localId: arg.id, type: F64, loc };
|
||||
});
|
||||
const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
|
||||
if (name === "setTime")
|
||||
return {
|
||||
kind: "libCall",
|
||||
fn: "date.timeClip",
|
||||
args: [args[0] ?? number(Number.NaN)],
|
||||
type: F64,
|
||||
loc,
|
||||
};
|
||||
const setter = DATE_FIELD_SETTERS.get(name)!;
|
||||
// The first argument is required: a missing one is undefined, so NaN.
|
||||
const used = args.length === 0 ? [number(Number.NaN)] : args.slice(0, setter.max);
|
||||
const operands = [...used];
|
||||
while (operands.length < 4) operands.push(number(0));
|
||||
return {
|
||||
kind: "libCall",
|
||||
fn: "date.setFields",
|
||||
args: [time, number(setter.code), number(used.length), ...operands],
|
||||
type: F64,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
/** The local a Date binding that holds its time value lowers to, for an
|
||||
* identifier `node` that names one (date-values.ts); null otherwise. */
|
||||
function timeValueLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null {
|
||||
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
||||
if (!ts.isIdentifier(node)) return null;
|
||||
const local = lowerer.resolveLocal(node);
|
||||
return local !== undefined && local !== null && lowerer.timeValueDateLocals.has(local)
|
||||
? local
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `d[Symbol.toPrimitive](hint)` on a Date with a literal hint: "number"
|
||||
* answers the time value, "string" and "default" the toString() text (a
|
||||
* Date's default hint is a string). Null for other calls. */
|
||||
export function lowerDateToPrimitiveCall(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
access: ts.ElementAccessExpression,
|
||||
): IrExpr | null {
|
||||
const key = access.argumentExpression;
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(key) ||
|
||||
key.name.text !== "toPrimitive" ||
|
||||
!ts.isIdentifier(key.expression) ||
|
||||
!lowerer.isStdlibGlobal(key.expression, "Symbol") ||
|
||||
lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date"
|
||||
)
|
||||
return null;
|
||||
const loc = locOf(call);
|
||||
const hint = call.arguments.nodes.length === 1 ? call.arguments.nodes[0]! : null;
|
||||
const text = hint !== null && ts.isStringLiteralLike(hint) ? hint.text : null;
|
||||
if (text !== "number" && text !== "string" && text !== "default")
|
||||
return lowerer.noLowering(
|
||||
'Date.prototype[Symbol.toPrimitive] without a "number", "string" or "default" literal hint',
|
||||
call,
|
||||
);
|
||||
const raw = lowerer.lowerExpr(access.expression);
|
||||
const date: IrExpr =
|
||||
raw.type.kind === "dyn"
|
||||
? { kind: "libCall", fn: "date.checkedValue", args: [raw], type: DATE_T, loc }
|
||||
: raw;
|
||||
return text === "number"
|
||||
? { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc }
|
||||
: { kind: "libCall", fn: "date.toString", args: [date], type: STRING, loc };
|
||||
}
|
||||
|
||||
@@ -185,6 +185,32 @@ export function builtinMemberRequireDecl(
|
||||
return spec !== null && canonicalBuiltinModule(spec) !== null;
|
||||
}
|
||||
|
||||
/** The member's own symbol for a variable bound to a builtin module's
|
||||
* member (`const SD = require("string_decoder").StringDecoder`,
|
||||
* builtinMemberRequireDecl) when the member is a class, so the variable
|
||||
* resolves as a named import of the class does. The symbol itself
|
||||
* otherwise. */
|
||||
export function builtinMemberBindingTarget(
|
||||
lowerer: Lowerer,
|
||||
symbol: ts.Symbol | null,
|
||||
): ts.Symbol | null {
|
||||
const decl = symbol?.valueDeclaration;
|
||||
if (
|
||||
decl === undefined ||
|
||||
!ts.isVariableDeclaration(decl) ||
|
||||
!builtinMemberRequireDecl(decl.name, decl.initializer)
|
||||
)
|
||||
return symbol;
|
||||
const init = decl.initializer as ts.PropertyAccessExpression;
|
||||
let member = lowerer.checker.getSymbolAtLocation(init.name);
|
||||
if (member && member.flags & ts.SymbolFlags.Alias)
|
||||
member = lowerer.checker.getAliasedSymbol(member);
|
||||
// Only a class member: `require("events").EventEmitter` is a static
|
||||
// property of the exported class, which builtinEmitterInfoOf follows
|
||||
// from the variable itself.
|
||||
return member?.declarations.some(ts.isClassDeclaration) ? member : symbol;
|
||||
}
|
||||
|
||||
/** The supported builtin module when `decl` destructures a builtin
|
||||
* NAMESPACE binding (`const crypto = require('crypto'); const {
|
||||
* createSign, sign: mySign } = crypto;` — test/common/crypto.js's
|
||||
|
||||
@@ -302,6 +302,15 @@ function interfaceClassModule(lowerer: Lowerer, node: ts.Expression): string | n
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `new readline.Interface(...)` and `new readlinePromises.Interface(...)`:
|
||||
* the constructor createInterface calls, with the same arguments. Null for
|
||||
* other constructors. */
|
||||
export function lowerReadlineInterfaceNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
|
||||
const module = interfaceClassModule(lowerer, expr.expression);
|
||||
if (module === null) return null;
|
||||
return lowerReadlineCreateCall(lowerer, expr, locOf(expr), module === "readline/promises");
|
||||
}
|
||||
|
||||
/** `rl instanceof readline.Interface` and `rl instanceof
|
||||
* readlinePromises.Interface` over a readline Interface: an interface is
|
||||
* an instance of the class of the module that created it (Node's
|
||||
@@ -1059,10 +1068,12 @@ function lowerOutput(lowerer: Lowerer, node: ts.Expression): IrExpr | "stdout" |
|
||||
* goes through rl.terminal with the completer. */
|
||||
export function lowerReadlineCreateCall(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.CallExpression,
|
||||
expr: ts.CallExpression | ts.NewExpression,
|
||||
loc: SrcLoc,
|
||||
promises: boolean,
|
||||
): IrExpr {
|
||||
const argNodes = expr.arguments?.nodes ?? [];
|
||||
const what = ts.isNewExpression(expr) ? "new Interface" : "createInterface";
|
||||
let input: IrExpr | null = null;
|
||||
let output: IrExpr | "stdout" | "none" = "none";
|
||||
let crlf: IrExpr = numLit(0, loc);
|
||||
@@ -1112,39 +1123,38 @@ export function lowerReadlineCreateCall(
|
||||
});
|
||||
terminal = { kind: "varRef", localId: local.id, type: BOOL, loc };
|
||||
};
|
||||
const optsNode = expr.arguments.nodes[0];
|
||||
const optsNode = argNodes[0];
|
||||
if (optsNode === undefined) {
|
||||
lowerer.noLowering(
|
||||
"createInterface without arguments",
|
||||
`${what} without arguments`,
|
||||
expr,
|
||||
"pass { input: process.stdin, output: process.stdout } or createInterface(input, output)",
|
||||
);
|
||||
}
|
||||
if (!ts.isObjectLiteralExpression(optsNode)) {
|
||||
// createInterface(input, output?, completer?, terminal?)
|
||||
if (expr.arguments.nodes.length > 4) {
|
||||
if (argNodes.length > 4) {
|
||||
lowerer.noLowering(
|
||||
`createInterface with ${expr.arguments.nodes.length} arguments`,
|
||||
expr.arguments.nodes[4]!,
|
||||
`${what} with ${argNodes.length} arguments`,
|
||||
argNodes[4]!,
|
||||
"pass createInterface(input, output, completer, terminal) or one options object",
|
||||
);
|
||||
}
|
||||
input = lowerInput(lowerer, optsNode);
|
||||
if (expr.arguments.nodes[1] !== undefined)
|
||||
output = lowerOutput(lowerer, expr.arguments.nodes[1]);
|
||||
const completerNode = expr.arguments.nodes[2];
|
||||
if (argNodes[1] !== undefined) output = lowerOutput(lowerer, argNodes[1]);
|
||||
const completerNode = argNodes[2];
|
||||
if (
|
||||
completerNode !== undefined &&
|
||||
!(ts.isIdentifier(completerNode) && completerNode.text === "undefined")
|
||||
) {
|
||||
setCompleter(completerNode);
|
||||
}
|
||||
if (expr.arguments.nodes[3] !== undefined) setTerminal(expr.arguments.nodes[3]);
|
||||
if (argNodes[3] !== undefined) setTerminal(argNodes[3]);
|
||||
} else {
|
||||
if (expr.arguments.nodes.length > 1) {
|
||||
if (argNodes.length > 1) {
|
||||
lowerer.noLowering(
|
||||
"createInterface with more than one argument",
|
||||
expr.arguments.nodes[1]!,
|
||||
`${what} with more than one argument`,
|
||||
argNodes[1]!,
|
||||
"pass every option in the one options object",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import {
|
||||
classPrototypeData,
|
||||
hasClassPrototypeData,
|
||||
prototypeReflectedFlag,
|
||||
observedPrototypeMethod,
|
||||
reflectClassPrototype,
|
||||
} from "./class-prototypes.js";
|
||||
@@ -4180,6 +4181,103 @@ export class ClassDynamicDispatch {
|
||||
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
|
||||
}
|
||||
|
||||
/** `%dyn.class.bagHasOwn`: whether an instance's property bag holds `key`
|
||||
* as its own property. False before the instance has a bag. */
|
||||
private readBagHasOwn(lowerer: Lowerer, bag: IrExpr, key: IrExpr, loc: SrcLoc): IrExpr {
|
||||
const name = "%dyn.class.bagHasOwn";
|
||||
let helper = lowerer.liftedFnNamed(name);
|
||||
if (!helper) {
|
||||
const value = varRef("p.0", DYN, loc);
|
||||
helper = {
|
||||
name,
|
||||
params: [
|
||||
{ localId: "p.0", name: "bag", type: DYN },
|
||||
{ localId: "p.1", name: "key", type: STRING },
|
||||
],
|
||||
returnType: BOOL,
|
||||
locals: [
|
||||
{ id: "p.0", name: "bag", type: DYN, mutable: false },
|
||||
{ id: "p.1", name: "key", type: STRING, mutable: false },
|
||||
],
|
||||
body: [
|
||||
{
|
||||
kind: "if",
|
||||
cond: { kind: "dynTest", test: "undefined", value, type: BOOL, loc },
|
||||
then: [
|
||||
{ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc },
|
||||
],
|
||||
else_: null,
|
||||
loc,
|
||||
},
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "libCall",
|
||||
fn: "dyn.hasOwn",
|
||||
args: [value, varRef("p.1", STRING, loc)],
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
};
|
||||
lowerer.liftedFns.push(helper);
|
||||
}
|
||||
this.generated.add(helper);
|
||||
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
|
||||
}
|
||||
|
||||
/** The `constructor` an instance of `info` inherits when it has no own
|
||||
* one. The class's prototype view answers its own `constructor` (a
|
||||
* replacement, or the class once reflection installed it). A view
|
||||
* without one answers the class itself unless reflection had populated it
|
||||
* (prototypeReflectedFlag, set when the view initializes): then the
|
||||
* program deleted the property, and the read continues to the base
|
||||
* class's prototype and past the last one to Object.prototype, as in
|
||||
* Node. A prototype's own `constructor` is read with the instance
|
||||
* `receiver` as `this`, so an accessor's getter runs on the instance. */
|
||||
private inheritedConstructor(
|
||||
lowerer: Lowerer,
|
||||
info: ClassInfo,
|
||||
key: IrExpr,
|
||||
receiver: IrExpr,
|
||||
loc: SrcLoc,
|
||||
): IrExpr {
|
||||
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl!), DYN);
|
||||
const prototype = hasClassPrototypeData(info) ? classPrototypeData(lowerer, info, loc) : null;
|
||||
if (!prototype) return cls;
|
||||
const base = info.base;
|
||||
const read = (): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "dyn.reflectGet",
|
||||
args: [prototype, lowerer.coerceToExpected(key, DYN), receiver],
|
||||
type: DYN,
|
||||
loc,
|
||||
});
|
||||
// Past the last class prototype, the read continues to Object.prototype's data property.
|
||||
const next: IrExpr =
|
||||
base && !base.builtinError && base.decl && !base.generic && !base.localClass
|
||||
? this.inheritedConstructor(lowerer, base, key, receiver, loc)
|
||||
: { kind: "dynKeyGet", value: prototype, key, type: DYN, loc };
|
||||
return {
|
||||
kind: "ternary",
|
||||
cond: { kind: "libCall", fn: "dyn.hasOwn", args: [prototype, key], type: BOOL, loc },
|
||||
then: read(),
|
||||
else_: {
|
||||
kind: "ternary",
|
||||
cond: varRef(prototypeReflectedFlag(lowerer, info), BOOL, loc),
|
||||
then: next,
|
||||
else_: cls,
|
||||
type: DYN,
|
||||
loc,
|
||||
},
|
||||
type: DYN,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
/** The hints for which some boxed class declares a conversion method. A
|
||||
* string conversion calls toString first, so without a declared toString
|
||||
* or Symbol.toPrimitive it answers Object.prototype.toString's
|
||||
@@ -5938,15 +6036,19 @@ export class ClassDynamicDispatch {
|
||||
!info.classDecorators
|
||||
) {
|
||||
this.constructors.set(info.def.name, info);
|
||||
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN);
|
||||
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
|
||||
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
|
||||
// `constructor` is an own property of every class prototype, so an
|
||||
// ancestor's prototype answers for an instance of this class only
|
||||
// after the program deletes the class's own: the instance's own
|
||||
// property answers, then the class's prototype (inheritedConstructor).
|
||||
const answered = this.readBagHasOwn(lowerer, bag, key, loc);
|
||||
return [
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "ternary",
|
||||
cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc),
|
||||
cond: answered,
|
||||
then: {
|
||||
kind: "libCall",
|
||||
fn: "dyn.bagGet",
|
||||
@@ -5954,7 +6056,13 @@ export class ClassDynamicDispatch {
|
||||
type: DYN,
|
||||
loc,
|
||||
},
|
||||
else_: cls,
|
||||
else_: this.inheritedConstructor(
|
||||
lowerer,
|
||||
info,
|
||||
key,
|
||||
lowerer.coerceToExpected(receiver, DYN),
|
||||
loc,
|
||||
),
|
||||
type: DYN,
|
||||
loc,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type IrStmt,
|
||||
type SrcLoc,
|
||||
} from "../../ir/ir.js";
|
||||
import { locOf } from "../program.js";
|
||||
import { isJsSourceFile, locOf } from "../program.js";
|
||||
import {
|
||||
classMemberNameOf,
|
||||
classValueRef,
|
||||
@@ -299,6 +299,23 @@ function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** The global that is true once the class's prototype view has been
|
||||
* populated by reflection (reflectClassPrototype): the view then holds an
|
||||
* own `constructor` unless the program deleted it. A view that was never
|
||||
* reflected leaves `constructor` implicit. */
|
||||
export function prototypeReflectedFlag(lowerer: Lowerer, info: ClassInfo): string {
|
||||
if (info.prototypeReflectedFlag) return info.prototypeReflectedFlag;
|
||||
const id = `%g.%prototype.data.${info.def.name}.reflected`;
|
||||
lowerer.globalsList.push({
|
||||
id,
|
||||
name: `${info.def.jsName ?? info.def.name}.prototype.reflected`,
|
||||
type: BOOL,
|
||||
mutable: true,
|
||||
});
|
||||
info.prototypeReflectedFlag = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Populate the shared prototype view before exposing it to reflection.
|
||||
* Methods and accessors are non-enumerable and retain declaration identity. */
|
||||
export function reflectClassPrototype(
|
||||
@@ -399,6 +416,12 @@ export function reflectClassPrototype(
|
||||
{ key: "value", value: classValueRef(lowerer, info, info.decl) },
|
||||
writable,
|
||||
]),
|
||||
{
|
||||
kind: "assign",
|
||||
localId: prototypeReflectedFlag(lowerer, info),
|
||||
value: { kind: "boolLit", value: true, type: BOOL, loc },
|
||||
loc,
|
||||
},
|
||||
);
|
||||
const context = newFnCtx(false, null, null, DYN);
|
||||
lowerer.fnStack.push(context);
|
||||
@@ -503,6 +526,69 @@ export function isErrorPrototypeValue(
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a bare `C.prototype` read becomes the prototype of another object
|
||||
* or is only compared or inspected: the argument of `Object.create` or
|
||||
* `Object.getPrototypeOf` / `Reflect.getPrototypeOf`, the prototype argument
|
||||
* of `Object.setPrototypeOf` / `Reflect.setPrototypeOf`, or an operand of
|
||||
* `===` / `!==`. In these positions the complete view of the
|
||||
* prototype (reflectClassPrototype) answers. Objects that inherit from it
|
||||
* run the class's methods with themselves as the receiver, which the
|
||||
* methods' receiver-independent lowering (reflectedClassMethodValue)
|
||||
* supports. Other positions, such as `Object.setPrototypeOf(C.prototype, X)`,
|
||||
* which would relink the class, keep the diagnostic. */
|
||||
function prototypeViewPosition(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean {
|
||||
let node: ts.Node = expr;
|
||||
let parent = node.parent;
|
||||
while (parent && ts.isParenthesizedExpression(parent)) {
|
||||
node = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
if (!parent) return false;
|
||||
if (ts.isCallExpression(parent) && ts.isPropertyAccessExpression(parent.expression)) {
|
||||
const args = parent.arguments.nodes;
|
||||
const objectMethod = lowerer.stdlibGlobalMember(parent.expression, "Object");
|
||||
const reflectMethod = lowerer.stdlibGlobalMember(parent.expression, "Reflect");
|
||||
if (
|
||||
objectMethod === "create" ||
|
||||
objectMethod === "getPrototypeOf" ||
|
||||
reflectMethod === "getPrototypeOf"
|
||||
)
|
||||
return args[0] === node;
|
||||
if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf")
|
||||
return args[1] === node;
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ts.isBinaryExpression(parent) &&
|
||||
(parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
||||
parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken)
|
||||
);
|
||||
}
|
||||
|
||||
/** A module-level JavaScript class named directly (not a class value that
|
||||
* may hold a subclass at run time): its prototype has a complete view whose
|
||||
* methods accept any receiver. */
|
||||
function viewableClass(lowerer: Lowerer, info: ClassInfo, receiver: ts.Expression): boolean {
|
||||
return (
|
||||
info.decl !== null &&
|
||||
info.localClass === undefined &&
|
||||
isJsSourceFile(ts.getSourceFileOfNode(info.decl)) &&
|
||||
exactClassOfReceiver(lowerer, receiver) === info
|
||||
);
|
||||
}
|
||||
|
||||
/** The complete prototype view of the class `node` names, when it is a
|
||||
* viewable class (util.inherits's superCtor); null otherwise. */
|
||||
export function classPrototypeView(
|
||||
lowerer: Lowerer,
|
||||
node: ts.Expression,
|
||||
loc: SrcLoc,
|
||||
): IrExpr | null {
|
||||
const info = exactClassOfReceiver(lowerer, node);
|
||||
if (!info || !viewableClass(lowerer, info, node)) return null;
|
||||
return reflectClassPrototype(lowerer, info, loc, classValueRef(lowerer, info, node));
|
||||
}
|
||||
|
||||
/** Named data and method slots have native descriptors. Bare reflection and
|
||||
* accessor replacement still require a complete view of the prototype. */
|
||||
export function lowerClassPrototypeData(
|
||||
@@ -566,6 +652,12 @@ export function lowerClassPrototypeData(
|
||||
});
|
||||
return reflectClassPrototype(lowerer, info, locOf(expr), receiver);
|
||||
}
|
||||
if (
|
||||
name === null &&
|
||||
prototypeViewPosition(lowerer, expr) &&
|
||||
viewableClass(lowerer, info, expr.expression)
|
||||
)
|
||||
return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr));
|
||||
if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
import { isDateSetter } from "./builtins/dates.js";
|
||||
|
||||
/** Date methods whose receiver is only read for its time value: a Date
|
||||
* local used as their receiver can store the time value instead of a Date
|
||||
@@ -41,12 +42,15 @@ const RELATIONAL = new Set([
|
||||
|
||||
/** True when a local `const` (or a `let` that is never assigned again)
|
||||
* initialized with `new Date(...)` can keep the Date's time value instead
|
||||
* of a Date object: no use observes the Date's identity. Every use reads the time value as
|
||||
* a Date method receiver, a String() or template operand, a relational or
|
||||
* string `+` operand, or `new Date(d)`'s argument, inside the declaring
|
||||
* function, or inside the block or loop that scopes a module-level
|
||||
* binding (no closure captures it). Top-level module bindings keep the
|
||||
* object. Other uses box the time value into a new Date at the use. */
|
||||
* of a Date object: no use observes the Date's identity. Every use reads
|
||||
* the time value as a Date method receiver (a setter replaces it; the
|
||||
* arguments of a method that reads it call no setter on it), a
|
||||
* String(), Number() or template operand, a unary + or - operand, a
|
||||
* relational or string `+` operand, or `new Date(d)`'s argument, inside
|
||||
* the declaring function, or inside the block or loop that scopes a
|
||||
* module-level binding (no closure captures it). Top-level module bindings
|
||||
* keep the object. Other uses box the time value into a new Date at the
|
||||
* use. */
|
||||
export function isTimeValueDateDeclaration(
|
||||
lowerer: Lowerer,
|
||||
decl: ts.VariableDeclaration,
|
||||
@@ -94,17 +98,21 @@ function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): bo
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
|
||||
if (!symbol) return false;
|
||||
let ok = true;
|
||||
let sets = false;
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (!ok) return;
|
||||
if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) {
|
||||
if (lowerer.checker.getSymbolAtLocation(node) === symbol) {
|
||||
if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false;
|
||||
const use = enclosingFunction(node) === fn ? timeValueUse(lowerer, node, symbol) : null;
|
||||
if (use === null) ok = false;
|
||||
else if (use === "set") sets = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(scope);
|
||||
if (ok && sets) lowerer.timeValueDateWrites.add(decl);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -164,29 +172,75 @@ function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A use of the binding that reads only its time value. */
|
||||
function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
|
||||
/** How a use of the binding uses its time value: "read" for a use that
|
||||
* reads only the time value, "set" for a setter call that replaces it, or
|
||||
* null for any other use. */
|
||||
function timeValueUse(
|
||||
lowerer: Lowerer,
|
||||
id: ts.Identifier,
|
||||
symbol: ts.Symbol,
|
||||
): "read" | "set" | null {
|
||||
let node: ts.Node = id;
|
||||
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
|
||||
const parent = node.parent;
|
||||
if (parent === undefined) return null;
|
||||
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
||||
const call = parent.parent;
|
||||
return (
|
||||
!parent.questionDotToken &&
|
||||
ts.isCallExpression(call) &&
|
||||
call.expression === parent &&
|
||||
!call.questionDotToken &&
|
||||
TIME_VALUE_METHODS.has(parent.name.text) &&
|
||||
lowerer.isStdlibMember(parent)
|
||||
);
|
||||
if (
|
||||
parent.questionDotToken ||
|
||||
!ts.isCallExpression(call) ||
|
||||
call.expression !== parent ||
|
||||
call.questionDotToken ||
|
||||
!lowerer.isStdlibMember(parent)
|
||||
)
|
||||
return null;
|
||||
if (isDateSetter(parent.name.text)) return "set";
|
||||
// The method reads the time value after its arguments are evaluated:
|
||||
// a setter there (`d.toJSON(d.setTime(0))`) changes what it reads.
|
||||
if (call.arguments.nodes.some((arg) => callsSetterOf(lowerer, arg, symbol))) return null;
|
||||
return TIME_VALUE_METHODS.has(parent.name.text) ? "read" : null;
|
||||
}
|
||||
return readsTimeValue(lowerer, node, parent, symbol) ? "read" : null;
|
||||
}
|
||||
|
||||
/** True when `node` calls a setter on the binding `symbol` names. */
|
||||
function callsSetterOf(lowerer: Lowerer, node: ts.Node, symbol: ts.Symbol): boolean {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
isDateSetter(node.expression.name.text)
|
||||
) {
|
||||
let receiver = node.expression.expression;
|
||||
while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression;
|
||||
if (ts.isIdentifier(receiver) && lowerer.checker.getSymbolAtLocation(receiver) === symbol)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
ts.forEachChild(node, (child) => callsSetterOf(lowerer, child, symbol) || undefined) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** A use of the binding, other than a method call, that reads only its
|
||||
* time value. */
|
||||
function readsTimeValue(
|
||||
lowerer: Lowerer,
|
||||
node: ts.Node,
|
||||
parent: ts.Node,
|
||||
symbol: ts.Symbol,
|
||||
): boolean {
|
||||
if (ts.isTemplateSpan(parent)) return parent.expression === node;
|
||||
// Unary + and -, the number hint.
|
||||
if (ts.isPrefixUnaryExpression(parent))
|
||||
return (
|
||||
parent.operator === ts.SyntaxKind.PlusToken || parent.operator === ts.SyntaxKind.MinusToken
|
||||
);
|
||||
if (ts.isCallExpression(parent)) {
|
||||
return (
|
||||
parent.arguments.nodes.length === 1 &&
|
||||
parent.arguments.nodes[0] === node &&
|
||||
ts.isIdentifier(parent.expression) &&
|
||||
lowerer.isStdlibGlobal(parent.expression, "String")
|
||||
(lowerer.isStdlibGlobal(parent.expression, "String") ||
|
||||
lowerer.isStdlibGlobal(parent.expression, "Number"))
|
||||
);
|
||||
}
|
||||
if (ts.isNewExpression(parent)) {
|
||||
@@ -198,6 +252,9 @@ function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
|
||||
);
|
||||
}
|
||||
if (ts.isBinaryExpression(parent)) {
|
||||
// A left operand converts after the right one is evaluated: a setter
|
||||
// there changes the time value the comparison or concatenation reads.
|
||||
if (parent.left === node && callsSetterOf(lowerer, parent.right, symbol)) return false;
|
||||
const other = parent.left === node ? parent.right : parent.left;
|
||||
const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind;
|
||||
if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date";
|
||||
|
||||
@@ -1600,6 +1600,19 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
|
||||
loc,
|
||||
};
|
||||
}
|
||||
// A Date against an untyped value: the same Date object, whose
|
||||
// checked-dynamic handle the untyped value would hold.
|
||||
if (dynSide.type.kind === "dyn" && scalarSide.type.kind === "date") {
|
||||
const boxed = lowerer.coerceToExpected(scalarSide, DYN);
|
||||
return {
|
||||
kind: "dynScalarEq",
|
||||
left: scalarSide === left ? boxed : left,
|
||||
right: scalarSide === right ? boxed : right,
|
||||
...(negated ? { negated: true as const } : {}),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
if (
|
||||
dynSide.type.kind === "dyn" &&
|
||||
(scalarSide.type.kind === "f64" ||
|
||||
@@ -2024,6 +2037,28 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
|
||||
loc,
|
||||
};
|
||||
if (left.type.kind === "string" || right.type.kind === "string") {
|
||||
// A Date on the left converts after the right operand is
|
||||
// evaluated, which can run its setters.
|
||||
if (left.type.kind === "date" && !isSafeToRepeat(right)) {
|
||||
const date = lowerer.declareHiddenLocal("%plus", left.type);
|
||||
const other = lowerer.declareHiddenLocal("%plus", right.type);
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [
|
||||
{ kind: "varDecl", localId: date.id, init: left, loc },
|
||||
{ kind: "varDecl", localId: other.id, init: right, loc },
|
||||
],
|
||||
result: {
|
||||
kind: "strConcat",
|
||||
left: concatOperand(lowerer, varRef(date.id, left.type, loc), expr.left),
|
||||
right: concatOperand(lowerer, varRef(other.id, right.type, loc), expr.right),
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
type: STRING,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "strConcat",
|
||||
left: concatOperand(lowerer, left, expr.left),
|
||||
|
||||
@@ -112,6 +112,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../.
|
||||
import { typedArrayLacksMember } from "../builtins/typed-array-members.js";
|
||||
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
|
||||
import { tryLowerExpression } from "./try-lower-expression.js";
|
||||
import { DATE_METHOD_HINT } from "../builtins/dates.js";
|
||||
import {
|
||||
cjsClassExprWholeExportOf,
|
||||
cjsExportAssignmentOf,
|
||||
@@ -2055,6 +2056,9 @@ export function lowerIntrinsicProperty(
|
||||
);
|
||||
}
|
||||
if (kind === "date") {
|
||||
// A Date held as a checked-dynamic value (a Date a JavaScript file
|
||||
// constructs) answers its method values at run time.
|
||||
if (tryLowerExpression(lowerer, expr.expression)?.type.kind === "dyn") return null;
|
||||
const methods = new Set([
|
||||
"getTime",
|
||||
"valueOf",
|
||||
@@ -2083,7 +2087,7 @@ export function lowerIntrinsicProperty(
|
||||
lowerer.noLowering(
|
||||
`Date.prototype.${name}`,
|
||||
expr,
|
||||
"getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering",
|
||||
DATE_METHOD_HINT,
|
||||
lowerer.checker.getSymbolAtLocation(expr.name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ const NO_CLASS_INSTANCE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
|
||||
"nullT",
|
||||
]);
|
||||
|
||||
const PRIMITIVE_WRAPPERS = ["Boolean", "Number", "String", "BigInt", "Symbol"] as const;
|
||||
|
||||
/** `x instanceof C` for a program-declared class C. When x's static
|
||||
* class and C are both in extends-hierarchies the test is dynamic — the
|
||||
* O(1) preorder-interval check against the vtable (`instanceOf` node).
|
||||
@@ -187,6 +189,20 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
|
||||
loc,
|
||||
};
|
||||
}
|
||||
// The primitive wrapper constructors: a compiled program never creates a
|
||||
// boxed primitive (`new Boolean(...)` and friends have no lowering), and
|
||||
// a primitive is never an instance, so the test is false for every value
|
||||
// (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs.
|
||||
if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) {
|
||||
const value = lowerer.lowerExpr(expr.left);
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [{ kind: "exprStmt", expr: value, loc }],
|
||||
result: { kind: "boolLit", value: false, type: BOOL, loc },
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
if (lowerer.isStdlibGlobal(expr.right, "FinalizationRegistry")) {
|
||||
const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN);
|
||||
return { kind: "libCall", fn: "finalizationRegistry.is", args: [value], type: BOOL, loc };
|
||||
@@ -288,6 +304,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
|
||||
if (!target) {
|
||||
const date = lowerer.isStdlibGlobal(expr.right, "Date");
|
||||
const url = lowerer.isStdlibGlobal(expr.right, "URL");
|
||||
// A catch binding holds a thrown Date as its checked-dynamic handle.
|
||||
const caughtDate = date ? lowerer.caughtLocalOf(expr.left) : null;
|
||||
if (caughtDate)
|
||||
return {
|
||||
kind: "libCall",
|
||||
fn: "dyn.nativeDateIs",
|
||||
args: [{ kind: "caughtToDyn", value: varRef(caughtDate.id, CAUGHT, loc), type: DYN, loc }],
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
if ((date || url) && !lowerer.caughtLocalOf(expr.left)) {
|
||||
const value = lowerer.lowerExpr(expr.left);
|
||||
if (value.type.kind === "dyn")
|
||||
@@ -1902,6 +1928,30 @@ export function lowerInExpression(
|
||||
};
|
||||
}
|
||||
}
|
||||
// A child process has `send`, `disconnect` and `channel` only when it was
|
||||
// created with an IPC channel (fork, or an "ipc" stdio entry), and keeps
|
||||
// them after the channel closes: the runtime answers from the child.
|
||||
if (recv.type.kind === "child" && CHILD_CHANNEL_MEMBERS.has(key))
|
||||
return { kind: "libCall", fn: "child.hasChannel", args: [recv], type: BOOL, loc };
|
||||
// A native Node handle (an http.Server, a socket, a child process...):
|
||||
// a member Node's declarations give the class is on its prototype, so
|
||||
// `"closeAllConnections" in server` answers true. Undeclared names can
|
||||
// still be real (internal `_`-prefixed fields), so they keep the fence.
|
||||
if (
|
||||
NATIVE_HANDLE_KINDS.has(recv.type.kind) &&
|
||||
nodeDeclaredMember(lowerer, lowerer.typeOf(expr.right), key)
|
||||
) {
|
||||
const answer: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
|
||||
return isSafeToDiscard(recv)
|
||||
? answer
|
||||
: {
|
||||
kind: "seqExpr",
|
||||
stmts: [{ kind: "exprStmt", expr: recv, loc }],
|
||||
result: answer,
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
if (recv.type.kind !== "record") {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
@@ -2178,24 +2228,43 @@ function lowerRuntimeKeyIn(
|
||||
return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc };
|
||||
}
|
||||
|
||||
/** Standard-library constructors the untyped instanceof helper has no
|
||||
* instance test for: an untyped `x instanceof Object` always refuses. */
|
||||
const DYN_INSTANCEOF_REFUSED: ReadonlySet<string> = new Set(["Object", "Function"]);
|
||||
|
||||
/** The SC1090 message `x instanceof C` throws, with the site's position,
|
||||
* when `C` is a runtime constructor with no instance test. A right-hand
|
||||
* side that names one of DYN_INSTANCEOF_REFUSED always reaches that
|
||||
* refusal, so the site is listed with the runtime fences. */
|
||||
* when `C` is a runtime constructor with no instance test. */
|
||||
function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): string {
|
||||
const diag = unsupportedDiag("SC1090", locOf(expr), "instanceof against this native constructor");
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(expr.right);
|
||||
if (
|
||||
symbol !== undefined &&
|
||||
lowerer.isStdlibSymbol(symbol) &&
|
||||
DYN_INSTANCEOF_REFUSED.has(ts.symbolName(symbol))
|
||||
)
|
||||
lowerer.runtimeFences.push(diag);
|
||||
const sf = ts.getSourceFileOfNode(expr);
|
||||
const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1;
|
||||
return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`;
|
||||
}
|
||||
|
||||
/** IR kinds of native Node handles whose members come from Node's own
|
||||
* declarations. */
|
||||
const NATIVE_HANDLE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
|
||||
"netServer",
|
||||
"netSocket",
|
||||
"httpReq",
|
||||
"httpRes",
|
||||
"httpClientReq",
|
||||
"child",
|
||||
"dgramSocket",
|
||||
"fsWatcher",
|
||||
"http2Session",
|
||||
"http2Stream",
|
||||
]);
|
||||
|
||||
/** Child process members that exist only on a child with an IPC channel. */
|
||||
const CHILD_CHANNEL_MEMBERS: ReadonlySet<string> = new Set(["send", "disconnect", "channel"]);
|
||||
|
||||
/** Whether every constituent of `t` declares a required member `key` in
|
||||
* a declaration file (Node's @types/node). */
|
||||
function nodeDeclaredMember(lowerer: Lowerer, t: ts.Type, key: string): boolean {
|
||||
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
|
||||
return parts.every((part) => {
|
||||
const prop = lowerer.checker.getPropertyOfType(part, key);
|
||||
if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) !== 0) return false;
|
||||
return (
|
||||
prop.declarations.length > 0 &&
|
||||
prop.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors
|
||||
import { InternalCompilerError } from "../../../errors.js";
|
||||
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
|
||||
import { isTemporalGlobalRoot } from "../lower-namespaces.js";
|
||||
import { isF64HandleTyped } from "../f64-handles.js";
|
||||
|
||||
export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr {
|
||||
const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression);
|
||||
@@ -281,13 +282,18 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc
|
||||
promise: "object",
|
||||
bytes: "object",
|
||||
regex: "object",
|
||||
date: "object",
|
||||
generator: "object",
|
||||
classval: "function",
|
||||
moduleNs: "object",
|
||||
undefinedT: "undefined",
|
||||
nullT: "object",
|
||||
};
|
||||
const folded = FOLD[operand.type.kind];
|
||||
// An f64 handle (a readline Interface, a Timeout) is an object.
|
||||
const folded =
|
||||
operand.type.kind === "f64" && isF64HandleTyped(lowerer, expr.expression)
|
||||
? "object"
|
||||
: FOLD[operand.type.kind];
|
||||
if (folded) {
|
||||
const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc };
|
||||
return isSafeToDiscard(operand)
|
||||
|
||||
@@ -120,6 +120,11 @@ function coercedUnion(lowerer: Lowerer, type: IrType): boolean {
|
||||
return arms.some((arm) => arm.kind !== "f64" && !isUnitType(arm));
|
||||
}
|
||||
|
||||
/** ToNumber of a Date: its time value (valueOf, the number hint). */
|
||||
function dateTimeValue(date: IrExpr, loc: SrcLoc): IrExpr {
|
||||
return { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc };
|
||||
}
|
||||
|
||||
/** ToNumber of a boolean: 1 for true, 0 for false. */
|
||||
function booleanToNumber(value: IrExpr, loc: SrcLoc): IrExpr {
|
||||
return {
|
||||
@@ -145,23 +150,25 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
|
||||
const operand =
|
||||
raw.type.kind === "bool"
|
||||
? booleanToNumber(raw, loc)
|
||||
: coercedUnion(lowerer, raw.type)
|
||||
? ({
|
||||
kind: "libCall",
|
||||
fn: "dyn.toNumberCoerce",
|
||||
args: [{ kind: "dynFrom", value: raw, type: DYN, loc }],
|
||||
type: F64,
|
||||
loc,
|
||||
} satisfies IrExpr)
|
||||
: raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))
|
||||
: raw.type.kind === "date"
|
||||
? dateTimeValue(raw, loc)
|
||||
: coercedUnion(lowerer, raw.type)
|
||||
? ({
|
||||
kind: "libCall",
|
||||
fn: "dyn.toNumberCoerce",
|
||||
args: [raw],
|
||||
args: [{ kind: "dynFrom", value: raw, type: DYN, loc }],
|
||||
type: F64,
|
||||
loc,
|
||||
} satisfies IrExpr)
|
||||
: lowerOptionalNumber(lowerer, raw, loc, expr.operand);
|
||||
: raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))
|
||||
? ({
|
||||
kind: "libCall",
|
||||
fn: "dyn.toNumberCoerce",
|
||||
args: [raw],
|
||||
type: F64,
|
||||
loc,
|
||||
} satisfies IrExpr)
|
||||
: lowerOptionalNumber(lowerer, raw, loc, expr.operand);
|
||||
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]);
|
||||
if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc };
|
||||
return { kind: "unary", op: "-", operand, type: F64, loc };
|
||||
@@ -185,6 +192,8 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
|
||||
return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc };
|
||||
}
|
||||
if (raw.type.kind === "bool") return booleanToNumber(raw, loc);
|
||||
// A Date's number hint answers its time value.
|
||||
if (raw.type.kind === "date") return dateTimeValue(raw, loc);
|
||||
const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus");
|
||||
if (classNumber) return classNumber;
|
||||
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
|
||||
/** Whether `node`'s value is an f64 handle: an object whose class
|
||||
* type-mapper.ts represents as an f64 id into a runtime registry (a
|
||||
* readline Interface, a Timeout or Immediate, an AsyncLocalStorage, a
|
||||
* diagnostics_channel Channel). Its checker type is an object type that
|
||||
* maps to f64; the Number wrapper interface maps to f64 for primitives. */
|
||||
export function isF64HandleTyped(lowerer: Lowerer, node: ts.Node): boolean {
|
||||
const type = lowerer.checker.getTypeAtLocation(node);
|
||||
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
|
||||
const symbol = type.getSymbol();
|
||||
if (symbol !== undefined && ts.symbolName(symbol) === "Number") return false;
|
||||
return lowerer.mapTypeOf(type)?.kind === "f64";
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/* Types of untyped JavaScript collections that their uses prove.
|
||||
*
|
||||
* In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as
|
||||
* Map<any, any>, Set<any> and any[] to the checker, so every use lowers to
|
||||
* the checked-dynamic collection operations. When every reference of such a
|
||||
* `const` is a direct use of the collection, the value never leaves the
|
||||
* binding. If its keys, values or elements are then each one primitive type
|
||||
* wherever the program writes and looks them up, the binding lowers as the
|
||||
* typed Map<K, V>, Set<K> or T[], as a JSDoc @type would make it:
|
||||
*
|
||||
* - Map: `m.get(k)`, `m.has(k)`, `m.delete(k)`, `m.clear()`, `m.size`, and
|
||||
* `m.set(k, v)` as a statement (its result is the map).
|
||||
* - Set: `s.add(k)` as a statement, `s.has(k)`, `s.delete(k)`, `s.clear()`,
|
||||
* `s.size`.
|
||||
* - Array: `a.push(...)`, `a.pop()`, `a.shift()`, `a.at(i)`, `a.includes(v)`,
|
||||
* `a.indexOf(v)`, `a.join(separator)`, `a.length`.
|
||||
*
|
||||
* Any other reference (passing, returning, storing, exporting, iterating,
|
||||
* spreading, inspecting the collection, an optional call), a direct `eval`
|
||||
* in the file, or any doubt about a type keeps the binding untyped.
|
||||
*
|
||||
* The types come from carrier declarations in scriptc-overrides.d.ts, which
|
||||
* only the lowering program includes. */
|
||||
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import { isJsSourceFile } from "../program.js";
|
||||
import { overridesDtsPath } from "../dts-paths.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
|
||||
type Primitive = "string" | "number" | "boolean";
|
||||
const PRIMITIVES: readonly Primitive[] = ["string", "number", "boolean"];
|
||||
|
||||
type Collection = "map" | "set" | "array";
|
||||
|
||||
interface Candidate {
|
||||
readonly kind: Collection;
|
||||
readonly declaration: ts.VariableDeclaration;
|
||||
/** Every identifier that names the binding, its declaration's included. */
|
||||
readonly names: ts.Identifier[];
|
||||
/** Map keys or Set members written by set/add. */
|
||||
readonly keyWrites: ts.Expression[];
|
||||
/** Map values written by set. Array elements written by push. */
|
||||
readonly valueWrites: ts.Expression[];
|
||||
/** Arguments compared with keys or elements: get/has/delete, includes/indexOf. */
|
||||
readonly lookups: ts.Expression[];
|
||||
/** Array indexes passed to at(). */
|
||||
readonly indexes: ts.Expression[];
|
||||
/** Separators passed to join(). */
|
||||
readonly separators: ts.Expression[];
|
||||
key: Primitive | undefined;
|
||||
value: Primitive | undefined;
|
||||
}
|
||||
|
||||
const MAP_LOOKUPS = new Set(["get", "has", "delete"]);
|
||||
const SET_LOOKUPS = new Set(["has", "delete"]);
|
||||
const ARRAY_READS = new Set(["pop", "shift"]);
|
||||
const ARRAY_SEARCHES = new Set(["includes", "indexOf"]);
|
||||
/** The expression lowering refuses deeper nesting (SC1090). */
|
||||
const MAX_DEPTH = 200;
|
||||
|
||||
/** The inferred type of the binding a `new Map()` or `new Set()` initializes,
|
||||
* which the construction adopts. */
|
||||
export function initializedCollectionType(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.NewExpression,
|
||||
): ts.Type | null {
|
||||
const declaration = expr.parent;
|
||||
return declaration !== undefined &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
declaration.initializer === expr &&
|
||||
ts.isIdentifier(declaration.name)
|
||||
? inferredCollectionType(lowerer, declaration.name)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The inferred collection type of an identifier that names a proven
|
||||
* binding, or null. Computed for every JavaScript file of the program on
|
||||
* first use. */
|
||||
export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null {
|
||||
const types = (lowerer.inferredCollectionTypes ??= inferProgram(lowerer));
|
||||
return types.size === 0 ? null : (types.get(node) ?? null);
|
||||
}
|
||||
|
||||
function inferProgram(lowerer: Lowerer): Map<ts.Node, ts.Type> {
|
||||
const out = new Map<ts.Node, ts.Type>();
|
||||
const carriers = carrierTypes(lowerer);
|
||||
if (carriers === null) return out;
|
||||
for (const file of lowerer.program.getSourceFiles()) {
|
||||
if (file.isDeclarationFile || !isJsSourceFile(file)) continue;
|
||||
for (const candidate of inferFile(lowerer, file)) {
|
||||
const name =
|
||||
candidate.kind === "map"
|
||||
? `map_${candidate.key}_${candidate.value}`
|
||||
: candidate.kind === "set"
|
||||
? `set_${candidate.key}`
|
||||
: `array_${candidate.value}`;
|
||||
const type = carriers.get(name);
|
||||
if (type === undefined) continue;
|
||||
for (const id of candidate.names) out.set(id, type);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The carrier declarations' types, by name (`map_string_number`,
|
||||
* `set_number`, `array_string`, ...), or null when the lowering program does
|
||||
* not include them. */
|
||||
function carrierTypes(lowerer: Lowerer): Map<string, ts.Type> | null {
|
||||
const file = lowerer.program.getSourceFile(overridesDtsPath());
|
||||
if (file === undefined) return null;
|
||||
const out = new Map<string, ts.Type>();
|
||||
for (const statement of file.statements.nodes) {
|
||||
if (
|
||||
!ts.isModuleDeclaration(statement) ||
|
||||
!ts.isIdentifier(statement.name) ||
|
||||
statement.name.text !== "__scriptcInferredCollections" ||
|
||||
statement.body === undefined ||
|
||||
!ts.isModuleBlock(statement.body)
|
||||
)
|
||||
continue;
|
||||
for (const member of statement.body.statements.nodes) {
|
||||
if (!ts.isVariableStatement(member)) continue;
|
||||
for (const declaration of member.declarationList.declarations.nodes)
|
||||
if (ts.isIdentifier(declaration.name))
|
||||
out.set(declaration.name.text, lowerer.checker.getTypeAtLocation(declaration.name));
|
||||
}
|
||||
}
|
||||
return out.size > 0 ? out : null;
|
||||
}
|
||||
|
||||
function inferFile(lowerer: Lowerer, file: ts.SourceFile): Candidate[] {
|
||||
const candidates = new Map<ts.Symbol, Candidate>();
|
||||
let directEval = false;
|
||||
const declarations = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "eval"
|
||||
)
|
||||
directEval = true;
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
const kind = candidateKind(lowerer, node);
|
||||
const symbol = kind !== null ? lowerer.checker.getSymbolAtLocation(node.name) : undefined;
|
||||
if (kind !== null && symbol !== undefined)
|
||||
candidates.set(symbol, {
|
||||
kind,
|
||||
declaration: node,
|
||||
names: [node.name as ts.Identifier],
|
||||
keyWrites: [],
|
||||
valueWrites: [],
|
||||
lookups: [],
|
||||
indexes: [],
|
||||
separators: [],
|
||||
key: undefined,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
ts.walkPreorder(file, declarations);
|
||||
if (directEval || candidates.size === 0) return [];
|
||||
|
||||
const names = new Set(
|
||||
[...candidates.values()].map((c) => (c.declaration.name as ts.Identifier).text),
|
||||
);
|
||||
const refused = new Set<ts.Symbol>();
|
||||
const references = (node: ts.Node): void => {
|
||||
if (ts.isIdentifier(node) && names.has(node.text)) {
|
||||
const symbol = referencedSymbol(lowerer, node);
|
||||
const candidate = symbol !== undefined ? candidates.get(symbol) : undefined;
|
||||
if (symbol !== undefined && candidate !== undefined && node !== candidate.declaration.name) {
|
||||
if (recordUse(candidate, node)) candidate.names.push(node);
|
||||
else refused.add(symbol);
|
||||
}
|
||||
}
|
||||
};
|
||||
ts.walkPreorder(file, references);
|
||||
for (const symbol of refused) candidates.delete(symbol);
|
||||
const proven = [...candidates.values()].filter(
|
||||
(c) => c.valueWrites.length > 0 || c.keyWrites.length > 0,
|
||||
);
|
||||
solve(lowerer, proven);
|
||||
return proven.filter((c) =>
|
||||
c.kind === "map" ? c.key && c.value : c.kind === "set" ? c.key : c.value,
|
||||
);
|
||||
}
|
||||
|
||||
/** The collection a `const` declaration starts empty, when the checker
|
||||
* types it as an untyped Map, Set or array. */
|
||||
function candidateKind(lowerer: Lowerer, declaration: ts.VariableDeclaration): Collection | null {
|
||||
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) return null;
|
||||
const list = declaration.parent;
|
||||
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null;
|
||||
const statement = list.parent;
|
||||
if (
|
||||
ts.isVariableStatement(statement) &&
|
||||
ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
||||
)
|
||||
return null;
|
||||
const init = declaration.initializer;
|
||||
let kind: Collection | null = null;
|
||||
if (ts.isArrayLiteralExpression(init) && init.elements.nodes.length === 0) kind = "array";
|
||||
else if (
|
||||
ts.isNewExpression(init) &&
|
||||
init.typeArguments === undefined &&
|
||||
(init.arguments?.nodes.length ?? 0) === 0
|
||||
) {
|
||||
if (lowerer.isStdlibGlobal(init.expression, "Map")) kind = "map";
|
||||
else if (lowerer.isStdlibGlobal(init.expression, "Set")) kind = "set";
|
||||
}
|
||||
if (kind === null) return null;
|
||||
// A JSDoc type, or anything else the checker already knows, stands.
|
||||
const type = lowerer.checker.getTypeAtLocation(declaration.name);
|
||||
const args =
|
||||
kind === "array"
|
||||
? lowerer.checker.isArrayType(type)
|
||||
? lowerer.checker.getTypeArguments(type as ts.TypeReference)
|
||||
: []
|
||||
: lowerer.checker.getTypeArguments(type as ts.TypeReference);
|
||||
const arity = kind === "map" ? 2 : 1;
|
||||
if (args.length !== arity || !args.every((a) => (a.flags & ts.TypeFlags.Any) !== 0)) return null;
|
||||
return kind;
|
||||
}
|
||||
|
||||
function referencedSymbol(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined {
|
||||
const parent = id.parent;
|
||||
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id)
|
||||
return lowerer.checker.getShorthandAssignmentValueSymbol(parent);
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(id);
|
||||
// `export { m }` names the export, an alias of the local binding.
|
||||
if (
|
||||
symbol !== undefined &&
|
||||
parent !== undefined &&
|
||||
ts.isExportSpecifier(parent) &&
|
||||
parent.propertyName === undefined &&
|
||||
(symbol.flags & ts.SymbolFlags.Alias) !== 0
|
||||
)
|
||||
return lowerer.checker.getAliasedSymbol(symbol);
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/** Records a reference's use, or answers false when the reference is not
|
||||
* one of the collection uses this inference proves. */
|
||||
function recordUse(candidate: Candidate, id: ts.Identifier): boolean {
|
||||
const access = id.parent;
|
||||
if (
|
||||
access === undefined ||
|
||||
!ts.isPropertyAccessExpression(access) ||
|
||||
access.expression !== id ||
|
||||
access.questionDotToken !== undefined
|
||||
)
|
||||
return false;
|
||||
const name = access.name.text;
|
||||
const call = access.parent;
|
||||
if (call !== undefined && ts.isCallExpression(call) && call.expression === access) {
|
||||
if (call.questionDotToken !== undefined) return false;
|
||||
const args = call.arguments.nodes;
|
||||
if (args.some(ts.isSpreadElement)) return false;
|
||||
switch (candidate.kind) {
|
||||
case "map":
|
||||
if (MAP_LOOKUPS.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "clear") return args.length === 0;
|
||||
if (name === "set" && args.length === 2 && isStatement(call)) {
|
||||
candidate.keyWrites.push(args[0]!);
|
||||
candidate.valueWrites.push(args[1]!);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case "set":
|
||||
if (SET_LOOKUPS.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "clear") return args.length === 0;
|
||||
if (name === "add" && args.length === 1 && isStatement(call))
|
||||
return (candidate.keyWrites.push(args[0]!), true);
|
||||
return false;
|
||||
case "array":
|
||||
if (name === "push") return (candidate.valueWrites.push(...args), true);
|
||||
if (ARRAY_READS.has(name)) return args.length === 0;
|
||||
if (name === "at" && args.length === 1) return (candidate.indexes.push(args[0]!), true);
|
||||
if (ARRAY_SEARCHES.has(name) && args.length === 1)
|
||||
return (candidate.lookups.push(args[0]!), true);
|
||||
if (name === "join" && args.length <= 1) return (candidate.separators.push(...args), true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (candidate.kind === "array" ? name === "length" : name === "size") && isPlainRead(access);
|
||||
}
|
||||
|
||||
function isStatement(call: ts.CallExpression): boolean {
|
||||
let node: ts.Node = call;
|
||||
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
|
||||
return node.parent !== undefined && ts.isExpressionStatement(node.parent);
|
||||
}
|
||||
|
||||
/** A read that neither writes nor deletes the property. */
|
||||
function isPlainRead(access: ts.PropertyAccessExpression): boolean {
|
||||
let node: ts.Node = access;
|
||||
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
|
||||
const parent = node.parent;
|
||||
if (parent === undefined) return false;
|
||||
if (
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.left === node &&
|
||||
isAssignment(parent.operatorToken.kind)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
|
||||
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
||||
parent.operator === ts.SyntaxKind.MinusMinusToken)
|
||||
)
|
||||
return false;
|
||||
return !ts.isDeleteExpression(parent);
|
||||
}
|
||||
|
||||
function isAssignment(kind: ts.SyntaxKind): boolean {
|
||||
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
|
||||
}
|
||||
|
||||
/** Decides each candidate's key and value types: a fixed point in which a
|
||||
* decided candidate's reads type the expressions built from them. */
|
||||
function solve(lowerer: Lowerer, candidates: Candidate[]): void {
|
||||
const bySymbol = new Map<ts.Symbol, Candidate>();
|
||||
for (const c of candidates) {
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(c.declaration.name);
|
||||
if (symbol) bySymbol.set(symbol, c);
|
||||
}
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const c of candidates) {
|
||||
if (c.kind !== "array" && c.key === undefined) {
|
||||
const key = uniform(lowerer, bySymbol, c.keyWrites);
|
||||
if (key !== null) {
|
||||
c.key = key;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (c.kind !== "set" && c.value === undefined) {
|
||||
// Each candidate type is assumed for this collection's own reads.
|
||||
const accepted = PRIMITIVES.filter((assumed) => {
|
||||
c.value = assumed;
|
||||
const found = uniform(lowerer, bySymbol, c.valueWrites);
|
||||
c.value = undefined;
|
||||
return found === assumed;
|
||||
});
|
||||
if (accepted.length === 1) {
|
||||
c.value = accepted[0];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const element = c.kind === "array" ? c.value : c.key;
|
||||
// Lookups compare with the stored type; an index is a number.
|
||||
const lookupsAgree =
|
||||
element !== undefined &&
|
||||
c.lookups.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === element) &&
|
||||
c.indexes.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "number") &&
|
||||
c.separators.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "string");
|
||||
if (!lookupsAgree) {
|
||||
c.key = undefined;
|
||||
c.value = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one primitive type of every expression, or null. */
|
||||
function uniform(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
exprs: readonly ts.Expression[],
|
||||
): Primitive | null {
|
||||
let found: Primitive | null = null;
|
||||
for (const e of exprs) {
|
||||
const p = primitiveOf(lowerer, bySymbol, e, new Set());
|
||||
if (p === null || (found !== null && p !== found)) return null;
|
||||
found = p;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function checkerPrimitive(type: ts.Type): Primitive | null {
|
||||
const flags = type.flags;
|
||||
if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) return "string";
|
||||
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) return "number";
|
||||
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The primitive type `e` always evaluates to, or null. The checker answers
|
||||
* where it knows; where it types `e` as `any`, the value's structure does:
|
||||
* a read of a decided candidate, arithmetic and comparisons over such
|
||||
* values, and constants defined from them. An expression nested deeper than
|
||||
* the lowering's nesting limit answers null. */
|
||||
function primitiveOf(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
visiting: Set<ts.Node>,
|
||||
depth = 0,
|
||||
): Primitive | null {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
const type = lowerer.checker.getTypeAtLocation(e);
|
||||
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return checkerPrimitive(type);
|
||||
const of = (x: ts.Expression): Primitive | null =>
|
||||
primitiveOf(lowerer, bySymbol, x, visiting, depth + 1);
|
||||
if (ts.isParenthesizedExpression(e)) return of(e.expression);
|
||||
if (ts.isPrefixUnaryExpression(e)) {
|
||||
if (e.operator === ts.SyntaxKind.ExclamationToken) return "boolean";
|
||||
return e.operator !== ts.SyntaxKind.PlusPlusToken &&
|
||||
e.operator !== ts.SyntaxKind.MinusMinusToken &&
|
||||
of(e.operand) === "number"
|
||||
? "number"
|
||||
: null;
|
||||
}
|
||||
if (ts.isConditionalExpression(e)) {
|
||||
const yes = of(e.whenTrue);
|
||||
return yes !== null && yes === of(e.whenFalse) ? yes : null;
|
||||
}
|
||||
if (ts.isBinaryExpression(e)) {
|
||||
const op = e.operatorToken.kind;
|
||||
switch (op) {
|
||||
case ts.SyntaxKind.LessThanToken:
|
||||
case ts.SyntaxKind.GreaterThanToken:
|
||||
case ts.SyntaxKind.LessThanEqualsToken:
|
||||
case ts.SyntaxKind.GreaterThanEqualsToken:
|
||||
case ts.SyntaxKind.EqualsEqualsEqualsToken:
|
||||
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
|
||||
case ts.SyntaxKind.EqualsEqualsToken:
|
||||
case ts.SyntaxKind.ExclamationEqualsToken:
|
||||
return "boolean";
|
||||
case ts.SyntaxKind.QuestionQuestionToken:
|
||||
case ts.SyntaxKind.BarBarToken: {
|
||||
// `m.get(k) ?? d` and `a.pop() || d`: the stored type or a default of it.
|
||||
const stored = storedRead(lowerer, bySymbol, e.left);
|
||||
const fallback = of(e.right);
|
||||
return stored !== null && stored === fallback ? stored : null;
|
||||
}
|
||||
case ts.SyntaxKind.PlusToken: {
|
||||
const left = of(e.left);
|
||||
const right = of(e.right);
|
||||
if (left === "number" && right === "number") return "number";
|
||||
return left !== null && right !== null && (left === "string" || right === "string")
|
||||
? "string"
|
||||
: null;
|
||||
}
|
||||
case ts.SyntaxKind.MinusToken:
|
||||
case ts.SyntaxKind.AsteriskToken:
|
||||
case ts.SyntaxKind.SlashToken:
|
||||
case ts.SyntaxKind.PercentToken:
|
||||
case ts.SyntaxKind.AsteriskAsteriskToken:
|
||||
case ts.SyntaxKind.BarToken:
|
||||
case ts.SyntaxKind.AmpersandToken:
|
||||
case ts.SyntaxKind.CaretToken:
|
||||
case ts.SyntaxKind.LessThanLessThanToken:
|
||||
case ts.SyntaxKind.GreaterThanGreaterThanToken:
|
||||
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
|
||||
return of(e.left) === "number" && of(e.right) === "number" ? "number" : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(e) || ts.isPropertyAccessExpression(e))
|
||||
return candidateRead(lowerer, bySymbol, e);
|
||||
// A constant defined from such an expression.
|
||||
if (ts.isIdentifier(e) && !visiting.has(e)) {
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(e);
|
||||
const declaration = symbol?.valueDeclaration;
|
||||
if (
|
||||
declaration !== undefined &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
declaration.initializer !== undefined &&
|
||||
ts.isVariableDeclarationList(declaration.parent) &&
|
||||
(declaration.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
) {
|
||||
visiting.add(e);
|
||||
const p = primitiveOf(lowerer, bySymbol, declaration.initializer, visiting, depth + 1);
|
||||
visiting.delete(e);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The candidate a use reads, when `e` is `c.member` or `c.member(...)` of a
|
||||
* candidate `c`. */
|
||||
function candidateOf(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): { candidate: Candidate; member: string } | null {
|
||||
const access = ts.isCallExpression(e) ? e.expression : e;
|
||||
if (!ts.isPropertyAccessExpression(access) || !ts.isIdentifier(access.expression)) return null;
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(access.expression);
|
||||
const candidate = symbol !== undefined ? bySymbol.get(symbol) : undefined;
|
||||
return candidate !== undefined ? { candidate, member: access.name.text } : null;
|
||||
}
|
||||
|
||||
/** The type of a candidate's read that may answer undefined: `m.get(k)`,
|
||||
* `a.pop()`, `a.shift()`, `a.at(i)`. */
|
||||
function storedRead(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): Primitive | null {
|
||||
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
||||
if (!ts.isCallExpression(e)) return null;
|
||||
const read = candidateOf(lowerer, bySymbol, e);
|
||||
if (read === null) return null;
|
||||
const { candidate, member } = read;
|
||||
if (candidate.kind === "map" && member === "get") return candidate.value ?? null;
|
||||
if (candidate.kind === "array" && (ARRAY_READS.has(member) || member === "at"))
|
||||
return candidate.value ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The type of a candidate's read that always answers a value: size,
|
||||
* length, has, includes, indexOf, push. */
|
||||
function candidateRead(
|
||||
lowerer: Lowerer,
|
||||
bySymbol: Map<ts.Symbol, Candidate>,
|
||||
e: ts.Expression,
|
||||
): Primitive | null {
|
||||
const read = candidateOf(lowerer, bySymbol, e);
|
||||
if (read === null) return null;
|
||||
const { candidate, member } = read;
|
||||
if (!ts.isCallExpression(e)) return member === "size" || member === "length" ? "number" : null;
|
||||
if (member === "has" || member === "includes" || member === "delete") return "boolean";
|
||||
if (member === "indexOf" || (candidate.kind === "array" && member === "push")) return "number";
|
||||
if (candidate.kind === "array" && member === "join") return "string";
|
||||
return null;
|
||||
}
|
||||
@@ -51,11 +51,15 @@ import {
|
||||
STRING,
|
||||
type SrcLoc,
|
||||
VOID,
|
||||
isDynTypedRefType,
|
||||
isUnitType,
|
||||
typeEquals,
|
||||
typeKey,
|
||||
} from "../../ir/ir.js";
|
||||
import { boolLit, countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js";
|
||||
import { everyExpr } from "../../ir/traverse.js";
|
||||
import { checkedClassInstanceOf } from "./class-construction.js";
|
||||
import { isJsSourceFile } from "../program.js";
|
||||
|
||||
/** node:assert/strict binds the loose NAMES to the strict comparisons —
|
||||
* Node's own aliasing (`strict.equal === assert.strictEqual`). */
|
||||
@@ -73,6 +77,63 @@ function canonicalAssertMember(module: string, member: string): string {
|
||||
return member;
|
||||
}
|
||||
|
||||
/** The assert methods that are function values (scr_assert_method_value). */
|
||||
const ASSERT_FUNCTION_VALUES: ReadonlySet<string> = new Set([
|
||||
"ok",
|
||||
"fail",
|
||||
"equal",
|
||||
"notEqual",
|
||||
"strictEqual",
|
||||
"notStrictEqual",
|
||||
"deepEqual",
|
||||
"notDeepEqual",
|
||||
"deepStrictEqual",
|
||||
"notDeepStrictEqual",
|
||||
"partialDeepStrictEqual",
|
||||
"ifError",
|
||||
]);
|
||||
|
||||
/** The assert method a `const` binding holds (`const ok = assert.ok`,
|
||||
* `const eq = strictEqual`), or null. */
|
||||
export function assertFunctionAliasOf(
|
||||
lowerer: Lowerer,
|
||||
ident: ts.Identifier,
|
||||
): { module: string; member: string } | null {
|
||||
const decl = lowerer.checker.getSymbolAtLocation(ident)?.valueDeclaration;
|
||||
if (
|
||||
!decl ||
|
||||
!ts.isVariableDeclaration(decl) ||
|
||||
!ts.isIdentifier(decl.name) ||
|
||||
!ts.isVariableDeclarationList(decl.parent) ||
|
||||
(decl.parent.flags & ts.NodeFlags.Const) === 0 ||
|
||||
!decl.initializer
|
||||
)
|
||||
return null;
|
||||
let init = decl.initializer;
|
||||
while (ts.isParenthesizedExpression(init)) init = init.expression;
|
||||
const bi = ts.isPropertyAccessExpression(init)
|
||||
? lowerer.builtinMemberOf(init)
|
||||
: ts.isIdentifier(init)
|
||||
? lowerer.builtinImportOf(init)
|
||||
: null;
|
||||
if (!bi || (bi.module !== "assert" && bi.module !== "assert/strict")) return null;
|
||||
return ASSERT_FUNCTION_VALUES.has(canonicalAssertMember(bi.module, bi.member)) ? bi : null;
|
||||
}
|
||||
|
||||
/** An assert method read as a value (`emitter.on("x", assert.fail)`,
|
||||
* `const eq = assert.strictEqual`): an untyped function that asserts as
|
||||
* the method does, the same value for every read. Null for other
|
||||
* members. */
|
||||
export function assertFunctionValue(
|
||||
bi: { module: string; member: string },
|
||||
loc: SrcLoc,
|
||||
): IrExpr | null {
|
||||
if (bi.module !== "assert" && bi.module !== "assert/strict") return null;
|
||||
const member = canonicalAssertMember(bi.module, bi.member);
|
||||
if (!ASSERT_FUNCTION_VALUES.has(member)) return null;
|
||||
return { kind: "libCall", fn: "assert.methodValue", args: [strLit(member, loc)], type: DYN, loc };
|
||||
}
|
||||
|
||||
/* Node's AssertionError carries actual, expected and operator. Where the
|
||||
* verdict is computed in compiled code, the failure branch records them
|
||||
* (assert.record) before the throwing call, so a passing assertion boxes
|
||||
@@ -166,28 +227,156 @@ function requireStatementPosition(lowerer: Lowerer, call: ts.CallExpression, wha
|
||||
);
|
||||
}
|
||||
|
||||
/** The message the runtime reads from a registered value
|
||||
* (SCR_ASSERT_VALUE_MESSAGE in scr_assert.h). */
|
||||
const VALUE_MESSAGE = "\u0001scriptc:assert-message-value";
|
||||
|
||||
/** A message argument that is not a string, met while one assertion
|
||||
* lowers (withValueMessage). `fail` marks assert.fail's message, which
|
||||
* Node uses for every value but null and undefined. */
|
||||
let valueMessage: { value: IrExpr; has: string; text: string; fail: boolean } | null = null;
|
||||
|
||||
/** The optional trailing message argument: Node accepts a string or an
|
||||
* Error (throwing the Error itself) — the STRING form lowers; a literal
|
||||
* `undefined` counts as omitted (Node's `message == null` test). Returns
|
||||
* the libCall's (msg, hasMsg) pair — a "" dummy when omitted, so the
|
||||
* runtime can distinguish an omitted message from an empty one. */
|
||||
* runtime can distinguish an omitted message from an empty one. Any other
|
||||
* value that crosses into the checked-dynamic tree (an untyped
|
||||
* `failures.join('')`, an Error) is registered with the runtime at the call
|
||||
* instead (withValueMessage). */
|
||||
function lowerMessageArg(
|
||||
lowerer: Lowerer,
|
||||
node: ts.Expression | undefined,
|
||||
loc: SrcLoc,
|
||||
fail = false,
|
||||
): { msg: IrExpr; hasMsg: IrExpr } {
|
||||
if (!node || (ts.isIdentifier(node) && node.text === "undefined")) {
|
||||
return { msg: { kind: "strLit", value: "", type: STRING, loc }, hasMsg: boolLit(false, loc) };
|
||||
}
|
||||
const msg = lowerer.lowerExpr(node);
|
||||
if (msg.type.kind !== "string") {
|
||||
if (msg.type.kind === "string") return { msg, hasMsg: boolLit(true, loc) };
|
||||
const value =
|
||||
valueMessage === null && (msg.type.kind === "dyn" || lowerer.dynConvertible(msg.type))
|
||||
? lowerer.coerceToExpected(msg, DYN)
|
||||
: null;
|
||||
if (value === null || value.type.kind !== "dyn") {
|
||||
lowerer.noLowering(
|
||||
`assert messages of type '${lowerer.fmt(msg.type)}'`,
|
||||
node,
|
||||
"only string messages lower (Node also accepts an Error to throw — construct and throw it directly)",
|
||||
);
|
||||
}
|
||||
return { msg, hasMsg: boolLit(true, loc) };
|
||||
const has = lowerer.declareHiddenLocal("assert.hasMessage", BOOL);
|
||||
const text = lowerer.declareHiddenLocal("assert.messageText", STRING);
|
||||
valueMessage = { value, has: has.id, text: text.id, fail };
|
||||
return { msg: varRef(text.id, STRING, loc), hasMsg: varRef(has.id, BOOL, loc) };
|
||||
}
|
||||
|
||||
/** Lowers one assertion; a message value met on the way is evaluated after
|
||||
* the assertion's other arguments, as Node evaluates them. A null or
|
||||
* undefined message is omitted (Node's `message == null` test); a truthy
|
||||
* one is registered with the runtime, which the failure reads; any other
|
||||
* value is the empty message, which Node's comparison diffs treat as no
|
||||
* message too (createErrDiff tests the message's truthiness). */
|
||||
function withValueMessage(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.CallExpression,
|
||||
loc: SrcLoc,
|
||||
lower: () => IrExpr | null,
|
||||
): IrExpr | null {
|
||||
const outer = valueMessage;
|
||||
valueMessage = null;
|
||||
try {
|
||||
const lowered = lower();
|
||||
const message = valueMessage as {
|
||||
value: IrExpr;
|
||||
has: string;
|
||||
text: string;
|
||||
fail: boolean;
|
||||
} | null;
|
||||
if (lowered === null || message === null) return lowered;
|
||||
// assert.ok's statements evaluate the value first; its call is last.
|
||||
const before = lowered.kind === "seqExpr" ? lowered.stmts : [];
|
||||
const call = lowered.kind === "seqExpr" ? lowered.result : lowered;
|
||||
if (call.kind !== "libCall") {
|
||||
lowerer.noLowering(
|
||||
"assert messages that are not strings in this assertion form",
|
||||
expr,
|
||||
"pass a string message",
|
||||
);
|
||||
}
|
||||
const stmts: IrStmt[] = [...before];
|
||||
// An argument computed from the message (assert.fail's generated flag)
|
||||
// reads it after the declarations below; the others are evaluated first.
|
||||
const readsMessage = (arg: IrExpr): boolean =>
|
||||
!everyExpr(arg, {
|
||||
stmt: () => true,
|
||||
expr: (e) =>
|
||||
!(e.kind === "varRef" && (e.localId === message.has || e.localId === message.text)),
|
||||
});
|
||||
const args = call.args.map((arg, index) =>
|
||||
readsMessage(arg) ? arg : stableValue(lowerer, stmts, arg, `assert.arg${index}`, loc),
|
||||
);
|
||||
const local = lowerer.declareHiddenLocal("assert.message", DYN);
|
||||
const value = varRef(local.id, DYN, loc);
|
||||
// The runtime reports a registered value: a truthy one for the other
|
||||
// methods, any value but null and undefined for assert.fail (Node's
|
||||
// `actual == null` test), whose other values read "Failed".
|
||||
const registered: IrExpr = message.fail
|
||||
? varRef(message.has, BOOL, loc)
|
||||
: { kind: "dynTest", test: "truthy", value, type: BOOL, loc };
|
||||
stmts.push(
|
||||
{ kind: "varDecl", localId: local.id, init: message.value, loc },
|
||||
{
|
||||
kind: "varDecl",
|
||||
localId: message.has,
|
||||
init: { kind: "dynTest", test: "nullish", negated: true, value, type: BOOL, loc },
|
||||
loc,
|
||||
},
|
||||
{
|
||||
kind: "varDecl",
|
||||
localId: message.text,
|
||||
init: {
|
||||
kind: "ternary",
|
||||
cond: registered,
|
||||
then: strLit(VALUE_MESSAGE, loc),
|
||||
else_: strLit(message.fail ? "Failed" : "", loc),
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
{
|
||||
kind: "if",
|
||||
cond: registered,
|
||||
then: [
|
||||
{
|
||||
kind: "exprStmt",
|
||||
expr: {
|
||||
kind: "libCall",
|
||||
fn: "assert.messageValue",
|
||||
args: [value, { kind: "dynTest", test: "error", value, type: BOOL, loc }],
|
||||
type: VOID,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
else_: null,
|
||||
loc,
|
||||
},
|
||||
{ kind: "exprStmt", expr: { ...call, args }, loc },
|
||||
);
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts,
|
||||
result: { kind: "libCall", fn: "assert.messageClear", args: [], type: VOID, loc },
|
||||
type: VOID,
|
||||
loc,
|
||||
};
|
||||
} finally {
|
||||
valueMessage = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/** The module-function dispatch — the spoke's entry, called from both the
|
||||
@@ -201,6 +390,15 @@ export function lowerAssertModuleCall(
|
||||
loc: SrcLoc,
|
||||
): IrExpr | null {
|
||||
if (bi.module !== "assert" && bi.module !== "assert/strict") return null;
|
||||
return withValueMessage(lowerer, expr, loc, () => lowerAssertMember(lowerer, expr, bi, loc));
|
||||
}
|
||||
|
||||
function lowerAssertMember(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.CallExpression,
|
||||
bi: { module: string; member: string },
|
||||
loc: SrcLoc,
|
||||
): IrExpr | null {
|
||||
switch (canonicalAssertMember(bi.module, bi.member)) {
|
||||
case "ok":
|
||||
return lowerAssertOk(lowerer, expr, loc);
|
||||
@@ -880,7 +1078,7 @@ export function lowerAssertDirectCall(
|
||||
'use the default import: import assert from "node:assert")',
|
||||
);
|
||||
}
|
||||
return lowerAssertOk(lowerer, expr, loc);
|
||||
return withValueMessage(lowerer, expr, loc, () => lowerAssertOk(lowerer, expr, loc));
|
||||
}
|
||||
|
||||
/** assert(value[, message]) / assert.ok(value[, message]): the truthiness
|
||||
@@ -889,7 +1087,24 @@ export function lowerAssertDirectCall(
|
||||
* the same bytes at compile time). */
|
||||
function lowerAssertOk(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr {
|
||||
requireStatementPosition(lowerer, expr, ts.getTextOfNode(expr.expression));
|
||||
if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) {
|
||||
if (expr.arguments.nodes.length === 0) {
|
||||
// Node's message for a call without arguments.
|
||||
const text = "No value argument passed to `assert.ok()`";
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [recordOnFailure(lowerer, boolLit(true, loc), null, boolLit(true, loc), "==", loc)],
|
||||
result: {
|
||||
kind: "libCall",
|
||||
fn: "assert.ok",
|
||||
args: [boolLit(false, loc), strLit(text, loc), boolLit(true, loc)],
|
||||
type: VOID,
|
||||
loc,
|
||||
},
|
||||
type: VOID,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
if (expr.arguments.nodes.length > 2) {
|
||||
lowerer.noLowering(
|
||||
`${ts.getTextOfNode(expr.expression)} with ${expr.arguments.nodes.length} arguments`,
|
||||
expr,
|
||||
@@ -953,22 +1168,26 @@ function lowerAssertFail(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc)
|
||||
generated = boolLit(true, loc);
|
||||
} else {
|
||||
// Node's AssertionError sets generatedMessage to !message, so an
|
||||
// empty custom message counts as generated.
|
||||
msg = stableValue(
|
||||
lowerer,
|
||||
stmts,
|
||||
lowerMessageArg(lowerer, msgNode, loc).msg,
|
||||
"%assertFailMessage",
|
||||
loc,
|
||||
);
|
||||
generated = {
|
||||
kind: "strEq",
|
||||
negated: false,
|
||||
left: msg,
|
||||
right: strLit("", loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
// empty custom message counts as generated. A message that is not a
|
||||
// string is read from the hidden local that withValueMessage declares
|
||||
// after these statements, so it is used as is.
|
||||
const { msg: lowered, hasMsg } = lowerMessageArg(lowerer, msgNode, loc, true);
|
||||
if (lowered.kind === "varRef" && hasMsg.kind === "varRef") {
|
||||
// A value message: generated when Node reads "Failed" for null or
|
||||
// undefined; the runtime sets the flag for a registered value.
|
||||
msg = lowered;
|
||||
generated = { kind: "unary", op: "!", operand: hasMsg, type: BOOL, loc };
|
||||
} else {
|
||||
msg = stableValue(lowerer, stmts, lowered, "%assertFailMessage", loc);
|
||||
generated = {
|
||||
kind: "strEq",
|
||||
negated: false,
|
||||
left: msg,
|
||||
right: strLit("", loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
}
|
||||
const falseLit: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
|
||||
stmts.push(recordOnFailure(lowerer, boolLit(true, loc), null, null, "fail", loc));
|
||||
@@ -1056,9 +1275,22 @@ function lowerAssertEqual(
|
||||
// would answer false where Node can answer true (the value's own
|
||||
// earlier crossing) — a miscompare, never lowered. Bytes fence both
|
||||
// ways (the dyn copy cannot carry the Buffer/Uint8Array brand the
|
||||
// checker type knows); class instances and everything non-convertible
|
||||
// keep the fence too.
|
||||
if (a.type.kind === "dyn" || b.type.kind === "dyn") {
|
||||
// checker type knows); everything non-convertible keeps the fence too.
|
||||
// A class instance crosses as a reference to the instance itself, so
|
||||
// strict compares are honest reference equality, between two instances
|
||||
// too (assert.strictEqual(e, e.setMaxListeners(1))).
|
||||
const instanceType = (t: IrType): boolean =>
|
||||
isDynTypedRefType(t) && !lowerer.isSubclassOf(t.className, "%Error");
|
||||
const instance = (e: IrExpr): boolean =>
|
||||
!deep &&
|
||||
(instanceType(e.type) ||
|
||||
(e.type.kind === "union" &&
|
||||
(lowerer.unions
|
||||
.get(e.type.unionId)
|
||||
?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ??
|
||||
false))) &&
|
||||
lowerer.dynConvertible(e.type);
|
||||
if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) {
|
||||
const box = (e: IrExpr, node: ts.Expression): IrExpr => {
|
||||
if (e.type.kind === "dyn") return e;
|
||||
const k = e.type.kind;
|
||||
@@ -1067,6 +1299,18 @@ function lowerAssertEqual(
|
||||
k === "f64" ||
|
||||
k === "string" ||
|
||||
k === "bool" ||
|
||||
// A union of scalars and units crosses by value, like its arms.
|
||||
(e.type.kind === "union" &&
|
||||
(lowerer.unions
|
||||
.get(e.type.unionId)
|
||||
?.arms.every(
|
||||
(arm) =>
|
||||
arm.kind === "f64" ||
|
||||
arm.kind === "string" ||
|
||||
arm.kind === "bool" ||
|
||||
isUnitType(arm),
|
||||
) ??
|
||||
false)) ||
|
||||
(k === "func" && lowerer.dynConvertible(e.type)) ||
|
||||
// Runtime handles cross by REFERENCE (identity preserved), so the
|
||||
// strict compare is honest: assert.strictEqual(this, server) —
|
||||
@@ -1082,6 +1326,9 @@ function lowerAssertEqual(
|
||||
// same). The deep walk compares the retained error data without
|
||||
// re-encoding and losing identity (SEMANTICS.md).
|
||||
(k === "object" && e.type.kind === "object" && e.type.className === "%Error") ||
|
||||
instance(e) ||
|
||||
// A symbol crosses as itself, so strict compares are identity.
|
||||
(!deep && k === "symbol") ||
|
||||
(deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) ||
|
||||
// Under skipPrototype the prototype a class instance or Buffer
|
||||
// brand would carry is not compared.
|
||||
@@ -1470,17 +1717,29 @@ function classifyThrowsExpected(
|
||||
displayName: rec ? rec.lib : info.def.name.replace(/^%/, ""),
|
||||
};
|
||||
}
|
||||
if (
|
||||
(surface === "assert.doesNotThrow" || surface === "assert.doesNotReject") &&
|
||||
expectedT?.kind === "func"
|
||||
) {
|
||||
if (expectedT?.kind === "func") {
|
||||
const value = lowerer.lowerExpr(node);
|
||||
// An untyped value there (a dynamic-tier function's result) is checked
|
||||
// at run time like any untyped expected value.
|
||||
if (value.type.kind === "jsval" || value.type.kind === "dyn") {
|
||||
const boxed = lowerer.coerceToExpected(value, DYN);
|
||||
if (boxed.type.kind === "dyn") return { form: "errValue", value: boxed };
|
||||
}
|
||||
// A JavaScript function accepts any argument; a typed one must take
|
||||
// unknown values (the caught value need not be an Error).
|
||||
if (
|
||||
value.type.kind === "func" &&
|
||||
value.type.params.every((param) => param.kind === "dyn") &&
|
||||
(value.type.params.every((param) => param.kind === "dyn") ||
|
||||
isJsSourceFile(ts.getSourceFileOfNode(node))) &&
|
||||
lowerer.dynConvertible(value.type)
|
||||
) {
|
||||
return { form: "predicate", value: lowerer.coerceToExpected(value, DYN) };
|
||||
// The boxed function carries its source name (Node's message names
|
||||
// a named validation function).
|
||||
const fnName = jsFuncNameOf(node);
|
||||
return {
|
||||
form: "predicate",
|
||||
value: { kind: "dynFrom", value, ...(fnName !== null ? { fnName } : {}), type: DYN, loc },
|
||||
};
|
||||
}
|
||||
lowerer.noLowering(
|
||||
`${surface} with this validation callback`,
|
||||
@@ -1546,7 +1805,9 @@ function classifyThrowsExpected(
|
||||
// tracingChannel suite's shape): the value crosses as a dyn error and
|
||||
// the runtime compares per Node's expectsError key walk. A
|
||||
// checked-dynamic expected rides directly; a static %Error boxes.
|
||||
if (expectedT?.kind === "dyn") {
|
||||
// An untyped JavaScript value (a parameter) too: a function there is
|
||||
// checked at run time (assertThrowsHelper).
|
||||
if (expectedT?.kind === "dyn" || expectedT === null) {
|
||||
const value = lowerer.lowerExpr(node);
|
||||
if (value.type.kind === "dyn") return { form: "errValue", value };
|
||||
}
|
||||
@@ -1943,6 +2204,46 @@ function assertThrowsHelper(
|
||||
return { e: strLit("", loc), has: false };
|
||||
})();
|
||||
|
||||
// expectedException's function branch: a function with a prototype that
|
||||
// the caught value is an instance of passes; anything else is called as
|
||||
// a validation function, whose result must be true.
|
||||
const validateStmts = (fn: IrExpr): IrStmt[] => {
|
||||
const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc });
|
||||
const prototype: IrExpr = {
|
||||
kind: "dynKeyGet",
|
||||
value: fn,
|
||||
key: strLit("prototype", loc),
|
||||
optional: true,
|
||||
type: DYN,
|
||||
loc,
|
||||
};
|
||||
return [
|
||||
ifStmt(
|
||||
{ kind: "dynTest", test: "undefined", negated: true, value: prototype, type: BOOL, loc },
|
||||
[
|
||||
ifStmt(
|
||||
checkedClassInstanceOf(
|
||||
lowerer,
|
||||
actual(),
|
||||
fn,
|
||||
"assert.throws against this native constructor is not supported yet",
|
||||
loc,
|
||||
),
|
||||
[doReturn],
|
||||
),
|
||||
],
|
||||
),
|
||||
lib("assert.throwsValidate", [
|
||||
actual(),
|
||||
fn,
|
||||
strLit(mode === "rejects" ? "rejects" : "throws", loc),
|
||||
m(),
|
||||
hm(),
|
||||
]),
|
||||
doReturn,
|
||||
];
|
||||
};
|
||||
|
||||
let catchBody: IrStmt[];
|
||||
if (mode === "dnr" || mode === "dnt") {
|
||||
const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc });
|
||||
@@ -2116,7 +2417,13 @@ function assertThrowsHelper(
|
||||
// caught value crosses as a dyn value (identity-preserving) and
|
||||
// the runtime walks the expected's keys — a mismatch throws the
|
||||
// deep-equal AssertionError.
|
||||
// A function there is a validation function (or an error class
|
||||
// value) as in the predicate form.
|
||||
catchBody = [
|
||||
ifStmt(
|
||||
{ kind: "dynTest", test: "function", value: varRef("ev.0", DYN, loc), type: BOOL, loc },
|
||||
validateStmts(varRef("ev.0", DYN, loc)),
|
||||
),
|
||||
lib("assert.expectsErrDyn", [
|
||||
{ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc },
|
||||
varRef("ev.0", DYN, loc),
|
||||
@@ -2127,7 +2434,8 @@ function assertThrowsHelper(
|
||||
];
|
||||
break;
|
||||
case "predicate":
|
||||
throw new InternalCompilerError("assert validation callback only enters a no-error helper");
|
||||
catchBody = validateStmts(varRef("predicate.0", DYN, loc));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { assertFunctionAliasOf } from "./lower-assert.js";
|
||||
import { keyedHelperName } from "./stable-names.js";
|
||||
import { perKeyOrder } from "./key-order-witness.js";
|
||||
import { lowerDateToPrimitiveCall } from "./builtins/dates.js";
|
||||
import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js";
|
||||
import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js";
|
||||
import { lowerDynStreamMemberCall } from "./dyn-streams.js";
|
||||
@@ -1052,6 +1054,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
|
||||
}
|
||||
const bi = lowerer.builtinImportOf(expr.expression);
|
||||
if (bi) return lowerBuiltinCall(lowerer, expr, bi, loc);
|
||||
// A constant bound to an assert method (`const ok = assert.ok`) called
|
||||
// directly is that assertion, with the message Node quotes the call for.
|
||||
{
|
||||
const alias = assertFunctionAliasOf(lowerer, expr.expression);
|
||||
const served = alias ? lowerer.lowerAssertModuleCall(expr, alias, loc) : null;
|
||||
if (served) return served;
|
||||
}
|
||||
// The assert module binding called DIRECTLY (`assert(x)` — a default
|
||||
// import or the CJS `const assert = require("assert")`): Node's
|
||||
// module object IS assert.ok; namespace-import bindings fence inside
|
||||
@@ -1816,6 +1825,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
|
||||
// The element spelling of an unsupported native Web method must fence
|
||||
// before lowering the callee into a checked-dynamic keyed read.
|
||||
if (ts.isElementAccessExpression(expr.expression)) {
|
||||
const datePrimitive = lowerDateToPrimitiveCall(lowerer, expr, expr.expression);
|
||||
if (datePrimitive) return datePrimitive;
|
||||
const timersDispose = lowerMockTimersElementCall(lowerer, expr, expr.expression);
|
||||
if (timersDispose) return timersDispose;
|
||||
const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr);
|
||||
@@ -2456,6 +2467,9 @@ function lowerNumberConstructorValue(
|
||||
};
|
||||
const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc);
|
||||
if (optionalNumber) return optionalNumber;
|
||||
// A Date's number hint answers its time value.
|
||||
if (arg.type.kind === "date")
|
||||
return { kind: "libCall", fn: "date.valueOf", args: [arg], type: F64, loc };
|
||||
// A program class instance converts through its own methods.
|
||||
const classNumber = classToNumberConversion(lowerer, arg, argNode);
|
||||
if (classNumber) return classNumber;
|
||||
@@ -8703,7 +8717,12 @@ function lowerObjectStaticCall(
|
||||
(probed && isDynTypedRefType(probed.type)) ||
|
||||
(probed?.type.kind === "object" && lowerer.errorHierarchyClassOf(probed.type.className)) ||
|
||||
(probed && constructor && (constructor.callableBase || hasRuntimeStatics(constructor))) ||
|
||||
(probed?.type.kind === "func" && lowerer.dynConvertible(probed.type))
|
||||
(probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) ||
|
||||
// null, undefined and unions of untyped JavaScript values: the same
|
||||
// probe, whose ToObject throws Node's TypeError on nullish.
|
||||
(probed &&
|
||||
(isUnitType(probed.type) || probed.type.kind === "union") &&
|
||||
lowerer.dynConvertible(probed.type))
|
||||
) {
|
||||
const loc = locOf(call);
|
||||
const receiver = lowerer.coerceToExpected(probed, DYN);
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
superIntoResultSlot,
|
||||
} from "./constructor-returns.js";
|
||||
import { checkedIterableSpread } from "./checked-iterable-spread.js";
|
||||
import { lowerReadlineUiNew } from "./builtins/readline.js";
|
||||
import { builtinMemberRequireDecl } from "./builtins/module-bindings.js";
|
||||
import { lowerReadlineInterfaceNew, lowerReadlineUiNew } from "./builtins/readline.js";
|
||||
import {
|
||||
builtinMemberBindingTarget,
|
||||
builtinMemberRequireDecl,
|
||||
} from "./builtins/module-bindings.js";
|
||||
import { checkedClassUnionAssertion } from "./class-assertions.js";
|
||||
import { collectClassMethodMutations } from "./class-method-mutations.js";
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
@@ -113,6 +116,7 @@ import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./low
|
||||
import { isAssertClassNew } from "./assert-instances.js";
|
||||
import { isCjsRequireGlobal } from "./lower-node-module.js";
|
||||
import { lowerArrayConstructor } from "./containers/array-construction.js";
|
||||
import { initializedCollectionType } from "./js-collection-types.js";
|
||||
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
|
||||
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
|
||||
import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js";
|
||||
@@ -481,6 +485,10 @@ export interface ClassInfo {
|
||||
localPrototypeData?: true;
|
||||
prototypeMethodValues?: Map<string, IrExpr>;
|
||||
prototypeReflectionReady?: true;
|
||||
/** The global that records at run time that the prototype view was
|
||||
* populated by reflection, so it holds its own `constructor` until the
|
||||
* program deletes it (prototypeReflectedFlag). */
|
||||
prototypeReflectedFlag?: string;
|
||||
prototypeObservedMethodsReady?: true;
|
||||
/** The globals holding each observed method's original prototype value
|
||||
* (set when the prototype data is built), by method name. A typed call
|
||||
@@ -9819,7 +9827,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
}
|
||||
const worker = lowerWorkerNew(lowerer, expr);
|
||||
if (worker) return worker;
|
||||
const readlineUi = lowerReadlineUiNew(lowerer, expr);
|
||||
const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr);
|
||||
if (readlineUi) return readlineUi;
|
||||
const channel = lowerMessageChannelNew(lowerer, expr) ?? lowerBroadcastChannelNew(lowerer, expr);
|
||||
if (channel) return channel;
|
||||
@@ -10119,6 +10127,102 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
const agent = lowerHttpAgentNew(lowerer, expr);
|
||||
if (agent) return agent;
|
||||
}
|
||||
// `new crypto.X509Certificate(data)` — the Dirent-style data record:
|
||||
// the certificate's lowered members (fingerprint — the SHA-1 of the
|
||||
// DER, uppercase colon-separated — plus the validFrom/validTo
|
||||
// validity window in Node's ASN1_TIME_print shape) compute AT
|
||||
// CONSTRUCTION, when Node parses too, so unparseable input throws
|
||||
// Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the
|
||||
// handle never exists. Both import forms (`crypto.X509Certificate`
|
||||
// through the namespace, named `X509Certificate`); Buffer input only
|
||||
// — the readFileSync idiom. mapType interns the matching record, so
|
||||
// locals and the composed member reads all flow. The data argument
|
||||
// feeds THREE field computations, so construction goes through an
|
||||
// interned helper whose parameter evaluates it exactly once.
|
||||
// It runs before the standard-library constructor fence below, which
|
||||
// also sees `const X = require("crypto").X509Certificate` as the
|
||||
// builtin class.
|
||||
{
|
||||
const callee = expr.expression;
|
||||
const isX509 =
|
||||
(ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === "X509Certificate" &&
|
||||
lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") ||
|
||||
(ts.isIdentifier(callee) &&
|
||||
(() => {
|
||||
const bi = lowerer.builtinImportOf(callee);
|
||||
return bi?.module === "crypto" && bi.member === "X509Certificate";
|
||||
})());
|
||||
if (isX509) {
|
||||
const args: readonly ts.Expression[] = expr.arguments?.nodes ?? [];
|
||||
if (args.length !== 1) {
|
||||
lowerer.noLowering(
|
||||
"X509Certificate with this argument shape",
|
||||
expr,
|
||||
"the supported form is new X509Certificate(readFileSync(path))",
|
||||
);
|
||||
}
|
||||
const data = lowerer.lowerExpr(args[0]!);
|
||||
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
|
||||
if (!isBytes && data.type.kind !== "string") {
|
||||
lowerer.noLowering(
|
||||
`X509Certificate over '${lowerer.fmt(data.type)}' data`,
|
||||
args[0]!,
|
||||
"pass the certificate Buffer or PEM string (an fs.readFileSync result)",
|
||||
);
|
||||
}
|
||||
const t = lowerer.mapTypeOf(lowerer.typeOf(expr));
|
||||
if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr));
|
||||
const key = `x509.record:${isBytes ? "bytes" : "str"}`;
|
||||
let helper = lowerer.valueHelpers.get(key);
|
||||
if (!helper) {
|
||||
helper = keyedHelperName(lowerer, "%x509.record", key);
|
||||
lowerer.valueHelpers.set(key, helper);
|
||||
const dataT = data.type;
|
||||
const dRef: IrExpr = { kind: "varRef", localId: "d.0", type: dataT, loc };
|
||||
const field = (
|
||||
name: string,
|
||||
fn:
|
||||
| "crypto.x509Fingerprint"
|
||||
| "crypto.x509FingerprintStr"
|
||||
| "crypto.x509ValidFrom"
|
||||
| "crypto.x509ValidFromStr"
|
||||
| "crypto.x509ValidTo"
|
||||
| "crypto.x509ValidToStr",
|
||||
): { name: string; value: IrExpr } => ({
|
||||
name,
|
||||
value: { kind: "libCall", fn, args: [dRef], type: STRING, loc },
|
||||
});
|
||||
lowerer.liftedFns.push({
|
||||
name: helper,
|
||||
params: [{ localId: "d.0", name: "d", type: dataT }],
|
||||
returnType: t,
|
||||
locals: [{ id: "d.0", name: "d", type: dataT, mutable: false }],
|
||||
body: [
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "recordLit",
|
||||
fields: [
|
||||
field(
|
||||
"fingerprint",
|
||||
isBytes ? "crypto.x509Fingerprint" : "crypto.x509FingerprintStr",
|
||||
),
|
||||
field("validFrom", isBytes ? "crypto.x509ValidFrom" : "crypto.x509ValidFromStr"),
|
||||
field("validTo", isBytes ? "crypto.x509ValidTo" : "crypto.x509ValidToStr"),
|
||||
],
|
||||
type: t,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
});
|
||||
}
|
||||
return { kind: "call", callee: helper, args: [data], type: t, loc };
|
||||
}
|
||||
}
|
||||
if (
|
||||
ts.isIdentifier(expr.expression) ||
|
||||
(ts.isPropertyAccessExpression(expr.expression) &&
|
||||
@@ -10132,7 +10236,12 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
// (a no-op for every non-import= binding).
|
||||
if (ts.isIdentifier(expr.expression)) fenceEarlyAliasUse(lowerer, expr.expression, expr);
|
||||
const identifier = ts.isIdentifier(expr.expression) ? expr.expression : expr.expression.name;
|
||||
const symbol = ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null;
|
||||
// `const SD = require("string_decoder").StringDecoder; new SD()`
|
||||
// constructs the member, as a named import of it does.
|
||||
const symbol = builtinMemberBindingTarget(
|
||||
lowerer,
|
||||
ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null,
|
||||
);
|
||||
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Array")) {
|
||||
return lowerArrayConstructor(lowerer, expr, expr.arguments?.nodes ?? []);
|
||||
}
|
||||
@@ -10437,6 +10546,29 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [arg], type: F64, loc };
|
||||
return { kind: "libCall", fn: "date.newMs", args: [time], type: DATE_T, loc };
|
||||
}
|
||||
// A union (`Date | string | number`) or checked-dynamic value: the
|
||||
// runtime's constructor reads a Date's time value, parses a string
|
||||
// and converts anything else to a primitive and a number.
|
||||
if (
|
||||
(arg.type.kind === "union" || arg.type.kind === "dyn") &&
|
||||
lowerer.dynConvertible(arg.type)
|
||||
) {
|
||||
const value: IrExpr = {
|
||||
kind: "libCall",
|
||||
fn: "date.nativeNewValue",
|
||||
args: [
|
||||
{
|
||||
kind: "dynArrLit",
|
||||
elems: [lowerer.coerceToExpected(arg, DYN)],
|
||||
type: DYN,
|
||||
loc,
|
||||
},
|
||||
],
|
||||
type: DYN,
|
||||
loc,
|
||||
};
|
||||
return { kind: "libCall", fn: "date.checkedValue", args: [value], type: DATE_T, loc };
|
||||
}
|
||||
lowerer.noLowering(
|
||||
`new Date of '${lowerer.fmt(arg.type)}' values`,
|
||||
args[0]!,
|
||||
@@ -10568,7 +10700,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
// expression). The CONTEXTUAL type carries the annotation — adopt
|
||||
// it when it is a supported map. TS type arguments keep winning:
|
||||
// their expression type already maps.
|
||||
if (mapped?.kind !== "map" || contextualSeed) {
|
||||
const inferred = initializedCollectionType(lowerer, expr);
|
||||
if (inferred) {
|
||||
tsType = inferred;
|
||||
mapped = lowerer.mapTypeOf(inferred);
|
||||
} else if (mapped?.kind !== "map" || contextualSeed) {
|
||||
const ctx = lowerer.checker.getContextualType(expr);
|
||||
const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
|
||||
if (ctx && ctxMapped?.kind === "map") {
|
||||
@@ -10708,7 +10844,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
// `new Set<T>()`: construct + bulk add from supported values,
|
||||
// including live collection cursors. Duplicates collapse in insertion order.
|
||||
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) {
|
||||
const tsType = lowerer.typeOf(expr);
|
||||
const inferred = initializedCollectionType(lowerer, expr);
|
||||
const tsType = inferred ?? lowerer.typeOf(expr);
|
||||
let mapped = lowerer.mapTypeOf(tsType);
|
||||
const fieldType = assignedThisFieldType(lowerer, expr);
|
||||
if (mapped?.kind !== "set" && fieldType?.kind === "set") mapped = fieldType;
|
||||
@@ -10899,99 +11036,6 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
|
||||
);
|
||||
}
|
||||
}
|
||||
// `new crypto.X509Certificate(data)` — the Dirent-style data record:
|
||||
// the certificate's lowered members (fingerprint — the SHA-1 of the
|
||||
// DER, uppercase colon-separated — plus the validFrom/validTo
|
||||
// validity window in Node's ASN1_TIME_print shape) compute AT
|
||||
// CONSTRUCTION, when Node parses too, so unparseable input throws
|
||||
// Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the
|
||||
// handle never exists. Both import forms (`crypto.X509Certificate`
|
||||
// through the namespace, named `X509Certificate`); Buffer input only
|
||||
// — the readFileSync idiom. mapType interns the matching record, so
|
||||
// locals and the composed member reads all flow. The data argument
|
||||
// feeds THREE field computations, so construction goes through an
|
||||
// interned helper whose parameter evaluates it exactly once.
|
||||
{
|
||||
const callee = expr.expression;
|
||||
const isX509 =
|
||||
(ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === "X509Certificate" &&
|
||||
lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") ||
|
||||
(ts.isIdentifier(callee) &&
|
||||
(() => {
|
||||
const bi = lowerer.builtinImportOf(callee);
|
||||
return bi?.module === "crypto" && bi.member === "X509Certificate";
|
||||
})());
|
||||
if (isX509) {
|
||||
const args: readonly ts.Expression[] = expr.arguments?.nodes ?? [];
|
||||
if (args.length !== 1) {
|
||||
lowerer.noLowering(
|
||||
"X509Certificate with this argument shape",
|
||||
expr,
|
||||
"the supported form is new X509Certificate(readFileSync(path))",
|
||||
);
|
||||
}
|
||||
const data = lowerer.lowerExpr(args[0]!);
|
||||
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
|
||||
if (!isBytes && data.type.kind !== "string") {
|
||||
lowerer.noLowering(
|
||||
`X509Certificate over '${lowerer.fmt(data.type)}' data`,
|
||||
args[0]!,
|
||||
"pass the certificate Buffer or PEM string (an fs.readFileSync result)",
|
||||
);
|
||||
}
|
||||
const t = lowerer.mapTypeOf(lowerer.typeOf(expr));
|
||||
if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr));
|
||||
const key = `x509.record:${isBytes ? "bytes" : "str"}`;
|
||||
let helper = lowerer.valueHelpers.get(key);
|
||||
if (!helper) {
|
||||
helper = keyedHelperName(lowerer, "%x509.record", key);
|
||||
lowerer.valueHelpers.set(key, helper);
|
||||
const dataT = data.type;
|
||||
const dRef: IrExpr = { kind: "varRef", localId: "d.0", type: dataT, loc };
|
||||
const field = (
|
||||
name: string,
|
||||
fn:
|
||||
| "crypto.x509Fingerprint"
|
||||
| "crypto.x509FingerprintStr"
|
||||
| "crypto.x509ValidFrom"
|
||||
| "crypto.x509ValidFromStr"
|
||||
| "crypto.x509ValidTo"
|
||||
| "crypto.x509ValidToStr",
|
||||
): { name: string; value: IrExpr } => ({
|
||||
name,
|
||||
value: { kind: "libCall", fn, args: [dRef], type: STRING, loc },
|
||||
});
|
||||
lowerer.liftedFns.push({
|
||||
name: helper,
|
||||
params: [{ localId: "d.0", name: "d", type: dataT }],
|
||||
returnType: t,
|
||||
locals: [{ id: "d.0", name: "d", type: dataT, mutable: false }],
|
||||
body: [
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "recordLit",
|
||||
fields: [
|
||||
field(
|
||||
"fingerprint",
|
||||
isBytes ? "crypto.x509Fingerprint" : "crypto.x509FingerprintStr",
|
||||
),
|
||||
field("validFrom", isBytes ? "crypto.x509ValidFrom" : "crypto.x509ValidFromStr"),
|
||||
field("validTo", isBytes ? "crypto.x509ValidTo" : "crypto.x509ValidToStr"),
|
||||
],
|
||||
type: t,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
});
|
||||
}
|
||||
return { kind: "call", callee: helper, args: [data], type: t, loc };
|
||||
}
|
||||
}
|
||||
// `new X(...)` through a class VALUE (a classval-typed binding, array
|
||||
// element, map read, param): the newValue dispatch through the class
|
||||
// object's construct thunk. Arguments complete against the STATIC
|
||||
|
||||
@@ -2339,6 +2339,7 @@ export function typeofAnswer(arm: IrType): string | null {
|
||||
case "set":
|
||||
case "generator":
|
||||
case "regex":
|
||||
case "date":
|
||||
case "bytes":
|
||||
case "url":
|
||||
case "searchParams":
|
||||
|
||||
@@ -183,13 +183,9 @@ export function lowerGenMethodCall(
|
||||
);
|
||||
}
|
||||
arg = lowerer.lowerExpr(argNode);
|
||||
if (arg.type.kind === "date") {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
argNode,
|
||||
".throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead)",
|
||||
);
|
||||
}
|
||||
// A Date throws as its checked-dynamic handle, the same Date object
|
||||
// (the throw statement's rule).
|
||||
if (arg.type.kind === "date") arg = lowerer.coerceToExpected(arg, DYN);
|
||||
if (arg.type.kind === "void" || arg.type.kind === "caught" || isUnitType(arg.type)) {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyedHelperName } from "./stable-names.js";
|
||||
import { perKeyOrder } from "./key-order-witness.js";
|
||||
import { classPrototypeView } from "./class-prototypes.js";
|
||||
import {
|
||||
dynUndefinedExpr,
|
||||
boolLit,
|
||||
@@ -3527,8 +3528,14 @@ function lowerUtilCallableCall(
|
||||
member === "inherits" && expr.arguments.nodes[1] !== undefined
|
||||
? builtinClassKindOf(lowerer, expr.arguments.nodes[1])
|
||||
: null;
|
||||
// A superCtor class whose prototype has a complete view (a module-level
|
||||
// JavaScript class named directly) passes that view to the runtime.
|
||||
const superView =
|
||||
member === "inherits" && builtinBase === null && expr.arguments.nodes[1] !== undefined
|
||||
? classPrototypeView(lowerer, expr.arguments.nodes[1], loc)
|
||||
: null;
|
||||
for (const [index, arg] of expr.arguments.nodes.slice(0, classArgs).entries()) {
|
||||
if (index === 1 && builtinBase !== null) continue;
|
||||
if (index === 1 && (builtinBase !== null || superView !== null)) continue;
|
||||
if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval")
|
||||
lowerer.noLowering(
|
||||
`util.${member} over a class constructor`,
|
||||
@@ -3560,7 +3567,9 @@ function lowerUtilCallableCall(
|
||||
for (const arg of expr.arguments.nodes.slice(arity))
|
||||
stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc });
|
||||
const type = fn === "util.inherits" ? VOID : DYN;
|
||||
const result: IrExpr = { kind: "libCall", fn, args, type, loc };
|
||||
const result: IrExpr = superView
|
||||
? { kind: "libCall", fn: "util.inheritsPrototype", args: [...args, superView], type, loc }
|
||||
: { kind: "libCall", fn, args, type, loc };
|
||||
return { kind: "seqExpr", stmts, result, type: result.type, loc };
|
||||
}
|
||||
|
||||
|
||||
@@ -1630,16 +1630,22 @@ export function lowerStaticAbortControllerCall(
|
||||
call: ts.CallExpression,
|
||||
): IrExpr | null {
|
||||
const access = call.expression;
|
||||
// An optional chain (`controller?.abort()`) lowers the call again once it
|
||||
// has narrowed the receiver (Lowerer.chainHandled).
|
||||
if (
|
||||
call.questionDotToken ||
|
||||
(call.questionDotToken && !lowerer.chainHandled.has(call)) ||
|
||||
call.arguments.nodes.some((argument) => ts.isSpreadElement(argument)) ||
|
||||
(!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) ||
|
||||
access.questionDotToken ||
|
||||
(access.questionDotToken && !lowerer.chainHandled.has(access)) ||
|
||||
staticResponseMemberName(lowerer, access) !== "abort"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(access.expression));
|
||||
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(
|
||||
access.questionDotToken
|
||||
? lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression))
|
||||
: lowerer.typeOf(access.expression),
|
||||
);
|
||||
const symbol = receiverTs.getAliasSymbol() ?? receiverTs.getSymbol();
|
||||
if (
|
||||
symbol === undefined ||
|
||||
|
||||
@@ -1624,8 +1624,47 @@ const THROWN_AS_DYN: ReadonlySet<IrType["kind"]> = new Set([
|
||||
"bytes",
|
||||
"func",
|
||||
"regex",
|
||||
"date",
|
||||
]);
|
||||
|
||||
/** A `return` at the top level of a CommonJS module (preflight admits it
|
||||
* there only): it ends the module's evaluation, and its value is
|
||||
* evaluated and discarded, as Node's module wrapper does. Top-level
|
||||
* bindings and export assignments after it would be left uninitialized
|
||||
* where Node leaves them in their temporal dead zone or absent, so a
|
||||
* module with any keeps a refusal. Null for a return inside a function. */
|
||||
function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null {
|
||||
let top: ts.Node = stmt;
|
||||
while (top.parent !== undefined && !ts.isSourceFile(top.parent)) {
|
||||
if (ts.isFunctionLike(top.parent) || ts.isClassStaticBlockDeclaration(top.parent)) return null;
|
||||
top = top.parent;
|
||||
}
|
||||
const sf = top.parent;
|
||||
if (sf === undefined || !ts.isSourceFile(sf)) return null;
|
||||
const stmts = sf.statements.nodes;
|
||||
const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1);
|
||||
if (
|
||||
later.some(
|
||||
(s) =>
|
||||
ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null,
|
||||
)
|
||||
) {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
stmt,
|
||||
"a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it",
|
||||
);
|
||||
}
|
||||
const loc = locOf(stmt);
|
||||
const ret: IrStmt = { kind: "return", value: null, loc };
|
||||
if (!stmt.expression) return ret;
|
||||
return {
|
||||
kind: "block",
|
||||
body: [{ kind: "exprStmt", expr: lowerer.lowerExpr(stmt.expression), loc }, ret],
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null {
|
||||
if (ownPrototypeResetStatement(lowerer, stmt))
|
||||
return { kind: "block", body: [], loc: locOf(stmt) };
|
||||
@@ -1735,6 +1774,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
|
||||
if (ts.isForOfStatement(stmt)) return lowerer.lowerForOf(stmt);
|
||||
if (ts.isLabeledStatement(stmt)) return lowerLabeled(lowerer, stmt);
|
||||
if (ts.isReturnStatement(stmt)) {
|
||||
const moduleReturn = lowerModuleLevelReturn(lowerer, stmt);
|
||||
if (moduleReturn) return moduleReturn;
|
||||
// Implicit-any instance RETURN INFERENCE (function-returns.ts
|
||||
// resolveInferredReturn): the value lowers BARE and the statement
|
||||
// records itself — the post-pass settles the instance's return type
|
||||
@@ -1828,9 +1869,7 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
|
||||
return { kind: "rethrow", localId: caughtLocal.id, loc: locOf(stmt) };
|
||||
}
|
||||
// Exception-representable values move into the runtime's exception
|
||||
// cell. `throw f()` of a void call has no value; Date has numeric
|
||||
// storage internally, but cannot preserve its object kind through the
|
||||
// untyped catch boundary. Reject both rather than changing JS identity.
|
||||
// cell. `throw f()` of a void call has no value.
|
||||
let value = lowerer.lowerExpr(stmt.expression);
|
||||
if (value.type.kind === "void")
|
||||
lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression));
|
||||
@@ -1842,19 +1881,12 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
|
||||
} else if (value.type.kind === "record" && lowerer.dynConvertible(value.type)) {
|
||||
value = { kind: "dynFrom", value, liveRef: true, type: DYN, loc: locOf(stmt.expression) };
|
||||
} else if (THROWN_AS_DYN.has(value.type.kind) && lowerer.dynConvertible(value.type)) {
|
||||
// Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes and
|
||||
// unions over them keep their identity and contents the same way:
|
||||
// the catch binding, `typeof`, `instanceof`, `Array.isArray`,
|
||||
// inspection and typed views read the thrown object.
|
||||
// Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes,
|
||||
// Dates and unions over them keep their identity and contents the
|
||||
// same way: the catch binding, `typeof`, `instanceof`,
|
||||
// `Array.isArray`, inspection and typed views read the thrown object.
|
||||
value = lowerer.coerceInto(stmt.expression, value, DYN);
|
||||
}
|
||||
if (value.type.kind === "date") {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
stmt.expression,
|
||||
"throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead)",
|
||||
);
|
||||
}
|
||||
// A dyn throw rides the REF cell arm (scr_throw_ref over the checked-dynamic tree
|
||||
// node — identity preserved through catch bindings, caughtToDyn
|
||||
// passes it back by reference). The JS-lane traced-call shape:
|
||||
@@ -5450,7 +5482,12 @@ export function lowerVarDecl(
|
||||
if (isTimeValueDateDeclaration(lowerer, decl)) {
|
||||
const loc = locOf(decl);
|
||||
const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T);
|
||||
const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet);
|
||||
const local = lowerer.declareLocal(
|
||||
decl.name,
|
||||
decl.name.text,
|
||||
F64,
|
||||
isLet || lowerer.timeValueDateWrites.has(decl),
|
||||
);
|
||||
lowerer.timeValueDateLocals.add(local);
|
||||
const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc };
|
||||
return { kind: "varDecl", localId: local.id, init, loc };
|
||||
|
||||
@@ -239,7 +239,7 @@ test("optional union widening keeps refused and missing contracts current", () =
|
||||
unionId: lowerer.unions.recursiveRef(recursive, () => "Recursive"),
|
||||
};
|
||||
expect(lowerer.withUndefinedArmOf(pending)).not.toBeNull();
|
||||
lowerer.unions.finalizeRecursive(recursive, [{ kind: "date" }, STRING]);
|
||||
lowerer.unions.finalizeRecursive(recursive, [{ kind: "promise", inner: F64 }, STRING]);
|
||||
expect(lowerer.withUndefinedArmOf(pending)).toBeNull();
|
||||
expect(lowerer.withUndefinedArmOf(pending)).toBeNull();
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
trackClassFieldCreation,
|
||||
} from "./class-dynamic-dispatch.js";
|
||||
import { guardClassAttributeWrites } from "./class-attributes.js";
|
||||
import { inferredCollectionType } from "./js-collection-types.js";
|
||||
import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js";
|
||||
import { applyHandleProperties } from "./handle-properties.js";
|
||||
import { installBufferValue } from "./containers/bytes.js";
|
||||
@@ -593,7 +594,11 @@ import {
|
||||
lowerTestMethodCall,
|
||||
lowerTestCtxProperty,
|
||||
} from "./lower-test.js";
|
||||
import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js";
|
||||
import {
|
||||
assertFunctionValue,
|
||||
lowerAssertModuleCall,
|
||||
lowerAssertDirectCall,
|
||||
} from "./lower-assert.js";
|
||||
import type { AssertInstanceOptions } from "./assert-instances.js";
|
||||
import { lowerUtilModuleCall } from "./lower-inspect.js";
|
||||
import { lowerSystemErrorMapValue } from "./lower-system-errors.js";
|
||||
@@ -2362,6 +2367,9 @@ export class Lowerer {
|
||||
* Date's time value instead of a Date object (date-values.ts). */
|
||||
readonly timeValueDateDecisions = new Map<ts.Node, boolean>();
|
||||
readonly timeValueDateLocals = new Set<IrLocal>();
|
||||
/** Declarations of those locals that a setter call writes
|
||||
* (date-values.ts). */
|
||||
readonly timeValueDateWrites = new Set<ts.VariableDeclaration>();
|
||||
/** Record bindings whose flow class reaches storage outside the class:
|
||||
* they keep the declared layout, so an initializer from unchecked reads
|
||||
* converts once instead of the binding adopting the widened variant. */
|
||||
@@ -2846,6 +2854,9 @@ export class Lowerer {
|
||||
/** Parameters read as `any` in the error-path copy of an error-first
|
||||
* callback. */
|
||||
readonly anySymbols = new Set<ts.Symbol>();
|
||||
/** The collection types that uses prove for untyped JavaScript bindings,
|
||||
* by every identifier naming one (js-collection-types.ts). */
|
||||
inferredCollectionTypes: Map<ts.Node, ts.Type> | undefined = undefined;
|
||||
/** overload-choice's answers per call, binding and function. */
|
||||
readonly dynamicOverloadAnswers = new Map<ts.Node, boolean>();
|
||||
/** Error parameters of an error-first callback with a known value in
|
||||
@@ -3910,7 +3921,8 @@ export class Lowerer {
|
||||
/** Declared class fields may store a DYN value only when its static type
|
||||
* is `unknown` or `object` (also with `null` or `undefined`, or an array
|
||||
* of those), names checked protocol identity, is an owned runtime
|
||||
* handle (a Worker or MessagePort, a fetch Headers/Request/Response, or
|
||||
* handle (a Worker or MessagePort, a fetch Headers/Request/Response or
|
||||
* AbortController/AbortSignal, or
|
||||
* a node:crypto ECDH, DiffieHellman or DiffieHellmanGroup), or is a
|
||||
* WeakMap/WeakSet (native weak collections live in checked-dynamic
|
||||
* storage, as locals of those types do); `any` fields stay refused. */
|
||||
@@ -9067,6 +9079,10 @@ export class Lowerer {
|
||||
}
|
||||
|
||||
typeOf(node: ts.Node): ts.Type {
|
||||
if (ts.isIdentifier(node)) {
|
||||
const inferred = inferredCollectionType(this, node);
|
||||
if (inferred) return inferred;
|
||||
}
|
||||
if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType();
|
||||
// A checked-dynamic argument that another overload could take at run
|
||||
// time leaves the resolved overload's return a guess, and JS bindings
|
||||
@@ -13985,6 +14001,8 @@ export class Lowerer {
|
||||
* deliberately narrower than table membership because many table rows
|
||||
* are only dispatch sentinels for call-site-specific lowering. */
|
||||
lowerBuiltinCallableValue(bi: { module: string; member: string }, loc: SrcLoc): IrExpr | null {
|
||||
const assertion = assertFunctionValue(bi, loc);
|
||||
if (assertion) return assertion;
|
||||
if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc);
|
||||
if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member))
|
||||
return lowerUtilTypeValue(this, bi.member, loc);
|
||||
|
||||
@@ -873,6 +873,12 @@ export const OBJECT_CALLABLE_VALUES: Record<string, BuiltinModuleFn | undefined>
|
||||
result: DYN,
|
||||
valueParams: exactValueParams(DYN, DYN),
|
||||
},
|
||||
hasOwn: {
|
||||
fn: "dyn.hasOwnComputed",
|
||||
params: [DYN, DYN],
|
||||
result: BOOL,
|
||||
valueParams: exactValueParams(DYN, DYN),
|
||||
},
|
||||
keys: { fn: "dyn.objKeys", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
|
||||
values: { fn: "dyn.objValues", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
|
||||
entries: { fn: "dyn.objEntries", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
|
||||
@@ -2338,6 +2344,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [
|
||||
],
|
||||
note: "zero arguments, one milliseconds/date-string argument, or local-time year/month fields; values are the read-only TimeClip scalar slice",
|
||||
},
|
||||
{
|
||||
id: "stdlib.date.setters",
|
||||
kind: "stdlib",
|
||||
name: "Date.prototype setters",
|
||||
fns: ["date.setFields", "date.timeClip", "date.store"],
|
||||
note: "setTime and the local and UTC field setters, with their optional arguments",
|
||||
},
|
||||
{
|
||||
id: "stdlib.date.UTC",
|
||||
kind: "stdlib",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { npmStaticPackageOfPath } from "./npm-static.js";
|
||||
import { trackedReadFile } from "./input-tracker.js";
|
||||
import { npmPackageNameOf } from "./workspace-registry.js";
|
||||
import { typeOnlyNamedImport } from "./type-only-imports.js";
|
||||
import { moduleSourceCandidates } from "./module-source-candidates.js";
|
||||
|
||||
export function isPrunedNpmReexport(program: ts.Program, stmt: ts.ExportDeclaration): boolean {
|
||||
return program.analysis.prunedNpmReexports.has(stmt);
|
||||
@@ -117,6 +118,21 @@ function pureExternalPackage(
|
||||
|
||||
type Demand = Set<string> | null; // null requests the entire namespace
|
||||
|
||||
/** One module edge of a package file, resolved once per plan. */
|
||||
interface ModuleEdge {
|
||||
spec: string;
|
||||
dep: ts.SourceFile | null;
|
||||
}
|
||||
|
||||
/** A file's runtime module edges: its import declarations with the names
|
||||
* they request (null: the whole module), its `export … from` declarations,
|
||||
* and its `import()` and `require()` calls with one string argument. */
|
||||
interface ModuleEdges {
|
||||
imports: (ModuleEdge & { names: readonly string[] | null })[];
|
||||
reexports: (ModuleEdge & { stmt: ts.ExportDeclaration })[];
|
||||
calls: ModuleEdge[];
|
||||
}
|
||||
|
||||
/** Package files outside the demanded module graph are absent from the
|
||||
* executable program. All ordinary project files retain their preflight
|
||||
* behavior, including files not imported by the entry. */
|
||||
@@ -151,6 +167,70 @@ export function planNpmStaticReexports(
|
||||
}
|
||||
};
|
||||
|
||||
// The demand walk visits a file again whenever another of its names is
|
||||
// requested, and every purity check walks the files it reaches: both read
|
||||
// these edges instead of resolving the file's syntax each time.
|
||||
const edgeMemo = new Map<ts.SourceFile, ModuleEdges>();
|
||||
const edgesOf = (sf: ts.SourceFile): ModuleEdges => {
|
||||
const cached = edgeMemo.get(sf);
|
||||
if (cached !== undefined) return cached;
|
||||
const edges: ModuleEdges = { imports: [], reexports: [], calls: [] };
|
||||
for (const stmt of sf.statements.nodes) {
|
||||
if (ts.isImportDeclaration(stmt)) {
|
||||
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
||||
const clause = stmt.importClause;
|
||||
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
|
||||
const spec = stmt.moduleSpecifier.text;
|
||||
let names: string[] | null = null;
|
||||
if (
|
||||
clause !== undefined &&
|
||||
(clause.namedBindings === undefined ||
|
||||
!(
|
||||
ts.isNamespaceImport(clause.namedBindings) ||
|
||||
(ts.isNamedImports(clause.namedBindings) &&
|
||||
clause.namedBindings.elements.nodes.length === 0)
|
||||
)) &&
|
||||
// `import { type A }` still loads the module, like `import {}`.
|
||||
!typeOnlyNamedImport(stmt)
|
||||
) {
|
||||
names = [];
|
||||
if (clause.name !== undefined) names.push("default");
|
||||
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const element of clause.namedBindings.elements.nodes) {
|
||||
if (!element.isTypeOnly) names.push((element.propertyName ?? element.name).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
edges.imports.push({ spec, dep: resolveEdge(sf, spec), names });
|
||||
} else if (ts.isExportDeclaration(stmt)) {
|
||||
// `export { type A } from` still loads the module under Node's
|
||||
// type stripping; only `export type … from` erases.
|
||||
if (
|
||||
stmt.isTypeOnly ||
|
||||
stmt.moduleSpecifier === undefined ||
|
||||
!ts.isStringLiteral(stmt.moduleSpecifier)
|
||||
)
|
||||
continue;
|
||||
const spec = stmt.moduleSpecifier.text;
|
||||
edges.reexports.push({ spec, dep: resolveEdge(sf, spec), stmt });
|
||||
}
|
||||
}
|
||||
for (const node of moduleSourceCandidates(program, sf).calls) {
|
||||
if (node.arguments.nodes.length !== 1) continue;
|
||||
const arg = node.arguments.nodes[0];
|
||||
if (arg === undefined || !ts.isStringLiteralLike(arg)) continue;
|
||||
const kind =
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
? "import"
|
||||
: ts.isIdentifier(node.expression) && node.expression.text === "require"
|
||||
? "require"
|
||||
: null;
|
||||
if (kind !== null) edges.calls.push({ spec: arg.text, dep: resolveEdge(sf, arg.text, kind) });
|
||||
}
|
||||
edgeMemo.set(sf, edges);
|
||||
return edges;
|
||||
};
|
||||
|
||||
for (const sf of files) {
|
||||
if (npmStaticPackageOfPath(sf.fileName) === null) request(sf, null);
|
||||
}
|
||||
@@ -178,57 +258,65 @@ export function planNpmStaticReexports(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// An edge this program does not resolve: the conservative
|
||||
// whole-dependency promise of the external package it names.
|
||||
const externalPure = (sf: ts.SourceFile, spec: string): boolean => {
|
||||
const resolved = resolveBareModule(sf.fileName, spec, "js-only");
|
||||
const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile);
|
||||
const root =
|
||||
pkg === null || resolved === null
|
||||
? null
|
||||
: packageRootJsonPath(resolved.typesFile, pkg, packageJsons);
|
||||
return root !== null && pureExternalPackage(root, packageJsons);
|
||||
};
|
||||
// Check the actual imported closure: metadata for one file never vouches
|
||||
// for its side-effectful descendants, even across a package or a cycle.
|
||||
const pureModuleTree = (root: ts.SourceFile): boolean => {
|
||||
const cached = pureMemo.get(root);
|
||||
if (cached !== undefined) return cached;
|
||||
const visited = new Set<ts.SourceFile>();
|
||||
const visit = (sf: ts.SourceFile): boolean => {
|
||||
if (visited.has(sf)) return true;
|
||||
if (pureMemo.get(sf) === false || !filePure(sf)) return false;
|
||||
visited.add(sf);
|
||||
const edges: { spec: string; kind?: "import" | "require" }[] = [];
|
||||
for (const stmt of sf.statements.nodes) {
|
||||
if (
|
||||
ts.isImportDeclaration(stmt) &&
|
||||
stmt.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword &&
|
||||
ts.isStringLiteral(stmt.moduleSpecifier)
|
||||
)
|
||||
edges.push({ spec: stmt.moduleSpecifier.text });
|
||||
else if (
|
||||
ts.isExportDeclaration(stmt) &&
|
||||
!stmt.isTypeOnly &&
|
||||
stmt.moduleSpecifier &&
|
||||
ts.isStringLiteral(stmt.moduleSpecifier)
|
||||
)
|
||||
edges.push({ spec: stmt.moduleSpecifier.text });
|
||||
// A file is pure when every file it reaches is. The walk finds the
|
||||
// strongly connected components of the module graph (Tarjan): a finished
|
||||
// component is pure as a whole. On the first impure file the walk stops,
|
||||
// and every file still on its stack reaches that file, so each is impure.
|
||||
const order = new Map<ts.SourceFile, number>();
|
||||
const stack: ts.SourceFile[] = [];
|
||||
/** The lowest order the file's walk reaches (Infinity for a finished
|
||||
* component), or -1 once it reaches an impure file. */
|
||||
const visit = (sf: ts.SourceFile): number => {
|
||||
const known = pureMemo.get(sf);
|
||||
if (known !== undefined) return known ? Infinity : -1;
|
||||
const seen = order.get(sf);
|
||||
if (seen !== undefined) return seen;
|
||||
if (!filePure(sf)) {
|
||||
pureMemo.set(sf, false);
|
||||
return -1;
|
||||
}
|
||||
const own = order.size;
|
||||
order.set(sf, own);
|
||||
stack.push(sf);
|
||||
let low = own;
|
||||
const edges = edgesOf(sf);
|
||||
for (const list of [edges.imports, edges.reexports, edges.calls]) {
|
||||
for (const edge of list) {
|
||||
const reached =
|
||||
edge.dep !== null ? visit(edge.dep) : externalPure(sf, edge.spec) ? low : -1;
|
||||
if (reached < 0) return -1;
|
||||
if (reached < low) low = reached;
|
||||
}
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return;
|
||||
const arg = node.arguments.nodes[0];
|
||||
if (!arg || !ts.isStringLiteralLike(arg)) return;
|
||||
if (node.expression.kind === ts.SyntaxKind.ImportKeyword)
|
||||
edges.push({ spec: arg.text, kind: "import" });
|
||||
else if (ts.isIdentifier(node.expression) && node.expression.text === "require")
|
||||
edges.push({ spec: arg.text, kind: "require" });
|
||||
});
|
||||
return edges.every((edge) => {
|
||||
const dep = resolveEdge(sf, edge.spec, edge.kind);
|
||||
if (dep !== null) return visit(dep);
|
||||
const resolved = resolveBareModule(sf.fileName, edge.spec, "js-only");
|
||||
const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile);
|
||||
const root =
|
||||
pkg === null || resolved === null
|
||||
? null
|
||||
: packageRootJsonPath(resolved.typesFile, pkg, packageJsons);
|
||||
return root !== null && pureExternalPackage(root, packageJsons);
|
||||
});
|
||||
};
|
||||
const pure = visit(root);
|
||||
if (pure) for (const sf of visited) pureMemo.set(sf, true);
|
||||
else pureMemo.set(root, false);
|
||||
return pure;
|
||||
}
|
||||
if (low === own) {
|
||||
for (;;) {
|
||||
const member = stack.pop()!;
|
||||
order.delete(member);
|
||||
pureMemo.set(member, true);
|
||||
if (member === sf) break;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
};
|
||||
const pureModuleTree = (root: ts.SourceFile): boolean => {
|
||||
if (visit(root) >= 0) return true;
|
||||
for (const sf of stack) pureMemo.set(sf, false);
|
||||
stack.length = 0;
|
||||
order.clear();
|
||||
return false;
|
||||
};
|
||||
const canPrune = (
|
||||
sf: ts.SourceFile,
|
||||
@@ -245,78 +333,32 @@ export function planNpmStaticReexports(
|
||||
return names !== undefined && names !== null && !names.has(stmt.exportClause.name.text);
|
||||
};
|
||||
|
||||
// A file is queued again only when its demand grows. Its imports and
|
||||
// calls request the same modules every time; only a namespace re-export
|
||||
// the new demand names stops being prunable.
|
||||
const walked = new Set<ts.SourceFile>();
|
||||
while (queue.length > 0) {
|
||||
const sf = queue.shift()!;
|
||||
for (const stmt of sf.statements.nodes) {
|
||||
if (ts.isImportDeclaration(stmt)) {
|
||||
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
||||
const clause = stmt.importClause;
|
||||
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
|
||||
const dep = resolveEdge(sf, stmt.moduleSpecifier.text);
|
||||
if (
|
||||
clause === undefined ||
|
||||
(clause.namedBindings !== undefined &&
|
||||
(ts.isNamespaceImport(clause.namedBindings) ||
|
||||
(ts.isNamedImports(clause.namedBindings) &&
|
||||
clause.namedBindings.elements.nodes.length === 0))) ||
|
||||
// `import { type A }` still loads the module, like `import {}`.
|
||||
typeOnlyNamedImport(stmt)
|
||||
) {
|
||||
request(dep, null);
|
||||
} else {
|
||||
if (clause.name !== undefined) request(dep, "default");
|
||||
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const element of clause.namedBindings.elements.nodes) {
|
||||
if (!element.isTypeOnly) request(dep, (element.propertyName ?? element.name).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ts.isExportDeclaration(stmt)) {
|
||||
// `export { type A } from` still loads the module under Node's
|
||||
// type stripping; only `export type … from` erases.
|
||||
if (
|
||||
stmt.isTypeOnly ||
|
||||
stmt.moduleSpecifier === undefined ||
|
||||
!ts.isStringLiteral(stmt.moduleSpecifier)
|
||||
)
|
||||
continue;
|
||||
const dep = resolveEdge(sf, stmt.moduleSpecifier.text);
|
||||
if (canPrune(sf, stmt, dep)) continue;
|
||||
request(dep, null);
|
||||
const edges = edgesOf(sf);
|
||||
const first = !walked.has(sf);
|
||||
walked.add(sf);
|
||||
if (first) {
|
||||
for (const edge of edges.imports) {
|
||||
if (edge.names === null) request(edge.dep, null);
|
||||
else for (const name of edge.names) request(edge.dep, name);
|
||||
}
|
||||
}
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return undefined;
|
||||
const arg = node.arguments.nodes[0];
|
||||
if (arg === undefined || !ts.isStringLiteralLike(arg)) return undefined;
|
||||
if (
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === "require")
|
||||
) {
|
||||
request(
|
||||
resolveEdge(
|
||||
sf,
|
||||
arg.text,
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword ? "import" : "require",
|
||||
),
|
||||
null,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
for (const edge of edges.reexports) {
|
||||
if (canPrune(sf, edge.stmt, edge.dep)) continue;
|
||||
request(edge.dep, null);
|
||||
}
|
||||
if (first) for (const edge of edges.calls) request(edge.dep, null);
|
||||
}
|
||||
|
||||
const pruned = new Set<ts.ExportDeclaration>();
|
||||
for (const sf of demanded.keys()) {
|
||||
for (const stmt of sf.statements.nodes) {
|
||||
if (
|
||||
!ts.isExportDeclaration(stmt) ||
|
||||
stmt.moduleSpecifier === undefined ||
|
||||
!ts.isStringLiteral(stmt.moduleSpecifier)
|
||||
)
|
||||
continue;
|
||||
if (canPrune(sf, stmt, resolveEdge(sf, stmt.moduleSpecifier.text))) pruned.add(stmt);
|
||||
}
|
||||
for (const edge of edgesOf(sf).reexports)
|
||||
if (canPrune(sf, edge.stmt, edge.dep)) pruned.add(edge.stmt);
|
||||
}
|
||||
program.analysis.prunedNpmReexports.clear();
|
||||
for (const stmt of pruned) program.analysis.prunedNpmReexports.add(stmt);
|
||||
|
||||
@@ -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 { reportShapes } from "./shape-inference/index.js";
|
||||
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
|
||||
import {
|
||||
containsJsx,
|
||||
@@ -487,43 +488,33 @@ function visitNpmStaticDeclarationClosure(
|
||||
visit(entryPath);
|
||||
}
|
||||
|
||||
function npmStaticDeclarationOverloadsOf(
|
||||
/** The overloads and properties of the classes a package's declaration
|
||||
* closure declares, read from one parse of each declaration file. */
|
||||
function npmStaticDeclarationMetadataOf(
|
||||
services: FrontendServices,
|
||||
entryPath: string,
|
||||
): NpmStaticDeclarationOverloads {
|
||||
const classes = new Map<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
|
||||
): { overloads: NpmStaticDeclarationOverloads; properties: NpmStaticDeclarationProperties } {
|
||||
const overloads = new Map<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
|
||||
const properties = new Map<string, Map<string, string>>();
|
||||
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
|
||||
for (const [className, methods] of parseNpmStaticDeclarationOverloads(
|
||||
services.parse(path, source, "ts"),
|
||||
)) {
|
||||
const sourceFile = services.parse(path, source, "ts");
|
||||
for (const [className, methods] of parseNpmStaticDeclarationOverloads(sourceFile)) {
|
||||
const target =
|
||||
classes.get(className) ?? new Map<string, readonly NpmStaticOverloadSignature[]>();
|
||||
classes.set(className, target);
|
||||
overloads.get(className) ?? new Map<string, readonly NpmStaticOverloadSignature[]>();
|
||||
overloads.set(className, target);
|
||||
for (const [methodName, signatures] of methods) {
|
||||
if (!target.has(methodName)) target.set(methodName, signatures);
|
||||
}
|
||||
}
|
||||
});
|
||||
return classes;
|
||||
}
|
||||
|
||||
function npmStaticDeclarationPropertiesOf(
|
||||
services: FrontendServices,
|
||||
entryPath: string,
|
||||
): NpmStaticDeclarationProperties {
|
||||
const classes = new Map<string, Map<string, string>>();
|
||||
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
|
||||
for (const [className, properties] of parseNpmStaticDeclarationProperties(
|
||||
services.parse(path, source, "ts"),
|
||||
)) {
|
||||
const target = classes.get(className) ?? new Map<string, string>();
|
||||
classes.set(className, target);
|
||||
for (const [propertyName, type] of properties) {
|
||||
for (const [className, fields] of parseNpmStaticDeclarationProperties(sourceFile)) {
|
||||
const target = properties.get(className) ?? new Map<string, string>();
|
||||
properties.set(className, target);
|
||||
for (const [propertyName, type] of fields) {
|
||||
if (!target.has(propertyName)) target.set(propertyName, type);
|
||||
}
|
||||
}
|
||||
});
|
||||
return classes;
|
||||
return { overloads, properties };
|
||||
}
|
||||
|
||||
/** See LoadResult.startupCrash: Node's exact error message, the IR error
|
||||
@@ -1095,8 +1086,7 @@ export function loadProgram(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const overloads = npmStaticDeclarationOverloadsOf(services, resolved.typesFile);
|
||||
const properties = npmStaticDeclarationPropertiesOf(services, resolved.typesFile);
|
||||
const { overloads, properties } = npmStaticDeclarationMetadataOf(services, resolved.typesFile);
|
||||
const classNames = new Set([...overloads.keys(), ...properties.keys()]);
|
||||
if (classNames.size === 0) continue;
|
||||
const runtime = resolveBareModule(entryPath, pkg, "js-only");
|
||||
@@ -1260,6 +1250,10 @@ export function loadProgram(
|
||||
npmStaticPackages.length > 0,
|
||||
projector,
|
||||
);
|
||||
// Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES).
|
||||
const shapes = process.env["SCRIPTC_DEBUG_SHAPES"];
|
||||
if (shapes !== undefined && shapes !== "" && shapes !== "0" && npmStaticPackages.length > 0)
|
||||
reportShapes(load.program, shapes);
|
||||
return {
|
||||
...load,
|
||||
services,
|
||||
@@ -5358,6 +5352,16 @@ function preflight7(load: LoadResult): {
|
||||
return new RegExp(`\\btypeof\\s*\\(?\\s*${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(sf.text);
|
||||
};
|
||||
|
||||
// A `return` at the top level of a CommonJS module (TS1108): Node runs
|
||||
// the module as a function body, so the return ends the module's
|
||||
// evaluation. ES modules and TypeScript keep the error.
|
||||
const cjsTopLevelReturnSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
|
||||
if (d.code !== 1108 || d.fileName === undefined || !isJsSourceFileName(d.fileName))
|
||||
return false;
|
||||
const sf = p.getSourceFile(d.fileName);
|
||||
return sf !== undefined && isCjsJsFile7(sf, p);
|
||||
};
|
||||
|
||||
const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
|
||||
if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined)
|
||||
return false;
|
||||
@@ -5404,6 +5408,7 @@ function preflight7(load: LoadResult): {
|
||||
!isNpmStaticSubclassArgument(p, d) &&
|
||||
!workspaceImplicitAnySuppressed(p, d) &&
|
||||
!typeofTestedNameSuppressed(p, d) &&
|
||||
!cjsTopLevelReturnSuppressed(p, d) &&
|
||||
!jsdocTypeSuppressed(p, d, commentDup),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,12 @@ import * as syntax from "./npm-static-declaration-syntax.js";
|
||||
import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js";
|
||||
import type { SourceFile } from "./ts7/ast-types.js";
|
||||
|
||||
/** Whether the source can contain `word` as a keyword or identifier: it
|
||||
* holds the word itself or a Unicode escape that might spell it. */
|
||||
function spells(source: string, word: string): boolean {
|
||||
return source.includes(word) || source.includes("\\u");
|
||||
}
|
||||
|
||||
/** Evaluate an isolated, self-contained TypeScript callback under a finite
|
||||
* budget. Hosts supply execution; shared lowering owns capture checks and
|
||||
* verifies the returned value before baking it into the program. */
|
||||
@@ -150,13 +156,18 @@ export class FrontendServices {
|
||||
return normalizeCjsExportsVar(this.parse(path, source, "js"));
|
||||
}
|
||||
nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
|
||||
// Rewrites `null`-initialized fields of class declarations.
|
||||
if (!spells(source, "class") || !spells(source, "null")) return null;
|
||||
return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source);
|
||||
}
|
||||
jsDocNamepaths(path: string, source: string): string | null {
|
||||
if (!source.includes("~") && !source.includes("#")) return null;
|
||||
// A namepath segment joined by `#` or `~` (applyNpmStaticJsDocNamepaths).
|
||||
if (!/[$\w][#~][$A-Z_a-z]/.test(source)) return null;
|
||||
return syntax.applyNpmStaticJsDocNamepaths(this.parse(path, source, "js"), source);
|
||||
}
|
||||
findReturnWidening(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
|
||||
// Widens `find` calls returned by methods of class declarations.
|
||||
if (!spells(source, "class") || !spells(source, "find")) return null;
|
||||
return syntax.applyNpmStaticFindReturnWidening(this.parse(path, source, "js"), source);
|
||||
}
|
||||
declarationProperties(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
/* Static-object specialization, stage 1 (DESIGN-constant-objects.md):
|
||||
* constant object graphs.
|
||||
*
|
||||
* An object literal is a SINGLETON when the code that creates it runs at
|
||||
* most once: at module top level, or in a function every call path to which
|
||||
* starts at module evaluation through exactly one call site outside loops
|
||||
* (IIFEs, revealing modules, init helpers). A singleton's field is CONSTANT
|
||||
* when every store to it runs in such once-only code, no reflective or
|
||||
* computed write reaches the object, and the object never escapes to code
|
||||
* the analysis cannot see. A constant object has only constant fields and a
|
||||
* fixed key set; constant objects whose constant fields point at each other
|
||||
* form a constant graph (`ns`, `ns.util`, `ns.util.trim`).
|
||||
*
|
||||
* This stage computes the graphs and reports them, with the rule that
|
||||
* demoted every near miss, and the reads and calls a later stage could fold.
|
||||
* Nothing reaches code generation. */
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import type { Atom } from "./domain.js";
|
||||
import type { FunctionInfo, Op, ShapeAnalysis } from "./analyze.js";
|
||||
import { npmStaticPackageOfPath } from "../npm-static.js";
|
||||
|
||||
type Node = ts.Node;
|
||||
|
||||
export interface ConstantField {
|
||||
readonly key: string;
|
||||
/** Distinct atoms the field may hold. */
|
||||
readonly values: readonly Atom[];
|
||||
/** Stores (the literal's own member included). */
|
||||
readonly stores: number;
|
||||
}
|
||||
|
||||
/** An object the analysis tracks as one allocation: an object literal, a
|
||||
* function object (a namespace or an ES5 constructor), or a constructor's
|
||||
* default prototype object. */
|
||||
export interface Candidate {
|
||||
readonly atom: Atom;
|
||||
/** The syntax that creates it (the literal or the function). */
|
||||
readonly node: Node;
|
||||
readonly kind: "literal" | "function" | "prototype";
|
||||
/** Keys the creating syntax itself defines. */
|
||||
readonly keys: readonly string[];
|
||||
/** Whether the creating syntax is a plain data literal. */
|
||||
readonly plain: boolean;
|
||||
readonly inPackage: boolean;
|
||||
}
|
||||
|
||||
export interface ConstantObject {
|
||||
readonly site: Candidate;
|
||||
readonly fields: readonly ConstantField[];
|
||||
readonly frozen: boolean;
|
||||
}
|
||||
|
||||
export interface NearMiss {
|
||||
readonly site: Candidate;
|
||||
/** Null when the whole object was demoted. */
|
||||
readonly key: string | null;
|
||||
readonly rule:
|
||||
| "init-factory"
|
||||
| "allocated-repeatedly"
|
||||
| "escapes"
|
||||
| "reflective-write"
|
||||
| "computed-write"
|
||||
| "literal-form"
|
||||
| "written-after-init"
|
||||
| "lazy-cache";
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
export interface ConstantGraphs {
|
||||
readonly objects: readonly ConstantObject[];
|
||||
readonly nearMisses: readonly NearMiss[];
|
||||
/** Reads `e.k` whose receiver is exactly one constant object and whose
|
||||
* field is constant, and the subset that are callees. */
|
||||
readonly foldableReads: number;
|
||||
readonly foldableCalls: number;
|
||||
/** Reads of a constant field through a receiver that may also be other
|
||||
* values. */
|
||||
readonly mixedReads: number;
|
||||
readonly once: (fn: Node) => boolean;
|
||||
}
|
||||
|
||||
function isLoop(n: Node): boolean {
|
||||
return (
|
||||
ts.isForStatement(n) ||
|
||||
ts.isForInStatement(n) ||
|
||||
ts.isForOfStatement(n) ||
|
||||
ts.isWhileStatement(n) ||
|
||||
ts.isDoStatement(n)
|
||||
);
|
||||
}
|
||||
|
||||
/** The function (or source file) whose body contains `n`. */
|
||||
function ownerFunction(n: Node): Node {
|
||||
for (let p = n.parent; p !== undefined; p = p.parent) {
|
||||
if (ts.isSourceFile(p)) return p;
|
||||
if (ts.isFunctionLike(p) && (p as { body?: Node }).body !== undefined) return p;
|
||||
}
|
||||
return n.getSourceFile();
|
||||
}
|
||||
|
||||
/** Whether `n` sits in a loop of its own function's body. */
|
||||
function inLoop(n: Node, owner: Node): boolean {
|
||||
for (let p = n.parent; p !== undefined && p !== owner; p = p.parent) if (isLoop(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function loc(n: Node): string {
|
||||
const sf = n.getSourceFile();
|
||||
const file = sf.fileName.split("/node_modules/").pop() ?? sf.fileName;
|
||||
return `${file.split("/").slice(-2).join("/")}:${sf.getLineAndCharacterOfPosition(ts.tokenStartOfNode(n)).line + 1}`;
|
||||
}
|
||||
|
||||
/** `x.k || (x.k = v)`, `x.k ??= v`, `x.k ||= v`, `if (!x.k) x.k = v`. */
|
||||
function isCacheStore(write: Node): boolean {
|
||||
let assign: Node | undefined = write.parent;
|
||||
while (assign !== undefined && ts.isParenthesizedExpression(assign)) assign = assign.parent;
|
||||
if (assign === undefined || !ts.isBinaryExpression(assign)) return false;
|
||||
const op = assign.operatorToken.kind;
|
||||
if (op === ts.SyntaxKind.BarBarEqualsToken || op === ts.SyntaxKind.QuestionQuestionEqualsToken)
|
||||
return true;
|
||||
if (op !== ts.SyntaxKind.EqualsToken) return false;
|
||||
const text = write.getText();
|
||||
let up: Node | undefined = assign.parent;
|
||||
while (up !== undefined && ts.isParenthesizedExpression(up)) up = up.parent;
|
||||
if (
|
||||
up !== undefined &&
|
||||
ts.isBinaryExpression(up) &&
|
||||
(up.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
|
||||
up.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) &&
|
||||
up.left.getText() === text
|
||||
)
|
||||
return true;
|
||||
for (let p: Node | undefined = assign.parent; p !== undefined; p = p.parent) {
|
||||
if (ts.isIfStatement(p)) {
|
||||
const cond = p.expression.getText().replace(/\s+/g, "");
|
||||
return cond === `!${text}` || cond === `${text}===undefined` || cond === `${text}==null`;
|
||||
}
|
||||
if (ts.isFunctionLike(p) || ts.isSourceFile(p)) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const OBJECT_DEMOTING: ReadonlySet<Op["kind"]> = new Set([
|
||||
"computedWrite",
|
||||
"delete",
|
||||
"defineProperty",
|
||||
"setPrototypeOf",
|
||||
"assign",
|
||||
"proxy",
|
||||
]);
|
||||
|
||||
export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
|
||||
const infosByNode = new Map<Node, FunctionInfo[]>();
|
||||
for (const info of analysis.functions.values()) {
|
||||
let l = infosByNode.get(info.node);
|
||||
if (l === undefined) infosByNode.set(info.node, (l = []));
|
||||
l.push(info);
|
||||
}
|
||||
const edgesTo = new Map<Node, (typeof analysis.calls)[number][]>();
|
||||
for (const e of analysis.calls) {
|
||||
let l = edgesTo.get(e.callee.node);
|
||||
if (l === undefined) edgesTo.set(e.callee.node, (l = []));
|
||||
l.push(e);
|
||||
}
|
||||
const escapedNodes = new Set<Node>([...analysis.escapedFunctions].map((f) => f.node));
|
||||
|
||||
const onceMemo = new Map<Node, boolean>();
|
||||
const once = (fn: Node): boolean => {
|
||||
if (ts.isSourceFile(fn)) return true;
|
||||
const known = onceMemo.get(fn);
|
||||
if (known !== undefined) return known;
|
||||
onceMemo.set(fn, false); // a cycle is not once-only
|
||||
let result = false;
|
||||
const edges = edgesTo.get(fn) ?? [];
|
||||
if (
|
||||
infosByNode.has(fn) &&
|
||||
!escapedNodes.has(fn) &&
|
||||
edges.length > 0 &&
|
||||
new Set(edges.map((e) => e.site)).size === 1
|
||||
) {
|
||||
result = edges.every(
|
||||
(e) =>
|
||||
!e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && once(e.caller.node),
|
||||
);
|
||||
}
|
||||
onceMemo.set(fn, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Whether every call of a function comes from once-only code outside
|
||||
// loops, through any number of call sites: an init factory, whose
|
||||
// allocations are one object per call site (schema builders called at
|
||||
// module initialization).
|
||||
const initOnlyMemo = new Map<Node, boolean>();
|
||||
const initOnly = (fn: Node): boolean => {
|
||||
if (ts.isSourceFile(fn)) return true;
|
||||
const known = initOnlyMemo.get(fn);
|
||||
if (known !== undefined) return known;
|
||||
initOnlyMemo.set(fn, false);
|
||||
const edges = edgesTo.get(fn) ?? [];
|
||||
const result =
|
||||
infosByNode.has(fn) &&
|
||||
!escapedNodes.has(fn) &&
|
||||
edges.length > 0 &&
|
||||
edges.every(
|
||||
(e) =>
|
||||
!e.inLoop &&
|
||||
e.caller !== null &&
|
||||
!inLoop(e.site, e.caller.node) &&
|
||||
(once(e.caller.node) || initOnly(e.caller.node)),
|
||||
);
|
||||
initOnlyMemo.set(fn, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const opsByAtom = new Map<Atom, Op[]>();
|
||||
for (const op of analysis.ops) {
|
||||
for (const a of analysis.nodes[op.recv]!.atoms) {
|
||||
let l = opsByAtom.get(a);
|
||||
if (l === undefined) opsByAtom.set(a, (l = []));
|
||||
l.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
const objects: ConstantObject[] = [];
|
||||
const nearMisses: NearMiss[] = [];
|
||||
const constantKeys = new Map<Atom, Set<string>>();
|
||||
const candidates: Candidate[] = [];
|
||||
for (const site of analysis.sites) {
|
||||
if (site.kind !== "object") continue;
|
||||
candidates.push({
|
||||
atom: site.atom,
|
||||
node: site.node,
|
||||
kind: "literal",
|
||||
keys: site.keys ?? [],
|
||||
plain: !(
|
||||
site.hasAccessor ||
|
||||
site.hasComputedKey ||
|
||||
site.spreads.length > 0 ||
|
||||
site.keys === null
|
||||
),
|
||||
inPackage: site.inPackage,
|
||||
});
|
||||
}
|
||||
const inPackage = (n: Node): boolean =>
|
||||
npmStaticPackageOfPath(n.getSourceFile().fileName) !== null;
|
||||
for (const atom of analysis.atoms.atoms) {
|
||||
if (atom.kind !== "function" || atom.node === undefined) continue;
|
||||
const hasFields = (analysis.fields.get(atom)?.size ?? 0) > 0;
|
||||
const proto = analysis.protoOf.get(atom);
|
||||
if (!hasFields && proto === undefined) continue;
|
||||
candidates.push({
|
||||
atom,
|
||||
node: atom.node,
|
||||
kind: "function",
|
||||
keys: [],
|
||||
plain: true,
|
||||
inPackage: inPackage(atom.node),
|
||||
});
|
||||
if (proto !== undefined)
|
||||
candidates.push({
|
||||
atom: proto,
|
||||
node: atom.node,
|
||||
kind: "prototype",
|
||||
keys: ["constructor"],
|
||||
plain: true,
|
||||
inPackage: inPackage(atom.node),
|
||||
});
|
||||
}
|
||||
for (const site of candidates) {
|
||||
// A function object is created where its expression or declaration
|
||||
// runs; its prototype with it.
|
||||
const owner = ownerFunction(site.node);
|
||||
if (!once(owner) || inLoop(site.node, owner)) {
|
||||
const factory = !inLoop(site.node, owner) && initOnly(owner);
|
||||
if (site.inPackage || factory)
|
||||
nearMisses.push({
|
||||
site,
|
||||
key: null,
|
||||
rule: factory ? "init-factory" : "allocated-repeatedly",
|
||||
detail: factory
|
||||
? `one object per call of ${loc(owner)} (${new Set((edgesTo.get(owner) ?? []).map((e) => e.site)).size} call sites)`
|
||||
: `created in ${owner.kind === ts.SyntaxKind.SourceFile ? "a module loop" : loc(owner)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const escaped = analysis.escaped.get(site.atom);
|
||||
if (escaped !== undefined) {
|
||||
nearMisses.push({ site, key: null, rule: "escapes", detail: escaped });
|
||||
continue;
|
||||
}
|
||||
if (!site.plain) {
|
||||
nearMisses.push({
|
||||
site,
|
||||
key: null,
|
||||
rule: "literal-form",
|
||||
detail: "accessor, computed key or spread in the literal",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const ops = opsByAtom.get(site.atom) ?? [];
|
||||
const demoting = ops.find((op) => OBJECT_DEMOTING.has(op.kind));
|
||||
if (demoting !== undefined) {
|
||||
nearMisses.push({
|
||||
site,
|
||||
key: null,
|
||||
rule: demoting.kind === "computedWrite" ? "computed-write" : "reflective-write",
|
||||
detail: `${demoting.kind} at ${loc(demoting.node)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const frozen = ops.some((op) => op.kind === "freeze");
|
||||
const stores = new Map<string, number>();
|
||||
for (const k of site.keys) stores.set(k, 1);
|
||||
// A function's own data fields come only from stores.
|
||||
const demotedKeys = new Map<string, NearMiss>();
|
||||
for (const op of ops) {
|
||||
// A `length` store on an object (not an array) is an ordinary write.
|
||||
if ((op.kind !== "write" && op.kind !== "lengthWrite") || op.name === null) continue;
|
||||
stores.set(op.name, (stores.get(op.name) ?? 0) + 1);
|
||||
const fn = ownerFunction(op.node);
|
||||
if (once(fn) && !inLoop(op.node, fn)) continue;
|
||||
if (demotedKeys.has(op.name)) continue;
|
||||
demotedKeys.set(op.name, {
|
||||
site,
|
||||
key: op.name,
|
||||
rule: isCacheStore(op.node) ? "lazy-cache" : "written-after-init",
|
||||
detail: loc(op.node),
|
||||
});
|
||||
}
|
||||
for (const miss of demotedKeys.values()) nearMisses.push(miss);
|
||||
const fieldMap = analysis.fields.get(site.atom) ?? new Map<string, number>();
|
||||
const fields: ConstantField[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const [key, count] of stores) {
|
||||
if (demotedKeys.has(key)) continue;
|
||||
const id = fieldMap.get(key);
|
||||
const values = id === undefined ? [] : [...analysis.nodes[id]!.atoms];
|
||||
fields.push({ key, values, stores: count });
|
||||
keys.add(key);
|
||||
}
|
||||
constantKeys.set(site.atom, keys);
|
||||
if (demotedKeys.size === 0) objects.push({ site, fields, frozen });
|
||||
}
|
||||
|
||||
let foldableReads = 0,
|
||||
foldableCalls = 0,
|
||||
mixedReads = 0;
|
||||
for (const op of analysis.ops) {
|
||||
if (op.kind !== "read" || op.name === null) continue;
|
||||
const atoms = analysis.nodes[op.recv]!.atoms;
|
||||
const constant = [...atoms].filter((a) => constantKeys.get(a)?.has(op.name!) === true);
|
||||
if (constant.length === 0) continue;
|
||||
if (atoms.size === 1) {
|
||||
foldableReads++;
|
||||
const p = op.node.parent;
|
||||
if (p !== undefined && ts.isCallExpression(p) && p.expression === op.node) foldableCalls++;
|
||||
} else mixedReads++;
|
||||
}
|
||||
return { objects, nearMisses, foldableReads, foldableCalls, mixedReads, once };
|
||||
}
|
||||
|
||||
/** Report lines (SCRIPTC_DEBUG_SHAPES=const): per package, the constant
|
||||
* graphs with their roots, the near misses by rule, and the foldable reads. */
|
||||
export function constantGraphReport(analysis: ShapeAnalysis, graphs: ConstantGraphs): string[] {
|
||||
const lines: string[] = [];
|
||||
const pkgOf = (site: Candidate): string =>
|
||||
npmStaticPackageOfPath(site.node.getSourceFile().fileName) ?? "<user>";
|
||||
const byPkg = new Map<string, { objects: ConstantObject[]; misses: NearMiss[] }>();
|
||||
const entry = (pkg: string) => {
|
||||
let e = byPkg.get(pkg);
|
||||
if (e === undefined) byPkg.set(pkg, (e = { objects: [], misses: [] }));
|
||||
return e;
|
||||
};
|
||||
for (const o of graphs.objects) entry(pkgOf(o.site)).objects.push(o);
|
||||
for (const m of graphs.nearMisses) entry(pkgOf(m.site)).misses.push(m);
|
||||
const constantAtoms = new Set(graphs.objects.map((o) => o.site.atom));
|
||||
const referenced = new Set<Atom>();
|
||||
for (const o of graphs.objects)
|
||||
for (const f of o.fields)
|
||||
if (f.values.length === 1 && constantAtoms.has(f.values[0]!)) referenced.add(f.values[0]!);
|
||||
const describe = (o: ConstantObject, depth: number, seen: Set<Atom>): string => {
|
||||
const parts = o.fields.map((f) => {
|
||||
if (f.values.length !== 1)
|
||||
return `${f.key}: ${f.values.length === 0 ? "?" : f.values.map((a) => a.label).join("|")}`;
|
||||
const v = f.values[0]!;
|
||||
const child = graphs.objects.find((x) => x.site.atom === v);
|
||||
if (child !== undefined && depth < 3 && !seen.has(v)) {
|
||||
seen.add(v);
|
||||
return `${f.key}: ${describe(child, depth + 1, seen)}`;
|
||||
}
|
||||
return `${f.key}: ${v.kind === "function" && child === undefined ? "fn" : v.label}`;
|
||||
});
|
||||
const shown = parts.length > 8 ? [...parts.slice(0, 8), `… ${parts.length - 8} more`] : parts;
|
||||
return `{${shown.join(", ")}}${o.frozen ? " (frozen)" : ""}`;
|
||||
};
|
||||
lines.push(
|
||||
`analysis ${analysis.steps} steps${analysis.budgetExceeded ? " (BUDGET EXCEEDED: facts incomplete)" : ""}, ${analysis.files.length} files`,
|
||||
);
|
||||
lines.push(
|
||||
`constant objects ${graphs.objects.length}, near misses ${graphs.nearMisses.length}; foldable reads ${graphs.foldableReads} (calls ${graphs.foldableCalls}), mixed-receiver reads ${graphs.mixedReads}`,
|
||||
);
|
||||
for (const [pkg, e] of [...byPkg].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
const fields = e.objects.reduce((n, o) => n + o.fields.length, 0);
|
||||
const rules = new Map<string, number>();
|
||||
for (const m of e.misses) rules.set(m.rule, (rules.get(m.rule) ?? 0) + 1);
|
||||
lines.push(
|
||||
`${pkg}: ${e.objects.length} constant objects (${fields} fields); near misses ${[...rules].map(([r, n]) => `${r} ${n}`).join(", ") || "none"}`,
|
||||
);
|
||||
for (const o of e.objects) {
|
||||
if (referenced.has(o.site.atom)) continue;
|
||||
lines.push(` graph ${loc(o.site.node)} ${describe(o, 0, new Set([o.site.atom]))}`);
|
||||
}
|
||||
for (const m of e.misses.filter((x) => x.rule !== "allocated-repeatedly").slice(0, 40)) {
|
||||
lines.push(
|
||||
` near miss ${loc(m.site.node)}${m.key === null ? "" : ` .${m.key}`}: ${m.rule} (${m.detail})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Shape inference (results/design-shape-inference/DESIGN.md), stage S1:
|
||||
* the abstract domain of the allocation-site points-to analysis.
|
||||
*
|
||||
* An abstract value is a set of atoms. An atom names a set of runtime
|
||||
* values: a primitive kind, an allocation site (object or array literal, or
|
||||
* a value a builtin creates at one call site), the instances of one class,
|
||||
* one class value, one function (closure site), a builtin object, or ⊤ —
|
||||
* every value the analysis does not track. Value nodes hold atom sets and
|
||||
* flow into each other along inclusion edges; the solver (analyze.ts)
|
||||
* attaches load, store and call constraints that fire as atoms arrive. */
|
||||
import type * as ts from "../ts7/adapter.js";
|
||||
|
||||
export type AtomKind =
|
||||
| "prim"
|
||||
| "top"
|
||||
| "object"
|
||||
| "array"
|
||||
| "instance"
|
||||
| "class"
|
||||
| "function"
|
||||
| "builtin"
|
||||
| "promise"
|
||||
| "collection"
|
||||
| "dictionary";
|
||||
|
||||
export interface Atom {
|
||||
readonly id: number;
|
||||
readonly kind: AtomKind;
|
||||
/** Short display name, e.g. `string`, `ZodString`, `{status,value}@types.js:804`. */
|
||||
readonly label: string;
|
||||
/** The syntax that creates the atom: the literal, class or function
|
||||
* declaration, or the builtin call. Undefined for prims, ⊤ and builtins. */
|
||||
readonly node?: ts.Node | undefined;
|
||||
/** builtin atoms: the dotted path (`Object.keys`, `array.push`). */
|
||||
readonly path?: string | undefined;
|
||||
/** method atoms of builtin receivers: the receiver atom. */
|
||||
readonly receiver?: Atom | undefined;
|
||||
/** function atoms: the analysis context of the function's clone (a
|
||||
* method's receiver class), "" for the context-insensitive default. */
|
||||
readonly ctx?: string | undefined;
|
||||
}
|
||||
|
||||
export class AtomTable {
|
||||
readonly atoms: Atom[] = [];
|
||||
private readonly byKey = new Map<string, Atom>();
|
||||
|
||||
intern(key: string, make: (id: number) => Omit<Atom, "id">): Atom {
|
||||
let atom = this.byKey.get(key);
|
||||
if (atom === undefined) {
|
||||
const id = this.atoms.length;
|
||||
atom = { id, ...make(id) };
|
||||
this.atoms.push(atom);
|
||||
this.byKey.set(key, atom);
|
||||
}
|
||||
return atom;
|
||||
}
|
||||
}
|
||||
|
||||
/** Object.prototype members: a read of one of these names on a plain
|
||||
* object resolves on the prototype, never to an absent own key. */
|
||||
export const OBJECT_PROTOTYPE_MEMBERS: ReadonlySet<string> = new Set([
|
||||
"constructor",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"toLocaleString",
|
||||
"toString",
|
||||
"valueOf",
|
||||
"__defineGetter__",
|
||||
"__defineSetter__",
|
||||
"__lookupGetter__",
|
||||
"__lookupSetter__",
|
||||
"__proto__",
|
||||
]);
|
||||
|
||||
/** Global names the analysis models as builtin objects. Any other global is ⊤. */
|
||||
export const MODELED_GLOBALS: ReadonlySet<string> = new Set([
|
||||
"Object",
|
||||
"Array",
|
||||
"JSON",
|
||||
"Math",
|
||||
"Number",
|
||||
"String",
|
||||
"Boolean",
|
||||
"Symbol",
|
||||
"Promise",
|
||||
"Map",
|
||||
"Set",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"console",
|
||||
"Reflect",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"isNaN",
|
||||
"isFinite",
|
||||
"encodeURIComponent",
|
||||
"decodeURIComponent",
|
||||
"encodeURI",
|
||||
"decodeURI",
|
||||
"BigInt",
|
||||
"Proxy",
|
||||
]);
|
||||
|
||||
/** Results of string (and number) methods, by name. Anything else is ⊤. */
|
||||
type MethodResult = "string" | "number" | "boolean" | "array";
|
||||
export const STRING_METHOD_RESULTS: ReadonlyMap<string, MethodResult> = new Map<
|
||||
string,
|
||||
MethodResult
|
||||
>([
|
||||
["at", "string"],
|
||||
["charAt", "string"],
|
||||
["charCodeAt", "number"],
|
||||
["codePointAt", "number"],
|
||||
["concat", "string"],
|
||||
["endsWith", "boolean"],
|
||||
["includes", "boolean"],
|
||||
["indexOf", "number"],
|
||||
["lastIndexOf", "number"],
|
||||
["localeCompare", "number"],
|
||||
["match", "array"],
|
||||
["matchAll", "array"],
|
||||
["normalize", "string"],
|
||||
["padEnd", "string"],
|
||||
["padStart", "string"],
|
||||
["repeat", "string"],
|
||||
["replace", "string"],
|
||||
["replaceAll", "string"],
|
||||
["search", "number"],
|
||||
["slice", "string"],
|
||||
["split", "array"],
|
||||
["startsWith", "boolean"],
|
||||
["substring", "string"],
|
||||
["substr", "string"],
|
||||
["toLowerCase", "string"],
|
||||
["toUpperCase", "string"],
|
||||
["toLocaleLowerCase", "string"],
|
||||
["toLocaleUpperCase", "string"],
|
||||
["toString", "string"],
|
||||
["trim", "string"],
|
||||
["trimEnd", "string"],
|
||||
["trimStart", "string"],
|
||||
["valueOf", "string"],
|
||||
["test", "boolean"],
|
||||
["toFixed", "string"],
|
||||
["toPrecision", "string"],
|
||||
["toExponential", "string"],
|
||||
]);
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Shape inference for untyped npm JavaScript
|
||||
* (results/design-shape-inference/DESIGN.md). Stage S1: report only. */
|
||||
import type * as ts from "../ts7/adapter.js";
|
||||
import { analyzeShapes } from "./analyze.js";
|
||||
import { buildShapeReport } from "./report.js";
|
||||
import { computeConstantGraphs, constantGraphReport } from "./constant-graphs.js";
|
||||
|
||||
export { analyzeShapes } from "./analyze.js";
|
||||
export { buildShapeReport } from "./report.js";
|
||||
|
||||
/** SCRIPTC_DEBUG_SHAPES: `1` reports package code in hot functions,
|
||||
* `verbose` every package site, `all` user files too, any other value only
|
||||
* files whose path contains it. Writes to stderr; changes nothing else. */
|
||||
export function reportShapes(program: ts.Program, filter: string): void {
|
||||
const start = performance.now();
|
||||
const analysis = analyzeShapes(program, program.getTypeChecker());
|
||||
// `const`: the constant object graphs (DESIGN-constant-objects.md, stage 1).
|
||||
const lines =
|
||||
filter === "const"
|
||||
? constantGraphReport(analysis, computeConstantGraphs(analysis))
|
||||
: buildShapeReport(analysis, filter).lines;
|
||||
const ms = (performance.now() - start).toFixed(0);
|
||||
process.stderr.write(`[shapes] ${ms} ms\n${lines.map((l) => `[shapes] ${l}`).join("\n")}\n`);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/* Shape inference stage S1: the plan the analysis would hand the lowering,
|
||||
* as a report (DESIGN.md §4.5, §5.5). Nothing here changes compilation.
|
||||
*
|
||||
* Per allocation site of package code, the verdict:
|
||||
* P local — a native record would be exact: no reflection, no added
|
||||
* keys, no computed writes, and its objects never escape;
|
||||
* P escaping — exact as a record behind identity-keeping capsules: it
|
||||
* escapes, but the census leaves every later operation
|
||||
* answerable (fields untyped code may write become dyn);
|
||||
* G <reason> — keeps today's dynamic object, with the rule that decided.
|
||||
* Plus class field types, the census, and the hot functions' value types. */
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import { OBJECT_PROTOTYPE_MEMBERS, type Atom } from "./domain.js";
|
||||
import { locLabel, type FunctionInfo, type LiteralSite, type ShapeAnalysis } from "./analyze.js";
|
||||
|
||||
export interface SiteVerdict {
|
||||
readonly site: LiteralSite;
|
||||
readonly label: string;
|
||||
readonly verdict: "P-local" | "P-escaping" | "G";
|
||||
readonly reasons: string[];
|
||||
/** P-escaping: fields untyped code may write (they become dyn slots). */
|
||||
readonly writableByTop: string[];
|
||||
readonly freezable: boolean;
|
||||
readonly brand: boolean;
|
||||
}
|
||||
|
||||
export interface ShapeReport {
|
||||
readonly sites: SiteVerdict[];
|
||||
readonly hot: Set<FunctionInfo>;
|
||||
readonly lines: string[];
|
||||
}
|
||||
|
||||
const MAX_ATOMS_SHOWN = 6;
|
||||
|
||||
function fieldKeys(fields: ReadonlyMap<string, number> | undefined): string[] {
|
||||
return fields === undefined ? [] : [...fields.keys()];
|
||||
}
|
||||
|
||||
function fieldEntries(fields: ReadonlyMap<string, number> | undefined): [string, number][] {
|
||||
return fields === undefined ? [] : [...fields.entries()];
|
||||
}
|
||||
|
||||
export function describeValue(analysis: ShapeAnalysis, node: number): string {
|
||||
const atoms = [...analysis.nodes[node]!.atoms];
|
||||
if (atoms.length === 0) return "∅";
|
||||
const labels = [...new Set(atoms.map((a) => a.label))];
|
||||
labels.sort((a, b) => (a === "⊤" ? 1 : b === "⊤" ? -1 : a.localeCompare(b)));
|
||||
const shown = labels.slice(0, MAX_ATOMS_SHOWN).join(" | ");
|
||||
if (labels.length <= MAX_ATOMS_SHOWN) return shown;
|
||||
return `${shown} | +${labels.length - MAX_ATOMS_SHOWN}${labels.includes("⊤") ? " | ⊤" : ""}`;
|
||||
}
|
||||
|
||||
function atomsOf(analysis: ShapeAnalysis, node: number): Set<Atom> {
|
||||
return analysis.nodes[node]!.atoms;
|
||||
}
|
||||
|
||||
/** Functions reached from call sites in loops (or loop callbacks), and
|
||||
* everything they call: the static hotness heuristic (DESIGN.md §8.1). */
|
||||
export function hotFunctions(analysis: ShapeAnalysis): Set<FunctionInfo> {
|
||||
const hot = new Set<FunctionInfo>();
|
||||
const callees = new Map<FunctionInfo | null, FunctionInfo[]>();
|
||||
const work: FunctionInfo[] = [];
|
||||
for (const c of analysis.calls) {
|
||||
let l = callees.get(c.caller);
|
||||
if (l === undefined) callees.set(c.caller, (l = []));
|
||||
l.push(c.callee);
|
||||
if (c.inLoop && !hot.has(c.callee)) {
|
||||
hot.add(c.callee);
|
||||
work.push(c.callee);
|
||||
}
|
||||
}
|
||||
while (work.length > 0) {
|
||||
const f = work.pop()!;
|
||||
for (const g of callees.get(f) ?? []) {
|
||||
if (!hot.has(g)) {
|
||||
hot.add(g);
|
||||
work.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hot;
|
||||
}
|
||||
|
||||
export function buildShapeReport(analysis: ShapeAnalysis, filter: string): ShapeReport {
|
||||
const lines: string[] = [];
|
||||
const all = filter === "all" || filter === "verbose";
|
||||
const verbose = filter === "verbose";
|
||||
const fileFilter = filter !== "1" && !all ? filter : null;
|
||||
const wanted = (n: ts.Node, inPackage: boolean): boolean => {
|
||||
if (fileFilter !== null) return n.getSourceFile().fileName.includes(fileFilter);
|
||||
return all || inPackage;
|
||||
};
|
||||
|
||||
// Census facts: operations whose receiver may be ⊤ reach every escaped atom.
|
||||
|
||||
// Index ops by the site atoms they can reach directly.
|
||||
const byAtom = new Map<Atom, typeof analysis.ops>();
|
||||
const topOps: typeof analysis.ops = [];
|
||||
for (const o of analysis.ops) {
|
||||
const atoms = atomsOf(analysis, o.recv);
|
||||
for (const a of atoms) {
|
||||
if (a.kind !== "object" && a.kind !== "array") continue;
|
||||
let l = byAtom.get(a);
|
||||
if (l === undefined) byAtom.set(a, (l = []));
|
||||
l.push(o);
|
||||
}
|
||||
if (atoms.has(analysis.top)) topOps.push(o);
|
||||
}
|
||||
|
||||
// Key-order groups: sites with the same key set and different orders need brands.
|
||||
const orders = new Map<string, Set<string>>();
|
||||
for (const s of analysis.sites) {
|
||||
if (s.kind !== "object" || s.keys === null) continue;
|
||||
const set = [...s.keys].sort().join(",");
|
||||
let o = orders.get(set);
|
||||
if (o === undefined) orders.set(set, (o = new Set()));
|
||||
o.add(s.keys.join(","));
|
||||
}
|
||||
|
||||
const verdicts: SiteVerdict[] = [];
|
||||
for (const s of analysis.sites) {
|
||||
if (!s.inPackage && !all && fileFilter === null) continue;
|
||||
const reasons: string[] = [];
|
||||
const escapedWhy = analysis.escaped.get(s.atom);
|
||||
const escapes = escapedWhy !== undefined;
|
||||
if (s.hasAccessor) reasons.push("accessor in literal");
|
||||
if (s.hasProto) reasons.push(s.kind === "array" ? "hole in literal" : "__proto__ in literal");
|
||||
if (s.hasComputedKey) reasons.push("computed key in literal");
|
||||
for (const sp of s.spreads)
|
||||
if (atomsOf(analysis, sp).has(analysis.top)) reasons.push("spread of ⊤");
|
||||
const direct = byAtom.get(s.atom) ?? [];
|
||||
const ops = escapes ? [...direct, ...topOps] : direct;
|
||||
const keys = new Set(s.keys ?? []);
|
||||
const fieldsOf = analysis.fields.get(s.atom);
|
||||
const knownKeys =
|
||||
s.keys !== null ? keys : new Set([...fieldKeys(fieldsOf)].filter((k) => k !== "*"));
|
||||
let freezable = false;
|
||||
const seen = new Set<string>();
|
||||
const note = (r: string): void => {
|
||||
if (!seen.has(r)) {
|
||||
seen.add(r);
|
||||
reasons.push(r);
|
||||
}
|
||||
};
|
||||
for (const o of ops) {
|
||||
const viaTop = !atomsOf(analysis, o.recv).has(s.atom);
|
||||
const where = `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`;
|
||||
switch (o.kind) {
|
||||
case "delete":
|
||||
case "defineProperty":
|
||||
case "setPrototypeOf":
|
||||
case "seal":
|
||||
case "proxy":
|
||||
note(`${o.kind} at ${where}`);
|
||||
break;
|
||||
case "assign":
|
||||
note(`Object.assign target at ${where}`);
|
||||
break;
|
||||
case "freeze":
|
||||
freezable = true;
|
||||
break;
|
||||
case "computedWrite":
|
||||
if (!viaTop)
|
||||
note(s.kind === "array" ? `index write at ${where}` : `computed write at ${where}`);
|
||||
break;
|
||||
case "lengthWrite":
|
||||
if (s.kind === "array") note(`length write at ${where}`);
|
||||
break;
|
||||
case "write":
|
||||
if (s.kind === "object" && !viaTop && o.name !== null && !knownKeys.has(o.name))
|
||||
note(`adds key '${o.name}' at ${where}`);
|
||||
break;
|
||||
case "read":
|
||||
if (
|
||||
s.kind === "object" &&
|
||||
!underPromiseGuard(o.node) &&
|
||||
escapes &&
|
||||
o.name !== null &&
|
||||
!viaTop &&
|
||||
!knownKeys.has(o.name) &&
|
||||
!OBJECT_PROTOTYPE_MEMBERS.has(o.name)
|
||||
)
|
||||
note(`reads absent key '${o.name}' at ${where} (escapes)`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
let brand = false;
|
||||
if (s.kind === "object" && s.keys !== null) {
|
||||
const o = orders.get([...s.keys].sort().join(","));
|
||||
if (o !== undefined && o.size > 1) brand = true;
|
||||
}
|
||||
const writableByTop: string[] = [];
|
||||
if (escapes) {
|
||||
if (analysis.topComputedWrites.length > 0) writableByTop.push("*");
|
||||
else for (const k of knownKeys) if (analysis.topWriteNames.has(k)) writableByTop.push(k);
|
||||
}
|
||||
const verdict: SiteVerdict["verdict"] =
|
||||
reasons.length > 0 ? "G" : escapes ? "P-escaping" : "P-local";
|
||||
const label = s.atom.label;
|
||||
if (escapes) reasons.push(`escapes: ${escapedWhy}`);
|
||||
verdicts.push({ site: s, label, verdict, reasons, writableByTop, freezable, brand });
|
||||
}
|
||||
|
||||
const hot = hotFunctions(analysis);
|
||||
const hotNodes = new Set<ts.Node>([...hot].map((f) => f.node));
|
||||
|
||||
// ---- print ----
|
||||
const pkgFns = [...analysis.functions.values()].filter((f) => f.inPackage);
|
||||
lines.push(
|
||||
`analyzed ${analysis.files.length} files (${analysis.packageFiles} package JS), ${analysis.functions.size} functions (${pkgFns.length} in packages), ${analysis.atoms.atoms.length} atoms, ${analysis.nodes.length} value nodes, ${analysis.steps} steps${analysis.budgetExceeded ? " — BUDGET EXCEEDED, results incomplete" : ""}`,
|
||||
);
|
||||
const topNames = [...analysis.topWriteNames.entries()].map(
|
||||
([n, node]) => `${n} (${locLabel(node)})`,
|
||||
);
|
||||
lines.push(
|
||||
`census: writes on ⊤ receivers: ${topNames.length === 0 ? "none" : topNames.slice(0, 20).join(", ")}${topNames.length > 20 ? ` +${topNames.length - 20}` : ""}`,
|
||||
);
|
||||
lines.push(
|
||||
`census: computed writes on ⊤ receivers: ${analysis.topComputedWrites.length === 0 ? "none" : analysis.topComputedWrites.slice(0, 8).map(locLabel).join(", ")}`,
|
||||
);
|
||||
const topReflect = topOps.filter(
|
||||
(o) =>
|
||||
o.kind !== "read" &&
|
||||
o.kind !== "computedRead" &&
|
||||
o.kind !== "write" &&
|
||||
o.kind !== "computedWrite",
|
||||
);
|
||||
lines.push(
|
||||
`census: reflection on ⊤ receivers: ${topReflect.length === 0 ? "none" : topReflect.map((o) => `${o.kind} (${locLabel(o.node)})`).join(", ")}`,
|
||||
);
|
||||
const escapedPkg = [...analysis.escaped.keys()].filter(
|
||||
(a) =>
|
||||
a.node !== undefined &&
|
||||
wanted(a.node, a.node.getSourceFile().fileName.includes("/node_modules/")),
|
||||
);
|
||||
lines.push(
|
||||
`escaped atoms: ${analysis.escaped.size} (${escapedPkg.length} shown below where relevant)`,
|
||||
);
|
||||
const firstEscapes = [...analysis.escaped.entries()]
|
||||
.filter(([, why]) => !why.startsWith("via "))
|
||||
.slice(0, 12);
|
||||
for (const [atom, why] of firstEscapes) lines.push(` escape root: ${atom.label}: ${why}`);
|
||||
|
||||
const counts = { "P-local": 0, "P-escaping": 0, G: 0 };
|
||||
const hotCounts = { "P-local": 0, "P-escaping": 0, G: 0 };
|
||||
const inHot = (n: ts.Node): boolean => {
|
||||
let p: ts.Node | undefined = n.parent;
|
||||
while (p !== undefined) {
|
||||
if (hotNodes.has(p)) return true;
|
||||
p = p.parent;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const v of verdicts) {
|
||||
counts[v.verdict]++;
|
||||
if (inHot(v.site.node)) hotCounts[v.verdict]++;
|
||||
}
|
||||
lines.push(
|
||||
`sites: ${verdicts.length} (P local ${counts["P-local"]}, P escaping ${counts["P-escaping"]}, G ${counts.G}); in hot functions: P local ${hotCounts["P-local"]}, P escaping ${hotCounts["P-escaping"]}, G ${hotCounts.G}`,
|
||||
);
|
||||
for (const v of verdicts) {
|
||||
if (!wanted(v.site.node, v.site.inPackage)) continue;
|
||||
if (!verbose && !inHot(v.site.node)) continue;
|
||||
const extra: string[] = [];
|
||||
if (v.brand) extra.push("needs brand (key order)");
|
||||
if (v.freezable) extra.push("freezable");
|
||||
if (v.writableByTop.length > 0) extra.push(`⊤-writable: ${v.writableByTop.join(",")}`);
|
||||
const fields = analysis.fields.get(v.site.atom);
|
||||
const ftypes =
|
||||
v.site.kind === "array"
|
||||
? `elem: ${analysis.elements.has(v.site.atom) ? describeValue(analysis, analysis.elements.get(v.site.atom)!) : "∅"}`
|
||||
: fieldEntries(fields)
|
||||
.filter(([k]) => k !== "*")
|
||||
.map(([k, id]) => `${k}: ${describeValue(analysis, id)}`)
|
||||
.join("; ");
|
||||
lines.push(
|
||||
`site ${v.label}${inHot(v.site.node) ? " [hot]" : ""}: ${v.verdict}${v.reasons.length > 0 ? ` — ${v.reasons.join("; ")}` : ""}${extra.length > 0 ? ` [${extra.join("; ")}]` : ""}`,
|
||||
);
|
||||
if (ftypes.length > 0) lines.push(` ${ftypes}`);
|
||||
}
|
||||
|
||||
// Class fields.
|
||||
for (const atom of analysis.atoms.atoms) {
|
||||
if (atom.kind !== "instance" || atom.node === undefined) continue;
|
||||
const inPackage = atom.node.getSourceFile().fileName.includes("/node_modules/");
|
||||
if (!wanted(atom.node, inPackage)) continue;
|
||||
const fm = analysis.fields.get(atom);
|
||||
if (fm === undefined || fm.size === 0) continue;
|
||||
const esc = analysis.escaped.get(atom);
|
||||
const parts = [...fm.entries()].map(([k, id]) => {
|
||||
const w =
|
||||
esc !== undefined &&
|
||||
!k.startsWith("#") &&
|
||||
(analysis.topComputedWrites.length > 0 || analysis.topWriteNames.has(k))
|
||||
? " (⊤-writable)"
|
||||
: "";
|
||||
return `${k}: ${describeValue(analysis, id)}${w}`;
|
||||
});
|
||||
lines.push(
|
||||
`class ${atom.label}${esc !== undefined ? ` (escapes: ${esc})` : ""}: ${parts.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hot functions.
|
||||
const hotPkg = [...hot].filter((info) => wanted(info.node, info.inPackage));
|
||||
lines.push(`hot functions: ${hotPkg.length} (clones per receiver class counted separately)`);
|
||||
for (const info of hotPkg) {
|
||||
const n = info.node;
|
||||
const params = info.params.map((p, i) => `p${i}=${describeValue(analysis, p)}`).join(", ");
|
||||
const flags = [
|
||||
info.usesArguments ? "uses arguments" : "",
|
||||
analysis.escaped.size > 0 && [...analysis.escaped.keys()].some((a) => a.node === n)
|
||||
? "escapes"
|
||||
: "",
|
||||
].filter((x) => x !== "");
|
||||
lines.push(
|
||||
`fn ${info.label}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}: this=${describeValue(analysis, info.thisNode)}${params.length > 0 ? `, ${params}` : ""}, ret=${describeValue(analysis, info.ret)}`,
|
||||
);
|
||||
}
|
||||
const why = process.env["SCRIPTC_DEBUG_SHAPES_WHY"];
|
||||
if (why !== undefined) {
|
||||
for (const info of analysis.functions.values()) {
|
||||
if (!info.label.startsWith(why)) continue;
|
||||
for (const [what, node] of [
|
||||
["ret", info.ret],
|
||||
...info.params.map((p, i) => [`p${i}`, p] as const),
|
||||
] as const) {
|
||||
if (!analysis.nodes[node]!.atoms.has(analysis.top)) continue;
|
||||
lines.push(`why ⊤ in ${info.label} ${what}:`);
|
||||
let cur: number | string | undefined = node;
|
||||
for (let i = 0; i < 40 && typeof cur === "number"; i++) {
|
||||
lines.push(` ${analysis.nodeLabel.get(cur) ?? `node ${cur}`}`);
|
||||
cur = analysis.topCause.get(cur);
|
||||
}
|
||||
if (typeof cur === "string") lines.push(` seed: ${cur}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sites: verdicts, hot, lines };
|
||||
}
|
||||
|
||||
/** A read inside the true arm of a promise test (`isAsync(x)`,
|
||||
* `x instanceof Promise`): flow-sensitive narrowing removes the record
|
||||
* arms there. Stage S1's locals are flow-insensitive, so the report
|
||||
* recognizes the guard syntactically. */
|
||||
function underPromiseGuard(node: ts.Node): boolean {
|
||||
let child: ts.Node = node;
|
||||
let p = node.parent;
|
||||
while (p !== undefined && !ts.isFunctionLike(p)) {
|
||||
let cond: ts.Expression | undefined;
|
||||
if (ts.isIfStatement(p) && p.thenStatement === child) cond = p.expression;
|
||||
if (ts.isConditionalExpression(p) && p.whenTrue === child) cond = p.condition;
|
||||
if (cond !== undefined && isPromiseTest(cond)) return true;
|
||||
child = p;
|
||||
p = p.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPromiseTest(e: ts.Expression): boolean {
|
||||
if (ts.isParenthesizedExpression(e)) return isPromiseTest(e.expression);
|
||||
if (ts.isBinaryExpression(e)) {
|
||||
if (e.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword)
|
||||
return ts.isIdentifier(e.right) && e.right.text === "Promise";
|
||||
if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
|
||||
return isPromiseTest(e.left) || isPromiseTest(e.right);
|
||||
return false;
|
||||
}
|
||||
if (ts.isCallExpression(e) && ts.isIdentifier(e.expression))
|
||||
return /^is(Async|Promise|Thenable)$/.test(e.expression.text);
|
||||
return false;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { tokenEquivalentSource } from "./token-equivalence.js";
|
||||
import { tokenEquivalentSource, tokenPositionEquivalentSource } from "./token-equivalence.js";
|
||||
|
||||
const equivalent = (previous: string, current: string, path = "/src/a.ts") =>
|
||||
tokenEquivalentSource(path, previous, current);
|
||||
const positionEquivalent = (previous: string, current: string, path = "/src/a.ts") =>
|
||||
tokenPositionEquivalentSource(path, previous, current);
|
||||
|
||||
test("whitespace and plain comments are trivia", () => {
|
||||
expect(equivalent("const a = 1;\n", "const a = 1; // one\n")).toBe(true);
|
||||
@@ -105,3 +107,50 @@ test("text the scanner cannot classify is never equivalent", () => {
|
||||
expect(equivalent("const a = <b/>; // a\n", "const a = <b/>; // b\n", "/src/a.tsx")).toBe(false);
|
||||
expect(equivalent('const s = "open\n', 'const s = "open // \n')).toBe(false);
|
||||
});
|
||||
|
||||
test("a / after a non-null assertion divides", () => {
|
||||
expect(
|
||||
equivalent("const q = data[i]! / 7; // ratio\n", "const q = data[i]! / 7; // the ratio\n"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
equivalent("const q = value! / 7; // ratio\n", "const q = value! / 7; // the ratio\n"),
|
||||
).toBe(true);
|
||||
expect(equivalent("const q = this! / 7; // ratio\n", "const q = this! / 7; // the ratio\n")).toBe(
|
||||
true,
|
||||
);
|
||||
// A prefix `!` negates a regular expression, whose `//` is code.
|
||||
expect(
|
||||
equivalent("const t = !/[//]/.test(s); c = 1;\n", "const t = !/[//]/.test(s); c = 2;\n"),
|
||||
).toBe(false);
|
||||
expect(equivalent("return !/[//]/.test(s); // one\n", "return !/[//]/.test(s); // two\n")).toBe(
|
||||
true,
|
||||
);
|
||||
// After a line break, `!` starts a new expression.
|
||||
expect(equivalent("a\n!/[//]/.test(s); c = 1;\n", "a\n!/[//]/.test(s); c = 2;\n")).toBe(false);
|
||||
// `f()!` and `if (a) !` read the same up to the `/`.
|
||||
expect(equivalent("const q = f()! / 2; // one\n", "const q = f()! / 2; // two\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("position equivalence also keeps every token's line and column", () => {
|
||||
const source = "const a = 1; // one\nconst b = a; /* two */ f(b);\n";
|
||||
// A comment's text within its line, after the line's last token.
|
||||
expect(
|
||||
positionEquivalent(source, "const a = 1; // the first\nconst b = a; /* two */ f(b);\n"),
|
||||
).toBe(true);
|
||||
// Comments and whitespace after the last token, and trailing whitespace.
|
||||
expect(positionEquivalent(source, source + "// appended\n\n")).toBe(true);
|
||||
expect(
|
||||
positionEquivalent(source, "const a = 1; // one\nconst b = a; /* two */ f(b); \n"),
|
||||
).toBe(true);
|
||||
expect(positionEquivalent(source, source.replaceAll("\n", "\r\n"))).toBe(true);
|
||||
// A longer comment before a token moves its column; a new line moves the
|
||||
// lines below it. Both stay token-equivalent.
|
||||
const column = "const a = 1; // one\nconst b = a; /* the second */ f(b);\n";
|
||||
const line = "// header\nconst a = 1; // one\nconst b = a; /* two */ f(b);\n";
|
||||
expect(positionEquivalent(source, column)).toBe(false);
|
||||
expect(positionEquivalent(source, line)).toBe(false);
|
||||
expect(equivalent(source, column)).toBe(true);
|
||||
expect(equivalent(source, line)).toBe(true);
|
||||
// A token edit is never equivalent.
|
||||
expect(positionEquivalent(source, source.replace("1", "2"))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
* before it. After `)` it divides unless the parenthesis closes an `if`,
|
||||
* `while`, `for`, `for await`, or `with` header. After `}`, `++` or
|
||||
* `--` the scanner cannot tell which, so it gives up, as it does for
|
||||
* escaped identifiers, which can spell keywords.
|
||||
* escaped identifiers, which can spell keywords. After `!` in
|
||||
* TypeScript it divides when the `!` follows an operand on the same line
|
||||
* (a non-null assertion, `x! / 2`) and gives up when that operand is a
|
||||
* `)`, `}`, `++` or `--`.
|
||||
* - A `/` with a line break (or a comment that spans lines) since the
|
||||
* previous token gives up: whether automatic semicolon insertion ends the
|
||||
* statement before it decides how it reads.
|
||||
@@ -35,6 +38,8 @@ interface Token {
|
||||
text: string;
|
||||
comment: boolean;
|
||||
lineBreakBefore: boolean;
|
||||
/** The offset of the token's first code unit. */
|
||||
start: number;
|
||||
}
|
||||
|
||||
/** Keywords after which `/` starts a regular expression. */
|
||||
@@ -135,14 +140,20 @@ function scan(path: string, source: string): Token[] | null {
|
||||
// A line break, or a comment spanning lines, since the last token other
|
||||
// than a comment.
|
||||
let breakSinceCode = false;
|
||||
// The previous token and the one before it, and whether a `)` closed a
|
||||
// statement header.
|
||||
const last: { token: string | null; before: string | null } = { token: null, before: null };
|
||||
// The previous token and the one before it, whether a line break came
|
||||
// before the previous token, and whether a `)` closed a statement header.
|
||||
const last: { token: string | null; before: string | null; broke: boolean } = {
|
||||
token: null,
|
||||
before: null,
|
||||
broke: false,
|
||||
};
|
||||
let closedHeader = false;
|
||||
// Every token ends at `index` when it is pushed.
|
||||
const push = (text: string, comment = false): void => {
|
||||
tokens.push({ text, comment, lineBreakBefore });
|
||||
tokens.push({ text, comment, lineBreakBefore, start: index - text.length });
|
||||
lineBreakBefore = false;
|
||||
if (!comment) {
|
||||
last.broke = breakSinceCode;
|
||||
breakSinceCode = false;
|
||||
last.before = last.token;
|
||||
last.token = text;
|
||||
@@ -291,7 +302,19 @@ function scan(path: string, source: string): Token[] | null {
|
||||
else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`"))
|
||||
regex = false;
|
||||
else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false;
|
||||
else regex = true;
|
||||
else if (before === "!" && typeScript && !last.broke && last.before !== null) {
|
||||
// `x! / 2` divides: a `!` right after an operand on the same line is
|
||||
// a non-null assertion. `!/re/.test(s)` negates a regular expression.
|
||||
const operand = last.before;
|
||||
if (operand === ")" || operand === "}" || operand === "++" || operand === "--") return null;
|
||||
regex = !(
|
||||
operand === "]" ||
|
||||
operand.startsWith("'") ||
|
||||
operand.startsWith('"') ||
|
||||
operand.endsWith("`") ||
|
||||
(/^[A-Za-z0-9_$]/.test(operand) && !EXPRESSION_KEYWORDS.has(operand))
|
||||
);
|
||||
} else regex = true;
|
||||
if (regex) {
|
||||
const start = index;
|
||||
index++;
|
||||
@@ -373,9 +396,33 @@ function scan(path: string, source: string): Token[] | null {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Whether `current` differs from `previous` only in whitespace and in
|
||||
* comments that cannot affect compilation. */
|
||||
export function tokenEquivalentSource(path: string, previous: string, current: string): boolean {
|
||||
/** The line and column of each token's start, with the line terminators
|
||||
* source locations count (backend/source-locations.ts). */
|
||||
function tokenPositions(source: string, tokens: readonly Token[]): number[] {
|
||||
const positions: number[] = [];
|
||||
let line = 0;
|
||||
let lineStart = 0;
|
||||
let offset = 0;
|
||||
for (const token of tokens) {
|
||||
for (; offset < token.start; offset++) {
|
||||
const code = source.charCodeAt(offset);
|
||||
if (code === 13 && source.charCodeAt(offset + 1) === 10) offset++;
|
||||
if (code === 10 || code === 13 || code === 0x2028 || code === 0x2029) {
|
||||
line++;
|
||||
lineStart = offset + 1;
|
||||
}
|
||||
}
|
||||
positions.push(line, token.start - lineStart);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function equivalentSources(
|
||||
path: string,
|
||||
previous: string,
|
||||
current: string,
|
||||
positions: boolean,
|
||||
): boolean {
|
||||
if (previous === current) return true;
|
||||
const left = scan(path, previous);
|
||||
if (left === null) return false;
|
||||
@@ -387,5 +434,27 @@ export function tokenEquivalentSource(path: string, previous: string, current: s
|
||||
if (a.text !== b.text || a.comment !== b.comment) return false;
|
||||
if (index > 0 && a.lineBreakBefore !== b.lineBreakBefore) return false;
|
||||
}
|
||||
return true;
|
||||
if (!positions) return true;
|
||||
const before = tokenPositions(previous, left);
|
||||
const after = tokenPositions(current, right);
|
||||
return before.every((value, index) => value === after[index]);
|
||||
}
|
||||
|
||||
/** Whether `current` differs from `previous` only in whitespace and in
|
||||
* comments that cannot affect compilation. */
|
||||
export function tokenEquivalentSource(path: string, previous: string, current: string): boolean {
|
||||
return equivalentSources(path, previous, current, false);
|
||||
}
|
||||
|
||||
/** Whether `current` is token-equivalent to `previous` (tokenEquivalentSource)
|
||||
* and every token keeps its line and column: the edit leaves the source
|
||||
* positions debug information records unchanged (a comment's text within
|
||||
* its line, comments and whitespace after the last token, trailing
|
||||
* whitespace). */
|
||||
export function tokenPositionEquivalentSource(
|
||||
path: string,
|
||||
previous: string,
|
||||
current: string,
|
||||
): boolean {
|
||||
return equivalentSources(path, previous, current, true);
|
||||
}
|
||||
|
||||
@@ -128,10 +128,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
|
||||
// JavaScript subclasses may widen fields or add method parameters.
|
||||
// Class lowering checks native storage and override ABIs at each use.
|
||||
2415,
|
||||
// a private or protected constructor (`new readline.Interface(...)`,
|
||||
// whose declaration is protected): JavaScript has no constructor
|
||||
// accessibility, and Node constructs the class. Each `new` meets the
|
||||
// lowerer's per-site constructor fences.
|
||||
2673,
|
||||
2674,
|
||||
// unresolvable modules: Node throws WHEN the require executes — the
|
||||
// binding types as any and reached uses fence instead of passing unchecked
|
||||
2307,
|
||||
2792,
|
||||
// `delete` of a property the inference typed required (2790): JS deletes
|
||||
// any configurable property, and the lowering checks each delete's
|
||||
// receiver itself
|
||||
2790,
|
||||
// useUnknownInCatchVariables' annotation demand ("'e' is of type
|
||||
// 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT
|
||||
// annotate its variable, so the demand is unsatisfiable in source —
|
||||
|
||||
@@ -1747,9 +1747,17 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx):
|
||||
return { kind: "generator", async: true, ...channels };
|
||||
}
|
||||
|
||||
/** The native fetch handles (Headers, Request, Response), optionally in
|
||||
* arrays or with null/undefined: checked-dynamic handles wherever they are
|
||||
* stored, like the worker handles above. */
|
||||
const FETCH_HANDLE_NAMES: ReadonlySet<string> = new Set([
|
||||
"Headers",
|
||||
"Request",
|
||||
"Response",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
]);
|
||||
|
||||
/** The native fetch handles (Headers, Request, Response, AbortController,
|
||||
* AbortSignal), optionally in arrays or with null/undefined: checked-dynamic
|
||||
* handles wherever they are stored, like the worker handles above. */
|
||||
export function isFetchHandleType(
|
||||
type: ts.Type,
|
||||
ctx: Pick<TypeMapperCtx, "checker" | "isStdlibFile">,
|
||||
@@ -1765,9 +1773,7 @@ export function isFetchHandleType(
|
||||
const symbol = member.getSymbol();
|
||||
if (
|
||||
!symbol ||
|
||||
(ts.symbolName(symbol) !== "Headers" &&
|
||||
ts.symbolName(symbol) !== "Request" &&
|
||||
ts.symbolName(symbol) !== "Response") ||
|
||||
!FETCH_HANDLE_NAMES.has(ts.symbolName(symbol)) ||
|
||||
!symbol.declarations.some(
|
||||
(d) =>
|
||||
(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
|
||||
@@ -7015,6 +7021,43 @@ function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A mapped type reached through a type alias whose arguments mention no
|
||||
* type parameter (`Record<never, number>`, `Catalog<typeof data, "image">`,
|
||||
* a module-level alias without arguments):
|
||||
* the checker computed its members from concrete inputs, so an empty
|
||||
* result really is the empty object type, unlike `Partial<T>` inside a
|
||||
* generic body. */
|
||||
function resolvedMappedAlias(t: ts.Type, ctx: TypeMapperCtx): boolean {
|
||||
if (!isMappedShape(t) || t.getAliasSymbol() === undefined) return false;
|
||||
const args = t.getAliasTypeArguments();
|
||||
const seen = new Set<ts.Type>();
|
||||
const concrete = (part: ts.Type): boolean => {
|
||||
if (part.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Index)) return false;
|
||||
if (part.flags & (ts.TypeFlags.IndexedAccess | ts.TypeFlags.Conditional)) return false;
|
||||
if (seen.has(part)) return true;
|
||||
seen.add(part);
|
||||
if (part.isUnionType() || part.isIntersectionType())
|
||||
return ts.constituentTypes(part).every(concrete);
|
||||
if (!part.getAliasTypeArguments().every(concrete)) return false;
|
||||
return (
|
||||
(part.flags & ts.TypeFlags.Object) === 0 ||
|
||||
((part as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0 ||
|
||||
ctx.checker.getTypeArguments(part as ts.TypeReference).every(concrete)
|
||||
);
|
||||
};
|
||||
// An alias without type arguments is concrete when it is declared at
|
||||
// module level (a function body's alias may mention that function's type
|
||||
// parameters).
|
||||
if (args.length === 0)
|
||||
return t
|
||||
.getAliasSymbol()!
|
||||
.declarations.every(
|
||||
(d) =>
|
||||
ts.isTypeAliasDeclaration(d) && (ts.isSourceFile(d.parent) || ts.isModuleBlock(d.parent)),
|
||||
);
|
||||
return args.every(concrete);
|
||||
}
|
||||
|
||||
/** True for MAPPED-type results — `Partial<Config>`, `Record<"a", n>`,
|
||||
* whatever `Pick`/`Omit` reduce to. Their shape is computed by the checker,
|
||||
* not declared anywhere: the only declaration behind them is the utility
|
||||
@@ -7969,8 +8012,12 @@ function mapRecordTypeInner(
|
||||
// still unknown there) — interning `{}` would give a wrong type. The
|
||||
// degenerate empties (`Partial<{}>`, `Omit<C, keyof C>`) go with it.
|
||||
// An INDEX-SIGNATURE shape is exempt: `Record<string, T>` legitimately
|
||||
// has zero declared members — the signature is the shape.
|
||||
if (computed && props.length === 0 && !indexValue) return null;
|
||||
// has zero declared members — the signature is the shape. So is a mapped
|
||||
// alias instantiated over concrete arguments (`Record<never, T>`, a
|
||||
// catalog type whose key filter keeps no key): the checker resolved it,
|
||||
// and it is the empty object type below.
|
||||
if (computed && props.length === 0 && !indexValue && !resolvedMappedAlias(widened, ctx))
|
||||
return null;
|
||||
// An empty object type — `{}` (spelled, the checker's shared
|
||||
// intrinsic, or inferred from an empty object literal), `interface
|
||||
// Empty {}` — is tsc's TOP type over non-nullish values: every number,
|
||||
|
||||
@@ -94,6 +94,7 @@ import { createNativeLinkPlan } from "./backend/link-plan.js";
|
||||
import {
|
||||
nativeProfileKey,
|
||||
nativeProfileRefusal,
|
||||
type NativeProfileMatch,
|
||||
type NativeProfileOptions,
|
||||
} from "./backend/native-profile.js";
|
||||
import {
|
||||
@@ -659,6 +660,7 @@ interface NativeProgramObject {
|
||||
partitionPaths: string[];
|
||||
artifactPath: string;
|
||||
dependencies: NativeArtifactDependency[];
|
||||
profileMatch?: NativeProfileMatch;
|
||||
}
|
||||
|
||||
async function emitNativeProgramObject(
|
||||
@@ -694,6 +696,7 @@ async function emitNativeProgramObject(
|
||||
partitionPaths: artifact.outputPaths.slice(1),
|
||||
artifactPath,
|
||||
dependencies: artifact.dependencies,
|
||||
...(artifact.profileMatch === undefined ? {} : { profileMatch: artifact.profileMatch }),
|
||||
};
|
||||
} catch (error) {
|
||||
await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions));
|
||||
@@ -875,6 +878,7 @@ async function prepareExecutableInput(
|
||||
pointerBits: buildPlatform === "wasi" ? 32 : 64,
|
||||
wasi: buildPlatform === "wasi",
|
||||
runtimeAbiMarker: useRuntimePack,
|
||||
unwindAbi: buildPlatform === "linux",
|
||||
objectAudit: opts.sanitize === true,
|
||||
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
|
||||
...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}),
|
||||
@@ -1851,6 +1855,7 @@ async function compileTracked(
|
||||
}
|
||||
await pruneBuildCache(cacheRoot);
|
||||
timing("complete");
|
||||
const profileMatch = nativeProgramObject?.profileMatch;
|
||||
return {
|
||||
ok: true,
|
||||
artifact: {
|
||||
@@ -1858,6 +1863,7 @@ async function compileTracked(
|
||||
path: opts.outPath,
|
||||
translationUnitPath: llvmPath,
|
||||
backend,
|
||||
...(profileMatch === undefined ? {} : { profileMatch }),
|
||||
},
|
||||
binaryPath: opts.outPath,
|
||||
llvmPath,
|
||||
|
||||
@@ -591,6 +591,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
|
||||
"util.styleText",
|
||||
"util.toUSVString",
|
||||
"util.inherits",
|
||||
"util.inheritsPrototype",
|
||||
"util.deprecate",
|
||||
"util.callbackify",
|
||||
"util.promisifyDyn",
|
||||
@@ -976,6 +977,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
|
||||
"assert.noErrorPredicate",
|
||||
"assert.regexDynTest",
|
||||
"assert.expectsErrDyn",
|
||||
"assert.throwsValidate",
|
||||
"assert.ifErrorErr",
|
||||
"assert.ifErrorF64",
|
||||
"assert.ifErrorStr",
|
||||
|
||||
@@ -1146,6 +1146,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
* arguments (scr_util_callable.c). The function results are native dyn
|
||||
* wrappers; call sites with a static function type check them back. */
|
||||
"util.inherits": { argTypes: [DYN, DYN], result: VOID },
|
||||
"util.inheritsPrototype": { argTypes: [DYN, DYN, DYN], result: VOID },
|
||||
"util.deprecate": { argTypes: [DYN, DYN, DYN, DYN], result: DYN },
|
||||
"util.callbackify": { argTypes: [DYN], result: DYN },
|
||||
/** util.promisify over an untyped function value: a native dyn function
|
||||
@@ -2608,6 +2609,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
result: CHILD_T,
|
||||
},
|
||||
"child.connected": { argTypes: [CHILD_T], result: BOOL },
|
||||
"child.hasChannel": { argTypes: [CHILD_T], result: BOOL },
|
||||
"child.send": { argTypes: [CHILD_T, STRING], result: BOOL },
|
||||
"child.sendCb": { argTypes: [CHILD_T, STRING, null], result: BOOL },
|
||||
"child.disconnect": { argTypes: [CHILD_T], result: VOID },
|
||||
@@ -4410,6 +4412,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
// The equality quartet over checked-dynamic operands (the frontend
|
||||
// boxes a static side into the checked-dynamic tree first).
|
||||
"assert.eqDyn": { argTypes: [DYN, DYN, BOOL, BOOL, STRING, BOOL], result: VOID },
|
||||
/** A message argument that is not a string, registered for the next
|
||||
* failing assertion (scr_assert_message_value). */
|
||||
"assert.messageValue": { argTypes: [DYN, BOOL], result: VOID },
|
||||
/** An assert method as a function value, by canonical name. */
|
||||
"assert.methodValue": { argTypes: [STRING], result: DYN },
|
||||
"assert.messageClear": { argTypes: [], result: VOID },
|
||||
"assert.looseResult": { argTypes: [BOOL, BOOL, STRING, STRING, STRING, BOOL], result: VOID },
|
||||
// The throws(fn, {shape}) accumulator: begin/slot calls never throw;
|
||||
// shapeEnd throws the Comparison diff. The error slot is the
|
||||
@@ -4435,6 +4443,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
* against the caught value's; a mismatch throws the deep-equal
|
||||
* AssertionError (scr_assert.c). MAY THROW by design. */
|
||||
"assert.expectsErrDyn": { argTypes: [DYN, DYN, STRING, BOOL], result: VOID },
|
||||
/** A validation function against the caught value (Node's expectedException). */
|
||||
"assert.throwsValidate": { argTypes: [DYN, DYN, STRING, STRING, BOOL], result: VOID },
|
||||
// assert.ifError's typed entries (always throw; unit args never
|
||||
// lower). The error slot is program-dependent — any %Error-hierarchy
|
||||
// class, root or subclass (the insp.error precedent, null slot).
|
||||
@@ -4873,6 +4883,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
* lib-call argument passes the time value itself, and any other use
|
||||
* creates the Date. */
|
||||
"date.fromTime": { argTypes: [F64], result: DATE_T },
|
||||
/** Date.prototype's field setters over a time value
|
||||
* (scr_date_set_fields): time value, setter code, argument count and four
|
||||
* arguments; answers the new time value. */
|
||||
"date.setFields": { argTypes: [F64, F64, F64, F64, F64, F64, F64], result: F64 },
|
||||
/** TimeClip of a number (setTime's argument). */
|
||||
"date.timeClip": { argTypes: [F64], result: F64 },
|
||||
/** Stores a time value into a Date object and answers it (a setter's
|
||||
* write). */
|
||||
"date.store": { argTypes: [DATE_T, F64], result: F64 },
|
||||
/** Date.prototype.toString / toDateString / toTimeString and String(date):
|
||||
* V8's local-time formats with the host zone's long name, "Invalid Date"
|
||||
* for an invalid date. Owned (+1) string; never throws. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user