mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Reuse lowered units across builds behind SCRIPTC_INCR_LOWERING=1
This commit is contained in:
@@ -136,6 +136,8 @@ jobs:
|
||||
run: pnpm generate:ts7-ast --check
|
||||
- name: Verify the generated IR field readers
|
||||
run: pnpm generate:ir-fields --check
|
||||
- name: Verify the generated IR codec
|
||||
run: pnpm generate:ir-codec --check
|
||||
|
||||
native_macos:
|
||||
needs: [validation_scope, lanes]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"test:ts7": "node scripts/test-ts7.mjs",
|
||||
"test:checker-identity": "node scripts/checker-identity.mjs",
|
||||
"generate:ts7-ast": "node scripts/generate-ts7-ast-schema.mjs",
|
||||
"generate:ir-codec": "node scripts/generate-ir-codec.mjs",
|
||||
"generate:ir-fields": "node scripts/generate-ir-fields.mjs",
|
||||
"test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts",
|
||||
"test:test262": "tsx tests/test262/run.ts",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../backend/target-diagnostics.js";
|
||||
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
|
||||
import type { FrontendFactory } from "../frontend/pipeline.js";
|
||||
import type { UnitStore } from "../frontend/lowering/unit-reuse.js";
|
||||
import type { CompilationTiming } from "../timing.js";
|
||||
import { readProjectConfig } from "../project-config.js";
|
||||
import { nodeIslandPolicy } from "../node-islands/policy.js";
|
||||
@@ -42,7 +43,9 @@ export interface PreparedExecutableModule {
|
||||
warnings: ScrDiagnostic[];
|
||||
}
|
||||
|
||||
/** Release the parser/checker before native object generation begins. */
|
||||
/** Release the parser/checker before native object generation begins.
|
||||
* `unitStore` keeps the lowered units of this build for the next one
|
||||
* (unit-reuse.ts). */
|
||||
export function prepareExecutableModule(
|
||||
entryPath: string,
|
||||
opts: CompileRequestOptions,
|
||||
@@ -52,6 +55,7 @@ export function prepareExecutableModule(
|
||||
timing: CompilationTiming = () => {},
|
||||
lowerIslands: ReadonlyMap<string, IslandPackage> = new Map(),
|
||||
retryFences = true,
|
||||
unitStore: UnitStore | null = null,
|
||||
): PreparedExecutableModule | CompileFailure {
|
||||
// The island decision reads the detector's scan unless islands are off.
|
||||
const islandScan =
|
||||
@@ -115,6 +119,7 @@ export function prepareExecutableModule(
|
||||
timing,
|
||||
grown,
|
||||
retryFences,
|
||||
unitStore,
|
||||
);
|
||||
}
|
||||
return fail(fe.preflight);
|
||||
@@ -127,6 +132,7 @@ export function prepareExecutableModule(
|
||||
targetPlatform: buildPlatform,
|
||||
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
|
||||
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
|
||||
...(unitStore !== null ? { unitStore } : {}),
|
||||
},
|
||||
(module) => validateModule(module).length === 0,
|
||||
);
|
||||
@@ -165,6 +171,7 @@ export function prepareExecutableModule(
|
||||
timing,
|
||||
grown,
|
||||
retryFences,
|
||||
unitStore,
|
||||
);
|
||||
}
|
||||
// A failed program whose compiled packages need the runtime or a
|
||||
@@ -267,6 +274,7 @@ export function prepareExecutableModule(
|
||||
timing,
|
||||
islands,
|
||||
false,
|
||||
unitStore,
|
||||
);
|
||||
if (islanded.ok) return islanded;
|
||||
firstError ??= islanded.diagnostics[0] ?? null;
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
irDataTable,
|
||||
journalDump,
|
||||
journalMode,
|
||||
HeldTables,
|
||||
lowerWaveRecorded,
|
||||
type WaveReuse,
|
||||
mapTable,
|
||||
setTable,
|
||||
waveStats,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
type UnitEvents,
|
||||
type WaveHost,
|
||||
} from "./unit-journal.js";
|
||||
import { UnitReuse, unitReuseVerify, verifyStoredUnit, type UnitStore } from "./unit-reuse.js";
|
||||
import {
|
||||
moduleNamespaceDynValue,
|
||||
moduleNamespaceObject,
|
||||
@@ -961,6 +964,9 @@ export interface LowerOptions {
|
||||
nodeIslands?: NodeIslandSet | null;
|
||||
/** Parser/checker services owned by the load; required for npm graph analysis. */
|
||||
frontendServices?: FrontendServices | undefined;
|
||||
/** Where reachable emit stores its units' records and finds an earlier
|
||||
* build's (unit-reuse.ts). */
|
||||
unitStore?: UnitStore | null;
|
||||
/** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */
|
||||
projectCheckJs?: boolean;
|
||||
/** Coverage: additionally lower the unreached remainder (bodies nothing
|
||||
@@ -1360,7 +1366,7 @@ function lowerToIrWithIslands(
|
||||
});
|
||||
for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d);
|
||||
for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d);
|
||||
const emitted = reachableEmit.emitReachable(options.libRoots);
|
||||
const emitted = reachableEmit.emitReachable(options.libRoots, options.unitStore ?? null);
|
||||
let { reachable } = emitted;
|
||||
let result = emitted.result;
|
||||
let resultLowerer = reachableEmit;
|
||||
@@ -9940,7 +9946,10 @@ export class Lowerer {
|
||||
* demand-driven) and lifted lambdas lower inline with their enclosing
|
||||
* body; both fire edges through the same hooks and are not units
|
||||
* themselves. */
|
||||
emitReachable(extraRoots?: readonly string[]): { reachable: Set<string>; result: LowerResult } {
|
||||
emitReachable(
|
||||
extraRoots?: readonly string[],
|
||||
unitStore: UnitStore | null = null,
|
||||
): { reachable: Set<string>; result: LowerResult } {
|
||||
// Frontend passes below rewrite function bodies through the IR mapper
|
||||
// (caught-value and class dispatch); key-order notes follow the record
|
||||
// literals they rebuild until the witness pass has read them.
|
||||
@@ -9998,12 +10007,16 @@ export class Lowerer {
|
||||
// Every lowerable body, by emitted-function name. The names double as
|
||||
// retained-function keys and are deterministic by construction
|
||||
// (qualified declaration names).
|
||||
// `source` is the declaration whose text a unit lowers and `owners` the
|
||||
// declarations around it (unit-reuse.ts).
|
||||
const units = new Map<
|
||||
string,
|
||||
{
|
||||
order: number;
|
||||
roots: readonly ts.Node[];
|
||||
lower: () => IrFunction | null;
|
||||
source?: ts.Node | undefined;
|
||||
owners?: readonly ts.Node[];
|
||||
}
|
||||
>();
|
||||
// Roots are collected with plain pushes: spread arguments into a rest
|
||||
@@ -10047,6 +10060,7 @@ export class Lowerer {
|
||||
order: unitOrder++,
|
||||
roots: functionRoots(decl),
|
||||
lower: () => this.lowerFunction(decl),
|
||||
source: decl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10067,6 +10081,7 @@ export class Lowerer {
|
||||
roots: classCtorRoots(info),
|
||||
lower: () =>
|
||||
info.localClass && !info.localClass.ready ? null : this.lowerClassCtor(info),
|
||||
source: info.decl ?? undefined,
|
||||
});
|
||||
// The descriptor helpers of a class that extends Array, reached
|
||||
// from its constructor.
|
||||
@@ -10075,11 +10090,13 @@ export class Lowerer {
|
||||
order: unitOrder++,
|
||||
roots: [],
|
||||
lower: () => arrayClassFieldsFn(this, info),
|
||||
source: info.decl ?? undefined,
|
||||
});
|
||||
units.set(`%${cName}.species`, {
|
||||
order: unitOrder++,
|
||||
roots: [],
|
||||
lower: () => arrayClassSpeciesFn(this, info),
|
||||
source: info.decl ?? undefined,
|
||||
});
|
||||
}
|
||||
for (const { mName, member } of this.classMethodMembers(info)) {
|
||||
@@ -10090,6 +10107,8 @@ export class Lowerer {
|
||||
info.localClass && !info.localClass.ready
|
||||
? null
|
||||
: this.lowerClassMethodMember(info, member),
|
||||
source: member,
|
||||
owners: info.decl ? [info.decl] : [],
|
||||
});
|
||||
}
|
||||
for (const prop of info.throwingSetters) {
|
||||
@@ -10097,6 +10116,7 @@ export class Lowerer {
|
||||
order: unitOrder++,
|
||||
roots: [],
|
||||
lower: () => this.throwingSetterFn(info, prop),
|
||||
source: info.decl ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10106,6 +10126,8 @@ export class Lowerer {
|
||||
order: unitOrder++,
|
||||
roots: functionRoots(entry.member),
|
||||
lower: () => lowerStaticMethod(this, info, name),
|
||||
source: entry.member,
|
||||
owners: info.decl ? [info.decl] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10420,17 +10442,94 @@ export class Lowerer {
|
||||
),
|
||||
);
|
||||
};
|
||||
const recorded = journalMode() === "serial" ? waveStats() : null;
|
||||
const host = recorded === null ? null : this.waveHost(loweredUnits, lowerUnitBody);
|
||||
// With a unit store, every unit is recorded as in the serial mode, and
|
||||
// a unit applies the record an earlier build stored for it where that
|
||||
// record still holds (unit-reuse.ts).
|
||||
const reuse =
|
||||
unitStore === null
|
||||
? null
|
||||
: UnitReuse.open(
|
||||
unitStore,
|
||||
this.program,
|
||||
parts.map((fp) => fp.sf),
|
||||
{
|
||||
facts: (declaration) => this.declarationFacts(declaration),
|
||||
exportsOf: (specifier) => {
|
||||
const symbol = this.checker.getSymbolAtLocation(specifier);
|
||||
if (!symbol) return null;
|
||||
return this.checker
|
||||
.getExportsOfModule(symbol)
|
||||
.map((exported) => ts.symbolName(exported))
|
||||
.sort()
|
||||
.join("\n");
|
||||
},
|
||||
isLocal: (identifier, container) => {
|
||||
const declarations = this.checker.getSymbolAtLocation(identifier)?.declarations;
|
||||
if (declarations === undefined || declarations.length === 0) return false;
|
||||
const sf = ts.getSourceFileOfNode(container);
|
||||
return declarations.every(
|
||||
(declaration) =>
|
||||
ts.getSourceFileOfNode(declaration) === sf &&
|
||||
declaration.pos >= container.pos &&
|
||||
declaration.end <= container.end,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
const recorded =
|
||||
journalMode() === "serial" || (reuse !== null && reuse.records) ? waveStats() : null;
|
||||
const host =
|
||||
recorded === null
|
||||
? null
|
||||
: this.waveHost(loweredUnits, lowerUnitBody, (name) => {
|
||||
const priority = metadataPriority.get(name)!;
|
||||
return { owner: demandOwner(priority), priority };
|
||||
});
|
||||
const dump = recorded === null ? null : journalDump();
|
||||
const fileOfUnit = (name: string): string | null => {
|
||||
const source = units.get(name)?.source;
|
||||
return source === undefined ? null : ts.getSourceFileOfNode(source).fileName;
|
||||
};
|
||||
const waveReuse: WaveReuse | null =
|
||||
reuse === null || !reuse.records
|
||||
? null
|
||||
: {
|
||||
verify: unitReuseVerify(),
|
||||
stored: (name) => {
|
||||
// A unit without a declared rank takes the next expression
|
||||
// rank when it lowers, which applying a record would not
|
||||
// advance.
|
||||
const unit = units.get(name);
|
||||
const file = fileOfUnit(name);
|
||||
if (unit === undefined || file === null || !metadataPriority.has(name)) return null;
|
||||
return reuse.candidate(name, file, unit.source ?? null, unit.owners ?? []);
|
||||
},
|
||||
check: (name, stored, pending, unit) =>
|
||||
verifyStoredUnit(name, stored, pending, unit.record, unit.reasons),
|
||||
note: (name, record, applied, files) => {
|
||||
const file = fileOfUnit(name);
|
||||
if (file !== null) reuse.note(name, file, record, applied, files);
|
||||
},
|
||||
};
|
||||
const recordLines: string[] = [];
|
||||
const drainUnits = (): void => {
|
||||
// The waves of one drain have only checker prefetches between them, so
|
||||
// they share the content their units start from (HeldTables).
|
||||
const held =
|
||||
host !== null && recorded !== null ? new HeldTables(host.tables, recorded) : null;
|
||||
while (queue.length > 0) {
|
||||
const wave = queue.splice(0);
|
||||
shuffle?.(wave);
|
||||
this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots));
|
||||
if (host !== null && recorded !== null) {
|
||||
lowerWaveRecorded(host, wave, recorded, dump === null ? null : recordLines);
|
||||
lowerWaveRecorded(
|
||||
host,
|
||||
wave,
|
||||
recorded,
|
||||
dump === null ? null : recordLines,
|
||||
waveReuse,
|
||||
held,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const name of wave) {
|
||||
@@ -10447,6 +10546,7 @@ export class Lowerer {
|
||||
}
|
||||
}
|
||||
}
|
||||
held?.check();
|
||||
};
|
||||
drainUnits();
|
||||
this.restoreGenericInstanceOrder();
|
||||
@@ -10491,6 +10591,13 @@ export class Lowerer {
|
||||
}
|
||||
classDispatch.finalize(this);
|
||||
if (dump !== null) writeRecords(dump, recordLines);
|
||||
if (reuse !== null) {
|
||||
reuse.finish();
|
||||
if (process.env["SCRIPTC_TIMING"] === "1")
|
||||
process.stderr.write(
|
||||
`scriptc lowering ${JSON.stringify({ phase: "unit-reuse", ...reuse.stats })}\n`,
|
||||
);
|
||||
}
|
||||
if (recorded !== null && process.env["SCRIPTC_TIMING"] === "1")
|
||||
process.stderr.write(
|
||||
`scriptc lowering ${JSON.stringify({ phase: "unit-records", ...recorded })}\n`,
|
||||
@@ -10539,11 +10646,57 @@ export class Lowerer {
|
||||
return { reachable, result: this.finishModule(functions) };
|
||||
}
|
||||
|
||||
/** What the steps before the worklist settled for a top-level
|
||||
* declaration, as text without source positions (unit-reuse.ts): a
|
||||
* function's signature, a class's layout and the signatures of its
|
||||
* methods and statics, and the globals of a variable statement. */
|
||||
private declarationFacts(declaration: ts.Node): string {
|
||||
const positionFree = (key: string, value: unknown): unknown =>
|
||||
key === "loc" || key === "scope" ? undefined : value;
|
||||
if (ts.isFunctionDeclaration(declaration)) {
|
||||
const symbol = declSymbolOf(this, declaration);
|
||||
const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined;
|
||||
return sig === undefined ? "" : JSON.stringify(sig, positionFree);
|
||||
}
|
||||
if (ts.isClassDeclaration(declaration)) {
|
||||
const info = this.classes.get(this.classNamer(declaration));
|
||||
if (info === undefined) return "";
|
||||
const staticMethods: { name: string; params: ParamShape[]; ret: IrType }[] = [];
|
||||
if (info.staticMethods)
|
||||
for (const [name, method] of info.staticMethods)
|
||||
staticMethods.push({ name, params: method.params, ret: method.ret });
|
||||
return JSON.stringify(
|
||||
{
|
||||
def: info.def,
|
||||
fields: [...info.fields],
|
||||
methods: [...info.methods],
|
||||
statics: info.staticFields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
runtimeOptional: field.runtimeOptional ?? false,
|
||||
globalId: field.globalId,
|
||||
readonly: field.readonly,
|
||||
})),
|
||||
staticMethods,
|
||||
},
|
||||
positionFree,
|
||||
);
|
||||
}
|
||||
if (ts.isVariableStatement(declaration)) {
|
||||
const globals: (IrGlobal | null)[] = [];
|
||||
for (const decl of declaration.declarationList.declarations.nodes)
|
||||
globals.push(this.globalsByDeclNode.get(decl) ?? null);
|
||||
return JSON.stringify(globals, positionFree);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The lowerer's side of a recorded wave (unit-journal.ts).
|
||||
* `lowerUnitBody` lowers a unit as reachable emit does. */
|
||||
private waveHost(
|
||||
loweredUnits: Map<string, IrFunction>,
|
||||
lowerUnitBody: (name: string) => IrFunction | null,
|
||||
ownerOf: (name: string) => { owner: GenericDemandOwner; priority: readonly [number, number] },
|
||||
): WaveHost {
|
||||
const scalars = this.journalScalars();
|
||||
const flags = new Map(scalars.map((scalar) => [scalar.name, scalar]));
|
||||
@@ -10574,6 +10727,35 @@ export class Lowerer {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
// Makes a recorded call again (resolves has answered true for it).
|
||||
const replay = (event: JournalEvent): void => {
|
||||
switch (event.kind) {
|
||||
case "edge":
|
||||
this.noteEdge(event.name);
|
||||
break;
|
||||
case "superConstructorEdge":
|
||||
this.noteSuperConstructorEdge(event.name);
|
||||
break;
|
||||
case "virtualEdge":
|
||||
this.noteVirtualEdge(classNamed(event.name)!, event.detail!);
|
||||
break;
|
||||
case "functionDemand":
|
||||
this.noteGenericInstanceDemand(instanceNamed(event.name)!);
|
||||
break;
|
||||
case "classDemand":
|
||||
this.noteGenericClassInstanceDemand(classNamed(event.name)!);
|
||||
break;
|
||||
case "statement":
|
||||
this.countStatement(statementNode(event)!, event.detail ?? undefined);
|
||||
break;
|
||||
case "failedStatement":
|
||||
this.countStatementFailed(statementNode(event)!, event.detail ?? undefined);
|
||||
break;
|
||||
case "flag":
|
||||
flags.get(event.name)!.set!();
|
||||
break;
|
||||
}
|
||||
};
|
||||
return {
|
||||
tables: this.journalTables(),
|
||||
scalars,
|
||||
@@ -10614,6 +10796,15 @@ export class Lowerer {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
keep: (name, fn, events) => {
|
||||
if (fn) loweredUnits.set(name, fn);
|
||||
const { owner, priority } = ownerOf(name);
|
||||
this.withGenericDemandOwner(owner, () =>
|
||||
this.shapes.withDeclaredOrderPriority(priority, () => {
|
||||
for (const event of events) replay(event);
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Lowerer } from "./lowerer.js";
|
||||
import {
|
||||
checkedTable,
|
||||
contentDigest,
|
||||
decodeRecordFunction,
|
||||
encodeUnitFunction,
|
||||
flagScalar,
|
||||
functionTable,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
recordUnit,
|
||||
setTable,
|
||||
waveStats,
|
||||
type UnitRecord,
|
||||
type WaveHost,
|
||||
type WaveReuse,
|
||||
} from "./unit-journal.js";
|
||||
import type { IrFunction } from "../../ir/ir.js";
|
||||
|
||||
@@ -225,6 +228,7 @@ function host(lower: (name: string, state: HostState) => IrFunction | null): {
|
||||
},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -276,7 +280,10 @@ test("a unit's record lists its events, flags and added entries", () => {
|
||||
["flag", "flag"],
|
||||
]);
|
||||
expect(record!.tables).toEqual({ helpers: [`"key.a"\n"%h.a"`] });
|
||||
expect(JSON.parse(JSON.stringify(record))).toEqual(record);
|
||||
// The function is in the binary encoding, and the rest is JSON data.
|
||||
const { fn: bytes, ...rest } = record!;
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(JSON.parse(JSON.stringify(rest))).toEqual(rest);
|
||||
});
|
||||
|
||||
test("a unit that changes an entry it did not add has no record", () => {
|
||||
@@ -303,6 +310,7 @@ test("a recorded wave throws when a unit changes a held function in place", () =
|
||||
store: () => {},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
};
|
||||
lowerWaveRecorded(subject, ["a"], waveStats());
|
||||
expect(() => lowerWaveRecorded(subject, ["a", "b"], waveStats())).toThrow(
|
||||
@@ -310,6 +318,47 @@ test("a recorded wave throws when a unit changes a held function in place", () =
|
||||
);
|
||||
});
|
||||
|
||||
test("a recorded wave holds the entries an applied record adds after its first lowering unit", () => {
|
||||
// `first` lowers, which takes the wave's content; `a` applies a stored
|
||||
// record that adds `%added`; `b` changes `%added` in place without naming
|
||||
// it, which the end of the wave must see.
|
||||
const functions: IrFunction[] = [];
|
||||
const subject: WaveHost = {
|
||||
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
|
||||
scalars: [],
|
||||
lowerRecorded: (name) => {
|
||||
if (name === "b")
|
||||
functions
|
||||
.find((f) => f.name === "%added")!
|
||||
.locals.push({ id: "x.0", name: "x", type: { kind: "f64" } } as never);
|
||||
return { fn: fn(`%${name}`), poisoned: false };
|
||||
},
|
||||
store: () => {},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
};
|
||||
const stored: UnitRecord = {
|
||||
name: "a",
|
||||
fn: null,
|
||||
poisoned: false,
|
||||
events: [],
|
||||
tables: { lifted: [encodeUnitFunction(fn("%added"))!] },
|
||||
reads: {},
|
||||
};
|
||||
const reuse: WaveReuse = {
|
||||
stored: (name) => (name === "a" ? { record: stored, fn: () => null } : null),
|
||||
verify: false,
|
||||
check: () => {},
|
||||
note: () => {},
|
||||
};
|
||||
const stats = waveStats();
|
||||
expect(() => lowerWaveRecorded(subject, ["first", "a", "b"], stats, null, reuse)).toThrow(
|
||||
"a unit changed lifted entry %added in place without naming it",
|
||||
);
|
||||
expect(stats.applied).toBe(1);
|
||||
});
|
||||
|
||||
/** A wave host over one function table holding `%shared` and one checked
|
||||
* list, whose units change `%shared` in place and name it in their IR. */
|
||||
function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked: string[] } {
|
||||
@@ -334,6 +383,7 @@ function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked
|
||||
store: () => {},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -352,6 +402,40 @@ test("a unit that changes a function it names in place has no record", () => {
|
||||
expect(stats).toMatchObject({ recorded: 0, unrecorded: 1, reasons: { "lifted:changed": 1 } });
|
||||
});
|
||||
|
||||
test("a record's binary function names the held functions its strings name", () => {
|
||||
const functions = [fn("%shared")];
|
||||
const loc = { file: "b.ts", start: 0, end: 0 };
|
||||
const subject: WaveHost = {
|
||||
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
|
||||
scalars: [],
|
||||
lowerRecorded: () => ({
|
||||
fn: {
|
||||
...fn("%a"),
|
||||
body: [
|
||||
{
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "call", callee: "%shared", args: [], type: { kind: "f64" }, loc },
|
||||
loc,
|
||||
},
|
||||
] as never,
|
||||
},
|
||||
poisoned: false,
|
||||
}),
|
||||
store: () => {},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
};
|
||||
const unit = recordUnit(subject, "a");
|
||||
expect(unit.record!.fn).toBeInstanceOf(Uint8Array);
|
||||
expect(unit.record!.reads).toEqual({
|
||||
lifted: [["%shared", contentDigest(encodeUnitFunction(functions[0]!)!)]],
|
||||
});
|
||||
// The files of the function's source positions, for reuse to check.
|
||||
expect([...unit.files!].sort()).toEqual(["a.ts", "b.ts"]);
|
||||
expect(decodeRecordFunction(unit.record!.fn!)).toEqual(unit.fn);
|
||||
});
|
||||
|
||||
test("a record lists the held functions it names, with their digests", () => {
|
||||
const functions = [fn("%shared"), fn("%unused")];
|
||||
const subject: WaveHost = {
|
||||
@@ -373,6 +457,7 @@ test("a record lists the held functions it names, with their digests", () => {
|
||||
store: () => {},
|
||||
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
|
||||
resolves: () => true,
|
||||
keep: () => {},
|
||||
};
|
||||
const { record } = recordUnit(subject, "a");
|
||||
// The function the unit added is in its entries, not its reads.
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* unit's lowering reached: the unit's IR, the entries it added to the
|
||||
* tables that collect lowering results (Lowerer.journalTables), and the
|
||||
* calls it made into reachability, generic demand and statement counting
|
||||
* (JournalEvent). Records hold only strings, numbers and names, so they can
|
||||
* be written to a file and read by another process or another build.
|
||||
* (JournalEvent). Records hold only the function's bytes (or its JSON),
|
||||
* strings, numbers and names, so they can be written to a file and read by
|
||||
* another process or another build.
|
||||
*
|
||||
* A unit has no record when its lowering changed state that no record
|
||||
* carries: a table that is not appendable grew, a field changed, an entry
|
||||
@@ -23,20 +24,59 @@
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
import { decodeFunctionBinary, encodeFunctionStrings, functionDigest } from "../../ir/codec.js";
|
||||
import type { IrFunction } from "../../ir/ir.js";
|
||||
import { deserializeFunction, encodeFunction } from "../../ir/serialize.js";
|
||||
|
||||
/** A function as records hold it, or null when it does not encode. Every
|
||||
* function a record holds goes through this pair. */
|
||||
/** A function as table entries hold it, or null when it does not encode.
|
||||
* Every function an entry holds goes through this pair. */
|
||||
export function encodeUnitFunction(fn: IrFunction): string | null {
|
||||
return encodeFunction(fn) ?? null;
|
||||
}
|
||||
|
||||
/** A function from its record encoding (encodeUnitFunction). */
|
||||
/** A function from its entry encoding (encodeUnitFunction). */
|
||||
export function decodeUnitFunction(text: string): IrFunction {
|
||||
return deserializeFunction(text);
|
||||
}
|
||||
|
||||
/** A unit's function as its record holds it: the binary IR encoding, or
|
||||
* JSON (encodeUnitFunction) when an object in it would not read back with
|
||||
* its own key order, which frontend passes and the IR's text depend on. */
|
||||
export type RecordFunction = string | Uint8Array;
|
||||
|
||||
/** `fn` as a record holds it, or null when it does not encode. A binary
|
||||
* encoding comes with its strings and the files its fields named `file`
|
||||
* hold (encodeFunctionStrings). */
|
||||
export function encodeRecordFunction(fn: IrFunction): {
|
||||
readonly fn: RecordFunction;
|
||||
readonly strings: readonly string[] | null;
|
||||
readonly files: readonly string[] | null;
|
||||
} | null {
|
||||
const binary = encodeFunctionStrings(fn);
|
||||
if (binary !== null) return { fn: binary.bytes, strings: binary.strings, files: binary.files };
|
||||
const text = encodeUnitFunction(fn);
|
||||
return text === null ? null : { fn: text, strings: null, files: null };
|
||||
}
|
||||
|
||||
/** A function from its record encoding (encodeRecordFunction). */
|
||||
export function decodeRecordFunction(fn: RecordFunction): IrFunction {
|
||||
return typeof fn === "string"
|
||||
? decodeUnitFunction(fn)
|
||||
: decodeFunctionBinary(Buffer.from(fn.buffer, fn.byteOffset, fn.byteLength));
|
||||
}
|
||||
|
||||
/** `record` as a JSON line, with a binary function in base64, marked when
|
||||
* it was `applied` from an earlier build's store. */
|
||||
export function recordLine(record: UnitRecord, applied = false): string {
|
||||
const fn =
|
||||
record.fn === null || typeof record.fn === "string"
|
||||
? record.fn
|
||||
: Buffer.from(record.fn.buffer, record.fn.byteOffset, record.fn.byteLength).toString(
|
||||
"base64",
|
||||
);
|
||||
return JSON.stringify(applied ? { ...record, fn, applied: true } : { ...record, fn });
|
||||
}
|
||||
|
||||
/** A call a unit made while it lowered, which applying its record makes
|
||||
* again. */
|
||||
export interface JournalEvent {
|
||||
@@ -62,9 +102,9 @@ export interface JournalEvent {
|
||||
/** One unit's record. */
|
||||
export interface UnitRecord {
|
||||
readonly name: string;
|
||||
/** The unit's function in the compact IR encoding, or null when the unit
|
||||
/** The unit's function (encodeRecordFunction), or null when the unit
|
||||
* lowered to nothing. */
|
||||
readonly fn: string | null;
|
||||
readonly fn: RecordFunction | null;
|
||||
/** True when the unit's lowering ended in a poison: its diagnostics and
|
||||
* the edges it fired before poisoning stay, and it has no function. */
|
||||
readonly poisoned: boolean;
|
||||
@@ -91,6 +131,12 @@ export interface JournalTable {
|
||||
readonly entries?: (from: number) => string[] | null;
|
||||
/** Adds the encoded entries whose keys this process's table lacks. */
|
||||
readonly apply?: (entries: readonly string[]) => void;
|
||||
/** Whether every encoded entry whose key this process's table holds
|
||||
* holds the same value there. */
|
||||
readonly agrees?: (entries: readonly string[]) => boolean;
|
||||
/** Whether this process's table holds an entry under the encoded entry's
|
||||
* key. */
|
||||
readonly holds?: (entry: string) => boolean;
|
||||
/** A digest of the content of the entry under `key`, when the table
|
||||
* holds one before position `before`; null otherwise. */
|
||||
readonly digest?: (key: string, before: number) => string | null;
|
||||
@@ -125,10 +171,20 @@ export function contentDigest(text: string): string {
|
||||
return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
/** The names quoted in JSON `texts`, unescaped only as far as their
|
||||
* delimiting quotes: the strings that start with `%` (helper, lifted and
|
||||
* IR data names) and union ids (`u` and 12 hex digits), which the tables
|
||||
* a unit reads and changes are keyed by. */
|
||||
/** Adds to `out` the names among `strings`: the strings that start with `%`
|
||||
* (helper, lifted and IR data names) and union ids (`u` and 12 hex digits),
|
||||
* which the tables a unit reads and changes are keyed by. */
|
||||
export function namedStrings(strings: Iterable<string>, out: Set<string>): void {
|
||||
for (const text of strings) {
|
||||
const first = text.charCodeAt(0);
|
||||
if (first === 37 /* % */) out.add(text);
|
||||
else if (first === 117 /* u */ && text.length === 13 && /^u[0-9a-f]{12}$/.test(text))
|
||||
out.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** The names (namedStrings) quoted in JSON `texts`, unescaped only as far
|
||||
* as their delimiting quotes. */
|
||||
export function quotedStrings(texts: readonly string[]): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102);
|
||||
@@ -225,6 +281,10 @@ export interface WaveHost {
|
||||
statementCounts(): { total: number; failed: number; other: number };
|
||||
/** Whether `event` names something this process holds. */
|
||||
resolves(event: JournalEvent): boolean;
|
||||
/** Keeps a unit's function from its record and makes the record's events
|
||||
* again, under the unit's generic demand owner and declared-order
|
||||
* priority. */
|
||||
keep(name: string, fn: IrFunction | null, events: readonly JournalEvent[]): void;
|
||||
}
|
||||
|
||||
/** Whether reachable emit records its units (SCRIPTC_LOWER_JOURNAL=serial,
|
||||
@@ -259,10 +319,69 @@ export interface WaveStats {
|
||||
unrecorded: number;
|
||||
/** Per table, field or check, the units it left without a record. */
|
||||
reasons: Record<string, number>;
|
||||
/** Units whose stored record applied in place of lowering them
|
||||
* (unit-reuse.ts). */
|
||||
applied: number;
|
||||
/** Time taking and checking the tables' contents, finding stored records,
|
||||
* applying them, and lowering and recording units, in milliseconds. */
|
||||
holdMs: number;
|
||||
storedMs: number;
|
||||
applyMs: number;
|
||||
recordMs: number;
|
||||
/** Of recordMs, the time the units' lowering took, and the time a
|
||||
* recorded unit's results took to be replaced by its record's. */
|
||||
lowerMs: number;
|
||||
replaceMs: number;
|
||||
}
|
||||
|
||||
export function waveStats(): WaveStats {
|
||||
return { waves: 0, units: 0, recorded: 0, unrecorded: 0, reasons: {} };
|
||||
return {
|
||||
waves: 0,
|
||||
units: 0,
|
||||
recorded: 0,
|
||||
unrecorded: 0,
|
||||
reasons: {},
|
||||
applied: 0,
|
||||
holdMs: 0,
|
||||
storedMs: 0,
|
||||
applyMs: 0,
|
||||
recordMs: 0,
|
||||
lowerMs: 0,
|
||||
replaceMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** A wave's use of the records an earlier build stored (unit-reuse.ts). */
|
||||
/** A record an earlier build stored, with its function, which the store
|
||||
* can keep apart from the record (`record.fn` is null then). */
|
||||
export interface StoredRecord {
|
||||
readonly record: UnitRecord;
|
||||
fn(): IrFunction | null;
|
||||
}
|
||||
|
||||
export interface WaveReuse {
|
||||
/** The stored record of `name`, when the unit can apply it. */
|
||||
stored(name: string): StoredRecord | null;
|
||||
/** Whether units with a stored record lower and are checked against it
|
||||
* instead (SCRIPTC_INCR_VERIFY=1). */
|
||||
readonly verify: boolean;
|
||||
/** Checks the record a unit lowered to against its stored one, whose
|
||||
* entries the tables did not hold yet at the unit's turn are `pending`. */
|
||||
check(
|
||||
name: string,
|
||||
stored: StoredRecord,
|
||||
pending: Readonly<Record<string, readonly string[]>>,
|
||||
unit: RecordedUnit,
|
||||
): void;
|
||||
/** Notes the record a unit lowered to, with the files its binary
|
||||
* function names (RecordedUnit.files), or the stored record it applied
|
||||
* (`files` null). */
|
||||
note(
|
||||
name: string,
|
||||
record: UnitRecord | null,
|
||||
applied: boolean,
|
||||
files: readonly string[] | null,
|
||||
): void;
|
||||
}
|
||||
|
||||
/** What lowering one unit with its events recording produced. */
|
||||
@@ -275,6 +394,11 @@ export interface RecordedUnit {
|
||||
readonly fn: IrFunction | null;
|
||||
/** Each table's size before the unit. */
|
||||
readonly before: readonly number[];
|
||||
/** The time the unit's lowering took. */
|
||||
readonly lowerMs: number;
|
||||
/** The files the fields named `file` in the record's function hold, when
|
||||
* the function is binary. */
|
||||
readonly files: readonly string[] | null;
|
||||
}
|
||||
|
||||
/** Lowers `name` with its events recording, and makes its record. The unit
|
||||
@@ -297,7 +421,9 @@ export function recordUnit(
|
||||
const marks = host.scalars.map((scalar) => scalar.mark());
|
||||
const counts = host.statementCounts();
|
||||
const recorder = new UnitEvents();
|
||||
const lowering = performance.now();
|
||||
const { fn, poisoned } = host.lowerRecorded(name, recorder);
|
||||
const lowerMs = performance.now() - lowering;
|
||||
const reasons: string[] = [];
|
||||
const grown: number[] = [];
|
||||
tables.forEach((table, index) => {
|
||||
@@ -322,8 +448,8 @@ export function recordUnit(
|
||||
if (now.total - counts.total !== statements || now.failed - counts.failed !== failed)
|
||||
reasons.push("statements");
|
||||
if (now.other !== counts.other) reasons.push("diagnostics");
|
||||
const text = fn === null ? null : encodeUnitFunction(fn);
|
||||
if (fn !== null && text === null) reasons.push("encoding");
|
||||
const encoded = fn === null ? null : encodeRecordFunction(fn);
|
||||
if (fn !== null && encoded === null) reasons.push("encoding");
|
||||
const added: Record<string, readonly string[]> = {};
|
||||
if (reasons.length === 0)
|
||||
for (const index of grown) {
|
||||
@@ -335,13 +461,14 @@ export function recordUnit(
|
||||
// A unit uses the entries its IR and new entries name: it reads them, and
|
||||
// a unit that changes one in place changes one it names.
|
||||
const texts: string[] = [];
|
||||
if (text !== null) texts.push(text);
|
||||
if (typeof encoded?.fn === "string") texts.push(encoded.fn);
|
||||
for (const index of grown) {
|
||||
const table = tables[index]!;
|
||||
const entries = added[table.name] ?? table.entries!(before[index]!);
|
||||
if (entries !== null) for (const entry of entries) texts.push(entry);
|
||||
}
|
||||
const names = quotedStrings(texts);
|
||||
if (encoded?.strings != null) namedStrings(encoded.strings, names);
|
||||
if (own !== null) {
|
||||
if (reasons.length === 0)
|
||||
tables.forEach((table, index) => {
|
||||
@@ -369,36 +496,72 @@ export function recordUnit(
|
||||
}
|
||||
if (read.length > 0) reads[table.name] = read;
|
||||
});
|
||||
if (reasons.length > 0) return { record: null, reasons, fn, before };
|
||||
if (reasons.length > 0) return { record: null, reasons, fn, before, lowerMs, files: null };
|
||||
return {
|
||||
record: { name, fn: text, poisoned, events, tables: added, reads },
|
||||
record: { name, fn: encoded?.fn ?? null, poisoned, events, tables: added, reads },
|
||||
reasons,
|
||||
fn,
|
||||
before,
|
||||
lowerMs,
|
||||
files: encoded?.files ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lowers `wave` with every unit recorded. A unit with a record keeps the
|
||||
* results its record carries: the entries it added are taken out of their
|
||||
* tables and added again from the record, and its function is the one
|
||||
* decoded from the record. A unit without one keeps what lowering made.
|
||||
/** Lowers `wave` with every unit recorded. Without `reuse`, a unit with a
|
||||
* record keeps the results its record carries: the entries it added are
|
||||
* taken out of their tables and added again from the record, and its
|
||||
* function is the one decoded from the record. Otherwise, and for a unit
|
||||
* without a record, a unit keeps what lowering made.
|
||||
* Throws an InternalCompilerError when a unit changed the content of an
|
||||
* entry a table held when the unit started (JournalTable.contents), which
|
||||
* no record carries.
|
||||
* `lines`, when given, receives each unit's record as a JSON line. */
|
||||
* `lines`, when given, receives each unit's record as a JSON line. A unit
|
||||
* whose stored record `reuse` gives applies it, and lowers when it does not
|
||||
* apply. With `held`, the wave continues the content the waves before it
|
||||
* left (HeldTables), and the check waits for HeldTables.check. */
|
||||
export function lowerWaveRecorded(
|
||||
host: WaveHost,
|
||||
wave: readonly string[],
|
||||
stats: WaveStats,
|
||||
lines: string[] | null = null,
|
||||
reuse: WaveReuse | null = null,
|
||||
held: HeldTables | null = null,
|
||||
): void {
|
||||
stats.waves++;
|
||||
const tables = host.tables;
|
||||
// Units keep the content of the entries that tables held when they
|
||||
// started: a record carries a unit's new entries, not changes to others.
|
||||
const contents = tables.map((table) => (table.contents === undefined ? null : table.contents()));
|
||||
const contents = held ?? new HeldTables(tables, stats);
|
||||
let started = 0;
|
||||
for (const name of wave) {
|
||||
const unit = recordUnit(host, name, contents);
|
||||
started = performance.now();
|
||||
let stored = reuse?.stored(name) ?? null;
|
||||
stats.storedMs += performance.now() - started;
|
||||
let pending: Readonly<Record<string, readonly string[]>> = {};
|
||||
if (stored !== null && reuse!.verify) {
|
||||
// Verify mode lowers the units whose stored record would apply and
|
||||
// checks the record they lower to.
|
||||
if (recordApplies(host, stored.record).length === 0)
|
||||
pending = pendingEntries(host, stored.record);
|
||||
else stored = null;
|
||||
} else if (stored !== null) {
|
||||
started = performance.now();
|
||||
const reasons = applyRecord(host, stored.record, stored.fn);
|
||||
stats.applyMs += performance.now() - started;
|
||||
if (reasons.length === 0) {
|
||||
stats.units++;
|
||||
stats.recorded++;
|
||||
stats.applied++;
|
||||
contents.extendTaken();
|
||||
lines?.push(recordLine(stored.record, true));
|
||||
reuse!.note(name, stored.record, true, null);
|
||||
continue;
|
||||
}
|
||||
for (const reason of reasons)
|
||||
stats.reasons[`stored:${reason}`] = (stats.reasons[`stored:${reason}`] ?? 0) + 1;
|
||||
}
|
||||
started = performance.now();
|
||||
const unit = recordUnit(host, name, contents.take());
|
||||
stats.recordMs += performance.now() - started;
|
||||
stats.lowerMs += unit.lowerMs;
|
||||
stats.units++;
|
||||
const record = unit.record;
|
||||
if (record === null) {
|
||||
@@ -406,43 +569,174 @@ export function lowerWaveRecorded(
|
||||
stats.unrecorded++;
|
||||
for (const reason of unit.reasons) stats.reasons[reason] = (stats.reasons[reason] ?? 0) + 1;
|
||||
host.store(name, unit.fn);
|
||||
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
|
||||
reuse?.note(name, null, false, null);
|
||||
continue;
|
||||
}
|
||||
lines?.push(JSON.stringify(record));
|
||||
lines?.push(recordLine(record));
|
||||
stats.recorded++;
|
||||
tables.forEach((table, index) => {
|
||||
const entries = record.tables[table.name];
|
||||
if (entries === undefined) return;
|
||||
table.truncate!(unit.before[index]!);
|
||||
table.apply!(entries);
|
||||
});
|
||||
host.store(name, record.fn === null ? null : decodeUnitFunction(record.fn));
|
||||
// The serial mode replaces a unit's results by its record's, so that the
|
||||
// build's output is what applying the records gives. Reuse keeps what
|
||||
// lowering made: its records apply in later builds, which verify mode
|
||||
// and the edit-sequence tests check.
|
||||
let fn = unit.fn;
|
||||
if (reuse === null) {
|
||||
const replacing = performance.now();
|
||||
tables.forEach((table, index) => {
|
||||
const entries = record.tables[table.name];
|
||||
if (entries === undefined) return;
|
||||
table.truncate!(unit.before[index]!);
|
||||
table.apply!(entries);
|
||||
});
|
||||
fn = record.fn === null ? null : decodeRecordFunction(record.fn);
|
||||
stats.replaceMs += performance.now() - replacing;
|
||||
}
|
||||
host.store(name, fn);
|
||||
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
|
||||
reuse?.note(name, record, false, unit.files);
|
||||
}
|
||||
tables.forEach((table, index) => {
|
||||
const key = contents[index]?.changed() ?? null;
|
||||
if (key !== null)
|
||||
throw new InternalCompilerError(
|
||||
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
|
||||
if (held === null) contents.check();
|
||||
}
|
||||
|
||||
/** The content of the tables' entries that recorded units start from
|
||||
* (JournalTable.contents): units keep the content of the entries the
|
||||
* tables held when they started, since a record carries a unit's new
|
||||
* entries, not changes to others. Applying a stored record only adds
|
||||
* entries, so the content is taken when the first unit lowers, and not at
|
||||
* all when none does. Consecutive waves with nothing but checker prefetches
|
||||
* between them share one: the content a wave leaves, as the check finds it,
|
||||
* is the content the next one starts with. */
|
||||
export class HeldTables {
|
||||
private contents: (HeldContents | null)[] | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly tables: readonly JournalTable[],
|
||||
private readonly stats: WaveStats,
|
||||
) {}
|
||||
|
||||
/** The held content, taken now when it was not yet. */
|
||||
take(): (HeldContents | null)[] {
|
||||
if (this.contents === null) {
|
||||
const taken = performance.now();
|
||||
this.contents = this.tables.map((table) =>
|
||||
table.contents === undefined ? null : table.contents(),
|
||||
);
|
||||
});
|
||||
this.stats.holdMs += performance.now() - taken;
|
||||
}
|
||||
return this.contents;
|
||||
}
|
||||
|
||||
/** Adds the entries past the held ones, when the content is taken. */
|
||||
extendTaken(): void {
|
||||
if (this.contents !== null) for (const entry of this.contents) entry?.extend();
|
||||
}
|
||||
|
||||
/** Throws an InternalCompilerError when an entry a table held changed in
|
||||
* place since the content was taken, other than by the units that keep
|
||||
* their changes (HeldContents.refresh). */
|
||||
check(): void {
|
||||
const contents = this.contents;
|
||||
if (contents === null) return;
|
||||
const started = performance.now();
|
||||
this.tables.forEach((table, index) => {
|
||||
const key = contents[index]?.changed() ?? null;
|
||||
if (key !== null)
|
||||
throw new InternalCompilerError(
|
||||
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
|
||||
);
|
||||
});
|
||||
this.stats.holdMs += performance.now() - started;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies `record` in place of lowering its unit, at the unit's turn, or
|
||||
* returns why the unit has to lower instead. A record applies when every
|
||||
* entry it adds agrees with what the tables hold, it names only known
|
||||
* tables, every event resolves and every entry it reads is held with the
|
||||
* same content. Applying adds its entries, keeps its function (`stored`'s,
|
||||
* when the store keeps it apart from the record) and makes its events
|
||||
* again. */
|
||||
export function applyRecord(
|
||||
host: WaveHost,
|
||||
record: UnitRecord,
|
||||
stored?: () => IrFunction | null,
|
||||
): string[] {
|
||||
const reasons = recordApplies(host, record);
|
||||
if (reasons.length > 0) return reasons;
|
||||
for (const table of host.tables) {
|
||||
const entries = record.tables[table.name];
|
||||
if (entries !== undefined) table.apply!(entries);
|
||||
}
|
||||
const fn = record.poisoned
|
||||
? null
|
||||
: stored !== undefined
|
||||
? stored()
|
||||
: record.fn === null
|
||||
? null
|
||||
: decodeRecordFunction(record.fn);
|
||||
host.keep(record.name, fn, record.events);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The entries `record` adds that the tables do not hold yet, by table
|
||||
* name: what applying it would add at its unit's turn. */
|
||||
export function pendingEntries(
|
||||
host: WaveHost,
|
||||
record: UnitRecord,
|
||||
): Record<string, readonly string[]> {
|
||||
const out: Record<string, readonly string[]> = {};
|
||||
for (const table of host.tables) {
|
||||
const entries = record.tables[table.name];
|
||||
if (entries === undefined) continue;
|
||||
const pending = entries.filter((entry) => table.holds?.(entry) !== true);
|
||||
if (pending.length > 0) out[table.name] = pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Why `record` does not apply at its unit's turn (applyRecord), or no
|
||||
* reasons when it does. */
|
||||
export function recordApplies(host: WaveHost, record: UnitRecord): string[] {
|
||||
const tables = host.tables;
|
||||
for (const table of tables) {
|
||||
const entries = record.tables[table.name];
|
||||
if (entries === undefined) continue;
|
||||
if (table.agrees === undefined || table.apply === undefined || !table.agrees(entries))
|
||||
return [`disagrees:${table.name}`];
|
||||
}
|
||||
for (const table of Object.keys(record.tables))
|
||||
if (!tables.some((held) => held.name === table)) return [`unknown table:${table}`];
|
||||
for (const event of record.events) if (!host.resolves(event)) return [`event:${event.kind}`];
|
||||
for (const table of tables) {
|
||||
const reads = record.reads[table.name];
|
||||
if (reads === undefined) continue;
|
||||
if (table.digest === undefined) return [`unknown read:${table.name}`];
|
||||
const size = table.size();
|
||||
for (const [key, digest] of reads)
|
||||
if (table.digest(key, size) !== digest) return [`read:${table.name}`];
|
||||
}
|
||||
for (const table of Object.keys(record.reads))
|
||||
if (!tables.some((held) => held.name === table)) return [`unknown read:${table}`];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The content of a keyed table's entries (JournalTable.contents): each
|
||||
* entry's encoded value, and the order of the keys, since a table only adds
|
||||
* entries at the end. An entry is found by its key, or by the name its value
|
||||
* holds (`valueName`): a unit names the helper it got, not the key it was
|
||||
* looked up by. `text` answers undefined for a key the table does not
|
||||
* entry's content (`text`: its encoded value, or the value itself when it
|
||||
* is a string or a number), and the order of the keys, since a table only
|
||||
* adds entries at the end. An entry is found by its key, or by the name its
|
||||
* value holds (`valueName`): a unit names the helper it got, not the key it
|
||||
* was looked up by. `text` answers undefined for a key the table does not
|
||||
* hold. */
|
||||
function keyedContents(
|
||||
size: () => number,
|
||||
keys: () => Iterable<string>,
|
||||
text: (key: string) => string | null | undefined,
|
||||
text: (key: string) => string | number | null | undefined,
|
||||
valueName: (key: string) => string | null,
|
||||
): HeldContents {
|
||||
const texts = new Map<string, string | null>();
|
||||
const texts = new Map<string, string | number | null>();
|
||||
let order: string[] = [];
|
||||
const byName = new Map<string, string>();
|
||||
const differs = (key: string): boolean => text(key) !== texts.get(key);
|
||||
const differs = (key: string): boolean => !Object.is(text(key), texts.get(key));
|
||||
const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name));
|
||||
const held: HeldContents = {
|
||||
extend: () => {
|
||||
@@ -499,6 +793,7 @@ export function mapTable(name: string, map: Map<string, string>): JournalTable {
|
||||
(value) => JSON.stringify(value),
|
||||
(text) => JSON.parse(text) as string,
|
||||
(value) => value,
|
||||
(value) => value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,6 +804,8 @@ export function countTable(name: string, map: Map<string, number>): JournalTable
|
||||
map,
|
||||
(value) => String(value),
|
||||
(text) => Number(text),
|
||||
null,
|
||||
(value) => value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,13 +827,17 @@ export function irDataTable<V>(name: string, map: Map<string, V>): JournalTable
|
||||
/** An appendable table over a Map from strings to values that `encode`
|
||||
* writes, or answers null for, and `decode` reads back. An entry is its key
|
||||
* as a JSON string, a line break and its value: a JSON string holds no line
|
||||
* break. A value that does not encode leaves its unit without a record. */
|
||||
* break. A value that does not encode leaves its unit without a record.
|
||||
* `content` is what held contents compare (JournalTable.contents): the
|
||||
* encoding, or the value itself for a table of strings or numbers, which
|
||||
* only change by being replaced. */
|
||||
export function codecTable<V>(
|
||||
name: string,
|
||||
map: Map<string, V>,
|
||||
encode: (value: V) => string | null,
|
||||
decode: (text: string) => V,
|
||||
nameOf: ((value: V) => string) | null = null,
|
||||
content: (value: V) => string | number | null = encode,
|
||||
): JournalTable {
|
||||
return {
|
||||
name,
|
||||
@@ -552,6 +853,13 @@ export function codecTable<V>(
|
||||
}
|
||||
return out;
|
||||
},
|
||||
agrees: (entries) =>
|
||||
entries.every((entry) => {
|
||||
const split = entry.indexOf("\n");
|
||||
const held = map.get(JSON.parse(entry.slice(0, split)) as string);
|
||||
return held === undefined || encode(held) === entry.slice(split + 1);
|
||||
}),
|
||||
holds: (entry) => map.has(JSON.parse(entry.slice(0, entry.indexOf("\n"))) as string),
|
||||
apply: (entries) => {
|
||||
for (const entry of entries) {
|
||||
const split = entry.indexOf("\n");
|
||||
@@ -569,7 +877,7 @@ export function codecTable<V>(
|
||||
() => map.keys(),
|
||||
(key) => {
|
||||
const value = map.get(key);
|
||||
return value === undefined ? undefined : encode(value);
|
||||
return value === undefined ? undefined : content(value);
|
||||
},
|
||||
(key) => {
|
||||
const value = map.get(key);
|
||||
@@ -585,6 +893,8 @@ export function setTable(name: string, set: Set<string>): JournalTable {
|
||||
name,
|
||||
size: () => set.size,
|
||||
entries: (from) => [...set].slice(from),
|
||||
agrees: () => true,
|
||||
holds: (entry) => set.has(entry),
|
||||
apply: (entries) => {
|
||||
for (const entry of entries) set.add(entry);
|
||||
},
|
||||
@@ -652,6 +962,14 @@ export function functionTable(
|
||||
}
|
||||
return out;
|
||||
},
|
||||
agrees: (entries) =>
|
||||
entries.every((entry) => {
|
||||
const fn = decodeUnitFunction(entry);
|
||||
if (!has(fn.name)) return true;
|
||||
const index = indexOf(fn.name);
|
||||
return index !== undefined && encodeUnitFunction(functions[index]!) === entry;
|
||||
}),
|
||||
holds: (entry) => has(decodeUnitFunction(entry).name),
|
||||
apply: (entries) => {
|
||||
for (const entry of entries) {
|
||||
const fn = decodeUnitFunction(entry);
|
||||
@@ -663,16 +981,17 @@ export function functionTable(
|
||||
while (functions.length > from) functions.pop();
|
||||
changed();
|
||||
},
|
||||
// Held functions are their content digests (heldFunction).
|
||||
contents: () => {
|
||||
const texts: (string | null)[] = [];
|
||||
const indexes = new Map<string, number>();
|
||||
const differs = (index: number): boolean =>
|
||||
index < functions.length && encodeUnitFunction(functions[index]!) !== texts[index];
|
||||
index < functions.length && heldFunction(functions[index]!) !== texts[index];
|
||||
const held: HeldContents = {
|
||||
extend: () => {
|
||||
for (let index = texts.length; index < functions.length; index++) {
|
||||
const fn = functions[index]!;
|
||||
texts.push(encodeUnitFunction(fn));
|
||||
texts.push(heldFunction(fn));
|
||||
indexes.set(fn.name, index);
|
||||
}
|
||||
},
|
||||
@@ -692,7 +1011,7 @@ export function functionTable(
|
||||
for (const key of keys) {
|
||||
const index = indexes.get(key);
|
||||
if (index !== undefined && index < functions.length)
|
||||
texts[index] = encodeUnitFunction(functions[index]!);
|
||||
texts[index] = heldFunction(functions[index]!);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -702,6 +1021,17 @@ export function functionTable(
|
||||
};
|
||||
}
|
||||
|
||||
/** The content a function table holds of `fn` (HeldContents): its digest
|
||||
* (functionDigest), or its entry encoding when the binary encoder cannot
|
||||
* write it. */
|
||||
function heldFunction(fn: IrFunction): string | null {
|
||||
try {
|
||||
return functionDigest(fn);
|
||||
} catch {
|
||||
return encodeUnitFunction(fn);
|
||||
}
|
||||
}
|
||||
|
||||
/** A table whose growth leaves the unit without a record. */
|
||||
export function checkedTable(name: string, size: () => number): JournalTable {
|
||||
return { name, size };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { InternalCompilerError } from "../errors.js";
|
||||
import { keyedHelperName, type KeyedNameOwner } from "./lowering/stable-names.js";
|
||||
import type { HeldContents, JournalTable } from "./lowering/unit-journal.js";
|
||||
import { contentDigest, type HeldContents, type JournalTable } from "./lowering/unit-journal.js";
|
||||
import { mockCallRecordType, mockContextType } from "./mock-shapes.js";
|
||||
import * as ts from "./ts7/adapter.js";
|
||||
import { bodyReadsArguments } from "./arguments-usage.js";
|
||||
@@ -716,9 +716,39 @@ export class UnionRegistry {
|
||||
* the key its arms give. A unit that adds a recursive union changes
|
||||
* journalSizes, so the unions past a unit's start are interned ones. */
|
||||
journalTable(): JournalTable {
|
||||
// The position of each union by id, for the unions indexed so far.
|
||||
let indexes = new Map<string, number>();
|
||||
let indexed = 0;
|
||||
const indexOf = (id: string): number | undefined => {
|
||||
if (indexed > this.unions.length) {
|
||||
indexes = new Map();
|
||||
indexed = 0;
|
||||
}
|
||||
for (; indexed < this.unions.length; indexed++)
|
||||
indexes.set(this.unions[indexed]!.id, indexed);
|
||||
const index = indexes.get(id);
|
||||
return index !== undefined && this.unions[index]?.id === id ? index : undefined;
|
||||
};
|
||||
return {
|
||||
name: "unions",
|
||||
size: () => this.unions.length,
|
||||
digest: (key, before) => {
|
||||
if (!this.byId.has(key)) return null;
|
||||
let index = indexOf(key);
|
||||
if (index === undefined) {
|
||||
// A list that was cut and grown again holds other unions at the
|
||||
// positions indexed before.
|
||||
indexes = new Map();
|
||||
indexed = 0;
|
||||
index = indexOf(key);
|
||||
}
|
||||
if (index === undefined || index >= before) return null;
|
||||
return contentDigest(JSON.stringify(this.unions[index]!));
|
||||
},
|
||||
holds: (entry) => {
|
||||
const def = JSON.parse(entry) as IrUnionDef;
|
||||
return this.byKey.has(unionKey(def.arms, def.discriminant));
|
||||
},
|
||||
entries: (from) => {
|
||||
const out: string[] = [];
|
||||
for (const def of this.unions.slice(from)) {
|
||||
@@ -729,6 +759,12 @@ export class UnionRegistry {
|
||||
}
|
||||
return out;
|
||||
},
|
||||
agrees: (entries) =>
|
||||
entries.every((entry) => {
|
||||
const def = JSON.parse(entry) as IrUnionDef;
|
||||
const held = this.byKey.get(unionKey(def.arms, def.discriminant));
|
||||
return held === undefined ? !this.byId.has(def.id) : held === def.id;
|
||||
}),
|
||||
apply: (entries) => {
|
||||
for (const entry of entries) {
|
||||
const def = JSON.parse(entry) as IrUnionDef;
|
||||
|
||||
@@ -23,6 +23,11 @@ import { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js";
|
||||
import { setLibDiagnosticsStore } from "./frontend/ts7/lib-diagnostics.js";
|
||||
import { analyzeWithVerdictCache, fileVerdictStore } from "./coverage/verdict-cache.js";
|
||||
import { coverageCompilerIdentity } from "./coverage/verdict-startup.js";
|
||||
import {
|
||||
directoryUnitStore,
|
||||
unitReuseEnabled,
|
||||
type UnitStore,
|
||||
} from "./frontend/lowering/unit-reuse.js";
|
||||
import { commentOnlyReplay } from "./coverage/verdict-semantic.js";
|
||||
import { llvmRefusalDiag, targetRefusalDiag } from "./backend/target-diagnostics.js";
|
||||
import type {
|
||||
@@ -914,6 +919,37 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD
|
||||
);
|
||||
}
|
||||
|
||||
/** The unit store of a Node-hosted build (unit-reuse.ts), keyed by the
|
||||
* compiler, the entry, the options that reach lowering and the target
|
||||
* platform. */
|
||||
async function nodeUnitStore(
|
||||
entryPath: string,
|
||||
opts: CompileRequestOptions,
|
||||
ffi: FfiProfile | null,
|
||||
buildPlatform: string,
|
||||
): Promise<UnitStore | null> {
|
||||
if (!unitReuseEnabled() || provenanceSources() !== null) return null;
|
||||
const root = await prepareBuildCacheRoot(buildCacheRoot());
|
||||
if (root === null) return null;
|
||||
const directory = join(root, "units-v1");
|
||||
const implementation = await coverageCompilerIdentity(directory);
|
||||
if (implementation === null) return null;
|
||||
const { outPath: _outPath, outDir: _outDir, ...lowering } = opts;
|
||||
const key = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
entry: resolve(entryPath),
|
||||
options: lowering,
|
||||
ffi,
|
||||
platform: buildPlatform,
|
||||
compiler: implementation.digest,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return directoryUnitStore(directory, key);
|
||||
}
|
||||
|
||||
interface PreparedExecutable {
|
||||
llvmSource: LlvmInput;
|
||||
/** The kept LLVM text beside the executable, installed when the build ends. */
|
||||
@@ -945,6 +981,9 @@ async function prepareExecutableInput(
|
||||
buildPlatform,
|
||||
nodeFrontend,
|
||||
timing,
|
||||
new Map(),
|
||||
true,
|
||||
await nodeUnitStore(entryPath, opts, ffi, buildPlatform),
|
||||
);
|
||||
if (!prepared.ok) return prepared;
|
||||
const { mod, sourceTexts } = prepared;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
import { mkdtemp, readFile, rm } 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 {
|
||||
decodeFunctionBinary,
|
||||
decodeModuleBinary,
|
||||
encodeFunctionBinary,
|
||||
encodeFunctionInOrder,
|
||||
encodeFunctionStrings,
|
||||
encodeModuleBinary,
|
||||
functionDigest,
|
||||
} from "./codec.js";
|
||||
import { IR_CODEC_LAYOUT } from "./codec.generated.js";
|
||||
import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js";
|
||||
import { deserializeModuleBytes } from "./serialize.js";
|
||||
|
||||
// Programs with many node kinds: classes, closures, async, generators,
|
||||
// dynamic values, streams, records, regexes and lone surrogates.
|
||||
const PROGRAMS = [
|
||||
"1003-json-parse-unions.ts",
|
||||
"114-live-record-delete.ts",
|
||||
"1200-regex-test-basics.ts",
|
||||
"1631-inspect-arrays.ts",
|
||||
"4900-console-instances.ts",
|
||||
"4991-lone-surrogate-halves.ts",
|
||||
"5612-readable-from-iterables.cjs",
|
||||
"7110-als-completion-callbacks.ts",
|
||||
"7321-speculative-loop-bail-sites.ts",
|
||||
];
|
||||
|
||||
/** Whether `a` and `b` hold the same data: the same keys with defined
|
||||
* values, in any order, and numbers equal by Object.is. */
|
||||
function sameData(a: unknown, b: unknown): boolean {
|
||||
if (typeof a === "number" && typeof b === "number") return Object.is(a, b);
|
||||
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return a === b;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
const record = (value: object) => value as Record<string, unknown>;
|
||||
const keys = (value: object) =>
|
||||
Object.keys(value)
|
||||
.filter((key) => record(value)[key] !== undefined)
|
||||
.sort();
|
||||
const ka = keys(a);
|
||||
const kb = keys(b);
|
||||
return (
|
||||
ka.length === kb.length &&
|
||||
ka.every((key, index) => key === kb[index] && sameData(record(a)[key], record(b)[key]))
|
||||
);
|
||||
}
|
||||
|
||||
test.each(PROGRAMS)("%s round-trips through the binary encoding", async (program) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-codec-"));
|
||||
try {
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
const entry = join(import.meta.dirname, "../../../../tests/corpus", program);
|
||||
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"));
|
||||
const mod = deserializeModule(await readFile(outPath, "utf8"));
|
||||
const bytes = encodeModuleBinary(mod);
|
||||
const back = decodeModuleBinary(bytes);
|
||||
expect(sameData(mod, back)).toBe(true);
|
||||
expect(Buffer.compare(encodeModuleBinary(back), bytes)).toBe(0);
|
||||
// The JSON reader takes the binary encoding too.
|
||||
expect(sameData(deserializeModuleBytes(bytes), mod)).toBe(true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const loc = { file: "/src/main.ts", start: 0, end: 1 };
|
||||
const fn = (body: IrFunction["body"]): IrFunction => ({
|
||||
name: "%main",
|
||||
params: [],
|
||||
locals: [],
|
||||
body,
|
||||
returnType: VOID,
|
||||
loc,
|
||||
});
|
||||
|
||||
test("numbers keep NaN, the infinities, -0 and integers outside int32", () => {
|
||||
const values = [
|
||||
NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
-0,
|
||||
0,
|
||||
1,
|
||||
-1,
|
||||
2 ** 31,
|
||||
-(2 ** 31) - 1,
|
||||
2 ** 53,
|
||||
0.1,
|
||||
-1.5e300,
|
||||
];
|
||||
const source = fn(
|
||||
values.map((value) => ({
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "numLit", value, type: F64, loc },
|
||||
loc,
|
||||
})),
|
||||
);
|
||||
const back = decodeFunctionBinary(encodeFunctionBinary(source));
|
||||
const read = back.body.map((s) =>
|
||||
s.kind === "exprStmt" && s.expr.kind === "numLit" ? s.expr.value : null,
|
||||
);
|
||||
expect(read.length).toBe(values.length);
|
||||
read.forEach((value, index) => expect(Object.is(value, values[index])).toBe(true));
|
||||
});
|
||||
|
||||
test("strings keep lone surrogates and repeat through the string table", () => {
|
||||
const texts = ["plain", "\ud83d", "a\udc00b", "😀", "plain", "é"];
|
||||
const source = fn(
|
||||
texts.map((value) => ({
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "strLit", value, type: STRING, loc },
|
||||
loc,
|
||||
})),
|
||||
);
|
||||
const back = decodeFunctionBinary(encodeFunctionBinary(source));
|
||||
expect(
|
||||
back.body.map((s) => (s.kind === "exprStmt" && s.expr.kind === "strLit" ? s.expr.value : null)),
|
||||
).toEqual(texts);
|
||||
});
|
||||
|
||||
test("absent optional fields stay absent", () => {
|
||||
const source: IrModule = {
|
||||
irVersion: 17,
|
||||
sourceFile: "/src/main.ts",
|
||||
functions: [fn([])],
|
||||
entry: "%main",
|
||||
};
|
||||
const back = decodeModuleBinary(encodeModuleBinary(source));
|
||||
expect(Object.keys(back).sort()).toEqual(["entry", "functions", "irVersion", "sourceFile"]);
|
||||
expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort());
|
||||
});
|
||||
|
||||
test("a function whose objects follow the declared field order reads back with its key order", () => {
|
||||
// As lowering builds functions: the source name after the name, and the
|
||||
// async flag set after the literal.
|
||||
const source: IrFunction = {
|
||||
name: "%f",
|
||||
sourceName: "f",
|
||||
ownsPrototype: true,
|
||||
params: [],
|
||||
returnType: VOID,
|
||||
locals: [{ id: "x.0", name: "x", type: F64, mutable: true }],
|
||||
body: [{ kind: "return", value: null, loc }],
|
||||
loc,
|
||||
};
|
||||
source.async = true;
|
||||
const bytes = encodeFunctionInOrder(source);
|
||||
expect(bytes).not.toBeNull();
|
||||
const back = decodeFunctionBinary(bytes!);
|
||||
expect(JSON.stringify(back)).toBe(JSON.stringify(source));
|
||||
expect(Object.keys(back)).toEqual(Object.keys(source));
|
||||
});
|
||||
|
||||
test("a function with an object out of the declared field order has no in-order encoding", () => {
|
||||
const source = fn([{ kind: "return", value: null, loc }]);
|
||||
// `loc` first: spreading the rest keeps its place.
|
||||
const { loc: first, ...rest } = source;
|
||||
const shuffled = { loc: first, ...rest } as IrFunction;
|
||||
expect(encodeFunctionInOrder(shuffled)).toBeNull();
|
||||
// An object with a key its type does not declare.
|
||||
expect(encodeFunctionInOrder({ ...source, extra: 1 } as IrFunction)).toBeNull();
|
||||
// The helper above builds `locals` before `returnType`.
|
||||
expect(encodeFunctionInOrder(source)).toBeNull();
|
||||
const ordered: IrFunction = {
|
||||
name: source.name,
|
||||
params: [],
|
||||
returnType: VOID,
|
||||
locals: [],
|
||||
body: source.body,
|
||||
loc,
|
||||
};
|
||||
expect(encodeFunctionInOrder(ordered)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("a node whose kind its union does not declare is refused", () => {
|
||||
const source = fn([{ kind: "noSuchStatement", loc } as never]);
|
||||
expect(encodeFunctionInOrder(source)).toBeNull();
|
||||
expect(() => encodeFunctionBinary(source)).toThrow(/cannot encode a IrStmt kind noSuchStatement/);
|
||||
});
|
||||
|
||||
test("an encoding lists its strings and the files its fields named file hold", () => {
|
||||
const other = { file: "/src/other.ts", start: 2, end: 3 };
|
||||
const source: IrFunction = {
|
||||
name: "%f",
|
||||
params: [],
|
||||
returnType: VOID,
|
||||
locals: [],
|
||||
body: [
|
||||
{
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "strLit", value: "%named", type: STRING, loc: other },
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
};
|
||||
const encoded = encodeFunctionStrings(source)!;
|
||||
expect(encoded.strings).toEqual(expect.arrayContaining(["%f", "%named", "/src/main.ts"]));
|
||||
expect([...encoded.files].sort()).toEqual(["/src/main.ts", "/src/other.ts"]);
|
||||
expect(Buffer.compare(encoded.bytes, encodeFunctionInOrder(source)!)).toBe(0);
|
||||
});
|
||||
|
||||
test("a function's digest follows its content and not its key order", () => {
|
||||
const source = fn([{ kind: "return", value: null, loc }]);
|
||||
const digest = functionDigest(source);
|
||||
expect(digest).toMatch(/^\d+:[0-9a-f]{16}$/);
|
||||
expect(functionDigest(fn([{ kind: "return", value: null, loc }]))).toBe(digest);
|
||||
// The same fields in another order.
|
||||
const { loc: first, ...rest } = source;
|
||||
expect(functionDigest({ loc: first, ...rest } as IrFunction)).toBe(digest);
|
||||
// A change in place.
|
||||
source.locals.push({ id: "x.0", name: "x", type: F64, mutable: true });
|
||||
expect(functionDigest(source)).not.toBe(digest);
|
||||
// A node the encoder refuses is covered by the function's JSON text.
|
||||
const refused = fn([{ kind: "noSuchStatement", loc } as never]);
|
||||
const text = functionDigest(refused);
|
||||
expect(text).toMatch(/^json:/);
|
||||
(refused.body[0] as unknown as { extra: number }).extra = 1;
|
||||
expect(functionDigest(refused)).not.toBe(text);
|
||||
});
|
||||
|
||||
test("bytes another layout wrote are refused", () => {
|
||||
const bytes = Buffer.from(encodeFunctionBinary(fn([])));
|
||||
// The layout digest follows the magic as the first new string.
|
||||
const at = bytes.indexOf(Buffer.from(IR_CODEC_LAYOUT));
|
||||
bytes[at] = bytes[at] === 0x30 ? 0x31 : 0x30;
|
||||
expect(() => decodeFunctionBinary(bytes)).toThrow(/layout/);
|
||||
expect(() => decodeFunctionBinary(Buffer.from("{}"))).toThrow(/not an IR encoding/);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
/* The IR's binary encoding: the fields of every node and record, by name in
|
||||
* the order ir.ts declares them (codec.generated.ts), written without field
|
||||
* names or JSON structure.
|
||||
*
|
||||
* - A length, a union's member or arm index and a string reference are
|
||||
* unsigned varints (7 bits per byte, low bits first).
|
||||
* - A number is one byte, 0 for an integer in the int32 range other than
|
||||
* -0, followed by its zigzag varint, or 1 followed by its IEEE 754 bytes
|
||||
* (little-endian), which keep NaN, the infinities and -0.
|
||||
* - A string is a varint: 0 for a new string, which also gives it the next
|
||||
* index in the string table, otherwise one more than the index of a
|
||||
* string the encoding already has. A new string's bytes follow, after a
|
||||
* varint of twice their count: plus one when they are UTF-16LE code units
|
||||
* (a string with a lone surrogate, which UTF-8 cannot spell), else UTF-8.
|
||||
* - A boolean is one byte.
|
||||
* - A field that can be absent or null starts with a byte: 0 absent, then 1
|
||||
* for null and 2 for a value when both are possible, otherwise 1 for a
|
||||
* value.
|
||||
*
|
||||
* An encoding starts with MAGIC and IR_CODEC_LAYOUT, a digest of the
|
||||
* generated coders, so a reader refuses bytes that other coders wrote. Decoded objects carry their fields in
|
||||
* declaration order, with absent optional fields left out; an object read
|
||||
* back has the key order of the object written when IrWriter.inOrder held. */
|
||||
import type { IrFunction, IrModule } from "./ir.js";
|
||||
import {
|
||||
IR_CODEC_LAYOUT,
|
||||
decodeIrFunction,
|
||||
decodeIrModule,
|
||||
encodeIrFunction,
|
||||
encodeIrModule,
|
||||
} from "./codec.generated.js";
|
||||
|
||||
/** The first bytes of an encoding: "SCIR". */
|
||||
const MAGIC = [0x53, 0x43, 0x49, 0x52];
|
||||
|
||||
/** Whether `value` has a surrogate code unit without its pair. */
|
||||
function loneSurrogate(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit < 0xd800 || unit > 0xdfff) continue;
|
||||
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
|
||||
if (unit > 0xdbff || next < 0xdc00 || next > 0xdfff) return true;
|
||||
index++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class IrWriter {
|
||||
private bytes = Buffer.allocUnsafe(1 << 16);
|
||||
private size = 0;
|
||||
private readonly strings = new Map<string, number>();
|
||||
/** The values of the string fields named `file` written so far. */
|
||||
readonly files = new Set<string>();
|
||||
private readonly scratch = Buffer.allocUnsafe(8);
|
||||
/** Whether every object written so far reads back with its own key order
|
||||
* (`keys`). False once one held a key its type does not declare, or its
|
||||
* keys in another order than the declaration's. */
|
||||
inOrder = true;
|
||||
|
||||
/** Notes an object's own keys against its type's declared fields. The
|
||||
* decoder builds an object with its fields in declaration order, so an
|
||||
* object whose keys come in another order, or include one the type does
|
||||
* not declare, does not read back as it was. */
|
||||
keys(own: readonly string[], declared: readonly string[]): void {
|
||||
if (!this.inOrder) return;
|
||||
let at = 0;
|
||||
for (const key of own) {
|
||||
while (at < declared.length && declared[at] !== key) at++;
|
||||
if (at === declared.length) {
|
||||
this.inOrder = false;
|
||||
return;
|
||||
}
|
||||
at++;
|
||||
}
|
||||
}
|
||||
|
||||
private reserve(count: number): void {
|
||||
if (this.size + count <= this.bytes.length) return;
|
||||
let capacity = this.bytes.length * 2;
|
||||
while (capacity < this.size + count) capacity *= 2;
|
||||
const grown = Buffer.allocUnsafe(capacity);
|
||||
this.bytes.copy(grown, 0, 0, this.size);
|
||||
this.bytes = grown;
|
||||
}
|
||||
|
||||
byte(value: number): void {
|
||||
this.reserve(1);
|
||||
this.bytes[this.size++] = value;
|
||||
}
|
||||
|
||||
/** A non-negative integer below 2^53. */
|
||||
length(value: number): void {
|
||||
this.reserve(8);
|
||||
while (value >= 0x80) {
|
||||
this.bytes[this.size++] = (value % 0x80) + 0x80;
|
||||
value = Math.floor(value / 0x80);
|
||||
}
|
||||
this.bytes[this.size++] = value;
|
||||
}
|
||||
|
||||
bool(value: boolean): void {
|
||||
this.byte(value ? 1 : 0);
|
||||
}
|
||||
|
||||
num(value: number): void {
|
||||
if (
|
||||
Number.isInteger(value) &&
|
||||
value >= -0x80000000 &&
|
||||
value <= 0x7fffffff &&
|
||||
!Object.is(value, -0)
|
||||
) {
|
||||
this.byte(0);
|
||||
this.length(value < 0 ? -value * 2 - 1 : value * 2);
|
||||
return;
|
||||
}
|
||||
this.byte(1);
|
||||
this.scratch.writeDoubleLE(value, 0);
|
||||
this.reserve(8);
|
||||
this.scratch.copy(this.bytes, this.size, 0, 8);
|
||||
this.size += 8;
|
||||
}
|
||||
|
||||
str(value: string): void {
|
||||
const known = this.strings.get(value);
|
||||
if (known !== undefined) {
|
||||
this.length(known + 1);
|
||||
return;
|
||||
}
|
||||
this.strings.set(value, this.strings.size);
|
||||
this.length(0);
|
||||
const utf8 = Buffer.byteLength(value, "utf8");
|
||||
// UTF-8 cannot spell a lone surrogate, so a string with one is written
|
||||
// as its UTF-16 code units.
|
||||
const units = utf8 !== value.length && loneSurrogate(value);
|
||||
const count = units ? value.length * 2 : utf8;
|
||||
this.length(count * 2 + (units ? 1 : 0));
|
||||
this.reserve(count);
|
||||
if (units) this.bytes.write(value, this.size, count, "utf16le");
|
||||
else this.bytes.write(value, this.size, count, "utf8");
|
||||
this.size += count;
|
||||
}
|
||||
|
||||
/** What the encoding could not write, such as a node whose kind its
|
||||
* union does not declare, or null. Such an encoding does not decode. */
|
||||
refused: string | null = null;
|
||||
|
||||
/** Notes that `what` could not be written (`refused`). */
|
||||
refuse(what: string): void {
|
||||
this.inOrder = false;
|
||||
this.refused ??= what;
|
||||
}
|
||||
|
||||
/** A string field named `file`: a source file the encoding names. */
|
||||
file(value: string): void {
|
||||
this.files.add(value);
|
||||
this.str(value);
|
||||
}
|
||||
|
||||
/** Raw bytes, after their count. */
|
||||
blob(bytes: Uint8Array): void {
|
||||
this.length(bytes.length);
|
||||
this.reserve(bytes.length);
|
||||
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).copy(this.bytes, this.size);
|
||||
this.size += bytes.length;
|
||||
}
|
||||
|
||||
/** The strings written so far, each once, in the order first written. */
|
||||
written(): IterableIterator<string> {
|
||||
return this.strings.keys();
|
||||
}
|
||||
|
||||
/** Starts a new encoding in the same buffer. */
|
||||
reset(): void {
|
||||
this.size = 0;
|
||||
this.strings.clear();
|
||||
this.files.clear();
|
||||
this.inOrder = true;
|
||||
this.refused = null;
|
||||
}
|
||||
|
||||
/** The bytes written so far. Throws when the encoding refused a value. */
|
||||
finish(): Buffer {
|
||||
if (this.refused !== null) throw new Error(`IR codec: cannot encode a ${this.refused}`);
|
||||
return this.bytes.subarray(0, this.size);
|
||||
}
|
||||
}
|
||||
|
||||
export class IrReader {
|
||||
private offset = 0;
|
||||
private readonly strings: string[] = [];
|
||||
/** The last presence byte `tag` read. */
|
||||
lastTag = 0;
|
||||
|
||||
constructor(private readonly bytes: Buffer) {}
|
||||
|
||||
private fail(what: string): never {
|
||||
throw new Error(`IR codec: ${what} at byte ${this.offset}`);
|
||||
}
|
||||
|
||||
byte(): number {
|
||||
if (this.offset >= this.bytes.length) this.fail("unexpected end");
|
||||
return this.bytes[this.offset++]!;
|
||||
}
|
||||
|
||||
length(): number {
|
||||
let value = 0;
|
||||
let scale = 1;
|
||||
for (;;) {
|
||||
const byte = this.byte();
|
||||
value += (byte % 0x80) * scale;
|
||||
if (byte < 0x80) return value;
|
||||
scale *= 0x80;
|
||||
if (scale > 2 ** 56) this.fail("varint too long");
|
||||
}
|
||||
}
|
||||
|
||||
bool(): boolean {
|
||||
return this.byte() !== 0;
|
||||
}
|
||||
|
||||
num(): number {
|
||||
const tag = this.byte();
|
||||
if (tag === 0) {
|
||||
const zigzag = this.length();
|
||||
return zigzag % 2 === 0 ? zigzag / 2 : -(zigzag + 1) / 2;
|
||||
}
|
||||
if (tag !== 1) this.fail("invalid number tag");
|
||||
if (this.offset + 8 > this.bytes.length) this.fail("unexpected end");
|
||||
const value = this.bytes.readDoubleLE(this.offset);
|
||||
this.offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
str(): string {
|
||||
const reference = this.length();
|
||||
if (reference > 0) {
|
||||
const known = this.strings[reference - 1];
|
||||
if (known === undefined) this.fail("invalid string reference");
|
||||
return known;
|
||||
}
|
||||
const spelled = this.length();
|
||||
const count = Math.floor(spelled / 2);
|
||||
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
|
||||
const value =
|
||||
spelled % 2 === 0
|
||||
? this.bytes.toString("utf8", this.offset, this.offset + count)
|
||||
: this.bytes.toString("utf16le", this.offset, this.offset + count);
|
||||
this.offset += count;
|
||||
this.strings.push(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Raw bytes blob wrote, as a view of the bytes read. */
|
||||
blob(): Buffer {
|
||||
const count = this.length();
|
||||
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
|
||||
const out = this.bytes.subarray(this.offset, this.offset + count);
|
||||
this.offset += count;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A presence byte below `limit`, kept in lastTag. */
|
||||
tag(limit: number, where: string): number {
|
||||
const tag = this.byte();
|
||||
if (tag >= limit) this.fail(`invalid presence byte for ${where}`);
|
||||
this.lastTag = tag;
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Whether every byte was read. */
|
||||
done(): boolean {
|
||||
return this.offset === this.bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function header(w: IrWriter): void {
|
||||
for (const byte of MAGIC) w.byte(byte);
|
||||
w.str(IR_CODEC_LAYOUT);
|
||||
}
|
||||
|
||||
function readHeader(r: IrReader): void {
|
||||
for (const byte of MAGIC) if (r.byte() !== byte) throw new Error("IR codec: not an IR encoding");
|
||||
const layout = r.str();
|
||||
if (layout !== IR_CODEC_LAYOUT)
|
||||
throw new Error(
|
||||
`IR codec: the encoding has layout ${layout}, this compiler reads ${IR_CODEC_LAYOUT}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether `bytes` start like an encoding this module writes. */
|
||||
export function isIrEncoding(bytes: Uint8Array): boolean {
|
||||
return MAGIC.every((byte, index) => bytes[index] === byte);
|
||||
}
|
||||
|
||||
export function encodeModuleBinary(mod: IrModule): Buffer {
|
||||
const w = new IrWriter();
|
||||
header(w);
|
||||
encodeIrModule(w, mod);
|
||||
return w.finish();
|
||||
}
|
||||
|
||||
export function decodeModuleBinary(bytes: Buffer): IrModule {
|
||||
const r = new IrReader(bytes);
|
||||
readHeader(r);
|
||||
const mod = decodeIrModule(r);
|
||||
if (!r.done()) throw new Error("IR codec: bytes after the module");
|
||||
return mod;
|
||||
}
|
||||
|
||||
export function encodeFunctionBinary(fn: IrFunction): Buffer {
|
||||
const w = new IrWriter();
|
||||
header(w);
|
||||
encodeIrFunction(w, fn);
|
||||
return w.finish();
|
||||
}
|
||||
|
||||
/** The writer encodeFunctionInOrder reuses: its result is a copy. */
|
||||
const inOrderWriter = new IrWriter();
|
||||
|
||||
/** `fn`'s encoding when it reads back as `fn` is, every object with its
|
||||
* own key order (IrWriter.inOrder), or null. */
|
||||
export function encodeFunctionInOrder(fn: IrFunction): Buffer | null {
|
||||
return encodeFunctionStrings(fn)?.bytes ?? null;
|
||||
}
|
||||
|
||||
/** encodeFunctionInOrder's encoding of `fn`, with the strings it holds,
|
||||
* each once, and the files its fields named `file` hold (IrWriter.file), or
|
||||
* null. */
|
||||
export function encodeFunctionStrings(fn: IrFunction): {
|
||||
readonly bytes: Buffer;
|
||||
readonly strings: readonly string[];
|
||||
readonly files: readonly string[];
|
||||
} | null {
|
||||
const w = inOrderWriter;
|
||||
w.reset();
|
||||
header(w);
|
||||
encodeIrFunction(w, fn);
|
||||
// An encoding that refused a value is not in order either.
|
||||
return w.inOrder
|
||||
? { bytes: Buffer.from(w.finish()), strings: [...w.written()], files: [...w.files] }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A 64-bit digest of `fn`'s content, the same for functions whose fields
|
||||
* hold the same values in any key order, with the byte count of the
|
||||
* content it covers. It covers the binary encoding, or the JSON text when
|
||||
* the encoder refuses a value (IrWriter.refuse). */
|
||||
export function functionDigest(fn: IrFunction): string {
|
||||
const w = inOrderWriter;
|
||||
w.reset();
|
||||
encodeIrFunction(w, fn);
|
||||
if (w.refused === null) return bytesDigest(w.finish());
|
||||
return `json:${bytesDigest(Buffer.from(JSON.stringify(fn), "utf8"))}`;
|
||||
}
|
||||
|
||||
/** Two 32-bit multiply-xor lanes over `bytes`, FNV-1a's and one with
|
||||
* another offset and MurmurHash2's multiplier, and the byte count. */
|
||||
function bytesDigest(bytes: Uint8Array): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x050c5d1f;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const byte = bytes[i]!;
|
||||
a = Math.imul(a ^ byte, 0x01000193);
|
||||
b = Math.imul(b ^ byte, 0x5bd1e995);
|
||||
}
|
||||
const hex = (lane: number): string => (lane >>> 0).toString(16).padStart(8, "0");
|
||||
return `${bytes.length}:${hex(a)}${hex(b)}`;
|
||||
}
|
||||
|
||||
export function decodeFunctionBinary(bytes: Buffer): IrFunction {
|
||||
const r = new IrReader(bytes);
|
||||
readHeader(r);
|
||||
const fn = decodeIrFunction(r);
|
||||
if (!r.done()) throw new Error("IR codec: bytes after the function");
|
||||
return fn;
|
||||
}
|
||||
@@ -802,14 +802,14 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
|
||||
case "classRef": {
|
||||
walker.string(node.kind);
|
||||
walker.string(node.className);
|
||||
if (!skipType) {
|
||||
walkTypeFields(node.type, walker, skipType);
|
||||
}
|
||||
if (node.captures !== undefined && node.captures !== null) {
|
||||
for (const item6 of node.captures) {
|
||||
walker.string(item6);
|
||||
}
|
||||
}
|
||||
if (!skipType) {
|
||||
walkTypeFields(node.type, walker, skipType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "closure": {
|
||||
@@ -890,12 +890,12 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
|
||||
case "dynFrom": {
|
||||
walker.string(node.kind);
|
||||
walkExprFields(node.value, walker, skipType);
|
||||
if (node.fnName !== undefined && node.fnName !== null) {
|
||||
walker.string(node.fnName);
|
||||
}
|
||||
if (!skipType) {
|
||||
walkTypeFields(node.type, walker, skipType);
|
||||
}
|
||||
if (node.fnName !== undefined && node.fnName !== null) {
|
||||
walker.string(node.fnName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "dynHasKey": {
|
||||
@@ -1635,14 +1635,14 @@ export function walkStmtFields(node: IrStmt, walker: IrFieldWalker, skipType: bo
|
||||
}
|
||||
case "block": {
|
||||
walker.string(node.kind);
|
||||
for (const item30 of node.body) {
|
||||
walkStmtFields(item30, walker, skipType);
|
||||
}
|
||||
if (node.labels !== undefined && node.labels !== null) {
|
||||
for (const item31 of node.labels) {
|
||||
walker.string(item31);
|
||||
for (const item30 of node.labels) {
|
||||
walker.string(item30);
|
||||
}
|
||||
}
|
||||
for (const item31 of node.body) {
|
||||
walkStmtFields(item31, walker, skipType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "break": {
|
||||
@@ -2111,7 +2111,7 @@ export type IrFieldsCurrent = [
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "caughtTest" }>["negated"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "caughtToDyn" }>, "kind" | "value" | "type" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "chainRecv" }>, "kind" | "id" | "type" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "captures" | "type" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "type" | "loc" | "captures">>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "closure" }>, "kind" | "fnName" | "captures" | "adapts" | "type" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "closure" }>["adapts"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "downcast" }>, "kind" | "value" | "type" | "loc">>,
|
||||
@@ -2125,9 +2125,9 @@ export type IrFieldsCurrent = [
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["exact"]>>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["origin"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynDestrCheck" }>, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "type" | "loc" | "liveRef" | "fnName" | "fnLength">>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["liveRef"]>>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynHasKey" }>, "kind" | "key" | "negated" | "value" | "type" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynHasKey" }>["negated"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynInvoke" }>, "kind" | "recv" | "method" | "calleeName" | "calleeNameValue" | "args" | "type" | "loc">>,
|
||||
@@ -2240,7 +2240,7 @@ export type IrFieldsCurrent = [
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "arraySetUndefined" }>, "kind" | "arr" | "index" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "assign" }>, "kind" | "localId" | "value" | "initializes" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrStmt, { kind: "assign" }>["initializes"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "body" | "labels" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "labels" | "body" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "break" }>, "kind" | "label" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "bytesSet" }>, "kind" | "arr" | "index" | "value" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "continue" }>, "kind" | "label" | "loc">>,
|
||||
@@ -2264,9 +2264,9 @@ export type IrFieldsCurrent = [
|
||||
Unread<FieldsNotRead<Extract<NonNullable<Extract<NonNullable<Extract<IrStmt, { kind: "switch" }>["cases"]>, readonly unknown[]>[number]>, object>, "test" | "body">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "switch" }>, "kind" | "disc" | "cases" | "labels" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "throw" }>, "kind" | "value" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "finallyBody" | "suppressFinallyErrors" | "dropFinallyErrors" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "dropFinallyErrors" | "finallyBody" | "suppressFinallyErrors" | "loc">>,
|
||||
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["dropFinallyErrors"]>>,
|
||||
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "varDecl" }>, "kind" | "localId" | "init" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrStmt, { kind: "while" }>, "kind" | "cond" | "body" | "labels" | "loc">>,
|
||||
Unread<FieldsNotRead<Extract<IrType, { kind: "array" }>, "kind" | "elem" | "optionalElements">>,
|
||||
|
||||
@@ -1964,14 +1964,10 @@ export interface IrFunction {
|
||||
/** Original TS name (mangling is a backend concern). Lifted lambdas get
|
||||
* synthetic '%'-prefixed names ('%' can't appear in a TS identifier). */
|
||||
name: string;
|
||||
/** Generated reflection dispatch contains speculative source-member edges.
|
||||
* Target-only host operations behind those edges retain runtime refusals. */
|
||||
speculativeDispatch?: boolean;
|
||||
/** A read helper the backend can answer directly for dyn arrays and live
|
||||
* array capsules, calling the helper only when that read has no answer.
|
||||
* `index`: a computed read `(value, key)` whose callers pass a number
|
||||
* converted to a string as the key. */
|
||||
fastRead?: "index";
|
||||
/** Source function name for native error stack frames. */
|
||||
sourceName?: string;
|
||||
/** Ordinary authored function declarations/expressions own a prototype. */
|
||||
ownsPrototype?: true;
|
||||
params: IrParam[];
|
||||
returnType: IrType;
|
||||
/** All locals including params, pre-collected and scope-flat: ids are
|
||||
@@ -1986,22 +1982,29 @@ export interface IrFunction {
|
||||
* their first (`this`) parameter. The slot indexes the class object's
|
||||
* capture array; no closure parameter is added to the method ABI. */
|
||||
classCaptures?: (IrParam & { slot: number })[];
|
||||
body: IrStmt[];
|
||||
loc: SrcLoc;
|
||||
/** The function's execution tier (ir/tiers.ts). Absent means static: the
|
||||
* function may use dynamic values only through boundary operations
|
||||
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
|
||||
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
|
||||
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
|
||||
* from the body (assignLegacyTiers); stage S2 of the two-tier design
|
||||
* keeps it out of serialized IR, so loaders derive it again. */
|
||||
tier?: "dynamic";
|
||||
/** Generated reflection dispatch contains speculative source-member edges.
|
||||
* Target-only host operations behind those edges retain runtime refusals. */
|
||||
speculativeDispatch?: boolean;
|
||||
/** A read helper the backend can answer directly for dyn arrays and live
|
||||
* array capsules, calling the helper only when that read has no answer.
|
||||
* `index`: a computed read `(value, key)` whose callers pass a number
|
||||
* converted to a string as the key. */
|
||||
fastRead?: "index";
|
||||
/** Async: the body runs on a fiber; `returnType` is the INNER type T (a
|
||||
* `return v` fulfills with v) while ordinary call sites receive
|
||||
* Promise<T>. With `generator` present, call sites receive the lazy
|
||||
* async-generator object instead. */
|
||||
async?: true;
|
||||
/** Async module initializers only: a module-global Promise<T> slot where
|
||||
* the spawn wrapper caches its first evaluation promise. Every later
|
||||
* static/dynamic import receives that same promise, including while the
|
||||
* first evaluation is suspended. */
|
||||
asyncCacheGlobal?: string;
|
||||
/** Async cyclic module initializers only: the SCC's shared Promise<T>
|
||||
* slot. Eager recursive spawning writes member promises from the inside
|
||||
* out, so the member that actually initiated evaluation writes last and
|
||||
* becomes the runtime cycle root. Dynamic imports wait on this shared
|
||||
* completion verdict instead of a build-time-selected member. */
|
||||
asyncCycleCacheGlobal?: string;
|
||||
/** Generator (`function*` / `async function*`): the body runs on a fiber created SUSPENDED
|
||||
* (nothing runs until the first `.next()`; backends may instead compile a
|
||||
* synchronous body as a stackless coroutine with the same protocol); `returnType` is the
|
||||
@@ -2030,20 +2033,17 @@ export interface IrFunction {
|
||||
* that generator's tag. */
|
||||
inheritsTag?: true;
|
||||
};
|
||||
/** Source function name for native error stack frames. */
|
||||
sourceName?: string;
|
||||
/** Ordinary authored function declarations/expressions own a prototype. */
|
||||
ownsPrototype?: true;
|
||||
/** The function's execution tier (ir/tiers.ts). Absent means static: the
|
||||
* function may use dynamic values only through boundary operations
|
||||
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
|
||||
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
|
||||
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
|
||||
* from the body (assignLegacyTiers); stage S2 of the two-tier design
|
||||
* keeps it out of serialized IR, so loaders derive it again. */
|
||||
tier?: "dynamic";
|
||||
body: IrStmt[];
|
||||
loc: SrcLoc;
|
||||
/** Async module initializers only: a module-global Promise<T> slot where
|
||||
* the spawn wrapper caches its first evaluation promise. Every later
|
||||
* static/dynamic import receives that same promise, including while the
|
||||
* first evaluation is suspended. */
|
||||
asyncCacheGlobal?: string;
|
||||
/** Async cyclic module initializers only: the SCC's shared Promise<T>
|
||||
* slot. Eager recursive spawning writes member promises from the inside
|
||||
* out, so the member that actually initiated evaluation writes last and
|
||||
* becomes the runtime cycle root. Dynamic imports wait on this shared
|
||||
* completion verdict instead of a build-time-selected member. */
|
||||
asyncCycleCacheGlobal?: string;
|
||||
}
|
||||
|
||||
export interface IrParam {
|
||||
@@ -2280,7 +2280,7 @@ export type IrStmt =
|
||||
/** A bare lexical block `{ ... }` — its own scope. `labels` makes it a
|
||||
* labeled-break target (`lbl: { ... break lbl; ... }` and the wrapper
|
||||
* the frontend puts around labeled non-loop statements). */
|
||||
| { kind: "block"; body: IrStmt[]; labels?: string[]; loc: SrcLoc }
|
||||
| { kind: "block"; labels?: string[]; body: IrStmt[]; loc: SrcLoc }
|
||||
/** `throw v`. Any non-void value type can be thrown; ownership of a
|
||||
* refcounted value MOVES into the runtime's exception cell. Terminates
|
||||
* the path like `return`: control unwinds to the innermost enclosing
|
||||
@@ -2324,15 +2324,15 @@ export type IrStmt =
|
||||
tryBody: IrStmt[];
|
||||
catchBody: IrStmt[] | null;
|
||||
catchLocalId: string | null;
|
||||
finallyBody: IrStmt[] | null;
|
||||
/** Resource-management cleanup: if the guarded body and cleanup both
|
||||
* throw, raise a SuppressedError instead of replacing the first error. */
|
||||
suppressFinallyErrors?: true;
|
||||
/** Iterator closing: if the guarded body threw and the cleanup throws
|
||||
* too, the cleanup's error is dropped and the first one propagates.
|
||||
* After a generator .return() the cleanup's error replaces it, and on
|
||||
* normal exits it propagates. */
|
||||
dropFinallyErrors?: true;
|
||||
finallyBody: IrStmt[] | null;
|
||||
/** Resource-management cleanup: if the guarded body and cleanup both
|
||||
* throw, raise a SuppressedError instead of replacing the first error. */
|
||||
suppressFinallyErrors?: true;
|
||||
loc: SrcLoc;
|
||||
};
|
||||
|
||||
@@ -3341,7 +3341,7 @@ export type IrExpr =
|
||||
* every classRef, so a value's construct thunk always has a constructor
|
||||
* to call; backends emit class objects (and thunks) for exactly the
|
||||
* classes some classRef in the module names. */
|
||||
| { kind: "classRef"; className: string; captures?: string[]; type: IrType; loc: SrcLoc }
|
||||
| { kind: "classRef"; className: string; type: IrType; loc: SrcLoc; captures?: string[] }
|
||||
/** `new X(args)` through a class VALUE: call the class object's
|
||||
* construct thunk. `callee` is classval-typed; args are completed
|
||||
* against `%<callee.className>.constructor`'s ABI — sound because every
|
||||
@@ -3513,13 +3513,13 @@ export type IrExpr =
|
||||
| {
|
||||
kind: "dynFrom";
|
||||
value: IrExpr;
|
||||
type: IrType;
|
||||
loc: SrcLoc;
|
||||
liveRef?: true;
|
||||
fnName?: string;
|
||||
/** A boxed function's `length` when it differs from its parameter
|
||||
* count (builtin function values). */
|
||||
fnLength?: number;
|
||||
liveRef?: true;
|
||||
type: IrType;
|
||||
loc: SrcLoc;
|
||||
}
|
||||
/** CALLING a dyn value — `fn(a, b)` where fn is checked-dynamic (an
|
||||
* implicit-any JS binding, a dyn record member, a dynKeyGet result).
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* text parts and decoded from bytes one function at a time; neither direction
|
||||
* materializes the complete document as a single string. */
|
||||
import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
|
||||
import { decodeModuleBinary, isIrEncoding } from "./codec.js";
|
||||
|
||||
// Version 17 adds the dynamic tier's jsval type and js.* builtins (two-tier
|
||||
// design stage S3) and serializes each function's execution tier.
|
||||
@@ -268,10 +269,12 @@ function functionSpans(bytes: Buffer): FunctionSpans | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a UTF-8 IR artifact (compact or readable) without materializing
|
||||
* it as one string: the header and each function are parsed separately.
|
||||
/** Decode an IR artifact: the binary encoding (codec.ts), or UTF-8 JSON
|
||||
* (compact or readable) without materializing it as one string: the header
|
||||
* and each function are parsed separately.
|
||||
* Split points are ASCII structural bytes, so every range is valid UTF-8. */
|
||||
export function deserializeModuleBytes(bytes: Buffer): IrModule {
|
||||
if (isIrEncoding(bytes)) return decodeModuleBinary(bytes);
|
||||
const spans = functionSpans(bytes);
|
||||
if (spans === null) return deserializeModule(bytes.toString("utf8"));
|
||||
const mod = checkedVersion(
|
||||
|
||||
@@ -174,6 +174,8 @@ export class NativeCache {
|
||||
"binary",
|
||||
"dsym",
|
||||
"coverage",
|
||||
"units",
|
||||
"unit-files",
|
||||
]) {
|
||||
const directory = join(this.root, family);
|
||||
let names: string[];
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { compilerOwnedFileRoots } from "../frontend/dts-paths.js";
|
||||
import { provenanceSources } from "../frontend/provenance-registry.js";
|
||||
import { processFork } from "../frontend/process-fork.js";
|
||||
import { unitReuseEnabled, type UnitStore } from "../frontend/lowering/unit-reuse.js";
|
||||
import {
|
||||
tokenEquivalentSource,
|
||||
tokenPositionEquivalentSource,
|
||||
@@ -196,6 +197,21 @@ function equivalentFrontendInput(
|
||||
};
|
||||
}
|
||||
|
||||
/** The unit store of a build whose unit key is `key`: its program entry
|
||||
* under that key, and file entries by content digest. */
|
||||
function nativeUnitStore(cache: NativeCache, key: string): UnitStore {
|
||||
return {
|
||||
readProgram: () => cache.read("units", key),
|
||||
writeProgram: (bytes) => cache.write("units", key, bytes),
|
||||
readFile: (name) => (/^[0-9a-f]{64}$/.test(name) ? cache.read("unit-files", name) : null),
|
||||
writeFile: (bytes) => {
|
||||
const digest = contentDigest(bytes);
|
||||
if (!cache.has("unit-files", digest)) cache.write("unit-files", digest, bytes, digest);
|
||||
return digest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function implementationIdentity(toolchain: NativeToolchain): string {
|
||||
// Release versions alone cannot distinguish two development builds.
|
||||
return contentDigest(
|
||||
@@ -221,6 +237,9 @@ const FRONTEND_INDEPENDENT_VARIABLES = new Set([
|
||||
"SCRIPTC_CHECKER_SERVER",
|
||||
"SCRIPTC_EMIT_WORKERS",
|
||||
"SCRIPTC_EMIT_WORKER_FUNCTIONS",
|
||||
// Reusing lowered units leaves the lowered module as it is.
|
||||
"SCRIPTC_INCR_LOWERING",
|
||||
"SCRIPTC_INCR_VERIFY",
|
||||
"SCRIPTC_TIMING",
|
||||
]);
|
||||
|
||||
@@ -372,22 +391,28 @@ export function prepareNativeExecutable(
|
||||
const exclusions = { outputPaths, outputDirectories: [...directories] };
|
||||
const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? "";
|
||||
let key: string | null = null;
|
||||
// The unit store's key (unit-reuse.ts): the frontend's inputs without the
|
||||
// output paths, which lowering does not read.
|
||||
let unitKey: string | null = null;
|
||||
// The entry this build replaces: its probes give the digests of unchanged files.
|
||||
let previous: FrontendInputSnapshot | undefined;
|
||||
if (cache !== null && irInput === "" && provenanceSources() === null) {
|
||||
try {
|
||||
key = contentDigest(
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
entry,
|
||||
options,
|
||||
ffi,
|
||||
implementation: implementationIdentity(toolchain),
|
||||
target: toolchain.target.name,
|
||||
declarations: toolchain.declarationsRoot ?? null,
|
||||
environment: frontendEnvironment(),
|
||||
}),
|
||||
);
|
||||
const inputs = {
|
||||
schema: 1,
|
||||
entry,
|
||||
options,
|
||||
ffi,
|
||||
implementation: implementationIdentity(toolchain),
|
||||
target: toolchain.target.name,
|
||||
declarations: toolchain.declarationsRoot ?? null,
|
||||
environment: frontendEnvironment(),
|
||||
};
|
||||
key = contentDigest(JSON.stringify(inputs));
|
||||
if (unitReuseEnabled()) {
|
||||
const { outPath: _outPath, outDir: _outDir, ...lowering } = options;
|
||||
unitKey = contentDigest(JSON.stringify({ ...inputs, options: lowering }));
|
||||
}
|
||||
const cached = readFrontendCache(cache, key, exclusions);
|
||||
if (cached !== null && "input" in cached) {
|
||||
timing("frontend-cache-hit");
|
||||
@@ -413,7 +438,17 @@ export function prepareNativeExecutable(
|
||||
irInput !== ""
|
||||
? preparedFromIr(irInput, entry, options)
|
||||
: tracker.runSynchronous(() =>
|
||||
prepareExecutableModule(entry, options, ffi, toolchain.target.platform, frontend, timing),
|
||||
prepareExecutableModule(
|
||||
entry,
|
||||
options,
|
||||
ffi,
|
||||
toolchain.target.platform,
|
||||
frontend,
|
||||
timing,
|
||||
new Map(),
|
||||
true,
|
||||
cache !== null && unitKey !== null ? nativeUnitStore(cache, unitKey) : null,
|
||||
),
|
||||
);
|
||||
if (!prepared.ok) return prepared;
|
||||
if (irInput !== "") timing("ir-input");
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { compile } from "../src/index.js";
|
||||
|
||||
const VARIABLES = [
|
||||
"SCRIPTC_CACHE_DIR",
|
||||
"SCRIPTC_INCR_LOWERING",
|
||||
"SCRIPTC_INCR_VERIFY",
|
||||
"SCRIPTC_LOWER_JOURNAL_DUMP",
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of VARIABLES) delete process.env[name];
|
||||
});
|
||||
|
||||
/** A program in a scratch directory, built with unit reuse into its own
|
||||
* cache and without it. */
|
||||
class Workspace {
|
||||
private builds = 0;
|
||||
constructor(readonly dir: string) {}
|
||||
|
||||
static async create(files: Record<string, string>): Promise<Workspace> {
|
||||
const workspace = new Workspace(await mkdtemp(join(tmpdir(), "scriptc-unit-reuse-")));
|
||||
for (const [name, text] of Object.entries(files)) await workspace.write(name, text);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
write(name: string, text: string): Promise<void> {
|
||||
return writeFile(join(this.dir, name), text);
|
||||
}
|
||||
|
||||
async edit(name: string, from: string, to: string): Promise<void> {
|
||||
const text = await readFile(join(this.dir, name), "utf8");
|
||||
expect(text, `${name} spells ${from}`).toContain(from);
|
||||
await this.write(name, text.replace(from, to));
|
||||
}
|
||||
|
||||
private async lower(label: string, reuse: boolean, verify = false): Promise<string> {
|
||||
for (const name of VARIABLES) delete process.env[name];
|
||||
if (reuse) {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = join(this.dir, "cache");
|
||||
process.env["SCRIPTC_INCR_LOWERING"] = "1";
|
||||
process.env["SCRIPTC_LOWER_JOURNAL_DUMP"] = join(this.dir, label);
|
||||
if (verify) process.env["SCRIPTC_INCR_VERIFY"] = "1";
|
||||
}
|
||||
const outPath = join(this.dir, `${label}.ir.json`);
|
||||
const result = await compile(join(this.dir, "main.ts"), {
|
||||
outDir: join(this.dir, "out"),
|
||||
outPath,
|
||||
outputKind: "ir",
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
return readFile(outPath, "utf8");
|
||||
}
|
||||
|
||||
/** The units with a record in the last build, recorded or applied. */
|
||||
units: string[] = [];
|
||||
|
||||
/** Builds with reuse, checks the IR against a build without it, and
|
||||
* returns the units whose stored records applied. A build in verify mode
|
||||
* follows and must give the same IR. */
|
||||
async build(): Promise<string[]> {
|
||||
const label = `build-${this.builds++}`;
|
||||
const reused = await this.lower(label, true);
|
||||
expect(reused).toBe(await this.lower(`${label}-plain`, false));
|
||||
const applied: string[] = [];
|
||||
this.units = [];
|
||||
for (const line of (await readFile(join(this.dir, label, "records.jsonl"), "utf8"))
|
||||
.trimEnd()
|
||||
.split("\n")) {
|
||||
// A unit without a record has a line with its reasons.
|
||||
const record = JSON.parse(line) as { name: string; applied?: boolean; reasons?: string[] };
|
||||
if (record.reasons !== undefined) continue;
|
||||
this.units.push(record.name);
|
||||
if (record.applied === true) applied.push(record.name);
|
||||
}
|
||||
this.units.sort();
|
||||
expect(await this.lower(`${label}-verify`, true, true)).toBe(reused);
|
||||
return applied.sort();
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return rm(this.dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const MODULES = {
|
||||
"shapes.ts": [
|
||||
"export function area(width: number, height: number): number {",
|
||||
" return width * height;",
|
||||
"}",
|
||||
"export function label(name: string): string {",
|
||||
" return `shape ${name}`;",
|
||||
"}",
|
||||
"export class Box {",
|
||||
" constructor(readonly width: number) {}",
|
||||
" scaled(by: number): number {",
|
||||
" return this.width * by;",
|
||||
" }",
|
||||
" named(): string {",
|
||||
" return label(`box ${this.width}`);",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"report.ts": [
|
||||
'import { Box, area } from "./shapes.js";',
|
||||
"export function report(sizes: number[]): number {",
|
||||
" let total = 0;",
|
||||
" for (const size of sizes) total += area(size, size);",
|
||||
" return total;",
|
||||
"}",
|
||||
"export function describe(box: Box): string {",
|
||||
" return `${box.named()} ${box.scaled(2)}`;",
|
||||
"}",
|
||||
"export function twice(value: number): number {",
|
||||
" return value * 2;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"format.ts": [
|
||||
"export function pad(text: string, width: number): string {",
|
||||
" return text.padStart(width);",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"main.ts": [
|
||||
'import { Box, label } from "./shapes.js";',
|
||||
'import { describe, report, twice } from "./report.js";',
|
||||
'import { pad } from "./format.js";',
|
||||
'console.log(report([1, 2, 3]), describe(new Box(4)), label("main"), twice(3), pad("x", 4));',
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
/** Whether `applied` holds a unit named `name`. */
|
||||
const has = (applied: readonly string[], name: string): boolean =>
|
||||
applied.some((unit) => unit.endsWith(`.${name}`) || unit === name);
|
||||
|
||||
test("an unchanged rebuild applies every unit's stored record", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
// The first build into the cache records nothing; build()'s verify build
|
||||
// after it finds the tables and records.
|
||||
expect(await workspace.build()).toEqual([]);
|
||||
const units = workspace.units;
|
||||
expect(units.length).toBeGreaterThan(4);
|
||||
expect(await workspace.build()).toEqual(units);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("a body edit lowers the units of its file and the units that spell its name", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
await workspace.build();
|
||||
await workspace.edit("shapes.ts", "return width * height;", "return width * height + 1;");
|
||||
const applied = await workspace.build();
|
||||
// report() spells area(); twice() and pad() spell nothing whose
|
||||
// declaration changed. The units of shapes.ts lower again.
|
||||
expect(has(applied, "report")).toBe(false);
|
||||
expect(has(applied, "twice")).toBe(true);
|
||||
expect(has(applied, "pad")).toBe(true);
|
||||
expect(has(applied, "scaled")).toBe(false);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("a signature edit lowers the units whose text spells a name it reaches", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
await workspace.build();
|
||||
await workspace.edit(
|
||||
"shapes.ts",
|
||||
" named(): string {",
|
||||
' named(prefix = ""): string {\n if (prefix !== "") return prefix;',
|
||||
);
|
||||
const applied = await workspace.build();
|
||||
// The member's surface changed, and with it the type of Box: the units
|
||||
// that spell named or Box lower again, the others apply.
|
||||
expect(has(applied, "named")).toBe(false);
|
||||
expect(has(applied, "report")).toBe(true);
|
||||
expect(has(applied, "twice")).toBe(true);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("an edit above every declaration of a file keeps the other files' units", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
await workspace.build();
|
||||
await workspace.edit(
|
||||
"shapes.ts",
|
||||
"export function area",
|
||||
"// A comment.\nexport function area",
|
||||
);
|
||||
const applied = await workspace.build();
|
||||
expect(has(applied, "report")).toBe(true);
|
||||
expect(has(applied, "twice")).toBe(true);
|
||||
expect(has(applied, "scaled")).toBe(false);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("an added declaration keeps the units that do not spell its name", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
await workspace.build();
|
||||
await workspace.edit(
|
||||
"report.ts",
|
||||
"export function describe",
|
||||
"export function unused(): number {\n return 1;\n}\nexport function describe",
|
||||
);
|
||||
const applied = await workspace.build();
|
||||
expect(has(applied, "scaled")).toBe(true);
|
||||
expect(has(applied, "label")).toBe(true);
|
||||
expect(has(applied, "pad")).toBe(true);
|
||||
expect(has(applied, "twice")).toBe(false);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("units that use a module's namespace object lower again when its exports change", async () => {
|
||||
const workspace = await Workspace.create({
|
||||
"values.ts": "export let count = 1;\nexport function bump(): void {\n count += 1;\n}\n",
|
||||
"main.ts": [
|
||||
'import * as values from "./values.js";',
|
||||
"function keys(): string {",
|
||||
' return Object.keys(values).join(",");',
|
||||
"}",
|
||||
"function current(): number {",
|
||||
" return values.count;",
|
||||
"}",
|
||||
"values.bump();",
|
||||
"console.log(keys(), current());",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await workspace.build();
|
||||
expect(await workspace.build()).toEqual(workspace.units);
|
||||
await workspace.write(
|
||||
"values.ts",
|
||||
"export let count = 1;\nexport function bump(): void {\n count += 1;\n}\nexport const added = 2;\n",
|
||||
);
|
||||
// Both units spell the namespace binding, whose object gained a key.
|
||||
const applied = await workspace.build();
|
||||
expect(has(applied, "keys")).toBe(false);
|
||||
expect(has(applied, "current")).toBe(false);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("a unit whose record holds another file's positions lowers again when that file changes", async () => {
|
||||
const workspace = await Workspace.create({
|
||||
"lib.ts":
|
||||
'export function add(a: number, b: number): number {\n return a + b;\n}\nexport const label = "lib";\n',
|
||||
"main.ts": [
|
||||
'import * as lib from "./lib.js";',
|
||||
"function show(): string {",
|
||||
" const { add: plus, label: name } = lib;",
|
||||
" return `${name} ${plus(1, 2)}`;",
|
||||
"}",
|
||||
"function other(): number {",
|
||||
" return 42;",
|
||||
"}",
|
||||
"console.log(show(), other());",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await workspace.build();
|
||||
await workspace.edit("lib.ts", "export function add", "// A comment.\nexport function add");
|
||||
// The build compares its IR, positions included, with a clean build's.
|
||||
const applied = await workspace.build();
|
||||
expect(has(applied, "other")).toBe(true);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("the first build into a cache writes the declaration tables and no unit records", async () => {
|
||||
const workspace = await Workspace.create(MODULES);
|
||||
try {
|
||||
const cache = join(workspace.dir, "cache");
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cache;
|
||||
process.env["SCRIPTC_INCR_LOWERING"] = "1";
|
||||
const build = async (): Promise<void> => {
|
||||
const result = await compile(join(workspace.dir, "main.ts"), {
|
||||
outDir: join(workspace.dir, "out"),
|
||||
outPath: join(workspace.dir, "main.ir.json"),
|
||||
outputKind: "ir",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
};
|
||||
const entries = async (family: string): Promise<string[]> =>
|
||||
readdir(join(cache, "units-v1", family)).catch(() => []);
|
||||
await build();
|
||||
expect(await entries("program")).toHaveLength(1);
|
||||
expect(await entries("files")).toEqual([]);
|
||||
await build();
|
||||
expect((await entries("files")).length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await workspace.dispose();
|
||||
}
|
||||
}, 300_000);
|
||||
@@ -0,0 +1,440 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Generates packages/compiler/src/ir/codec.generated.ts from the types in
|
||||
// ir.ts: for every object type an IrModule can reach, an encoder that
|
||||
// writes its fields by name in declaration order and a decoder that reads
|
||||
// them back into an object literal of that type. Unions that `kind`
|
||||
// discriminates are written as the member's index; other unions as the
|
||||
// index of the arm a runtime test picks. The byte format is in codec.ts.
|
||||
// `--check` fails when the file is stale.
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const require = createRequire(join(root, "package.json"));
|
||||
const ts = require("typescript");
|
||||
const irPath = join(root, "packages/compiler/src/ir/ir.ts");
|
||||
const target = join(root, "packages/compiler/src/ir/codec.generated.ts");
|
||||
|
||||
const program = ts.createProgram([irPath], { strict: true, target: ts.ScriptTarget.ES2022 });
|
||||
const checker = program.getTypeChecker();
|
||||
const source = program.getSourceFile(irPath);
|
||||
|
||||
/** Exported type names of ir.ts, by the type they declare. */
|
||||
const exported = new Map();
|
||||
ts.forEachChild(source, (node) => {
|
||||
if (
|
||||
(ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) &&
|
||||
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
||||
node.typeParameters === undefined
|
||||
) {
|
||||
const type = checker.getTypeAtLocation(node.name);
|
||||
if (!exported.has(type)) exported.set(type, node.name.text);
|
||||
}
|
||||
});
|
||||
/** Exported names by the member set of the union or the single type they
|
||||
* declare: a field of type `IrExpr | undefined` codes its IrExpr part with
|
||||
* IrExpr's coder. */
|
||||
const exportedBy = new Map();
|
||||
for (const [type, name] of exported) {
|
||||
const parts = type.isUnion() ? type.types : [type];
|
||||
const key = parts
|
||||
.map((p) => p.id)
|
||||
.sort((a, b) => a - b)
|
||||
.join("|");
|
||||
if (!exportedBy.has(key)) exportedBy.set(key, name);
|
||||
}
|
||||
const partsKey = (parts) =>
|
||||
parts
|
||||
.map((p) => p.id)
|
||||
.sort((a, b) => a - b)
|
||||
.join("|");
|
||||
const used = new Set(["IrModule", "IrFunction"]);
|
||||
const moduleType = [...exported].find(([, name]) => name === "IrModule")[0];
|
||||
const functionType = [...exported].find(([, name]) => name === "IrFunction")[0];
|
||||
|
||||
const F = ts.TypeFlags;
|
||||
const NULLISH = F.Null | F.Undefined | F.Void;
|
||||
const STRING = F.StringLike;
|
||||
const NUMBER = F.NumberLike;
|
||||
const BOOLEAN = F.BooleanLike;
|
||||
|
||||
/** An intersection no value has: `IrType & { kind: "record" }` distributes
|
||||
* over IrType's members, and all but one give `kind` two literal types. */
|
||||
function impossible(type) {
|
||||
return type.isIntersection() && checker.typeToString(type) === "never";
|
||||
}
|
||||
|
||||
/** The parts of `type` that are not null or undefined, and which of the two
|
||||
* it allows. */
|
||||
function split(type) {
|
||||
const parts = type.isUnion() ? type.types : [type];
|
||||
return {
|
||||
present: parts.filter((part) => (part.flags & (NULLISH | F.Never)) === 0 && !impossible(part)),
|
||||
nullable: parts.some((part) => (part.flags & F.Null) !== 0),
|
||||
undefinable: parts.some((part) => (part.flags & (F.Undefined | F.Void)) !== 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** The literal value of `kind` on an object type, or null. */
|
||||
function kindOf(type) {
|
||||
const kind = checker.getPropertyOfType(type, "kind");
|
||||
if (kind === undefined) return null;
|
||||
const kindType = checker.getTypeOfSymbol(kind);
|
||||
return kindType.isStringLiteral() ? kindType.value : null;
|
||||
}
|
||||
|
||||
/** How a value of the non-nullish `parts` is coded. */
|
||||
function shapeOf(parts, where) {
|
||||
for (const part of parts)
|
||||
if ((part.flags & (F.Any | F.Unknown)) !== 0) throw new Error(`untyped value at ${where}`);
|
||||
const objects = parts.filter((part) => (part.flags & (STRING | NUMBER | BOOLEAN)) === 0);
|
||||
const strings = parts.filter((part) => (part.flags & STRING) !== 0);
|
||||
const numbers = parts.filter((part) => (part.flags & NUMBER) !== 0);
|
||||
const booleans = parts.filter((part) => (part.flags & BOOLEAN) !== 0);
|
||||
if (objects.length === 0) {
|
||||
const scalarKinds = [strings, numbers, booleans].filter((list) => list.length > 0).length;
|
||||
if (scalarKinds === 1)
|
||||
return strings.length > 0
|
||||
? { code: "string" }
|
||||
: numbers.length > 0
|
||||
? { code: "number" }
|
||||
: { code: "bool" };
|
||||
}
|
||||
if (objects.length === 1 && strings.length + numbers.length + booleans.length === 0)
|
||||
return checker.isArrayType(objects[0])
|
||||
? { code: "array", item: objects[0] }
|
||||
: { code: "object", type: objects[0] };
|
||||
if (
|
||||
objects.length > 1 &&
|
||||
strings.length + numbers.length + booleans.length === 0 &&
|
||||
objects.every((part) => kindOf(part) !== null && !checker.isArrayType(part))
|
||||
)
|
||||
return { code: "kinds", members: objects };
|
||||
// Arms told apart at run time: typeof for scalars, Array.isArray, a
|
||||
// `kind` literal, or a property only one object arm declares.
|
||||
const arms = [];
|
||||
if (strings.length > 0) arms.push({ test: "string", parts: strings });
|
||||
if (numbers.length > 0) arms.push({ test: "number", parts: numbers });
|
||||
if (booleans.length > 0) arms.push({ test: "boolean", parts: booleans });
|
||||
const arrays = objects.filter((part) => checker.isArrayType(part));
|
||||
if (arrays.length > 1) throw new Error(`several array types at ${where}`);
|
||||
if (arrays.length === 1) arms.push({ test: "array", parts: arrays });
|
||||
const records = objects.filter((part) => !checker.isArrayType(part));
|
||||
for (const record of records) {
|
||||
const own = checker.getPropertiesOfType(record).map((p) => p.name);
|
||||
const others = records.filter((other) => other !== record);
|
||||
const required = checker
|
||||
.getPropertiesOfType(record)
|
||||
.filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0)
|
||||
.map((p) => p.name);
|
||||
let key = required.find((name) =>
|
||||
others.every((other) => checker.getPropertyOfType(other, name) === undefined),
|
||||
);
|
||||
// A property every arm has, whose object value has a key only this
|
||||
// arm's does (IrFfiParam's callback.id and callback.release).
|
||||
if (key === undefined)
|
||||
for (const outer of required) {
|
||||
const inner = (t) => {
|
||||
const p = checker.getPropertyOfType(t, outer);
|
||||
return p === undefined ? undefined : split(checker.getTypeOfSymbol(p)).present;
|
||||
};
|
||||
const mine = inner(record);
|
||||
if (mine?.length !== 1) continue;
|
||||
const found = checker
|
||||
.getPropertiesOfType(mine[0])
|
||||
.filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0)
|
||||
.map((p) => p.name)
|
||||
.find((name) =>
|
||||
others.every((other) => {
|
||||
const theirs = inner(other);
|
||||
return (
|
||||
theirs === undefined ||
|
||||
theirs.every((t) => checker.getPropertyOfType(t, name) === undefined)
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (found !== undefined) {
|
||||
key = `${outer}.${found}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (records.length > 1 && key === undefined)
|
||||
throw new Error(
|
||||
`no property tells ${checker.typeToString(record)} apart at ${where}: ${own.join(",")}`,
|
||||
);
|
||||
arms.push({ test: records.length === 1 ? "object" : `key:${key}`, parts: [record] });
|
||||
}
|
||||
return { code: "arms", arms };
|
||||
}
|
||||
|
||||
// One encoder and one decoder per object type, union and array the module
|
||||
// reaches, named by a number in the order they are first reached.
|
||||
const coders = new Map();
|
||||
const order = [];
|
||||
function coderFor(parts, typeExpr, where) {
|
||||
const key = partsKey(parts);
|
||||
const known = coders.get(key);
|
||||
if (known !== undefined) return known;
|
||||
const shape = shapeOf(parts, where);
|
||||
if (shape.code === "string" || shape.code === "number" || shape.code === "bool") return shape;
|
||||
const exportedName = exportedBy.get(key);
|
||||
if (exportedName !== undefined) used.add(exportedName);
|
||||
const name = exportedName ?? `T${order.length}`;
|
||||
const coder = { ...shape, name, typeExpr: exportedName ?? typeExpr, where };
|
||||
coders.set(key, coder);
|
||||
order.push(coder);
|
||||
return coder;
|
||||
}
|
||||
|
||||
/** The type expression of `parts`, `typeExpr` naming the field or element
|
||||
* type they come from. */
|
||||
function presentExpr(typeExpr) {
|
||||
return `NonNullable<${typeExpr}>`;
|
||||
}
|
||||
|
||||
/** Lines that write `value` of `type`, named `typeExpr`. A string field
|
||||
* named `file` is written with IrWriter.file, which notes it as a source
|
||||
* file the encoding names. */
|
||||
function encodeValue(value, type, typeExpr, where, optional, field = null) {
|
||||
const { present, nullable, undefinable } = split(type);
|
||||
const coder = coderFor(present, presentExpr(typeExpr), where);
|
||||
const write =
|
||||
coder.code === "string"
|
||||
? `w.${field === "file" ? "file" : "str"}(${value});`
|
||||
: coder.code === "number"
|
||||
? `w.num(${value});`
|
||||
: coder.code === "bool"
|
||||
? `w.bool(${value});`
|
||||
: `enc${coder.name}(w, ${value});`;
|
||||
if (!nullable && !undefinable && !optional) return [write];
|
||||
if (nullable && (undefinable || optional))
|
||||
return [
|
||||
`if (${value} === undefined) w.byte(0);`,
|
||||
`else if (${value} === null) w.byte(1);`,
|
||||
`else {`,
|
||||
` w.byte(2);`,
|
||||
` ${write}`,
|
||||
`}`,
|
||||
];
|
||||
const missing = nullable ? "null" : "undefined";
|
||||
return [`if (${value} === ${missing}) w.byte(0);`, `else {`, ` w.byte(1);`, ` ${write}`, `}`];
|
||||
}
|
||||
|
||||
/** An expression that reads a value of `type`; null-checks are statements
|
||||
* through `local`. */
|
||||
function decodeValue(type, typeExpr, where, optional) {
|
||||
const { present, nullable, undefinable } = split(type);
|
||||
const coder = coderFor(present, presentExpr(typeExpr), where);
|
||||
// A scalar read is cast to its literal type (`17`, `true`, a union of
|
||||
// string literals), which the bytes cannot spell.
|
||||
const scalar = (call, plain) =>
|
||||
present.every((part) => part.flags === plain) ? call : `(${call} as ${presentExpr(typeExpr)})`;
|
||||
const read =
|
||||
coder.code === "string"
|
||||
? scalar("r.str()", F.String)
|
||||
: coder.code === "number"
|
||||
? scalar("r.num()", F.Number)
|
||||
: coder.code === "bool"
|
||||
? present.length === 2
|
||||
? "r.bool()"
|
||||
: `(r.bool() as ${presentExpr(typeExpr)})`
|
||||
: `dec${coder.name}(r)`;
|
||||
if (!nullable && !undefinable && !optional) return read;
|
||||
if (nullable && (undefinable || optional))
|
||||
return `r.tag(3, "${where}") === 0 ? undefined : r.lastTag === 1 ? null : ${read}`;
|
||||
return `r.tag(2, "${where}") === 0 ? ${nullable ? "null" : "undefined"} : ${read}`;
|
||||
}
|
||||
|
||||
/** Field reads and writes of one object type. */
|
||||
function objectLines(type, typeExpr, where, kind) {
|
||||
const enc = [];
|
||||
const dec = [];
|
||||
const fields = [];
|
||||
let index = 0;
|
||||
for (const property of checker.getPropertiesOfType(type)) {
|
||||
if (property.name === "kind" && kind !== null) continue;
|
||||
const fieldType = checker.getTypeOfSymbol(property);
|
||||
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0;
|
||||
const fieldExpr = `${typeExpr}[${JSON.stringify(property.name)}]`;
|
||||
const at = `${where}.${property.name}`;
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(property.name))
|
||||
throw new Error(`field name ${property.name} at ${where} is not an identifier`);
|
||||
const access = `v.${property.name}`;
|
||||
enc.push(...encodeValue(access, fieldType, fieldExpr, at, optional, property.name));
|
||||
const local = `f${index++}`;
|
||||
dec.push(`const ${local} = ${decodeValue(fieldType, fieldExpr, at, optional)};`);
|
||||
fields.push({ name: property.name, local, optional });
|
||||
}
|
||||
// The object is built with its fields in declaration order, an absent
|
||||
// optional field left out where it would stand: an object whose keys
|
||||
// follow that order (IrWriter.keys) reads back with the same key order.
|
||||
const literal = [
|
||||
...(kind === null ? [] : [`kind: ${JSON.stringify(kind)}`]),
|
||||
...fields.map((f) =>
|
||||
f.optional
|
||||
? `...(${f.local} !== undefined ? { ${f.name}: ${f.local} } : {})`
|
||||
: `${f.name}: ${f.local}`,
|
||||
),
|
||||
];
|
||||
dec.push(`const o: ${typeExpr} = { ${literal.join(", ")} };`);
|
||||
dec.push("return o;");
|
||||
const declared = `KEYS${keyLists.length}`;
|
||||
keyLists.push(
|
||||
`const ${declared}: readonly string[] = ${JSON.stringify([...(kind === null ? [] : ["kind"]), ...fields.map((f) => f.name)])};`,
|
||||
);
|
||||
enc.unshift(`w.keys(Object.keys(v), ${declared});`);
|
||||
return { enc, dec };
|
||||
}
|
||||
|
||||
/** The declared field names of each object type, for IrWriter.keys. */
|
||||
const keyLists = [];
|
||||
|
||||
const body = [];
|
||||
function emit(coder) {
|
||||
const { name, typeExpr } = coder;
|
||||
if (coder.code === "object") {
|
||||
const { enc, dec } = objectLines(coder.type, typeExpr, coder.where, null);
|
||||
body.push(
|
||||
`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`,
|
||||
...enc.map((l) => ` ${l}`),
|
||||
"}",
|
||||
`function dec${name}(r: IrReader): ${typeExpr} {`,
|
||||
...dec.map((l) => ` ${l}`),
|
||||
"}",
|
||||
"",
|
||||
);
|
||||
} else if (coder.code === "array") {
|
||||
const item = checker.getTypeArguments(coder.item)[0];
|
||||
const itemExpr = `${typeExpr}[number]`;
|
||||
const at = `${coder.where}[]`;
|
||||
const enc = encodeValue("item", item, itemExpr, at, false);
|
||||
const dec = decodeValue(item, itemExpr, at, false);
|
||||
body.push(
|
||||
`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`,
|
||||
" w.length(v.length);",
|
||||
" for (const item of v) {",
|
||||
...enc.map((l) => ` ${l}`),
|
||||
" }",
|
||||
"}",
|
||||
`function dec${name}(r: IrReader): ${typeExpr} {`,
|
||||
" const length = r.length();",
|
||||
` const out: ${typeExpr}[number][] = [];`,
|
||||
` for (let i = 0; i < length; i++) out.push(${dec});`,
|
||||
" return out;",
|
||||
"}",
|
||||
"",
|
||||
);
|
||||
} else if (coder.code === "kinds") {
|
||||
const members = [...coder.members].sort((a, b) => (kindOf(a) < kindOf(b) ? -1 : 1));
|
||||
const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`, " switch (v.kind) {"];
|
||||
const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, ` switch (r.length()) {`];
|
||||
members.forEach((member, index) => {
|
||||
const kind = kindOf(member);
|
||||
const memberExpr = `Extract<${typeExpr}, { kind: ${JSON.stringify(kind)} }>`;
|
||||
const lines = objectLines(member, memberExpr, `${coder.where}<${kind}>`, kind);
|
||||
enc.push(
|
||||
` case ${JSON.stringify(kind)}: {`,
|
||||
` w.length(${index});`,
|
||||
...lines.enc.map((l) => ` ${l}`),
|
||||
" return;",
|
||||
" }",
|
||||
);
|
||||
dec.push(` case ${index}: {`, ...lines.dec.map((l) => ` ${l}`), " }");
|
||||
});
|
||||
// A kind the union does not declare writes nothing that decodes.
|
||||
enc.push(" }", ` w.refuse("${name} kind " + String((v as { kind: unknown }).kind));`, "}");
|
||||
dec.push(" }", ` throw new Error("IR codec: invalid ${name} kind");`, "}", "");
|
||||
body.push(...enc, ...dec);
|
||||
} else if (coder.code === "arms") {
|
||||
const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`];
|
||||
const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, " switch (r.length()) {"];
|
||||
coder.arms.forEach((arm, index) => {
|
||||
const armExpr =
|
||||
arm.test === "string" || arm.test === "number" || arm.test === "boolean"
|
||||
? `Extract<${typeExpr}, ${arm.test}>`
|
||||
: arm.test === "array"
|
||||
? `Extract<${typeExpr}, readonly unknown[]>`
|
||||
: arm.test === "object"
|
||||
? `Exclude<${typeExpr}, string | number | boolean | readonly unknown[]>`
|
||||
: arm.test.includes(".")
|
||||
? `Extract<${typeExpr}, { ${arm.test.slice(4).split(".")[0]}: { ${arm.test.slice(4).split(".")[1]}: unknown } }>`
|
||||
: `Extract<${typeExpr}, { ${arm.test.slice(4)}: unknown }>`;
|
||||
const test =
|
||||
arm.test === "string" || arm.test === "number" || arm.test === "boolean"
|
||||
? `typeof v === ${JSON.stringify(arm.test)}`
|
||||
: arm.test === "array"
|
||||
? "Array.isArray(v)"
|
||||
: arm.test === "object"
|
||||
? "true"
|
||||
: arm.test.includes(".")
|
||||
? `typeof v === "object" && ${JSON.stringify(arm.test.slice(4).split(".")[0])} in v && ${JSON.stringify(arm.test.slice(4).split(".")[1])} in (v as { ${arm.test.slice(4).split(".")[0]}: object }).${arm.test.slice(4).split(".")[0]}`
|
||||
: `typeof v === "object" && ${JSON.stringify(arm.test.slice(4))} in v`;
|
||||
const parts = arm.parts;
|
||||
const armCoder = coderFor(parts, armExpr, `${coder.where}|${index}`);
|
||||
const write =
|
||||
armCoder.code === "string"
|
||||
? "w.str(v);"
|
||||
: armCoder.code === "number"
|
||||
? "w.num(v);"
|
||||
: armCoder.code === "bool"
|
||||
? "w.bool(v);"
|
||||
: `enc${armCoder.name}(w, v as ${armExpr});`;
|
||||
const read =
|
||||
armCoder.code === "string"
|
||||
? "r.str()"
|
||||
: armCoder.code === "number"
|
||||
? "r.num()"
|
||||
: armCoder.code === "bool"
|
||||
? "r.bool()"
|
||||
: `dec${armCoder.name}(r)`;
|
||||
enc.push(` if (${test}) {`, ` w.length(${index});`, ` ${write}`, " return;", " }");
|
||||
dec.push(` case ${index}:`, ` return ${read} as ${typeExpr};`);
|
||||
});
|
||||
enc.push(` throw new Error("IR codec: no arm of ${name} matches");`, "}");
|
||||
dec.push(" }", ` throw new Error("IR codec: invalid ${name} arm");`, "}", "");
|
||||
body.push(...enc, ...dec);
|
||||
}
|
||||
}
|
||||
|
||||
coderFor([moduleType], "IrModule", "IrModule");
|
||||
coderFor([functionType], "IrFunction", "IrFunction");
|
||||
for (let index = 0; index < order.length; index++) emit(order[index]);
|
||||
|
||||
const lines = [
|
||||
"// Generated from packages/compiler/src/ir/ir.ts by scripts/generate-ir-codec.mjs.",
|
||||
"// Run `pnpm generate:ir-codec` after changing the IR's types.",
|
||||
`import type { ${[...used].sort().join(", ")} } from "./ir.js";`,
|
||||
'import type { IrReader, IrWriter } from "./codec.js";',
|
||||
"",
|
||||
"/** A digest of the coders below: an encoding written with other coders is",
|
||||
" * refused instead of misread, also after a change that keeps IR_VERSION. */",
|
||||
`export const IR_CODEC_LAYOUT = ${JSON.stringify(createHash("sha256").update(body.join("\n")).digest("hex").slice(0, 16))};`,
|
||||
"",
|
||||
"/** Writes `mod` field by field (codec.ts describes the bytes). */",
|
||||
"export function encodeIrModule(w: IrWriter, mod: IrModule): void {",
|
||||
" encIrModule(w, mod);",
|
||||
"}",
|
||||
"/** Reads a module encodeIrModule wrote. */",
|
||||
"export function decodeIrModule(r: IrReader): IrModule {",
|
||||
" return decIrModule(r);",
|
||||
"}",
|
||||
"/** Writes one function. */",
|
||||
"export function encodeIrFunction(w: IrWriter, fn: IrFunction): void {",
|
||||
" encIrFunction(w, fn);",
|
||||
"}",
|
||||
"/** Reads a function encodeIrFunction wrote. */",
|
||||
"export function decodeIrFunction(r: IrReader): IrFunction {",
|
||||
" return decIrFunction(r);",
|
||||
"}",
|
||||
"",
|
||||
...keyLists,
|
||||
"",
|
||||
...body,
|
||||
];
|
||||
const text = lines.join("\n");
|
||||
if (process.argv.includes("--check")) {
|
||||
if (readFileSync(target, "utf8") !== text)
|
||||
throw new Error("codec.generated.ts is stale; run node scripts/generate-ir-codec.mjs");
|
||||
} else writeFileSync(target, text);
|
||||
Reference in New Issue
Block a user