ffi M5.A revert: drop compiler synthesis, require explicit Into(Block) impls

Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.

New design (strict mode):

1. Stdlib's modules/std/objc_block.sx hand-rolls
   `__block_invoke_void` + `Into(Block) for Closure() -> void` and
   the same pair for `Closure(bool) -> void` (restored from M5.A.2).
   These are readable reference implementations of the bridge ABI.

2. The compiler intercept fires NO synthesis — instead, when
   `tryUserConversion` can't find a reachable `Into(Block)` impl for
   the closure's signature, it emits a focused diagnostic:
     "no `Into(Block) for <Closure-sig>` impl — add a per-signature
      `__block_invoke_<sig>` trampoline + Into impl alongside the
      existing ones in modules/std/objc_block.sx, or declare it in
      your own code"

3. Per-signature declarations live in stdlib (for common signatures)
   or in user code (for app-specific ones). 96-objc-block-multi-arg
   now demonstrates the user-side pattern in-file — it declares its
   own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
   *void) -> void` impl alongside its main().

Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
  source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
  pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
  not silent fallthrough.

Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.

190/190 example tests pass; chess on iOS-sim green.
This commit is contained in:
agra
2026-05-27 00:34:26 +03:00
parent 26329fe7ba
commit 07f25689ff
3 changed files with 131 additions and 272 deletions

View File

@@ -145,7 +145,6 @@ pub const Lowering = struct {
comptime_constants: std.StringHashMap(ComptimeValue) = std.StringHashMap(ComptimeValue).init(std.heap.page_allocator), // compile-time known constants (e.g. OS, ARCH)
diagnostics: ?*errors.DiagnosticList = null, // error reporting with source locations
xx_reentrancy: std.AutoHashMap(u64, void) = std.AutoHashMap(u64, void).init(std.heap.page_allocator), // (src_ty, dst_ty) pairs currently being resolved through user-space Into; prevents infinite monomorphisation when a convert body re-enters the same xx
block_invoke_trampolines: std.StringHashMap(FuncId) = std.StringHashMap(FuncId).init(std.heap.page_allocator), // M5.A — dedup compiler-synthesised `__block_invoke_<sig>` trampolines per mangled closure signature; one entry per Closure(...) type seen at an `xx cl : Block` cast site
pub const ComptimeValue = union(enum) {
int_val: i64,
@@ -11053,246 +11052,21 @@ pub const Lowering = struct {
return result;
}
/// M5.A — try the closureBlock bridge before the user-space Into
/// fallback. If `src_ty` is `Closure(...)` and `dst_ty` is `Block`,
/// synthesise a `__block_invoke_<sig>` trampoline (deduped per
/// signature) and emit IR constructing the Block struct inline.
/// Returns the Block value, or null if the cast doesn't match this
/// pattern (caller falls back to user-space Into).
fn tryClosureToBlockConversion(self: *Lowering, operand: Ref, src_ty: TypeId, dst_ty: TypeId) ?Ref {
// Source must be Closure(...).
if (src_ty.isBuiltin()) return null;
/// Detect the `xx closure : Block` cast pattern so `tryUserConversion`
/// can emit a focused diagnostic when no `Into(Block) for Closure(...)`
/// impl is reachable. Replaces what was briefly a compiler-synthesised
/// trampoline path with a "declare an impl" requirement — the stdlib
/// covers common signatures (see modules/std/objc_block.sx), users
/// add their own for unusual ones.
fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool {
if (src_ty.isBuiltin()) return false;
const src_info = self.module.types.get(src_ty);
if (src_info != .closure) return null;
// Destination must be the `Block` struct (declared in std/objc_block.sx).
if (dst_ty.isBuiltin()) return null;
if (src_info != .closure) return false;
if (dst_ty.isBuiltin()) return false;
const dst_info = self.module.types.get(dst_ty);
if (dst_info != .@"struct") return null;
const block_name_id = self.module.types.internString("Block");
if (dst_info.@"struct".name != block_name_id) return null;
const ptr_void = self.module.types.ptrTo(.void);
// Emit (or look up) the trampoline for this signature.
const trampoline_fid = self.emitBlockInvokeTrampoline(src_ty) orelse return null;
// Look up the shared block infrastructure declared in std/objc_block.sx.
const isa_gid = self.global_names.get("_NSConcreteStackBlock") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "xx Closure : Block requires `#import \"modules/std/objc_block.sx\";` (missing `_NSConcreteStackBlock` extern)", .{});
}
return null;
};
const descriptor_gid = self.global_names.get("__sx_block_descriptor") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "xx Closure : Block requires `#import \"modules/std/objc_block.sx\";` (missing `__sx_block_descriptor` global)", .{});
}
return null;
};
// Build the Block struct fields.
const isa_addr = self.builder.emit(.{ .global_addr = isa_gid.id }, ptr_void);
const descriptor_addr = self.builder.emit(.{ .global_addr = descriptor_gid.id }, ptr_void);
const invoke_addr = self.builder.emit(.{ .func_ref = trampoline_fid }, ptr_void);
// Extract the closure's fn_ptr (field 0) and env (field 1).
const closure_fn_ptr = self.builder.structGet(operand, 0, ptr_void);
const closure_env = self.builder.structGet(operand, 1, ptr_void);
// Construct Block { isa, flags=0, reserved=0, invoke, descriptor, sx_env, sx_fn }.
var fields = std.ArrayList(Ref).empty;
defer fields.deinit(self.alloc);
fields.append(self.alloc, isa_addr) catch return null;
fields.append(self.alloc, self.builder.constInt(0, .s32)) catch return null; // flags
fields.append(self.alloc, self.builder.constInt(0, .s32)) catch return null; // reserved
fields.append(self.alloc, invoke_addr) catch return null;
fields.append(self.alloc, descriptor_addr) catch return null;
fields.append(self.alloc, closure_env) catch return null; // sx_env
fields.append(self.alloc, closure_fn_ptr) catch return null; // sx_fn
const owned_fields = self.alloc.dupe(Ref, fields.items) catch return null;
return self.builder.emit(.{ .struct_init = .{ .fields = owned_fields } }, dst_ty);
}
/// Synthesise (and cache) the C-ABI trampoline that bridges Apple's
/// `__block_literal.invoke` calling convention to the sx closure
/// stored in `block_self.sx_fn` + `block_self.sx_env`. One per
/// unique closure signature.
///
/// Signature for a `Closure(A, B) -> R`:
/// `__block_invoke_<sig>(block_self: *Block, a: A, b: B) -> R callconv(.c)`
/// Body:
/// sx_fn = block_self.sx_fn
/// sx_env = block_self.sx_env
/// ret sx_fn(__sx_default_context, sx_env, a, b) // matches sx closure ABI
fn emitBlockInvokeTrampoline(self: *Lowering, closure_ty: TypeId) ?FuncId {
const closure_info = self.module.types.get(closure_ty);
if (closure_info != .closure) return null;
const cinfo = closure_info.closure;
const mangled = self.mangleClosureSigForBlock(cinfo) orelse return null;
defer self.alloc.free(mangled);
if (self.block_invoke_trampolines.get(mangled)) |fid| return fid;
const ptr_void = self.module.types.ptrTo(.void);
// The trampoline name owned by the module's intern pool.
const tramp_name = std.fmt.allocPrint(self.alloc, "__block_invoke_{s}", .{mangled}) catch return null;
const tramp_name_id = self.module.types.internString(tramp_name);
// Hold the mangled string across the cache map's lifetime.
const mangled_owned = self.alloc.dupe(u8, mangled) catch return null;
const block_ty_name = self.module.types.internString("Block");
const block_ty = self.module.types.findByName(block_ty_name) orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitBlockInvokeTrampoline: `Block` struct not in module — `#import \"modules/std/objc_block.sx\";` required", .{});
}
self.alloc.free(mangled_owned);
return null;
};
const block_ptr_ty = self.module.types.ptrTo(block_ty);
// Save+restore builder state — emitting a new function mid-pass.
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
defer {
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
}
// Build the trampoline's param list: (block_self: *Block, user_args...).
var params = std.ArrayList(inst_mod.Function.Param).empty;
params.append(self.alloc, .{
.name = self.module.types.internString("block_self"),
.ty = block_ptr_ty,
}) catch {
self.alloc.free(mangled_owned);
return null;
};
for (cinfo.params, 0..) |pty, i| {
var nbuf: [32]u8 = undefined;
const pname = std.fmt.bufPrint(&nbuf, "arg{d}", .{i}) catch "arg";
params.append(self.alloc, .{
.name = self.module.types.internString(pname),
.ty = pty,
}) catch {
self.alloc.free(mangled_owned);
return null;
};
}
const params_slice = params.toOwnedSlice(self.alloc) catch {
self.alloc.free(mangled_owned);
return null;
};
const fid = self.builder.beginFunction(tramp_name_id, params_slice, cinfo.ret);
const func = self.builder.currentFunc();
func.linkage = .external;
func.call_conv = .c;
func.has_implicit_ctx = false;
const entry_name = self.module.types.internString("entry");
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
// Load block_self struct, extract sx_env (field 5) + sx_fn (field 6).
const block_self_ref = Ref.fromIndex(0);
const block_val = self.builder.load(block_self_ref, block_ty);
const sx_env = self.builder.structGet(block_val, 5, ptr_void);
const sx_fn = self.builder.structGet(block_val, 6, ptr_void);
// Call sx_fn(__sx_default_context, sx_env, user_args...) — matches
// the sx closure ABI (ctx prepended to env + user args).
const default_ctx_gi = self.global_names.get("__sx_default_context") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitBlockInvokeTrampoline: __sx_default_context not in module (compiler bug)", .{});
}
self.alloc.free(mangled_owned);
return null;
};
const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void);
const num_user_args = cinfo.params.len;
const total_args = 2 + num_user_args; // ctx + env + user
const call_args = self.alloc.alloc(Ref, total_args) catch {
self.alloc.free(mangled_owned);
return null;
};
call_args[0] = default_ctx_addr;
call_args[1] = sx_env;
var i: usize = 0;
while (i < num_user_args) : (i += 1) {
// Params start at slot 1 (block_self is slot 0); user args
// are slots 1..num_user_args.
call_args[2 + i] = Ref.fromIndex(@intCast(1 + i));
}
const result = self.builder.emit(.{ .call_indirect = .{
.callee = sx_fn,
.args = call_args,
} }, cinfo.ret);
if (cinfo.ret == .void) {
self.builder.retVoid();
} else {
self.builder.ret(result, cinfo.ret);
}
self.builder.finalize();
self.block_invoke_trampolines.put(mangled_owned, fid) catch {
self.alloc.free(mangled_owned);
return null;
};
return fid;
}
/// Compact mangling for a closure signature's `__block_invoke_<sig>`
/// trampoline name. Keys: `v` void, `b` bool, `i` s32, `q` s64, `f`
/// f32, `d` f64, `p` pointer/aggregate (everything that lowers to a
/// machine word). Multi-arg signatures are underscore-joined; return
/// comes first.
fn mangleClosureSigForBlock(self: *Lowering, cinfo: types.TypeInfo.ClosureInfo) ?[]u8 {
var buf = std.ArrayList(u8).empty;
defer buf.deinit(self.alloc);
buf.append(self.alloc, mangleTypeForBlock(cinfo.ret, &self.module.types)) catch return null;
for (cinfo.params) |pty| {
buf.append(self.alloc, '_') catch return null;
buf.append(self.alloc, mangleTypeForBlock(pty, &self.module.types)) catch return null;
}
return self.alloc.dupe(u8, buf.items) catch null;
}
fn mangleTypeForBlock(ty: TypeId, tbl: *const types.TypeTable) u8 {
if (ty == .void) return 'v';
if (ty == .bool) return 'b';
if (ty == .f32) return 'f';
if (ty == .f64) return 'd';
const info = tbl.get(ty);
return switch (info) {
.signed => |w| switch (w) {
1, 8 => 'c',
16 => 's',
32 => 'i',
64 => 'q',
else => 'i',
},
.unsigned => |w| switch (w) {
1, 8 => 'C',
16 => 'S',
32 => 'I',
64 => 'Q',
else => 'I',
},
// Everything else lowers to a machine word at the C ABI.
// 'p' covers pointers, many-pointers, struct ptrs, closures,
// and small structs that pass in a register. Aggregates
// larger than the register cutoff would need a different
// calling convention — out of M5.A scope.
else => 'p',
};
if (dst_info != .@"struct") return false;
const block_name = self.module.types.internString("Block");
return dst_info.@"struct".name == block_name;
}
/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
@@ -11300,14 +11074,6 @@ pub const Lowering = struct {
/// no impl matches (caller falls back to the built-in result, which is
/// the unchanged operand — Phase 3 emits no diagnostic for v0).
fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref {
// M5.A: compiler-synthesised closure→Block bridge. Runs BEFORE
// the user-space Into lookup so the synthesised trampoline takes
// over from the hand-rolled stdlib impls. Falls through (returns
// null in caller chain) when src isn't Closure or dst isn't Block.
if (self.tryClosureToBlockConversion(operand, src_ty, dst_ty)) |converted| {
return converted;
}
// Reentrancy guard — pack (src, dst) into a u64.
const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index());
if (self.xx_reentrancy.contains(guard_key)) {
@@ -11335,8 +11101,36 @@ pub const Lowering = struct {
key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
const key = key_buf.items;
const entries = self.param_impl_map.get(key) orelse return null;
if (entries.items.len == 0) return null;
const entries = self.param_impl_map.get(key) orelse {
// M5.A — focused diagnostic for the closure→Block case. When
// a user writes `xx cl : Block` for a closure signature that
// has no `Into(Block) for Closure(<sig>)` impl in stdlib or
// user code, the generic "no Into impl" path returns silently
// and the cast becomes a no-op. Emit a hint pointing at the
// missing impl pattern so they know what to add.
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
diags.current_source_file = operand_node.source_file orelse self.current_source_file;
defer diags.current_source_file = saved;
diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
}
return operand;
}
return null;
};
if (entries.items.len == 0) {
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
diags.current_source_file = operand_node.source_file orelse self.current_source_file;
defer diags.current_source_file = saved;
diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
}
return operand;
}
return null;
}
// Filter by import visibility: only impls in modules that the current
// file transitively imports (or the current file itself) are reachable.