Files
sx/src/ir/lower/call.zig
agra 5b18048abb ir: AST-callee param typing pins the callee module's source context
The two not-yet-lowered fn_ast_map paths in resolveCallParamTypes (the
qualified `ns.f(...)` call and the plain free-fn call) resolved each
param type in the CALL SITE's visibility context, so a namespaced
import's param type that is bare-visible only in its own module
diagnosed "type 'X' is not visible" at calls whose caller never names
the type bare. Route both through the E4 source pin
(resolveParamTypeInSource), as the method paths already do.

A generic callee's bare T leaves are not nominal names in that module:
astCalleeParamTypes installs the call's inferred $T -> concrete
bindings (the one binding builder) before resolving, or the pin turns
the unbound leaf into "unknown type 'T'" (regressed examples/0129
through math/scalar.sx's clamp).

Regression: examples/0840 (namespaced fn with a module-bare param
type; failed "not visible" pre-pin).
2026-06-12 07:41:18 +03:00

2357 lines
124 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;
}
// `args.items` is the post-expansion count: trailing defaults
// were filled by `expandCallDefaults`, comptime-pack spreads
// expanded element-wise above.
{
const arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else self.program_index.fn_ast_map.get(func_name);
if (arity_fd) |fd| {
if (self.checkCallArity(fd, id.name, args.items.len, false, c.callee.span)) 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);
if (self.checkCallArity(fd, fa.field, args.items.len, false, c.callee.span)) return Ref.none;
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| {
if (self.checkCallArity(fd, effective_name, args.items.len, false, c.callee.span)) return Ref.none;
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 — but only for the protocol's OWN methods. A
// non-member field falls through to the free-fn ufcs machinery
// (`context.allocator.create(Session)` — a ufcs fn taking the
// protocol value as its first param).
if (self.getProtocolInfo(obj_ty)) |proto_info| {
if (protocolHasMethod(proto_info, fa.field)) {
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| {
if (protocolHasMethod(proto_info, fa.field)) {
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
const plain_method_fd = self.program_index.fn_ast_map.get(qualified);
if (plain_method_fd) |fd| {
if (self.checkCallArity(fd, qualified, method_args.items.len, true, c.callee.span)) return Ref.none;
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 has_ctx = func.has_implicit_ctx;
self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
if (plain_method_fd) |fd| self.appendDefaultArgs(fd, &method_args);
// 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);
}
}
// Free-function dot-call (`recv.fn(args)` → `fn(recv, args)`)
// is OPT-IN: only a fn declared `name :: ufcs (...) {...}` or a
// `name :: ufcs target;` alias dispatches. A plain fn is
// callable directly or via `|>` only — a dot-call on one gets a
// tailored diagnostic rather than silently becoming a method.
//
// 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 alias_target = self.program_index.ufcs_alias_map.get(fa.field);
const eff_field = alias_target orelse fa.field;
const ufcs_fd = self.program_index.fn_ast_map.get(eff_field);
const ufcs_opted_in = alias_target != null or (ufcs_fd != null and ufcs_fd.?.is_ufcs);
if (ufcs_opted_in) {
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;
}
// Generic ufcs target: monomorphize with the receiver's AST
// node prepended so bindings align with fd.params[0].
if (ufcs_fd) |fd| {
if (fd.type_params.len > 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) |arg| eff_args.append(self.alloc, arg) catch unreachable;
var gbindings = self.genericResolver().buildTypeBindings(fd, eff_args.items);
defer gbindings.deinit();
const gmangled = self.genericResolver().mangleGenericName(eff_field, fd, &gbindings);
if (!self.lowered_functions.contains(gmangled)) {
self.monomorphizeFunction(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. method_args[0] is the
// receiver (a VALUE — a type-expr receiver
// classifies as a namespace call, never here),
// so fd.params[0] is a value param.
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 == fd.params.len;
var arg_idx: usize = 1;
for (fd.params[1..]) |p| {
if (isTypeParamDecl(&p, 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);
}
return self.emitError(eff_field, c.callee.span);
}
}
const ufcs_arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else ufcs_fd;
if (ufcs_arity_fd) |fd| {
if (self.checkCallArity(fd, fa.field, method_args.items.len, true, c.callee.span)) return Ref.none;
}
const ufcs_fid: ?FuncId = blk_uf: {
if (sel_author) |sf| {
break :blk_uf self.selectedFuncId(sf, eff_field);
}
if (ufcs_fd != null) {
if (!self.lowered_functions.contains(eff_field)) {
self.lazyLowerFunction(eff_field);
}
}
break :blk_uf self.resolveFuncByName(eff_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);
if (ufcs_arity_fd) |fd| self.appendDefaultArgs(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);
}
return self.emitError(eff_field, c.callee.span);
}
// A fn by this name exists but is not dot-callable: tailored help.
if (ufcs_fd != null or self.resolveFuncByName(fa.field) != null) {
if (self.diagnostics) |d| {
const id = d.addFmtId(.err, c.callee.span, "'{s}' is not a ufcs function — a plain function does not dispatch via dot-call", .{fa.field});
d.addHelpFmt(id, c.callee.span, null, "call it directly (`{s}(receiver, ...)`), pipe it (`receiver |> {s}(...)`), or declare it `{s} :: ufcs (...) {{ ... }}`", .{ fa.field, fa.field, fa.field });
}
return Ref.none;
}
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;
}
fn protocolHasMethod(proto_info: anytype, name: []const u8) bool {
for (proto_info.methods) |m| {
if (std.mem.eql(u8, m.name, name)) return true;
}
return false;
}
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;
}
}
/// Reject a direct call whose argument count cannot bind to the callee's
/// declared parameter list. `supplied` counts the args as they bind to
/// params — receiver included for dot-dispatch, defaults not yet
/// appended. Returns true when a diagnostic was emitted (the call must
/// not lower). Pack / comptime / generic / `#compiler` / `#builtin`
/// callees bind args through their own dispatch and are exempt.
pub fn checkCallArity(self: *Lowering, fd: *const ast.FnDecl, callee_name: []const u8, supplied: usize, has_receiver: bool, span: ast.Span) bool {
if (fd.type_params.len > 0 or hasComptimeParams(fd) or isPackFn(fd)) return false;
switch (fd.body.data) {
.compiler_expr, .builtin_expr => return false,
else => {},
}
var min: usize = 0;
var max: ?usize = fd.params.len;
for (fd.params, 0..) |p, i| {
if (p.is_variadic) {
max = null;
break;
}
if (p.default_expr == null) min = i + 1;
}
if (supplied >= min and (max == null or supplied <= max.?)) return false;
if (self.diagnostics) |d| {
// Dot-dispatch report counts the user-visible args: the receiver
// slot is implicit at the call site, so it is elided from both
// the expected and the supplied counts.
const recv: usize = @intFromBool(has_receiver);
const got = supplied -| recv;
const lo = min -| recv;
const got_verb: []const u8 = if (got == 1) "was" else "were";
if (max == null) {
const s: []const u8 = if (lo == 1) "" else "s";
d.addFmt(.err, span, "'{s}' expects at least {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb });
} else if (max.? -| recv == lo) {
const s: []const u8 = if (lo == 1) "" else "s";
d.addFmt(.err, span, "'{s}' expects {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb });
} else {
d.addFmt(.err, span, "'{s}' expects between {d} and {d} arguments, but {d} {s} given", .{ callee_name, lo, max.? -| recv, got, got_verb });
}
}
return true;
}
/// 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;
}
/// Param types of a not-yet-lowered AST callee for arg target-typing,
/// resolved in the callee's own module context (the E4 source pin — see
/// `resolveParamTypeInSource`). A generic callee's bare `T` leaves mean
/// nothing as nominal names in that module: without this call's inferred
/// `$T → concrete` bindings the pin would resolve `T` as an undeclared
/// type in a non-main module and diagnose it unknown.
fn astCalleeParamTypes(self: *Lowering, fd: *const ast.FnDecl, args: []const *const Node) []const TypeId {
const saved_bindings = self.type_bindings;
defer self.type_bindings = saved_bindings;
var gbindings: ?std.StringHashMap(TypeId) = null;
defer if (gbindings) |*gb| gb.deinit();
if (fd.type_params.len > 0) {
gbindings = self.genericResolver().buildTypeBindings(fd, args);
self.type_bindings = gbindings.?;
}
var types_list = std.ArrayList(TypeId).empty;
for (fd.params) |p| {
types_list.append(self.alloc, self.resolveParamTypeInSource(fd.body.source_file, &p)) 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| {
return astCalleeParamTypes(self, fd, c.args);
}
}
}
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| {
return astCalleeParamTypes(self, fd, c.args);
}
// 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 &.{};
}