P5.3: on_build(cb) build-callback registrar; callback takes BuildOptions

Per user design: on_build(build) is the build-callback registrar (a free fn),
generalizing set_post_link_callback — the callback is (opt: BuildOptions) ->
bool and the compiler invokes it post-codegen WITH the BuildOptions handle.

- VM: callCompilerFn 'on_build' arm + legacy handleOnBuild, both set
  post_link_callback_fn + a new BuildConfig.post_link_takes_options flag.
- comptime_vm: runEntry refactored to runEntryArgs(extra) (implicit ctx +
  explicit args); new public runBuildCallback(..., pass_options) passes the
  opaque BuildOptions handle (one word) after the ctx. The fat-config
  marshaling fear is moot — the handle is a single null-sentinel word.
- core.invokeByFuncId/invokeByName take pass_options (was an unused args
  slice); main.zig passes comp.getPostLinkTakesOptions().
- build.sx: on_build decl (set_post_link_callback kept for now).

Smoke test examples/1664-platform-on-build-callback (AOT): #run on_build(build)
with build :: (opt: BuildOptions) -> bool; the callback is invoked with the
handle arg (runEntryArgs param-count match) and runs the primitives.

Benign .ir churn (37 snapshots: type table +1 for the on_build fn type +
global renumber; behavior identical). 705/0 both gates.
This commit is contained in:
agra
2026-06-19 08:47:05 +03:00
parent d8affd45e8
commit 9cbee5e4bd
48 changed files with 35175 additions and 34932 deletions

View File

