refactor(B5.1): move protocol emission to lower/protocol.zig

Verbatim relocation of the 13-method protocol cluster (protocol decl
registration, param-protocol instantiation, thunk creation, vtable
globals, protocol-value construction, dispatch emission, impl lookup)
into src/ir/lower/protocol.zig. 13 fn aliases on Lowering keep all call
sites unchanged.

Two pub nested types travelled with the run (ProjectionPosition,
PackProjection) and are re-exposed via Lowering type aliases; they are
pack-domain types and may relocate to lower/pack.zig in B7.2.

Method pub-flips: allocViaContext, callForeign, genericInstanceMethod,
monomorphizeFunction.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
This commit is contained in:
agra
2026-06-10 13:54:59 +03:00
parent c10aaa1482
commit 8990bd4978
2 changed files with 668 additions and 601 deletions

View File

@@ -39,6 +39,7 @@ const lower_stmt = @import("lower/stmt.zig");
const lower_control_flow = @import("lower/control_flow.zig");
const lower_decl = @import("lower/decl.zig");
const lower_nominal = @import("lower/nominal.zig");
const lower_protocol = @import("lower/protocol.zig");
const TypeId = types.TypeId;
const StringId = types.StringId;
@@ -1413,44 +1414,6 @@ pub const Lowering = struct {
// ── Control flow ────────────────────────────────────────────────
/// Shared implementation for the `has_impl(P, T)` builtin and its
/// `tryConstBoolCondition` arm. The protocol expression is either:
/// - Plain `Hash` (identifier / type_expr) → walks
/// `protocol_thunk_map["Hash\x00<T>"]`.
/// - Parameterised `Into(Block)` (call) → walks `param_impl_map`
/// keyed by `"<P>\x00<arg_mangled>\x00<T_mangled>"`.
/// Returns false on any malformed protocol-arg shape (caller
/// reports a diagnostic if it wants).
pub fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool {
switch (proto_node.data) {
.identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty),
.type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty),
.call => |c| {
const p_name: []const u8 = switch (c.callee.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => return false,
};
// Resolve protocol type args. Each goes through
// `resolveTypeArg` so type aliases / generics / pack-
// indexed types all work as protocol args.
var arg_mangles = std.ArrayList(u8).empty;
defer arg_mangles.deinit(self.alloc);
for (c.args, 0..) |a, i| {
if (i > 0) arg_mangles.append(self.alloc, 0) catch return false;
const aty = self.resolveTypeArg(a);
arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false;
}
const ty_mangled = self.mangleTypeName(ty);
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{
p_name, arg_mangles.items, ty_mangled,
}) catch return false;
return self.param_impl_map.contains(key);
},
else => return false,
}
}
fn lowerStructLiteral(self: *Lowering, sl: *const ast.StructLiteral, span: ast.Span) Ref {
// Check for tagged enum construction: .Variant.{ payload_fields }
// This happens when type_expr is an enum_literal and target_type is a union
@@ -4838,7 +4801,7 @@ pub const Lowering = struct {
/// NOT fall back to a direct libc malloc — that was the silent escape
/// hatch that bit us through the implicit-context refactor (see the
/// "Silent unimplemented arms" REJECTED PATTERN in CLAUDE.md).
fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
pub fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) {
return self.diagnoseMissingContext("heap allocation");
}
@@ -4874,7 +4837,7 @@ pub const Lowering = struct {
/// Used for the compiler-internal byte-copy in the protocol-erasure
/// heap path and the closure env-copy path, both of which need
/// libc `memcpy` after the `#builtin` form was dropped.
fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
pub fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
const fid = self.resolveFuncByName(name) orelse @panic("foreign symbol missing — std.sx not imported?");
return self.builder.call(fid, args, ret_ty);
}
@@ -6845,7 +6808,7 @@ pub const Lowering = struct {
self.builder.finalize();
}
fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name: []const u8, bindings: *std.StringHashMap(TypeId)) void {
pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name: []const u8, bindings: *std.StringHashMap(TypeId)) void {
// Mark as lowered before lowering (prevents infinite recursion)
// Need to dupe the name since mangled_name may be stack-allocated
const owned_name = self.alloc.dupe(u8, mangled_name) catch return;
@@ -9210,7 +9173,7 @@ pub const Lowering = struct {
/// no such `method` (a normal unresolved-method, handled downstream). A
/// confirmed instance whose author is present but whose bindings are missing is
/// a LOUD invariant failure — instantiation writes both together (CP-2).
fn genericInstanceMethod(self: *Lowering, inst_name: []const u8, method: []const u8) ?GenericStructMethod {
pub fn genericInstanceMethod(self: *Lowering, inst_name: []const u8, method: []const u8) ?GenericStructMethod {
const author = self.struct_instance_author.get(inst_name) orelse return null;
const bindings = self.struct_instance_bindings.getPtr(inst_name) orelse
std.debug.panic("generic struct instance '{s}' has an author but no bindings", .{inst_name});
@@ -9667,152 +9630,6 @@ pub const Lowering = struct {
// ── Type registration ───────────────────────────────────────────
/// Register a protocol declaration as a struct type in the IR type table.
/// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... }
/// Non-inline protocols: { ctx: *void, __vtable: *void }
/// Also stores protocol info for dispatch and vtable struct type for vtable protocols.
/// Register a protocol declaration. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub`
/// entry point because the scan pass + several unit tests reach it here.
pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
return self.protocolResolver().registerProtocolDecl(pd);
}
/// Instantiate a parameterized protocol as a runtime VALUE type:
/// `VL(s64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`),
/// with method infos resolved under the type-arg binding (so `get -> T`
/// becomes `get -> s64`) and the binding recorded for projection. Cached by
/// the mangled name `VL__s64`. Mirrors the non-parameterized path in
/// `registerProtocolDecl`.
fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId {
const table = &self.module.types;
const void_ptr_ty = table.ptrTo(.void);
var np = std.ArrayList(u8).empty;
np.appendSlice(self.alloc, pd.name) catch {};
var tb = std.StringHashMap(TypeId).init(self.alloc);
for (pd.type_params, 0..) |tp, i| {
if (i >= args.len) break;
const ty = self.resolveTypeWithBindings(args[i]);
tb.put(tp.name, ty) catch {};
np.appendSlice(self.alloc, "__") catch {};
np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
}
const mangled = np.items;
const name_id = table.internString(mangled);
if (table.findByName(name_id)) |existing| {
const info = table.get(existing);
if (info == .@"struct" and info.@"struct".is_protocol) return existing;
}
// Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol).
var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable;
if (pd.is_inline) {
for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
} else {
fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable;
}
const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } };
const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info);
table.updatePreservingKey(id, struct_info);
// Method infos resolved with the type-arg binding (T → s64), pinned to
// the protocol's OWN module (E4) so a method-signature type visible only
// there resolves correctly when instantiated cross-module. `Self` and the
// bound type-args short-circuit before the leaf; a concrete library type
// in a signature is the case this pin protects.
const saved_tb = self.type_bindings;
self.type_bindings = tb;
const saved_pp_src = self.current_source_file;
defer self.setCurrentSourceFile(saved_pp_src);
if (pd.source_file) |src| self.setCurrentSourceFile(src);
var method_infos = std.ArrayList(ProtocolMethodInfo).empty;
for (pd.methods) |method| {
var ptypes = std.ArrayList(TypeId).empty;
for (method.params) |p| {
const pty = blk: {
if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty;
break :blk self.resolveTypeWithBindings(p);
};
ptypes.append(self.alloc, pty) catch unreachable;
}
var ret_is_self = false;
const ret = if (method.return_type) |rt| blk: {
if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
ret_is_self = true;
break :blk void_ptr_ty;
}
break :blk self.resolveTypeWithBindings(rt);
} else .void;
method_infos.append(self.alloc, .{
.name = method.name,
.param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
.ret_type = ret,
.ret_is_self = ret_is_self,
}) catch unreachable;
}
self.type_bindings = saved_tb;
const owned = self.alloc.dupe(u8, mangled) catch return id;
self.program_index.protocol_decl_map.put(owned, .{
.name = owned,
.is_inline = pd.is_inline,
.methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable,
}) catch {};
// Record the type-arg binding so projection (`xs.T`, `.value`) and
// method-arg resolution on this instance can recover it.
self.struct_instance_bindings.put(owned, tb) catch {};
if (!pd.is_inline) {
var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
var vtable_name_buf: [192]u8 = undefined;
const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable";
const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } });
self.protocol_vtable_type_map.put(owned, vtable_ty) catch {};
}
return id;
}
// ── Pack projection name resolution (Feature 1, Decision 4) ──────────
//
// A `..pack.<name>` projection can target two protocol namespaces:
// - type-arg namespace: the `protocol($T, ...)` params.
// - runtime-accessor namespace: the protocol's methods (protocols have
// no fields; a zero-arg method like `value` is the accessor).
// Resolution is POSITION-driven, not precedence-driven: type position
// consults type-args, value position consults methods, with NO
// cross-namespace fallback.
pub const ProjectionPosition = enum { type_position, value_position };
pub const PackProjection = union(enum) {
type_arg: u32, // index into the protocol's `type_params`
method: u32, // index into the protocol's `methods`
not_found, // `name` absent from the position-selected namespace
};
/// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`).
/// Returns the `type_params` index, or null (also for unknown protocols).
pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.type_params, 0..) |tp, i| {
if (std.mem.eql(u8, tp.name, name)) return @intCast(i);
}
return null;
}
/// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods
/// — protocols have no fields). Returns the `methods` index, or null.
pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.methods, 0..) |m, i| {
if (std.mem.eql(u8, m.name, name)) return @intCast(i);
}
return null;
}
/// Resolve `..pack.<name>` against `protocol_name` by position (Decision 4).
/// No cross-namespace fallback: a value-position name that exists only as a
/// type-arg (or vice versa) is `.not_found`, letting the caller emit a
@@ -10084,419 +9901,6 @@ pub const Lowering = struct {
// ── Protocol dispatch ──────────────────────────────────────────
/// Check if a type name is a registered protocol.
fn isProtocolType(self: *Lowering, type_name: []const u8) bool {
return self.program_index.protocol_decl_map.contains(type_name);
}
/// Get protocol info for a TypeId (if it's a protocol type).
/// Protocol lookup. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9
/// callers (dispatch sites here + `calls.zig`) reach it.
pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo {
return self.protocolResolver().getProtocolInfo(ty);
}
/// Get or create thunks for a (protocol, concrete_type) pair.
/// Returns a slice of FuncIds, one per protocol method.
fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId {
// Key: "Proto\x00Type"
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{};
if (self.protocol_thunk_map.get(key)) |thunks| return thunks;
// PLANNING: which methods need a thunk (owned by the registry).
const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{};
var thunk_ids = std.ArrayList(FuncId).empty;
defer thunk_ids.deinit(self.alloc);
// EMISSION: materialize one thunk per method (stays in Lowering).
for (methods) |method| {
const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method);
thunk_ids.append(self.alloc, thunk_id) catch unreachable;
}
const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable;
self.protocol_thunk_map.put(key, owned) catch {};
return owned;
}
/// Emit the process-wide default Context as an LLVM static constant.
///
/// @__sx_default_context = internal constant %Context {
/// %Allocator { ptr null,
/// ptr @__thunk_CAllocator_Allocator_alloc,
/// ptr @__thunk_CAllocator_Allocator_dealloc },
/// ptr null
/// }
///
/// Used by FFI inbound wrappers (Step 4) and the interp's default-
/// context call entry (Step 7). Only emitted when the program imports
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
pub fn emitDefaultContextGlobal(self: *Lowering) void {
const saved_edc = self.emitting_default_context;
self.emitting_default_context = true;
defer self.emitting_default_context = saved_edc;
const tbl = &self.module.types;
const ctx_name_id = tbl.internString("Context");
const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
if (tbl.findByName(tbl.internString("Allocator")) == null) return;
if (tbl.findByName(tbl.internString("CAllocator")) == null) return;
// Force the CAllocator → Allocator thunks to exist so we can
// reference them by FuncId in the static initializer.
const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
if (thunks.len < 2) return;
// Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
// CAllocator is stateless, so ctx is null.
const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
alloc_fields[0] = .null_val;
alloc_fields[1] = .{ .func_ref = thunks[0] };
alloc_fields[2] = .{ .func_ref = thunks[1] };
// Context value: { allocator: Allocator, data: *void }
const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
ctx_fields[0] = .{ .aggregate = alloc_fields };
ctx_fields[1] = .null_val;
const global_name = "__sx_default_context";
const global_name_id = tbl.internString(global_name);
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = ctx_ty,
.init_val = .{ .aggregate = ctx_fields },
.is_const = true,
});
self.putGlobal(self.current_source_file, global_name, .{ .id = gid, .ty = ctx_ty });
}
/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
/// The thunk calls ConcreteType.method(ctx, args...).
fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
// Build params: [__sx_ctx]? + ctx: *void + method params.
// Thunks are sx-side functions, so they get the implicit __sx_ctx
// at slot 0 when it's enabled program-wide. The concrete protocol
// receiver (ctx) follows at slot 1; user method args at slot 2+.
var params = std.ArrayList(inst_mod.Function.Param).empty;
defer params.deinit(self.alloc);
const void_ptr = self.module.types.ptrTo(.void);
const thunk_has_ctx = self.implicit_ctx_enabled;
if (thunk_has_ctx) {
params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable;
}
params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable;
for (method.param_types, 0..) |pty, i| {
var buf: [32]u8 = undefined;
const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
}
// Generate unique name
var name_buf: [192]u8 = undefined;
const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk";
const thunk_name_id = self.module.types.internString(thunk_name);
// Save builder state
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
const saved_ctx_ref_thunk = self.current_ctx_ref;
defer self.current_ctx_ref = saved_ctx_ref_thunk;
const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type);
func.has_implicit_ctx = thunk_has_ctx;
const func_id = self.module.addFunction(func);
self.builder.func = func_id;
self.builder.inst_counter = @intCast(owned_params.len);
if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0);
const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{});
self.builder.switchToBlock(entry_block);
// Ensure the concrete method is lowered
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name;
if (!self.lowered_functions.contains(qualified)) {
if (self.program_index.fn_ast_map.contains(qualified)) {
self.lazyLowerFunction(qualified);
} else if (self.genericInstanceMethod(concrete_type_name, method.name)) |gm| {
// Generic-struct instance (`Combined__s64_s64`): the impl method is
// authored on the instance's STAMPED decl (CP-4). Monomorphize it
// for this instance's bindings so the thunk has a concrete
// `Combined__s64_s64.get` to call.
self.monomorphizeFunction(gm.fd, qualified, gm.bindings);
}
}
// Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...).
// The concrete method is itself an sx function that takes the
// implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we
// forward the thunk's own __sx_ctx.
if (self.resolveFuncByName(qualified)) |concrete_fid| {
const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)];
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
// Slot offsets inside the thunk: __sx_ctx at 0 (if present),
// protocol receiver (ctx) at slot user_base, user args at +1, +2...
const user_base: u32 = if (thunk_has_ctx) 1 else 0;
// Forward our __sx_ctx to the concrete method's __sx_ctx slot.
if (concrete_func.has_implicit_ctx) {
call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
// Pass ctx as the next arg (it's the concrete *Type disguised as *void).
// If the concrete method expects a value (e.g., f32) not a pointer, load from ctx.
const ctx_ref = Ref.fromIndex(user_base);
const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0;
if (concrete_receiver_idx < concrete_func.params.len) {
const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty;
const first_info = self.module.types.get(first_concrete_ty);
if (first_info != .pointer) {
// Concrete expects value — load from ctx pointer
call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable;
} else {
call_args.append(self.alloc, ctx_ref) catch unreachable;
}
} else {
call_args.append(self.alloc, ctx_ref) catch unreachable;
}
for (method.param_types, 0..) |proto_pty, i| {
var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i));
// If protocol param is a pointer (Self→*void) but concrete method
// expects a value type, load the value from the pointer.
const concrete_idx = concrete_receiver_idx + 1 + i;
if (concrete_idx < concrete_func.params.len) {
const concrete_pty = concrete_func.params[concrete_idx].ty;
const proto_info = self.module.types.get(proto_pty);
const concrete_info = self.module.types.get(concrete_pty);
if (proto_info == .pointer and concrete_info != .pointer) {
arg_ref = self.builder.load(arg_ref, concrete_pty);
}
}
call_args.append(self.alloc, arg_ref) catch unreachable;
}
const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const concrete_ret = concrete_func.ret;
const result = self.builder.call(concrete_fid, owned_args, concrete_ret);
if (method.ret_type != .void) {
// If protocol returns *void (Self) but concrete returns a value type,
// box the value: alloca+store and return the pointer
const ret_info = self.module.types.get(method.ret_type);
const concrete_ret_info = self.module.types.get(concrete_ret);
if (ret_info == .pointer and concrete_ret_info != .pointer) {
const slot = self.builder.alloca(concrete_ret);
self.builder.store(slot, result);
self.builder.ret(slot, method.ret_type);
} else {
self.builder.ret(result, method.ret_type);
}
} else {
self.builder.retVoid();
}
} else {
// Can't resolve concrete method — emit unreachable
_ = self.builder.emit(.{ .@"unreachable" = {} }, .void);
}
self.builder.finalize();
// Restore builder state
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
return func_id;
}
/// Build a protocol value from a concrete pointer.
/// For inline protocols: struct_init { ctx, thunk1, thunk2, ... }
/// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated
/// When `heap_copy` is true, the concrete data is heap-copied so the protocol value
/// outlives the current stack frame (used when source is a value, not an explicit pointer).
/// When false, the pointer is used directly (user manages the pointee's lifetime).
fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref {
const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr;
const thunks = self.getOrCreateThunks(proto_name, concrete_type_name);
if (thunks.len != pd.methods.len) return concrete_ptr;
const void_ptr_ty = self.module.types.ptrTo(.void);
// When source is a value (not an explicit pointer), heap-allocate
// so the protocol value outlives the current stack frame.
// When source is an explicit pointer (xx @obj), use it directly —
// the user is responsible for the pointee's lifetime.
var ctx_ptr = concrete_ptr;
if (heap_copy) {
const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
const size_ref = self.builder.constInt(@intCast(concrete_size), .s64);
const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
_ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
ctx_ptr = heap_ptr;
}
if (pd.is_inline) {
// Inline: { ctx, fn1, fn2, ... }
var field_vals = std.ArrayList(Ref).empty;
defer field_vals.deinit(self.alloc);
field_vals.append(self.alloc, ctx_ptr) catch unreachable;
for (thunks) |thunk_id| {
const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty);
field_vals.append(self.alloc, fn_ref) catch unreachable;
}
const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable;
return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty);
} else {
// Vtable: { ctx, vtable_ptr }
// Vtable is a global constant (same function pointers for every instance
// of the same Protocol+ConcreteType pair). Cached per pair.
const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr;
// Build cache key: "Proto\x00Type"
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable;
const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: {
// Create vtable global with function pointer initializer
const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable;
const global_name_id = self.module.types.strings.intern(self.alloc, global_name);
const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable;
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = vtable_ty,
.init_val = .{ .vtable = thunk_ids },
.is_const = true,
});
self.protocol_vtable_global_map.put(key, gid) catch {};
break :blk gid;
};
// Reference the vtable global's address
const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty);
const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty);
// Build protocol struct: { ctx, &vtable }
var proto_fields = std.ArrayList(Ref).empty;
defer proto_fields.deinit(self.alloc);
proto_fields.append(self.alloc, ctx_ptr) catch unreachable;
proto_fields.append(self.alloc, vtable_addr) catch unreachable;
const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable;
return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty);
}
}
/// Emit protocol method dispatch for a protocol-typed receiver.
/// Returns the call result ref.
fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId) Ref {
// Find method index
var method_idx: ?usize = null;
var method_info: ?ProtocolMethodInfo = null;
for (proto_info.methods, 0..) |m, i| {
if (std.mem.eql(u8, m.name, method_name)) {
method_idx = i;
method_info = m;
break;
}
}
const mi = method_info orelse return self.emitError(method_name, null);
const midx = method_idx orelse 0;
// Extract ctx from protocol struct (field 0)
const void_ptr = self.module.types.ptrTo(.void);
const ctx = self.builder.structGet(receiver, 0, void_ptr);
// Extract fn_ptr
const fn_ptr = if (proto_info.is_inline) blk: {
// Inline: fn_ptr at field 1+method_idx
break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr);
} else blk: {
// Vtable: load vtable struct, extract fn_ptr at method_idx
const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr);
const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null);
const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty);
break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr);
};
_ = proto_ty;
// Build call args: [__sx_ctx]? + receiver_ctx + user args.
// Protocol thunks are sx-side, so they carry the implicit __sx_ctx
// at slot 0 when the program uses Context — forward our caller's
// ctx so the thunk's body (and the concrete method it forwards to)
// sees the same Context as the dispatching code.
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
if (self.implicit_ctx_enabled) {
call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
call_args.append(self.alloc, ctx) catch unreachable;
for (args, 0..) |a, i| {
const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr;
const arg_ty = self.builder.getRefType(a);
// Untargeted `null` lowers as const_null with type .void. Re-emit it
// as a null of the expected pointer type instead of alloca'ing void.
if (arg_ty == .void and expected_ty == void_ptr) {
call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable;
continue;
}
// A protocol method that expects `*void` accepts any single-pointer
// value directly (`*T`, `[*]T`). Only wrap non-pointer values in an
// alloca-slot — wrapping a pointer would pass the stack slot's
// address instead of the actual pointer, and the callee would read
// 8 bytes of pointer plus garbage from beyond the stack.
const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: {
const info = self.module.types.get(arg_ty);
break :blk info == .pointer or info == .many_pointer;
} else false;
if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) {
const slot = self.builder.alloca(arg_ty);
self.builder.store(slot, a);
call_args.append(self.alloc, slot) catch unreachable;
} else {
// Coerce to match declared parameter type (critical for WASM strict signatures)
const coerced = self.coerceToType(a, arg_ty, expected_ty);
call_args.append(self.alloc, coerced) catch unreachable;
}
}
const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);
// If the protocol method was declared `-> Self` (encoded here as *void)
// and the caller expects a value type, unbox: load the concrete value
// from the returned pointer. A literal `-> *void` return is NOT
// auto-loaded — it's a real pointer whose pointee size we don't know.
if (mi.ret_is_self) {
if (self.target_type) |target| {
const target_info = self.module.types.get(target);
if (target_info != .pointer) {
return self.builder.load(raw_result, target);
}
}
}
return raw_result;
}
/// Resolve the concrete type name for protocol erasure.
/// Handles both direct types and pointer-to-types.
pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
if (ty.isBuiltin()) {
// Primitive types like s64 — check if they have toName()
return self.module.types.typeName(ty);
}
const info = self.module.types.get(ty);
if (info == .pointer) {
// *ConcreteType → resolve pointee
const pointee = info.pointer.pointee;
if (pointee.isBuiltin()) return self.module.types.typeName(pointee);
const pi = self.module.types.get(pointee);
if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name);
return null;
}
if (info == .@"struct") return self.module.types.getString(info.@"struct".name);
return null;
}
// ── Helpers ─────────────────────────────────────────────────────
/// Infer the type of an expression from its AST node (used for untyped var decls).
pub fn inferExprType(self: *Lowering, node: *const Node) TypeId {
return switch (node.data) {
@@ -13158,6 +12562,23 @@ pub const Lowering = struct {
pub const bareVisibleStructDecl = lower_nominal.bareVisibleStructDecl;
pub const bareVisibleStructTemplate = lower_nominal.bareVisibleStructTemplate;
pub const registerGenericStructAlias = lower_nominal.registerGenericStructAlias;
// --- moved to lower/protocol.zig (lower_protocol) ---
pub const ProjectionPosition = lower_protocol.ProjectionPosition;
pub const PackProjection = lower_protocol.PackProjection;
pub const registerProtocolDecl = lower_protocol.registerProtocolDecl;
pub const instantiateParamProtocol = lower_protocol.instantiateParamProtocol;
pub const lookupProtocolArg = lower_protocol.lookupProtocolArg;
pub const lookupProtocolField = lower_protocol.lookupProtocolField;
pub const isProtocolType = lower_protocol.isProtocolType;
pub const getProtocolInfo = lower_protocol.getProtocolInfo;
pub const getOrCreateThunks = lower_protocol.getOrCreateThunks;
pub const emitDefaultContextGlobal = lower_protocol.emitDefaultContextGlobal;
pub const createProtocolThunk = lower_protocol.createProtocolThunk;
pub const buildProtocolValue = lower_protocol.buildProtocolValue;
pub const emitProtocolDispatch = lower_protocol.emitProtocolDispatch;
pub const resolveConcreteTypeName = lower_protocol.resolveConcreteTypeName;
pub const computeHasImpl = lower_protocol.computeHasImpl;
};
/// JNI param/return type resolution: user-declared types pass through

646
src/ir/lower/protocol.zig Normal file
View File

@@ -0,0 +1,646 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("../../ast.zig");
const Node = ast.Node;
const types = @import("../types.zig");
const inst_mod = @import("../inst.zig");
const mod_mod = @import("../module.zig");
const type_bridge = @import("../type_bridge.zig");
const unescape = @import("../../unescape.zig");
const parser_mod = @import("../../parser.zig");
const interp_mod = @import("../interp.zig");
const errors = @import("../../errors.zig");
const jni_descriptor = @import("../jni_descriptor.zig");
const program_index_mod = @import("../program_index.zig");
const resolver_mod = @import("../resolver.zig");
const imports_mod = @import("../../imports.zig");
const ProgramIndex = program_index_mod.ProgramIndex;
const GlobalInfo = program_index_mod.GlobalInfo;
const StructTemplate = program_index_mod.StructTemplate;
const TemplateParam = program_index_mod.TemplateParam;
const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo;
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
const ModuleConstInfo = program_index_mod.ModuleConstInfo;
const TypeResolver = @import("../type_resolver.zig").TypeResolver;
const ResolveEnv = @import("../type_resolver.zig").ResolveEnv;
const PackResolver = @import("../packs.zig").PackResolver;
const ExprTyper = @import("../expr_typer.zig").ExprTyper;
const CallResolver = @import("../calls.zig").CallResolver;
const GenericResolver = @import("../generics.zig").GenericResolver;
const ProtocolResolver = @import("../protocols.zig").ProtocolResolver;
const CoercionResolver = @import("../conversions.zig").CoercionResolver;
const ErrorAnalysis = @import("../error_analysis.zig").ErrorAnalysis;
const ErrorFlow = @import("../error_flow.zig").ErrorFlow;
const ObjcLowering = @import("../ffi_objc.zig").ObjcLowering;
const semantic_diagnostics = @import("../semantic_diagnostics.zig");
const TypeId = types.TypeId;
const StringId = types.StringId;
const Ref = inst_mod.Ref;
const BlockId = inst_mod.BlockId;
const FuncId = inst_mod.FuncId;
const Function = inst_mod.Function;
const Module = mod_mod.Module;
const Builder = mod_mod.Builder;
const lower = @import("../lower.zig");
const Lowering = lower.Lowering;
const Scope = lower.Scope;
/// Shared implementation for the `has_impl(P, T)` builtin and its
/// `tryConstBoolCondition` arm. The protocol expression is either:
/// - Plain `Hash` (identifier / type_expr) → walks
/// `protocol_thunk_map["Hash\x00<T>"]`.
/// - Parameterised `Into(Block)` (call) → walks `param_impl_map`
/// keyed by `"<P>\x00<arg_mangled>\x00<T_mangled>"`.
/// Returns false on any malformed protocol-arg shape (caller
/// reports a diagnostic if it wants).
pub fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool {
switch (proto_node.data) {
.identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty),
.type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty),
.call => |c| {
const p_name: []const u8 = switch (c.callee.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => return false,
};
// Resolve protocol type args. Each goes through
// `resolveTypeArg` so type aliases / generics / pack-
// indexed types all work as protocol args.
var arg_mangles = std.ArrayList(u8).empty;
defer arg_mangles.deinit(self.alloc);
for (c.args, 0..) |a, i| {
if (i > 0) arg_mangles.append(self.alloc, 0) catch return false;
const aty = self.resolveTypeArg(a);
arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false;
}
const ty_mangled = self.mangleTypeName(ty);
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{
p_name, arg_mangles.items, ty_mangled,
}) catch return false;
return self.param_impl_map.contains(key);
},
else => return false,
}
}
/// Register a protocol declaration as a struct type in the IR type table.
/// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... }
/// Non-inline protocols: { ctx: *void, __vtable: *void }
/// Also stores protocol info for dispatch and vtable struct type for vtable protocols.
/// Register a protocol declaration. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub`
/// entry point because the scan pass + several unit tests reach it here.
pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
return self.protocolResolver().registerProtocolDecl(pd);
}
/// Instantiate a parameterized protocol as a runtime VALUE type:
/// `VL(s64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`),
/// with method infos resolved under the type-arg binding (so `get -> T`
/// becomes `get -> s64`) and the binding recorded for projection. Cached by
/// the mangled name `VL__s64`. Mirrors the non-parameterized path in
/// `registerProtocolDecl`.
pub fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId {
const table = &self.module.types;
const void_ptr_ty = table.ptrTo(.void);
var np = std.ArrayList(u8).empty;
np.appendSlice(self.alloc, pd.name) catch {};
var tb = std.StringHashMap(TypeId).init(self.alloc);
for (pd.type_params, 0..) |tp, i| {
if (i >= args.len) break;
const ty = self.resolveTypeWithBindings(args[i]);
tb.put(tp.name, ty) catch {};
np.appendSlice(self.alloc, "__") catch {};
np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
}
const mangled = np.items;
const name_id = table.internString(mangled);
if (table.findByName(name_id)) |existing| {
const info = table.get(existing);
if (info == .@"struct" and info.@"struct".is_protocol) return existing;
}
// Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol).
var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable;
if (pd.is_inline) {
for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
} else {
fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable;
}
const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } };
const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info);
table.updatePreservingKey(id, struct_info);
// Method infos resolved with the type-arg binding (T → s64), pinned to
// the protocol's OWN module (E4) so a method-signature type visible only
// there resolves correctly when instantiated cross-module. `Self` and the
// bound type-args short-circuit before the leaf; a concrete library type
// in a signature is the case this pin protects.
const saved_tb = self.type_bindings;
self.type_bindings = tb;
const saved_pp_src = self.current_source_file;
defer self.setCurrentSourceFile(saved_pp_src);
if (pd.source_file) |src| self.setCurrentSourceFile(src);
var method_infos = std.ArrayList(ProtocolMethodInfo).empty;
for (pd.methods) |method| {
var ptypes = std.ArrayList(TypeId).empty;
for (method.params) |p| {
const pty = blk: {
if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty;
break :blk self.resolveTypeWithBindings(p);
};
ptypes.append(self.alloc, pty) catch unreachable;
}
var ret_is_self = false;
const ret = if (method.return_type) |rt| blk: {
if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
ret_is_self = true;
break :blk void_ptr_ty;
}
break :blk self.resolveTypeWithBindings(rt);
} else .void;
method_infos.append(self.alloc, .{
.name = method.name,
.param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
.ret_type = ret,
.ret_is_self = ret_is_self,
}) catch unreachable;
}
self.type_bindings = saved_tb;
const owned = self.alloc.dupe(u8, mangled) catch return id;
self.program_index.protocol_decl_map.put(owned, .{
.name = owned,
.is_inline = pd.is_inline,
.methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable,
}) catch {};
// Record the type-arg binding so projection (`xs.T`, `.value`) and
// method-arg resolution on this instance can recover it.
self.struct_instance_bindings.put(owned, tb) catch {};
if (!pd.is_inline) {
var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
var vtable_name_buf: [192]u8 = undefined;
const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable";
const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } });
self.protocol_vtable_type_map.put(owned, vtable_ty) catch {};
}
return id;
}
// ── Pack projection name resolution (Feature 1, Decision 4) ──────────
//
// A `..pack.<name>` projection can target two protocol namespaces:
// - type-arg namespace: the `protocol($T, ...)` params.
// - runtime-accessor namespace: the protocol's methods (protocols have
// no fields; a zero-arg method like `value` is the accessor).
// Resolution is POSITION-driven, not precedence-driven: type position
// consults type-args, value position consults methods, with NO
// cross-namespace fallback.
pub const ProjectionPosition = enum { type_position, value_position };
pub const PackProjection = union(enum) {
type_arg: u32, // index into the protocol's `type_params`
method: u32, // index into the protocol's `methods`
not_found, // `name` absent from the position-selected namespace
};
/// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`).
/// Returns the `type_params` index, or null (also for unknown protocols).
pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.type_params, 0..) |tp, i| {
if (std.mem.eql(u8, tp.name, name)) return @intCast(i);
}
return null;
}
/// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods
/// — protocols have no fields). Returns the `methods` index, or null.
pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.methods, 0..) |m, i| {
if (std.mem.eql(u8, m.name, name)) return @intCast(i);
}
return null;
}
/// Check if a type name is a registered protocol.
pub fn isProtocolType(self: *Lowering, type_name: []const u8) bool {
return self.program_index.protocol_decl_map.contains(type_name);
}
/// Get protocol info for a TypeId (if it's a protocol type).
/// Protocol lookup. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9
/// callers (dispatch sites here + `calls.zig`) reach it.
pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo {
return self.protocolResolver().getProtocolInfo(ty);
}
/// Get or create thunks for a (protocol, concrete_type) pair.
/// Returns a slice of FuncIds, one per protocol method.
pub fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId {
// Key: "Proto\x00Type"
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{};
if (self.protocol_thunk_map.get(key)) |thunks| return thunks;
// PLANNING: which methods need a thunk (owned by the registry).
const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{};
var thunk_ids = std.ArrayList(FuncId).empty;
defer thunk_ids.deinit(self.alloc);
// EMISSION: materialize one thunk per method (stays in Lowering).
for (methods) |method| {
const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method);
thunk_ids.append(self.alloc, thunk_id) catch unreachable;
}
const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable;
self.protocol_thunk_map.put(key, owned) catch {};
return owned;
}
/// Emit the process-wide default Context as an LLVM static constant.
///
/// @__sx_default_context = internal constant %Context {
/// %Allocator { ptr null,
/// ptr @__thunk_CAllocator_Allocator_alloc,
/// ptr @__thunk_CAllocator_Allocator_dealloc },
/// ptr null
/// }
///
/// Used by FFI inbound wrappers (Step 4) and the interp's default-
/// context call entry (Step 7). Only emitted when the program imports
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
pub fn emitDefaultContextGlobal(self: *Lowering) void {
const saved_edc = self.emitting_default_context;
self.emitting_default_context = true;
defer self.emitting_default_context = saved_edc;
const tbl = &self.module.types;
const ctx_name_id = tbl.internString("Context");
const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
if (tbl.findByName(tbl.internString("Allocator")) == null) return;
if (tbl.findByName(tbl.internString("CAllocator")) == null) return;
// Force the CAllocator → Allocator thunks to exist so we can
// reference them by FuncId in the static initializer.
const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
if (thunks.len < 2) return;
// Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
// CAllocator is stateless, so ctx is null.
const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
alloc_fields[0] = .null_val;
alloc_fields[1] = .{ .func_ref = thunks[0] };
alloc_fields[2] = .{ .func_ref = thunks[1] };
// Context value: { allocator: Allocator, data: *void }
const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
ctx_fields[0] = .{ .aggregate = alloc_fields };
ctx_fields[1] = .null_val;
const global_name = "__sx_default_context";
const global_name_id = tbl.internString(global_name);
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = ctx_ty,
.init_val = .{ .aggregate = ctx_fields },
.is_const = true,
});
self.putGlobal(self.current_source_file, global_name, .{ .id = gid, .ty = ctx_ty });
}
/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
/// The thunk calls ConcreteType.method(ctx, args...).
pub fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
// Build params: [__sx_ctx]? + ctx: *void + method params.
// Thunks are sx-side functions, so they get the implicit __sx_ctx
// at slot 0 when it's enabled program-wide. The concrete protocol
// receiver (ctx) follows at slot 1; user method args at slot 2+.
var params = std.ArrayList(inst_mod.Function.Param).empty;
defer params.deinit(self.alloc);
const void_ptr = self.module.types.ptrTo(.void);
const thunk_has_ctx = self.implicit_ctx_enabled;
if (thunk_has_ctx) {
params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable;
}
params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable;
for (method.param_types, 0..) |pty, i| {
var buf: [32]u8 = undefined;
const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
}
// Generate unique name
var name_buf: [192]u8 = undefined;
const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk";
const thunk_name_id = self.module.types.internString(thunk_name);
// Save builder state
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
const saved_ctx_ref_thunk = self.current_ctx_ref;
defer self.current_ctx_ref = saved_ctx_ref_thunk;
const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type);
func.has_implicit_ctx = thunk_has_ctx;
const func_id = self.module.addFunction(func);
self.builder.func = func_id;
self.builder.inst_counter = @intCast(owned_params.len);
if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0);
const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{});
self.builder.switchToBlock(entry_block);
// Ensure the concrete method is lowered
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name;
if (!self.lowered_functions.contains(qualified)) {
if (self.program_index.fn_ast_map.contains(qualified)) {
self.lazyLowerFunction(qualified);
} else if (self.genericInstanceMethod(concrete_type_name, method.name)) |gm| {
// Generic-struct instance (`Combined__s64_s64`): the impl method is
// authored on the instance's STAMPED decl (CP-4). Monomorphize it
// for this instance's bindings so the thunk has a concrete
// `Combined__s64_s64.get` to call.
self.monomorphizeFunction(gm.fd, qualified, gm.bindings);
}
}
// Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...).
// The concrete method is itself an sx function that takes the
// implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we
// forward the thunk's own __sx_ctx.
if (self.resolveFuncByName(qualified)) |concrete_fid| {
const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)];
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
// Slot offsets inside the thunk: __sx_ctx at 0 (if present),
// protocol receiver (ctx) at slot user_base, user args at +1, +2...
const user_base: u32 = if (thunk_has_ctx) 1 else 0;
// Forward our __sx_ctx to the concrete method's __sx_ctx slot.
if (concrete_func.has_implicit_ctx) {
call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
// Pass ctx as the next arg (it's the concrete *Type disguised as *void).
// If the concrete method expects a value (e.g., f32) not a pointer, load from ctx.
const ctx_ref = Ref.fromIndex(user_base);
const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0;
if (concrete_receiver_idx < concrete_func.params.len) {
const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty;
const first_info = self.module.types.get(first_concrete_ty);
if (first_info != .pointer) {
// Concrete expects value — load from ctx pointer
call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable;
} else {
call_args.append(self.alloc, ctx_ref) catch unreachable;
}
} else {
call_args.append(self.alloc, ctx_ref) catch unreachable;
}
for (method.param_types, 0..) |proto_pty, i| {
var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i));
// If protocol param is a pointer (Self→*void) but concrete method
// expects a value type, load the value from the pointer.
const concrete_idx = concrete_receiver_idx + 1 + i;
if (concrete_idx < concrete_func.params.len) {
const concrete_pty = concrete_func.params[concrete_idx].ty;
const proto_info = self.module.types.get(proto_pty);
const concrete_info = self.module.types.get(concrete_pty);
if (proto_info == .pointer and concrete_info != .pointer) {
arg_ref = self.builder.load(arg_ref, concrete_pty);
}
}
call_args.append(self.alloc, arg_ref) catch unreachable;
}
const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const concrete_ret = concrete_func.ret;
const result = self.builder.call(concrete_fid, owned_args, concrete_ret);
if (method.ret_type != .void) {
// If protocol returns *void (Self) but concrete returns a value type,
// box the value: alloca+store and return the pointer
const ret_info = self.module.types.get(method.ret_type);
const concrete_ret_info = self.module.types.get(concrete_ret);
if (ret_info == .pointer and concrete_ret_info != .pointer) {
const slot = self.builder.alloca(concrete_ret);
self.builder.store(slot, result);
self.builder.ret(slot, method.ret_type);
} else {
self.builder.ret(result, method.ret_type);
}
} else {
self.builder.retVoid();
}
} else {
// Can't resolve concrete method — emit unreachable
_ = self.builder.emit(.{ .@"unreachable" = {} }, .void);
}
self.builder.finalize();
// Restore builder state
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
return func_id;
}
/// Build a protocol value from a concrete pointer.
/// For inline protocols: struct_init { ctx, thunk1, thunk2, ... }
/// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated
/// When `heap_copy` is true, the concrete data is heap-copied so the protocol value
/// outlives the current stack frame (used when source is a value, not an explicit pointer).
/// When false, the pointer is used directly (user manages the pointee's lifetime).
pub fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref {
const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr;
const thunks = self.getOrCreateThunks(proto_name, concrete_type_name);
if (thunks.len != pd.methods.len) return concrete_ptr;
const void_ptr_ty = self.module.types.ptrTo(.void);
// When source is a value (not an explicit pointer), heap-allocate
// so the protocol value outlives the current stack frame.
// When source is an explicit pointer (xx @obj), use it directly —
// the user is responsible for the pointee's lifetime.
var ctx_ptr = concrete_ptr;
if (heap_copy) {
const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
const size_ref = self.builder.constInt(@intCast(concrete_size), .s64);
const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
_ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
ctx_ptr = heap_ptr;
}
if (pd.is_inline) {
// Inline: { ctx, fn1, fn2, ... }
var field_vals = std.ArrayList(Ref).empty;
defer field_vals.deinit(self.alloc);
field_vals.append(self.alloc, ctx_ptr) catch unreachable;
for (thunks) |thunk_id| {
const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty);
field_vals.append(self.alloc, fn_ref) catch unreachable;
}
const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable;
return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty);
} else {
// Vtable: { ctx, vtable_ptr }
// Vtable is a global constant (same function pointers for every instance
// of the same Protocol+ConcreteType pair). Cached per pair.
const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr;
// Build cache key: "Proto\x00Type"
const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable;
const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: {
// Create vtable global with function pointer initializer
const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable;
const global_name_id = self.module.types.strings.intern(self.alloc, global_name);
const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable;
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = vtable_ty,
.init_val = .{ .vtable = thunk_ids },
.is_const = true,
});
self.protocol_vtable_global_map.put(key, gid) catch {};
break :blk gid;
};
// Reference the vtable global's address
const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty);
const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty);
// Build protocol struct: { ctx, &vtable }
var proto_fields = std.ArrayList(Ref).empty;
defer proto_fields.deinit(self.alloc);
proto_fields.append(self.alloc, ctx_ptr) catch unreachable;
proto_fields.append(self.alloc, vtable_addr) catch unreachable;
const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable;
return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty);
}
}
/// Emit protocol method dispatch for a protocol-typed receiver.
/// Returns the call result ref.
pub fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId) Ref {
// Find method index
var method_idx: ?usize = null;
var method_info: ?ProtocolMethodInfo = null;
for (proto_info.methods, 0..) |m, i| {
if (std.mem.eql(u8, m.name, method_name)) {
method_idx = i;
method_info = m;
break;
}
}
const mi = method_info orelse return self.emitError(method_name, null);
const midx = method_idx orelse 0;
// Extract ctx from protocol struct (field 0)
const void_ptr = self.module.types.ptrTo(.void);
const ctx = self.builder.structGet(receiver, 0, void_ptr);
// Extract fn_ptr
const fn_ptr = if (proto_info.is_inline) blk: {
// Inline: fn_ptr at field 1+method_idx
break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr);
} else blk: {
// Vtable: load vtable struct, extract fn_ptr at method_idx
const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr);
const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null);
const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty);
break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr);
};
_ = proto_ty;
// Build call args: [__sx_ctx]? + receiver_ctx + user args.
// Protocol thunks are sx-side, so they carry the implicit __sx_ctx
// at slot 0 when the program uses Context — forward our caller's
// ctx so the thunk's body (and the concrete method it forwards to)
// sees the same Context as the dispatching code.
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
if (self.implicit_ctx_enabled) {
call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
call_args.append(self.alloc, ctx) catch unreachable;
for (args, 0..) |a, i| {
const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr;
const arg_ty = self.builder.getRefType(a);
// Untargeted `null` lowers as const_null with type .void. Re-emit it
// as a null of the expected pointer type instead of alloca'ing void.
if (arg_ty == .void and expected_ty == void_ptr) {
call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable;
continue;
}
// A protocol method that expects `*void` accepts any single-pointer
// value directly (`*T`, `[*]T`). Only wrap non-pointer values in an
// alloca-slot — wrapping a pointer would pass the stack slot's
// address instead of the actual pointer, and the callee would read
// 8 bytes of pointer plus garbage from beyond the stack.
const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: {
const info = self.module.types.get(arg_ty);
break :blk info == .pointer or info == .many_pointer;
} else false;
if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) {
const slot = self.builder.alloca(arg_ty);
self.builder.store(slot, a);
call_args.append(self.alloc, slot) catch unreachable;
} else {
// Coerce to match declared parameter type (critical for WASM strict signatures)
const coerced = self.coerceToType(a, arg_ty, expected_ty);
call_args.append(self.alloc, coerced) catch unreachable;
}
}
const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);
// If the protocol method was declared `-> Self` (encoded here as *void)
// and the caller expects a value type, unbox: load the concrete value
// from the returned pointer. A literal `-> *void` return is NOT
// auto-loaded — it's a real pointer whose pointee size we don't know.
if (mi.ret_is_self) {
if (self.target_type) |target| {
const target_info = self.module.types.get(target);
if (target_info != .pointer) {
return self.builder.load(raw_result, target);
}
}
}
return raw_result;
}
/// Resolve the concrete type name for protocol erasure.
/// Handles both direct types and pointer-to-types.
pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
if (ty.isBuiltin()) {
// Primitive types like s64 — check if they have toName()
return self.module.types.typeName(ty);
}
const info = self.module.types.get(ty);
if (info == .pointer) {
// *ConcreteType → resolve pointee
const pointee = info.pointer.pointee;
if (pointee.isBuiltin()) return self.module.types.typeName(pointee);
const pi = self.module.types.get(pointee);
if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name);
return null;
}
if (info == .@"struct") return self.module.types.getString(info.@"struct".name);
return null;
}
// ── Helpers ─────────────────────────────────────────────────────