Files
scriptc/packages/compiler/src/backend/c/c-emitter.ts
T

2171 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { InternalCompilerError } from "../../errors.js";
import { SourceLocations } from "../source-locations.js";
/* IR → C. Three-address style: every IR expression lands in a fresh C temp.
* Verbose (clang -O2 erases it) but buys three things: short-circuit
* emission is trivially correct, reference counting has one mechanical
* hook point, and the output shape is already close to a CFG lowering.
*
* RC ownership discipline (must match docs/ir.md):
* - every refcounted temp (isRefCounted kinds) holds an owned (+1) reference;
* - varDecl/assign/return/call-argument MOVE that ownership (the temp is
* struck from its release list); everything else borrows;
* - each statement releases its remaining refcounted temps when it ends;
* - each scope releases the refcounted locals declared in it when it exits;
* - callees own their params and release them on exit (callers pass +1);
* - `return` first releases pending temps and every in-scope refcounted
* local.
*
* RC dispatch is type-directed: frames and scopes carry {name, type} so a
* release always knows which scr_*_release to call. `isRefCounted` in
* ir.ts is the membership test — no `kind === "string"` checks here.
*
* The generated C is a debugging surface: locals keep their TS names inside
* the mangled form and every statement carries a `source line` comment.
*/
import type {
IrBytesElem,
IrGlobal,
IrRecordShape,
IrExpr,
IrFfiCallbackParamClass,
IrFfiCallbackParam,
IrFfiImport,
IrFfiReleaseParam,
IrFfiReturnClass,
IrFfiValueParamClass,
IrFunction,
IrLocal,
IrModule,
IrStmt,
IrType,
IrUnionDef,
SrcLoc,
} from "../../ir/ir.js";
import { ffiCallbackType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, POINTER_KINDS, type PointerKind, RUNTIME_EMITTER_CLASS, VOID } from "../../ir/ir.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import type { IntegerRanges } from "../../ir/integer-ranges.js";
import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js";
import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js";
import {
mangleAsyncSpawn,
mangleGenSpawn,
mangleClassObj,
mangleField,
mangleGlobal,
mangleFnClosure,
mangleFunction,
mangleLocal,
mangleRawParam,
mangleVtSlot,
mangleWrapper,
} from "../mangle.js";
import { cCommentText, cFnPtrCast, cType, releaseCallC, cStringLiteral, cDecl, cNumberLiteral } from "./types.js";
import { computeTraced } from "../cycle-analysis.js";
import { computeMayThrow } from "./may-throw.js";
import { unionTruthyHelper, unionEqHelper, unionToStrHelper, unionJoinHelper, jsonWriteHelper, jsonIndentHelper, dynMatchHelper, dynCheckHelper, dynFuncBoxHelper, dynToStrHelper, caughtToDynHelper, toDynHelper, recordKeyGetHelper, recordKeySetHelper } from "./walkers.js";
import { type StructShape, type VtSlot, ClassMeta, emitStructDefs, vtEntriesFor, vtSlotParams, emitVtableDecls, emitVtableInstances, emitVtAdapterDefs, emitHierarchyClassHelpers, emitClassObjs, emitCtorThunkDefs, errorVtStampLines, emitterVtStampLines, streamVtStampLines, traceAdapterC, traceArgC, boxNewC, arrNewC } from "./shapes.js";
import { emitAsyncScaffolding, childDataThunkFor, childExitThunkFor, childExitSignalThunkFor, execFileThunkFor, ipcMessageThunkFor, ipcSendThunkFor, closeBindThunkFor, connectResThunkFor, connectSockThunkFor, closeOverrideWrapFor, cryptoBytesThunkFor, dgramMsgThunkFor, dnsLookupThunkFor, fsRenameThunkFor, genResultThunkFor, netLookupAnswerThunkFor, emitterInvokeThunkFor, streamCbThunkFor, streamDataThunkFor, raceAdapterFor, resolveThunkFor, sniAnswerThunkFor, zlibBytesThunkFor } from "./async.js";
import { emitNpmEmbedding, islandAdapter, islandTypedAdapter } from "./island.js";
import { emitFunction, emitBlock, emitStmts, emitStmt, emitTryCatch, emitSwitch, mergeBrace, emitBranchInto, emitCondition } from "./stmts.js";
import { emitExpr, liveDynRefAdapter as buildLiveDynRefAdapter, type StreamTypedRefAdapter } from "./exprs.js";
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
export interface CEmitOptions {
/** Exact frontend sources for dev-build #line directives. */
debugSources?: ReadonlyMap<string, string>;
/** Library archive assembly may move the volatile identity getters into a
* separate translation unit. Public/direct emission keeps them by default. */
emitLibraryIdentity?: boolean;
}
export function emitCModule(
mod: IrModule,
sourceText?: string,
options: CEmitOptions = {},
): string {
return new CEmitter(scalarizeNumericRecords(mod), sourceText, options).emit();
}
// Box construction moved onto CEmitter (boxNewC method): obj-kind boxes now
// also carry the payload type's trace entry point, which is type-directed
// through the emitter's cycle analysis.
/** A declared C value with its IR type — the unit frames and scopes track
* so releases can be type-directed. */
export interface Temp {
name: string;
type: IrType;
}
type TruthyPointerType = Extract<IrType, {
kind: Exclude<PointerKind, "string" | "union" | "dyn" | "jsval" | "caught">
}>;
/** A scope entry: a refcounted local, either held directly or through a
* capture box (boxed locals release their BOX; the box frees its contents). */
export interface ScopeEntry extends Temp {
boxed?: boolean;
}
function ffiNativeTypeC(
cls: IrFfiCallbackParamClass | IrFfiValueParamClass | IrFfiReturnClass,
): string {
switch (cls) {
case "f64":
return "double";
case "f32":
return "float";
case "bool":
case "u8":
return "uint8_t";
case "i8":
return "int8_t";
case "u16":
return "uint16_t";
case "i16":
return "int16_t";
case "u32":
return "uint32_t";
case "i32":
return "int32_t";
case "cstring":
return "const char *";
case "string":
case "bytes":
case "mutable-bytes":
throw new InternalCompilerError(`emitter bug: span class '${cls}' has no scalar C type`);
case "void":
return "void";
}
}
function ffiCallbackNativeParamsC(
callback: IrFfiCallbackParam["callback"] | IrFfiReleaseParam["callback"],
named: boolean,
): string[] {
return callback.params.flatMap((param, i): string[] => {
if (isFfiContextParam(param)) return [`void *${named ? "sc_ctx" : ""}`.trim()];
if (param === "string" || param === "bytes") {
return [
`const uint8_t *${named ? `sc_a${i}` : ""}`.trim(),
`size_t${named ? ` sc_a${i}_len` : ""}`,
];
}
return [`${ffiNativeTypeC(param)}${named ? ` sc_a${i}` : ""}`];
});
}
function ffiCallbackPointerTypeC(
callback: IrFfiCallbackParam["callback"] | IrFfiReleaseParam["callback"],
): string {
const ret = ffiNativeTypeC(callback.returns);
const params = ffiCallbackNativeParamsC(callback, false);
return `${ret} (*)(${params.length > 0 ? params.join(", ") : "void"})`;
}
function ffiCallbackDummyC(callback: IrFfiCallbackParam["callback"]): string {
return callback.returns === "void" ? "" : "0";
}
export class CEmitter {
private readonly debug: SourceLocations | null;
sourceLoc: SrcLoc | null = null;
readonly lines: string[] = [];
indent = 0;
tempCounter = 0;
/** Interned string literals: UTF-8 text → static symbol name. */
readonly literals = new Map<string, string>();
/** Kind-specialized typed-array access helpers actually used by this
* program. Keeping these in the generated TU means unrelated binaries do
* not pay even debug/link metadata for byte-loop fast paths. */
readonly bytesElementHelpers = new Set<`${"get" | "set"}:${IrBytesElem}:${"f64" | "u64"}`>();
/** Interned unit-armed union instances: "unionId:tag" → static symbol.
* A unit arm (undefined/null) has no payload, so every instance of one
* (union, tag) pair is identical — ONE immortal (rc == SIZE_MAX) static
* serves them all. Immortals are skipped by retain/release and by the
* cycle collector's child filter (SCR_CYC_SKIP), so the missing cycle
* header is fine even when a traced container points at one. */
readonly unitInstances = new Map<string, string>();
/** Interned regex literals: "<flags>/<pattern>" → static symbol. One
* immortal (rc == SIZE_MAX) ScrRegex per distinct (pattern, flags) pair —
* the bytecode slot starts NULL and the runtime compiles it lazily on
* first use. The source/flags strings ride the ordinary literal table. */
readonly regexInstances = new Map<string, string>();
/** Interned tagged-template strings objects: per-site key → symbol +
* cooked spans. One immortal (rc == SIZE_MAX) ScrArr of interned string
* literals per template-literal SITE — the spec's per-occurrence
* identity: the same site evaluated twice hands the tag the same array,
* two sites never share even with identical text. */
readonly templateStringsInstances = new Map<string, { sym: string; cooked: string[] }>();
/** Class objects (classes as first-class values): className → static
* symbol. One immortal ScrClassObj per class some classRef names —
* preorder interval baked as constants, the .name string in the literal
* table, and a per-class construct thunk (void *sc_ct_*) newValue
* dispatches through. Registered during body emission (the regex
* pattern); the statics and thunks are assembled around the bodies. */
readonly classObjs = new Map<string, string>();
/** Statement frames own expression temps and sequence locals until cleanup. */
frames: ScopeEntry[][] = [];
/** Stack of scopes: refcounted locals (with types) declared in each. */
scopes: ScopeEntry[][] = [];
/** The function being emitted: local table (for boxedness) and whether
* each local arrived through the closure environment (env captures are
* borrowed — never declared, never released here). */
currentLocals = new Map<string, IrLocal>();
captureIds = new Set<string>();
/** Canonical byte-loop induction locals currently represented by an
* unsigned integer shadow. Ordinary number reads widen the shadow back to
* f64; direct byte indices consume it without a conversion round trip. */
integerLoopBindings = new Map<string, string>();
integerRanges: IntegerRanges = new Map();
/** Declared functions referenced as values: each needs an env-signature
* wrapper + an interned immortal closure (so `f === f` holds). */
readonly fnValues = new Set<string>();
/** Emitted ref-kind resolve thunks for new Promise, interned per inner
* typeKey → thunk symbol. */
readonly resolveThunks = new Map<string, string>();
readonly genResThunks = new Map<string, string>();
/** Emitted child exit adapters, interned per union id (childExitThunkFor). */
readonly childExitThunks = new Map<string, string>();
/** Emitted child-stream data adapters, interned per union id
* (childDataThunkFor). */
readonly childDataThunks = new Map<string, string>();
/** Emitted bound-close adapters, interned per union id
* (closeBindThunkFor). */
readonly closeBindThunks = new Map<string, string>();
/** Emitted close-override wrappers, interned per (union id, ret kind)
* (closeOverrideWrapFor). */
readonly closeOverrideWraps = new Map<string, string>();
/** Emitted dgram message adapters, interned per rinfo shape id
* (dgramMsgThunkFor). */
readonly dgramMsgThunks = new Map<string, string>();
/** Emitted dns.lookup callback adapters, interned per union id + param
* count (dnsLookupThunkFor). */
readonly dnsLookupThunks = new Map<string, string>();
/** Emitted fs.rename callback adapters, interned per callback type. */
readonly fsRenameThunks = new Map<string, string>();
/** Emitted SNI answer-closure thunks, interned per cb func-type key
* (sniAnswerThunkFor). */
readonly sniAnswerThunks = new Map<string, string>();
/** Emitted net.connect lookup answer thunks, interned per cb func-type
* key (netLookupAnswerThunkFor). */
readonly netLookupAnswerThunks = new Map<string, string>();
/** Emitted EventEmitter listener invoke adapters, interned per listener
* func-type key (emitterInvokeThunkFor). */
readonly emitterInvokeThunks = new Map<string, string>();
/** Emitted stream option-callback invoke adapters (read/write/final/
* destroy/transform/flush — the leading-`this` closures), interned per
* (kind, callback func-type) key (streamCbThunkFor). */
readonly streamCbThunks = new Map<string, string>();
/** Emitted stream completion-callback closure fns (the `callback` a
* user's write/final/destroy/transform/flush receives and calls),
* interned per (kind, done func-type) key (streamDoneFnFor). */
readonly streamDoneFns = new Map<string, string>();
/** Emitted CONNECT-listener union-socket adapters, interned per cb
* func-type key (connectSockThunkFor). */
readonly connectSockThunks = new Map<string, string>();
/** Emitted Promise.race fulfillment adapters, interned per
* `entryInner=>resultInner` typeKey pair (raceAdapterFor). */
readonly raceThunks = new Map<string, string>();
/** setTimeout appeared somewhere: main must run the event loop even in
* programs with no async functions. */
usesTimers = false;
readonly fnByName = new Map<string, IrFunction>();
/** Manifest-bound native imports, used by ffiCall emission. */
readonly ffiByName = new Map<string, IrFfiImport>();
/** One C-ABI trampoline per manifest callback entry. Raw function
* pointers additionally get a distinct TLS context slot, so two
* callbacks with the same signature never alias each other's closure. */
readonly ffiCallbackAdapters: Map<string, FfiCallbackAdapter>;
/** Module-level constant consulted per ffiCall: with a retained
* descriptor anywhere in the manifest, every native call is a
* pending-exception checkpoint (may-throw derives the same fact from
* the same helper). */
readonly ffiHasRetainedCallback: boolean;
readonly ffiHasForeignCallback: boolean;
readonly globalsById = new Map<string, IrGlobal>();
readonly constantNumericTables: ReadonlyMap<string, ConstantNumericTable>;
readonly unionsById = new Map<string, IrUnionDef>();
/** Active optional-chain bind temps, by chain id (chainRecv reads). */
readonly chainTemps = new Map<string, Temp>();
readonly recordsById = new Map<string, IrRecordShape>();
/** Record shapes whose bodies emitted recordClone. Filled while function
* bodies emit, before emitStructDefs assembles per-shape helpers. */
readonly recordCloneShapes = new Set<string>();
/** Type-directed JSON walkers, interned per typeKey — one serializer per
* type used in jsonStringify position (sc_jw_*), one match predicate
* (sc_dm_*) and one checked builder (sc_dc_*) per type used in dynCheck
* position. Emitted as prototypes + definitions after the struct block
* (they reference struct types, per-shape RC helpers, and each other, so
* the prototypes make definition order irrelevant). */
readonly jsonWriters = new Map<string, string>();
/** The pretty-print re-indenter (type-independent, interned once):
* emitted on the first `JSON.stringify(v, null, space)` site. */
jsonIndentFn: string | null = null;
/** The dyn ToString pair (type-independent, interned once): emitted on
* the first String(unknown) / `${unknown}` site. */
dynToStrFn: string | null = null;
/** The caught→dyn converter (type-independent, interned once): emitted
* on the first catch binding flowing into an `unknown` slot. */
caughtToDynFn: string | null = null;
/** Interned per-union helpers: unionId → emitted function name. */
readonly unionTruthyFns = new Map<string, string>();
readonly unionEqFns = new Map<string, string>();
readonly unionToStrFns = new Map<string, string>();
readonly unionJoinFns = new Map<string, string>();
readonly dynMatchers = new Map<string, string>();
readonly dynBuilders = new Map<string, string>();
/** Static→dyn converters (sc_td_*), per typeKey; dynamic-keyed record
* read helpers (sc_rkg_*), per shapeId|result typeKey; dynamic-keyed
* write helpers (sc_rks_*), per shapeId. */
readonly toDynFns = new Map<string, string>();
/** ReadableStream.from typed-array element adapters, per element
* typeKey. The runtime retains the original array and calls one of these
* for each pull, preserving iterator-time reads without teaching the
* runtime the compiler's program-specific record/union layouts. */
readonly streamFromArrayAdapters = new Map<string, string>();
/** Identity-preserving static→dyn capsules for program classes and Web
* APIs whose values remain directly observable. */
readonly liveDynRefAdapters = new Map<
string,
{ snapshot: string; commit: string }
>();
/** Runtime-arm dispatchers for live references whose static type is a
* union. Mutable arms become typed capsules; scalar/unit arms retain the
* ordinary static-to-dyn conversion. */
readonly liveDynUnionRefAdapters = new Map<string, string>();
readonly dynPromiseAdapters = new Map<string, string>();
/** The checked-dynamic function boundary's per-signature helpers (see
* walkers.ts): call thunks (sc_dfk_*), box builders (sc_dfb_*),
* and dynCheck adapters (sc_dfa_*), each per func typeKey. */
readonly dynFuncThunks = new Map<string, string>();
readonly dynFuncBoxes = new Map<string, string>();
readonly dynFuncAdapters = new Map<string, string>();
/** dyn-promise settle adapters (sc_pda_*), per INNER typeKey: convert a
* typed fulfillment payload into the boxed destination's dyn payload
* (scr_dyn_new_promise_adapting's callback — toDynHelper's promise arm). */
readonly promiseDynAdapters = new Map<string, string>();
readonly recordKeyGetFns = new Map<string, string>();
readonly unionWidenFns = new Map<string, string>();
readonly recordKeySetFns = new Map<string, string>();
readonly walkerProtos: string[] = [];
readonly walkerDefs: string[] = [];
/** Island host-call adapters, interned per (arity, void-ness): the one
* uniform shape scr_jsval_from_closure calls — unpack the cell array,
* call the closure through its real ABI (which CONSUMES its params, so
* each cell is retained in), return the +1 result cell or NULL for void.
* Definitions ride walkerDefs (self-contained over the closure ABI). */
readonly islandAdapters = new Map<string, string>();
/** TYPED island host-call adapters, interned per full signature (param
* typeKeys + return classification): each incoming engine argument
* converts to the param's static type through the exit machinery before
* the closure runs — see islandTypedAdapter (island.ts). */
readonly islandTypedAdapters = new Map<string, string>();
/** Enclosing break/continue targets. An unlabeled `break` binds to the
* innermost loop-or-switch entry (labeled BLOCK entries are skipped); an
* unlabeled `continue` searches inward-out for the innermost LOOP,
* skipping switches and blocks. A LABELED jump binds to the entry whose
* `labels` contains its label: `break lbl` jumps to the target's
* endLabel (loops allocate one lazily via `usedEnd` — a C `break` only
* exits the innermost loop), `continue lbl` to the target loop's
* continueLabel. Loops: `continueLabel` is null when C `continue` is
* correct (unlabeled while/forOf); for/do-while loops need a goto label
* so `continue` still runs the update/condition, and LABELED loops of
* every shape allocate one up front (a labeled continue from a nested
* loop needs a goto). Switches are emitted as goto chains — never as C
* `switch` — so a C `break`/`continue` inside an emitted switch region
* still binds to the enclosing C loop; a break targeting the switch
* itself jumps to `endLabel` instead. Labeled blocks carry only an
* endLabel (`break lbl` is the only jump that can target them).
* `scopeDepth` = scopes.length at entry — a break/continue releases
* every scope pushed after that before jumping. `frameDepth` =
* frames.length at entry: statements the jump exits may still hold
* pending refcounted temps in their frames (a switch's discriminant,
* most notably), whose normal end-of-statement releases sit on the
* fall-through path the jump bypasses — the jump releases every frame
* pushed after the target's own before jumping. */
jumpTargets: (
| {
kind: "loop";
continueLabel: string | null;
usedContinue: boolean;
endLabel: string | null;
usedEnd: boolean;
labels?: string[] | undefined;
scopeDepth: number;
frameDepth: number;
finallyDepth: number;
}
| { kind: "switch"; endLabel: string; usedEnd: boolean; labels?: string[] | undefined; scopeDepth: number; frameDepth: number; finallyDepth: number }
| { kind: "block"; endLabel: string; usedEnd: boolean; labels: string[]; scopeDepth: number; frameDepth: number; finallyDepth: number }
)[] = [];
labelCounter = 0;
readonly returnTypeByFn = new Map<string, IrType>();
lineStarts: number[] | null = null;
/** May-throw analysis results (computeMayThrow): pending-exception checks
* are emitted only after calls that can actually raise. */
readonly mayThrow: Set<string>;
readonly indirectMayThrow: boolean;
/** Enclosing try contexts, innermost last — the compile-time analogue of
* the jump-target stack for UNWINDING: a pending check (or `throw`) inside
* a try releases frames/scopes down to the recorded depths and jumps to
* `label` (the catch, or the exception-path finally) instead of returning
* out of the function. Purely compile-time: entering a try emits no code. */
tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] =
[];
/** Enclosing try-with-finally regions, innermost last. Returns use the
* central pending-return copies; break/continue inline the crossed bodies
* with the same truncated ownership and exception context as LLVM. */
finallyStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number; tryDepth: number; body: IrStmt[] }[] =
[];
/** Scope entry protecting sc_pret while a pending-return finally copy
* runs. A return in that copy replaces the old completion: it evaluates
* first, releases the old slot, then removes this protection from its
* own unwind path before dispatching. */
pendingReturnScopeIndex: number | null = null;
/** Return type of the function being emitted — the unwind path returns a
* dummy of this type (never read: callers check the pending flag first). */
currentReturnType: IrType = VOID;
/** The generator channels of the function being emitted (null outside
* generator bodies): yieldExpr emission reads them, and emitTryCatch's
* catch prologue emits the GENRET sentinel re-unwind exactly here. */
currentGenerator: { yieldT: IrType; nextT: IrType } | null = null;
/** Cycle-capable shapes ("object:<name>" / "record:<id>") and unions
* (unionId): instances get a cycle header + emitted trace/teardown, and
* fields/payloads of these types are visited by container traces.
* Computed as a greatest fixpoint in the constructor — see there. */
readonly tracedShapes = new Set<string>();
readonly tracedUnions = new Set<string>();
/** The class graph (single inheritance): base/children links, hierarchy
* membership (extends anywhere ⇒ vtable word + dynamic release), the
* whole-program preorder numbering behind O(1) instanceof, and the
* per-hierarchy virtual slot lists (root classes only). Computed once in
* the constructor. */
readonly classMeta = new Map<string, ClassMeta>();
/** Method names with at least one may-throw implementation: a
* virtualCall's pending check keys on this (same over-approximation as
* computeMayThrow's callee cover). */
readonly mayThrowMethods = new Set<string>();
/** Vtable slot adapters to define after the function signatures (they
* call sc_f_* bodies): dedupe key "implClass.method". */
readonly vtAdapters = new Map<string, { impl: ClassMeta; slot: VtSlot }>();
constructor(
readonly mod: IrModule,
sourceText?: string,
private readonly options: CEmitOptions = {},
) {
this.debug = options.debugSources === undefined ? null : new SourceLocations(options.debugSources);
this.constantNumericTables = findConstantNumericTables(mod);
this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []);
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
for (const fn of mod.functions) {
this.returnTypeByFn.set(fn.name, fn.returnType);
this.fnByName.set(fn.name, fn);
}
for (const entry of mod.ffiImports ?? []) {
this.ffiByName.set(entry.name, entry);
}
const mt = computeMayThrow(mod);
this.mayThrow = mt.fns;
this.indirectMayThrow = mt.indirect;
for (const g of mod.globals ?? []) this.globalsById.set(g.id, g);
for (const u of mod.unions ?? []) this.unionsById.set(u.id, u);
for (const r of mod.records ?? []) this.recordsById.set(r.id, r);
// The class graph. Link base/children, number the forest in preorder
// (roots and children in module class order — deterministic), and
// compute each hierarchy's virtual slots: a class's method gets a slot
// iff no ancestor declares it (root-most) AND some strict descendant
// redeclares it — never-overridden methods stay direct calls
// everywhere (whole-program devirtualization).
for (const cls of mod.classes ?? []) {
const meta = new ClassMeta(cls);
this.classMeta.set(cls.name, meta);
}
for (const meta of this.classMeta.values()) {
if (meta.def.base === undefined) continue;
const base = this.classMeta.get(meta.def.base);
if (!base) throw new InternalCompilerError(`emitter bug: undeclared base class ${meta.def.base}`);
meta.base = base;
base.children.push(meta);
}
let preCounter = 0;
const number = (meta: ClassMeta, root: ClassMeta): void => {
meta.root = root;
meta.pre = preCounter++;
for (const c of meta.children) number(c, root);
meta.post = preCounter - 1; // max pre in the subtree (inclusive)
};
for (const meta of this.classMeta.values()) {
if (meta.base === null) number(meta, meta);
// The runtime emitter class is ALWAYS a hierarchy member: ScrEmitter
// carries its vtable word whether or not the program subclasses it.
meta.hierarchy = meta.base !== null || meta.children.length > 0 ||
meta.def.name === RUNTIME_EMITTER_CLASS;
}
const declares = (m: ClassMeta, method: string): boolean =>
m.def.methods?.includes(method) ?? false;
const declaredBelow = (m: ClassMeta, method: string): boolean =>
m.children.some((c) => declares(c, method) || declaredBelow(c, method));
const collectSlots = (m: ClassMeta, root: ClassMeta, seen: Map<string, number>): void => {
for (const method of m.def.methods ?? []) {
let inherited = false;
for (let a = m.base; a; a = a.base) inherited = inherited || declares(a, method);
if (!inherited && declaredBelow(m, method)) {
let fn = this.fnByName.get(`%${m.def.name}.${method}`);
if (!fn && m.def.abstractMethods?.includes(method)) {
// An ABSTRACT declarer has no function; the slot's ABI
// signature comes from any concrete descendant implementation
// (the frontend's override exactness makes them identical).
// No concrete implementation anywhere in the subtree means no
// instance can dispatch the slot (only abstract classes
// declare it, and abstract classes never instantiate) — skip.
const findImpl = (c: ClassMeta): IrFunction | undefined => {
for (const child of c.children) {
const f = declares(child, method) && !child.def.abstractMethods?.includes(method)
? this.fnByName.get(`%${child.def.name}.${method}`)
: undefined;
const found = f ?? findImpl(child);
if (found) return found;
}
return undefined;
};
fn = findImpl(m);
if (!fn) continue;
}
if (!fn) throw new InternalCompilerError(`emitter bug: missing method function %${m.def.name}.${method}`);
// Sibling branches can each own a slot for the same method name
// (each root-most in its own subtree — mixin layers make this
// routine): the member name disambiguates by occurrence.
const occurrence = seen.get(method) ?? 0;
seen.set(method, occurrence + 1);
root.slots.push({ method, declarer: m, fn, member: mangleVtSlot(method, occurrence) });
}
}
for (const c of m.children) collectSlots(c, root, seen);
};
for (const meta of this.classMeta.values()) {
if (meta.base === null && meta.hierarchy) collectSlots(meta, meta, new Map());
}
for (const cls of mod.classes ?? []) {
for (const m of cls.methods ?? []) {
if (this.mayThrow.has(`%${cls.name}.${m}`)) this.mayThrowMethods.add(m);
}
}
const traced = computeTraced(mod);
for (const shape of traced.shapes) this.tracedShapes.add(shape);
for (const union of traced.unions) this.tracedUnions.add(union);
if (sourceText !== undefined) {
this.lineStarts = [0];
for (let i = 0; i < sourceText.length; i++) {
if (sourceText.charAt(i) === "\n") this.lineStarts.push(i + 1);
}
}
}
emit(): string {
const body: string[] = [];
// Async-generator spawn wrappers need their type-directed result
// builders even when user code only creates and drops the iterator.
// Register them before the unit-instance table flushes below.
for (const fn of this.mod.functions) {
if (!fn.async || fn.generator === undefined) continue;
genResultThunkFor(
this,
{
kind: "generator",
async: true,
yieldT: fn.generator.yieldT,
retT: fn.returnType,
nextT: fn.generator.nextT,
},
fn.generator.resultType,
);
}
// Function bodies are emitted first (into this.lines) so the literal
// table is complete; the file is then assembled around them.
for (const fn of this.mod.functions) {
this.emitFunction(fn);
// Large compiler functions can emit more lines than JavaScript's
// argument-count limit, particularly with sanitizer cleanup paths.
for (const line of this.lines) body.push(line);
this.lines.length = 0;
}
const out: string[] = [
`/* Generated by scriptc from ${this.mod.sourceFile}. Do not edit. */`,
`#include "scr_runtime.h"`,
`#include <math.h>`,
`#include <stdio.h>`,
`#include <stdlib.h>`,
``,
];
out.push(...this.emitBytesElementHelpers());
for (const table of this.constantNumericTables.values()) {
out.push(
`static const double ${table.symbol}[] = { ${table.values.map(cNumberLiteral).join(", ")} };`,
`static inline double ${table.symbol}_get(const ScrArr *a, double i) {`,
// Range checks precede the conversion: NaN, infinities and large
// numbers must never reach a C float-to-integer conversion.
` if (a != NULL && i >= 0.0 && i < ${table.values.length}.0) {`,
` size_t index = (size_t)i;`,
` if ((double)index == i) return ${table.symbol}[index];`,
` }`,
` return scr_arr_get_number(a, i);`,
`}`, ``,
);
}
// Struct defs render into their own buffer BEFORE the unit-instance
// table flushes: class newFns point undefined-armed union fields at
// interned unit instances (fields start as JS's undefined, not NULL),
// so the instances they intern must both exist in the map by flush
// time and be DEFINED earlier in the file than the newFn bodies.
const structDefs: string[] = [];
this.emitStructDefs(structDefs);
for (const [text, sym] of this.literals) {
const bytes = Buffer.from(text, "utf8");
out.push(
`static struct { size_t rc; size_t len; size_t cap; char data[${bytes.length + 1}]; } ${sym} =`,
` { SIZE_MAX, ${bytes.length}, ${bytes.length}, ${cStringLiteral(bytes)} };`,
);
}
if (this.literals.size > 0) out.push("");
for (const [key, sym] of this.unitInstances) {
// One immortal instance per unit-armed (union, tag): tag set, payload
// slot and RC entry points zero — retain/release/collector all skip
// rc == SIZE_MAX, so these never join the RC audit or a trace walk.
const [unionId, tag] = key.split(":");
out.push(
`static ScrUnion ${sym} = { .rc = SIZE_MAX, .tag = ${tag} }; /* ${unionId} unit arm */`,
);
}
if (this.unitInstances.size > 0) out.push("");
for (const line of structDefs) out.push(line); // program-sized: never spread
for (const [key, sym] of this.regexInstances) {
// One immortal ScrRegex per (pattern, flags) literal, pointing at the
// interned source/flags strings. `.bc` starts NULL: the runtime
// compiles the pattern lazily on first use and caches it here (and
// frees it at exit), so unexecuted regexes cost nothing. NOT const —
// the bc slot mutates.
const sep = key.indexOf("/");
const flags = key.slice(0, sep);
const pattern = key.slice(sep + 1);
const src = this.internLiteral(pattern);
const fl = this.internLiteral(flags);
const safe = cCommentText(`/${pattern}/${flags}`);
out.push(
`static ${this.mod.lib?.threadInstances === true ? "_Thread_local " : ""}ScrRegex ${sym} = { .rc = SIZE_MAX, .source = (ScrStr *)&${src}, ` +
`.flags = (ScrStr *)&${fl}, .bc = NULL }; /* ${safe} */`,
);
}
if (this.regexInstances.size > 0) out.push("");
for (const [, { sym, cooked }] of this.templateStringsInstances) {
// One immortal ScrArr per tagged-template site: the data slots point
// at the interned cooked-string literals (address constants — fully
// static, no lazy init; reads retain immortal strings, a no-op).
// len == cap and nothing ever mutates it (TemplateStringsArray is
// ReadonlyArray, tsc rejects the mutating spellings).
const slots = cooked.map((s) => `(void *)&${this.internLiteral(s)}`).join(", ");
out.push(
`static void *${sym}_data[${cooked.length}] = { ${slots} };`,
`static ScrArr ${sym} = { .rc = SIZE_MAX, .len = ${cooked.length}, .cap = ${cooked.length}, ` +
`.elem = SCR_ELEM_STR, .elem_retain = NULL, .elem_release = NULL, .elem_trace = NULL, ` +
`.data = (uint64_t *)${sym}_data };`,
);
}
if (this.templateStringsInstances.size > 0) out.push("");
const embedded = this.mod.embedded;
emitNpmEmbedding(this, out);
const globals = this.mod.globals ?? [];
// Thread-instanced library state (abi.instance_per_thread): module
// globals, run-once guards, and the regex literal caches below live in
// thread-local storage — one full instance per embedder thread,
// matching the runtime objects compiled with -DSCR_THREAD_INSTANCES.
// Immutable interned data (string literals, unit arms, template
// arrays, vtables) stays shared.
const tl = this.mod.lib?.threadInstances === true ? "_Thread_local " : "";
for (const g of globals) {
// File-scope statics: zero/NULL-initialized, assigned by %init
// functions, released (refcounted ones) before the RC audit runs.
out.push(`static ${tl}${cDecl(g.type, mangleGlobal(g.id))}; /* ${g.name} */`);
}
if (globals.length > 0) out.push("");
// Outbound native FFI declarations. string/bytes each expand from one
// scriptc value to a borrowed pointer+length pair. Format-2 callbacks
// and their independently positioned contexts are exact pointer slots.
// Library callback channels reuse ffiCall IR but are NOT direct symbol
// imports: their profile names are registration keys and TypeScript
// bindings only, and call sites dispatch through the runtime slot. Do
// not emit extern declarations for them (besides inventing undefined
// symbols, a valid TS channel name such as `int` is a C keyword).
const libraryCallbackNames = new Set(this.mod.lib?.callbacks?.map((cb) => cb.name) ?? []);
const directFfiImports = (this.mod.ffiImports ?? []).filter(
(entry) => !libraryCallbackNames.has(entry.name),
);
for (const entry of directFfiImports) {
const params = entry.params.flatMap((param): string[] => {
if (isFfiCallbackParam(param) || isFfiReleaseParam(param)) {
return [ffiCallbackPointerTypeC(param.callback)];
}
if (isFfiContextParam(param)) return ["void *"];
if (param === "mutable-bytes") return ["uint8_t *", "size_t"];
return param === "string" || param === "bytes"
? ["const uint8_t *", "size_t"]
: [ffiNativeTypeC(param)];
});
const ret = ffiNativeTypeC(entry.returns);
out.push(`extern ${ret} ${entry.symbol}(${params.length > 0 ? params.join(", ") : "void"});`);
}
if (directFfiImports.length > 0) out.push("");
for (const fn of this.mod.functions) out.push(this.signature(fn) + ";");
this.emitFfiCallbackDefs(out);
// Class objects (classes as values): construct-thunk prototypes plus
// the immortal statics that take their addresses — after the function
// signatures (the thunks call sc_new_*/the constructors), before
// anything that references &sc_co_*.
emitClassObjs(this, out);
// Vtable slot adapters: prototyped with the vtables (emitStructDefs),
// defined here where the method bodies they call are declared.
this.emitVtAdapterDefs(out);
// Wrappers + interned closures for declared functions used as values.
// Placed after the forward declarations (they call sc_f_*) and before
// the bodies (which reference &sc_fc_*).
this.emitAsyncScaffolding(out);
for (const name of this.fnValues) {
const fn = this.fnByName.get(name)!;
const params = ["ScrClosure *sc_env", ...fn.params.map((p) => cDecl(p.type, mangleLocal(p.localId)))];
const call = `${this.callTargetC(name)}(${fn.params.map((p) => mangleLocal(p.localId)).join(", ")})`;
const retType = fn.generator ? "ScrGen *" : fn.async ? "ScrPromise *" : cType(fn.returnType);
out.push(
``,
`static ${retType}${retType.endsWith("*") ? "" : " "}${mangleWrapper(name)}(${params.join(", ")}) {`,
` (void)sc_env;`,
fn.returnType.kind === "void" && !fn.async && !fn.generator ? ` ${call};` : ` return ${call};`,
`}`,
`static struct { size_t rc; void *fn; size_t ncaps; ScrBox *props; } ${mangleFnClosure(name)} =`,
` { SIZE_MAX, (void *)&${mangleWrapper(name)}, 0, NULL };`,
);
}
// Construct-thunk definitions (prototyped with the class objects
// above): they call sc_new_* and the constructors, both declared.
emitCtorThunkDefs(this, out);
// Type-directed JSON walkers (jsonStringify serializers, dynCheck
// matchers/builders), interned per type during body emission above.
if (this.walkerProtos.length > 0) {
out.push("");
for (const line of this.walkerProtos) out.push(line);
out.push("");
for (const line of this.walkerDefs) out.push(line);
}
// Loop-appended, never spread: `body` scales with the PROGRAM (a large
// embedded graph emits hundreds of thousands of lines), and a spread
// push passes every line as a call argument — the engine's stack
// overflows long before memory matters.
out.push("");
for (const line of body) out.push(line);
if (this.mod.lib !== undefined) {
// LIBRARY mode: no main(), no scr_init/scr_lib_init, no event
// loop — the profile-declared external symbols instead. Everything
// above is unchanged (still all internal linkage).
this.emitLibEntries(out, globals);
return out.join("\n");
}
const refGlobals = globals.filter((g) => isRefCounted(g.type));
// Interned function-value closures are IMMORTAL (rc == SIZE_MAX), so
// an own-property table Object.defineProperties hung on one would
// outlive the RC audit — release it with the globals. Only emitted
// when the dispatch unit is even linked (defineProps is the only
// writer).
const fnValueProps = moduleUsesDynInvoke(this.mod) ? [...this.fnValues] : [];
if (refGlobals.length > 0 || fnValueProps.length > 0) {
out.push(`static void sc_release_globals(void) {`);
for (const g of refGlobals) {
out.push(` ${releaseCallC(g.type, mangleGlobal(g.id))};`);
}
for (const name of fnValueProps) {
out.push(
` if (${mangleFnClosure(name)}.props) { scr_box_release(${mangleFnClosure(name)}.props); ${mangleFnClosure(name)}.props = NULL; }`,
);
}
out.push(`}`, ``);
}
const asyncEntry = this.fnByName.get(this.mod.entry)?.async === true;
const hasAsync = this.mod.functions.some((f) => f.async);
// Generator programs run the loop too (an empty pass when nothing is
// pending): its exit accounting notes still-suspended generator
// fibers as abandoned, so the RC audit downgrades exactly like the
// async loop-exhaustion story.
const hasGenerators = this.mod.functions.some((f) => f.generator !== undefined);
// Embedded npm code can leave island promise chains pending when %main
// returns (a package function's async work) — the loop's io hook
// drains the engine's job queue at quiescence, so npm-importing
// programs always run the loop, like Node always runs its own.
const usesIsland = embedded !== undefined && embedded.modules.length > 0;
const snapshotsTlsCa =
moduleUsesTls(this.mod) ||
moduleUsesTlsCa(this.mod) ||
moduleEmbedsBuiltin(this.mod, "node:https") ||
moduleEmbedsBuiltin(this.mod, "node:tls");
// A pending module root normally selects Node's exit status 13, but
// an already-failed node:test run or an embedded process.exitCode has
// higher precedence. Keep this expression shared with the ordinary
// successful epilogue so both paths consult the same program verdict.
const usesNodeTest = moduleUsesNodeTest(this.mod);
const programExitUsesIsland = !usesNodeTest && usesIsland;
const programExitCode = usesNodeTest
? "scr_test_exit_code()"
: usesIsland
? "scr_island_exit_code()"
: "scr_exit_code_hint_get()";
// Exit listeners can read MODULE GLOBALS directly (test/common's
// runCallChecks over its mustCallChecks ledger — an interned top-level
// closure, no capture boxes keeping anything alive), so they must run
// BEFORE sc_release_globals — the atexit half alone would fire after
// main freed them (observed use-after-free). scr_run_exit_listeners
// is idempotent (scr_exit_ran), so the atexit becomes a no-op; the
// code argument is the hint the failure reporters maintain — exactly
// what the atexit path would have passed. With a retained FFI
// descriptor the inline call is required even with nothing to
// release: listeners must beat the atexit FFI ledger sweep (a
// listener may legitimately release or pump a registration), and
// only the inline call orders ahead of every atexit handler. Plain
// event programs with neither keep the atexit path, so their
// listener timing is unchanged.
const needsRelease = refGlobals.length > 0 || fnValueProps.length > 0;
const runExitListeners =
moduleUsesProcessEvents(this.mod) && (needsRelease || this.ffiHasRetainedCallback)
? "scr_run_exit_listeners((double)scr_exit_code_hint_get()); "
: "";
const exitCleanup = `${runExitListeners}${needsRelease ? "sc_release_globals(); " : ""}`;
const releaseGlobals = needsRelease
? ` ${runExitListeners}sc_release_globals();`
: runExitListeners !== ""
? ` ${runExitListeners.trim()}`
: ` /* no refcounted globals */`;
const uncaught = (indent: string, releaseTop = false) => [
`${indent}if (scr_exc_pending()) {`,
`${indent} scr_exc_print_uncaught();`,
`${indent} ${releaseTop ? "scr_promise_release(sc_top); " : ""}` +
`${exitCleanup}return 1;`,
`${indent}}`,
];
out.push(
// Real argc/argv feed the library's interned process.argv (see
// scr_lib_init — lazy, so argv-free programs allocate nothing).
`int main(int argc, char **argv) {`,
` scr_init();`,
// The builtin error classes' preorder intervals are program-dependent
// (they share this module's class-forest numbering, so instanceof and
// the uncaught printer's Error-range test agree between runtime-made
// and compiled error objects) — stamp them before any code runs.
...this.errorVtStampLines(),
// The runtime emitter vtable's interval, when the program touches
// node:events (the class def rides the module exactly then).
...emitterVtStampLines(this),
// The runtime stream vtables' intervals, when the program touches
// node:stream (the emitter story — instanceof and dynamic teardown
// both dispatch through them).
...streamVtStampLines(this),
// Node reads NODE_EXTRA_CA_CERTS and the referenced file during
// process initialization. Native fetch performs the same idempotent
// install from scr_fetch_install.
...(snapshotsTlsCa ? [` scr_tls_ca_install();`] : []),
` scr_lib_init(argc, argv);`,
// Fetch-referencing programs register the native fetch bridge before any
// island entry (the engine's lazy boot consults it): the ONLY
// reference to scr_fetch.c, so fetch-free builds never compile or
// link it (native-toolchain.ts gates on the same predicate). moduleUsesFetch is
// true only for fetch-referencing embedded graphs and for USER-code
// fetch (the island-backed ambient's globalGet) — both boot the
// engine before the global is read.
...(moduleUsesFetch(this.mod) ? [` scr_fetch_install();`] : []),
// Embedded graphs that import node:zlib register the island's zlib
// bridge before any island entry — the ONLY reference to
// scr_zlib_island.c (native-toolchain.ts compiles it on the same predicate), so
// zlib-free dynamic builds keep the island's clear refusal.
...(moduleEmbedsBuiltin(this.mod, "node:zlib") ? [` scr_zlib_island_install();`] : []),
// Embedded graphs that import node:http/https register the island's
// http client bridge (scr_net_island.c — native-toolchain.ts compiles it and the
// socket units on the same predicate; native-fetch builds also
// register it from scr_fetch_install, idempotently).
...(moduleEmbedsBuiltin(this.mod, "node:http") || moduleEmbedsBuiltin(this.mod, "node:https")
? [` scr_net_island_install();`]
: []),
// Event-surface programs (signal/exit listeners, stdin events) fill
// the loop's nullable event hooks before %main — the events unit
// (scr_events.c) links only when this line is emitted (native-toolchain.ts gates
// on the same predicate, like fetch).
...(moduleUsesProcessEvents(this.mod) ? [` scr_events_install();`] : []),
...(moduleUsesChildProcess(this.mod) ? [` scr_child_dyn_install();`] : []),
// Net-surface programs fill the loop's net hooks before %main — the
// net unit (scr_net.c) links only when this line is emitted (native-toolchain.ts
// gates on the same predicate, like events). The dyn-install twin
// stamps the netSocket handle-dispatch ops into the dyn core so
// sockets can cross the checked-dynamic boundary (SCR_DYN_HANDLE).
...(moduleUsesNet(this.mod) ? [` scr_net_install();`, ` scr_net_dyn_install();`] : []),
// Http-surface programs additionally stamp the httpReq/httpRes
// handle-dispatch ops (scr_http.c links exactly when this line is
// emitted — native-toolchain.ts's http gate).
...(moduleUsesHttpServer(this.mod) ? [` scr_http_dyn_install();`] : []),
// http2-surface programs stamp the h2 session/stream handle-dispatch
// ops (scr_http2.c links exactly when this line is emitted — native-toolchain.ts's
// http2 gate).
...(moduleUsesHttp2(this.mod) ? [` scr_http2_dyn_install();`] : []),
// Stream-surface programs fill the loop's stream hook (the deferred
// next-tick emissions) before %main — scr_stream.c links only when
// this line is emitted (native-toolchain.ts gates on the same predicate).
...(moduleUsesStream(this.mod) ? [` scr_stream_install();`] : []),
// Dgram/dns-surface programs fill the loop's dgram hooks the same
// way — scr_dgram.c links only when this line is emitted.
...(moduleUsesDgram(this.mod) ? [` scr_dgram_install();`] : []),
// fs.watch programs fill the loop's watch hooks the same way —
// scr_watch.c links only when this line is emitted.
...(moduleUsesFsWatch(this.mod) ? [` scr_watch_install();`] : []),
...(this.ffiHasForeignCallback ? [` scr_ffi_install();`] : []),
// The embedded npm tables must be registered before %main: the %init
// functions it calls import from them. Static data only — the engine
// still boots lazily, on the first island entry. Compressed module
// text (island.ts stores big sources as raw DEFLATE) needs the
// inflater installed first — scr_zlib.c joins the link on the same
// moduleEmbedsCompressedNpm predicate (index.ts's zlib switch), so
// compression-free dynamic builds keep their exact link line.
...(embedded && embedded.modules.length > 0
? [
...(moduleEmbedsCompressedNpm(this.mod)
? [` scr_island_set_inflate(scr_zlib_inflate_exact);`]
: []),
` scr_island_modules(sc_npm_modules, ${embedded.modules.length}, ` +
`${embedded.edges.length > 0 ? "sc_npm_edges" : "NULL"}, ${embedded.edges.length});`,
]
: []),
...(asyncEntry
? [` ScrPromise *sc_top = ${mangleAsyncSpawn(this.mod.entry)}();`]
: [` ${mangleFunction(this.mod.entry)}();`]),
// Uncaught exception from top-level code: Node exits 1.
...(this.mayThrow.has(this.mod.entry) && !asyncEntry ? uncaught(" ") : []),
// The event loop runs to exhaustion (microtasks before timers). A
// throw escaping a timer callback and unhandled promise rejections
// both exit 1, like Node.
...(hasAsync || hasGenerators || this.usesTimers || usesIsland || this.ffiHasForeignCallback
? [
` bool sc_loop_rejection = scr_loop_run(${asyncEntry ? "sc_top" : "NULL"});`,
...uncaught(" ", asyncEntry),
` if (sc_loop_rejection) {`,
` scr_discard_unhandled_rejections();`,
...(asyncEntry ? [` scr_promise_release(sc_top);`] : []),
` ${exitCleanup}return 1;`,
` }`,
...(asyncEntry
? [
` int sc_top_status = scr_promise_finish_top_level(sc_top);`,
` if (sc_top_status == 1) {`,
// Earlier-checkpoint rejections were decided inside the
// loop. The fatal module verdict suppresses unrelated
// rejections from THIS checkpoint, exactly Node's order.
` scr_discard_unhandled_rejections();`,
` scr_promise_rethrow_top_level(sc_top);`,
` scr_promise_release(sc_top);`,
` scr_exc_print_uncaught();`,
` ${exitCleanup}return 1;`,
` }`,
` scr_promise_release(sc_top);`,
]
: []),
` if (scr_report_unhandled_rejections()) {`,
` ${exitCleanup}return 1;`,
` }`,
...(asyncEntry
? [
` if (sc_top_status == 13) {`,
` int sc_exit_status = ${programExitCode};`,
` if (sc_exit_status == 0) sc_exit_status = sc_top_status;`,
...(programExitUsesIsland && runExitListeners !== ""
? [` size_t sc_exit_code_version = scr_island_exit_code_version();`]
: []),
// finish_top_level initially notes 13; replace that hint
// before exit listeners run when a higher-priority
// verdict has already selected the process status.
` scr_exit_code_note(sc_exit_status);`,
...(runExitListeners !== "" ? [` ${runExitListeners.trim()}`] : []),
...(programExitUsesIsland && runExitListeners !== ""
? [
` if (scr_island_exit_code_version() != sc_exit_code_version) {`,
` sc_exit_status = scr_island_exit_code();`,
` scr_exit_code_note(sc_exit_status);`,
` }`,
]
: []),
...(needsRelease ? [` sc_release_globals();`] : []),
` return sc_exit_status;`,
` }`,
]
: []),
]
: []),
releaseGlobals,
// node:test programs exit through the runner's verdict (Node's
// contract: 1 when any non-todo test failed). The loop-run above is
// guaranteed for these programs — every registration libCall sets
// usesTimers, so the runner fiber always drains before this line.
// Island programs exit with process.exitCode when the embedded
// graph set it (Node's implicit exit status: set it, return
// normally, exit with it) — 0 when never set.
` return ${programExitCode};`,
`}`,
``,
);
return out.join("\n");
}
/* ── library mode ─────────────────────────────────────────────────────
* The program TU's ONLY external-linkage definitions: the export-map
* wrappers plus the mode-provided init / sink-registration / reset /
* collect entries, all delegating their runtime halves to scr_library.c so
* both backends' emitted bodies are trivially identical. The init entry
* IS module-graph evaluation: full deterministic reset (program globals
* released and zeroed — run-once guards included — then the runtime's
* session reset), the error-vt interval stamps verbatim from today's
* main, then %main, then the escaped-exception check. */
emitLibEntries(out: string[], globals: IrGlobal[]): void {
const lib = this.mod.lib!;
const autoReset = lib.resultResetSymbol === null;
out.push(``, `/* ── library-mode entries (profile: ${lib.profileName}) ── */`, ``);
// Session reset of PROGRAM state: release every refcounted global and
// zero everything (the run-once module guards included), putting the
// program back at the not-yet-evaluated state.
out.push(`static void sc_lib_release_globals(void) {`);
for (const g of globals) {
const name = mangleGlobal(g.id);
if (isRefCounted(g.type)) {
out.push(` ${releaseCallC(g.type, name)}; ${name} = NULL; /* ${g.name} */`);
} else if (g.type.kind === "bool") {
out.push(` ${name} = false; /* ${g.name} */`);
} else {
out.push(` ${name} = 0; /* ${g.name} */`);
}
}
if (globals.length === 0) out.push(` /* no globals */`);
out.push(`}`, ``);
// The runtime detected-trap overlay table (scr_runtime.h declares it,
// the library trap funnel consults it): flat code/teaching/remediation
// triples, one per runtime trap code (SC4013–SC4019) the profile
// declares text for. NULL keeps the funnel's default for that cell;
// the empty table still defines the symbols the funnel links against.
if (lib.trapOverlays.length === 0) {
out.push(`const char *const scr_library_trap_overlays[] = { NULL };`);
} else {
const cells = lib.trapOverlays.flatMap((o) => [
cStringLiteral(Buffer.from(o.code, "utf8")),
o.teaching !== undefined ? cStringLiteral(Buffer.from(o.teaching, "utf8")) : "NULL",
o.remediation !== undefined ? cStringLiteral(Buffer.from(o.remediation, "utf8")) : "NULL",
]);
out.push(`const char *const scr_library_trap_overlays[] = { ${cells.join(", ")} };`);
}
out.push(`const size_t scr_library_trap_overlays_len = ${lib.trapOverlays.length};`, ``);
out.push(
`void ${lib.initSymbol}(void) {`,
` scr_library_entry(true, "${lib.initSymbol}"); /* init always resets the result arena */`,
` sc_lib_release_globals();`,
` scr_library_reset();`,
...this.errorVtStampLines(),
...emitterVtStampLines(this),
` ${mangleFunction(this.mod.entry)}();`,
` scr_library_check_exc();`,
`}`,
``,
`void ${lib.sinkRegisterSymbol}(void (*fn)(void *ctx, const uint8_t *msg, size_t msg_len, uint64_t address), void *ctx) {`,
` scr_library_callback_entry_guard("${lib.sinkRegisterSymbol}");`,
` scr_library_set_sink(fn, ctx);`,
`}`,
``,
);
if (lib.callbacks !== undefined && lib.callbacks.length > 0) {
// Host-callback registration: a pure store dispatch (the sink
// registration's rule — no entry prologue, no poison guard, legal
// before init) except the first operation rejects callback-time
// re-entry (SC4026). The channel name selects the slot; an unknown or
// NULL name is a defined -1, never a store. Latest registration
// wins; a NULL fn clears the channel.
out.push(
`int32_t ${lib.callbackRegisterSymbol}(const char *name, void (*fn)(void), void *ctx) {`,
` scr_library_callback_entry_guard("${lib.callbackRegisterSymbol}");`,
` if (name == NULL) return -1;`,
);
for (const cb of lib.callbacks) {
out.push(
` if (strcmp(name, ${cStringLiteral(Buffer.from(cb.name, "utf8"))}) == 0) {`,
` scr_library_cb_set(${cb.slot}, fn, ctx); /* channel '${cb.name}' */`,
` return 0;`,
` }`,
);
}
out.push(` return -1;`, `}`, ``);
}
if (lib.identity !== undefined && this.options.emitLibraryIdentity !== false) {
// Profile-declared identity getters (the ask-2 sidecar's boot-time
// pairing fence): pure data returns with NO entry prologue — exempt
// from the poisoned guard and every runtime touch (ratified), so a
// host can read them before init and after a trap.
out.push(...emitLibraryIdentityLines("c", lib.identity));
}
if (lib.resultResetSymbol !== null) {
out.push(
`void ${lib.resultResetSymbol}(void) {`,
` scr_library_entry(false, "${lib.resultResetSymbol}");`,
` scr_library_arena_reset();`,
`}`,
``,
);
}
if (lib.collectSymbol !== null) {
out.push(
`void ${lib.collectSymbol}(void) {`,
` scr_library_entry(false, "${lib.collectSymbol}");`,
` scr_library_collect(); /* arena reset + a full cycle collection */`,
`}`,
``,
);
}
for (const e of lib.exports) {
const params: string[] = [];
const args: string[] = [];
e.params.forEach((cls, i) => {
switch (cls) {
case "f64":
params.push(`double a${i}`);
args.push(`a${i}`);
break;
case "bool":
params.push(`uint8_t a${i}`);
args.push(`(a${i} != 0)`);
break;
case "u8":
params.push(`uint8_t a${i}`);
args.push(`(double)a${i}`);
break;
case "u32":
params.push(`uint32_t a${i}`);
args.push(`(double)a${i}`);
break;
case "i32":
params.push(`int32_t a${i}`);
args.push(`(double)a${i}`);
break;
case "i64":
// The inbound declared-integer edge (ask 4): the helper
// converts exactly or delivers the host-contract trap — a
// value past ±(2^53−1) cannot ride f64 without silent
// rounding, which is a coercion the author never wrote.
params.push(`int64_t a${i}`);
args.push(`scr_library_i64_in(a${i}, ${cStringLiteral(Buffer.from(e.inboundIntTrap!, "utf8"))})`);
break;
case "u64":
params.push(`uint64_t a${i}`);
args.push(`scr_library_u64_in(a${i}, ${cStringLiteral(Buffer.from(e.inboundIntTrap!, "utf8"))})`);
break;
case "string":
params.push(`const uint8_t *a${i}_ptr`, `size_t a${i}_len`);
args.push(`scr_library_str_in(a${i}_ptr, a${i}_len)`);
break;
case "bytes":
params.push(`const uint8_t *a${i}_ptr`, `size_t a${i}_len`);
// The helper's trap message is the compiler-assembled
// structured trap-teaching form (0x01 text 0x1F SC4012 0x1F
// symbol [0x1F remediation]) — assembled once at export
// resolution, identical across both backends.
args.push(`scr_library_bytes_in(a${i}_ptr, a${i}_len, ${cStringLiteral(Buffer.from(e.inboundBytesTrap!, "utf8"))})`);
break;
}
});
if (e.returns === "string" || e.returns === "bytes") {
params.push(`const uint8_t **out`, `size_t *out_len`);
}
const retType =
e.returns === "f64" ? "double"
: e.returns === "bool" ? "uint8_t"
: e.returns === "i64" ? "int64_t"
: e.returns === "u64" ? "uint64_t"
: "void";
const call = `${mangleFunction(e.fnName)}(${args.join(", ")})`;
out.push(`${retType} ${e.symbol}(${params.length > 0 ? params.join(", ") : "void"}) {`);
// The prologue records this entry's symbol in the funnel's
// current-entry slot: a detected trap anywhere below names the
// entry the host called (structured trap-teaching field 2).
out.push(` scr_library_entry(${autoReset ? "true" : "false"}, "${e.symbol}");`);
switch (e.returns) {
case "void":
out.push(` ${call};`, ` scr_library_check_exc();`);
break;
case "f64":
out.push(` double sc_r = ${call};`, ` scr_library_check_exc();`, ` return sc_r;`);
break;
case "i64":
case "u64":
// The outbound declared-integer edge (ask 4): every value
// reaching this return was PROVEN whole and inside the class's
// range at compile time, so the cast is exact by construction
// (and the unwind path's 0.0 converts cleanly).
out.push(
` double sc_r = ${call};`,
` scr_library_check_exc();`,
` return (${retType})sc_r;`,
);
break;
case "bool":
out.push(` bool sc_r = ${call};`, ` scr_library_check_exc();`, ` return (uint8_t)(sc_r ? 1 : 0);`);
break;
case "string":
out.push(` ScrStr *sc_r = ${call};`, ` scr_library_check_exc();`, ` scr_library_str_out(sc_r, out, out_len);`);
break;
case "bytes":
out.push(` ScrBytes *sc_r = ${call};`, ` scr_library_check_exc();`, ` scr_library_bytes_out(sc_r, out, out_len);`);
break;
}
out.push(`}`, ``);
}
}
emitAsyncScaffolding(out: string[]): void {
return emitAsyncScaffolding(this, out);
}
emitStructDefs(out: string[]): void {
return emitStructDefs(this, out);
}
/* ── vtables (class hierarchies) ──────────────────────────────────────
* One vtable struct type per hierarchy (named after the root): a ScrVt
* head — the class's preorder interval for instanceof and its DIRECT
* release for dynamic teardown — plus one member per virtual slot. Only
* methods actually overridden somewhere have slots; the slot's signature
* is the root-most declaring class's, and overriding implementations sit
* behind reinterpreting adapters (prefix layout makes the `this` cast
* sound). Each class gets one static const vtable instance stamped into
* every object it allocates. */
vtEntriesFor(meta: ClassMeta): { slot: VtSlot; impl: ClassMeta | null }[] {
return vtEntriesFor(this, meta);
}
vtSlotParams(slot: VtSlot, named: boolean): string[] {
return vtSlotParams(this, slot, named);
}
emitVtableDecls(out: string[], hierarchyClasses: ClassMeta[]): void {
return emitVtableDecls(this, out, hierarchyClasses);
}
emitVtableInstances(out: string[], hierarchyClasses: ClassMeta[]): void {
return emitVtableInstances(this, out, hierarchyClasses);
}
emitVtAdapterDefs(out: string[]): void {
return emitVtAdapterDefs(this, out);
}
emitHierarchyClassHelpers(out: string[],
meta: ClassMeta,
s: StructShape,): void {
return emitHierarchyClassHelpers(this, out, meta, s);
}
/* ── type-directed JSON walkers (jsonStringify / dynCheck) ────────────
* No dyn is built for stringify and no tags are consulted: the STATIC IR
* type drives everything, one emitted helper per typeKey (interned, like
* the array-HOF desugars). dynCheck helpers walk the runtime dyn (ScrDyn)
* against the target type: match predicates (sc_dm_*) answer "does this
* dyn fit?" without throwing (union arms are tried in canonical order —
* first FULL match wins), and builders (sc_dc_*) construct the typed
* value (+1) or throw a path-annotated TypeError through the exception
* cell. Recursion terminates because recursive shapes/unions are rejected
* by the frontend. */
islandAdapter(arity: number, retKind: "void" | "jsval" | "f64" | "bool" | "string"): string {
return islandAdapter(this, arity, retKind);
}
islandTypedAdapter(fn: IrType & { kind: "func" }): string {
return islandTypedAdapter(this, fn);
}
unionTruthyHelper(unionId: string): string {
return unionTruthyHelper(this, unionId);
}
unionEqHelper(unionId: string, sameValue: boolean): string {
return unionEqHelper(this, unionId, sameValue);
}
unionToStrHelper(unionId: string): string {
return unionToStrHelper(this, unionId);
}
unionJoinHelper(unionId: string): string {
return unionJoinHelper(this, unionId);
}
jsonWriteHelper(t: IrType): string {
return jsonWriteHelper(this, t);
}
jsonIndentHelper(): string {
return jsonIndentHelper(this);
}
dynMatchHelper(t: IrType): string {
return dynMatchHelper(this, t);
}
dynCheckHelper(t: IrType): string {
return dynCheckHelper(this, t);
}
toDynHelper(t: IrType): string {
return toDynHelper(this, t);
}
liveDynRefAdapter(t: IrType): StreamTypedRefAdapter {
return buildLiveDynRefAdapter(this, t);
}
dynFuncBoxHelper(t: IrType & { kind: "func" }): string {
return dynFuncBoxHelper(this, t);
}
recordKeyGetHelper(shapeId: string, t: IrType, overflowOnly = false): string {
return recordKeyGetHelper(this, shapeId, t, overflowOnly);
}
recordKeySetHelper(shapeId: string): string {
return recordKeySetHelper(this, shapeId);
}
dynToStrHelper(): string {
return dynToStrHelper(this);
}
caughtToDynHelper(): string {
return caughtToDynHelper(this);
}
/* ── cycle-collection wiring ──────────────────────────────────────────
* Containers that store a payload's RC entry points as function pointers
* (obj-kind boxes, union ref arms, promise payloads, the exception cell)
* also store the payload type's TRACE entry point — non-NULL exactly when
* the payload type carries a cycle header, which is what the container's
* own trace keys on (visit when present, release at teardown when not).
* Closures/unions/promises always carry one (runtime-provided traces);
* classes/records carry one iff their shape can participate in a cycle
* (emitted trace); strings/arrays/dyn never can. */
traceAdapterC(t: IrType): string | null {
return traceAdapterC(this, t);
}
traceArgC(t: IrType): string {
return traceArgC(this, t);
}
boxNewC(t: IrType): string {
return boxNewC(this, t);
}
arrNewC(elem: IrType, capExpr: string | number): string {
return arrNewC(this, elem, capExpr);
}
/* ── plumbing ─────────────────────────────────────────────────────── */
integerLoopIndex(expr: IrExpr): string | null {
return expr.kind === "varRef" ? this.integerLoopBindings.get(expr.localId) ?? null : null;
}
bytesElementHelper(op: "get" | "set", elem: IrBytesElem, integerIndex = false): string {
const mode = integerIndex ? "u64" : "f64";
this.bytesElementHelpers.add(`${op}:${elem}:${mode}`);
return `sc_bytes_${op}_${elem}${integerIndex ? "_u64" : ""}`;
}
private emitBytesElementHelpers(): string[] {
if (this.bytesElementHelpers.size === 0) return [];
const hasF64 = [...this.bytesElementHelpers].some((key) => key.endsWith(":f64"));
const hasU64 = [...this.bytesElementHelpers].some((key) => key.endsWith(":u64"));
const out = [`/* Typed-array hot paths specialized from the IR element kind. */`];
if (hasF64) {
out.push(
`static inline size_t sc_bytes_index_checked(const ScrBytes *b, double i) {`,
` if (!(i >= 0.0 && i < (double)b->len)) { (void)scr_bytes_get(b, i); return 0; }`,
` size_t idx = (size_t)i;`,
` if ((double)idx != i) { (void)scr_bytes_get(b, i); return 0; }`,
` return idx;`,
`}`,
);
}
if (hasU64) {
out.push(
`static inline size_t sc_bytes_index_u64_checked(const ScrBytes *b, uint64_t i) {`,
` if (i >= b->len) { (void)scr_bytes_get(b, (double)i); return 0; }`,
` return (size_t)i;`,
`}`,
);
}
if ([...this.bytesElementHelpers].some((key) => key.startsWith("set:") && !key.startsWith("set:f32:") && !key.startsWith("set:f64:"))) {
out.push(
`static inline uint32_t sc_bytes_coerce_u32(double v) {`,
` if (v >= -9007199254740992.0 && v <= 9007199254740992.0) return (uint32_t)(int64_t)v;`,
` if (v != v || isinf(v)) return 0;`,
` double t = fmod(trunc(v), 4294967296.0);`,
` if (t < 0) t += 4294967296.0;`,
` return (uint32_t)t;`,
`}`,
);
}
const elements: ("u8" | "u32" | "i32" | "f32" | "f64")[] = ["u8", "u32", "i32", "f32", "f64"];
const modes: ("f64" | "u64")[] = ["f64", "u64"];
for (const elem of elements) {
for (const mode of modes) {
const suffix = mode === "u64" ? "_u64" : "";
const indexType = mode === "u64" ? "uint64_t" : "double";
const checked = mode === "u64" ? "sc_bytes_index_u64_checked" : "sc_bytes_index_checked";
if (this.bytesElementHelpers.has(`get:${elem}:${mode}`)) {
if (elem === "u8") {
out.push(
`static inline double sc_bytes_get_u8${suffix}(const ScrBytes *b, ${indexType} i) {`,
` return (double)b->data[${checked}(b, i)];`,
`}`,
);
} else {
const valueType = elem === "f32" ? "float" : elem === "f64" ? "double" : elem === "i32" ? "int32_t" : "uint32_t";
const size = elem === "f64" ? 8 : 4;
out.push(
`static inline double sc_bytes_get_${elem}${suffix}(const ScrBytes *b, ${indexType} i) {`,
` ${valueType} v;`,
` memcpy(&v, b->data + ${checked}(b, i) * ${size}, ${size});`,
` return (double)v;`,
`}`,
);
}
}
if (this.bytesElementHelpers.has(`set:${elem}:${mode}`)) {
if (elem === "u8") {
out.push(
`static inline void sc_bytes_set_u8${suffix}(ScrBytes *b, ${indexType} i, double v) {`,
` b->data[${checked}(b, i)] = (uint8_t)sc_bytes_coerce_u32(v);`,
`}`,
);
} else {
const valueType = elem === "f32" ? "float" : elem === "f64" ? "double" : "uint32_t";
const init = elem === "f32" ? `(float)v` : elem === "f64" ? "v" : `sc_bytes_coerce_u32(v)`;
const size = elem === "f64" ? 8 : 4;
out.push(
`static inline void sc_bytes_set_${elem}${suffix}(ScrBytes *b, ${indexType} i, double v) {`,
` size_t idx = ${checked}(b, i);`,
` ${valueType} stored = ${init};`,
` memcpy(b->data + idx * ${size}, &stored, ${size});`,
`}`,
);
}
}
}
}
out.push("");
return out;
}
line(text: string): void {
const pos = this.sourceLoc === null ? null : this.debug?.position(this.sourceLoc);
if (pos !== null && pos !== undefined && text.trim() !== "") {
// Repeat the directive for every generated line: one TS statement can
// expand into many native instructions, all belonging to that line.
this.lines.push(`#line ${pos.line} ${JSON.stringify(pos.file)}`);
}
this.lines.push(" ".repeat(this.indent) + text);
}
endSourceFunction(): void {
this.sourceLoc = null;
if (this.debug !== null) this.lines.push('#line 1 "<scriptc>"');
}
srcComment(loc: SrcLoc): string {
if (!this.lineStarts) return "";
let lo = 0, hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.lineStarts[mid]! <= loc.start) lo = mid;
else hi = mid - 1;
}
return ` /* ${this.mod.sourceFile}:${lo + 1} */`;
}
newTemp(type: IrType, init: string): Temp {
const name = `sc_t${this.tempCounter++}`;
this.line(`${cDecl(type, name)} = ${init};`);
if (isRefCounted(type)) this.currentFrame().push({ name, type });
return { name, type };
}
/** A borrowed compiler temp. Used only when a surrounding operation has
* proved that evaluation cannot overwrite the owning binding before the
* temp's last use. Unlike newTemp, this does not join the release frame. */
newBorrowedTemp(type: IrType, init: string): Temp {
const name = `sc_t${this.tempCounter++}`;
this.line(`${cDecl(type, name)} = ${init};`);
return { name, type };
}
/** newTemp for a MAY-THROW runtime call: the result joins its frame
* BEFORE the standard pending check, so an unwind releases the dummy
* (NULL for refcounted kinds) harmlessly and the value is only read past
* the check. The shared shape of every fallible boundary call — jsOp,
* jsExit, composite jsMarshal, dynCheck, await reads. */
fallibleTemp(type: IrType, call: string): Temp {
const t = this.newTemp(type, call);
this.emitPendingCheck();
return t;
}
currentFrame(): ScopeEntry[] {
const frame = this.frames[this.frames.length - 1];
if (!frame) throw new InternalCompilerError("emitter bug: no active statement frame");
return frame;
}
/** Strike a refcounted temp from its frame: ownership is being moved. */
moveTemp(t: Temp): void {
if (!isRefCounted(t.type)) return;
for (let i = this.frames.length - 1; i >= 0; i--) {
const idx = this.frames[i]!.findIndex((e) => e.name === t.name);
if (idx >= 0) {
this.frames[i]!.splice(idx, 1);
return;
}
}
throw new InternalCompilerError(`emitter bug: moved temp ${t.name} not found in any frame`);
}
/** The release call for one owned refcounted value. */
releaseValue(name: string, type: IrType): void {
this.line(`${releaseCallC(type, name)};`);
}
releaseFrame(frame: ScopeEntry[]): void {
for (const t of frame) {
if (t.boxed) this.line(`scr_box_release(${t.name});`);
else this.releaseValue(t.name, t.type);
}
}
/** THE release-on-jump path (break/continue/return): pending statement
* frames and entered scopes down to (and excluding nothing above) the
* given depths, innermost first — everything whose normal fall-through
* releases the jump bypasses. The jump target's own frame/scope stay
* live: a loop's releases after the loop and a switch's after its end
* label are still on the jump's path (return passes 0/0 — it leaves the
* whole function). */
releaseForJump(frameDepth: number, scopeDepth: number): void {
for (let i = this.frames.length - 1; i >= frameDepth; i--) this.releaseFrame(this.frames[i]!);
for (let i = this.scopes.length - 1; i >= scopeDepth; i--) this.releaseFrame(this.scopes[i]!);
}
/** Run the finally regions a break/continue exits, then release to its
* resolved target. Every body sees compile-time frame/scope/finally/try
* stacks truncated to the region boundary: a throw propagates outside
* the completing try, and a jump inside the finally replaces the pending
* jump without re-entering the same cleanup. */
emitFinallysForJump(finallyDepth: number, frameDepth: number, scopeDepth: number): void {
if (this.finallyStack.length <= finallyDepth) {
this.releaseForJump(frameDepth, scopeDepth);
return;
}
const savedFrames = this.frames;
const savedScopes = this.scopes;
const savedFinally = this.finallyStack;
const savedTry = this.tryStack;
for (let i = savedFinally.length - 1; i >= finallyDepth; i--) {
const fin = savedFinally[i]!;
this.releaseForJump(fin.frameDepth, fin.scopeDepth);
this.frames = this.frames.slice(0, fin.frameDepth);
this.scopes = this.scopes.slice(0, fin.scopeDepth);
this.finallyStack = savedFinally.slice(0, i);
this.tryStack = savedTry.slice(0, fin.tryDepth);
this.emitBlock(fin.body);
}
this.releaseForJump(frameDepth, scopeDepth);
this.frames = savedFrames;
this.scopes = savedScopes;
this.finallyStack = savedFinally;
this.tryStack = savedTry;
}
/** THE unwind path at a point where an exception is pending: release
* everything between here and the innermost try handler — or the whole
* function — via releaseForJump, then jump to the handler / return a
* dummy value (never read: callers of a may-throw function test the
* pending flag before using the result). Callers own the surrounding
* `if (scr_exc_pending())`; a `throw` unwinds unconditionally. */
emitUnwind(): void {
const target = this.tryStack[this.tryStack.length - 1];
if (target) {
this.releaseForJump(target.frameDepth, target.scopeDepth);
target.used = true;
this.line(`goto ${target.label};`);
return;
}
this.releaseForJump(0, 0);
const t = this.currentReturnType;
if (t.kind === "void") this.line(`return;`);
else if (t.kind === "f64" || t.kind === "date") this.line(`return 0;`);
else if (t.kind === "bool") this.line(`return false;`);
else this.line(`return NULL;`);
}
errorVtStampLines(): string[] {
return errorVtStampLines(this);
}
/** The C symbol a direct call or closure enters a function through:
* async bodies are entered via their emitted spawn wrapper (which runs
* the fiber eagerly to its first suspension and returns the promise);
* generator bodies via theirs (which only ALLOCATES the suspended
* fiber and returns the generator object). */
callTargetC(fnName: string): string {
const fn = this.fnByName.get(fnName);
if (fn?.generator !== undefined) return mangleGenSpawn(fnName);
if (fn?.async === true) return mangleAsyncSpawn(fnName);
return mangleFunction(fnName);
}
/** The emitter contract for exceptions: after EVERY call that can throw
* (per the may-throw analysis), test the pending flag and unwind. */
emitPendingCheck(): void {
this.line(`if (scr_exc_pending()) {`);
this.indent++;
this.emitUnwind();
this.indent--;
this.line(`}`);
}
internLiteral(text: string): string {
let sym = this.literals.get(text);
if (!sym) {
sym = `sc_lit_${this.literals.size}`;
this.literals.set(text, sym);
}
return sym;
}
/** The class object's static symbol (classes as values), registering it
* for assembly on first use and interning the .name literal while the
* literal table is still open (bodies emit before the file assembles —
* the regex-literal discipline). */
classObjSym(className: string): string {
let sym = this.classObjs.get(className);
if (!sym) {
const meta = this.classMeta.get(className);
if (!meta) throw new InternalCompilerError(`emitter bug: classRef to unknown class ${className}`);
this.internLiteral(meta.def.jsName ?? "");
sym = mangleClassObj(className);
this.classObjs.set(className, sym);
}
return sym;
}
/** The interned immortal instance for a UNIT arm of a union — asserts the
* arm really is payload-less (undefined/null). */
internUnitInstance(unionId: string, tag: number): string {
const arm = this.unionsById.get(unionId)?.arms[tag];
if (!arm || !isUnitType(arm)) {
throw new InternalCompilerError(`emitter bug: unit instance for non-unit arm ${tag} of ${unionId}`);
}
const key = `${unionId}:${tag}`;
let sym = this.unitInstances.get(key);
if (!sym) {
sym = `sc_unit_${this.unitInstances.size}`;
this.unitInstances.set(key, sym);
}
return sym;
}
/** The C expression for that instance — the one pointer every unit-arm
* value of this (union, tag) is: rc == SIZE_MAX, so RC entry points and
* the collector both skip it and no retain is ever owed. */
unitInstanceRef(unionId: string, tag: number): string {
return `(ScrUnion *)&${this.internUnitInstance(unionId, tag)}`;
}
/** The class-newFn initialization line for a field whose type ADMITS
* undefined — such fields start as JS's `undefined`, never the calloc
* NULL: tsc's strictPropertyInitialization accepts them with no
* initializer and no constructor assignment (undefined is in the type),
* so a fresh instance is readable before any assignment runs — a method
* assigns later, a constructor branch skips it, a base constructor's
* virtual call reads a derived field before super() returns. Node reads
* `undefined` there; a NULL payload pointer would be a segfault (union
* fields), or an invalid dyn/jsval cell. Native checked-dynamic fields
* get the immortal dyn undefined. Undefined-armed unions get
* the interned immortal unit instance (free; releases skip it); jsval
* (`any`) fields get an engine undefined cell — such classes exist only
* in --dynamic builds, and the field's release balances it. Empty for
* every type that cannot hold undefined (tsc's SPI guards those) and for
* record shapes' construction paths, which write every field. */
undefFieldInitLineC(name: string, t: IrType): string[] {
// Error.cause uses NULL for absence; only the options constructor may
// install a present value (including undefined) in this runtime slot.
if (name === "%cause") return [];
if (t.kind === "dyn") {
return [` o->${mangleField(name)} = scr_dyn_undefined(); /* ${cCommentText(name)} starts undefined */`];
}
if (t.kind === "jsval") {
return [` o->${mangleField(name)} = scr_jsval_undefined(); /* ${cCommentText(name)} starts undefined */`];
}
const tag = undefinedArmTag(t, this.unionsById);
if (tag < 0 || t.kind !== "union") return [];
return [` o->${mangleField(name)} = ${this.unitInstanceRef(t.unionId, tag)}; /* ${cCommentText(name)} starts undefined */`];
}
/* ── functions ────────────────────────────────────────────────────── */
ffiCallbackAdapter(binding: string, id: string): FfiCallbackAdapter {
const adapter = this.ffiCallbackAdapters.get(`${binding}:${id}`);
if (!adapter) throw new InternalCompilerError(`emitter bug: no callback adapter for ${binding}:${id}`);
return adapter;
}
/** C-callable trampolines for format-2/3/4 callbacks. The external callback
* ABI is scalar C, format-3 copy-in string/byte slots, plus an optional
* exact-position context pointer; the internal side is scriptc's
* (ScrClosure *env, params...) convention. A raw callback borrows its
* closure through a distinct TLS slot for the dynamic extent of the outer
* call, or a process-global slot for retained replace semantics. */
emitFfiCallbackDefs(out: string[]): void {
if (this.ffiCallbackAdapters.size === 0) return;
for (const adapter of this.ffiCallbackAdapters.values()) {
const cb = adapter.callback;
if (adapter.tls !== null) {
out.push(`static _Thread_local ScrClosure *${adapter.tls};`);
}
if (adapter.global !== null) {
out.push(`static ScrClosure *${adapter.global};`);
}
if (adapter.table !== null) {
out.push(`static ScrFfiTable ${adapter.table};`);
}
const nativeParams = ffiCallbackNativeParamsC(cb, true);
const ret = ffiNativeTypeC(cb.returns);
const contextParam = cb.params.findIndex(isFfiContextParam);
if (cb.invoke === "foreign") {
if (adapter.table === null || contextParam < 0 || cb.returns !== "void") {
throw new InternalCompilerError("emitter bug: invalid foreign FFI callback descriptor");
}
const dispatch = `${adapter.symbol}_dispatch`;
const ft = ffiCallbackType(cb);
const scriptArgs = cb.params.flatMap((param, i): string[] => {
if (isFfiContextParam(param)) return [];
switch (param) {
case "f64":
case "f32":
case "i8":
case "u16":
case "i16":
return [`scr_ffi_call_get_f64(sc_call, ${i})`];
case "bool":
return [`scr_ffi_call_get_bool(sc_call, ${i})`];
case "u8":
return [`scr_ffi_call_get_u8(sc_call, ${i})`];
case "u32":
return [`scr_ffi_call_get_u32(sc_call, ${i})`];
case "i32":
return [`scr_ffi_call_get_i32(sc_call, ${i})`];
case "cstring":
case "string":
return [`scr_str_from_utf8_lossy(scr_ffi_call_get_data(sc_call, ${i}), scr_ffi_call_get_len(sc_call, ${i}))`];
case "bytes":
return [`scr_bytes_from_data(scr_ffi_call_get_data(sc_call, ${i}), scr_ffi_call_get_len(sc_call, ${i}))`];
}
});
out.push(
`static void ${dispatch}(ScrClosure *sc_cb, ScrFfiCall *sc_call) {`,
` (${cFnPtrCast(ft)}sc_cb->fn)(sc_cb${scriptArgs.length ? `, ${scriptArgs.join(", ")}` : ""});`,
`}`,
`static void ${adapter.symbol}(${nativeParams.join(", ")}) {`,
` ScrClosure *sc_cb = (ScrClosure *)sc_ctx;`,
` ScrFfiCall *sc_call = scr_ffi_call_new(&${adapter.table}, sc_cb, &${dispatch}, ${cb.params.length});`,
);
cb.params.forEach((param, i) => {
if (isFfiContextParam(param)) return;
switch (param) {
case "f64":
case "f32":
case "i8":
case "u16":
case "i16":
out.push(` scr_ffi_call_set_f64(sc_call, ${i}, sc_a${i});`);
break;
case "bool":
out.push(` scr_ffi_call_set_bool(sc_call, ${i}, sc_a${i});`);
break;
case "u8":
out.push(` scr_ffi_call_set_u8(sc_call, ${i}, sc_a${i});`);
break;
case "u32":
out.push(` scr_ffi_call_set_u32(sc_call, ${i}, sc_a${i});`);
break;
case "i32":
out.push(` scr_ffi_call_set_i32(sc_call, ${i}, sc_a${i});`);
break;
case "cstring":
out.push(` scr_ffi_call_copy_cstring(sc_call, ${i}, sc_a${i});`);
break;
case "string":
case "bytes":
out.push(` scr_ffi_call_copy_${param}(sc_call, ${i}, sc_a${i}, sc_a${i}_len);`);
break;
}
});
out.push(` scr_ffi_post(sc_call);`, `}`, ``);
continue;
}
out.push(
`static ${ret} ${adapter.symbol}(${nativeParams.length > 0 ? nativeParams.join(", ") : "void"}) {`,
` ScrClosure *sc_cb = ${contextParam >= 0 ? `(ScrClosure *)sc_ctx` : adapter.tls ?? adapter.global};`,
` if (sc_cb == NULL) scr_trap("scriptc: native callback invoked outside its ${adapter.callback.lifetime === "call" ? "call-scoped" : "retained"} lifetime\\n");`,
);
const dummy = ffiCallbackDummyC(cb);
out.push(` if (scr_exc_pending()) ${cb.returns === "void" ? "return;" : `return ${dummy};`}`);
// Reject every invalid native pointer before materializing any owned
// callback arguments, so the trap path cannot strand an earlier copy.
for (let i = 0; i < cb.params.length; i++) {
const param = cb.params[i]!;
if (param === "cstring") {
out.push(
` if (sc_a${i} == NULL) scr_trap("scriptc: native callback passed a NULL cstring\\n");`,
);
} else if (param === "string" || param === "bytes") {
out.push(
` if (sc_a${i} == NULL && sc_a${i}_len != 0) scr_trap("scriptc: native callback passed a NULL ${param} span with nonzero length\\n");`,
);
}
}
const ft = ffiCallbackType(cb);
const materialized = new Map<number, string>();
for (let i = 0; i < cb.params.length; i++) {
const param = cb.params[i]!;
if (isFfiContextParam(param)) continue;
if (param === "cstring") {
const local = `sc_s${i}`;
out.push(
` ScrStr *${local} = scr_str_from_utf8_lossy((const uint8_t *)sc_a${i}, strlen(sc_a${i}));`,
);
materialized.set(i, local);
} else if (param === "string") {
const local = `sc_s${i}`;
out.push(` ScrStr *${local} = scr_str_from_utf8_lossy(sc_a${i}, sc_a${i}_len);`);
materialized.set(i, local);
} else if (param === "bytes") {
const local = `sc_s${i}`;
out.push(` ScrBytes *${local} = scr_bytes_from_data(sc_a${i}, sc_a${i}_len);`);
materialized.set(i, local);
}
}
const scriptArgs = cb.params.flatMap((param, i): string[] => {
if (isFfiContextParam(param)) return [];
switch (param) {
case "f64":
return [`sc_a${i}`];
case "bool":
return [`(sc_a${i} != 0)`];
case "u8":
case "f32":
case "i8":
case "u16":
case "i16":
case "u32":
case "i32":
return [`(double)sc_a${i}`];
case "cstring":
case "string":
case "bytes":
return [materialized.get(i)!];
}
});
const call = `(${cFnPtrCast(ft)}sc_cb->fn)(sc_cb${scriptArgs.length ? `, ${scriptArgs.join(", ")}` : ""})`;
if (cb.lifetime === "retained") {
// The callback may unregister or replace its own descriptor. Hold
// one invocation reference so that dropping the table's last pin
// cannot free the executing closure or its captures mid-call.
out.push(` scr_closure_retain(sc_cb);`);
}
if (cb.returns === "void") {
out.push(
` ${call};`,
...(cb.lifetime === "retained" ? [` scr_closure_release(sc_cb);`] : []),
` return;`,
`}`,
``,
);
continue;
}
out.push(` ${cDecl(ft.ret, "sc_result")} = ${call};`);
if (cb.lifetime === "retained") out.push(` scr_closure_release(sc_cb);`);
switch (cb.returns) {
case "f64":
out.push(` return sc_result;`);
break;
case "f32":
out.push(` return (float)sc_result;`);
break;
case "i8":
case "i16": {
const bits = cb.returns === "i8" ? 8 : 16;
out.push(` uint32_t sc_bits = (uint32_t)scr_bit_ushr(sc_result, 0.0) & ${(2 ** bits) - 1}u;`);
out.push(` return (${ffiNativeTypeC(cb.returns)})(sc_bits < ${2 ** (bits - 1)}u ? (int32_t)sc_bits : (int32_t)sc_bits - ${2 ** bits});`);
break;
}
case "u16":
out.push(` return (uint16_t)(uint32_t)scr_bit_ushr(sc_result, 0.0);`);
break;
case "bool":
out.push(` return (uint8_t)(sc_result ? 1 : 0);`);
break;
case "u8":
out.push(` return (uint8_t)(uint32_t)scr_bit_ushr(sc_result, 0.0);`);
break;
case "u32":
out.push(` return (uint32_t)scr_bit_ushr(sc_result, 0.0);`);
break;
case "i32":
out.push(` return (int32_t)scr_bit_or(sc_result, 0.0);`);
break;
}
out.push(`}`, ``);
}
}
signature(fn: IrFunction): string {
const boxedIds = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id));
const parts = fn.params.map((p) =>
// A boxed param's sc_l_ name is its box; the raw value arrives under
// a sc_p_ name and is moved into the box in the prologue.
cDecl(p.type, boxedIds.has(p.localId) ? mangleRawParam(p.localId) : mangleLocal(p.localId)),
);
// Lifted functions receive their closure first.
if (fn.captures !== undefined) parts.unshift("ScrClosure *sc_env");
const params = parts.length ? parts.join(", ") : "void";
return `static ${cType(fn.returnType)} ${mangleFunction(fn.name)}(${params})`;
}
emitFunction(fn: IrFunction): void {
return emitFunction(this, fn);
}
/* ── statements ───────────────────────────────────────────────────── */
emitBlock(stmts: IrStmt[], setup?: (scope: ScopeEntry[]) => void): void {
return emitBlock(this, stmts, setup);
}
emitStmts(stmts: IrStmt[]): void {
return emitStmts(this, stmts);
}
emitStmt(s: IrStmt): void {
return emitStmt(this, s);
}
/** A fresh loop jump-target entry. `continueLabel` null means "C
* continue is correct" (while/forOf) — but a LABELED loop always
* allocates one (a labeled continue arriving from a nested loop needs a
* goto), and every labeled loop gets a lazy end label for labeled break
* (a C break only exits the innermost loop). */
loopTarget(continueLabel: string | null, labels: string[] | undefined): (typeof this.jumpTargets)[number] & { kind: "loop" } {
return {
labels,
kind: "loop",
continueLabel: continueLabel === null && labels !== undefined ? `sc_cont_${this.labelCounter++}` : continueLabel,
usedContinue: false,
endLabel: labels !== undefined ? `sc_end_${this.labelCounter++}` : null,
usedEnd: false,
scopeDepth: this.scopes.length,
frameDepth: this.frames.length,
finallyDepth: this.finallyStack.length,
};
}
emitTryCatch(s: IrStmt & { kind: "tryCatch" }): void {
return emitTryCatch(this, s);
}
emitSwitch(s: IrStmt & { kind: "switch" }): void {
return emitSwitch(this, s);
}
mergeBrace(emitBlockFn: () => void): void {
return mergeBrace(this, emitBlockFn);
}
emitBranchInto(target: string, expr: IrExpr): void {
return emitBranchInto(this, target, expr);
}
emitCondition(cond: IrExpr): string {
return emitCondition(this, cond);
}
/** C expression testing JS truthiness of a temp (falsy: 0, -0, NaN, "").
* `x == x` rejects NaN, `x != 0` rejects both zeros; strings only need
* their length — no runtime call, no ownership change. */
truthyC(t: Temp): string {
if (POINTER_KINDS.has(t.type.kind) &&
t.type.kind !== "string" && t.type.kind !== "bigint" && t.type.kind !== "union" &&
t.type.kind !== "dyn" && t.type.kind !== "jsval" &&
t.type.kind !== "caught") {
// JS objects are ALWAYS truthy ([] and {} included). These are
// non-NULL pointers, so the honest constant reads as a pointer
// test (no unused-value warnings, operand still evaluated).
return `${t.name} != NULL`;
}
switch (t.type.kind) {
case "bool":
return t.name;
case "f64":
return `${t.name} == ${t.name} && ${t.name} != 0`;
case "string":
return `${t.name}->len != 0`;
case "bigint":
return `scr_bigint_truthy(${t.name})`;
case "date":
// The scalar payload may be 0 or NaN, but the source Date object
// is always truthy.
return "true";
case "jsval":
// Island truthiness: the engine's ToBoolean (never throws, no
// ownership change) — jsval operands are legal in `logical`.
return `(scr_jsval_truthy(${t.name}) != 0)`;
case "union":
// The ARM value's ToBoolean, answered by a per-union interned
// helper (switch on tag: unit arms false, scalar/string arms by
// value, ref arms true, jsval arms ask the engine).
return `${this.unionTruthyHelper(t.type.unionId)}(${t.name})`;
case "procStream":
// A stream value is a JS object (always truthy); the scalar fd
// representation is 1 or 2, so the honest constant reads as its
// own non-zero test.
return `${t.name} != 0`;
case "dyn":
// ToBoolean over the dyn kind (scr_dyn_truthy — JS-exact for
// every kind; borrowed, never throws): `v || dflt` and condition
// descent on checked-dynamic values.
return `scr_dyn_truthy(${t.name})`;
case "undefinedT":
case "nullT":
case "caught":
throw new InternalCompilerError(`emitter bug: truthiness of ${t.type.kind}`);
case "void":
throw new InternalCompilerError("emitter bug: truthiness of void");
default: {
const _exhaustive: never = t.type as Exclude<typeof t.type, TruthyPointerType>;
void _exhaustive;
throw new InternalCompilerError("unreachable");
}
}
}
/* ── expressions ──────────────────────────────────────────────────── */
emitExpr(e: IrExpr): Temp {
return emitExpr(this, e);
}
childExitThunkFor(param: IrType): string {
return childExitThunkFor(this, param);
}
execFileThunkFor(cbT: IrType & { kind: "func" }): string {
return execFileThunkFor(this, cbT);
}
ipcMessageThunkFor(cbT: IrType & { kind: "func" }): string {
return ipcMessageThunkFor(this, cbT);
}
ipcSendThunkFor(cbT: IrType & { kind: "func" }): string {
return ipcSendThunkFor(this, cbT);
}
childDataThunkFor(param: IrType): string {
return childDataThunkFor(this, param);
}
closeBindThunkFor(cbUnion: IrType, retServer: boolean): string {
return closeBindThunkFor(this, cbUnion, retServer);
}
closeOverrideWrapFor(cbUnion: IrType, retServer: boolean): string {
return closeOverrideWrapFor(this, cbUnion, retServer);
}
childExitSignalThunkFor(codeParam: IrType, sigParam: IrType): string {
return childExitSignalThunkFor(this, codeParam, sigParam);
}
dgramMsgThunkFor(param: IrType): string {
return dgramMsgThunkFor(this, param);
}
dnsLookupThunkFor(cbT: IrType): string {
return dnsLookupThunkFor(this, cbT);
}
fsRenameThunkFor(cbT: IrType): string {
return fsRenameThunkFor(this, cbT);
}
cryptoBytesThunkFor(cbT: IrType): string {
return cryptoBytesThunkFor(this, cbT);
}
zlibBytesThunkFor(cbT: IrType): string {
return zlibBytesThunkFor(this, cbT);
}
sniAnswerThunkFor(cbT: IrType): string {
return sniAnswerThunkFor(this, cbT);
}
netLookupAnswerThunkFor(cbT: IrType): string {
return netLookupAnswerThunkFor(this, cbT);
}
emitterInvokeThunkFor(cbT: IrType): string {
return emitterInvokeThunkFor(this, cbT);
}
streamDataThunkFor(cbT: IrType): string {
return streamDataThunkFor(this, cbT);
}
streamCbThunkFor(kind: "r" | "w" | "f" | "d" | "t" | "l" | "e", cbT: IrType): string {
return streamCbThunkFor(this, kind, cbT);
}
connectSockThunkFor(cbT: IrType): string {
return connectSockThunkFor(this, cbT);
}
connectResThunkFor(cbT: IrType): string {
return connectResThunkFor(this, cbT);
}
raceAdapterFor(from: IrType, to: IrType): string {
return raceAdapterFor(this, from, to);
}
resolveThunkFor(inner: IrType): string {
return resolveThunkFor(this, inner);
}
}