@@ -188,7 +188,7 @@ pub const Compilation = struct {
/// if applicable) to invoke a named sx function. Used for the post-link
/// bundling callback. Returns the function's return value, or null if the
/// name doesn't resolve to a function in the lowered module.
pub fn invokeByName(self: *Compilation, name: []const u8, args: []const ir.Value) !?ir.Value {
pub fn invokeByName(self: *Compilation, name: []const u8, pass_options: bool) !?ir.Value {
const mod = self.ir_module orelse return null;
var found_id: ?ir.FuncId = null;
for (mod.functions.items, 0..) |func, i| {
@@ -199,14 +199,15 @@ pub const Compilation = struct {
}
}
const fid = found_id orelse return null;
return try self.invokeByFuncId(fid, args);
return try self.invokeByFuncId(fid, pass_options);
}
/// Re-enter the IR interpreter and call a previously-resolved function
/// id. Companion to `invokeByName` — used when the FuncId was captured
/// at `#run` time (e.g. by `set_post_link_callback`) and we want to
/// invoke it later without name lookup.
pub fn invokeByFuncId(self: *Compilation, id: ir.FuncId, args: []const ir.Value) !ir.Value {
/// Re-enter the evaluator and call a previously-resolved function id. The
/// post-link build callback, captured at `#run` time (by `on_build` /
/// `set_post_link_callback`). `pass_options` passes the opaque `BuildOptions`
/// handle as the callback's arg (the `on_build(cb)` form, `cb: (opt:
/// BuildOptions) -> bool`); false for the legacy no-arg form.
pub fn invokeByFuncId(self: *Compilation, id: ir.FuncId, pass_options: bool) !ir.Value {
const mod = self.ir_module orelse return error.NoIRModule;
// The build driver (post-link callback) runs on the comptime VM — NOT
// the legacy interp. The driver allocates Lists, which the legacy interp
@@ -215,13 +216,8 @@ pub const Compilation = struct {
// callback can't safely re-run on a second evaluator (double execution),
// so a VM bail is a hard build error. The bail reason is in
// `comptime_vm.last_bail_reason` (surfaced by `main.printInterpBailDiag`).
// Post-link callbacks are nullary today (the implicit `*Context` is
// materialized by the VM's `runEntry`); a non-empty `args` would need a
// VM entry that marshals them, which arrives with the `on_build(config)`
// slot (Phase 5.3) — reject it loudly rather than silently drop.
if (args.len != 0) return error.ComptimeVmArgsUnsupported;
const build_config = if (self.ir_emitter) |*e| &e.build_config else null;
return ir.comptime_vm.tryEval(self.allocator, mod, id, build_config, &self.import_sources) orelse
return ir.comptime_vm.runBuildCallback(self.allocator, mod, id, build_config, &self.import_sources, pass_options) orelse
error.ComptimeVmBail;
}
@@ -249,13 +245,20 @@ pub const Compilation = struct {
return null;
}
/// Get the post-link callback function id (set via
/// `BuildOptions.set_post_link_callback(fn)`), if any.
/// Get the post-link callback function id (set via `on_build(fn)` or the
/// legacy `set_post_link_callback(fn)`), if any.
pub fn getPostLinkCallback(self: *Compilation) ?ir.FuncId {
if (self.ir_emitter) |*e| return e.build_config.post_link_callback_fn;
return null;
}
/// Whether the post-link callback takes the `BuildOptions` handle arg (the
/// `on_build(cb)` form). Drives the `pass_options` flag at invocation.
pub fn getPostLinkTakesOptions(self: *Compilation) bool {
if (self.ir_emitter) |*e| return e.build_config.post_link_takes_options;
return false;
}
/// Get the post-link module name (set via
/// `BuildOptions.set_post_link_module("name")`), if any.
pub fn getPostLinkModule(self: *Compilation) ?[]const u8 {

View File

@@ -33,6 +33,11 @@ pub const BuildConfig = struct {
/// and invokes this function with no args. A `false` return is
/// treated as a build failure.
post_link_callback_fn: ?FuncId = null,
/// True when the post-link callback was registered via `on_build(cb)` (the
/// Phase 5 form, `cb: (opt: BuildOptions) -> bool`) rather than the legacy
/// `set_post_link_callback(cb)` (`cb: () -> bool`). When set, the compiler
/// invokes the callback with the opaque `BuildOptions` handle as its arg.
post_link_takes_options: bool = false,
/// Alternative to `post_link_callback_fn`: the qualified name of
/// a module whose `bundle_main` function should be called
/// post-link.

View File

@@ -61,6 +61,7 @@ pub const bound_fns = [_]BoundFn{
// ── BuildOptions (migrated off `#compiler` onto `abi(.compiler)`) ─────────
.{ .sx_name = "build_options", .handler = handleBuildOptions },
.{ .sx_name = "set_post_link_callback", .handler = handleSetPostLinkCallback },
.{ .sx_name = "on_build", .handler = handleOnBuild },
// ── build-pipeline metadata queries (Phase 5.2) ──────────────────────────
// VM-only: the post-link callback that calls these always runs on the VM
// (`core.invokeByFuncId`), so `comptime_vm.callCompilerFn` services them and
@@ -353,3 +354,20 @@ fn handleSetPostLinkCallback(interp: *Interpreter, args: []const Value) InterpEr
}
return .void_val;
}
/// `on_build(cb)` — register the build callback (the Phase 5 form, a free fn; cb
/// is arg 0, and `cb: (opt: BuildOptions) -> bool` so the callback is invoked with
/// the `BuildOptions` handle). Sets `post_link_takes_options` to distinguish it
/// from the legacy `set_post_link_callback` (`() -> bool`).
fn handleOnBuild(interp: *Interpreter, args: []const Value) InterpError!Value {
if (args.len != 1) return error.TypeError;
const bc = interp.build_config orelse return error.CannotEvalComptime;
switch (args[0]) {
.func_ref => |id| {
bc.post_link_callback_fn = id;
bc.post_link_takes_options = true;
},
else => return error.TypeError,
}
return .void_val;
}

View File

@@ -223,6 +223,37 @@ pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.
};
}
/// Run a post-link build callback on the VM (the post-codegen build driver — see
/// `core.invokeByFuncId`). Like `tryEval`, but for a callback that may take the
/// opaque `BuildOptions` handle as an explicit arg (the `on_build(cb)` form,
/// `cb: (opt: BuildOptions) -> bool`): when `pass_options` is set, the handle (a
/// null sentinel — the real state is the threaded `BuildConfig`) is passed after
/// the implicit ctx. Returns null on a bail (`last_bail_reason` names the cause).
pub fn runBuildCallback(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.FuncId, build_config: ?*interp_mod.BuildConfig, source_map: ?*const std.StringHashMap([:0]const u8), pass_options: bool) ?Value {
last_bail_reason = null;
const func = module.getFunction(func_id);
if (func.is_extern or func.blocks.items.len == 0) {
last_bail_reason = "extern / no body";
return null;
}
var vm = Vm.init(gpa);
defer vm.deinit();
vm.table = &module.types;
vm.module = module;
vm.build_config = build_config;
vm.source_map = source_map;
const extra: []const Reg = if (pass_options) &.{null_addr} else &.{};
const reg = vm.runEntryArgs(func_id, extra) catch |err| {
last_bail_reason = vm.detail orelse @errorName(err);
return null;
};
if (func.ret == .void or func.ret == .noreturn) return .void_val;
return vm.regToValue(gpa, &module.types, reg, func.ret) catch |err| {
last_bail_reason = vm.detail orelse @errorName(err);
return null;
};
}
// ── Executor ────────────────────────────────────────────────────────────────
//
// Walks the SAME SSA IR the legacy interpreter (`interp.zig`) walks, but over
@@ -327,18 +358,27 @@ pub const Vm = struct {
/// materialized ctx is zeroed; a body that ignores it runs, one that uses the
/// allocator hits unported `call_indirect` and bails.
fn runEntry(self: *Vm, func_id: FuncId) Error!Reg {
return self.runEntryArgs(func_id, &.{});
}
/// Run a comptime entry with the materialized implicit `*Context` (when the
/// function has one) PREPENDED to `extra` explicit arg words. A nullary
/// const-init / `#run` passes `extra = &.{}`; a post-link build callback of
/// the `on_build` form passes the opaque `BuildOptions` handle.
fn runEntryArgs(self: *Vm, func_id: FuncId, extra: []const Reg) Error!Reg {
const module = self.module orelse return self.failMsg("comptime VM: entry run needs a module");
const func = module.getFunction(func_id);
var argbuf: [1]Reg = undefined;
var args: []const Reg = &.{};
var argbuf: std.ArrayList(Reg) = .empty;
defer argbuf.deinit(self.gpa);
if (func.has_implicit_ctx) {
if (func.params.len != 1) return self.failMsg("comptime VM: has_implicit_ctx with non-ctx params");
argbuf[0] = try self.materializeDefaultContext(module);
args = argbuf[0..1];
argbuf.append(self.gpa, try self.materializeDefaultContext(module)) catch @panic("comptime VM: out of memory (entry args)");
}
for (extra) |a| argbuf.append(self.gpa, a) catch @panic("comptime VM: out of memory (entry args)");
if (argbuf.items.len != func.params.len)
return self.failMsg("comptime VM: entry arg count mismatch (ctx + explicit args vs params)");
self.call_stack.append(self.gpa, func_id) catch @panic("comptime VM: out of memory (call stack)");
defer _ = self.call_stack.pop();
return self.run(func, args);
return self.run(func, argbuf.items);
}
/// Materialize the default `Context` in flat memory and return its address —
@@ -1452,6 +1492,20 @@ pub const Vm = struct {
bc.post_link_callback_fn = fid;
return @as(Reg, null_addr);
}
// `on_build(cb)` — register the build callback (the Phase 5 form, `cb:
// (opt: BuildOptions) -> bool`). Like `set_post_link_callback` but a free
// fn (cb is arg 0, no self) and the callback receives the `BuildOptions`
// handle when invoked (the `post_link_takes_options` flag drives that).
if (std.mem.eql(u8, name, "on_build")) {
if (args.len != 1) return self.failMsg("comptime on_build: expected (cb)");
const bc = self.build_config orelse
return self.failMsg("comptime on_build: no build config threaded into the VM");
const fid = funcRefToId(frame.get(args[0].index())) orelse
return self.failMsg("comptime on_build: cb arg is not a function value");
bc.post_link_callback_fn = fid;
bc.post_link_takes_options = true;
return @as(Reg, null_addr);
}
// ── build-pipeline metadata queries (Phase 5.2) ─────────────────────
// Read-only: the compiler answers them from the `BuildConfig` `main.zig`
// forwards before the post-link callback runs. Each builds a fresh

View File

@@ -824,7 +824,7 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
// `set_post_link_module("name")`, re-enter the IR interpreter and
// invoke that sx function now. A `false` return fails the build.
if (comp.getPostLinkCallback()) |fid| {
const ret = comp.invokeByFuncId(fid, &.{}) catch |err| {
const ret = comp.invokeByFuncId(fid, comp.getPostLinkTakesOptions()) catch |err| {
printInterpBailDiag(&comp, "post-link callback", err);
return error.CompileError;
};
@@ -835,7 +835,7 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
} else if (comp.getPostLinkModule()) |mod_name| {
const qualified = try std.fmt.allocPrint(allocator, "{s}.bundle_main", .{mod_name});
defer allocator.free(qualified);
const ret_opt = comp.invokeByName(qualified, &.{}) catch |err| {
const ret_opt = comp.invokeByName(qualified, false) catch |err| {
const label = try std.fmt.allocPrint(allocator, "post-link module '{s}.bundle_main'", .{mod_name});
defer allocator.free(label);
printInterpBailDiag(&comp, label, err);