Files

991 lines
39 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
/**
* Code generation as ninja rules.
*
* Each codegen step is a single ninja `build` statement with explicit
* inputs (script + sources) and outputs. All rules set `restat = 1` —
* most codegen scripts use `writeIfNotChanged`, so downstream is pruned
* when output content didn't change.
*
* Source lists come from the patterns in glob-sources.ts, globbed once at
* configure time via `globAllSources()`. The expanded
* paths are baked into build.ninja; adding a file picks up on next configure.
*
* bindgenv2 is special: its output set is dynamic (depends on which types
* the .bindv2.ts files export). We invoke it with `--command=list-outputs`
* at CONFIGURE time to get the actual output paths.
*
* ## Undeclared outputs
*
* Every generated file that is compiled or #included must be a declared
* output of its step. Compiles only order-depend on codegen (bun.ts); the
* depfile is what rebuilds them when a generated header changes, and a
* depfile entry does that within the same run only if it names a declared
* output, which ninja re-stats after the step ran. A file no edge declares
* is stat'd once at startup, so a compile that includes it picks up a rerun
* of the step one build late. That is why emitBindgen/emitBindgenV2 declare
* the per-file Generated*.h headers and not just the .cpp that gets compiled.
*
* Files nothing compiles may stay undeclared (.d.ts twins, bundle-modules'
* eval/ dir, JSSink.lut.txt consumed within its own step). The remaining
* #included exception is BunBuiltinNames+extras.h from bundle-functions.ts,
* reached through the PCH.
*/
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { basename, dirname, relative, resolve } from "node:path";
import type { Sources } from "../glob-sources.ts";
import { generateBuildOptionsRs } from "./buildOptionsRs.ts";
import type { Config } from "./config.ts";
import { BuildError, assert } from "./error.ts";
import { writeIfChanged } from "./fs.ts";
import { generateJsonByteClass } from "./jsonByteClass.ts";
import type { Ninja } from "./ninja.ts";
import { quote, quoteArgs } from "./shell.ts";
import { generateXmlByteClass } from "./xmlByteClass.ts";
// The individual emit functions take these four params. Bundled to keep
// signatures short.
interface Ctx {
n: Ninja;
cfg: Config;
sources: Sources;
o: CodegenOutputs;
dirStamp: string;
}
/**
* Read a package.json and return the list of dependency package.json paths
* under node_modules/. Used as outputs of `bun install` — if any are missing,
* install re-runs.
*/
function readPackageDeps(pkgDir: string): string[] {
const pkgPath = resolve(pkgDir, "package.json");
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as typeof pkg;
} catch (cause) {
throw new BuildError(`Could not parse package.json`, { file: pkgPath, cause });
}
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
const nodeModules = resolve(pkgDir, "node_modules");
return Object.keys(deps).map(name => resolve(nodeModules, name, "package.json"));
}
// ───────────────────────────────────────────────────────────────────────────
// Ninja rule registration
// ───────────────────────────────────────────────────────────────────────────
/**
* Node-style platform/arch strings for the TARGET (not the host running
* codegen). Passed as TARGET_PLATFORM/TARGET_ARCH so scripts that inline
* `process.platform` into bundled JS use the target's value.
*/
function codegenTarget(cfg: Config): { platform: string; arch: string } {
const platform =
cfg.abi === "android"
? "android"
: cfg.os === "darwin"
? "darwin"
: cfg.os === "windows"
? "win32"
: cfg.os === "freebsd"
? "freebsd"
: "linux";
const arch = cfg.x64 ? "x64" : "arm64";
return { platform, arch };
}
export function registerCodegenRules(n: Ninja, cfg: Config): void {
// Shell syntax: HOST platform, not target. rust-only cross-compiles on
// a linux box for other linux/freebsd targets; these rules run on the host.
const hostWin = cfg.host.os === "windows";
const q = (p: string) => quote(p, hostWin);
const bun = q(cfg.bun);
const esbuild = q(cfg.esbuild);
const { platform, arch } = codegenTarget(cfg);
// Generic codegen: `cd <repo-root> && [env VARS] bun <args>`.
// Both `bun run script.ts` and `bun script.ts` go through this — the
// caller puts the `run` subcommand in $args when needed.
//
// TARGET_PLATFORM/ARCH: scripts that inline process.platform into the
// bundled JS modules (replacements.ts, bundle-modules.ts,
// create-hash-table.ts) read these so a cross-compiled binary doesn't
// ship with the build host's platform baked in.
//
// restat = 1 because most scripts use writeIfNotChanged(). Scripts that
// don't (generate-jssink) always write → restat is a no-op for
// them, no harm.
const env = hostWin
? `set TARGET_PLATFORM=${platform}&& set TARGET_ARCH=${arch}&& `
: `TARGET_PLATFORM=${platform} TARGET_ARCH=${arch} `;
n.rule("codegen", {
command: hostWin ? `cmd /c "cd /d $cwd && ${env}${bun} $args"` : `cd $cwd && ${env}${bun} $args`,
description: "gen $desc",
restat: true,
});
// esbuild invocations. No restat — esbuild always touches outputs.
// No pool — esbuild is fast and single-threaded per bundle.
n.rule("esbuild", {
command: hostWin ? `cmd /c "cd /d $cwd && ${esbuild} $args"` : `cd $cwd && ${esbuild} $args`,
description: "esbuild $desc",
});
// bun install. Inputs: package.json + bun.lock. Outputs: a stamp file we
// touch on success, plus each node_modules/<dep>/package.json as IMPLICIT
// outputs (so deleting node_modules/ correctly retriggers install).
//
// Why stamp + restat instead of just the node_modules paths as outputs:
// `bun install --frozen-lockfile` with no changes doesn't touch anything.
// If package.json was edited at time T and install ran at T-1day, the
// node_modules files have mtimes from T-1day < T → ninja loops forever.
// Touching the stamp gives ninja something with mtime T to compare against.
// Restat lets implicit outputs keep their old mtimes, pruning downstream.
//
// CMake only tracked package.json as input; we add bun.lock so lockfile
// version bumps actually reinstall.
const touch = hostWin ? "type nul >" : "touch";
n.rule("bun_install", {
command: hostWin
? `cmd /c "cd /d $dir && ${bun} install --frozen-lockfile && ${touch} $stamp"`
: `cd $dir && ${bun} install --frozen-lockfile && ${touch} $stamp`,
description: "install $dir",
restat: true,
// bun install can be memory-hungry and grabs a lockfile; serialize.
pool: "bun_install",
});
n.pool("bun_install", 1);
// Codegen dir stamp — all outputs go into cfg.codegenDir, but the dir must
// exist first. Scripts generally mkdir themselves, but some (esbuild) don't.
n.build({
outputs: [codegenDirStamp(cfg)],
rule: "mkdir_stamp",
inputs: [],
vars: { dir: n.rel(cfg.codegenDir) },
});
// Stamps dir — holds bun_install stamp files.
const stampsDir = resolve(cfg.buildDir, "stamps");
n.build({
outputs: [resolve(stampsDir, ".dir")],
rule: "mkdir_stamp",
inputs: [],
vars: { dir: n.rel(stampsDir) },
});
}
function codegenDirStamp(cfg: Config): string {
return resolve(cfg.codegenDir, ".dir");
}
// ───────────────────────────────────────────────────────────────────────────
// Codegen step emitters
// ───────────────────────────────────────────────────────────────────────────
/**
* All codegen outputs, grouped by consumer. Downstream phases (cpp compile,
* rust build, link) add the appropriate group to their implicit inputs.
*/
export interface CodegenOutputs {
/** All codegen outputs — use for phony target `codegen`. */
all: string[];
/** Outputs the cargo step depends on (generated .rs that gets `include!`d). */
rustInputs: string[];
/** Outputs the cargo step needs to exist but doesn't embed (debug bake runtime). */
rustOrderOnly: string[];
/** Generated .cpp files. Compiled alongside handwritten C++ in bun.ts. */
cppSources: string[];
/**
* Generated headers that are #included by hand-written .cpp files.
* The PCH order-depends on all of these, and cxx waits on PCH — so
* they're guaranteed to exist before any compile. Depfile tracking
* handles subsequent changes.
*/
cppHeaders: string[];
/**
* ALL cpp-relevant codegen outputs — the union of cppHeaders, cppSources,
* bindgenV2Cpp. cxx compilation order-depends on THIS (not `all`): cxx
* doesn't need bake.*.js, runtime.out.js, or any other rust-side embedded
* outputs. Using `all` would pull bake-codegen in cpp-only CI mode, which
* fails on old CI bun versions (bake-codegen shells out to `bun build`
* whose CSS url() handling changed between versions). cmake only wired
* bake outputs into BUN_ZIG_GENERATED_SOURCES, never C++ deps — same here.
*
* The "undeclared .h files" issue (some scripts emit .h alongside their
* declared outputs): those steps also emit a .cpp or .h that IS declared
* here, so they still run before any cxx compile.
*/
cppAll: string[];
/** The bindgenv2 .cpp outputs (compiled separately from handwritten C++). */
bindgenV2Cpp: string[];
/**
* The InternalModuleRegistryConstants.S path — compiled via cc() in bun.ts
* and linked alongside the C++ objects. Carries the concatenated JS module
* sources as a `.incbin` of the sibling `.bin`.
*/
internalModulesAsm: string;
/** The `.incbin`'d blob — implicit input to the `.S` compile edge. */
internalModulesBin: string;
/**
* Stamp output from `bun install` at repo root.
* The esbuild tool and the cppbind lezer parser live here. Any
* step that uses esbuild (or imports node_modules deps at configure
* time) depends on this.
*/
rootInstall: string;
}
/**
* Emit all codegen steps. Returns grouped outputs for downstream wiring.
*
* Call after registerCodegenRules() and after registerDirStamps() (we use
* the `mkdir_stamp` rule for the codegen dir).
*/
export function emitCodegen(n: Ninja, cfg: Config, sources: Sources): CodegenOutputs {
n.comment("─── Codegen ───");
n.blank();
const dirStamp = codegenDirStamp(cfg);
// ─── Root bun install (provides esbuild + lezer-cpp for cppbind) ───
const rootInstall = emitBunInstall(n, cfg, cfg.cwd);
const o: CodegenOutputs = {
all: [],
rustInputs: [],
rustOrderOnly: [],
cppSources: [],
cppHeaders: [],
cppAll: [],
bindgenV2Cpp: [],
internalModulesAsm: resolve(cfg.codegenDir, "InternalModuleRegistryConstants.S"),
internalModulesBin: resolve(cfg.codegenDir, "InternalModuleRegistryConstants.bin"),
rootInstall,
};
const ctx: Ctx = { n, cfg, sources, o, dirStamp };
// Configure-time write (not a ninja edge — it's a constant manifest like
// depVersionsHeader). Pushed into rustInputs so the cargo edge implicit-deps
// on it; bun_core/build.rs emits the matching `rerun-if-changed`.
const buildOptionsRs = generateBuildOptionsRs(cfg);
o.all.push(buildOptionsRs);
o.rustInputs.push(buildOptionsRs);
// Same shape: the JSON byte-classification tables, consumed by both the
// Highway kernel (.h) and the Rust scalar indexer (.rs).
const jsonByteClass = generateJsonByteClass(cfg);
o.all.push(jsonByteClass.h, jsonByteClass.rs);
o.rustInputs.push(jsonByteClass.rs);
o.cppHeaders.push(jsonByteClass.h);
const xmlByteClass = generateXmlByteClass(cfg);
o.all.push(xmlByteClass.h, xmlByteClass.rs);
o.rustInputs.push(xmlByteClass.rs);
o.cppHeaders.push(xmlByteClass.h);
emitBunError(ctx);
emitStringMaps(ctx);
emitRuntimeJs(ctx);
emitNodeFallbacks(ctx);
emitErrorCode(ctx);
emitGeneratedClasses(ctx);
emitHostExports(ctx);
emitCppBind(ctx);
emitJsModules(ctx);
emitBakeCodegen(ctx);
emitBindgenV2(ctx);
emitBindgen(ctx);
emitJsSink(ctx);
emitObjectLuts(ctx);
n.phony("codegen", o.all);
n.blank();
// Assemble cppAll — the cxx-relevant subset. See field docstring.
o.cppAll = [...new Set([...o.cppHeaders, ...o.cppSources, ...o.bindgenV2Cpp])];
return o;
}
// ───────────────────────────────────────────────────────────────────────────
// Helpers
// ───────────────────────────────────────────────────────────────────────────
/**
* Emit a `bun install` step for a package directory. Returns the stamp file
* path — use it as an implicit input on anything that needs node_modules/.
*
* The stamp is the explicit output; each node_modules/<dep>/package.json is
* an implicit output (so deleting node_modules/ correctly retriggers install,
* and restat prunes downstream when install was a no-op).
*/
function emitBunInstall(n: Ninja, cfg: Config, pkgDir: string): string {
const depPackageJsons = readPackageDeps(pkgDir);
assert(depPackageJsons.length > 0, `package.json has no dependencies: ${pkgDir}/package.json`);
const pkgJson = resolve(pkgDir, "package.json");
const lockfile = resolve(pkgDir, "bun.lock");
// bun.lock is optional (some packages might not have one yet), but if it
// exists it MUST be an input — lockfile bumps reinstall.
const inputs = [pkgJson];
try {
readFileSync(lockfile); // exists check
inputs.push(lockfile);
} catch {
// no lockfile, that's fine
}
// Stamp lives in the build dir, not the package dir — keeps the source
// tree clean and makes `rm -rf build/` fully reset install state.
// Uniqueify by hashing the package dir path (multiple installs possible).
const stampName = pkgDir.replace(/[^A-Za-z0-9]+/g, "_");
const stamp = resolve(cfg.buildDir, "stamps", `install_${stampName}.stamp`);
n.build({
outputs: [stamp],
implicitOutputs: depPackageJsons,
rule: "bun_install",
inputs,
orderOnlyInputs: [resolve(cfg.buildDir, "stamps", ".dir")],
// stamp must be absolute — the command `cd $dir && ... && touch $stamp`
// runs from $dir, not from buildDir. n.rel() would break that.
vars: { dir: pkgDir, stamp },
});
return stamp;
}
/**
/** `--debug=ON` / `--debug=OFF` flag used by several scripts. */
function debugFlag(cfg: Config): string {
return cfg.debug ? "--debug=ON" : "--debug=OFF";
}
/**
* Shell-quote args for a codegen rule command string. These rules wrap in
* `cmd /c` on a Windows HOST, so quoting follows the host shell.
*/
function shJoin(cfg: Config, args: string[]): string {
return quoteArgs(args, cfg.host.os === "windows");
}
// ───────────────────────────────────────────────────────────────────────────
// Individual step emitters
// ───────────────────────────────────────────────────────────────────────────
function emitBunError({ n, cfg, sources, o, dirStamp }: Ctx): void {
const sourceDir = resolve(cfg.cwd, "packages", "bun-error");
const installStamp = emitBunInstall(n, cfg, sourceDir);
const outDir = resolve(cfg.codegenDir, "bun-error");
const outputs = [resolve(outDir, "index.js"), resolve(outDir, "bun-error.css")];
n.build({
outputs,
rule: "esbuild",
inputs: sources.bunError,
// Install stamp as implicit — changing preact version re-bundles.
// Root install as well (esbuild tool lives there).
implicitInputs: [installStamp, o.rootInstall],
orderOnlyInputs: [dirStamp],
vars: {
cwd: sourceDir,
desc: "bun-error",
args: shJoin(cfg, [
"index.tsx",
"bun-error.css",
`--outdir=${outDir}`,
`--define:process.env.NODE_ENV="production"`,
"--minify",
"--bundle",
"--platform=browser",
"--format=esm",
]),
},
});
o.all.push(...outputs);
o.rustInputs.push(...outputs);
}
function emitRuntimeJs({ n, cfg, o, dirStamp }: Ctx): void {
const src = resolve(cfg.cwd, "src", "runtime.bun.js");
const out = resolve(cfg.codegenDir, "runtime.out.js");
n.build({
outputs: [out],
rule: "esbuild",
inputs: [src],
implicitInputs: [o.rootInstall],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "runtime.out.js",
args: shJoin(cfg, [
src,
`--outfile=${out}`,
`--define:process.env.NODE_ENV="production"`,
"--target=esnext",
"--bundle",
"--format=esm",
"--platform=node",
"--minify",
"--external:/bun:*",
]),
},
});
o.all.push(out);
o.rustInputs.push(out);
}
function emitNodeFallbacks({ n, cfg, sources, o, dirStamp }: Ctx): void {
const sourceDir = resolve(cfg.cwd, "src", "node-fallbacks");
const installStamp = emitBunInstall(n, cfg, sourceDir);
const outDir = resolve(cfg.codegenDir, "node-fallbacks");
// Two outputs per source: the bundled `.js` (read at runtime by debug
// builds) and its zstd-compressed `.js.zst` twin (embedded by release
// builds — see src/resolver/node_fallbacks.rs).
const jsOutputs = sources.nodeFallbacks.map(s => resolve(outDir, basename(s)));
const outputs = [...jsOutputs, ...jsOutputs.map(o => `${o}.zst`)];
// The script (build-fallbacks.ts) reads its args as [outdir, ...sources]
// but actually ignores the sources — it does readdirSync(".") to discover
// files. We pass them anyway so ninja tracks them as inputs.
const script = resolve(sourceDir, "build-fallbacks.ts");
n.build({
outputs,
rule: "codegen",
inputs: [script, ...sources.nodeFallbacks],
implicitInputs: [installStamp],
orderOnlyInputs: [dirStamp],
vars: {
cwd: sourceDir,
desc: "node-fallbacks/*.js",
// `bun run build-fallbacks` resolves to `./build-fallbacks.ts` in cwd
args: shJoin(cfg, ["run", "build-fallbacks", outDir, ...sources.nodeFallbacks]),
},
});
o.all.push(...outputs);
o.rustInputs.push(...outputs);
// ─── react-refresh (separate bundle, uses node-fallbacks' node_modules) ───
const rrSrc = resolve(sourceDir, "node_modules", "react-refresh", "cjs", "react-refresh-runtime.development.js");
const rrOut = resolve(outDir, "react-refresh.js");
n.build({
outputs: [rrOut],
rule: "codegen",
inputs: [resolve(sourceDir, "package.json"), resolve(sourceDir, "bun.lock")],
implicitInputs: [installStamp],
orderOnlyInputs: [dirStamp],
vars: {
cwd: sourceDir,
desc: "node-fallbacks/react-refresh.js",
args: shJoin(cfg, [
"build",
rrSrc,
`--outfile=${rrOut}`,
"--target=browser",
"--format=cjs",
"--minify",
`--define:process.env.NODE_ENV="development"`,
]),
},
});
o.all.push(rrOut);
o.rustInputs.push(rrOut);
}
/**
* `*.string-map.ts` → length-bucketed lookup fns (`generate-string-map.ts`).
* Output lands **in-tree** as `<dir>/<stem>.generated.rs` (checked in) so
* plain `cargo check` / rust-analyzer work without `BUN_CODEGEN_DIR` or a
* per-crate `build.rs`. The `.string-map.ts` is the source of truth; the
* `.generated.rs` is a deterministic artifact. A stale checked-in copy is
* regenerated and pushed back to the PR by the autofix workflow
* (.github/workflows/format.yml runs `codegen:string-maps` with the
* formatters); `bun run codegen:verify` is the local equivalent. `restat = 1`
* on the codegen rule + `writeIfNotChanged` in the script keep this a no-op
* when unchanged.
*/
function emitStringMaps({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "generate-string-map.ts");
for (const src of sources.stringMaps) {
// `src/js_parser/defines_table.string-map.ts` → `src/js_parser/defines_table.generated.rs`
const stem = basename(src).replace(/\.string-map\.ts$/, "");
const out = resolve(dirname(src), `${stem}.generated.rs`);
n.build({
outputs: [out],
rule: "codegen",
inputs: [script, src],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: `string-map ${stem}`,
args: shJoin(cfg, ["run", script, src, out]),
},
});
o.all.push(out);
o.rustInputs.push(out);
}
}
function emitErrorCode({ n, cfg, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "generate-node-errors.ts");
const inputs = [
script,
resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"),
// ErrorCode.cpp/.h are listed in CMake but the script doesn't read them;
// they're there so changes to the handwritten side (e.g. new error
// category added to the C++ enum) invalidate this step. We include them
// for the same reason.
resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.cpp"),
resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.h"),
];
const cppOutputs = [resolve(cfg.codegenDir, "ErrorCode+List.h"), resolve(cfg.codegenDir, "ErrorCode+Data.h")];
const rustOutput = resolve(cfg.codegenDir, "ErrorCode.generated.rs");
const outputs = [...cppOutputs, rustOutput];
n.build({
outputs,
rule: "codegen",
inputs,
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "ErrorCode+*.h",
args: shJoin(cfg, ["run", script, cfg.codegenDir]),
},
});
o.all.push(...outputs);
o.rustInputs.push(...outputs);
o.cppHeaders.push(...cppOutputs);
}
function emitGeneratedClasses({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "generate-classes.ts");
const outputs = [
resolve(cfg.codegenDir, "ZigGeneratedClasses.h"),
resolve(cfg.codegenDir, "ZigGeneratedClasses.cpp"),
resolve(cfg.codegenDir, "ZigGeneratedClasses+lazyStructureHeader.h"),
resolve(cfg.codegenDir, "ZigGeneratedClasses+DOMClientIsoSubspaces.h"),
resolve(cfg.codegenDir, "ZigGeneratedClasses+DOMIsoSubspaces.h"),
resolve(cfg.codegenDir, "ZigGeneratedClasses+lazyStructureImpl.h"),
// Rust sibling: include!()'d by src/runtime/generated_classes.rs. Must be
// a declared output so the cargo edge (which lists this in rustInputs)
// re-invokes when generate-classes.ts changes — cargo doesn't track
// include!() deps and the includer shim's mtime never moves.
resolve(cfg.codegenDir, "generated_classes.rs"),
];
n.build({
outputs,
rule: "codegen",
inputs: [script, ...sources.zigGeneratedClasses],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "ZigGeneratedClasses.{cpp,h,rs}",
args: shJoin(cfg, ["run", script, ...sources.zigGeneratedClasses, cfg.codegenDir]),
},
});
o.all.push(...outputs);
o.rustInputs.push(...outputs);
o.cppSources.push(outputs[1]!); // .cpp
o.cppHeaders.push(outputs[0]!, outputs[2]!, outputs[3]!, outputs[4]!, outputs[5]!); // .h files
}
function emitHostExports({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "generate-host-exports.ts");
const output = resolve(cfg.codegenDir, "generated_host_exports.rs");
// Inputs: every .rs under src/runtime + src/jsc (the scrape scope). The
// `sources.rust` glob already covers these plus Cargo manifests; filter to
// the two crates so unrelated edits (e.g. src/bundler) don't re-run the
// scrape. restat=1 + writeIfNotChanged means a no-marker-change edit
// produces identical output and the cargo step is pruned.
const slashed = (p: string) => p.replace(/\\/g, "/");
const scrapeDirs = [slashed(`${cfg.cwd}/src/runtime/`), slashed(`${cfg.cwd}/src/jsc/`)];
const rsInputs = sources.rust.filter(p => {
const q = slashed(p);
return q.endsWith(".rs") && scrapeDirs.some(d => q.includes(d)) && !q.endsWith("generated_host_exports.rs");
});
n.build({
outputs: [output],
rule: "codegen",
inputs: [script],
implicitInputs: rsInputs,
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "generated_host_exports.rs",
args: shJoin(cfg, ["run", script, cfg.codegenDir]),
},
});
o.all.push(output);
// bun_runtime/build.rs panics if this file is absent, so the rust_build edge
// must wait on it — `rustInputs` is the implicit-dep list the
// cargo edge consumes.
o.rustInputs.push(output);
}
function emitCppBind({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "cppbind.ts");
const outputRs = resolve(cfg.codegenDir, "cpp.rs");
// Write the .cpp file list for cppbind to scan. Build system owns the
// glob (glob-sources.ts); we hand the result to cppbind
// as an explicit input instead of it reading a magic hardcoded path.
// Relative paths, forward slashes — same format cppbind expects.
//
// Written at CONFIGURE time (not via a ninja rule): it's a derived
// manifest from our glob, and we want writeIfChanged semantics so a
// stable .cpp set → unchanged mtime → ninja doesn't re-run cppbind.
// codegenDir may not exist yet on first configure — mkdir it.
mkdirSync(cfg.codegenDir, { recursive: true });
const cxxSourcesFile = resolve(cfg.codegenDir, "cxx-sources.txt");
const cxxSourcesLines = sources.cxx.map(p => relative(cfg.cwd, p).replace(/\\/g, "/"));
writeIfChanged(cxxSourcesFile, cxxSourcesLines.join("\n") + "\n");
n.build({
outputs: [outputRs],
rule: "codegen",
inputs: [script],
// cppbind scans ALL .cpp files for [[ZIG_EXPORT]] annotations. Every
// .cpp is an implicit input so changing an annotation retriggers.
// ~540 files — ninja handles this fine via .ninja_deps stat caching.
// cxxSourcesFile also listed — if the list itself changes (file
// added/removed), that's a different input set.
implicitInputs: [
cxxSourcesFile,
...sources.cxx,
...sources.jsCodegen,
// cppbind auto-runs `bun install` for its lezer-cpp dep if needed,
// but depending on root install ensures that already happened on
// first build (and catches lezer version bumps).
o.rootInstall,
],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "cpp.rs (cppbind)",
// cppbind.ts takes: <srcdir> <codegendir> <cxx-sources>. No `run` —
// direct script invocation (`${BUN_EXECUTABLE} ${script} ...`).
args: shJoin(cfg, [script, resolve(cfg.cwd, "src"), cfg.codegenDir, cxxSourcesFile]),
},
});
o.all.push(outputRs);
// bun_jsc `include!`s cpp.rs — the cargo edge must order after this.
o.rustInputs.push(outputRs);
}
function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "bundle-modules.ts");
// InternalModuleRegistry.cpp is read by the script (for a sanity check).
const extraInput = resolve(cfg.cwd, "src", "jsc", "bindings", "InternalModuleRegistry.cpp");
// replacements.ts bakes ErrorCode.ts indices into every bundled module
// ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves
// stale error numbers in the JS bundles while the C++ enum regenerates.
const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts");
const outputs = [
resolve(cfg.codegenDir, "WebCoreJSBuiltins.cpp"),
resolve(cfg.codegenDir, "WebCoreJSBuiltins.h"),
resolve(cfg.codegenDir, "InternalModuleRegistryConstants.h"),
resolve(cfg.codegenDir, "InternalModuleRegistry+createInternalModuleById.h"),
resolve(cfg.codegenDir, "InternalModuleRegistry+enum.h"),
resolve(cfg.codegenDir, "InternalModuleRegistry+numberOfModules.h"),
resolve(cfg.codegenDir, "NativeModuleImpl.h"),
resolve(cfg.codegenDir, "SyntheticModuleType.h"),
resolve(cfg.codegenDir, "GeneratedJS2Native.h"),
// Rust sibling: include!()'d by src/runtime/generated_js2native.rs. Must be
// a declared output so the cargo edge re-invokes when bundle-modules.ts /
// generate-js2native.ts changes — the includer shim's mtime never moves.
resolve(cfg.codegenDir, "generated_js2native.rs"),
// Specifier → module-ID tag table: include!()'d by the
// `resolved_source_tag` module in src/jsc/lib.rs. Declared for the same
// reason as generated_js2native.rs.
resolve(cfg.codegenDir, "generated_resolved_source_tag.rs"),
o.internalModulesAsm,
o.internalModulesBin,
];
n.build({
outputs,
rule: "codegen",
inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "JS modules (bundle-modules)",
// Note: arg is BUILD_PATH (buildDir), not CODEGEN_PATH. The script
// derives CODEGEN_DIR = join(BUILD_PATH, "codegen") internally.
args: shJoin(cfg, ["run", script, debugFlag(cfg), cfg.buildDir]),
},
});
o.all.push(...outputs);
o.rustInputs.push(...outputs);
o.cppSources.push(outputs[0]!); // WebCoreJSBuiltins.cpp
o.cppHeaders.push(...outputs.filter(p => p.endsWith(".h")));
}
function emitBakeCodegen({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "bake-codegen.ts");
// InternalModuleRegistry.cpp is listed as a dep in CMake for this step too.
// The script doesn't read it; CMake copy-paste. We skip it.
// CMake only declares bake.client.js and bake.server.js as outputs. The
// script also emits bake.error.js (the runtime embeds it). We declare
// all three.
//
// Debug uses order-only deps on these .js files (loaded at runtime,
// no need to relink on change). Release uses implicit (embedded into
// the binary, must relink).
const outputs = [
resolve(cfg.codegenDir, "bake.client.js"),
resolve(cfg.codegenDir, "bake.server.js"),
resolve(cfg.codegenDir, "bake.error.js"),
];
n.build({
outputs,
rule: "codegen",
inputs: [script, ...sources.bakeRuntime],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "bake.{client,server,error}.js",
args: shJoin(cfg, ["run", script, debugFlag(cfg), `--codegen-root=${cfg.codegenDir}`]),
},
});
o.all.push(...outputs);
// Debug: read at RUNTIME (not embedded) → the build only needs existence.
// Release: embedded into the binary → content changes must trigger a relink.
if (cfg.debug) {
o.rustOrderOnly.push(...outputs);
} else {
o.rustInputs.push(...outputs);
}
}
/** Exported (with emitBindgen) for test/internal/build-codegen-declared-outputs.test.ts. */
export function emitBindgenV2({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "bindgenv2", "script.ts");
// The script's output set depends on which NamedTypes the .bindv2.ts files
// export. We run `--command=list-outputs` SYNCHRONOUSLY at configure time
// to get the real list. This is a configure-time dependency on bun +
// sources — same tradeoff CMake makes with execute_process().
//
// If list-outputs fails (e.g. syntax error in a .bindv2.ts file), we fail
// configure immediately with a clear error. Better to catch that here than
// get a cryptic "multiple rules generate <unknown>" from ninja.
const sourcesArg = sources.bindgenV2.join(",");
const listResult = spawnSync(
cfg.bun,
["run", script, "--command=list-outputs", `--sources=${sourcesArg}`, `--codegen-path=${cfg.codegenDir}`],
{ cwd: cfg.cwd, encoding: "utf8" },
);
if (listResult.status !== 0) {
throw new BuildError(`bindgenv2 list-outputs failed (exit ${listResult.status})`, {
file: script,
hint: listResult.stderr?.trim(),
});
}
// Output is semicolon-separated (CMake list format).
const allOutputs = listResult.stdout
.trim()
.split(";")
.filter(p => p.length > 0);
assert(allOutputs.length > 0, "bindgenv2 list-outputs returned no files");
const cppOutputs = allOutputs.filter(p => p.endsWith(".cpp"));
const headerOutputs = allOutputs.filter(p => p.endsWith(".h"));
const other = allOutputs.filter(p => !p.endsWith(".cpp") && !p.endsWith(".h"));
assert(other.length === 0, `bindgenv2 emitted unexpected output type: ${other.join(", ")}`);
n.build({
outputs: allOutputs,
rule: "codegen",
inputs: [script, ...sources.bindgenV2, ...sources.bindgenV2Internal],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "bindgenv2",
args: shJoin(cfg, [
"run",
script,
"--command=generate",
`--codegen-path=${cfg.codegenDir}`,
`--sources=${sourcesArg}`,
]),
},
});
o.all.push(...allOutputs);
o.bindgenV2Cpp.push(...cppOutputs);
o.cppHeaders.push(...headerOutputs);
}
export function emitBindgen({ n, cfg, sources, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "bindgen.ts");
const cppOut = resolve(cfg.codegenDir, "GeneratedBindings.cpp");
// Plus one header per .bind.ts (node_os.bind.ts → GeneratedNodeOs.h), which
// hand-written .cpp files include; see "Undeclared outputs" above.
const headers = sources.bindgen.map(src =>
resolve(cfg.codegenDir, `Generated${pascalCase(basename(src, ".bind.ts"))}.h`),
);
// bindgen.ts scans src/ for .bind.ts files itself; this list only tells
// ninja the inputs and which headers come out. New .bind.ts files need a
// reconfigure to be picked up (next glob gets them).
n.build({
outputs: [cppOut, ...headers],
rule: "codegen",
inputs: [script, ...sources.bindgen],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: ".bind.ts → GeneratedBindings.cpp",
args: shJoin(cfg, ["run", script, debugFlag(cfg), `--codegen-root=${cfg.codegenDir}`]),
},
});
o.all.push(cppOut, ...headers);
o.cppSources.push(cppOut);
o.cppHeaders.push(...headers);
}
/** Same transform as `pascal()` in src/codegen/bindgen-lib-internal.ts: `node_os` → `NodeOs`. */
function pascalCase(s: string): string {
return s[0]!.toUpperCase() + s.slice(1).replace(/[_-](\w)?/g, (_, c?: string) => c?.toUpperCase() ?? "");
}
function emitJsSink({ n, cfg, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "generate-jssink.ts");
const hashTableScript = resolve(cfg.cwd, "src", "codegen", "create-hash-table.ts");
const perlScript = resolve(cfg.cwd, "src", "codegen", "create_hash_table");
// generate-jssink.ts writes JSSink.{cpp,h,lut.txt} + generated_jssink.rs (the
// Rust `#[no_mangle]` thunks), then internally spawns create-hash-table.ts to
// convert .lut.txt → .lut.h. So all of {cpp,h,lut.h,rs} are outputs of this
// one step (.lut.txt is really an intermediate — we don't expose it).
const jssinkRs = resolve(cfg.codegenDir, "generated_jssink.rs");
const outputs = [
resolve(cfg.codegenDir, "JSSink.cpp"),
resolve(cfg.codegenDir, "JSSink.h"),
resolve(cfg.codegenDir, "JSSink.lut.h"),
jssinkRs,
];
n.build({
outputs,
rule: "codegen",
inputs: [script, hashTableScript, perlScript],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: "JSSink.{cpp,h,lut.h,rs}",
args: shJoin(cfg, ["run", script, cfg.codegenDir]),
},
});
o.all.push(...outputs);
o.cppSources.push(outputs[0]!); // .cpp
o.cppHeaders.push(outputs[1]!, outputs[2]!); // .h + .lut.h
// bun_runtime/build.rs panics if generated_jssink.rs is absent, so the
// rust_build edge must order after this codegen step — `rustInputs` is the
// implicit-dep list the cargo edge consumes (same as generated_host_exports).
// Without this, `mode: "rust-only"` (CI's build-rust job, which compiles no
// C++ so nothing else pulls JSSink.cpp/.h) never runs this edge and cargo
// hits the missing file.
o.rustInputs.push(jssinkRs);
}
/**
* LUT sources → .lut.h outputs. One build statement PER pair, because the
* script takes a single (src, out) pair.
*
* The source .cpp files contain `@begin XXXTable ... @end` blocks that the
* perl script parses into JSC HashTableValue arrays. The TS wrapper adds
* platform-specific #if preprocessing via TARGET_PLATFORM env var.
*/
function emitObjectLuts({ n, cfg, o, dirStamp }: Ctx): void {
const script = resolve(cfg.cwd, "src", "codegen", "create-hash-table.ts");
const perlScript = resolve(cfg.cwd, "src", "codegen", "create_hash_table");
// (source, output) pairs.
const pairs: [src: string, out: string][] = [
[resolve(cfg.cwd, "src/jsc/bindings/BunObject.cpp"), resolve(cfg.codegenDir, "BunObject.lut.h")],
[resolve(cfg.cwd, "src/jsc/bindings/ZigGlobalObject.lut.txt"), resolve(cfg.codegenDir, "ZigGlobalObject.lut.h")],
[resolve(cfg.cwd, "src/jsc/bindings/JSBuffer.cpp"), resolve(cfg.codegenDir, "JSBuffer.lut.h")],
[resolve(cfg.cwd, "src/jsc/bindings/BunProcess.cpp"), resolve(cfg.codegenDir, "BunProcess.lut.h")],
[
resolve(cfg.cwd, "src/jsc/bindings/ProcessBindingBuffer.cpp"),
resolve(cfg.codegenDir, "ProcessBindingBuffer.lut.h"),
],
[
resolve(cfg.cwd, "src/jsc/bindings/ProcessBindingConstants.cpp"),
resolve(cfg.codegenDir, "ProcessBindingConstants.lut.h"),
],
[resolve(cfg.cwd, "src/jsc/bindings/ProcessBindingFs.cpp"), resolve(cfg.codegenDir, "ProcessBindingFs.lut.h")],
[
resolve(cfg.cwd, "src/jsc/bindings/ProcessBindingNatives.cpp"),
resolve(cfg.codegenDir, "ProcessBindingNatives.lut.h"),
],
[
resolve(cfg.cwd, "src/jsc/bindings/ProcessBindingHTTPParser.cpp"),
resolve(cfg.codegenDir, "ProcessBindingHTTPParser.lut.h"),
],
[resolve(cfg.cwd, "src/jsc/modules/NodeModuleModule.cpp"), resolve(cfg.codegenDir, "NodeModuleModule.lut.h")],
[resolve(cfg.cwd, "src/jsc/bindings/webcore/JSEvent.cpp"), resolve(cfg.codegenDir, "JSEvent.lut.h")],
];
// create-hash-table.ts reads TARGET_PLATFORM env (set in registerCodegenRules)
// with process.platform fallback.
for (const [src, out] of pairs) {
n.build({
outputs: [out],
rule: "codegen",
inputs: [src],
implicitInputs: [script, perlScript],
orderOnlyInputs: [dirStamp],
vars: {
cwd: cfg.cwd,
desc: basename(out),
args: shJoin(cfg, ["run", script, src, out]),
},
});
o.all.push(out);
o.cppHeaders.push(out);
}
}