Merge branch 'arch-b' (phase B5: lower/protocol.zig + lower/coerce.zig)

This commit is contained in:
agra
2026-06-10 14:00:01 +03:00
3 changed files with 1463 additions and 1324 deletions

File diff suppressed because it is too large Load Diff

772
src/ir/lower/coerce.zig Normal file
View File

@@ -0,0 +1,772 @@
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;
const ParamImplEntry = Lowering.ParamImplEntry;
/// Lower the `xx` operator (type coercion).
/// Uses self.target_type for context when available. Handles:
/// - Any → concrete type: unbox_any
/// - int → int: widen/narrow
/// - int ↔ float: int_to_float/float_to_int
pub fn lowerXX(self: *Lowering, operand: Ref, operand_node: *const Node) Ref {
// Use the operand's *actual* lowered Ref type rather than reaching
// back through inferExprType — the latter doesn't cover every
// expression shape (notably lambdas), and a wrong src_ty here can
// route the cast through coerceToType (e.g. a bogus s64→ptr bitcast)
// and silently skip the user-space Into fallback.
const src_ty = self.builder.getRefType(operand);
const target_explicit = self.target_type != null;
const dst_ty = self.target_type orelse .unresolved;
// PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls
// through to the built-in ladder + the user-`Into` fallback below.
switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) {
// Any → concrete type: unbox.
.unbox_any => {
// When inside a float match arm covering both f32 and f64,
// and target is f64, we need a mini-dispatch to unbox correctly.
// f32 values are stored as zext(bitcast(f32→i32), i64) in Any,
// so bitcasting i64→f64 directly gives wrong results for f32.
if (dst_ty == .f64) {
if (self.current_match_tags) |tags| {
var has_f32 = false;
var has_f64 = false;
for (tags) |t| {
const tid = TypeId.fromIndex(@intCast(t));
if (tid == .f32) has_f32 = true;
if (tid == .f64) has_f64 = true;
}
if (has_f32 and has_f64) {
return self.lowerAnyToF64Dispatch(operand);
}
if (has_f32 and !has_f64) {
// Only f32 values: unbox as f32, then widen
const f32_val = self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, .f32);
return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
}
}
}
return self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, dst_ty);
},
// Same type: no-op.
.no_op => return operand,
// Concrete → Protocol: build protocol value.
.erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty),
// Protocol → pointer: recover the typed ctx pointer (field 0).
// The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
// `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
.protocol_to_pointer => {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
if (dst_ty == void_ptr_ty) return ctx_ref;
return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
},
.coerce => {},
}
const result = self.coerceExplicit(operand, src_ty, dst_ty);
// User-space fallback via `impl Into(Target) for Source`. Only fires
// when the target was explicitly named (not the .s64 default), src and
// dst differ, and the built-in ladder made no progress. Built-ins
// always win.
if (target_explicit and src_ty != dst_ty and result == operand) {
if (self.tryUserConversion(operand, operand_node, src_ty, dst_ty)) |converted| {
return converted;
}
// Pointer-target fallback: `xx <expr>` whose surrounding context
// expects `*T` (a fn arg slot, a var typed as a pointer-to-aggregate)
// can be satisfied by `impl Into(T) for src` plus an implicit
// alloca+store on the result. Lets users write
// `fn(xx () => { ... })` instead of materialising a named Block local
// just to take its address.
if (!dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .pointer) {
const pointee = dst_info.pointer.pointee;
if (pointee != src_ty) {
if (self.tryUserConversion(operand, operand_node, src_ty, pointee)) |converted| {
const slot = self.builder.alloca(pointee);
self.builder.store(slot, converted);
return slot;
}
}
}
}
}
return result;
}
/// 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.
pub 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 false;
if (dst_ty.isBuiltin()) return false;
const dst_info = self.module.types.get(dst_ty);
if (dst_info != .@"struct") return false;
const block_name = self.module.types.internString("Block");
return dst_info.@"struct".name == block_name;
}
/// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]`
/// and returns a call ref when a single pack impl matches `src_ty`'s
/// shape (concrete src closure / fn with the same fixed prefix as
/// the impl's source pack closure). Binds the pack-var to the source's
/// tail param types and the return-var (when generic) to the source's
/// return type, then monomorphises the convert method.
/// Returns null if no pack impls registered for this (proto, dst) or
/// none of them match `src_ty`'s shape.
pub fn tryPackImplMatch(
self: *Lowering,
operand: Ref,
operand_node: *const Node,
src_ty: TypeId,
dst_ty: TypeId,
proto_name: []const u8,
pack_key: []const u8,
guard_key: u64,
) ?Ref {
_ = operand_node;
// PLANNING: select the matching pack impl + its `convert` (registry).
const match = self.protocolResolver().matchPackImpl(src_ty, pack_key) orelse return null;
const entry = match.entry;
const fd = match.convert_fd;
const src_params = match.src_params;
const src_ret = match.src_ret;
const table = &self.module.types;
// EMISSION: bind the pack tail + ret-var, monomorphise, call (Lowering).
// Build bindings. Target → dst_ty (already in the protocol's type
// params), pack-var → src tail TypeIds, ret-var (when generic) →
// src ret.
const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?;
const tail = src_params[ent_pack_start..];
const tail_owned = self.alloc.dupe(TypeId, tail) catch return null;
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
bindings.put(pd.type_params[0].name, dst_ty) catch return null;
if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null;
var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc);
defer pack_bindings.deinit();
pack_bindings.put(entry.pack_var_name, tail_owned) catch return null;
// Mangled name keyed on the CONCRETE source so distinct shapes
// monomorphise separately. Same scheme as the concrete path:
// "<src>.convert__<dst>".
const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
}) catch return null;
self.xx_reentrancy.put(guard_key, {}) catch {};
defer _ = self.xx_reentrancy.remove(guard_key);
if (!self.lowered_functions.contains(mangled)) {
const saved_pack = self.pack_bindings;
self.pack_bindings = pack_bindings;
defer self.pack_bindings = saved_pack;
self.monomorphizeFunction(fd, mangled, &bindings);
}
const fid = self.resolveFuncByName(mangled) orelse return null;
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
var single = [_]Ref{operand};
const final_args = self.prependCtxIfNeeded(func, single[0..]);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
/// the impl's `convert` method and emit a direct call. Returns null when
/// no impl matches (caller falls back to the built-in result, which is
/// the unchanged operand — Phase 3 emits no diagnostic for v0).
pub fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref {
// 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)) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, operand_node.span, "recursive xx conversion from '{s}' to '{s}'", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
});
}
return operand;
}
// Build lookup key: "Into\x00<dst_mangled>\x00<src_mangled>".
// Hardcoded to the "Into" protocol for v1. Generalising to other
// parameterised protocols would walk protocol_decl_map looking for
// protocols that take a single type-param and have a `convert` method.
const proto_name = "Into";
const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
if (pd.type_params.len != 1) return null;
var key_buf = std.ArrayList(u8).empty;
key_buf.appendSlice(self.alloc, proto_name) catch return null;
key_buf.append(self.alloc, 0) catch return null;
key_buf.appendSlice(self.alloc, self.mangleTypeName(dst_ty)) catch return null;
key_buf.append(self.alloc, 0) catch return null;
key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
const key = key_buf.items;
// Pack-only key (proto + dst) — used if the concrete lookup misses.
// Same prefix as the concrete key, minus the `\x00<src_mangled>` tail.
const dst_mangled_len = self.mangleTypeName(dst_ty).len;
const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len];
const entries_opt = self.param_impl_map.get(key);
const has_concrete = entries_opt != null and entries_opt.?.items.len > 0;
if (!has_concrete) {
// Concrete miss — try the pack map before emitting a diagnostic.
if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| {
return result;
}
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;
}
const entries = entries_opt.?;
// Filter by import visibility: only impls in modules that the current
// file transitively imports (or the current file itself) are reachable.
// Falls open when import_graph isn't wired (e.g. comptime callers).
var visible_impls = std.ArrayList(ParamImplEntry).empty;
defer visible_impls.deinit(self.alloc);
self.protocolResolver().findVisibleImpls(entries.items, &visible_impls);
if (visible_impls.items.len == 0) {
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 visible xx conversion from '{s}' to '{s}' — impl exists in another module but is not imported", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
});
}
return operand;
}
if (visible_impls.items.len > 1) {
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, "duplicate xx conversion from '{s}' to '{s}': impls in {s} and {s}", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
visible_impls.items[0].defining_module, visible_impls.items[1].defining_module,
});
}
return operand;
}
const entry = visible_impls.items[0];
// Find the `convert` method on this impl.
var convert_fd: ?*const ast.FnDecl = null;
for (entry.methods) |m| {
if (std.mem.eql(u8, m.name, "convert")) {
convert_fd = m;
break;
}
}
const fd = convert_fd orelse return null;
// Bind Target → dst_ty.
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
bindings.put(pd.type_params[0].name, dst_ty) catch return null;
// Mangled name: "<src>.convert__<dst>".
const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
}) catch return null;
self.xx_reentrancy.put(guard_key, {}) catch {};
defer _ = self.xx_reentrancy.remove(guard_key);
if (!self.lowered_functions.contains(mangled)) {
self.monomorphizeFunction(fd, mangled, &bindings);
}
const fid = self.resolveFuncByName(mangled) orelse return null;
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
var single = [_]Ref{operand};
const final_args = self.prependCtxIfNeeded(func, single[0..]);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
/// True for expression shapes that name an addressable storage location
/// (variables, fields, array elements, dereferenced pointers). Used by
/// `xx <struct-typed expr>` to decide between borrow (lvalue → take the
/// address) and heap-copy (rvalue → allocate a fresh copy).
pub fn isLvalueExpr(self: *Lowering, node: *const Node) bool {
_ = self;
return switch (node.data) {
.identifier, .field_access, .index_expr, .deref_expr => true,
else => false,
};
}
/// Build a protocol value from a concrete value via xx conversion.
/// Coerce `val` (type `src`) to `dst`: if `dst` is a protocol, `xx`-erase
/// the concrete value into it; otherwise fall back to numeric/struct
/// coercion. Used to materialize a pack into a protocol-typed tuple field.
pub fn coerceOrErase(self: *Lowering, val: Ref, src: TypeId, dst: TypeId, node: *const Node) Ref {
if (src == dst) return val;
if (!dst.isBuiltin()) {
const di = self.module.types.get(dst);
if (di == .@"struct" and di.@"struct".is_protocol) {
return self.buildProtocolErasure(val, node, src, dst);
}
}
return self.coerceToType(val, src, dst);
}
pub fn buildProtocolErasure(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) Ref {
const dst_info = self.module.types.get(dst_ty);
if (dst_info != .@"struct") return operand;
const proto_name = self.module.types.getString(dst_info.@"struct".name);
// Determine concrete type name and type — resolve through pointer if needed
var concrete_ptr = operand;
var concrete_type_name: ?[]const u8 = null;
var concrete_ty: TypeId = src_ty;
var heap_copy = false;
if (!src_ty.isBuiltin()) {
const src_info = self.module.types.get(src_ty);
if (src_info == .pointer) {
// xx @acc — operand is already a pointer (user manages lifetime)
const pointee = src_info.pointer.pointee;
concrete_type_name = self.resolveConcreteTypeName(pointee);
concrete_ty = pointee;
heap_copy = false;
} else if (src_info == .@"struct") {
// Struct-typed operand. Split on lvalue-ness:
// - lvalue (identifier, field, index, deref): borrow the
// storage the operand already names. No heap copy; the
// protocol value's ctx points at the caller's slot, and
// mutations through the protocol are visible to the
// original. Lifetime is the caller's responsibility.
// - rvalue (struct literal, call result, etc.): heap-copy
// into a fresh allocation so the protocol value is
// self-contained and outlives this expression.
concrete_type_name = self.module.types.getString(src_info.@"struct".name);
concrete_ty = src_ty;
if (self.isLvalueExpr(operand_node)) {
concrete_ptr = self.lowerExprAsPtr(operand_node);
heap_copy = false;
} else {
heap_copy = true;
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, operand);
concrete_ptr = slot;
}
}
}
// Also try from the operand node for struct literals: xx Accumulator.{ total = 0 }
if (concrete_type_name == null) {
concrete_type_name = self.inferConcreteTypeName(operand_node);
if (concrete_type_name != null) heap_copy = true;
}
if (concrete_type_name) |ctn| {
return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
}
return operand;
}
/// Try to infer the concrete type name from an AST node (for struct literals etc.)
pub fn inferConcreteTypeName(self: *Lowering, node: *const Node) ?[]const u8 {
return switch (node.data) {
.struct_literal => |sl| if (sl.struct_name) |n| n else null,
.unary_op => |uop| if (uop.op == .address_of) self.inferConcreteTypeName(uop.operand) else null,
.identifier => |id| blk: {
// Check if identifier's type resolves to a struct
if (self.scope) |scope| {
if (scope.lookup(id.name)) |binding| {
if (!binding.ty.isBuiltin()) {
const bi = self.module.types.get(binding.ty);
if (bi == .@"struct") break :blk self.module.types.getString(bi.@"struct".name);
if (bi == .pointer) {
const pointee = bi.pointer.pointee;
if (!pointee.isBuiltin()) {
const pi = self.module.types.get(pointee);
if (pi == .@"struct") break :blk self.module.types.getString(pi.@"struct".name);
}
}
}
}
}
break :blk null;
},
else => null,
};
}
/// Generate a mini-dispatch for unboxing Any to f64 when the value might be f32 or f64.
/// Uses alloca-based merge: create result slot, branch, store in each arm, load after merge.
pub fn lowerAnyToF64Dispatch(self: *Lowering, any_val: Ref) Ref {
// Create result alloca BEFORE the branch
const result_slot = self.builder.alloca(.f64);
// Extract type tag from Any
const tag = self.builder.structGet(any_val, 0, .s64);
const f32_bb = self.freshBlock("f32.unbox");
const f64_bb = self.freshBlock("f64.unbox");
const merge_bb = self.freshBlock("float.merge");
// Branch: tag == f32_tag ? f32_bb : f64_bb
const f32_tag = self.builder.constInt(TypeId.f32.index(), .s64);
const cond = self.builder.emit(.{ .cmp_eq = .{ .lhs = tag, .rhs = f32_tag } }, .bool);
self.builder.condBr(cond, f32_bb, &.{}, f64_bb, &.{});
// f32 block: unbox as f32, fpext to f64, store
self.builder.switchToBlock(f32_bb);
const f32_val = self.builder.emit(.{ .unbox_any = .{
.operand = any_val,
} }, .f32);
const f64_from_f32 = self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
self.builder.store(result_slot, f64_from_f32);
self.builder.br(merge_bb, &.{});
// f64 block: unbox as f64 directly, store
self.builder.switchToBlock(f64_bb);
const f64_val = self.builder.emit(.{ .unbox_any = .{
.operand = any_val,
} }, .f64);
self.builder.store(result_slot, f64_val);
self.builder.br(merge_bb, &.{});
// Merge block: load result
self.builder.switchToBlock(merge_bb);
return self.builder.load(result_slot, .f64);
}
/// Produce a default value for a type, applying struct field defaults.
/// For structs with defaults (e.g., `b: s32 = 99`), creates a struct_literal with defaults applied.
/// For other types, returns a zero value.
pub fn buildDefaultValue(self: *Lowering, ty: TypeId) Ref {
if (ty.isBuiltin()) return self.builder.constInt(0, ty);
const info = self.module.types.get(ty);
if (info != .@"struct" and info != .tuple) return self.zeroValue(ty);
// For tuples, build a zero-initialized tuple
if (info == .tuple) {
var field_vals = std.ArrayList(Ref).empty;
defer field_vals.deinit(self.alloc);
for (info.tuple.fields) |f| {
field_vals.append(self.alloc, self.zeroValue(f)) catch unreachable;
}
return self.builder.emit(.{
.tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
}, ty);
}
// Check for struct defaults
const struct_name_str = self.module.types.getString(info.@"struct".name);
const field_defaults = self.struct_defaults_map.get(struct_name_str) orelse
return self.builder.constUndef(ty);
const fields = info.@"struct".fields;
var field_vals = std.ArrayList(Ref).empty;
defer field_vals.deinit(self.alloc);
for (fields, 0..) |f, i| {
if (i < field_defaults.len) {
if (field_defaults[i]) |default_expr| {
field_vals.append(self.alloc, self.lowerCoercedDefault(default_expr, f.ty)) catch unreachable;
} else {
field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
}
} else {
field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
}
}
return self.builder.emit(.{
.struct_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
}, ty);
}
/// Wrap ty in ?ty, but flatten: if ty is already ?U, return ?U (not ??U)
pub fn optionalOfFlattened(self: *Lowering, ty: TypeId) TypeId {
if (!ty.isBuiltin()) {
const info = self.module.types.get(ty);
if (info == .optional) return ty;
}
return self.module.types.optionalOf(ty);
}
/// Produce a zero/default value for any type — constInt(0) for integers,
/// constNull for pointers, constUndef for structs/complex types.
pub fn zeroValue(self: *Lowering, ty: TypeId) Ref {
if (ty.isBuiltin()) return self.builder.constInt(0, ty);
const info = self.module.types.get(ty);
return switch (info) {
// Arbitrary-width integer types (u1, u2, s4, ...) interned as
// `.signed`/`.unsigned` variants — fall through `isBuiltin()`.
.signed, .unsigned => self.builder.constInt(0, ty),
.pointer, .tuple, .optional => self.builder.constNull(ty),
.@"struct", .array, .slice, .many_pointer => self.builder.constNull(ty),
else => self.builder.constUndef(ty),
};
}
/// Emit the unified non-integral float→int narrowing diagnostic (F0.11 /
/// issue 0095). ONE wording, ONE place: every site that rejects an implicit
/// narrowing of a non-integral compile-time float to an integer type calls
/// this, so the message + fix-it stay identical across the typed-binding
/// coerce arm, the field/param-default sites, the typed-const path, and the
/// global-initializer path.
pub fn diagNonIntegralNarrow(self: *Lowering, span: ast.Span, value: f64, dst_ty: TypeId) void {
if (self.diagnostics) |d|
d.addFmt(.err, span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ value, self.formatTypeName(dst_ty) });
}
/// Lower a struct field default `default_expr`, coerced to the field type
/// `field_ty`. A compile-time float default narrowing into an integer field
/// follows the unified rule via `foldComptimeFloatInit`; everything else
/// lowers under the field type as target and coerces at the IR level.
pub fn lowerCoercedDefault(self: *Lowering, default_expr: *const Node, field_ty: TypeId) Ref {
if (self.foldComptimeFloatInit(default_expr, field_ty)) |folded| return folded;
const saved_tt = self.target_type;
self.target_type = field_ty;
const raw = self.lowerExpr(default_expr);
self.target_type = saved_tt;
return self.coerceToType(raw, self.builder.getRefType(raw), field_ty);
}
/// How a float→int conversion is treated. An IMPLICIT coercion (a typed
/// binding initializer) folds an integral compile-time float to its int and
/// REJECTS a non-integral one; an EXPLICIT `xx` / `cast` always truncates.
const CoerceMode = enum { implicit, explicit };
/// Insert a conversion if src_ty and dst_ty differ.
/// Handles int widening/narrowing, float widening/narrowing, and int↔float.
/// IMPLICIT coercion — the typed-binding initializer path. A compile-time
/// float narrowing to an integer folds when integral, errors when not.
pub fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
return self.coerceMode(val, src_ty, dst_ty, .implicit);
}
/// EXPLICIT coercion — the `xx` / `cast(T)` escape hatch. A float→int here
/// always truncates, bypassing the integral-fold / non-integral-error rule.
pub fn coerceExplicit(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
return self.coerceMode(val, src_ty, dst_ty, .explicit);
}
pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mode: CoerceMode) Ref {
// PLANNING: classify the built-in coercion (conversions.zig).
// EMISSION: each arm below reproduces the original lowering.
switch (self.coercionResolver().classify(src_ty, dst_ty)) {
.no_op, .none => return val,
// Unbox Any → concrete type
.unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty),
// Box concrete → Any
.box_any => return self.builder.boxAny(val, src_ty),
// Closure VALUE → bare function-pointer slot: not soundly representable.
// A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env
// arg, but a closure's underlying fn takes an env slot — so passing a
// closure value's fn_ptr drops the env and shifts the args (UB for a
// matching ABI, a wrong-tuple read for ∅-widening, a segfault when the
// closure captures). Only a closure LITERAL can cross this boundary,
// via the static adapter `lowerLambda` emits (so a literal arrives here
// already typed `.function`). Reject the variable case loudly.
.closure_to_fn_reject => {
if (self.diagnostics) |d| {
const cs = self.builder.current_span;
d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{});
}
return val;
},
// Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal
// flowing into a `(s32, s32)` slot — the multi-value failable success
// tuple). Same arity: extract each slot, coerce it, rebuild.
.tuple_elementwise => {
const si = self.module.types.get(src_ty);
const di = self.module.types.get(dst_ty);
var elems = std.ArrayList(Ref).empty;
defer elems.deinit(self.alloc);
for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| {
const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf);
elems.append(self.alloc, self.coerceMode(fv, sf, df, mode)) catch unreachable;
}
return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty);
},
// Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
.optional_unwrap => {
const child_ty = self.module.types.get(src_ty).optional.child;
const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
return self.coerceMode(unwrapped, child_ty, dst_ty, mode);
},
// void → Optional: produce null (void is the type of null_literal)
.void_to_optional => return self.builder.constNull(dst_ty),
// Concrete → Optional wrapping (coerce to the inner type first)
.optional_wrap => {
const child_ty = self.module.types.get(dst_ty).optional.child;
const coerced = self.coerceMode(val, src_ty, child_ty, mode);
return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty);
},
// Concrete → Protocol (auto type erasure)
.erase_protocol => {
const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name);
const ctn = self.resolveConcreteTypeName(src_ty).?;
// If src is a pointer, use directly; otherwise alloca+store + heap-copy
var concrete_ptr = val;
var concrete_ty = src_ty;
var heap_copy = false;
if (!src_ty.isBuiltin()) {
const si = self.module.types.get(src_ty);
if (si == .pointer) {
concrete_ty = si.pointer.pointee;
heap_copy = false;
} else {
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, val);
concrete_ptr = slot;
heap_copy = true;
}
}
return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
},
.int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.float_to_int => {
// Implicit float→int narrowing follows the unified rule (the
// same `floatToIntExact` the array-dim / `$K: Count` paths use):
// a compile-time INTEGRAL float folds to its int, a NON-integral
// one is a compile error. Explicit `xx` / `cast` (mode
// `.explicit`) skips this and truncates. A runtime float has no
// compile-time value to fold — it truncates as before.
if (mode == .implicit) {
if (self.builder.constFloatInfo(val)) |info| {
if (program_index_mod.floatToIntExact(info.value)) |iv| {
return self.builder.constInt(iv, dst_ty);
}
// Non-integral: diagnose, then fall through to the
// truncating op below so lowering finishes and
// `hasErrors()` aborts the build.
self.diagNonIntegralNarrow(.{ .start = info.span.start, .end = info.span.end }, info.value, dst_ty);
}
}
return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
},
// Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot.
// Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches
// to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level
// since LLVMBuildBitCast itself doesn't accept ptr↔int.
.ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.array_to_slice => return self.builder.emit(.{ .array_to_slice = .{ .operand = val } }, dst_ty),
}
}
/// Apply C default argument promotion to variadic-tail args. These rules
/// (bool/s8/s16/u8/u16 → s32, f32 → f64) match the C calling convention's
/// implicit promotions when an argument is passed through `...`.
pub fn promoteCVariadicArgs(self: *Lowering, args: []Ref, fixed_count: usize) void {
if (args.len <= fixed_count) return;
for (args[fixed_count..]) |*arg| {
const src_ty = self.builder.getRefType(arg.*);
const promoted: TypeId = switch (src_ty) {
.bool, .s8, .s16, .u8, .u16 => .s32,
.f32 => .f64,
else => continue,
};
arg.* = self.coerceToType(arg.*, src_ty, promoted);
}
}
/// Coerce call arguments in-place to match function parameter types.
pub fn coerceCallArgs(self: *Lowering, args: []Ref, params: []const Function.Param) void {
for (0..@min(args.len, params.len)) |i| {
const src_ty = self.builder.getRefType(args[i]);
const dst_ty = params[i].ty;
if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) {
const src_info = self.module.types.get(src_ty);
const dst_info = self.module.types.get(dst_ty);
// Array → many_pointer decay: alloca the array, GEP to first element
if (src_info == .array and dst_info == .many_pointer) {
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, args[i]);
const zero = self.builder.constInt(0, .s64);
args[i] = self.builder.emit(.{ .index_gep = .{ .lhs = slot, .rhs = zero } }, dst_ty);
continue;
}
// Implicit address-of: passing T value where *T is expected → alloca + store
// Only when the pointee type matches the source type.
if (dst_info == .pointer and src_info != .pointer and dst_info.pointer.pointee == src_ty) {
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, args[i]);
args[i] = slot;
continue;
}
}
args[i] = self.coerceToType(args[i], src_ty, dst_ty);
}
}

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 ─────────────────────────────────────────────────────