Files
sx/src/ir/lower/call.zig

2203 lines
116 KiB
Zig

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 type_bridge = @import("../type_bridge.zig");
const unescape = @import("../../unescape.zig");
const errors = @import("../../errors.zig");
const program_index_mod = @import("../program_index.zig");
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
const GlobalInfo = program_index_mod.GlobalInfo;
const CallResolver = @import("../calls.zig").CallResolver;
const TypeId = types.TypeId;
const Ref = inst_mod.Ref;
const BlockId = inst_mod.BlockId;
const FuncId = inst_mod.FuncId;
const Function = inst_mod.Function;
const lower = @import("../lower.zig");
const Lowering = lower.Lowering;
const SelectedFunc = Lowering.SelectedFunc;
const isTypeParamDecl = Lowering.isTypeParamDecl;
const isPackFn = Lowering.isPackFn;
const headNameOfCallee = Lowering.headNameOfCallee;
const hasComptimeParams = Lowering.hasComptimeParams;
pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
var c = c_in;
// A bare reserved-type-name spelling in call position parses as a
// `.type_expr` (e.g. `s2(4)`), but if a function of that name is in
// scope — a backtick-declared sx fn or a `#import c` foreign fn whose C
// name collides with a reserved type spelling — it is a CALL to that
// function. `TypeName(val)` is not a cast (casts are `cast(T, val)`), so
// there is no ambiguity. Rewrite the callee to an identifier so the
// normal call machinery resolves it, symmetric to the bare-value
// reference that already resolves via scope/globals.
//
// Scoped to RAW provenance: only a backtick (`is_raw`) or `#import c`
// foreign fn declaration may legally carry a reserved-name spelling
// (the decl check rejects every bare reserved-name sx fn). Refusing the
// rewrite for a non-raw match keeps a genuine reserved type spelling a
// type — belt-and-suspenders should any future path ever reintroduce a
// non-raw reserved-name callee.
if (c.callee.data == .type_expr) {
const tname = c.callee.data.type_expr.name;
const eff = if (self.scope) |scope| scope.lookupFn(tname) orelse tname else tname;
const fd: ?*const ast.FnDecl = self.program_index.fn_ast_map.get(eff) orelse
self.program_index.fn_ast_map.get(tname);
if (fd) |decl| if (decl.is_raw) {
const id_node = self.alloc.create(Node) catch unreachable;
id_node.* = .{ .span = c.callee.span, .data = .{ .identifier = .{ .name = tname, .is_raw = true } } };
const rewritten = self.alloc.create(ast.Call) catch unreachable;
rewritten.* = .{ .callee = id_node, .args = c.args };
c = rewritten;
};
}
// R5 §C: select the bare / value-UFCS same-name call author
// ONCE, via `CallResolver.selectedFreeAuthor` — the SINGLE producer of
// this verdict, the exact same one `CallResolver.plan` consumes for typing.
// The call-path consumers (default expansion, param typing, dispatch) all
// read THIS one author object, so plan-typing and lowering-dispatch can no
// longer disagree about which same-name function the call names, and the
// shadow's FuncId is materialized at most once (into `author_verdict`).
// `selectedFreeAuthor` is side-effect-free (it only runs the author
// selector — no return-type inference / type-arg resolution), so computing
// it eagerly here can't emit a premature diagnostic the way the full plan
// would.
var author_verdict = self.callResolver().selectedFreeAuthor(c);
const sel_author: ?*SelectedFunc = switch (author_verdict) {
.func => |*sf| sf,
else => null,
};
const author_ambiguous = author_verdict == .ambiguous;
// Expand default parameter values for bare identifier callees:
// when the caller omits trailing positional args, fill them in
// from the callee's `param: T = expr` declarations.
if (self.expandCallDefaults(c, sel_author, author_ambiguous)) |expanded| c = expanded;
// Check reflection builtins first (before lowering args — some args are type names, not values)
if (c.callee.data == .identifier) {
if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref;
}
// Check for runtime dispatch pattern BEFORE lowering args.
// lowerRuntimeDispatchCall handles its own arg lowering, and pre-lowering
// cast(type) val would produce a dead `call_builtin cast : void`.
if (c.callee.data == .identifier) {
const id_name = c.callee.data.identifier.name;
const eff_name = blk: {
const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk scoped;
};
// C-import visibility: deny calls to C fn_decls not in the caller's module scope
if (!self.isCImportVisible(eff_name)) {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "C function '{s}' not visible; add #import for the module that declares it", .{eff_name});
return Ref.none;
}
// Non-transitive `#import` visibility check. Apply only when the
// user-typed name resolved as-is to a top-level fn — local-scope
// mangling (eff_name != id_name) and UFCS alias rewriting are
// compiler indirections and stay exempt.
if (std.mem.eql(u8, eff_name, id_name) and
self.program_index.ufcs_alias_map.get(id_name) == null and
self.program_index.fn_ast_map.contains(eff_name) and
!self.isNameVisible(eff_name))
{
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is not visible; #import the module that declares it", .{eff_name});
return Ref.none;
}
if (self.program_index.fn_ast_map.get(eff_name)) |fd| {
if (self.current_match_tags) |tags| {
if (tags.len > 0 and self.hasCastWithRuntimeType(c)) {
return self.lowerRuntimeDispatchCall(fd, eff_name, c, tags);
}
}
}
}
// Handle closure(fn_or_lambda) — wrap bare functions into closures
if (c.callee.data == .identifier and std.mem.eql(u8, c.callee.data.identifier.name, "closure")) {
if (c.args.len >= 1) {
const arg = c.args[0];
// If argument is a bare function name, create a proper closure from it
if (arg.data == .identifier) {
const fn_name = arg.data.identifier.name;
// `closure(fn)` over a genuine flat same-name
// collision must capture the RESOLVED author's FuncId, not the
// first-wins winner's. Plain bare name only; `.ambiguous`
// → loud diagnostic; `.none` → existing first-wins path.
const closure_fid: ?FuncId = blk_cl: {
if (self.program_index.ufcs_alias_map.get(fn_name) == null and
(if (self.scope) |scope| scope.lookup(fn_name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.selectPlainCallableAuthor(fn_name, caller_file)) {
.func => |sf| {
var selected = sf;
break :blk_cl self.selectedFuncId(&selected, fn_name);
},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, arg.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fn_name});
return Ref.none;
},
.none => {},
}
}
}
if (!self.lowered_functions.contains(fn_name)) {
self.lazyLowerFunction(fn_name);
}
break :blk_cl self.resolveFuncByName(fn_name);
};
if (closure_fid) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
// Build closure type from user-visible params only —
// skip the implicit __sx_ctx param.
var param_types_list = std.ArrayList(TypeId).empty;
defer param_types_list.deinit(self.alloc);
const skip: usize = if (func.has_implicit_ctx) 1 else 0;
for (func.params[skip..]) |p| {
param_types_list.append(self.alloc, p.ty) catch unreachable;
}
const closure_ty = self.module.types.closureType(param_types_list.items, func.ret);
const closure_info = self.module.types.get(closure_ty).closure;
const tramp_id = self.createBareFnTrampoline(fid, closure_info);
return self.builder.closureCreate(tramp_id, Ref.none, closure_ty);
}
}
// Lambda or other expression — already produces closure_create
return self.lowerExpr(arg);
}
}
// Early detection of comptime-expanded calls (e.g. print) — skip arg evaluation
// since lowerComptimeCall re-evaluates args from AST (avoiding double evaluation)
if (c.callee.data == .identifier) {
const early_name = blk: {
const id_name = c.callee.data.identifier.name;
const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk scoped;
};
// R5 §C: the early pack/comptime/generic dispatch reads
// the SAME author the call resolver SELECTED — not the first-wins
// winner — whenever a genuine flat same-name collision rerouted the
// call (`sel_author != null`). The selector only ever returns a plain
// free fn (`isPlainFreeFn` rejects type-params / comptime / pack), so
// `sel_author.decl` matches none of the arms below and the early path
// falls through to the main dispatch, which CONSUMES `sel_author` and
// binds that author. Without this the early path would dispatch the
// first-wins winner (e.g. a pack `(..$args)`) and disagree with the
// main dispatch — the selected plain author's bare call would invoke
// the wrong function. On the common path (`sel_author == null`) this
// reads the winner exactly as before — byte-identical, since the
// selector reroutes nothing there.
const early_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else self.program_index.fn_ast_map.get(early_name);
if (early_fd) |fd| {
if (isPackFn(fd)) {
// Protocol packs (`..xs: P`) and comptime type-packs
// (`..$args`) both monomorphize per call shape.
return self.lowerPackFnCall(fd, c);
}
if (hasComptimeParams(fd)) {
return self.lowerComptimeCall(fd, c);
}
// Early detection of generic function calls — skip arg lowering for type params
// because lowerGenericCall resolves type params from AST nodes, not lowered refs.
// Only if the name is NOT shadowed by a local variable (closure, fn ptr, etc.).
// A selected author is never generic (`isPlainFreeFn` excludes
// `type_params > 0`), so this branch fires only on the winner.
const shadowed = if (self.scope) |scope| scope.lookup(c.callee.data.identifier.name) != null else false;
if (fd.type_params.len > 0 and !shadowed) {
// Types are explicit when call args match param count (e.g., are_equal(Point, p1, p2))
// Types are inferred when call args < param count (e.g., are_equal(p1, p2))
const types_explicit = c.args.len == fd.params.len;
var lowered_args = std.ArrayList(Ref).empty;
defer lowered_args.deinit(self.alloc);
for (c.args, 0..) |arg, ai| {
// Skip type param args only when types are passed explicitly
if (types_explicit and ai < fd.params.len and isTypeParamDecl(&fd.params[ai], fd.type_params)) {
lowered_args.append(self.alloc, Ref.none) catch unreachable;
} else {
const saved_target = self.target_type;
lowered_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
self.target_type = saved_target;
}
}
return self.lowerGenericCall(fd, early_name, c, lowered_args.items);
}
}
}
// Lower args (with target type propagation for xx conversions)
var args = std.ArrayList(Ref).empty;
defer args.deinit(self.alloc);
// Try to resolve param types for target_type context
const param_types = self.resolveCallParamTypes(c, sel_author);
// For enum_literal callees (.Variant(payload)), resolve the payload target type
// from the union field type so struct literal fields get proper coercion
var enum_payload_ty: ?TypeId = null;
if (c.callee.data == .enum_literal) {
const target = self.target_type orelse .unresolved;
if (!target.isBuiltin()) {
const info = self.module.types.get(target);
if (info == .tagged_union) {
const tag = self.resolveVariantIndex(target, c.callee.data.enum_literal.name);
if (tag < info.tagged_union.fields.len) {
enum_payload_ty = info.tagged_union.fields[tag].ty;
}
}
}
}
for (c.args, 0..) |arg, ai| {
if (arg.data == .spread_expr) {
// Pack spread `..xs` / `..xs.method` → expand to N positional
// args here. A runtime-slice spread (`..arr`) is left as a
// placeholder for the slice-variadic path (packVariadicCallArgs).
if (self.packSpreadRefs(arg.data.spread_expr.operand, arg.span)) |elems| {
defer self.alloc.free(elems);
for (elems) |e| args.append(self.alloc, e) catch unreachable;
continue;
}
args.append(self.alloc, Ref.none) catch unreachable;
continue;
}
const saved_target = self.target_type;
if (ai < param_types.len) {
self.target_type = param_types[ai];
}
if (enum_payload_ty) |ept| {
if (ai == 0) self.target_type = ept;
}
// Implicit float→int narrowing of a compile-time float argument
// (incl. an expanded `param: T = expr` default) follows the unified
// rule: an integral comptime float folds, a non-integral one errors.
// A runtime float / `xx` cast is unaffected and coerces as before.
if (ai < param_types.len) {
if (self.foldComptimeFloatInit(arg, param_types[ai])) |folded| {
args.append(self.alloc, folded) catch unreachable;
self.target_type = saved_target;
continue;
}
}
// Implicit address-of: when param expects *T and arg is an identifier
// with an alloca of type T, pass the alloca pointer directly (reference
// semantics, so mutations through the pointer are visible to the caller).
if (ai < param_types.len and arg.data == .identifier) {
const pt = param_types[ai];
if (!pt.isBuiltin()) {
const pti = self.module.types.get(pt);
if (pti == .pointer) {
if (self.scope) |scope| {
if (scope.lookup(arg.data.identifier.name)) |binding| {
// Only apply when the binding type matches the pointee type
if (binding.is_alloca and binding.ty == pti.pointer.pointee) {
const ptr_ty = self.module.types.ptrTo(binding.ty);
args.append(self.alloc, self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, ptr_ty)) catch unreachable;
self.target_type = saved_target;
continue;
}
}
}
}
}
}
// Implicit address-of for compound lvalues (field access / index /
// deref): when the param expects `*T` and the arg is an addressable
// lvalue of type `T`, pass the lvalue's real address (GEP) — same
// reference semantics as the identifier case above. Without this the
// arg would be loaded into a temporary and the callee would mutate a
// throwaway copy (silent data loss — e.g. `make_move(self.board, m)`).
if (ai < param_types.len and (arg.data == .field_access or arg.data == .index_expr or arg.data == .deref_expr)) {
const pt = param_types[ai];
if (!pt.isBuiltin()) {
const pti = self.module.types.get(pt);
if (pti == .pointer and self.inferExprType(arg) == pti.pointer.pointee) {
// `lowerExprAsPtr` yields the lvalue's address, typed
// either as `*T` already (index/deref) or as the pointee
// `T` (a field "place" ref); normalize to `*T` — exactly
// what `@field_access` does.
const place = self.lowerExprAsPtr(arg);
const place_ty = self.builder.getRefType(place);
const ref: ?Ref = if (place_ty == pt)
place
else if (place_ty == pti.pointer.pointee)
self.builder.emit(.{ .addr_of = .{ .operand = place } }, pt)
else
null;
if (ref) |r| {
args.append(self.alloc, r) catch unreachable;
self.target_type = saved_target;
continue;
}
}
}
}
const val = self.lowerExpr(arg);
self.target_type = saved_target;
// Passing a `*T` where a `T` value is expected — a by-reference loop
// capture (`for xs: (*m)`), a `*T` parameter, or any pointer local —
// otherwise slips through to LLVM as an opaque "call parameter type
// does not match function signature" verifier error. Flag it at the
// call site with a `.*` fix-it.
if (ai < param_types.len) {
const vt = self.builder.getRefType(val);
const vti = self.module.types.get(vt);
if (vti == .pointer and vti.pointer.pointee == param_types[ai]) {
if (self.diagnostics) |d| {
const tn = self.formatTypeName(param_types[ai]);
if (arg.data == .identifier) {
const nm = arg.data.identifier.name;
const lead: []const u8 = if (self.refCapturePointee(arg) != null) "by-reference loop capture" else "argument";
const fix = std.fmt.allocPrint(self.alloc, "{s}.*", .{nm}) catch nm;
const pid = d.addFmtId(.err, arg.span, "{s} '{s}' has type '*{s}', but '{s}' is expected here", .{ lead, nm, tn, tn });
d.addHelpFmt(pid, arg.span, fix, "dereference it to pass the value: `{s}`", .{fix});
} else {
const pid = d.addFmtId(.err, arg.span, "this argument has type '*{s}', but '{s}' is expected here", .{ tn, tn });
d.addHelpFmt(pid, arg.span, null, "dereference it with `.*` to pass the value", .{});
}
}
}
}
args.append(self.alloc, val) catch unreachable;
}
switch (c.callee.data) {
.identifier => |id| {
// Resolve local function name (bare → mangled) and UFCS aliases
const func_name = blk: {
// First try scope lookup for mangled local fn names
const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
// Then try UFCS alias on bare name
if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
// Resolve the alias target through scope too (target may be mangled)
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk scoped;
};
// Handle cast(TargetType, val) — emit conversion instructions
// for any compile-time-resolvable type argument. The gate is the
// canonical `isStaticTypeArg` (the one `type_name`/`type_eq` use),
// so compound shapes (`*T`, `[]T`, `?T`, `[*]T`, `[N]T`) resolve
// statically instead of falling into the runtime-dispatch path
// and dying unresolved (issue 0118). An identifier bound in scope
// as a runtime `Type` value (the `cast(type) val` category-arm
// form) still classifies as non-static and falls through.
if (std.mem.eql(u8, id.name, "cast") and c.args.len >= 2) {
const type_arg = c.args[0];
if (self.isStaticTypeArg(type_arg)) {
const dst_ty = self.resolveTypeArg(c.args[0]);
const val = args.items[1]; // already lowered
const src_ty = self.inferExprType(c.args[1]);
// Unbox Any → concrete type
if (src_ty == .any) {
return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty);
}
return self.coerceExplicit(val, src_ty, dst_ty);
}
// Runtime cast — fall through to builtin handling
}
// Check builtins first (these are handled natively by interpreter and emitter)
if (resolveBuiltin(id.name)) |bid| {
const ret_ty: TypeId = switch (bid) {
.size_of, .align_of => .s64,
.sqrt, .sin, .cos, .floor => blk: {
// Math builtins: return type matches argument type ($T -> T)
if (c.args.len > 0) {
const arg_ty = self.inferExprType(c.args[0]);
if (arg_ty == .f32) break :blk TypeId.f32;
}
break :blk TypeId.f64;
},
else => .void,
};
return self.builder.callBuiltin(bid, args.items, ret_ty);
}
// Check scope first: local variables (closures, fn ptrs) shadow global functions
if (self.scope) |scope| {
if (scope.lookup(id.name)) |binding| {
if (!binding.ty.isBuiltin()) {
const ty_info = self.module.types.get(binding.ty);
if (ty_info == .closure) {
const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
// Closure trampolines carry `__sx_ctx` at
// slot 0; emit_llvm's `call_closure` builds
// the call as [ctx, env, user_args], so we
// prepend ctx here. args[0] becomes ctx.
const owned = if (self.implicit_ctx_enabled) blk: {
const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
arr[0] = self.current_ctx_ref;
@memcpy(arr[1..], args.items);
break :blk arr;
} else self.alloc.dupe(Ref, args.items) catch unreachable;
const ret_ty = ty_info.closure.ret;
return self.builder.emit(.{ .call_closure = .{ .callee = callee_ref, .args = owned } }, ret_ty);
}
}
}
}
// R5 §C: a genuine flat same-name collision — bind the
// author the call resolver selected (own-author-wins, or the single
// flat-reachable author), or reject a bare call to a name ≥2
// imported modules author. `selectedFreeAuthor` (computed once
// above, and the exact verdict `plan` consumes for typing) is the
// single producer; lowering CONSUMES it rather than re-resolving
// the name, so typing and dispatch read the SAME author and can't
// disagree. Reached only for an identifier callee, so
// `sel_author` / `author_ambiguous` here are the bare verdict.
if (author_ambiguous) {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name});
return Ref.none;
}
if (sel_author) |sf| {
const fid = self.selectedFuncId(sf, func_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// The RESOLVED author's decl drives variadic packing — not a
// first-wins re-lookup by name, whose variadic shape may
// differ.
self.packVariadicCallArgs(sf.decl, c, &args);
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
return self.builder.call(fid, final_args, ret_ty);
}
// Check for comptime-expanded or generic functions
if (self.program_index.fn_ast_map.get(func_name)) |fd| {
if (hasComptimeParams(fd)) {
return self.lowerComptimeCall(fd, c);
}
if (fd.type_params.len > 0) {
// Runtime dispatch already handled above (before arg lowering)
return self.lowerGenericCall(fd, func_name, c, args.items);
}
}
// Check for #compiler free functions
if (self.program_index.fn_ast_map.get(func_name)) |fd_check| {
if (fd_check.body.data == .compiler_expr) {
const ret_ty = if (fd_check.return_type) |rt| type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) else TypeId.void;
return self.builder.compilerCall(func_name, args.items, ret_ty);
}
}
// Look up declared/extern function — try lazy lowering if not yet lowered
{
// First attempt: function may already be declared (from scanDecls)
// but not yet lowered. Try lazy lowering if needed.
if (self.program_index.fn_ast_map.contains(func_name) and !self.lowered_functions.contains(func_name)) {
self.lazyLowerFunction(func_name);
}
if (self.resolveFuncByName(func_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// Pack variadic args into a slice if the function has a variadic param
if (self.program_index.fn_ast_map.get(func_name)) |fd| {
self.packVariadicCallArgs(fd, c, &args);
}
const final_args = self.prependCtxIfNeeded(func, args.items);
// Coerce arguments to match parameter types
self.coerceCallArgs(final_args, params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
return self.builder.call(fid, final_args, ret_ty);
}
}
// May be a variable holding a function pointer (non-closure)
if (self.scope) |scope| {
if (scope.lookup(id.name)) |binding| {
const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
const ret_ty = if (!binding.ty.isBuiltin()) blk: {
const bti = self.module.types.get(binding.ty);
break :blk if (bti == .function) bti.function.ret else .s64;
} else .s64;
var final_args = std.ArrayList(Ref).empty;
defer final_args.deinit(self.alloc);
if (self.fnPtrTypeWantsCtx(binding.ty)) {
final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
final_args.appendSlice(self.alloc, args.items) catch unreachable;
const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, ret_ty);
}
}
// May be a global variable holding a function pointer
if (self.resolveGlobalRef(id.name, c.callee.span)) |gi| {
if (!gi.ty.isBuiltin()) {
const gti = self.module.types.get(gi.ty);
if (gti == .function) {
const callee_ref = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
// Coerce args to match fn-ptr param types (including implicit address-of)
for (args.items, 0..) |*arg, ai| {
if (ai < gti.function.params.len) {
const dst_ty = gti.function.params[ai];
const src_ty = self.inferExprType(c.args[ai]);
// Implicit address-of: passing T where *T expected
if (!dst_ty.isBuiltin()) {
const dti = self.module.types.get(dst_ty);
if (dti == .pointer and dti.pointer.pointee == src_ty and src_ty != .void) {
// For identifier args, pass the alloca directly (reference semantics)
if (c.args[ai].data == .identifier) {
if (self.scope) |scope| {
if (scope.lookup(c.args[ai].data.identifier.name)) |binding| {
if (binding.is_alloca) {
arg.* = self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, dst_ty);
continue;
}
}
}
}
// For other expressions, copy semantics
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, arg.*);
arg.* = slot;
continue;
}
}
arg.* = self.coerceToType(arg.*, src_ty, dst_ty);
}
}
var final_args = std.ArrayList(Ref).empty;
defer final_args.deinit(self.alloc);
if (self.fnPtrTypeWantsCtx(gi.ty)) {
final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
}
final_args.appendSlice(self.alloc, args.items) catch unreachable;
const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, gti.function.ret);
}
}
}
// Unresolved function call
return self.emitError(id.name, c.callee.span);
},
.field_access => |fa| {
// `super.method(args)` from inside a `#jni_main` (or any
// sx-defined `#jni_class`) bodied method. Dispatch via
// CallNonvirtual<T>Method against the parent class
// resolved from the enclosing fcd's `#extends` clause.
if (fa.object.data == .identifier and
std.mem.eql(u8, fa.object.data.identifier.name, "super"))
{
return self.lowerSuperCall(fa.field, args.items, c.callee.span);
}
// `Alias.method(args)` where Alias is a foreign-class
// identifier and `method` is a `static` member — JNI
// dispatch via FindClass + GetStaticMethodID + CallStatic*,
// OR (for `new`) via FindClass + GetMethodID("<init>") +
// NewObject. Falls through to existing paths when no match.
if (fa.object.data == .identifier) {
const alias = fa.object.data.identifier.name;
if (self.program_index.foreign_class_map.get(alias)) |fcd| {
for (fcd.members) |m| switch (m) {
.method => |md| if (md.is_static and std.mem.eql(u8, md.name, fa.field)) {
return self.lowerForeignStaticCall(fcd, md, args.items, c.callee.span);
},
else => {},
};
}
}
// Type constructor call: Sx(f32).user(0.5) — obj is a call that returns a type
if (fa.object.data == .call) {
const inner_call = &fa.object.data.call;
// Generic struct STATIC-METHOD head (`Box(s64).make(..)` or the
// qualified `a.Box(s64).make(..)`): the layout author is chosen
// by the single head choke-point (CP-1) and the method body by
// the instance's STAMPED author (CP-4), so layout-author ≡
// body-author for BOTH bare and qualified heads (E4 #1 / #2).
if (headNameOfCallee(inner_call.callee)) |hn| {
switch (self.selectGenericStructHead(hn.name, hn.alias, hn.is_qualified, inner_call.callee.span)) {
.poisoned => return Ref.none,
.template => |t| {
const inst_ty = self.instantiateGenericStruct(&t, inner_call.args);
const inst_name = self.formatTypeName(inst_ty);
if (self.genericInstanceMethod(inst_name, fa.field)) |gm| {
if (self.ensureGenericInstanceMethodLowered(gm)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, func.params);
return self.builder.call(fid, final_args, func.ret);
}
}
},
.not_generic => {},
}
}
if (inner_call.callee.data == .identifier) {
const inner_name = inner_call.callee.data.identifier.name;
const resolved = if (self.scope) |scope| (scope.lookupFn(inner_name) orelse inner_name) else inner_name;
if (self.program_index.fn_ast_map.get(resolved)) |fd| {
if (fd.type_params.len > 0) {
if (self.headFnLeak(inner_name, inner_call.callee.span)) return Ref.none;
// Try instantiate as type function
if (self.instantiateTypeFunction(inner_name, inner_name, fd, inner_call.args)) |result_ty| {
const type_info = self.module.types.get(result_ty);
if (type_info == .tagged_union) {
// Qualified enum construction: Type.variant(payload)
const tag = self.resolveVariantIndex(result_ty, fa.field);
var payload = if (args.items.len > 0) args.items[0] else Ref.none;
if (!payload.isNone()) {
const fields = type_info.tagged_union.fields;
if (tag < fields.len) {
const field_ty = fields[tag].ty;
if (field_ty != .void) {
const payload_ty = self.inferExprType(c.args[0]);
if (field_ty != payload_ty) {
payload = self.coerceToType(payload, payload_ty, field_ty);
}
}
}
}
return self.builder.enumInit(tag, payload, result_ty);
}
if (type_info == .@"enum") {
const tag = self.resolveVariantIndex(result_ty, fa.field);
return self.builder.enumInit(tag, Ref.none, result_ty);
}
}
}
}
}
}
// Namespace-qualified call (e.g. `std.print`) vs method / UFCS
// call on a value (`recv.method`). This boundary decides whether
// the receiver is prepended, so it MUST agree with the call
// plan's `free_fn_ufcs` (prepends) vs `namespace_fn` (does not)
// classification — source it from the single definition in
// `CallResolver` rather than re-deriving it here.
const is_namespace = !self.callResolver().objectIsValue(fa.object);
if (is_namespace) {
// Namespace call: module.func(args) — don't prepend object
const func_name = fa.field;
// Also try qualified name: Namespace.method (for struct methods)
const ns_name: ?[]const u8 = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
// `alias.Type.method()` — strip the alias so the existing
// `Type.method` qualified machinery resolves the static.
.field_access => self.namespaceRootedMember(fa.object),
else => null,
};
const qualified_name = if (ns_name) |n|
std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ n, fa.field }) catch func_name
else
func_name;
// The carry gate (issue 0114): a plain-identifier root that is
// a namespace ALIAS (not a type / fn global name — those are
// the `Type.method` paths below) must be visible under the
// carry rule, and its fn members dispatch pinned to the
// alias's TARGET module — never the global first-wins
// qualified registration, never the last-wins bare fallback.
gate: {
if (fa.object.data != .identifier) break :gate;
const oname = fa.object.data.identifier.name;
if (self.program_index.global_names.contains(oname)) break :gate;
switch (self.namespaceAliasVerdict(oname)) {
.target => |target| {
const fd = Lowering.namespaceFnMember(&target, fa.field) orelse break :gate;
// Foreign / builtin / #compiler bodies keep their
// literal global symbol — the existing bare-name
// machinery below resolves them.
switch (fd.body.data) {
.foreign_expr, .builtin_expr, .compiler_expr => break :gate,
else => {},
}
if (hasComptimeParams(fd)) return self.lowerComptimeCall(fd, c);
if (fd.type_params.len > 0) return self.lowerGenericCall(fd, fa.field, c, args.items);
var sf = SelectedFunc{ .decl = fd, .source = target.target_module_path };
const fid = self.selectedFuncId(&sf, fa.field);
const func = &self.module.functions.items[@intFromEnum(fid)];
self.packVariadicCallArgs(fd, c, &args);
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, func.params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, func.params.len);
return self.builder.call(fid, final_args, func.ret);
},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, fa.object.span, "namespace '{s}' is ambiguous: aliases from multiple flat-imported modules point at different targets; declare the alias locally", .{oname});
return Ref.none;
},
.none => {
if (self.aliasDeclaredAnywhere(oname)) {
if (self.diagnostics) |d|
d.addFmt(.err, fa.object.span, "namespace '{s}' is not visible; #import the module that declares it", .{oname});
return Ref.none;
}
},
}
}
// Check for comptime-expanded or generic functions (try both names)
const effective_name = if (self.program_index.fn_ast_map.get(qualified_name) != null) qualified_name else func_name;
if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
if (hasComptimeParams(fd)) {
return self.lowerComptimeCall(fd, c);
}
if (fd.type_params.len > 0) {
return self.lowerGenericCall(fd, effective_name, c, args.items);
}
}
if (self.program_index.fn_ast_map.contains(effective_name) and !self.lowered_functions.contains(effective_name)) {
self.lazyLowerFunction(effective_name);
}
if (self.resolveFuncByName(effective_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
self.packVariadicCallArgs(fd, c, &args);
}
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
return self.builder.call(fid, final_args, ret_ty);
}
// Check if this is Type.variant(payload) — qualified enum construction
if (ns_name) |type_name| {
const type_name_id = self.module.types.internString(type_name);
if (self.module.types.findByName(type_name_id)) |union_ty| {
const type_info = self.module.types.get(union_ty);
if (type_info == .tagged_union) {
const tag = self.resolveVariantIndex(union_ty, func_name);
var payload = if (args.items.len > 0) args.items[0] else Ref.none;
// Coerce payload to match field type
if (!payload.isNone()) {
const fields = type_info.tagged_union.fields;
if (tag < fields.len) {
const field_ty = fields[tag].ty;
const payload_ty = self.inferExprType(c.args[0]);
if (field_ty != payload_ty) {
payload = self.coerceToType(payload, payload_ty, field_ty);
}
}
}
return self.builder.enumInit(tag, payload, union_ty);
}
if (type_info == .@"enum") {
const tag = self.resolveVariantIndex(union_ty, func_name);
return self.builder.enumInit(tag, Ref.none, union_ty);
}
}
}
return self.emitError(func_name, c.callee.span);
}
// Method call: obj.method(args) → prepend obj (or &obj for *Self receivers)
// For ptr.*.method(): pass the pointer directly instead of loading + re-addressing.
// This ensures mutations through self: *T are visible after the call.
var obj_ty: TypeId = undefined;
var obj: Ref = undefined;
var effective_obj_node: *const Node = fa.object;
if (fa.object.data == .deref_expr) {
effective_obj_node = fa.object.data.deref_expr.operand;
obj_ty = self.inferExprType(effective_obj_node);
obj = self.lowerExpr(effective_obj_node);
} else {
obj_ty = self.inferExprType(fa.object);
obj = self.lowerExpr(fa.object);
}
// Check if field is a closure type — call as closure, not method
if (!obj_ty.isBuiltin()) {
const field_name_id = self.module.types.internString(fa.field);
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, fi| {
if (f.name == field_name_id and !f.ty.isBuiltin()) {
const fti = self.module.types.get(f.ty);
if (fti == .closure) {
// structGet requires an aggregate value; if obj is *T, load through it first.
var agg = obj;
const oi = self.module.types.get(obj_ty);
if (oi == .pointer) {
agg = self.builder.load(obj, oi.pointer.pointee);
}
const closure_val = self.builder.structGet(agg, @intCast(fi), f.ty);
// Prepend ctx for sx-side closure call ABI.
const owned = if (self.implicit_ctx_enabled) blk: {
const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
arr[0] = self.current_ctx_ref;
@memcpy(arr[1..], args.items);
break :blk arr;
} else self.alloc.dupe(Ref, args.items) catch unreachable;
return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret);
}
}
}
}
// Check if receiver is a protocol type → dispatch through vtable/fn_ptrs
if (self.getProtocolInfo(obj_ty)) |proto_info| {
return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, obj_ty);
}
// Check if receiver is `?Protocol` — for sentinel-shaped
// optionals (Protocol has ctx as first ptr field, and a
// null ctx is the "none" state) the unwrap is a no-op
// structurally. Treat the optional value as the protocol
// value and dispatch. Calling a method on a null protocol
// is undefined (same as derefing a null pointer); user
// guards with `if x != null` first.
if (!obj_ty.isBuiltin()) {
const opt_info = self.module.types.get(obj_ty);
if (opt_info == .optional) {
const pay_ty = opt_info.optional.child;
if (self.getProtocolInfo(pay_ty)) |proto_info| {
return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, pay_ty);
}
}
}
var method_args = std.ArrayList(Ref).empty;
defer method_args.deinit(self.alloc);
method_args.append(self.alloc, obj) catch unreachable;
for (args.items) |a| {
method_args.append(self.alloc, a) catch unreachable;
}
// Foreign-class DSL: `inst.method(args)` where `inst`'s
// type is an alias declared by `#jni_class("...") { ... }`
// (or its parallel forms). Routes to the JNI dispatch
// shape, descriptor derived from the sx signature.
const struct_name = self.getStructTypeName(obj_ty);
if (struct_name) |sname_for_foreign| {
if (self.program_index.foreign_class_map.get(sname_for_foreign)) |fcd| {
return self.lowerForeignMethodCall(fcd, fa.field, obj, args.items, c.callee.span);
}
}
// Try to resolve the method by struct type name
if (struct_name) |sname| {
// Try direct qualified name: StructName.method
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch fa.field;
// Generic #compiler method dispatch
if (self.program_index.fn_ast_map.get(qualified)) |method_fd| {
if (method_fd.body.data == .compiler_expr) {
const ret_ty = if (method_fd.return_type) |rt|
type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map)
else
.void;
return self.builder.compilerCall(qualified, method_args.items, ret_ty);
}
}
// Generic-struct instance method: select the body via the
// instance's STAMPED author (CP-4), so the dispatched method is
// the one authored alongside this instance's layout — never the
// global last-wins `fn_ast_map["Template.method"]`.
if (self.genericInstanceMethod(sname, fa.field)) |gm| {
if (self.ensureGenericInstanceMethodLowered(gm)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
self.appendDefaultArgs(gm.fd, &method_args);
const final_args = self.prependCtxIfNeeded(func, method_args.items);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
}
// Generic method on a non-template struct: `obj.method($T, ...)`
// or inferred form `obj.method(val)` where val's type pins $T.
if (self.program_index.fn_ast_map.get(qualified)) |gen_fd| {
if (gen_fd.type_params.len > 0 and gen_fd.body.data != .compiler_expr) {
// Effective AST args: prepend receiver so positions
// line up with fd.params (which has self at index 0).
var eff_args = std.ArrayList(*const Node).empty;
defer eff_args.deinit(self.alloc);
eff_args.append(self.alloc, effective_obj_node) catch unreachable;
for (c.args) |a| eff_args.append(self.alloc, a) catch unreachable;
var gbindings = self.genericResolver().buildTypeBindings(gen_fd, eff_args.items);
defer gbindings.deinit();
const gmangled = self.genericResolver().mangleGenericName(qualified, gen_fd, &gbindings);
if (!self.lowered_functions.contains(gmangled)) {
self.monomorphizeFunction(gen_fd, gmangled, &gbindings);
}
if (self.resolveFuncByName(gmangled)) |gfid| {
const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
const gret_ty = gfunc.ret;
const gparams = gfunc.params;
// Strip type-decl slots from method_args. method_args[0] is the
// receiver (corresponds to fd.params[0] = self, never a type decl).
// Walk fd.params[1..], advance arg_idx through method_args[1..].
var gvalue_args = std.ArrayList(Ref).empty;
defer gvalue_args.deinit(self.alloc);
gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
const types_explicit = method_args.items.len == gen_fd.params.len;
var arg_idx: usize = 1;
for (gen_fd.params[1..]) |p| {
if (isTypeParamDecl(&p, gen_fd.type_params)) {
if (types_explicit) arg_idx += 1;
continue;
}
if (arg_idx < method_args.items.len) {
gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
}
arg_idx += 1;
}
self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
const final_args = self.prependCtxIfNeeded(gfunc, gvalue_args.items);
self.coerceCallArgs(final_args, gparams);
return self.builder.call(gfid, final_args, gret_ty);
}
}
}
// Try non-generic qualified method
if (self.program_index.fn_ast_map.get(qualified)) |fd| {
if (!self.lowered_functions.contains(qualified)) {
self.lazyLowerFunction(qualified);
}
_ = fd;
}
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
const has_ctx = func.has_implicit_ctx;
self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
// Note: coerceCallArgs can trigger protocol thunk creation
// (module.addFunction), invalidating func pointer.
// Use pre-extracted params/ret_ty (+ has_ctx) instead of
// func.* after this.
const final_args = blk: {
if (!has_ctx) break :blk method_args.items;
const new_args = self.alloc.alloc(Ref, method_args.items.len + 1) catch break :blk method_args.items;
new_args[0] = self.current_ctx_ref;
@memcpy(new_args[1..], method_args.items);
break :blk new_args;
};
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
}
// Try to resolve as bare function name (free-function UFCS:
// `recv.fn(args)` → `fn(recv, args)`). Lazily lower the body —
// a function reached ONLY via UFCS would otherwise be declared
// but never emitted (undefined symbol at link).
//
// R5 §C: a free-function UFCS target with a
// genuine flat same-name collision dispatches to the author the
// call PLAN selected for the receiver's source — the SAME author
// plan typed the call's result as, so dispatch and typing can't
// disagree (without this, a string-typed winner over
// an s64 shadow boxes a raw int as a string pointer → segfault).
// The plan is the single producer; lowering consumes its verdict
// (`sel_author` / `cplan.ambiguous_collision`, computed once above)
// rather than re-resolving the field name. `.ambiguous` → loud
// diagnostic; otherwise the existing first-wins lazy path.
const ufcs_fid: ?FuncId = blk_uf: {
if (author_ambiguous) {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field});
return Ref.none;
}
if (sel_author) |sf| {
break :blk_uf self.selectedFuncId(sf, fa.field);
}
if (self.program_index.fn_ast_map.get(fa.field)) |_| {
if (!self.lowered_functions.contains(fa.field)) {
self.lazyLowerFunction(fa.field);
}
}
break :blk_uf self.resolveFuncByName(fa.field);
};
if (ufcs_fid) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// Same implicit address-of as a struct-defined method: if the
// free function's first param is `*T` and the receiver is a
// value `T`, pass its address instead of a by-value copy
self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
const final_args = self.prependCtxIfNeeded(func, method_args.items);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
return self.emitError(fa.field, c.callee.span);
},
.enum_literal => |el| {
const target_opt: ?TypeId = self.target_type;
// Try struct-method dispatch first: .{...}.method() where target is a struct
if (target_opt) |tgt| {
if (!tgt.isBuiltin()) {
const target_info = self.module.types.get(tgt);
if (target_info == .@"struct") {
const struct_name = self.module.types.typeName(tgt);
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ struct_name, el.name }) catch el.name;
if (self.program_index.fn_ast_map.get(qualified)) |fd| {
if (fd.type_params.len > 0) {
return self.lowerGenericCall(fd, qualified, c, args.items);
}
if (!self.lowered_functions.contains(qualified)) {
self.lazyLowerFunction(qualified);
}
}
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
}
}
}
// .Variant(payload) — tagged enum construction. Requires target to be a tagged union.
const target = blk: {
if (target_opt) |tgt| {
if (!tgt.isBuiltin() and self.module.types.get(tgt) == .tagged_union) break :blk tgt;
}
if (self.diagnostics) |diags| {
diags.addFmt(.err, c.callee.span, "cannot infer enum type for '.{s}' \u{2014} use an explicit type or assign to a typed variable", .{el.name});
}
return self.emitPlaceholder(el.name);
};
const tag = self.resolveVariantIndex(target, el.name);
var payload = if (args.items.len > 0) args.items[0] else Ref.none;
// Coerce payload to match the field type
if (!payload.isNone() and !target.isBuiltin()) {
const info = self.module.types.get(target);
if (info == .tagged_union) {
const fields = info.tagged_union.fields;
if (tag < fields.len) {
const field_ty = fields[tag].ty;
const payload_ty = self.inferExprType(c.args[0]);
if (field_ty != payload_ty) {
payload = self.coerceToType(payload, payload_ty, field_ty);
}
}
}
}
return self.builder.enumInit(tag, payload, target);
},
else => {
// Indirect call through expression
const callee_ref = self.lowerExpr(c.callee);
const owned = self.alloc.dupe(Ref, args.items) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, .s64);
},
}
}
/// Emit a diagnostic for code that needs `Context` (allocator
/// protocol, `push Context.{...}`, the `context` identifier) when
/// the program hasn't registered the type — i.e. doesn't transitively
/// import `modules/std.sx`. Returns a placeholder Ref so the lowering
/// can keep going and surface any additional errors.
pub fn diagnoseMissingContext(self: *Lowering, what: []const u8) Ref {
if (self.diagnostics) |d| {
const span = ast.Span{ .start = 0, .end = 0 };
d.addFmt(.err, span, "{s} requires the Context type — add `#import \"modules/std.sx\";` (or a module that imports it)", .{what});
}
return self.emitPlaceholder("missing-context");
}
/// Emit `context.allocator.alloc(size)` dispatch — used by internal
/// compiler-driven heap copies (e.g. the `xx value` protocol-erasure
/// path in `buildProtocolValue`). Routes through whatever allocator is
/// currently installed in `context`, so a surrounding
/// `push Context.{ allocator = my_alloc, ... }` actually backs every
/// allocation including the ones the compiler inserts.
///
/// If `Context` isn't registered (the program doesn't import std.sx),
/// emits a diagnostic and returns a placeholder. We deliberately do
/// 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).
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");
}
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
return self.diagnoseMissingContext("heap allocation");
};
const ctx_ty_info = self.module.types.get(ctx_ty);
if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) {
return self.diagnoseMissingContext("heap allocation");
}
const allocator_ty = ctx_ty_info.@"struct".fields[0].ty;
const ctx = self.builder.load(self.current_ctx_ref, ctx_ty);
const allocator = self.builder.structGet(ctx, 0, allocator_ty);
// #inline Allocator protocol layout: { ctx, alloc_fn_ptr, dealloc_fn_ptr }.
// field 0 = receiver ctx, field 1 = alloc fn-ptr.
const alloc_ctx = self.builder.structGet(allocator, 0, void_ptr_ty);
const fn_ptr = self.builder.structGet(allocator, 1, void_ptr_ty);
// Allocator thunks are sx-side and carry the implicit __sx_ctx at
// slot 0. Forward our caller's current_ctx_ref so the thunk's body
// (and the concrete alloc method it forwards to) has a real
// Context to thread on.
const args = if (self.implicit_ctx_enabled)
self.alloc.dupe(Ref, &.{ self.current_ctx_ref, alloc_ctx, size_ref }) catch unreachable
else
self.alloc.dupe(Ref, &.{ alloc_ctx, size_ref }) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{
.callee = fn_ptr,
.args = args,
} }, void_ptr_ty);
}
/// Emit a call to a foreign-declared function looked up by name.
/// 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.
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);
}
/// Prepend the caller's current `__sx_ctx` to `args` when the callee
/// has the implicit context param. Returns either the original `args`
/// (when no prepend is needed) or a newly-allocated slice with ctx at
/// slot 0. The returned slice is mutable so callers can pass it
/// straight into `coerceCallArgs`. Direct callers that built the args
/// themselves with __sx_ctx already prepended (protocol thunks, FFI
/// wrappers in Step 4) should NOT call this — they already manage
/// slot 0.
pub fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) []Ref {
if (!callee.has_implicit_ctx) return args;
const new_args = self.alloc.alloc(Ref, args.len + 1) catch return args;
new_args[0] = self.current_ctx_ref;
@memcpy(new_args[1..], args);
return new_args;
}
pub fn resolveFuncByName(self: *Lowering, name: []const u8) ?FuncId {
// Check foreign name map first (e.g., "c_abs" → "abs")
const effective_name = self.foreign_name_map.get(name) orelse name;
const name_id = self.module.types.internString(effective_name);
for (self.module.functions.items, 0..) |func, i| {
if (func.name == name_id) return FuncId.fromIndex(@intCast(i));
}
return null;
}
pub fn resolveBuiltin(name: []const u8) ?inst_mod.BuiltinId {
const builtins = .{
// Note: "print" is NOT here — it's a comptime-expanded function, not a simple builtin
.{ "out", inst_mod.BuiltinId.out },
.{ "sqrt", inst_mod.BuiltinId.sqrt },
.{ "sin", inst_mod.BuiltinId.sin },
.{ "cos", inst_mod.BuiltinId.cos },
.{ "floor", inst_mod.BuiltinId.floor },
.{ "size_of", inst_mod.BuiltinId.size_of },
.{ "align_of", inst_mod.BuiltinId.align_of },
.{ "cast", inst_mod.BuiltinId.cast },
};
inline for (builtins) |entry| {
if (std.mem.eql(u8, name, entry[0])) return entry[1];
}
return null;
}
// ── Generic calls ─────────────────────────────────────────────
/// Build `tp.name -> TypeId` bindings for a generic call.
/// `args_ast` must be parallel to `fd.params`; for dot-calls the caller
/// prepends the receiver's AST node so positions align with `fd.params[0] = self`.
/// Caller owns the returned map and must call `.deinit()`.
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
pub fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
var bindings = self.genericResolver().buildTypeBindings(fd, call_node.args);
defer bindings.deinit();
const types_passed_explicitly = call_node.args.len == fd.params.len;
const mangled_name = self.genericResolver().mangleGenericName(base_name, fd, &bindings);
if (!self.lowered_functions.contains(mangled_name)) {
self.monomorphizeFunction(fd, mangled_name, &bindings);
}
if (self.resolveFuncByName(mangled_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// Build value-only args (skip type param declaration args)
var value_args = std.ArrayList(Ref).empty;
defer value_args.deinit(self.alloc);
var arg_idx: usize = 0;
for (fd.params) |p| {
if (isTypeParamDecl(&p, fd.type_params)) {
if (types_passed_explicitly) arg_idx += 1;
continue;
}
if (arg_idx < lowered_args.len) {
value_args.append(self.alloc, lowered_args[arg_idx]) catch unreachable;
}
arg_idx += 1;
}
const final_args = self.prependCtxIfNeeded(func, value_args.items);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
return self.emitError(base_name, call_node.callee.span);
}
/// Create a monomorphized instance of a generic function.
/// Check if a call has a `cast(runtime_var, val)` argument (runtime type dispatch pattern).
pub fn hasCastWithRuntimeType(self: *Lowering, c: *const ast.Call) bool {
for (c.args) |arg| {
if (arg.data == .call) {
if (arg.data.call.callee.data == .identifier) {
const name = arg.data.call.callee.data.identifier.name;
if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
const type_arg = arg.data.call.args[0];
if (type_arg.data == .identifier) {
// It's a runtime type if it's in scope as a variable
if (self.scope) |scope| {
if (scope.lookup(type_arg.data.identifier.name) != null) return true;
}
}
}
}
}
}
return false;
}
/// Generate runtime dispatch for a generic call inside a type-match arm.
/// For each type tag in match_tags, monomorphizes the generic function and calls it.
pub fn lowerRuntimeDispatchCall(
self: *Lowering,
fd: *const ast.FnDecl,
base_name: []const u8,
call_node: *const ast.Call,
match_tags: []const u64,
) Ref {
// Find the cast arg: cast(type_var, any_val)
var cast_arg_idx: usize = 0;
var type_tag_node: ?*const Node = null;
var any_val_node: ?*const Node = null;
for (call_node.args, 0..) |arg, i| {
if (arg.data == .call and arg.data.call.callee.data == .identifier) {
const name = arg.data.call.callee.data.identifier.name;
if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
cast_arg_idx = i;
type_tag_node = arg.data.call.args[0];
any_val_node = arg.data.call.args[1];
break;
}
}
}
// Lower the type tag (runtime value) and Any value BEFORE the switch
const type_tag_raw = self.lowerExpr(type_tag_node orelse return self.emitError("dispatch", call_node.callee.span));
const type_tag_node_ty = self.inferExprType(type_tag_node.?);
const type_tag = if (type_tag_node_ty == .any)
self.builder.emit(.{ .unbox_any = .{ .operand = type_tag_raw } }, .s64)
else
type_tag_raw;
const any_val = self.lowerExpr(any_val_node orelse return self.emitError("dispatch", call_node.callee.span));
// Lower non-cast arguments once (before the switch)
var other_args = std.ArrayList(?Ref).empty;
defer other_args.deinit(self.alloc);
for (call_node.args, 0..) |arg, i| {
if (i == cast_arg_idx) {
other_args.append(self.alloc, null) catch unreachable; // placeholder
} else {
other_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
}
}
// Resolve return type (using first available binding)
const ret_ty: TypeId = blk: {
if (fd.return_type) |rt| {
if (rt.data == .type_expr) {
if (type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) != .unresolved) {
break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map);
}
}
}
break :blk .string; // default for to_string functions
};
const merge_bb = self.freshBlock("dispatch.merge");
const default_bb = self.freshBlock("dispatch.default");
// Build switch cases
var cases = std.ArrayList(inst_mod.SwitchBranch.Case).empty;
defer cases.deinit(self.alloc);
// For each type tag, create a case block
var case_blocks = std.ArrayList(BlockId).empty;
defer case_blocks.deinit(self.alloc);
for (match_tags) |tag| {
const case_bb = self.freshBlock("dispatch.case");
case_blocks.append(self.alloc, case_bb) catch unreachable;
cases.append(self.alloc, .{
.value = @intCast(tag),
.target = case_bb,
.args = &.{},
}) catch unreachable;
}
// Create a result alloca BEFORE the switch (must be before terminator)
var result_slot: ?Ref = null;
if (ret_ty != .void) {
result_slot = self.builder.alloca(ret_ty);
}
self.builder.switchBr(type_tag, cases.items, default_bb, &.{});
for (match_tags, 0..) |tag, ti| {
self.builder.switchToBlock(case_blocks.items[ti]);
const ty_id = TypeId.fromIndex(@intCast(tag));
// Unbox the Any value to the concrete type
const unboxed = self.builder.emit(.{ .unbox_any = .{
.operand = any_val,
} }, ty_id);
if (fd.type_params.len > 0) {
// Generic function: build type bindings + monomorphize
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
// Find which type param the cast arg corresponds to
if (cast_arg_idx < fd.params.len) {
const param_te = fd.params[cast_arg_idx].type_expr;
if (param_te.data == .type_expr) {
// Direct: `param: $T` → T = ty_id
const tp_name = param_te.data.type_expr.name;
for (fd.type_params) |tp| {
if (std.mem.eql(u8, tp.name, tp_name)) {
bindings.put(tp.name, ty_id) catch {};
break;
}
}
} else if (param_te.data == .slice_type_expr) {
// Compound: `param: []$T` → T = element type of ty_id
const elem_te = param_te.data.slice_type_expr.element_type;
if (elem_te.data == .type_expr) {
const tp_name = elem_te.data.type_expr.name;
for (fd.type_params) |tp| {
if (std.mem.eql(u8, tp.name, tp_name)) {
const elem_ty = self.getElementType(ty_id);
bindings.put(tp.name, if (elem_ty != .void) elem_ty else ty_id) catch {};
break;
}
}
}
} else if (param_te.data == .pointer_type_expr) {
// Compound: `param: *$T` → T = pointee type of ty_id
const pointee_te = param_te.data.pointer_type_expr.pointee_type;
if (pointee_te.data == .type_expr) {
const tp_name = pointee_te.data.type_expr.name;
for (fd.type_params) |tp| {
if (std.mem.eql(u8, tp.name, tp_name)) {
if (!ty_id.isBuiltin()) {
const pinfo = self.module.types.get(ty_id);
if (pinfo == .pointer) {
bindings.put(tp.name, pinfo.pointer.pointee) catch {};
break;
}
}
bindings.put(tp.name, ty_id) catch {};
break;
}
}
}
}
}
// Build mangled name
var mangled_buf: [256]u8 = undefined;
var mangled_len: usize = 0;
for (base_name) |ch| {
if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
}
for (fd.type_params) |tp| {
for ("__") |ch| {
if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
}
const bound_ty = bindings.get(tp.name) orelse ty_id;
const type_name_str = self.mangleTypeName(bound_ty);
for (type_name_str) |ch| {
if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
}
}
const mangled_name = mangled_buf[0..mangled_len];
// Monomorphize if not already done
if (!self.lowered_functions.contains(mangled_name)) {
self.monomorphizeFunction(fd, mangled_name, &bindings);
}
// Build call args (replace cast arg with unboxed value, skip type param decl args)
if (self.resolveFuncByName(mangled_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const callee_ret = func.ret;
const callee_params = func.params;
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
for (fd.params, 0..) |p, pi| {
if (isTypeParamDecl(&p, fd.type_params)) continue;
if (pi == cast_arg_idx) {
call_args.append(self.alloc, unboxed) catch unreachable;
} else if (pi < other_args.items.len) {
if (other_args.items[pi]) |ref| {
call_args.append(self.alloc, ref) catch unreachable;
}
}
}
const final_args = self.prependCtxIfNeeded(func, call_args.items);
self.coerceCallArgs(final_args, callee_params);
const result = self.builder.call(fid, final_args, callee_ret);
if (result_slot) |slot| {
self.builder.store(slot, result);
}
}
} else {
// Non-generic function: call directly with per-tag unboxing + coercion
const resolve_name = base_name;
if (!self.lowered_functions.contains(resolve_name)) {
self.lazyLowerFunction(resolve_name);
}
if (self.resolveFuncByName(resolve_name)) |fid| {
const callee_func = &self.module.functions.items[@intFromEnum(fid)];
const callee_ret = callee_func.ret;
const callee_params = callee_func.params;
const callee_has_ctx = callee_func.has_implicit_ctx;
var call_args = std.ArrayList(Ref).empty;
defer call_args.deinit(self.alloc);
for (fd.params, 0..) |_, pi| {
if (pi == cast_arg_idx) {
// Coerce unboxed value (typed as ty_id) to param type
var arg = unboxed;
// callee param index shifts by +1 if it carries __sx_ctx
const callee_pi = pi + @as(usize, if (callee_has_ctx) 1 else 0);
if (callee_pi < callee_params.len) {
arg = self.coerceToType(arg, ty_id, callee_params[callee_pi].ty);
}
call_args.append(self.alloc, arg) catch unreachable;
} else if (pi < other_args.items.len) {
if (other_args.items[pi]) |ref| {
call_args.append(self.alloc, ref) catch unreachable;
}
}
}
// Prepend __sx_ctx if needed BEFORE coercion so indices line up.
var final_call_args: []Ref = call_args.items;
if (callee_has_ctx) {
final_call_args = self.alloc.alloc(Ref, call_args.items.len + 1) catch call_args.items;
if (final_call_args.len == call_args.items.len + 1) {
final_call_args[0] = self.current_ctx_ref;
@memcpy(final_call_args[1..], call_args.items);
}
}
// Coerce non-cast args (source type unknown, use s64 default).
// cast_arg_idx is in user-space (skips __sx_ctx); offset by ctx_slots.
const ctx_slots: usize = if (callee_has_ctx) 1 else 0;
for (0..@min(final_call_args.len, callee_params.len)) |ci| {
if (ci < ctx_slots) continue; // skip __sx_ctx slot
if ((ci - ctx_slots) != cast_arg_idx) {
final_call_args[ci] = self.coerceToType(final_call_args[ci], .s64, callee_params[ci].ty);
}
}
const result = self.builder.call(fid, final_call_args, callee_ret);
if (result_slot) |slot| {
self.builder.store(slot, result);
}
}
}
self.builder.br(merge_bb, &.{});
}
// Default block: store a default value and branch to merge
self.builder.switchToBlock(default_bb);
if (result_slot) |slot| {
const empty_id = self.module.types.internString("");
const default_val = if (ret_ty == .string) self.builder.constString(empty_id) else self.zeroValue(ret_ty);
self.builder.store(slot, default_val);
}
self.builder.br(merge_bb, &.{});
// Merge block: load result
self.builder.switchToBlock(merge_bb);
if (result_slot) |slot| {
return self.builder.load(slot, ret_ty);
}
return self.builder.constInt(0, .void);
}
/// Try to lower a call as a reflection builtin (expanded inline during lowering).
/// Returns null if the call is not a recognized reflection builtin.
pub fn tryLowerReflectionCall(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
// Strict `$T: Type` guard for the type-introspection builtins. A
// value argument (`6`, `true`, `5.2`, a struct) is rejected with a
// diagnostic instead of being silently reinterpreted as a TypeId
// index / sized via its `typeof`. One shared
// classification covers all 7; it runs before dispatch.
if (self.reflectionTypeArgGuard(name, c)) |sentinel| return sentinel;
if (std.mem.eql(u8, name, "size_of")) {
// size_of(T) → const_int(sizeof(T))
const ty = self.resolveTypeArg(c.args[0]);
const size: i64 = @intCast(self.typeSizeBytes(ty));
return self.builder.constInt(size, .s64);
}
if (std.mem.eql(u8, name, "align_of")) {
const ty = self.resolveTypeArg(c.args[0]);
const a: i64 = @intCast(self.module.types.typeAlignBytes(ty));
return self.builder.constInt(a, .s64);
}
if (std.mem.eql(u8, name, "field_count")) {
// field_count(T) → const_int(N)
const ty = self.resolveTypeArg(c.args[0]);
const info = self.module.types.get(ty);
const count: i64 = switch (info) {
.@"struct" => |s| @intCast(s.fields.len),
.@"union" => |u| @intCast(u.fields.len),
.tagged_union => |u| @intCast(u.fields.len),
.@"enum" => |e| @intCast(e.variants.len),
.array => |a| @intCast(a.length),
.vector => |v| @intCast(v.length),
else => 0,
};
return self.builder.constInt(count, .s64);
}
if (std.mem.eql(u8, name, "type_name")) {
// type_name(T):
// - Statically resolvable arg (type expression, pack
// index, generic binding, etc.) → fold to const_string
// at lower time.
// - Dynamic arg (e.g. `list[i]` indexing into a
// `$args`-derived []Type slice) → emit a
// `callBuiltin(.type_name, [arg_ref])`. The interp's
// arm (commit 9600ba5) reads the runtime `.type_tag`
// and returns the per-position name. Without this
// split, the catch-all `else => .s64` in
// `resolveTypeArg` silently returns "s64" for every
// dynamic call — exactly the silent-arm pattern the
// project's REJECTED PATTERNS forbid.
if (self.isStaticTypeArg(c.args[0])) {
const ty = self.resolveTypeArg(c.args[0]);
const tn_str = self.formatTypeName(ty);
const sid = self.module.types.internString(tn_str);
return self.builder.constString(sid);
}
const arg_ref = self.lowerExpr(c.args[0]);
const args_owned = self.alloc.dupe(Ref, &.{arg_ref}) catch return self.builder.constString(self.module.types.internString(""));
return self.builder.callBuiltin(.type_name, args_owned, .string);
}
if (std.mem.eql(u8, name, "type_eq")) {
// type_eq(T1, T2) → const_bool — comptime TypeId equality.
// TypeIds are interned per structural shape so equality on
// them matches the user's intuition: `type_eq(s64, s64)` is
// true, `type_eq(*s64, *s64)` is true, distinct shapes are
// false. Pack-indexed types (`$args[0]`) resolve through
// `resolveTypeArg` → `resolveTypeWithBindings`.
if (c.args.len < 2) return self.builder.constBool(false);
const a = self.resolveTypeArg(c.args[0]);
const b = self.resolveTypeArg(c.args[1]);
return self.builder.constBool(a == b);
}
if (std.mem.eql(u8, name, "type_is_unsigned")) {
// type_is_unsigned(T) → bool. Static arg (a spelled type or
// generic binding) folds to const_bool at lower time. A
// dynamic arg — the runtime `type_of(x)` value queried by
// `any_to_string` — emits a `callBuiltin`: the interp reads
// the boxed TypeId, LLVM GEPs a per-type signedness table.
// Mirrors `type_name`'s static/dynamic split; the same split
// avoids `resolveTypeArg`'s silent `.s64` default lying about
// a runtime Type value.
if (c.args.len < 1) return self.builder.constBool(false);
if (self.isStaticTypeArg(c.args[0])) {
const ty = self.resolveTypeArg(c.args[0]);
return self.builder.constBool(self.module.types.isUnsignedInt(ty));
}
const arg_ref = self.lowerExpr(c.args[0]);
const args_owned = self.alloc.dupe(Ref, &.{arg_ref}) catch return self.builder.constBool(false);
return self.builder.callBuiltin(.type_is_unsigned, args_owned, .bool);
}
if (std.mem.eql(u8, name, "has_impl")) {
// has_impl(P, T) → const_bool. Returns true when type T has
// a reachable impl for protocol P. P is either:
// - plain protocol name (`Hash`, `Eq`) for unary protocols;
// - parameterised call like `Into(Block)` — for protocols
// with type args, the args must be fully spelled.
// Delegates to `computeHasImpl` (shared with the
// `tryConstBoolCondition` arm so `inline if has_impl(...)`
// folds at compile time).
if (c.args.len < 2) return self.builder.constBool(false);
const ty = self.resolveTypeArg(c.args[1]);
return self.builder.constBool(self.computeHasImpl(c.args[0], ty));
}
if (std.mem.eql(u8, name, "is_flags")) {
const ty = self.resolveTypeArg(c.args[0]);
if (!ty.isBuiltin()) {
const info = self.module.types.get(ty);
if (info == .@"enum") return self.builder.constBool(info.@"enum".is_flags);
}
return self.builder.constBool(false);
}
if (std.mem.eql(u8, name, "compile_error")) {
// compile_error(msg) — raise a build-time diagnostic at
// the call site. The argument must be a string literal so
// the message text is available at lower time. Returns a
// void-typed const (the call site is consumed for its
// side effect, not its value).
if (self.diagnostics) |diags| {
if (c.args.len < 1) {
diags.addFmt(.err, c.callee.span, "compile_error requires a string argument", .{});
} else if (c.args[0].data == .string_literal) {
const lit = c.args[0].data.string_literal;
const msg = if (lit.is_raw)
lit.raw
else
unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
diags.addFmt(.err, c.callee.span, "{s}", .{msg});
} else {
diags.addFmt(.err, c.callee.span, "compile_error argument must be a string literal", .{});
}
}
return self.builder.constInt(0, .void);
}
if (std.mem.eql(u8, name, "field_name")) {
// field_name(T, i) → field_name_get instruction
if (c.args.len < 2) return self.builder.constString(self.module.types.internString(""));
const ty = self.resolveTypeArg(c.args[0]);
const idx = self.lowerExpr(c.args[1]);
return self.builder.emit(.{ .field_name_get = .{
.base = .none,
.index = idx,
.struct_type = ty,
} }, .string);
}
if (std.mem.eql(u8, name, "is_comptime")) {
// True under the comptime interpreter, false in compiled code — the
// op decides per backend (it can't fold here, since the same IR
// serves both). Lets stdlib gate a comptime-only diagnostic branch.
return self.builder.emit(.{ .is_comptime = {} }, .bool);
}
if (std.mem.eql(u8, name, "__interp_print_frames")) {
// Backs `trace.print_interpreter_frames()`: dumps the interp call
// chain at comptime, no-op in compiled code (ERR E4.1).
return self.builder.emit(.{ .interp_print_frames = {} }, .void);
}
if (std.mem.eql(u8, name, "__trace_resolve_frame")) {
// Backs `trace.sx`'s formatter: a raw trace-buffer u64 → a `TraceFrame`.
// Compiled code reinterprets the operand as `*TraceFrame` and loads it;
// the interp unpacks (func_id, span.start) and resolves (ERR E3.0
// slice 3b). Result type is the `TraceFrame` struct from trace.sx.
const frame_ty = self.module.types.findByName(self.module.types.internString("TraceFrame")) orelse {
if (self.diagnostics) |d| d.addFmt(.err, null, "`__trace_resolve_frame` needs `TraceFrame` (from trace.sx) in scope", .{});
return self.builder.constInt(0, .void);
};
const arg = self.lowerExpr(c.args[0]);
return self.builder.emit(.{ .trace_resolve = .{ .operand = arg } }, frame_ty);
}
if (std.mem.eql(u8, name, "error_tag_name")) {
// error_tag_name(e) → look the error-set value's runtime tag id up
// in the always-linked tag-name table. The value IS its u32 tag id.
if (c.args.len < 1) return self.builder.constString(self.module.types.internString(""));
const e = self.lowerExpr(c.args[0]);
return self.builder.emit(.{ .error_tag_name_get = .{ .operand = e } }, .string);
}
if (std.mem.eql(u8, name, "field_value")) {
// field_value(s, i) → field_value_get instruction (structs/unions)
// → index_get + box_any (slices/arrays)
if (c.args.len < 2) return self.builder.constInt(0, .any);
const base = self.lowerExpr(c.args[0]);
const idx = self.lowerExpr(c.args[1]);
const struct_ty = self.inferExprType(c.args[0]);
// For slices, arrays, and vectors, use index_get to access elements
if (!struct_ty.isBuiltin()) {
const ti = self.module.types.get(struct_ty);
if (ti == .slice or ti == .array or ti == .vector) {
const elem_ty = self.getElementType(struct_ty);
const elem = self.builder.emit(.{ .index_get = .{ .lhs = base, .rhs = idx } }, elem_ty);
return self.builder.boxAny(elem, elem_ty);
}
}
return self.builder.emit(.{ .field_value_get = .{
.base = base,
.index = idx,
.struct_type = struct_ty,
} }, .any);
}
if (std.mem.eql(u8, name, "type_of")) {
// type_of(val) — produce a Type value (.any-typed aggregate).
if (c.args.len < 1) return self.builder.constType(.void);
const arg_ty = self.inferExprType(c.args[0]);
if (arg_ty == .any) {
// Runtime: extract tag, rebuild Any with `{.any, tag}` so
// the returned value carries Type semantics (tag field
// says ".any" → the value field holds the type id).
const val = self.lowerExpr(c.args[0]);
const tag_val = self.builder.structGet(val, 0, .s64);
return self.builder.boxAny(tag_val, .any);
} else {
return self.builder.constType(arg_ty);
}
}
if (std.mem.eql(u8, name, "field_index")) {
// field_index(T, val) → extract tag from tagged union
if (c.args.len < 2) return self.builder.constInt(0, .s64);
const val = self.lowerExpr(c.args[1]);
// For tagged unions: extract field 0 (the tag)
return self.builder.emit(.{ .enum_tag = .{ .operand = val } }, .s64);
}
if (std.mem.eql(u8, name, "field_value_int")) {
// field_value_int(T, i) → lookup enum variant value by index
if (c.args.len < 2) return self.builder.constInt(0, .s64);
const ty = self.resolveTypeArg(c.args[0]);
const idx = self.lowerExpr(c.args[1]);
// For enums with explicit values, build a global value array and index into it
if (!ty.isBuiltin()) {
const ti = self.module.types.get(ty);
if (ti == .@"enum") {
if (ti.@"enum".explicit_values) |vals| {
// Build inline switch: for each index, return the explicit value
// Simple approach: build an array of constants and use index_get
var elems = std.ArrayList(Ref).empty;
defer elems.deinit(self.alloc);
for (vals) |v| {
elems.append(self.alloc, self.builder.constInt(v, .s64)) catch unreachable;
}
const arr_ty = self.module.types.arrayOf(.s64, @intCast(vals.len));
const arr = self.builder.structInit(elems.items, arr_ty);
return self.builder.emit(.{ .index_get = .{ .lhs = arr, .rhs = idx } }, .s64);
}
}
}
// Default: return the index itself (regular enums)
return idx;
}
return null;
}
/// Strict `$T: Type` classification shared by the 7 type-introspection
/// builtins. An argument denotes a type iff it is a spelled /
/// compile-time type or generic type parameter (the `isStaticTypeArg`
/// shapes), or a runtime `Type` value — which is `.any`-typed at
/// runtime (`type_of(x)`, a `[]Type` element `list[i]`, a `Type`-typed
/// local / field / param). Any other expression — a value of type
/// s64 / f64 / bool / a struct — is NOT a type.
pub fn reflectionArgIsType(self: *Lowering, arg: *const Node) bool {
if (self.isStaticTypeArg(arg)) return true;
return self.inferExprType(arg) == .any;
}
/// Guard for the type-introspection builtins (`size_of`, `align_of`,
/// `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
/// `is_flags`): every argument must denote a type. A value argument is
/// rejected with a diagnostic rather than silently reinterpreted as a
/// TypeId index or sized via its `typeof`.
///
/// Returns null when `name` is not a guarded builtin OR every argument
/// is a type (→ fall through to normal dispatch). Returns a harmless
/// result-typed sentinel Ref when a violation was diagnosed; the
/// emitted `.err` gates the build so the value is never observed.
pub fn reflectionTypeArgGuard(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
const arity: usize = if (std.mem.eql(u8, name, "type_eq"))
2
else if (std.mem.eql(u8, name, "size_of") or
std.mem.eql(u8, name, "align_of") or
std.mem.eql(u8, name, "field_count") or
std.mem.eql(u8, name, "type_name") or
std.mem.eql(u8, name, "type_is_unsigned") or
std.mem.eql(u8, name, "is_flags"))
1
else
return null;
var ok = true;
if (c.args.len != arity) {
if (self.diagnostics) |d| {
d.addFmt(.err, c.callee.span, "{s} expects {d} type argument{s}, got {d}", .{
name, arity, if (arity == 1) @as([]const u8, "") else "s", c.args.len,
});
}
ok = false;
} else {
for (c.args) |a| {
if (self.reflectionArgIsType(a)) continue;
if (self.diagnostics) |d| {
d.addFmt(.err, a.span, "{s} expects a type, got '{s}'", .{
name, self.formatTypeName(self.inferExprType(a)),
});
}
ok = false;
}
}
if (ok) return null;
return self.reflectionErrorSentinel(name);
}
/// Result-typed placeholder returned after `reflectionTypeArgGuard`
/// diagnoses a non-type argument: a string for `type_name`, a bool for
/// the predicate builtins, an int for the size / count builtins. Never
/// observed at runtime — the diagnostic already fails the build — but
/// keeps the IR well-typed so lowering can finish and report every
/// error in one pass.
pub fn reflectionErrorSentinel(self: *Lowering, name: []const u8) Ref {
if (std.mem.eql(u8, name, "type_name"))
return self.builder.constString(self.module.types.internString(""));
if (std.mem.eql(u8, name, "type_eq") or
std.mem.eql(u8, name, "type_is_unsigned") or
std.mem.eql(u8, name, "is_flags"))
return self.builder.constBool(false);
return self.builder.constInt(0, .s64);
}
/// After args have been lowered, append the lowered values of any
/// `param: T = default_expr` defaults for positions past `args.items.len`.
/// Stops at the first param without a default. Used at method-dispatch
/// sites whose callee is a field_access (so `expandCallDefaults` can't
/// handle them up front). The default expression is lowered in the
/// caller's current scope, so identifiers like `context.allocator`
/// resolve to the caller's runtime context.
pub fn appendDefaultArgs(self: *Lowering, fd: *const ast.FnDecl, args: *std.ArrayList(Ref)) void {
if (args.items.len >= fd.params.len) return;
var i: usize = args.items.len;
while (i < fd.params.len) : (i += 1) {
const dflt = fd.params[i].default_expr orelse break;
const v = self.lowerExpr(dflt);
args.append(self.alloc, v) catch unreachable;
}
}
/// When a bare-identifier call omits trailing positional args and the
/// callee's signature provides defaults for them, return a fresh Call
/// node with the defaults filled in. Returns null when no expansion is
/// needed (callee unknown, all args provided, or no defaults available).
pub fn expandCallDefaults(self: *Lowering, c: *const ast.Call, sel_author: ?*const SelectedFunc, author_ambiguous: bool) ?*ast.Call {
const fd = blk: {
switch (c.callee.data) {
.identifier => |id| {
const eff_name = blk2: {
const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
break :blk2 if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk2 scoped;
};
// R5 §C: for a genuine flat same-name
// collision the omitted trailing args are filled from the
// author the call resolver selected — its `*FnDecl` defaults —
// not the first-wins winner's. lowering consumes the ONE author
// verdict (`selectedFreeAuthor`, computed once in `lowerCall`)
// rather than re-resolving the name, so default expansion and
// dispatch agree on the author. `.ambiguous` declines to expand
// (the call path emits the single diagnostic); a non-collision
// call keeps the existing first-wins winner, byte-for-byte.
// Reading `.decl` only keeps `materialized` null — inspecting
// defaults must not lower the author (0102d).
if (author_ambiguous) return null;
if (sel_author) |sf| break :blk sf.decl;
break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null;
},
// Namespace call `mod.fn(args)` — args map directly to params
// (no `self` prepend), so default expansion is the same shape as
// a bare call. A METHOD call `value.method(args)` prepends `self`
// (arg/param counts are offset), so it's excluded: only treat the
// receiver as a namespace when it isn't a value in scope.
.field_access => |fa| {
const obj_name: ?[]const u8 = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => null,
};
const name = obj_name orelse return null;
if (self.scope) |scope| {
if (scope.lookup(name) != null) return null; // method call on a value
}
if (self.program_index.global_names.contains(name)) return null;
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ name, fa.field }) catch fa.field;
break :blk self.program_index.fn_ast_map.get(qualified) orelse self.program_index.fn_ast_map.get(fa.field) orelse return null;
},
else => return null,
}
};
if (c.args.len >= fd.params.len) return null;
var end: usize = c.args.len;
while (end < fd.params.len) : (end += 1) {
if (fd.params[end].default_expr == null) break;
}
if (end == c.args.len) return null;
var new_args = self.alloc.alloc(*ast.Node, end) catch return null;
for (c.args, 0..) |arg, i| new_args[i] = arg;
var i: usize = c.args.len;
while (i < end) : (i += 1) {
const def = fd.params[i].default_expr.?;
// `#caller_location` resolves at the CALL site, not the callee's
// signature: emit a fresh marker carrying the call's span + file so
// lowering synthesizes the caller's `Source_Location` (ERR E4.1b).
if (def.data == .caller_location) {
const n = self.alloc.create(ast.Node) catch return null;
n.* = .{ .span = c.callee.span, .data = .{ .caller_location = {} }, .source_file = c.callee.source_file };
new_args[i] = n;
} else {
new_args[i] = def;
}
}
const new_call = self.alloc.create(ast.Call) catch return null;
new_call.* = .{ .callee = c.callee, .args = new_args };
return new_call;
}
/// Resolve parameter types for a call expression (for target_type context).
/// Returns empty slice if the function can't be resolved.
/// Return the param types of a Function from the caller's POV — i.e.
/// skipping the synthetic `__sx_ctx` slot when present. lowerCall's
/// arg-lowering uses these to set `target_type` per arg, and user
/// args don't include `__sx_ctx`, so the slot must be elided.
pub fn userParamTypes(self: *Lowering, func: *const Function) []TypeId {
const start: usize = if (func.has_implicit_ctx) 1 else 0;
var types_list = std.ArrayList(TypeId).empty;
if (func.params.len > start) {
for (func.params[start..]) |p| {
types_list.append(self.alloc, p.ty) catch unreachable;
}
}
return types_list.items;
}
pub fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?*SelectedFunc) []const TypeId {
// Method calls: obj.method(args) — resolve param types from the method signature,
// skipping the first param (self) since it's prepended later.
if (c.callee.data == .field_access) {
const fa = c.callee.data.field_access;
// Namespace/static call: `Type.method(args)` where `Type` is a type
// identifier (not a value in scope). Args correspond to ALL params
// — no self prepend — so target_type for arg lowering must include
// the leading param. Skipping it would lose the protocol context
// for `xx ptr` inline-cast args.
if (fa.object.data == .identifier) {
const obj_name = fa.object.data.identifier.name;
const is_value = blk: {
if (self.scope) |scope| {
if (scope.lookup(obj_name) != null) break :blk true;
}
if (self.program_index.global_names.contains(obj_name)) break :blk true;
break :blk false;
};
if (!is_value) {
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ obj_name, fa.field }) catch return &.{};
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
}
if (self.program_index.fn_ast_map.get(qualified)) |fd| {
var types_list = std.ArrayList(TypeId).empty;
for (fd.params) |p| {
types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
}
return types_list.items;
}
}
}
const obj_ty = self.inferExprType(fa.object);
// Protocol-typed receiver: look up the method on the protocol decl. The
// protocol's ProtocolMethodInfo.param_types already excludes self.
if (self.getProtocolInfo(obj_ty)) |proto_info| {
for (proto_info.methods) |m| {
if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
}
}
// Optional-protocol receiver (`?GPU`): same as above but the
// protocol type sits inside the optional's payload.
if (!obj_ty.isBuiltin()) {
const opt_info = self.module.types.get(obj_ty);
if (opt_info == .optional) {
if (self.getProtocolInfo(opt_info.optional.child)) |proto_info| {
for (proto_info.methods) |m| {
if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
}
}
}
}
// Closure-typed struct field: `c.on(args)` lowers to call_closure on
// the field value. Pick up the callee's param types from the closure
// type so each arg gets the right target_type during lowering.
if (!obj_ty.isBuiltin()) {
const field_name_id = self.module.types.internString(fa.field);
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields) |f| {
if (f.name == field_name_id and !f.ty.isBuiltin()) {
const fti = self.module.types.get(f.ty);
if (fti == .closure) return fti.closure.params;
if (fti == .function) return fti.function.params;
}
}
}
if (self.getStructTypeName(obj_ty)) |sname| {
// Foreign-class receiver (`#objc_class` / `#jni_class` / etc.):
// resolve the method from `foreign_class_map` walking `#extends`.
// Without this path, `target_type` for each arg falls back to
// whatever `self.target_type` was on entry — typically the
// enclosing fn's return type — which silently truncates `xx ptr`
// casts inside e.g. a `BOOL`-returning method body.
if (self.program_index.foreign_class_map.get(sname)) |fcd| {
if (self.findForeignMethodInChain(fcd, fa.field)) |found| {
const md = found.method;
const saved_fc = self.current_foreign_class;
defer self.current_foreign_class = saved_fc;
self.current_foreign_class = found.fcd;
const user_param_start: usize = if (md.is_static) 0 else 1;
if (md.params.len > user_param_start) {
var types_list = std.ArrayList(TypeId).empty;
for (md.params[user_param_start..]) |p_node| {
types_list.append(self.alloc, self.resolveType(p_node)) catch unreachable;
}
return types_list.items;
}
return &.{};
}
}
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch return &.{};
// Try already-lowered functions first
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
// Skip both `__sx_ctx` (if present) AND `self` param;
// caller args include neither.
const skip: usize = (if (func.has_implicit_ctx) @as(usize, 1) else 0) + 1;
if (func.params.len > skip) {
var types_list = std.ArrayList(TypeId).empty;
for (func.params[skip..]) |p| {
types_list.append(self.alloc, p.ty) catch unreachable;
}
return types_list.items;
}
}
// Try AST map (not yet lowered)
if (self.program_index.fn_ast_map.get(qualified)) |fd| {
if (fd.params.len > 0) {
var types_list = std.ArrayList(TypeId).empty;
for (fd.params[1..]) |p| {
types_list.append(self.alloc, self.resolveParamTypeInSource(fd.body.source_file, &p)) catch unreachable;
}
return types_list.items;
}
}
// Generic-struct instance method param types: select the method
// body via the instance's STAMPED author (CP-4), substituting the
// instance's bindings so `T → concrete`. The param source-pin
// follows the selected `fd` (its own `body.source_file`).
if (self.genericInstanceMethod(sname, fa.field)) |gm| {
if (gm.fd.params.len > 0) {
const saved_bindings = self.type_bindings;
self.type_bindings = gm.bindings.*;
var types_list = std.ArrayList(TypeId).empty;
for (gm.fd.params[1..]) |p| {
types_list.append(self.alloc, self.resolveParamTypeInSource(gm.fd.body.source_file, &p)) catch unreachable;
}
self.type_bindings = saved_bindings;
return types_list.items;
}
}
}
return &.{};
}
if (c.callee.data != .identifier) return &.{};
const bare_name = c.callee.data.identifier.name;
const name = blk: {
const scoped = if (self.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
if (self.program_index.ufcs_alias_map.get(bare_name)) |target| {
break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk scoped;
};
// R5 §C: a genuine flat same-name collision must type this
// call's args against the author the call resolver selected, not the
// first-wins winner's params. lowering consumes the ONE author verdict
// (`selectedFreeAuthor`, computed once in `lowerCall`) rather than
// re-resolving the name, so arg lowering (implicit address-of, coercion)
// matches the author actually dispatched — otherwise a `*T`-param shadow
// gets a `T` value arg that is later bit-cast to a pointer (segfault). The
// FuncId materializes into the SHARED verdict (once), so dispatch reuses
// it. A non-collision call falls to the existing first-wins path below,
// byte-for-byte.
if (sel_author) |sf| {
const fid = self.selectedFuncId(sf, bare_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
}
// Check declared functions
if (self.resolveFuncByName(name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
}
// Check AST map for function signatures
if (self.program_index.fn_ast_map.get(name)) |fd| {
var types_list = std.ArrayList(TypeId).empty;
for (fd.params) |p| {
types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
}
return types_list.items;
}
// Check global function pointer variables (quiet author-aware lookup —
// param typing only; the call site diagnoses ambiguity / visibility)
if (self.program_index.global_names.get(bare_name)) |gi_global| {
const gi: ?GlobalInfo = switch (self.selectGlobalAuthor(bare_name)) {
.resolved => |g| g,
.untracked => gi_global,
else => null,
};
if (gi) |g| {
if (!g.ty.isBuiltin()) {
const ti = self.module.types.get(g.ty);
if (ti == .function) {
return ti.function.params;
}
}
}
}
// Check local scope for function pointer variables
if (self.scope) |scope| {
if (scope.lookup(bare_name)) |binding| {
if (!binding.ty.isBuiltin()) {
const ti = self.module.types.get(binding.ty);
if (ti == .function) {
return ti.function.params;
}
}
}
}
return &.{};
}