P5.4 core: drive the whole build from sx default_pipeline (no auto-emit/link)

The compiler's post-IR role shrinks to: codegen -> invoke the build callback.
There is NO Zig auto-emit / auto-link anymore; emit + link are sx-called actions.

- emit_object() is now an ACTION (verify + emit via a host BuildHooks vtable),
  returning the object path. New query primitives build_output/build_target/
  build_frameworks/build_flags (data reads from the merged BuildConfig).
- library/modules/build.sx imports compiler.sx and defines default_pipeline:
  emit_object -> gather c_object_paths -> link(objs, output, libs, fws, flags,
  target). The std<->build import cycle is handled by the resolver.
- The compiler FORCE-LOWERS default_pipeline (well-known name) and AUTO-INVOKES
  it post-codegen when no on_build/set_post_link_callback override was
  registered (driver's final fallback: invokeByName default_pipeline).
- Prelude-less programs (e.g. asm tests) don't import build.sx, so the BUILD
  path auto-imports modules/build.sx (idempotent if already transitive) so
  default_pipeline is always available. JIT sx run is untouched (emits in-process).
- Removed the build cache short-circuits (incompatible with the always-run sx
  driver; a future cache can live in default_pipeline).

Benign 37-.ir churn (build.sx grew); zero behavior changes (verified diff is
.ir-only). 705/0 both gates.
This commit is contained in:
agra
2026-06-19 09:22:54 +03:00
parent 1f796e92ec
commit d178454841
44 changed files with 93042 additions and 80510 deletions

View File

@@ -71,11 +71,11 @@ pub const BuildConfig = struct {
c_object_paths: []const []const u8 = &.{},
link_libraries: []const []const u8 = &.{},
/// Path of the object file the compiler emitted for this build (`.sx-tmp/main.o`
/// or the cached `.o`). Forwarded by main.zig before the post-link callback so
/// the sx build driver can read it via the `emit_object()` compiler primitive
/// (the compiler emits the object eagerly; the primitive returns its path).
object_path: ?[]const u8 = null,
/// The fully-merged link flags (CLI `extra_link_flags` + `#run` build-block
/// flags), forwarded by main.zig. The sx driver reads them via `build_flags()`
/// and passes them to `link`. (Distinct from `link_flags`, which holds only
/// the `#run`-accumulated subset.)
merged_link_flags: []const []const u8 = &.{},
/// Host-installed callbacks for build-pipeline ACTIONS the comptime VM can't
/// perform itself (it can't depend on the driver — `core`/`main`/`target`).
@@ -126,6 +126,10 @@ pub const BuildConfig = struct {
/// error here and the VM surfaces it as a hard build error.
pub const BuildHooks = struct {
ctx: *anyopaque,
/// Verify + emit the codegen'd module to its object file; return the path
/// (ctx-owned). The `emit_object()` primitive — an ACTION, since the driver
/// no longer auto-emits (everything is sx-driven via `default_pipeline`).
emit_object: *const fn (ctx: *anyopaque) anyerror![]const u8,
/// Link `objects` → `output`, with the given `libraries` / `frameworks` /
/// link `flags` / `target` triple. (`objects` is the full object list; the
/// adapter splits it for the underlying linker.)

View File

@@ -72,6 +72,10 @@ pub const bound_fns = [_]BoundFn{
.{ .sx_name = "link_libraries", .handler = handleBuildPipelineQuery },
.{ .sx_name = "emit_object", .handler = handleBuildPipelineQuery },
.{ .sx_name = "link", .handler = handleBuildPipelineQuery },
.{ .sx_name = "build_output", .handler = handleBuildPipelineQuery },
.{ .sx_name = "build_target", .handler = handleBuildPipelineQuery },
.{ .sx_name = "build_frameworks", .handler = handleBuildPipelineQuery },
.{ .sx_name = "build_flags", .handler = handleBuildPipelineQuery },
};
/// Legacy-path stub for the Phase 5 build-pipeline primitives — see the

View File

@@ -1523,17 +1523,42 @@ pub const Vm = struct {
return self.failMsg("comptime link_libraries: no build config threaded into the VM");
return try self.makeStringList(table, result_ty, bc.link_libraries);
}
// `emit_object() -> string` — the compiler emits the object eagerly (the Zig
// driver, before the post-link callback); this returns its path. A query,
// not an action — so no driver callback is needed (unlike `link`).
// `emit_object() -> string` — ACTION: verify + emit the codegen'd module
// to its object file and return the path. Dispatches through the
// host-installed hook (the VM can't emit itself); the driver no longer
// auto-emits (everything is sx-driven via `default_pipeline`).
if (std.mem.eql(u8, name, "emit_object")) {
if (args.len != 0) return self.failMsg("comptime emit_object: expected no args");
const bc = self.build_config orelse
return self.failMsg("comptime emit_object: no build config threaded into the VM");
const path = bc.object_path orelse
return self.failMsg("comptime emit_object: no object was emitted (object_path unset)");
const hooks = bc.build_hooks orelse
return self.failMsg("comptime emit_object: no build hooks installed (emit is a post-codegen-only action)");
const path = hooks.emit_object(hooks.ctx) catch
return self.failMsg("comptime emit_object: object emission failed");
return try self.makeStringValue(table, path);
}
// Build-config metadata the sx driver passes to `link`. Read-only data
// forwarded by `main.zig` (the merged CLI + `#run` build config).
if (std.mem.eql(u8, name, "build_output")) {
if (args.len != 0) return self.failMsg("comptime build_output: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_output: no build config");
return try self.makeStringValue(table, bc.output_path orelse "");
}
if (std.mem.eql(u8, name, "build_target")) {
if (args.len != 0) return self.failMsg("comptime build_target: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_target: no build config");
return try self.makeStringValue(table, bc.target_triple orelse "");
}
if (std.mem.eql(u8, name, "build_frameworks")) {
if (args.len != 0) return self.failMsg("comptime build_frameworks: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_frameworks: no build config");
return try self.makeStringList(table, result_ty, bc.target_frameworks);
}
if (std.mem.eql(u8, name, "build_flags")) {
if (args.len != 0) return self.failMsg("comptime build_flags: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_flags: no build config");
return try self.makeStringList(table, result_ty, bc.merged_link_flags);
}
// `link(objects, output, libraries, frameworks, flags, target)` — the one
// genuine ACTION: dispatch to the host-installed linker (the VM can't link
// itself). Void return (the build callback isn't fallible — Phase 5

View File

@@ -155,6 +155,14 @@ fn isExportedEntryName(name: []const u8) bool {
std.mem.startsWith(u8, name, "Java_");
}
/// The well-known stdlib build driver (`library/modules/build.sx`). It is invoked
/// by the compiler post-codegen when no `#run on_build(...)` override exists, but
/// is never CALLED from sx — so it must be force-lowered like an OS entry point,
/// else lazy lowering leaves it a bodiless `declare` stub the VM can't run.
fn isDefaultBuildPipeline(name: []const u8) bool {
return std.mem.eql(u8, name, "default_pipeline");
}
/// Lower all top-level declarations from a root node.
/// Pass 1: Scan all declarations (register ASTs, types, extern stubs).
/// Pass 2: Lower only `main` (everything else is lowered lazily on demand).
@@ -222,6 +230,12 @@ pub fn lowerRoot(self: *Lowering, root: *const Node) void {
self.checkInfiniteSize();
// Pass 2: lower main (and comptime side-effects)
self.lowerMainAndComptime(decls);
// Pass 2b: force-lower the stdlib build driver `default_pipeline` (in the
// flat-imported `modules/build.sx`, so NOT in the main `decls` above). The
// compiler auto-invokes it post-codegen when no `#run on_build(...)` override
// exists, but nothing CALLS it from sx — so without this it stays a bodiless
// stub the build VM can't run. No-ops when build.sx isn't imported.
self.lazyLowerFunction("default_pipeline");
// Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
self.lowerDeferredTypeFns();
// Pass 4: target-specific entry-point sanity checks
@@ -1457,7 +1471,7 @@ pub fn lowerMainAndComptime(self: *Lowering, decls: []const *const Node) void {
// consumption (often never called from sx), so force-lower
// them like OS-called entry points — else lazy lowering
// leaves them as bodiless `declare` stubs (Phase 2).
if (isExportedEntryName(cd.name) or cd.value.data.fn_decl.extern_export == .export_) {
if (isExportedEntryName(cd.name) or cd.value.data.fn_decl.extern_export == .export_ or isDefaultBuildPipeline(cd.name)) {
self.lazyLowerFunction(cd.name);
}
} else if (cd.value.data == .comptime_expr) {

View File

@@ -573,12 +573,24 @@ fn compile(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8, out
/// dispatches `link(...)` through a `BuildHooks` whose `ctx` is one of these. The
/// VM passes the full object list; `target.link` takes (first object, rest), but
/// treats both as plain inputs, so the split is immaterial.
const LinkHooksCtx = struct {
const BuildHooksCtx = struct {
comp: *sx.core.Compilation,
obj_path: [:0]const u8,
allocator: std.mem.Allocator,
io: std.Io,
base_config: sx.target.TargetConfig,
has_jni_main: bool,
/// `emit_object()` — verify + emit the codegen'd module to its object file,
/// return the path. The compiler no longer auto-emits; the sx driver calls this.
fn emitObject(ctx_opaque: *anyopaque) anyerror![]const u8 {
const self: *BuildHooksCtx = @ptrCast(@alignCast(ctx_opaque));
const e = if (self.comp.ir_emitter) |*p| p else return error.NoEmitter;
try e.verifyWithMessage();
try e.emitObject(self.obj_path.ptr);
return self.obj_path;
}
fn link(
ctx_opaque: *anyopaque,
objects: []const []const u8,
@@ -590,14 +602,12 @@ const LinkHooksCtx = struct {
) anyerror!void {
_ = target; // the triple is already encoded in base_config (CLI-derived);
// explicit-triple reconciliation is a P5.4 concern when sx owns the config.
const self: *LinkHooksCtx = @ptrCast(@alignCast(ctx_opaque));
const self: *BuildHooksCtx = @ptrCast(@alignCast(ctx_opaque));
if (objects.len == 0) return error.NoObjects;
var cfg = self.base_config;
// Union the explicit `flags` with the CLI-derived ones (don't drop either).
var all_flags: std.ArrayList([]const u8) = .empty;
for (self.base_config.extra_link_flags) |f| try all_flags.append(self.allocator, f);
for (flags) |f| try all_flags.append(self.allocator, f);
cfg.extra_link_flags = all_flags.items;
// The passed `flags` are already the full merged set (`build_flags()` returns
// the merged CLI + `#run` flags), so use them as-is rather than re-unioning.
cfg.extra_link_flags = flags;
try sx.target.link(self.allocator, self.io, objects[0], objects[1..], output, libraries, frameworks, cfg, self.has_jni_main);
}
};
@@ -615,6 +625,21 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
comp.parse() catch { comp.renderErrors(); return error.CompileError; };
timer.record("parse");
// Auto-import the stdlib build driver so `default_pipeline` (+ the build
// primitives) is always present to drive the build — the program need not
// import the prelude (e.g. minimal asm tests). A flat import is idempotent if
// it's already pulled in transitively. BUILD-path only: the JIT `sx run` path
// emits + executes in-process and never invokes default_pipeline.
if (comp.root) |r| {
const imp = try allocator.create(sx.ast.Node);
imp.* = .{ .span = r.span, .source_file = input_path, .data = .{ .import_decl = .{ .path = "modules/build.sx", .name = null } } };
const old_decls = r.data.root.decls;
const new_decls = try allocator.alloc(*sx.ast.Node, old_decls.len + 1);
new_decls[0] = imp;
@memcpy(new_decls[1..], old_decls);
r.data.root.decls = new_decls;
}
timer.mark();
comp.resolveImports() catch { comp.renderErrors(); return error.CompileError; };
timer.record("imports");
@@ -630,52 +655,16 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
const obj_path = try std.fmt.allocPrintSentinel(allocator, "{s}/main.o", .{tmp_dir}, 0);
// Cache: compute key and check for cached binary/.o.
// Disabled for programs with top-level #run (same guard as the JIT
// path): the #run interp runs during codegen, and skipping codegen
// loses its effects — build config (link flags, frameworks, output
// path, bundling) and print side effects alike.
const use_cache = enable_cache and !hasTopLevelRun(root);
const key = computeCacheKey(source, &comp.import_sources, target_config);
const cache_obj = try cachePath(allocator, key, "o");
const cache_bin = try cachePath(allocator, key, "bin");
// Level 1: Try cached binary (skip everything — no codegen, no link).
// Skipped under --emit-obj, which needs the freshly-emitted object kept.
if (use_cache and !target_config.emit_obj) bin_cache: {
std.Io.Dir.copyFile(.cwd(), cache_bin, .cwd(), output_path, io, .{}) catch break :bin_cache;
timer.record("cache");
return;
}
// Level 2: Try cached .o (skip codegen+emit, still need link)
const used_obj_cache = blk: {
if (!use_cache) break :blk false;
std.Io.Dir.copyFile(.cwd(), cache_obj, .cwd(), obj_path, io, .{}) catch break :blk false;
break :blk true;
};
if (used_obj_cache) {
timer.record("cache");
} else {
// Cache MISS — full codegen + emit
timer.mark();
comp.generateCode() catch { comp.renderErrors(); return error.CompileError; };
timer.record("codegen");
timer.mark();
comp.ir_emitter.?.verifyWithMessage() catch return error.CompileError;
timer.record("verify");
timer.mark();
comp.ir_emitter.?.emitObject(obj_path.ptr) catch return error.CompileError;
timer.record("emit");
// Save .o to cache
if (use_cache) {
std.Io.Dir.copyFile(.cwd(), obj_path, .cwd(), cache_obj, io, .{ .make_path = true }) catch {};
}
}
// Codegen only. There is NO auto-emit / auto-link: the build is driven
// entirely by the sx `default_pipeline` (or a user `#run on_build(...)`
// override), invoked after codegen below. `emit_object` (verify + object
// emission) and `link` run as sx-called ACTIONS through the build hooks.
// (The build cache short-circuited codegen, which the always-run sx driver
// can't tolerate — removed; a future cache can live inside default_pipeline.)
_ = enable_cache;
timer.mark();
comp.generateCode() catch { comp.renderErrors(); return error.CompileError; };
timer.record("codegen");
// Compile C sources from #import c blocks to .o files
timer.mark();
@@ -721,23 +710,22 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
}
}
// Link (sx .o + C .o files)
timer.mark();
sx.target.link(allocator, io, obj_path, c_obj_paths, final_output, libs, fws, merged_config, comp.getJniMainEmissions().len > 0) catch {
std.debug.print("error: linking failed\n", .{});
return error.CompileError;
};
timer.record("link");
// Driver-side linker adapter behind the sx `link` primitive (Phase 5). Lives
// on this stack frame so it outlives the post-link callback invocation below.
var link_ctx = LinkHooksCtx{
// NO auto-link here — the sx `default_pipeline` (or a user `on_build`
// override) calls `link` (and `emit_object`) as actions through these hooks.
// The ctx lives on this stack frame so it outlives the callback below.
var build_ctx = BuildHooksCtx{
.comp = &comp,
.obj_path = obj_path,
.allocator = allocator,
.io = io,
.base_config = merged_config,
.has_jni_main = comp.getJniMainEmissions().len > 0,
};
var build_hooks = sx.ir.compiler_hooks.BuildHooks{ .ctx = &link_ctx, .link = LinkHooksCtx.link };
var build_hooks = sx.ir.compiler_hooks.BuildHooks{
.ctx = &build_ctx,
.emit_object = BuildHooksCtx.emitObject,
.link = BuildHooksCtx.link,
};
// Make the linked binary's path + bundling config visible to the
// post-link callback via `BuildOptions.binary_path()`,
@@ -763,11 +751,12 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
e.build_config.target_frameworks = fws;
e.build_config.target_framework_paths = merged_config.framework_paths;
// Phase 5: the sx-driven build pipeline reads these via the
// `c_object_paths()` / `link_libraries()` compiler primitives. Both
// slices reference compileWithTimer locals that outlive the callback.
// `c_object_paths()` / `link_libraries()` / `build_*()` primitives. Slices
// reference compileWithTimer locals that outlive the callback.
e.build_config.c_object_paths = c_obj_paths;
e.build_config.link_libraries = libs;
e.build_config.object_path = obj_path;
e.build_config.output_path = final_output;
e.build_config.merged_link_flags = merged_config.extra_link_flags;
// Android-specific bundling state.
if (e.build_config.manifest_path == null) e.build_config.manifest_path = merged_config.manifest_path;
if (e.build_config.keystore_path == null) e.build_config.keystore_path = merged_config.keystore_path;
@@ -850,6 +839,24 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
std.debug.print("error: post-link module '{s}.bundle_main' not found\n", .{mod_name});
return error.CompileError;
}
} else {
// No user/module override → run the stdlib default build pipeline. The
// compiler force-lowers `default_pipeline` (well-known name); it emits +
// links the program. Everything is sx-driven — this is the only build path
// when the user hasn't overridden it.
const ret_opt = comp.invokeByName("default_pipeline", true) catch |err| {
printInterpBailDiag(&comp, "default build pipeline", err);
return error.CompileError;
};
if (ret_opt) |ret| {
if (ret.asBool() == false) {
std.debug.print("error: default build pipeline returned false\n", .{});
return error.CompileError;
}
} else {
std.debug.print("error: default build pipeline 'default_pipeline' not found (is the prelude imported?)\n", .{});
return error.CompileError;
}
}
// Post-process wasm HTML: inject content hash for cache busting
@@ -857,11 +864,6 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
sx.target.postProcessWasmHtml(allocator, io, final_output);
}
// Save linked binary to cache
if (use_cache) {
std.Io.Dir.copyFile(.cwd(), output_path, .cwd(), cache_bin, io, .{ .make_path = true }) catch {};
}
std.debug.print("compiled: {s}\n", .{final_output});
// Clean up temp directory and all build artifacts. Under --emit-obj, keep