feat(compiler): run LLVM layout and helper emission natively

- Compile production LLVM layouts, ownership helpers, debug metadata, and JSON emitters on both native backends
- Support nullable collections and mixed insertion spreads needed by compiler code
- Verify native emission parity and generated executables with focused sanitizer coverage
This commit is contained in:
Chris Tate
2026-09-27 18:34:32 -05:00
parent 48e00aa779
commit 010bab09cd
32 changed files with 1338 additions and 162 deletions
+3 -7
View File
@@ -1,3 +1,4 @@
import { commentText, octalByte } from "../literals.js";
import { InternalCompilerError } from "../../errors.js";
/* Type-directed dispatch tables of the C emitter: the C spelling of every IR
* type and the per-type runtime entry points (retain/release, box kinds,
@@ -431,12 +432,7 @@ export function mapValKindC(value: IrType): string {
* ordinary output byte-for-byte, but split comment delimiters and encode
* source-control characters so the text cannot alter the translation unit. */
export function cCommentText(text: string): string {
return text
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (char) =>
`\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
)
.replace(/\*\//g, "* /")
.replace(/\/\*/g, "/ *");
return commentText(text).replace(/\*\//g, "* /").replace(/\/\*/g, "/ *");
}
/** UTF-8 bytes as an unambiguous C string literal (octal escapes are always
@@ -453,7 +449,7 @@ export function cStringLiteral(bytes: Buffer): string {
// standard C, exactly for this.
else if (b === 0x3f) out += "\\?";
else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b);
else out += "\\" + b.toString(8).padStart(3, "0");
else out += "\\" + octalByte(b);
}
return out + '"';
}
@@ -0,0 +1,62 @@
import { expect, test } from "vitest";
import { commentText, llvmBytes, llvmQuoted, octalByte, unsignedHex } from "./literals.js";
import { cCommentText, cNumberLiteral, cStringLiteral } from "./c/types.js";
import { f64Lit, llvmCommentText } from "./llvm/common.js";
import { mangleFunction, mangleLocal } from "./mangle.js";
test("integer escaping agrees with JavaScript across all UTF-16 code units", () => {
for (let value = 0; value <= 0xffff; value++) expect(unsignedHex(value)).toBe(value.toString(16));
for (const value of [0x10000, 0x10ffff, 2 ** 32, 2 ** 48 - 1, Number.MAX_SAFE_INTEGER]) {
expect(unsignedHex(value)).toBe(value.toString(16));
}
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
expect(() => unsignedHex(value)).toThrow(RangeError);
}
});
test("octal and LLVM escapes cover every byte without ambiguous suffixes", () => {
const bytes = Uint8Array.from({ length: 256 }, (_, i) => i);
for (const byte of bytes) expect(octalByte(byte)).toBe(byte.toString(8).padStart(3, "0"));
const expected = [...bytes].map((b) => b >= 32 && b < 127 && b !== 34 && b !== 92
? String.fromCharCode(b) : "\\" + b.toString(16).padStart(2, "0").toUpperCase()).join("");
expect(llvmBytes(bytes)).toBe(expected + "\\00");
expect(llvmBytes(bytes, false)).toBe(expected);
expect(llvmBytes(new Uint8Array())).toBe("\\00");
expect(cStringLiteral(Buffer.from([0, 49, 255, 55, 63, 34, 92]))).toBe('"\\0001\\3777\\?\\"\\\\"');
});
test("metadata strings preserve UTF-8 replacement, embedded NUL and quotes", () => {
for (const text of ["", "alpha.ts", "é/日本/😀.ts", 'quote"slash\\\0', "\ud800", "\udfff", "\ud83d\ude00"]) {
const bytes = Buffer.from(text, "utf8");
expect(llvmQuoted(text)).toBe('"' + llvmBytes(bytes, false) + '"');
}
expect(llvmQuoted("é")).toBe('"\\C3\\A9"');
expect(llvmQuoted("\ud800")).toBe('"\\EF\\BF\\BD"');
});
test("comments preserve ordinary text and encode all source-control units", () => {
const text = Array.from({ length: 0x100 }, (_, i) => String.fromCharCode(i)).join("") + "\u2028\u2029é😀\ud800/*end*/";
const expected = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (ch) =>
"\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
expect(commentText(text)).toBe(expected);
expect(llvmCommentText(text)).toBe(expected);
expect(cCommentText(text)).toBe(expected.replace(/\*\//g, "* /").replace(/\/\*/g, "/ *"));
});
test("name spelling remains byte-for-byte compatible, including surrogate units", () => {
for (const name of ["abc_X9", "x.12", "$x%y", "é日本", "😀", "\ud800\0", "dash-here"]) {
const expected = name.replace(/[^A-Za-z0-9_]/g, (ch) => ch === "." ? "_" : `_x${ch.codePointAt(0)!.toString(16)}_`);
expect(mangleFunction(name)).toBe("sc_f_" + expected);
expect(mangleLocal(name)).toBe("sc_l_" + expected);
}
});
test("double spelling preserves every bit, including signed zero and subnormals", () => {
for (const number of [0, -0, 1, -1, 0.1, Number.MIN_VALUE, Number.MAX_VALUE, Infinity, -Infinity, NaN]) {
const bytes = Buffer.alloc(8);
bytes.writeDoubleBE(number);
expect(f64Lit(number)).toBe("0x" + bytes.toString("hex").toUpperCase());
}
expect(cNumberLiteral(-0)).toBe("-0.0");
expect(cNumberLiteral(2 ** 64)).toBe("18446744073709552000.0");
});
+49
View File
@@ -0,0 +1,49 @@
/** Integer spelling for compiler-generated names and escapes. These
* callers need exact nonnegative integers, not Number.toString's general
* fractional-radix conversion. Division preserves all safe integer bits. */
export function unsignedHex(value: number): string {
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError("hexadecimal value must be a nonnegative safe integer");
const digits = "0123456789abcdef";
let result = "";
do {
result = digits.charAt(value % 16) + result;
value = Math.floor(value / 16);
} while (value !== 0);
return result;
}
/** Three octal digits prevent a following source digit extending an escape. */
export function octalByte(value: number): string {
const digits = "01234567";
return digits.charAt((value >>> 6) & 3) + digits.charAt((value >>> 3) & 7) + digits.charAt(value & 7);
}
/** LLVM byte-string contents, without quotes or the c prefix. */
export function llvmBytes(bytes: Uint8Array, nulTerminated = true): string {
let out = "";
for (const byte of bytes) {
out += byte >= 0x20 && byte < 0x7f && byte !== 0x22 && byte !== 0x5c
? String.fromCharCode(byte)
: "\\" + unsignedHex(byte).padStart(2, "0").toUpperCase();
}
return nulTerminated ? out + "\\00" : out;
}
/** Metadata operands use the same UTF-8 escapes, without a trailing NUL. */
export function llvmQuoted(text: string): string {
return '"' + llvmBytes(Buffer.from(text, "utf8"), false) + '"';
}
/** Escape UTF-16 source-control units without combining surrogate pairs. */
export function commentText(text: string): string {
let out = "";
let start = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029) {
out += text.slice(start, i) + "\\u" + unsignedHex(code).padStart(4, "0");
start = i + 1;
}
}
return out + text.slice(start);
}
@@ -0,0 +1,97 @@
import { expect, test } from "vitest";
import { BlockBuilder } from "./blocks.js";
import { f64Lit } from "./common.js";
test("entry allocations precede instructions and temporaries stay unique", () => {
const blocks = new BlockBuilder();
const first = blocks.tmp();
const second = blocks.slot();
expect(first).not.toBe(second);
blocks.line(`${first} = fadd double 0.0, 1.0`);
blocks.entryAllocas.push(`${second} = alloca double`);
blocks.terminate("ret void");
expect(blocks.render()).toBe(`entry:\n ${second} = alloca double\n ${first} = fadd double 0.0, 1.0\n ret void`);
});
test("terminators suppress dead instructions and keep their first target", () => {
const blocks = new BlockBuilder();
blocks.br("done");
blocks.line("invalid dead instruction");
blocks.terminate("ret i32 99");
expect(blocks.isTerminated()).toBe(true);
blocks.startBlock("done");
expect(blocks.isTerminated()).toBe(false);
blocks.terminate("ret void");
expect(blocks.render()).toBe("entry:\n br label %done\ndone:\n ret void");
});
test("unterminated unreachable joins receive an explicit terminator", () => {
const blocks = new BlockBuilder();
blocks.terminate("ret void");
blocks.startBlock("join");
expect(blocks.render()).toContain("join:\n unreachable");
});
test("debug attachments precede instruction comments and skip comment-only lines", () => {
const blocks = new BlockBuilder();
blocks.debugLocation = "!7";
blocks.line('; comment "quotes" ; remain text');
blocks.line('call void @f(ptr @"name;part") ; instruction comment');
blocks.terminate("ret void ; tail");
expect(blocks.render()).toBe([
"entry:", ' ; comment "quotes" ; remain text',
' call void @f(ptr @"name;part"), !dbg !7 ; instruction comment',
" ret void, !dbg !7 ; tail",
].join("\n"));
});
test("changing a debug location affects only subsequent instructions", () => {
const blocks = new BlockBuilder();
blocks.line("call void @first()");
blocks.debugLocation = "!1";
blocks.line("call void @second()");
blocks.debugLocation = "!2";
blocks.line("call void @third()");
blocks.debugLocation = null;
blocks.terminate("ret void");
expect(blocks.render()).toContain("call void @first()\n");
expect(blocks.render()).toContain("call void @second(), !dbg !1");
expect(blocks.render()).toContain("call void @third(), !dbg !2");
expect(blocks.render()).toContain("ret void");
});
test("counted loops expose one increment path and preserve nested block endings", () => {
const blocks = new BlockBuilder();
const visited: string[] = [];
blocks.countedLoop(f64Lit(4), (index, next) => {
visited.push(index, next);
const skip = blocks.newLabel("skip");
const emit = blocks.newLabel("emit");
blocks.condBr("%condition", skip, emit);
blocks.startBlock(skip);
blocks.br(next);
blocks.startBlock(emit);
blocks.line(`call void @consume(double ${index})`);
});
blocks.terminate("ret void");
const text = blocks.render();
expect(visited).toHaveLength(2);
expect(text).toContain("fcmp olt double");
expect(text).toContain(`br label %${visited[1]}`);
expect(text).toContain("fadd double");
expect(text).not.toContain("unreachable");
});
test("inclusive loops and nested loops keep labels separate", () => {
const blocks = new BlockBuilder();
blocks.countedLoop(f64Lit(2), (outer) => {
blocks.countedLoop(outer, (inner) => { blocks.line(`call void @consume(double ${inner})`); }, "ole");
});
blocks.terminate("ret void");
const text = blocks.render();
const labels = text.split("\n").filter((line) => line.endsWith(":"));
expect(new Set(labels).size).toBe(labels.length);
expect(text).toContain("fcmp olt double");
expect(text).toContain("fcmp ole double");
expect(text.match(/alloca double/g)).toHaveLength(2);
});
+2 -2
View File
@@ -45,8 +45,8 @@ export class BlockBuilder {
// can themselves contain semicolons; those are not comment delimiters.
let quoted = false;
for (let i = 0; i < s.length; i++) {
if (s[i] === '"') quoted = !quoted;
if (s[i] === ";" && !quoted) {
if (s.charAt(i) === '"') quoted = !quoted;
if (s.charAt(i) === ";" && !quoted) {
return `${s.slice(0, i).trimEnd()}, !dbg ${this.debugLocation} ${s.slice(i)}`;
}
}
+23 -24
View File
@@ -50,16 +50,19 @@ export interface LlVtSlot {
}
/** Per-class node of the class graph — the CEmitter ClassMeta shape. */
export interface LlClassMeta {
def: IrClassDef;
base: LlClassMeta | null;
children: LlClassMeta[];
export class LlClassMeta {
base: LlClassMeta | null = null;
children: LlClassMeta[] = [];
root: LlClassMeta;
pre: number;
post: number;
hierarchy: boolean;
pre = 0;
post = 0;
hierarchy = false;
/** Root classes: the hierarchy's slots in DFS-declaration order. */
slots: LlVtSlot[];
slots: LlVtSlot[] = [];
constructor(readonly def: IrClassDef) {
this.root = this;
}
}
/** The class graph: link base/children, number the forest in preorder
@@ -72,16 +75,7 @@ export interface LlClassMeta {
export function buildClassGraph(mod: IrModule, fnByName: Map<string, IrFunction>): Map<string, LlClassMeta> {
const metaMap = new Map<string, LlClassMeta>();
for (const cls of mod.classes ?? []) {
metaMap.set(cls.name, {
def: cls,
base: null,
children: [],
root: undefined as unknown as LlClassMeta,
pre: 0,
post: 0,
hierarchy: false,
slots: [],
});
metaMap.set(cls.name, new LlClassMeta(cls));
}
for (const meta of metaMap.values()) {
if (meta.def.base === undefined) continue;
@@ -107,7 +101,7 @@ export function buildClassGraph(mod: IrModule, fnByName: Map<string, IrFunction>
const collectSlots = (m: LlClassMeta, root: LlClassMeta): void => {
for (const method of m.def.methods ?? []) {
let inherited = false;
for (let a = m.base; a; a = a.base) inherited ||= declares(a, method);
for (let a = m.base; a; a = a.base) inherited = inherited || declares(a, method);
if (!inherited && declaredBelow(m, method)) {
let fn = fnByName.get(`%${m.def.name}.${method}`);
if (!fn && m.def.abstractMethods?.includes(method)) {
@@ -143,7 +137,7 @@ export function buildClassGraph(mod: IrModule, fnByName: Map<string, IrFunction>
* dispatches to, or null outside the slot's declaring subtree / on a
* fully-abstract chain (vtEntriesFor, ported). */
function vtEntriesFor(meta: LlClassMeta): { slot: LlVtSlot; impl: LlClassMeta | null }[] {
return meta.root.slots.map((slot) => {
return meta.root.slots.map((slot): { slot: LlVtSlot; impl: LlClassMeta | null } => {
if (!(slot.declarer.pre <= meta.pre && meta.pre <= slot.declarer.post)) {
return { slot, impl: null };
}
@@ -271,8 +265,13 @@ export function emitClassShapes(
// runtime root — %Error — counts exactly when an emitted subclass needs
// its type): a ScrVt head plus one ptr per slot. All slots are `ptr`, so
// the type is layout-only.
const roots = [...new Set(emitted.map((c) => metaMap.get(c.name)!).filter((m) => m.hierarchy).map((m) => m.root))];
for (const root of roots) {
const roots = new Set<string>();
for (const cls of emitted) {
const meta = metaMap.get(cls.name)!;
if (meta.hierarchy) roots.add(meta.root.def.name);
}
for (const name of roots) {
const root = metaMap.get(name)!;
const slotPtrs = root.slots.map(() => "ptr").join(", ");
typeDefs.push(
`%${mangleVtStruct(root.def.name)} = type { %ScrVt${root.slots.length ? ", " + slotPtrs : ""} } ` +
@@ -307,7 +306,7 @@ export function emitClassShapes(
const isStreamRooted = streamRooted(meta);
const fieldIndex = (i: number): number => fieldBase(meta) + i;
const refFields = cls.fields
.map((f, i) => ({ ...f, index: fieldIndex(i) }))
.map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) }))
.filter((f) => isRefCounted(f.type));
const sizeOf = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 1) to ${host.sizeType})`;
// An embedded prefix slot (the emitter registry at 2, the stream
@@ -468,7 +467,7 @@ export function emitClassShapes(
// trace: visit exactly the cycle-capable fields; gcFree: release
// exactly the complement, then free (the trace/teardown complement
// contract in scr_runtime.h).
const indexed = cls.fields.map((f, i) => ({ ...f, index: fieldIndex(i) }));
const indexed = cls.fields.map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) }));
const tracedFields = indexed.filter((f) => traceAdapter(host, f.type) !== null);
const untracedRefFields = indexed.filter(
(f) => isRefCounted(f.type) && traceAdapter(host, f.type) === null,
+5 -6
View File
@@ -1,3 +1,4 @@
import { commentText, unsignedHex } from "../literals.js";
import { InternalCompilerError } from "../../errors.js";
import type { IrFfiCallbackParamClass, IrFfiReturnClass, IrFfiValueParamClass } from "../../ir/ir.js";
@@ -5,9 +6,7 @@ import type { IrFfiCallbackParamClass, IrFfiReturnClass, IrFfiValueParamClass }
* ordinary output byte-for-byte, but encode control and line-separator code
* units so a property name can never inject a line or invalid source byte. */
export function llvmCommentText(text: string): string {
return text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (char) =>
`\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
);
return commentText(text);
}
export function ffiNativeTypeLl(
@@ -33,9 +32,9 @@ export function ffiNativeTypeLl(
}
export function f64Lit(n: number): string {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, n);
return `0x${[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
const bytes = new Uint8Array(8);
new DataView(bytes.buffer).setFloat64(0, n);
return `0x${[...bytes].map((b) => unsignedHex(b).padStart(2, "0")).join("").toUpperCase()}`;
}
export const F64_INF = f64Lit(Infinity);
@@ -0,0 +1,118 @@
import { expect, test } from "vitest";
import { BOOL, F64, STRING, VOID, type IrFunction, type IrLocal, type SrcLoc } from "../../ir/ir.js";
import { LlvmDebugInfo } from "./debug-info.js";
const file = "/source/日本/main.ts";
const text = "function example(value: number) {\r\n const outer = 1;\n { const inner = 2; }\n}\n";
const loc: SrcLoc = { file, start: 0, end: text.length };
const at = (word: string): SrcLoc => ({ file, start: text.indexOf(word), end: text.indexOf(word) + word.length });
function local(name: string, scope = loc): IrLocal {
return { id: name + ".0", name, type: F64, mutable: true, source: { loc: at(name), scope } };
}
function fn(locals: IrLocal[] = []): IrFunction {
return { name: "example", params: [], locals, returnType: VOID, body: [], loc };
}
test("missing sources leave functions and bindings without invented positions", () => {
const debug = new LlvmDebugInfo(file, new Map());
expect(debug.function(fn())).toBeNull();
expect(debug.local(local("outer"), null)).toBeNull();
expect(debug.location(loc, null)).toBeNull();
expect(debug.global({ id: "g", name: "g", type: F64, mutable: false })).toBeNull();
expect(debug.render()).not.toContain("DILocation");
});
test("parameters retain argument numbers and source positions across CRLF", () => {
const debug = new LlvmDebugInfo(file, new Map([[file, text]]));
const value = local("value");
const scope = debug.function(fn([value]));
const binding = debug.local(value, scope, 1);
expect(binding).not.toBeNull();
expect(debug.render()).toContain('name: "value", arg: 1');
expect(debug.render()).toContain("line: 1, column: 18");
expect(debug.location(at("outer"), scope)).not.toBeNull();
expect(debug.render()).toContain("line: 2, column: 9");
expect(debug.render()).toContain('filename: "main.ts"');
expect(debug.render()).toContain('directory: "/source/\\E6\\97\\A5\\E6\\9C\\AC"');
});
test("nested lexical scopes bind inner locals to the smallest containing block", () => {
const nested: SrcLoc = { file, start: text.indexOf("{ const"), end: text.indexOf("; }") + 3 };
const outer = local("outer");
const inner = local("inner", nested);
const debug = new LlvmDebugInfo(file, new Map([[file, text]]));
const scope = debug.function(fn([outer, inner]));
debug.local(outer, scope);
debug.local(inner, scope);
const rendered = debug.render();
const block = /(!\d+) = distinct !DILexicalBlock\(/.exec(rendered)?.[1];
expect(block).toBeDefined();
expect(rendered).toContain(`name: "outer", scope: ${scope}`);
expect(rendered).toContain(`name: "inner", scope: ${block}`);
});
test("captured locals use function scope while ordinary duplicate scopes intern", () => {
const nested: SrcLoc = { file, start: text.indexOf("{ const"), end: text.indexOf("; }") + 3 };
const inner = local("inner", nested);
inner.boxed = true;
const second = { ...inner, id: "other.0", name: "other" };
const captured = { ...inner, id: "captured.0", name: "captured" };
const func = fn([inner, second, captured]);
func.captures = [{ localId: captured.id, name: captured.name, type: F64 }];
const debug = new LlvmDebugInfo(file, new Map([[file, text]]));
const scope = debug.function(func);
debug.local(captured, scope, 0, true);
expect(debug.render().match(/distinct !DILexicalBlock\(/g)).toHaveLength(1);
expect(debug.render()).toContain(`name: "captured", scope: ${scope}`);
});
test.each([32, 64])("boxed and TDZ locals use honest storage descriptions (%i-bit)", (bits) => {
const debug = new LlvmDebugInfo(file, new Map([[file, text]]), bits);
const boxed: IrLocal = { ...local("outer"), boxed: true };
const tdz: IrLocal = { ...local("inner"), boxed: true, tdz: true };
const scope = debug.function(fn([boxed, tdz]));
const real = debug.local(boxed, scope);
const opaque = debug.local(tdz, scope);
expect(real?.expression).toBe(`!DIExpression(DW_OP_deref, DW_OP_plus_uconst, ${bits === 32 ? 24 : 40})`);
expect(opaque?.expression).toBe("!DIExpression()");
expect(debug.render()).toContain('name: "ScrBox", flags: DIFlagFwdDecl');
});
test("globals are attached to the correct compilation units and render is stable", () => {
const other = "/source/other.ts";
const otherLoc = { file: other, start: 0, end: 5 };
const debug = new LlvmDebugInfo(file, new Map([[file, text], [other, "const other = 1;"]]));
const first = debug.global({ id: "first", name: "first", type: STRING, mutable: false, source: { loc, scope: loc } });
const second = debug.global({ id: "second", name: "second", type: BOOL, mutable: true, source: { loc: otherLoc, scope: otherLoc } });
const rendered = debug.render();
expect(rendered).toContain(`!{${first}}`);
expect(rendered).toContain(`!{${second}}`);
expect(rendered.match(/distinct !DICompileUnit\(/g)).toHaveLength(2);
expect(debug.render()).toBe(rendered);
});
test("locations intern per file and scope and clamp columns to DWARF limits", () => {
const long = " ".repeat(70000) + "value";
const other = "/source/other.ts";
const debug = new LlvmDebugInfo(file, new Map([[file, text], [other, long]]));
const scope = debug.function(fn());
const pos = { file: other, start: 70000, end: 70005 };
const first = debug.location(pos, scope);
expect(debug.location(pos, scope)).toBe(first);
expect(debug.location({ file: "missing.ts", start: 0, end: 0 }, scope)).toBeNull();
expect(debug.render()).toContain("column: 65535");
expect(debug.render()).toContain("DILexicalBlockFile");
});
test.each([32, 64])("nullable reference types describe tags and ABI payload offsets (%i-bit)", (bits) => {
const debug = new LlvmDebugInfo(file, new Map([[file, text]]), bits, [
{ id: "value", arms: [STRING, { kind: "undefinedT" }] },
]);
const value: IrLocal = { ...local("value"), type: { kind: "union", unionId: "value" } };
const scope = debug.function(fn([value]));
debug.local(value, scope);
const rendered = debug.render();
expect(rendered).toContain('!DIEnumerator(name: "undefined", value: 1');
expect(rendered).toContain(`offset: ${(bits === 32 ? 24 : 40) * 8}`);
expect(rendered).toContain('name: "ScrUnion_value"');
});
@@ -1,17 +1,9 @@
import { llvmQuoted as quoted } from "../literals.js";
import { basename, dirname } from "node:path";
import { isUnitType, typeKey, type IrFunction, type IrGlobal, type IrLocal, type IrType, type IrUnionDef, type SrcLoc } from "../../ir/ir.js";
import { SourceLocations } from "../source-locations.js";
import { mangleFunction } from "../mangle.js";
/** LLVM metadata strings use UTF-8 byte escapes, including for paths. */
function quoted(text: string): string {
return '"' + [...Buffer.from(text)].map((byte) =>
byte >= 0x20 && byte < 0x7f && byte !== 0x22 && byte !== 0x5c
? String.fromCharCode(byte)
: `\\${byte.toString(16).padStart(2, "0").toUpperCase()}`,
).join("") + '"';
}
/** DWARF describes source bindings at their actual native storage locations. */
export class LlvmDebugInfo {
private readonly nodes: string[] = [];
@@ -69,7 +61,7 @@ export class LlvmDebugInfo {
const id = this.add(`distinct !DISubprogram(name: ${quoted(fn.name)}, linkageName: ${quoted(mangleFunction(fn.name))}, scope: ${file}, file: ${file}, line: ${pos.line}, type: ${signature}, scopeLine: ${pos.line}, spFlags: DISPFlagLocalToUnit | DISPFlagDefinition, unit: ${this.unit(pos.file).id})`);
const scopes: { loc: SrcLoc; id: string }[] = [];
this.lexicalScopes.set(id, scopes);
const captures = new Set(fn.captures?.map((c) => c.localId));
const captures = new Set((fn.captures ?? []).map((c) => c.localId));
const spans = fn.locals.filter((local) => !captures.has(local.id)).flatMap((local) => local.source ? [local.source.scope] : []);
// Outer scopes first; declarations in flattened IR still retain their
// source block, loop, catch, or function scope (including hoisted var).
+1 -7
View File
@@ -1,3 +1,4 @@
import { f64Lit } from "./common.js";
import { InternalCompilerError } from "../../errors.js";
/* The dyn (ScrDyn dyn) helper EMITTERS for the LLVM backend — the .ll
* mirror of walkers.ts's dyn slice: per-type match predicates
@@ -69,13 +70,6 @@ export interface DynHost extends WalkerHost {
liveDynRefAdapter(t: IrType): { snapshot: string; commit: string };
}
/** Exact double literal (the emitter's f64Lit — the walkers' copy). */
function f64Lit(n: number): string {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, n);
return `0x${[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
}
const FN_ATTRS = "#0";
export class LlDyn {
+6 -19
View File
@@ -1,3 +1,5 @@
import { emitLlvmLayouts } from "./layouts.js";
import { llvmBytes as llBytes } from "../literals.js";
import { InternalCompilerError } from "../../errors.js";
/* IR → LLVM IR text (.ll). The LLVM backend consumes the SAME in-memory
* IrModule the C backend does (never the JSON dump — see the -0 lesson in
@@ -113,8 +115,6 @@ import {
buildClassGraph,
classFieldIndex,
classStructSym,
emitClassObjDefs,
emitClassShapes,
type LlClassMeta,
} from "./classes.js";
import { LlDyn } from "./dyn.js";
@@ -126,7 +126,6 @@ import {
boxNewCall,
computeTraced,
elemAccess,
emitRecordShapes,
FN_ATTRS,
llFieldType,
releaseSym,
@@ -190,19 +189,6 @@ export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}):
return new LlEmitter(options.debugSources === undefined ? scalarizeNumericRecords(mod) : mod, options).emit();
}
/** LLVM c"..." payload for a UTF-8 literal, NUL-terminated like the C
* emitter's flexible-array-member initializer. */
function llBytes(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) {
s +=
b >= 0x20 && b < 0x7f && b !== 0x22 && b !== 0x5c
? String.fromCharCode(b)
: `\\${b.toString(16).padStart(2, "0").toUpperCase()}`;
}
return `${s}\\00`;
}
function llStrBytes(text: string): string {
return llBytes(Buffer.from(text, "utf8"));
}
@@ -775,9 +761,10 @@ class LlEmitter {
// emit), then the file assembles around them — the C emitter's order.
const fnDefs: string[] = [];
for (const fn of this.mod.functions) fnDefs.push(this.emitFunction(fn));
const shapes = emitRecordShapes(this, this.mod);
const classShapes = emitClassShapes(this, this.mod, this.classMeta);
const classObjDefs = emitClassObjDefs(this, this.classMeta, this.classObjs, this.fnByName, (t) => this.llType(t));
const layouts = emitLlvmLayouts(this, this.mod, this.classMeta, this.classObjs, this.fnByName, (t) => this.llType(t));
const shapes = layouts.records;
const classShapes = layouts.classes;
const classObjDefs = layouts.classObjects;
const wrappers = this.emitFnValueDefs();
const asyncDefs = this.emitAsyncScaffolding();
const ffiCallbacks = this.emitFfiCallbackDefs();
@@ -0,0 +1,26 @@
import type { IrFunction, IrModule, IrType } from "../../ir/ir.js";
import { emitClassObjDefs, emitClassShapes, type ClassHost, type LlClassMeta } from "./classes.js";
import { emitRecordShapes } from "./shapes.js";
export interface LlvmLayouts {
records: { typeDefs: string[]; defs: string[] };
classes: { typeDefs: string[]; defs: string[] };
classObjects: string[];
}
/** Generate the module's layouts, ownership helpers, vtables and class
* constructors together. Hosts supply symbol interning and declarations;
* all layout and lifetime decisions stay in this production stage. */
export function emitLlvmLayouts(
host: ClassHost,
mod: IrModule,
classes: Map<string, LlClassMeta>,
classObjects: Map<string, { nameSym: string }>,
functions: Map<string, IrFunction>,
llType: (type: IrType) => string,
): LlvmLayouts {
const records = emitRecordShapes(host, mod);
const classShapes = emitClassShapes(host, mod, classes);
const objects = emitClassObjDefs(host, classes, classObjects, functions, llType);
return { records, classes: classShapes, classObjects: objects };
}
@@ -1,3 +1,4 @@
import { f64Lit } from "./common.js";
import { InternalCompilerError } from "../../errors.js";
/* Structure-walking helper EMITTERS for the LLVM backend — the .ll mirror
* of walkers.ts's phase-3 slice: type-directed JSON serializers over
@@ -37,14 +38,6 @@ export interface WalkerHost extends ShapeHost {
needBadTag(): void;
}
/** Exact double literal (the emitter's f64Lit — duplicated to avoid a
* cyclic import; both spell every bit pattern identically). */
function f64Lit(n: number): string {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, n);
return `0x${[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
}
const FN_ATTRS = "#0";
export class LlWalkers {
+11 -3
View File
@@ -1,3 +1,5 @@
import { unsignedHex } from "./literals.js";
/* Name mangling. Distinct prefixes partition the C namespace:
* sc_f_ user functions sc_l_ locals
* sc_t emitter temporaries sc_lit_ interned string literals
@@ -12,9 +14,15 @@
*/
function sanitize(name: string): string {
return name.replace(/[^A-Za-z0-9_]/g, (ch) =>
ch === "." ? "_" : `_x${ch.codePointAt(0)!.toString(16)}_`,
);
let result = "";
for (let i = 0; i < name.length; i++) {
const code = name.charCodeAt(i);
const ch = name.charAt(i);
result += code === 46 ? "_"
: (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code === 95
? ch : `_x${unsignedHex(code)}_`;
}
return result;
}
export function mangleFunction(name: string): string {
@@ -437,15 +437,14 @@ export interface GenericInstance {
/** The fences on a defaulted parameter's body type: it becomes the value
* arm of the synthesized `T | undefined` ABI union, so it must be a valid
* single arm. func and Set ARE valid here: the ABI union's only test is
* single arm. Functions, Maps and Sets are valid: the ABI union's test is
* the prologue's own undefined-tag check (never a user narrowing, which
* is what keeps map/set out of general unions), so `runner: Runner =
* is what keeps containers out of unions with data siblings), so `runner: Runner =
* defaultRunner` and `skip: Set<string> = new Set()` arm like any ref
* kind — the nullable-callback union shape, built by the compiler. */
export function checkDefaultParamBodyType(lowerer: Lowerer, param: ts.ParameterDeclaration, bodyType: IrType): void {
if (
bodyType.kind === "void" ||
bodyType.kind === "map" ||
bodyType.kind === "regex" ||
bodyType.kind === "date" ||
bodyType.kind === "dyn" ||
@@ -100,7 +100,13 @@ function lowerArraySpreadItems(
];
for (const node of nodes) {
if (ts.isSpreadElement(node)) {
const source = lowerer.lowerExpr(node.expression);
let source = lowerer.lowerExpr(node.expression);
if (source.type.kind === "set" && typeEquals(source.type.elem, elem)) {
source = { kind: "setIntrinsic", method: "toArray", receiver: source, args: [], type: arrType, loc };
}
if (source.type.kind === "record" && lowerer.shapes.get(source.type.shapeId)?.tuple) {
source = lowerer.widthCoerce(source, arrType) ?? source;
}
if (!typeEquals(source.type, arrType)) {
lowerer.noLowering(`Array insertion spread from '${lowerer.fmt(source.type)}'`, node);
}
@@ -513,6 +519,17 @@ function fenceProducedArrayElem(lowerer: Lowerer, node: ts.Node, producer: strin
loc,
};
}
if (call.arguments.some(ts.isSpreadElement)) {
// Finish argument evaluation before mutating the receiver. Each
// spread snapshots its elements immediately, so a later argument
// can mutate that source without changing already-collected values.
const items = lowerArraySpreadItems(lowerer, call.arguments, elem, receiverIr, loc);
return {
kind: "arrIntrinsic",
method: name === "push" ? "pushSpread" : "unshiftSpread",
receiver, args: [items], type: F64, loc,
};
}
const valueProbes = call.arguments.map((arg) => tryLowerExpression(lowerer, arg));
if (valueProbes.some((probe) =>
probe !== null && lowerer.runtimeOptionalWidening(probe.type, elem) !== null)) {
@@ -10,7 +10,7 @@ import { dirname, posix } from "node:path";
import { pathToFileURL } from "node:url";
import type { Lowerer } from "./lowerer.js";
import { wasiGuestPath } from "../../wasi-paths.js";
import { BIGINT_T, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrFunction, IrJsOp, IrLocal, IrRecordShape, IrStmt, IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canDynCheckTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isJsonSafeType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionFuncSetArmsOk } from "../../ir/ir.js";
import { BIGINT_T, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrFunction, IrJsOp, IrLocal, IrRecordShape, IrStmt, IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canDynCheckTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isJsonSafeType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionContainerArmsOk } from "../../ir/ir.js";
import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, cjsExportDiscardReason, isCjsExportTableLiteral, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeEsmFile, locOf } from "../program.js";
import { ARRAY_METHODS, builtinConstLit, builtinFenceHintOf, builtinModuleConstOf, builtinModulesArrayLit, builtinModuleFnOf, COMPOUND_ASSIGN_OPS, CompoundOp, ISLAND_SURFACE, isChildSurfaceMember, MAP_METHODS, NARROW_FIRST, SET_METHODS, STRING_INDEX_METHODS, STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf } from "./surfaces.js";
import { UNSUPPORTED, blockedBindingUseDiag, requiresDynamicPackageDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js";
@@ -9143,14 +9143,12 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
}
if (joinArms.length === 0) return null;
// The join must be a BUILDABLE union: arm kinds the union invariants
// admit (no map/dyn/jsval/generator arms; func arms only beside
// func/unit siblings — unionFuncSetArmsOk, the validator's rule). A
// join mixing, say, a func answer with data answers has no union to
// surface as; the caller owns the fence message.
// admit (no dyn/jsval/generator arms; containers only beside unit
// siblings). The caller owns the diagnostic for a refused join.
if (
joinArms.length > 1 &&
(!unionFuncSetArmsOk(joinArms) ||
joinArms.some((a) => a.kind === "map" || a.kind === "dyn" || a.kind === "jsval" || a.kind === "generator" || a.kind === "caught"))
(!unionContainerArmsOk(joinArms) ||
joinArms.some((a) => a.kind === "dyn" || a.kind === "jsval" || a.kind === "generator" || a.kind === "caught"))
) {
return null;
}
@@ -1,6 +1,27 @@
import { describe, expect, test } from "vitest";
import { F64, STRING, VOID, type IrType } from "../ir/ir.js";
import { formatIrType, ShapeRegistry, UnionRegistry } from "./type-mapper.js";
import { F64, STRING, UNDEFINED_T, VOID, mapOf, setOf, type IrType } from "../ir/ir.js";
import { formatIrType, genResultRecord, ShapeRegistry, UnionRegistry, withUndefinedArm } from "./type-mapper.js";
describe("nullable collection union builders", () => {
test.each([mapOf(STRING, F64), setOf(STRING)])("optional %j fields and generator results share the same union", (type) => {
const shapes = new ShapeRegistry();
const unions = new UnionRegistry();
const optional = withUndefinedArm(type, unions);
expect(optional?.kind).toBe("union");
if (optional?.kind !== "union") throw new Error("missing union");
expect(unions.get(optional.unionId)?.arms).toContainEqual(type);
expect(unions.get(optional.unionId)?.arms).toContainEqual(UNDEFINED_T);
expect(withUndefinedArm(optional, unions)).toEqual(optional);
const result = genResultRecord(type, VOID, shapes, unions);
expect(result).not.toBeNull();
expect(shapes.get(result!.shapeId)?.fields.find((field) => field.name === "value")?.type).toEqual(optional);
});
test("collection generators retain the data-sibling refusal", () => {
expect(genResultRecord(mapOf(STRING, F64), STRING, new ShapeRegistry(), new UnionRegistry())).toBeNull();
expect(genResultRecord(setOf(STRING), F64, new ShapeRegistry(), new UnionRegistry())).toBeNull();
});
});
describe("IR type diagnostics", () => {
test("preserves small types, repeated sibling shapes, and array precedence", () => {
+18 -36
View File
@@ -2,7 +2,7 @@ import { InternalCompilerError } from "../errors.js";
import * as ts from "./ts7/adapter.js";
import { bodyReadsArguments } from "./arguments-usage.js";
import type { IrRecordShape, IrType, IrUnionDef, IrUnionDiscriminant } from "../ir/ir.js";
import { arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DYN, F64, funcOf, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, UNDEFINED_T, VOID } from "../ir/ir.js";
import { arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DYN, F64, funcOf, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, unionContainerArmsOk, UNDEFINED_T, VOID } from "../ir/ir.js";
import { BIGINT_T } from "../ir/ir.js";
import { isJsSourceFile, isNodeTypesPath } from "./program.js";
@@ -2884,27 +2884,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
arms.some(
(a) =>
a.kind === "void" || a.kind === "union" ||
// Map/Set arms stay out (like func against data arms: no
// narrowing test — no discriminant fields on them). REGEX
// arms map: `x instanceof RegExp` is their narrowing test
// (the skip-utility `string | RegExp` shape), and the arm
// rides the ref machinery like array regex elements.
a.kind === "map" || a.kind === "set" || a.kind === "date" || a.kind === "dyn" ||
// Generator arms follow the map/set rule: no narrowing test.
a.kind === "generator" ||
// Func arms map beside ANY sibling: `typeof x === "function"`
// is the narrowing against data arms (typeofAnswer knows every
// arm kind), unit TAG tests cover the nullable-callback shape
// (cb !== null, cb ?? f, cb?.()), and against FUNC siblings
// (`StringConstructor | NumberConstructor` — the option-table
// field) closures compare by pointer identity per tag
// (unionEq), so `x === String` narrows. No restriction left.
// Promise arms follow the func rule (typeof gives no test
// against sibling data arms): only the promise-or-absent shape
// maps — `Promise<T> | undefined`, and `Promise<T> | void`
// return types whose void part became the undefined arm above.
(a.kind === "promise" && !arms.every((b) => b === a || isUnitType(b))),
)
a.kind === "date" || a.kind === "dyn" ||
a.kind === "generator",
) || !unionContainerArmsOk(arms)
) {
return null;
}
@@ -3328,8 +3310,8 @@ export function unitOnlyUnion(unions: UnionRegistry): IrType {
* answers undefined). ONE shape per channel pair — `g.next()`'s lowering,
* `.return()`, `.throw()`, the for-of desugar, and mapType's
* IteratorResult alias mapping all intern through here, so reads agree.
* Null when the combined union would be illegal (a func/set arm beside
* data arms, a map/regex/Date arm — kinds with no narrowing test): such
* Null when the combined union would be illegal (a container arm beside
* data arms, a regex/Date arm — kinds with no narrowing test here): such
* generators stay unmapped. */
export function genResultRecord(
yieldT: IrType,
@@ -3349,7 +3331,7 @@ export function genResultRecord(
return true;
}
if (
t.kind === "map" || t.kind === "regex" || t.kind === "date" ||
t.kind === "regex" || t.kind === "date" ||
t.kind === "jsval" || t.kind === "generator"
) {
return false; // no legal union arm exists for these kinds
@@ -3360,11 +3342,11 @@ export function genResultRecord(
if (!add(yieldT) || !add(retT)) return null;
byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T);
const arms = [...byKey.values()];
// func/set arms are legal only beside unit arms (no narrowing test
// against data siblings — the union rule).
// Containers keep the shared nullable-only rule. Generator function
// payloads retain their existing unit-only boundary too.
if (
arms.some(
(a) => (a.kind === "func" || a.kind === "set") && !arms.every((b) => b === a || isUnitType(b)),
!unionContainerArmsOk(arms) || arms.some(
(a) => a.kind === "func" && !arms.every((b) => b === a || isUnitType(b)),
)
) {
return null;
@@ -3400,8 +3382,8 @@ export function isUnitOnlyTsType(t: ts.Type): boolean {
}
/** IR-level `t | undefined`, canonicalized and fenced exactly like the
* ts-union branch of mapType (typeKey-sorted arms, deduplicated; map/
* regex/Date/dyn/void arms unrepresentable; a func arm IS representable — the
* ts-union branch of mapType (typeKey-sorted arms, deduplicated;
* Date/dyn/void arms unrepresentable; a func arm IS representable — the
* result is exactly the nullable-callback shape mapType's union branch
* admits, `(() => void) | undefined`) so the interned union is IDENTICAL
* to what mapping the checker's own `T | undefined` produces. */
@@ -3416,7 +3398,7 @@ export function withUndefinedArm(t: IrType, unions: UnionRegistry): IrType | nul
return { kind: "union", unionId: unions.intern(arms) };
}
if (
t.kind === "void" || t.kind === "map" || t.kind === "date" || t.kind === "dyn" ||
t.kind === "void" || t.kind === "date" || t.kind === "dyn" ||
// A bare unit field type cannot occur (units live only inside unions),
// but guard against constructing a single-arm union from one.
isUnitType(t)
@@ -4030,15 +4012,15 @@ function armHasUnionHome(arm: IrType, siblingCount: number): boolean {
switch (arm.kind) {
case "void":
case "union":
case "map":
case "set":
case "regex":
case "date":
case "generator":
case "dyn":
return false;
// Promise arms map only beside unit siblings (the promise-or-absent
// shape); a data sibling has no narrowing test against them.
// Containers map only beside unit siblings; data siblings have no
// supported runtime narrowing test against them.
case "map":
case "set":
case "promise":
return siblingCount === 0;
default:
+8 -1
View File
@@ -173,8 +173,15 @@ export function streamTypedRefEligible(t: IrType): boolean {
return t.kind === "record" || t.kind === "array" || t.kind === "bytes";
}
/** Only ancestry is needed here. Descendant links would turn a native
* structural projection into a copy of the entire cyclic class graph. */
export interface IrClassAncestry {
def: { name: string };
base: IrClassAncestry | null;
}
/** True when a class descends from a runtime stream class. */
export function streamRooted(meta: IrClassGraphNode): boolean {
export function streamRooted(meta: IrClassAncestry): boolean {
for (let current = meta.base; current; current = current.base) {
if (RUNTIME_STREAM_CLASSES.has(current.def.name)) return true;
}
+6 -11
View File
@@ -685,18 +685,13 @@ export function funcOf(params: IrType[], ret: IrType): IrType {
return { kind: "func", params, ret };
}
/** The union FUNC/SET-arm sibling rule, shared by the frontend's union
* builders and the validator: FUNC arms are valid beside ANY sibling —
* `typeof x === "function"` narrows against data arms, unit tag tests
* cover the nullable-callback shape, and between func arms closures
* compare by pointer identity per tag (unionEq), so `x === String` is the
* narrowing (the primitive-constructor tables' `StringConstructor |
* NumberConstructor` field, and LinkOptions' `false | ((s: string) =>
* string)`). A SET arm keeps the unit-only rule (no narrowing test
* against data arms). */
export function unionFuncSetArmsOk(arms: IrType[]): boolean {
/** Maps, sets and promises may share a union with null/undefined only.
* Unit tag tests can narrow nullable containers without losing identity;
* arbitrary data siblings require a separate runtime narrowing operation.
* Share this rule across checker mapping, synthesized unions and validation. */
export function unionContainerArmsOk(arms: IrType[]): boolean {
return arms.every(
(a, i) => a.kind !== "set" || arms.every((b, j) => j === i || isUnitType(b)),
(a, i) => (a.kind !== "map" && a.kind !== "set" && a.kind !== "promise") || arms.every((b, j) => j === i || isUnitType(b)),
);
}
+23 -1
View File
@@ -1,10 +1,32 @@
import { expect, test } from "vitest";
import { BOOL, F64, NULL_T, STRING, UNDEFINED_T, VOID, arrayOf, type IrExpr, type IrModule, type IrType, type IrUnionDef } from "./ir.js";
import { BOOL, F64, NULL_T, STRING, UNDEFINED_T, VOID, arrayOf, mapOf, setOf, type IrExpr, type IrModule, type IrType, type IrUnionDef } from "./ir.js";
import { deserializeModule, serializeModule } from "./serialize.js";
import { validateModule } from "./validate.js";
const loc = { file: "numeric-read.ts", start: 0, end: 0 };
test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as IrType])("nullable %j payloads preserve an explicit absence tag", (type) => {
const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [
{ id: "nullable", arms: [type, NULL_T, UNDEFINED_T] },
]);
expect(validateModule(mod)).toEqual([]);
expect(deserializeModule(serializeModule(mod))).toEqual(mod);
});
test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as IrType])("%j payloads still refuse unrelated data siblings", (type) => {
const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [
{ id: "mixed", arms: [type, STRING, UNDEFINED_T] },
]);
expect(validateModule(mod).map((error) => error.message)).toContain(`union mixed: ${type.kind} arm 0 beside non-unit arms`);
});
test("two differently typed Map payloads cannot silently share one tag test", () => {
const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [
{ id: "maps", arms: [mapOf(STRING, F64), mapOf(STRING, STRING), UNDEFINED_T] },
]);
expect(validateModule(mod).filter((error) => error.message.includes("beside non-unit arms"))).toHaveLength(2);
});
function numericReadModule(overrides: Partial<IrExpr & { kind: "arrIntrinsic" }> = {}): IrModule {
const read: IrExpr = {
kind: "arrIntrinsic", method: "getNumber",
+6 -13
View File
@@ -18,7 +18,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "./ir.js";
import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canDynCheckTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./ir.js";
import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canDynCheckTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionContainerArmsOk, URL_T, VOID } from "./ir.js";
import { BIGINT_T } from "./ir.js";
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
@@ -1780,19 +1780,12 @@ export function validateModule(mod: IrModule): IrValidationError[] {
}
u.arms.forEach((arm, i) => {
// The unit kinds (undefinedT/nullT) are valid arms — union membership
// is the ONLY place they may appear; void/union/map/dyn/jsval/date
// stay out (maps and scalar Date values have no supported union
// representation/discriminant to narrow on). Func/set arm
// sibling rules live in unionFuncSetArmsOk (shared with the frontend's
// union builders): a func arm allows unit and FUNC siblings (the
// nullable-callback shape, and the primitive-constructor tables where
// closure pointer identity per tag is the narrowing); a set arm is
// valid exactly when every other arm is a unit (the defaulted-Set-
// param ABI); func/set-beside-data stays out.
// is the ONLY place they may appear. Containers are valid beside
// unit arms: tag tests distinguish absence while the reference
// payload preserves identity. The shared rule refuses data siblings.
if (
arm.kind === "void" ||
arm.kind === "union" ||
arm.kind === "map" ||
arm.kind === "dyn" ||
arm.kind === "jsval" ||
arm.kind === "date" ||
@@ -1801,8 +1794,8 @@ export function validateModule(mod: IrModule): IrValidationError[] {
errors.push({ message: `union ${u.id}: arm ${i} is ${arm.kind}`, loc: noLoc });
}
if (
(arm.kind === "func" || arm.kind === "set") &&
!unionFuncSetArmsOk(u.arms)
(arm.kind === "map" || arm.kind === "set" || arm.kind === "promise") &&
!unionContainerArmsOk(u.arms)
) {
errors.push({ message: `union ${u.id}: ${arm.kind} arm ${i} beside non-unit arms`, loc: noLoc });
}
@@ -7481,6 +7481,24 @@
],
"diags": []
},
"<repo>/tests/corpus/3105-nullable-collections.ts": {
"order": [
"<repo>/tests/corpus/3105-nullable-collections.ts"
],
"diags": []
},
"<repo>/tests/corpus/3106-mixed-insertion-spreads.ts": {
"order": [
"<repo>/tests/corpus/3106-mixed-insertion-spreads.ts"
],
"diags": []
},
"<repo>/tests/corpus/3107-nullable-collection-lifetimes.ts": {
"order": [
"<repo>/tests/corpus/3107-nullable-collection-lifetimes.ts"
],
"diags": []
},
"<repo>/tests/corpus/400-fib.ts": {
"order": [
"<repo>/tests/corpus/400-fib.ts"
+79
View File
@@ -0,0 +1,79 @@
interface Options {
sources?: ReadonlyMap<string, string>;
names?: ReadonlySet<string>;
}
function lookup(options: Options, key: string): string {
const sources = options.sources;
if (sources === undefined) return "absent";
return sources.get(key) ?? "missing";
}
function count(options: Options): number {
return options.names?.size ?? -1;
}
function chooseMap(mode: number, value: Map<string, number>): Map<string, number> | null | undefined {
if (mode === 0) return undefined;
if (mode === 1) return null;
return value;
}
function chooseSet(mode: number, value: Set<string>): Set<string> | null | undefined {
if (mode === 0) return undefined;
if (mode === 1) return null;
return value;
}
const sources = new Map<string, string>([["first.ts", "hello"], ["second.ts", "world"]]);
const names = new Set<string>(["first.ts", "second.ts"]);
console.log(lookup({}, "first.ts"), count({}));
console.log(lookup({ sources, names }, "first.ts"), count({ sources, names }));
console.log(lookup({ sources }, "other.ts"));
const values = new Map<string, number>([["value", 7]]);
for (let mode = 0; mode < 3; mode++) {
const map = chooseMap(mode, values);
const set = chooseSet(mode, names);
console.log(mode, map === undefined, map === null, Boolean(map), typeof map);
console.log(mode, set === undefined, set === null, Boolean(set), typeof set);
console.log(map?.get("value"), set?.has("first.ts"));
const present = map ?? values;
present.set("value", (present.get("value") ?? 0) + 1);
console.log(present === values, present.size, values.get("value"));
if (set) {
set.add("third.ts");
console.log(set === names, [...set].join(","));
}
}
function defaultMap(map: Map<string, number> = new Map<string, number>()): Map<string, number> {
map.set("default", 12);
return map;
}
function defaultSet(set: Set<string> = new Set<string>()): Set<string> {
set.add("default");
return set;
}
console.log(defaultMap().get("default"), defaultMap(values) === values);
console.log(defaultSet().has("default"), defaultSet(names) === names);
const optional: (Map<string, number> | undefined)[] = [undefined, values];
for (const map of optional) {
if (map !== undefined) console.log([...map.keys()].join(","));
else console.log("no map");
}
interface Holder {
map?: Map<string, number>;
set?: Set<string>;
}
const holder: Holder = {};
holder.map = values;
holder.set = names;
console.log(holder.map === values, holder.set === names);
holder.map = undefined;
holder.set = undefined;
console.log(holder.map, holder.set);
@@ -0,0 +1,92 @@
const events: string[] = [];
function mark(value: string): string {
events.push(value);
return value;
}
const text = ["head"];
const middle = ["a", "b"];
console.log(text.push(mark("before"), ...middle, mark("after"), ...["tail"]));
console.log(text.join(","), events.join(","));
console.log(text.unshift("start", ...new Set(["x", "x", "y"]), "end"));
console.log(text.join(","));
const source = [1, 2];
const target = [0];
function mutateSource(): number {
source[0] = 10;
source.push(3);
return 7;
}
console.log(target.push(...source, mutateSource(), ...source));
console.log(target.join(","), source.join(","));
const self = [1, 2];
console.log(self.push(0, ...self, 3, ...self));
console.log(self.join(","));
const front = [1, 2];
console.log(front.unshift(0, ...front, 3, ...front));
console.log(front.join(","));
let current = [4, 5];
const original = current;
function swap(): number {
current = [8, 9];
return 6;
}
console.log(current.push(...current, swap(), ...current));
console.log(original.join(","), current.join(","));
const failed = ["original"];
function fail(): string {
throw new Error("argument failed");
}
try {
failed.push("pending", ...["spread"], fail());
} catch (error) {
if (error instanceof Error) console.log(error.message);
}
console.log(failed.join(","));
try {
failed.unshift(...["spread"], fail(), "pending");
} catch (error) {
if (error instanceof Error) console.log(error.message);
}
console.log(failed.join(","));
const sparse = new Array<number>(3);
sparse[1] = 4;
const copied: number[] = [];
console.log(copied.push(1, ...sparse, 2));
console.log(copied.length, copied[1], copied[2], copied[3], 1 in copied, 3 in copied);
console.log(copied.unshift(0, ...sparse, -1));
console.log(copied.length, copied[1], copied[2], copied[3], 1 in copied, 3 in copied);
interface Item { label: string }
const one: Item = { label: "one" };
const two: Item = { label: "two" };
const refs: Item[] = [one];
const refsSource: Item[] = [two, one];
console.log(refs.push(two, ...refsSource, one));
two.label = "changed";
console.log(refs.map((item) => item.label).join(","), refs[1] === two, refs[3] === one);
const optional: (string | undefined)[] = ["one", undefined];
const optionalTarget: (string | undefined)[] = [];
console.log(optionalTarget.push(undefined, ...optional, "last"));
console.log(optionalTarget.map((value) => value ?? "absent").join(","));
const empty: number[] = [];
const numbers: number[] = [7];
console.log(numbers.push(...empty, ...empty, 8), numbers.join(","));
console.log(numbers.unshift(6, ...empty, 5), numbers.join(","));
const booleans = [true];
console.log(booleans.unshift(false, ...booleans, true), booleans.join(","));
function receiver(): string[] {
events.push("receiver");
return text;
}
events.length = 0;
console.log(receiver().push(mark("left"), ...middle, mark("right")));
console.log(events.join(","));
@@ -0,0 +1,90 @@
interface State {
label: string;
cache?: Map<string, State>;
}
function cycle(label: string): string {
const state: State = { label };
const cache = new Map<string, State>();
state.cache = cache;
cache.set("self", state);
return state.cache.get("self")!.label;
}
for (let i = 0; i < 200; i++) cycle("temporary");
console.log(cycle("live"));
class Config {
sources?: Map<string, string>;
flags?: Set<string>;
constructor(enabled: boolean) {
if (enabled) {
this.sources = new Map<string, string>([["entry", "text"]]);
this.flags = new Set<string>(["debug"]);
}
}
read(): string {
return this.sources?.get("entry") ?? "none";
}
clear(): void {
this.sources = undefined;
this.flags = undefined;
}
}
const active = new Config(true);
const empty = new Config(false);
console.log(active.read(), empty.read(), active.flags?.has("debug"));
const saved = active.sources;
active.clear();
console.log(active.read(), saved?.get("entry"), active.flags);
function captured(): () => string {
let cache: Map<string, string> | undefined = new Map<string, string>([["key", "value"]]);
return () => {
const result = cache?.get("key") ?? "cleared";
cache = undefined;
return result;
};
}
const read = captured();
console.log(read(), read());
function* maps(): Generator<Map<string, number>, void, unknown> {
yield new Map<string, number>([["value", 3]]);
yield new Map<string, number>([["value", 9]]);
}
const iterator = maps();
const first = iterator.next();
const second = iterator.next();
const last = iterator.next();
console.log(first.done, first.value?.get("value"));
console.log(second.done, second.value?.get("value"));
console.log(last.done, last.value);
interface Options {
values?: Map<string, number>;
}
function readOptions(options: Options): number {
const selected = options.values ?? new Map<string, number>([["fallback", 11]]);
return selected.get("fallback") ?? 0;
}
const supplied = new Map<string, number>([["fallback", 23]]);
const merged: Options = { values: supplied };
console.log(readOptions({}), readOptions(merged));
console.log(merged.values === supplied);
supplied.set("fallback", 42);
console.log(readOptions(merged));
function maybe(mode: number): ReadonlyMap<string, number> | null {
return mode ? supplied : null;
}
for (let mode = 0; mode < 2; mode++) {
const candidate = maybe(mode);
console.log(candidate !== null, candidate?.get("fallback"));
if (candidate !== null) {
let total = 0;
for (const value of candidate.values()) total += value;
console.log(total);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { commentText, llvmBytes, llvmQuoted, octalByte, unsignedHex } from "../../../packages/compiler/src/backend/literals.js";
import { cCommentText, cDecl, cNumberLiteral, cStringLiteral, cType } from "../../../packages/compiler/src/backend/c/types.js";
import { f64Lit, ffiNativeTypeLl, llvmCommentText } from "../../../packages/compiler/src/backend/llvm/common.js";
import { mangleClassNew, mangleClassObj, mangleClassRelease, mangleClassRetain, mangleClassStruct, mangleField, mangleFunction, mangleGlobal, mangleLocal, mangleRecordStruct, mangleVtInstance } from "../../../packages/compiler/src/backend/mangle.js";
import { BOOL, F64, STRING, VOID, arrayOf, funcOf, mapOf, type IrType } from "../../../packages/compiler/src/ir/ir.js";
const bytes = new Uint8Array(256);
for (let i = 0; i < bytes.length; i++) bytes[i] = i;
console.log(llvmBytes(bytes));
console.log(llvmBytes(bytes, false));
console.log(cStringLiteral(Buffer.from(bytes)));
console.log(llvmBytes(new Uint8Array()));
for (let i = 0; i < 256; i++) console.log(unsignedHex(i), octalByte(i));
for (const value of [0x100, 0xffff, 0x10000, 0x10ffff, 2 ** 32, 2 ** 48 - 1, Number.MAX_SAFE_INTEGER]) {
console.log(unsignedHex(value));
}
for (const value of [-1, 0.5, NaN, Infinity]) {
try { console.log(unsignedHex(value)); }
catch (error) { if (error instanceof Error) console.log(error.name + ": " + error.message); }
}
for (const value of [0, -0, 1, -1, 0.1, Number.MIN_VALUE, Number.MAX_VALUE, Infinity, -Infinity, NaN]) {
console.log(f64Lit(value), cNumberLiteral(value));
}
for (const text of ["", "ordinary", "é日本😀", 'quote"slash\\\0', "line\nnext\u2028last", "/* comment */"]) {
console.log(llvmQuoted(text));
console.log(cStringLiteral(Buffer.from(text, "utf8")));
console.log(JSON.stringify(commentText(text)));
console.log(JSON.stringify(cCommentText(text)));
console.log(JSON.stringify(llvmCommentText(text)));
}
for (const name of ["ordinary", "x.12", "$x%y", "é日本", "😀", "nul\0", "dash-here", "a\nb"]) {
console.log(mangleFunction(name), mangleLocal(name), mangleGlobal(name), mangleField(name));
console.log(mangleClassStruct(name), mangleClassObj(name), mangleClassNew(name));
console.log(mangleClassRetain(name), mangleClassRelease(name), mangleVtInstance(name), mangleRecordStruct(name));
}
const types: IrType[] = [
F64, BOOL, STRING, VOID, arrayOf(F64), mapOf(STRING, F64),
{ kind: "set", elem: STRING }, { kind: "record", shapeId: "row" },
{ kind: "object", className: "User" }, { kind: "union", unionId: "choice" },
{ kind: "classval", className: "User" }, { kind: "bytes", elem: "u8" },
{ kind: "dyn" }, { kind: "jsval" }, { kind: "caught" },
{ kind: "promise", inner: STRING }, funcOf([STRING], F64),
{ kind: "generator", yieldT: F64, retT: STRING, nextT: VOID },
{ kind: "date" }, { kind: "bigint" }, { kind: "regex" },
{ kind: "url" }, { kind: "searchParams" }, { kind: "symbol" },
{ kind: "stats" }, { kind: "fileHandle" }, { kind: "spawnRes" },
{ kind: "child" }, { kind: "netServer" }, { kind: "netSocket" },
{ kind: "http2Session" }, { kind: "http2Stream" },
];
for (const type of types) console.log(cType(type), cDecl(type, "slot"));
console.log(ffiNativeTypeLl("f64"), ffiNativeTypeLl("bool"), ffiNativeTypeLl("u8"));
console.log(ffiNativeTypeLl("u32"), ffiNativeTypeLl("i32"), ffiNativeTypeLl("cstring"), ffiNativeTypeLl("void"));
try { console.log(ffiNativeTypeLl("bytes")); }
catch (error) { if (error instanceof Error) console.log(error.name + ": " + error.message); }
+107
View File
@@ -0,0 +1,107 @@
import { readFileSync } from "node:fs";
import { deserializeModule } from "../../../packages/compiler/src/ir/serialize.js";
import { computeTraced } from "../../../packages/compiler/src/backend/cycle-analysis.js";
import { buildClassGraph, classFieldIndex, type ClassHost } from "../../../packages/compiler/src/backend/llvm/classes.js";
import { emitLlvmLayouts } from "../../../packages/compiler/src/backend/llvm/layouts.js";
import { LlvmDebugInfo } from "../../../packages/compiler/src/backend/llvm/debug-info.js";
import { LlWalkers, type WalkerHost } from "../../../packages/compiler/src/backend/llvm/walkers.js";
import { BlockBuilder } from "../../../packages/compiler/src/backend/llvm/blocks.js";
import { f64Lit } from "../../../packages/compiler/src/backend/llvm/common.js";
import { mangleClassObj } from "../../../packages/compiler/src/backend/mangle.js";
import { type IrType } from "../../../packages/compiler/src/ir/ir.js";
interface Request {
bits: 32 | 64;
clones: string[];
classObjects: string[];
sources: { file: string; text: string }[];
writers?: IrType[];
textUnions?: string[];
joinUnions?: string[];
indent?: boolean;
}
try {
const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8"));
const request = JSON.parse(readFileSync(process.argv[3]!, "utf8")) as Request;
const declarations = new Set<string>();
const strings: string[] = [];
const literals: string[] = [];
const units: { union: string; tag: number }[] = [];
let needsOom = false;
let needsBadTag = false;
const traced = computeTraced(mod);
const host: ClassHost & WalkerHost = {
declare: (decl) => { declarations.add(decl); },
needOom: () => { needsOom = true; },
needBadTag: () => { needsBadTag = true; },
internLiteral: (text) => {
let index = literals.indexOf(text);
if (index === -1) { index = literals.length; literals.push(text); }
return `@native_literal_${index}`;
},
sizeType: request.bits === 32 ? "i32" : "i64",
cycleColorOffset: request.bits === 32 ? 12 : 16,
tracedShapes: traced.shapes,
tracedUnions: traced.unions,
recordsById: new Map((mod.records ?? []).map((record) => [record.id, record])),
unionsById: new Map((mod.unions ?? []).map((union) => [union.id, union])),
recordCloneShapes: new Set(request.clones),
unitInstanceRef: (union, tag) => {
let index = units.findIndex((unit) => unit.union === union && unit.tag === tag);
if (index === -1) {
index = units.length;
units.push({ union, tag });
}
return `@native_unit_${index}`;
},
cstr: (text) => {
let index = strings.indexOf(text);
if (index === -1) { index = strings.length; strings.push(text); }
return `@native_string_${index}`;
},
};
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const graph = buildClassGraph(mod, functions);
const classObjects = new Map(request.classObjects.map((name) => [name, { nameSym: `@name_${mangleClassObj(name)}` }]));
const typeName = (type: IrType): string => type.kind === "void" ? "void" : type.kind === "f64" || type.kind === "date" ? "double" : type.kind === "bool" ? "i1" : "ptr";
const layouts = emitLlvmLayouts(host, mod, graph, classObjects, functions, typeName);
const walkers = new LlWalkers(host);
const helpers: string[] = [];
for (const type of request.writers ?? []) helpers.push(walkers.jsonWriteHelper(type));
for (const union of request.textUnions ?? []) helpers.push(walkers.unionToStrHelper(union));
for (const union of request.joinUnions ?? []) helpers.push(walkers.unionJoinHelper(union));
if (request.indent) helpers.push(walkers.jsonIndentHelper());
const classes = [...graph.values()].map((meta) => ({
name: meta.def.name, root: meta.root.def.name, base: meta.base?.def.name ?? null,
children: meta.children.map((child) => child.def.name), pre: meta.pre, post: meta.post,
hierarchy: meta.hierarchy,
fields: meta.def.fields.map((field) => ({ name: field.name, index: classFieldIndex(meta, field.name).index })),
slots: meta.slots.map((slot) => ({ method: slot.method, declarer: slot.declarer.def.name, function: slot.fn.name })),
}));
const debug = new LlvmDebugInfo(mod.sourceFile, new Map(request.sources.map((source) => [source.file, source.text])), request.bits, mod.unions ?? []);
const bindings: string[] = [];
for (const global of mod.globals ?? []) bindings.push(debug.global(global) ?? "");
for (const fn of mod.functions) {
const scope = debug.function(fn);
bindings.push(scope ?? "");
for (const local of fn.locals) {
const param = fn.params.findIndex((param) => param.localId === local.id);
const entry = debug.local(local, scope, param + 1, fn.captures?.some((capture) => capture.localId === local.id) ?? false);
bindings.push(entry === null ? "" : JSON.stringify(entry));
}
for (const stmt of fn.body) bindings.push(debug.location(stmt.loc, scope) ?? "");
}
const blocks = new BlockBuilder();
blocks.countedLoop(f64Lit(3), (index, next) => {
blocks.line(`call void @consume(double ${index})`);
blocks.br(next);
blocks.line("unreachable text must be dropped");
});
blocks.terminate("ret void");
console.log(JSON.stringify({ layouts, classes, declarations: [...declarations], strings, literals, units, needsOom, needsBadTag, helpers, walkers: walkers.defs, bindings, debug: debug.render(), block: blocks.render() }));
} catch (error) {
if (error instanceof Error) console.log(error.name + ": " + error.message);
else console.log("unexpected thrown value");
process.exitCode = 1;
}
@@ -0,0 +1,226 @@
import { BOOL, F64, STRING, UNDEFINED_T, VOID, arrayOf, funcOf, mapOf, type IrClassDef, type IrFunction, type IrModule, type IrType } from "../../packages/compiler/src/ir/ir.js";
import { IR_VERSION } from "../../packages/compiler/src/ir/serialize.js";
export interface EmissionRequest {
bits: 32 | 64;
clones: string[];
classObjects: string[];
sources: { file: string; text: string }[];
writers?: IrType[];
textUnions?: string[];
joinUnions?: string[];
indent?: boolean;
}
export interface EmissionCase {
name: string;
module: IrModule;
request: EmissionRequest;
contains: string[];
}
const loc = { file: "native layouts/日本.ts", start: 0, end: 12 };
const obj = (name: string): IrType => ({ kind: "object", className: name });
const rec = (id: string): IrType => ({ kind: "record", shapeId: id });
const optional: IrType = { kind: "union", unionId: "optional" };
export function emissionRequest(bits: 32 | 64 = 64): EmissionRequest {
return { bits, clones: [], classObjects: [], sources: [] };
}
export function emissionModule(): IrModule {
return {
irVersion: IR_VERSION, sourceFile: loc.file, entry: "main",
functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }],
};
}
function method(name: string, member: string, result: IrType = F64): IrFunction {
return {
name: `%${name}.${member}`,
params: [{ localId: "this.0", name: "this", type: obj(name) }],
locals: [{ id: "this.0", name: "this", type: obj(name), mutable: false }],
returnType: result, body: [], loc,
};
}
function cls(name: string, base?: string): IrClassDef {
return { name, ...(base ? { base } : {}), fields: [{ name: "value", type: F64 }], loc };
}
export function emissionCases(): EmissionCase[] {
const cases: EmissionCase[] = [];
const add = (name: string, module: IrModule, contains: string[], request = emissionRequest()): void => {
cases.push({ name, module, contains, request });
};
add("empty module", emissionModule(), []);
const scalar = emissionModule();
scalar.records = [{ id: "scalar", fields: [{ name: "enabled", type: BOOL }, { name: "value", type: F64 }] }];
add("scalar record allocation", scalar, ["type { i64, i8, double }", "@calloc", "@free"]);
const refs = emissionModule();
refs.records = [{ id: "references", fields: [
{ name: "text", type: STRING }, { name: "values", type: arrayOf(F64) },
{ name: "index", type: mapOf(STRING, F64) }, { name: "bytes", type: { kind: "bytes", elem: "u8" } },
] }];
add("reference fields", refs, ["@scr_str_release", "@scr_arr_release", "@scr_map_release", "@scr_bytes_release"]);
const recursive = emissionModule();
recursive.records = [{ id: "node", fields: [{ name: "children", type: arrayOf(rec("node")) }, { name: "text", type: STRING }] }];
add("recursive records", recursive, ["@scr_cyc_alloc", "@scr_cyc_on_dead", "@scr_arr_trace", "@scr_cyc_free"]);
const union = emissionModule();
union.records = [{ id: "linked", fields: [{ name: "next", type: optional }] }];
union.unions = [{ id: "optional", arms: [rec("linked"), UNDEFINED_T] }];
add("cycles through a nullable union", union, ["@scr_union_trace", "@scr_union_release"]);
const overflow = emissionModule();
overflow.records = [{ id: "dictionary", fields: [{ name: "label", type: STRING }], indexValue: rec("dictionary") }];
add("overflow-map cycles", overflow, ["[key: string]", "@scr_map_trace", "@scr_map_release"]);
const tuple = emissionModule();
tuple.records = [{ id: "tuple", tuple: true, fields: [{ name: "0", type: STRING }, { name: "1", type: F64 }] }];
add("tuple layout", tuple, ["type { i64, ptr, double }"]);
const wide = emissionModule();
wide.records = [{ id: "wide", fields: Array.from({ length: 18 }, (_, i) => ({ name: `f${i}`, type: i % 2 ? STRING : F64 })) }];
const clones = emissionRequest();
clones.clones = ["wide"];
add("wide record clone", wide, ["clone", "@scr_str_retain"], clones);
const small = emissionModule();
small.records = [{ id: "small", fields: [{ name: "value", type: F64 }] }];
const smallClones = emissionRequest();
smallClones.clones = ["small"];
add("inline clone threshold", small, ["type { i64, double }"], smallClones);
const exotic = emissionModule();
exotic.records = [{ id: "unicode😀", fields: [{ name: "break\nline\0*/é", type: STRING }] }];
add("source text escaping", exotic, ["break\\u000aline\\u0000*/é", "_xd83d__xde00_"]);
const standalone = emissionModule();
standalone.classes = [cls("Alone")];
add("standalone class", standalone, ["; class Alone { value }", "@sc_new_Alone"]);
const hierarchy = emissionModule();
const root = cls("Base"); root.methods = ["read"];
const child = cls("Child", "Base"); child.methods = ["read"];
child.fields.push({ name: "label", type: STRING });
hierarchy.classes = [root, child];
hierarchy.functions.push(method("Base", "read"), method("Child", "read"));
add("virtual dispatch and inherited fields", hierarchy, ["vtable: hierarchy rooted at Base [read]", "@sc_reld_Child", "dispatches"]);
const forest = structuredClone(hierarchy);
forest.classes!.push(cls("Unrelated"), cls("Leaf", "Child"));
forest.classes![3]!.fields = [...child.fields];
add("class forest numbering", forest, ["class Leaf (vt at 1)", "class Unrelated { value }"]);
const abstract = emissionModule();
const abstractRoot = cls("Abstract");
abstractRoot.abstract = true; abstractRoot.methods = ["read"]; abstractRoot.abstractMethods = ["read"];
const concrete = cls("Concrete", "Abstract"); concrete.methods = ["read"];
abstract.classes = [abstractRoot, concrete];
abstract.functions.push(method("Concrete", "read"));
add("abstract slot implementation", abstract, ["ptr null", "@sc_f__x25_Concrete_read"]);
const incomplete = emissionModule();
const abstractChild = cls("AbstractChild", "Abstract"); abstractChild.abstract = true;
incomplete.classes = [structuredClone(abstractRoot), abstractChild];
add("abstract hierarchy without implementation", incomplete, ["vtable: hierarchy rooted at Abstract"]);
const siblingSlots = emissionModule();
const left = cls("Left", "Root"); left.methods = ["read"];
const leftLeaf = cls("LeftLeaf", "Left"); leftLeaf.methods = ["read"];
const right = cls("Right", "Root"); right.methods = ["read"];
const rightLeaf = cls("RightLeaf", "Right"); rightLeaf.methods = ["read"];
siblingSlots.classes = [cls("Root"), left, leftLeaf, right, rightLeaf];
for (const name of ["Left", "LeftLeaf", "Right", "RightLeaf"]) siblingSlots.functions.push(method(name, "read"));
add("same method name in sibling subtrees", siblingSlots, ["[read, read]"]);
const classCycle = emissionModule();
classCycle.classes = [
{ name: "First", fields: [{ name: "other", type: obj("Second") }], loc },
{ name: "Second", fields: [{ name: "other", type: obj("First") }], loc },
];
add("mutually recursive classes", classCycle, ["@sc_trace_First", "@sc_trace_Second", "@scr_cyc_alloc"]);
const nullable = emissionModule();
nullable.unions = [{ id: "optional", arms: [mapOf(STRING, F64), UNDEFINED_T] }];
nullable.classes = [{ name: "Options", fields: [{ name: "map", type: optional }], loc }];
add("undefined field initialization", nullable, ["@native_unit_0", "starts undefined"]);
const dynField = emissionModule();
dynField.classes = [{ name: "Island", fields: [{ name: "value", type: { kind: "jsval" } }], loc }];
add("island field initialization", dynField, ["@scr_jsval_undefined", "@scr_jsval_release"]);
const emitter = emissionModule();
emitter.classes = [{ name: "%EventEmitter", runtime: true, fields: [], loc }, { name: "Emitter", base: "%EventEmitter", fields: [], loc }];
add("event emitter prefix", emitter, ["ScrEmitter prefix at 2", "@scr_emitter_reg_drop", "@native_string_0"]);
const stream = emissionModule();
stream.classes = [
{ name: "%EventEmitter", runtime: true, fields: [], loc },
{ name: "%Readable", runtime: true, base: "%EventEmitter", fields: [], loc },
{ name: "Reader", base: "%Readable", fields: [], loc },
];
add("stream prefix", stream, ["ScrStream prefix at 2", "@scr_stream_st_release", "@scr_stream_st_trace"]);
const construct = emissionModule();
construct.classes = [cls("Construct")];
const ctor = method("Construct", "constructor", VOID);
ctor.params.push({ localId: "n.0", name: "n", type: F64 }, { localId: "s.0", name: "s", type: STRING }, { localId: "b.0", name: "b", type: BOOL });
construct.functions.push(ctor);
const constructRequest = emissionRequest(); constructRequest.classObjects = ["Construct"];
add("constructor thunk ABI", construct, ["double %a0, ptr %a1, i1 %a2", "%ScrClassObj"], constructRequest);
const generic = structuredClone(construct);
generic.classes = [cls("Family"), { ...cls("Construct", "Family"), genericOf: "Family" }];
add("generic class object family interval", generic, ["construct thunk Construct", "vtable: hierarchy rooted at Family"], constructRequest);
const captured = emissionModule();
captured.records = [{ id: "closure", fields: [{ name: "fn", type: funcOf([], VOID) }] }];
add("closure cycles", captured, ["@scr_closure_trace_v", "@scr_closure_release"]);
const writer = structuredClone(recursive);
const writerRequest = emissionRequest();
writerRequest.writers = [rec("node"), arrayOf(rec("node")), F64, BOOL, STRING, rec("node")];
writerRequest.indent = true;
add("recursive JSON writers and indentation", writer, ["@scr_jb_enter", "@scr_jb_put_f64"], writerRequest);
const tupleRequest = emissionRequest(); tupleRequest.writers = [rec("tuple")];
add("tuple JSON writer", tuple, ["@scr_jb_putc"], tupleRequest);
const dictionaryRequest = emissionRequest(); dictionaryRequest.writers = [rec("dictionary")];
add("overflow JSON writer", overflow, ["@scr_map_keys_js_order"], dictionaryRequest);
const textUnion = emissionModule();
textUnion.unions = [{ id: "text", arms: [BOOL, F64, STRING, { kind: "nullT" }, UNDEFINED_T] }];
const textRequest = emissionRequest();
textRequest.writers = [{ kind: "union", unionId: "text" }];
textRequest.textUnions = ["text", "text"];
textRequest.joinUnions = ["text"];
add("union JSON, string and join helpers", textUnion, ["switch i32", "@scr_str_retain"], textRequest);
const debug = emissionModule();
const debugText = "function example(value: number) {\r\n const outer = 1;\n { const inner = 2; }\n}\n";
const scope = { file: loc.file, start: 0, end: debugText.length };
const innerScope = { file: loc.file, start: debugText.indexOf("{ const"), end: debugText.indexOf("; }") + 3 };
const position = (name: string) => ({ file: loc.file, start: debugText.indexOf(name), end: debugText.indexOf(name) + name.length });
debug.functions[0]!.loc = scope;
debug.functions[0]!.params = [{ localId: "value.0", name: "value", type: F64 }];
debug.functions[0]!.locals = [
{ id: "value.0", name: "value", type: F64, mutable: false, source: { loc: position("value"), scope } },
{ id: "outer.0", name: "outer", type: STRING, mutable: true, boxed: true, source: { loc: position("outer"), scope } },
{ id: "inner.0", name: "inner", type: F64, mutable: true, boxed: true, tdz: true, source: { loc: position("inner"), scope: innerScope } },
];
debug.functions[0]!.body = [{ kind: "return", value: null, loc: position("inner") }];
debug.globals = [{ id: "global", name: "global", type: BOOL, mutable: false, source: { loc: position("outer"), scope } }];
const debugRequest = emissionRequest(); debugRequest.sources = [{ file: loc.file, text: debugText }];
add("debug bindings and nested scopes", debug, ["DILexicalBlock", 'name: "ScrBox"', 'name: "value", arg: 1'], debugRequest);
// Every layout case also exercises the actual wasm32 size_t and cycle
// header offsets. The textual result must match the Node production stage.
return cases.flatMap((item) => [item, {
...item, name: item.name + " (32-bit)",
request: { ...item.request, bits: 32 as const },
contains: item.contains.map((text) => text.replaceAll("i64", "i32")),
}]);
}
+151
View File
@@ -0,0 +1,151 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { analyze, compile, compileC, deserializeModule, serializeModule } from "@scriptc/compiler";
import { emitLlvmModule } from "../../packages/compiler/src/backend/llvm/emitter.js";
import { scalarizeNumericRecords } from "../../packages/compiler/src/ir/scalar-records.js";
import { everyStmtList } from "../../packages/compiler/src/ir/traverse.js";
import { moduleUsesCopying, moduleUsesDynInvoke, moduleUsesInspect, moduleUsesRegex, type IrModule } from "../../packages/compiler/src/ir/ir.js";
import { emissionCases, emissionModule, emissionRequest, type EmissionRequest } from "./self-hosting-emission-cases.js";
const root = fileURLToPath(new URL("../..", import.meta.url));
const entry = join(root, "tests/fixtures/self-hosting/llvm-layouts.ts");
const options = { cwd: root, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 };
interface Emission {
layouts: {
records: { typeDefs: string[]; defs: string[] };
classes: { typeDefs: string[]; defs: string[] };
classObjects: string[];
};
classes: { name: string; root: string; base: string | null; children: string[]; pre: number; post: number; hierarchy: boolean; fields: { name: string; index: number }[]; slots: { method: string; declarer: string; function: string }[] }[];
declarations: string[];
walkers: string[];
strings: string[];
units: { union: string; tag: number }[];
needsOom: boolean;
bindings: string[];
debug: string;
block: string;
}
test("the production layout and metadata stage lowers statically", () => {
const { coverage } = analyze(entry, { dynamic: false });
expect(coverage.preflightFailed).toBe(false);
expect(coverage.diagnostics).toEqual([]);
expect(coverage.stats.statementsTotal).toBeGreaterThan(1200);
expect(coverage.stats.statementsFailed).toBe(0);
expect(coverage.stats.statementsIsland).toBe(0);
expect(coverage.stats.functionsSkipped).toBe(0);
});
for (const backend of ["c", "llvm"] as const) {
test(`native LLVM layout and metadata emission (${backend})`, async () => {
const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-emission-"));
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const executable = (name: string): string => join(dir, name + (process.platform === "win32" ? ".exe" : ""));
try {
const built = await compile(entry, { outDir: dir, outPath: executable("stage"), backend, dynamic: false, optimization: "dev", sanitize });
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect(built.backend).toBe(backend);
const run = (mod: IrModule, request: EmissionRequest, name: string, status = 0): string => {
const input = join(dir, "input.json");
const config = join(dir, "config.json");
writeFileSync(input, serializeModule(mod));
writeFileSync(config, JSON.stringify(request));
const args = [input, config];
const oracle = spawnSync(process.execPath, ["--import", "tsx", entry, ...args], options);
const native = spawnSync(built.binaryPath, args, options);
for (const result of [oracle, native]) {
expect(result.error, name).toBeUndefined();
expect(result.signal, `${name}: ${result.stderr}`).toBeNull();
expect(result.status, `${name}: ${result.stdout}\n${result.stderr}`).toBe(status);
expect(result.stderr.toString(), name).toBe("");
}
if (status === 0) expect(JSON.parse(native.stdout.toString()), name).toEqual(JSON.parse(oracle.stdout.toString()));
else expect(native.stdout, name).toEqual(oracle.stdout);
return native.stdout.toString();
};
for (const item of emissionCases()) {
const text = run(item.module, item.request, item.name);
const emitted = JSON.parse(text) as Emission;
const definitions = [...emitted.declarations, ...emitted.layouts.records.typeDefs, ...emitted.layouts.records.defs, ...emitted.layouts.classes.typeDefs, ...emitted.layouts.classes.defs, ...emitted.layouts.classObjects, ...emitted.walkers, emitted.debug].join("\n");
for (const expected of item.contains) expect(definitions, item.name).toContain(expected);
if (item.name.startsWith("class forest")) {
expect(emitted.classes.map((meta) => [meta.name, meta.root, meta.pre, meta.post])).toEqual([
["Base", "Base", 0, 2], ["Child", "Base", 1, 2], ["Unrelated", "Unrelated", 3, 3], ["Leaf", "Base", 2, 2],
]);
}
}
const invalid = emissionModule();
invalid.classes = [{ name: "Child", base: "Missing", fields: [], loc: { file: invalid.sourceFile, start: 0, end: 0 } }];
expect(run(invalid, emissionRequest(), "missing base", 1)).toContain("undeclared base class Missing");
const absent = emissionRequest(); absent.classObjects = ["Missing"];
expect(run(emissionModule(), absent, "missing class object", 1)).toContain("class object for unknown class Missing");
// A native bootstrap must preserve emitter refusals too: unsupported
// requests must not quietly return partial helper definitions.
const invalidRequests: { request: EmissionRequest; message: string }[] = [
{ request: { ...emissionRequest(), writers: [{ kind: "map", key: { kind: "string" }, value: { kind: "f64" } }] }, message: "jsonStringify:map" },
{ request: { ...emissionRequest(), writers: [{ kind: "record", shapeId: "Missing" }] }, message: "jsonStringify of unknown shape Missing" },
{ request: { ...emissionRequest(), writers: [{ kind: "union", unionId: "Missing" }] }, message: "jsonStringify of unknown union Missing" },
{ request: { ...emissionRequest(), textUnions: ["Missing"] }, message: "ToString of unknown union Missing" },
{ request: { ...emissionRequest(), joinUnions: ["Missing"] }, message: "join of unknown union Missing" },
];
for (const { request, message } of invalidRequests) {
expect(run(emissionModule(), request, message, 1)).toContain(message);
}
const unsupported = emissionModule();
unsupported.unions = [{ id: "containers", arms: [{ kind: "array", elem: { kind: "string" } }, { kind: "undefinedT" }] }];
expect(run(unsupported, { ...emissionRequest(), textUnions: ["containers"] }, "unsupported union stringification", 1)).toContain("unionToStr:array");
for (const source of ["711-inheritance-dispatch.ts", "756-cycle-inheritance.ts", "3103-scalar-record-nested-control-flow.ts"]) {
const sourcePath = join(root, "tests/corpus", source);
const irPath = join(dir, "frontend.json");
const frontend = await compile(sourcePath, { outDir: dir, outPath: irPath, outputKind: "ir", dynamic: false });
if (!frontend.ok) throw new Error(frontend.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = scalarizeNumericRecords(deserializeModule(readFileSync(irPath, "utf8")));
const request = emissionRequest();
request.sources = [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }];
for (const fn of mod.functions) everyStmtList(fn.body, { stmt: () => true, expr: (expr) => {
if (expr.kind === "recordClone" && expr.type.kind === "record" && !request.clones.includes(expr.type.shapeId)) request.clones.push(expr.type.shapeId);
return true;
} });
const native = JSON.parse(run(mod, request, source)) as Emission;
// Splice the native-generated sections into the real program. The
// full emitter still runs on Node; these type and helper definitions
// are supplied by the compiled production stage under test.
let llvm = emitLlvmModule(mod);
let supplied = 0;
for (const lines of [native.layouts.records.typeDefs, native.layouts.records.defs, native.layouts.classes.typeDefs, native.layouts.classes.defs]) {
const section = lines.join("\n");
if (section.length === 0) continue;
expect(llvm, source).toContain(section);
llvm = llvm.replace(section, `; native layout stage\n${section}`);
supplied++;
}
expect(supplied, source).toBeGreaterThan(0);
const path = join(dir, "generated.ll");
writeFileSync(path, llvm);
const outPath = executable("generated");
await compileC({ cPath: path, outPath, sanitize, inspect: moduleUsesInspect(mod), dynInvoke: moduleUsesDynInvoke(mod), regex: moduleUsesRegex(mod), copying: moduleUsesCopying(mod) });
const oracle = spawnSync(process.execPath, [sourcePath], options);
const program = spawnSync(outPath, [], options);
for (const result of [oracle, program]) {
expect(result.error, source).toBeUndefined();
expect(result.signal, `${source}: ${result.stderr}`).toBeNull();
expect(result.status, `${source}: ${result.stderr}`).toBe(0);
}
expect(program.stdout, source).toEqual(oracle.stdout);
expect(program.stderr, source).toEqual(oracle.stderr);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
+1 -1
View File
@@ -21,7 +21,7 @@ test("the complete IR validator lowers statically without skipped functions", ()
// Keep these outside the ordinary corpus: they import implementation files
// beyond the fixture directory, which the corpus oracle cache does not hash.
// Node executes the actual TS modules through tsx's .js → .ts resolution.
for (const component of ["source-locations", "ir-collections", "ir-types", "ir-control-flow"]) {
for (const component of ["source-locations", "ir-collections", "ir-types", "ir-control-flow", "emitter-literals"]) {
for (const backend of ["c", "llvm"] as const) {
test(`self-hosting ${component}: ${backend} matches Node`, async () => {
const entry = join(root, "tests/fixtures/self-hosting", `${component}.ts`);