refactor(ir): add CallPlan + CallResolver.plan(c); resultType delegates (A3.2 convergence step 2)

Introduce CallPlan — the single classification record for a call: kind (14
variants), return_type, a Target union (builtin/func/named/protocol_method/
foreign_method/constructed/none), variant tag, and the prepends_receiver /
prepends_ctx / expands_defaults properties the selected dispatch implies.

Move call recognition into CallResolver.plan(c) (branch order preserved
exactly) and reimplement resultType(c) as plan(c).return_type — the typing
consumer converges onto the plan first. lowerCall is untouched; routing it
through plan(c) is sub-step 3.

10 plan-object tests assert kind/target/variant + receiver/ctx/default
properties for every pinned call form: builtin/reflection, lazy + resolved
direct fn (incl. default-arg expansion + __sx_ctx prepend), closure /
default-conv vs C-conv fn-pointer, protocol dispatch, struct/UFCS #compiler
method, foreign instance vs static, qualified + dot-shorthand enum
construction, namespace fn, and the unresolved fallthrough.

Widen for the new collaborator only: resolveVariantIndex -> pub (plan resolves
the variant tag); Scope/Binding + init/deinit/put -> pub (so unit tests can
stand up a lexical scope for closure/fn-ptr callees without a full lowering).

zig build, zig build test, and tests/run_examples.sh (357/0) all green; no
behavior change.
This commit is contained in:
agra
2026-06-02 20:15:53 +03:00
parent 297f127821
commit 61f1f2368a
4 changed files with 599 additions and 82 deletions

View File

@@ -1,7 +1,15 @@
// Tests for calls.zig — focused on the call-result-typing paths CallResolver
// owns that need no lexical scope / fn registration: builtin and reflection
// builtin classification, and the unresolved fallthrough. Reached via the
// public `Lowering.inferExprType` delegation.
// Tests for calls.zig.
//
// Two layers:
// 1. Result-type delegation reached via the public `Lowering.inferExprType`
// (builtin / reflection classification, cast, dot-shorthand fallthrough) —
// these need no lexical scope / fn registration.
// 2. The `CallPlan` object built by `CallResolver.plan` — its selected
// kind / target / variant and the receiver / `__sx_ctx` / default-arg
// properties, across every call form pinned by A3.2 sub-step 1
// (direct / UFCS / protocol / closure / fn-pointer / foreign / enum /
// namespace). `resultType` is just `plan(c).return_type`, so these also
// lock the typing the regression suite relies on.
const std = @import("std");
const ast = @import("../ast.zig");
@@ -9,12 +17,49 @@ const Node = ast.Node;
const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId;
const FuncId = ir_mod.FuncId;
const Ref = ir_mod.Ref;
const Lowering = ir_mod.Lowering;
const CallResolver = ir_mod.CallResolver;
const CallPlan = ir_mod.CallPlan;
const lower = @import("lower.zig");
const Scope = lower.Scope;
const Binding = lower.Binding;
const BuiltinId = @import("inst.zig").BuiltinId;
fn node(data: ast.Node.Data) Node {
return .{ .span = .{ .start = 0, .end = 0 }, .data = data };
}
// ── AST builders (heap-allocated so the call graph outlives one statement) ──
fn mk(alloc: std.mem.Allocator, data: ast.Node.Data) *Node {
const n = alloc.create(Node) catch unreachable;
n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = data };
return n;
}
fn ident(alloc: std.mem.Allocator, name: []const u8) *Node {
return mk(alloc, .{ .identifier = .{ .name = name } });
}
fn typeExpr(alloc: std.mem.Allocator, name: []const u8) *Node {
return mk(alloc, .{ .type_expr = .{ .name = name } });
}
fn intLit(alloc: std.mem.Allocator, v: i64) *Node {
return mk(alloc, .{ .int_literal = .{ .value = v } });
}
fn emptyBody(alloc: std.mem.Allocator) *Node {
return mk(alloc, .{ .block = .{ .stmts = &.{} } });
}
fn fieldAccess(alloc: std.mem.Allocator, obj: *Node, field: []const u8) *Node {
return mk(alloc, .{ .field_access = .{ .object = obj, .field = field } });
}
fn callNode(alloc: std.mem.Allocator, callee: *Node, args: []const *Node) *Node {
return mk(alloc, .{ .call = .{ .callee = callee, .args = args } });
}
// ── Layer 1: result-type delegation (no scope / registration needed) ────────
test "calls: builtin and reflection result types, unknown fallthrough" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
@@ -94,3 +139,303 @@ test "calls: dot-shorthand enum construction types as the target type" {
l.target_type = .s32;
try std.testing.expectEqual(TypeId.s32, l.inferExprType(&enum_call));
}
// ── Layer 2: the CallPlan object (kind / target / variant / properties) ─────
test "plan: builtin and reflection carry kind + target" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
var arg = node(.{ .int_literal = .{ .value = 1 } });
var args = [_]*Node{&arg};
var so_callee = node(.{ .identifier = .{ .name = "size_of" } });
var so_call = node(.{ .call = .{ .callee = &so_callee, .args = &args } });
const so = cr.plan(&so_call.data.call);
try std.testing.expectEqual(CallPlan.Kind.builtin, so.kind);
try std.testing.expectEqual(BuiltinId.size_of, so.target.builtin);
try std.testing.expectEqual(TypeId.s64, so.return_type);
var tn_callee = node(.{ .identifier = .{ .name = "type_name" } });
var tn_call = node(.{ .call = .{ .callee = &tn_callee, .args = &args } });
const tn = cr.plan(&tn_call.data.call);
try std.testing.expectEqual(CallPlan.Kind.reflection, tn.kind);
try std.testing.expectEqualStrings("type_name", tn.target.named);
try std.testing.expectEqual(TypeId.string, tn.return_type);
}
test "plan: unresolved bare callee" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
var callee = node(.{ .identifier = .{ .name = "nope" } });
var call = node(.{ .call = .{ .callee = &callee, .args = &.{} } });
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.unresolved, p.kind);
try std.testing.expectEqual(TypeId.unresolved, p.return_type);
}
test "plan: lazy free fn classifies as direct_fn and flags default-arg expansion" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// greet :: (a: s64, b: s64 = 0) -> s64 — registered but NOT lowered, so
// it resolves through the AST (lazy) arm and `b`'s default is splice-able.
const params = [_]ast.Param{
.{ .name = "a", .name_span = .{ .start = 0, .end = 0 }, .type_expr = typeExpr(alloc, "s64") },
.{ .name = "b", .name_span = .{ .start = 0, .end = 0 }, .type_expr = typeExpr(alloc, "s64"), .default_expr = intLit(alloc, 0) },
};
const fd = ast.FnDecl{ .name = "greet", .params = &params, .return_type = typeExpr(alloc, "s64"), .body = emptyBody(alloc) };
l.program_index.fn_ast_map.put("greet", &fd) catch unreachable;
// greet(1) — omits `b`, so its default is spliced in.
{
const one = [_]*Node{intLit(alloc, 1)};
const call = callNode(alloc, ident(alloc, "greet"), &one);
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.direct_fn, p.kind);
try std.testing.expectEqualStrings("greet", p.target.named);
try std.testing.expectEqual(TypeId.s64, p.return_type);
try std.testing.expect(p.expands_defaults);
try std.testing.expect(!p.prepends_receiver);
}
// greet(1, 2) — all args supplied, no expansion.
{
const two = [_]*Node{ intLit(alloc, 1), intLit(alloc, 2) };
const call = callNode(alloc, ident(alloc, "greet"), &two);
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.direct_fn, p.kind);
try std.testing.expect(!p.expands_defaults);
}
}
test "plan: resolved free fn carries func target + __sx_ctx prepend" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// noop :: () { } — lowered, so it resolves to a concrete FuncId.
const fd = ast.FnDecl{ .name = "noop", .params = &.{}, .return_type = null, .body = emptyBody(alloc) };
l.lowerFunction(&fd, "noop", false);
const fid = l.resolveFuncByName("noop").?;
// Stamp the implicit-ctx flag the way the implicit-Context machinery would.
module.functions.items[@intFromEnum(fid)].has_implicit_ctx = true;
var callee = node(.{ .identifier = .{ .name = "noop" } });
var call = node(.{ .call = .{ .callee = &callee, .args = &.{} } });
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.direct_fn, p.kind);
try std.testing.expectEqual(fid, p.target.func);
try std.testing.expectEqual(TypeId.void, p.return_type);
try std.testing.expect(p.prepends_ctx);
}
test "plan: closure and fn-pointer callees, __sx_ctx by calling convention" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
l.implicit_ctx_enabled = true;
const cr = CallResolver{ .l = &l };
var scope = Scope.init(alloc, null);
defer scope.deinit();
l.scope = &scope;
// cb : Closure() -> bool — sx-side closure, carries ctx at slot 0.
const closure_ty = module.types.closureType(&.{}, .bool);
scope.put("cb", .{ .ref = Ref.none, .ty = closure_ty, .is_alloca = false });
{
const call = callNode(alloc, ident(alloc, "cb"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.closure, p.kind);
try std.testing.expectEqualStrings("cb", p.target.named);
try std.testing.expectEqual(TypeId.bool, p.return_type);
try std.testing.expect(p.prepends_ctx);
}
// fp : () -> s32 (default conv) — sx fn-pointer, carries ctx.
const fp_ty = module.types.functionType(&.{}, .s32);
scope.put("fp", .{ .ref = Ref.none, .ty = fp_ty, .is_alloca = false });
{
const call = callNode(alloc, ident(alloc, "fp"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.fn_pointer, p.kind);
try std.testing.expectEqual(TypeId.s32, p.return_type);
try std.testing.expect(p.prepends_ctx);
}
// cfp : () -> s32 (C conv) — C fn-pointer, NO implicit ctx.
const cfp_ty = module.types.functionTypeCC(&.{}, .s32, .c);
scope.put("cfp", .{ .ref = Ref.none, .ty = cfp_ty, .is_alloca = false });
{
const call = callNode(alloc, ident(alloc, "cfp"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.fn_pointer, p.kind);
try std.testing.expect(!p.prepends_ctx);
}
}
test "plan: protocol dispatch selects method index + prepends receiver" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// Drawable :: protocol { measure :: () -> s64; draw :: () -> bool; }
const methods = [_]ast.ProtocolMethodDecl{
.{ .name = "measure", .params = &.{}, .param_names = &.{}, .return_type = typeExpr(alloc, "s64"), .default_body = null },
.{ .name = "draw", .params = &.{}, .param_names = &.{}, .return_type = typeExpr(alloc, "bool"), .default_body = null },
};
const pd = ast.ProtocolDecl{ .name = "Drawable", .methods = &methods };
l.registerProtocolDecl(&pd);
// A receiver typed as the protocol: `cast(Drawable, _)`.
const recv = callNode(alloc, ident(alloc, "cast"), &[_]*Node{ typeExpr(alloc, "Drawable"), intLit(alloc, 0) });
const call = callNode(alloc, fieldAccess(alloc, recv, "draw"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.protocol_dispatch, p.kind);
try std.testing.expectEqual(@as(u32, 1), p.target.protocol_method);
try std.testing.expectEqual(TypeId.bool, p.return_type);
try std.testing.expect(p.prepends_receiver);
}
test "plan: struct (UFCS) method via #compiler dispatch + prepends receiver" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// struct Point, with a `#compiler` method Point.scale(self) -> s64.
_ = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Point"), .fields = &.{} } });
const self_param = ast.Param{ .name = "self", .name_span = .{ .start = 0, .end = 0 }, .type_expr = typeExpr(alloc, "Point") };
const params = [_]ast.Param{self_param};
const compiler_body = mk(alloc, .{ .compiler_expr = {} });
const method_fd = ast.FnDecl{ .name = "Point.scale", .params = &params, .return_type = typeExpr(alloc, "s64"), .body = compiler_body };
l.program_index.fn_ast_map.put("Point.scale", &method_fd) catch unreachable;
const recv = callNode(alloc, ident(alloc, "cast"), &[_]*Node{ typeExpr(alloc, "Point"), intLit(alloc, 0) });
const call = callNode(alloc, fieldAccess(alloc, recv, "scale"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.struct_method, p.kind);
try std.testing.expectEqualStrings("Point.scale", p.target.named);
try std.testing.expectEqual(TypeId.s64, p.return_type);
try std.testing.expect(p.prepends_receiver);
}
test "plan: foreign-class instance vs static dispatch" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
const members = [_]ast.ForeignClassMember{
.{ .method = .{ .name = "length", .params = &.{}, .param_names = &.{}, .return_type = typeExpr(alloc, "s64"), .is_static = false } },
.{ .method = .{ .name = "stringWithUTF8String", .params = &.{}, .param_names = &.{}, .return_type = typeExpr(alloc, "s64"), .is_static = true } },
};
var fcd = ast.ForeignClassDecl{ .name = "NSString", .foreign_path = "NSString", .runtime = .objc_class, .members = &members };
l.program_index.foreign_class_map.put("NSString", &fcd) catch unreachable;
_ = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("NSString"), .fields = &.{} } });
// Instance: `cast(NSString, _).length` — receiver prepended.
{
const recv = callNode(alloc, ident(alloc, "cast"), &[_]*Node{ typeExpr(alloc, "NSString"), intLit(alloc, 0) });
const call = callNode(alloc, fieldAccess(alloc, recv, "length"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.foreign_instance, p.kind);
try std.testing.expectEqualStrings("length", p.target.foreign_method.name);
try std.testing.expect(!p.target.foreign_method.is_static);
try std.testing.expectEqual(TypeId.s64, p.return_type);
try std.testing.expect(p.prepends_receiver);
}
// Static: `NSString.stringWithUTF8String(...)` — no receiver.
{
const call = callNode(alloc, fieldAccess(alloc, ident(alloc, "NSString"), "stringWithUTF8String"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.foreign_static, p.kind);
try std.testing.expectEqualStrings("stringWithUTF8String", p.target.foreign_method.name);
try std.testing.expect(p.target.foreign_method.is_static);
try std.testing.expect(!p.prepends_receiver);
}
}
test "plan: enum construction (qualified + dot-shorthand) carries variant tag" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
const red = module.types.internString("Red");
const green = module.types.internString("Green");
const variants = [_]@TypeOf(red){ red, green };
const color = module.types.intern(.{ .@"enum" = .{ .name = module.types.internString("Color"), .variants = &variants } });
// Qualified: `Color.Green`.
{
const call = callNode(alloc, fieldAccess(alloc, typeExpr(alloc, "Color"), "Green"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.enum_construct, p.kind);
try std.testing.expectEqual(color, p.target.constructed);
try std.testing.expectEqual(@as(?u32, 1), p.variant);
try std.testing.expectEqual(color, p.return_type);
}
// Dot-shorthand: `.Green` with the union as the target type.
{
l.target_type = color;
const call = callNode(alloc, mk(alloc, .{ .enum_literal = .{ .name = "Green" } }), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.enum_shorthand, p.kind);
try std.testing.expectEqual(color, p.target.constructed);
try std.testing.expectEqual(@as(?u32, 1), p.variant);
try std.testing.expectEqual(color, p.return_type);
}
}
test "plan: qualified namespace function" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// mathlib.square :: () -> s64 — registered under its qualified name, lazy.
const fd = ast.FnDecl{ .name = "mathlib.square", .params = &.{}, .return_type = typeExpr(alloc, "s64"), .body = emptyBody(alloc) };
l.program_index.fn_ast_map.put("mathlib.square", &fd) catch unreachable;
const call = callNode(alloc, fieldAccess(alloc, ident(alloc, "mathlib"), "square"), &.{});
const p = cr.plan(&call.data.call);
try std.testing.expectEqual(CallPlan.Kind.namespace_fn, p.kind);
try std.testing.expectEqualStrings("mathlib.square", p.target.named);
try std.testing.expectEqual(TypeId.s64, p.return_type);
}

View File

@@ -3,11 +3,77 @@ const ast = @import("../ast.zig");
const types = @import("types.zig");
const type_bridge = @import("type_bridge.zig");
const lower = @import("lower.zig");
const inst = @import("inst.zig");
const Node = ast.Node;
const TypeId = types.TypeId;
const FuncId = inst.FuncId;
const BuiltinId = inst.BuiltinId;
const Lowering = lower.Lowering;
/// The classification of a call expression: which dispatch path lowering will
/// take, the IR type the call evaluates to, and the properties (selected
/// target, enum variant, receiver / `__sx_ctx` prepend, default-arg expansion)
/// that path implies.
///
/// `plan(c)` is the single point that recognises a call form; `resultType(c)`
/// is the thin "just the type" projection (`plan(c).return_type`). This step
/// (A3.2 convergence sub-step 2) builds the plan object and routes typing
/// through it; `lowerCall` still owns its own dispatch and is rerouted onto
/// the plan in sub-step 3.
pub const CallPlan = struct {
kind: Kind,
return_type: TypeId,
target: Target = .none,
/// Enum / tagged-union variant tag, for the construction kinds.
variant: ?u32 = null,
/// Lowering prepends the receiver as arg 0 (UFCS / instance-method forms).
prepends_receiver: bool = false,
/// Lowering prepends the implicit `__sx_ctx` as arg 0.
prepends_ctx: bool = false,
/// The caller omits trailing positional args the callee provides defaults
/// for, so lowering splices them in (`expandCallDefaults` / `appendDefaultArgs`).
expands_defaults: bool = false,
pub const Kind = enum {
builtin,
reflection,
generic_fn,
/// A plain free function — resolved (`target.func`) or known only by
/// AST and lowered lazily (`target.named`).
direct_fn,
closure,
fn_pointer,
protocol_dispatch,
struct_method,
foreign_instance,
foreign_static,
namespace_fn,
enum_construct,
enum_shorthand,
unresolved,
};
/// What `plan` selected. The active arm is disambiguated by `kind`:
/// e.g. a `.named` under `.reflection` is a builtin name, under
/// `.direct_fn` a lazily-lowered fn, under `.closure` a binding.
pub const Target = union(enum) {
none,
builtin: BuiltinId,
/// A resolved (lowered) free / method / namespace function.
func: FuncId,
/// A callee carried by name — reflection builtin, generic / lazy fn,
/// closure / fn-pointer binding, or a not-yet-lowered namespace fn.
named: []const u8,
/// Protocol method, by index in the protocol's method table.
protocol_method: u32,
/// Foreign-class method (Obj-C / JNI), with its static-ness.
foreign_method: struct { name: []const u8, is_static: bool },
/// Enum / tagged-union type under construction.
constructed: TypeId,
};
};
/// Call result typing (architecture phase A3.2), extracted from
/// `Lowering.inferExprType`'s call arm. Discovers the IR type a call
/// expression evaluates to — across builtins / reflection builtins, generic
@@ -19,14 +85,19 @@ const Lowering = lower.Lowering;
/// A `*Lowering` facade (Principle 5, like `ExprTyper` / `PackResolver`): call
/// typing reads live lexical-scope / target-type state and the function /
/// foreign-class / protocol resolver helpers, so it borrows `*Lowering` rather
/// than re-threading every field. This step relocates the result-typing logic
/// only; call LOWERING (`lowerCall`) still owns its own dispatch — the two
/// converge onto a shared `CallPlan` in the follow-up A3.2 convergence work.
/// than re-threading every field.
pub const CallResolver = struct {
l: *Lowering,
/// Infer the IR type a call expression evaluates to (without lowering it).
pub fn resultType(self: CallResolver, c: *const ast.Call) TypeId {
return self.plan(c).return_type;
}
/// Classify a call: pick the dispatch kind / target / variant and derive
/// the result type and prepend / default-expansion properties. The single
/// source of truth for "what kind of call is this?".
pub fn plan(self: CallResolver, c: *const ast.Call) CallPlan {
if (c.callee.data == .identifier) {
const bare_name = c.callee.data.identifier.name;
// Resolve local function name (bare → mangled) and UFCS aliases
@@ -38,7 +109,7 @@ pub const CallResolver = struct {
break :blk scoped;
};
if (Lowering.resolveBuiltin(bare_name)) |bid| {
return switch (bid) {
const rt: TypeId = switch (bid) {
.sqrt, .sin, .cos, .floor => blk: {
if (c.args.len > 0) {
const arg_ty = self.l.inferExprType(c.args[0]);
@@ -50,69 +121,98 @@ pub const CallResolver = struct {
.cast => if (c.args.len > 0) self.l.resolveTypeArg(c.args[0]) else .unresolved,
else => .unresolved,
};
return .{ .kind = .builtin, .return_type = rt, .target = .{ .builtin = bid } };
}
// Reflection builtins live outside `resolveBuiltin`'s
// table (their lowering goes through
// `tryLowerReflectionCall`, not the `BuiltinId`
// dispatch). Recognize them here so pack-fn callers
// Reflection builtins live outside `resolveBuiltin`'s table (their
// lowering goes through `tryLowerReflectionCall`, not the
// `BuiltinId` dispatch). Recognize them here so pack-fn callers
// mangle their results with the right tag.
if (std.mem.eql(u8, bare_name, "type_name")) return .string;
if (std.mem.eql(u8, bare_name, "type_eq")) return .bool;
if (std.mem.eql(u8, bare_name, "has_impl")) return .bool;
if (std.mem.eql(u8, bare_name, "field_count")) return .s64;
if (std.mem.eql(u8, bare_name, "field_index")) return .s64;
if (std.mem.eql(u8, bare_name, "field_name")) return .string;
if (std.mem.eql(u8, bare_name, "error_tag_name")) return .string;
if (std.mem.eql(u8, bare_name, "is_comptime")) return .bool;
if (std.mem.eql(u8, bare_name, "__interp_print_frames")) return .void;
if (std.mem.eql(u8, bare_name, "type_name")) return refl(bare_name, .string);
if (std.mem.eql(u8, bare_name, "type_eq")) return refl(bare_name, .bool);
if (std.mem.eql(u8, bare_name, "has_impl")) return refl(bare_name, .bool);
if (std.mem.eql(u8, bare_name, "field_count")) return refl(bare_name, .s64);
if (std.mem.eql(u8, bare_name, "field_index")) return refl(bare_name, .s64);
if (std.mem.eql(u8, bare_name, "field_name")) return refl(bare_name, .string);
if (std.mem.eql(u8, bare_name, "error_tag_name")) return refl(bare_name, .string);
if (std.mem.eql(u8, bare_name, "is_comptime")) return refl(bare_name, .bool);
if (std.mem.eql(u8, bare_name, "__interp_print_frames")) return refl(bare_name, .void);
if (std.mem.eql(u8, bare_name, "__trace_resolve_frame"))
return self.l.module.types.findByName(self.l.module.types.internString("Frame")) orelse .unresolved;
if (std.mem.eql(u8, bare_name, "is_flags")) return .bool;
if (std.mem.eql(u8, bare_name, "type_of")) return .any;
if (std.mem.eql(u8, bare_name, "field_value")) return .any;
// Check if it's a generic function — infer return type via type bindings
return refl(bare_name, self.l.module.types.findByName(self.l.module.types.internString("Frame")) orelse .unresolved);
if (std.mem.eql(u8, bare_name, "is_flags")) return refl(bare_name, .bool);
if (std.mem.eql(u8, bare_name, "type_of")) return refl(bare_name, .any);
if (std.mem.eql(u8, bare_name, "field_value")) return refl(bare_name, .any);
// Generic function — infer return type via type bindings.
if (self.l.program_index.fn_ast_map.get(name)) |fd| {
if (fd.type_params.len > 0) {
return self.l.inferGenericReturnType(fd, c);
return .{
.kind = .generic_fn,
.return_type = self.l.inferGenericReturnType(fd, c),
.target = .{ .named = name },
.expands_defaults = defaultsFor(fd, c.args.len),
};
}
}
// Check declared functions for return type
// Declared (lowered) function return type from its signature.
if (self.l.resolveFuncByName(name)) |fid| {
return self.l.module.functions.items[@intFromEnum(fid)].ret;
const func = &self.l.module.functions.items[@intFromEnum(fid)];
return .{
.kind = .direct_fn,
.return_type = func.ret,
.target = .{ .func = fid },
.prepends_ctx = func.has_implicit_ctx,
.expands_defaults = if (self.l.program_index.fn_ast_map.get(name)) |fd| defaultsFor(fd, c.args.len) else false,
};
}
// Not lowered yet (lazy lowering): take the return type from
// the declared AST. A void/return-less fn is void — not an
// Not lowered yet (lazy lowering): take the return type from the
// declared AST. A void/return-less fn is void — not an
// `.unresolved` guess.
if (self.l.program_index.fn_ast_map.get(name)) |fd| {
if (fd.return_type) |rt| return self.l.resolveType(rt);
return .void;
return .{
.kind = .direct_fn,
.return_type = if (fd.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .named = name },
.expands_defaults = defaultsFor(fd, c.args.len),
};
}
// Check if callee is a local closure / function-type variable
// (e.g. a `cb: Closure(...) -> R` or bare `cb: (T) -> R`
// parameter) — extract its declared return type so `try` /
// `catch` on the call see the (possibly failable) result.
// Local closure- / function-typed binding (e.g. a `cb: Closure(...)
// -> R` or bare `cb: (T) -> R` parameter) — extract its declared
// return type so `try` / `catch` on the call see the (possibly
// failable) result.
if (self.l.scope) |scope| {
if (scope.lookup(bare_name)) |binding| {
if (!binding.ty.isBuiltin()) {
const ti = self.l.module.types.get(binding.ty);
if (ti == .closure) return ti.closure.ret;
if (ti == .function) return ti.function.ret;
if (ti == .closure) return .{
.kind = .closure,
.return_type = ti.closure.ret,
.target = .{ .named = bare_name },
.prepends_ctx = self.l.implicit_ctx_enabled,
};
if (ti == .function) return .{
.kind = .fn_pointer,
.return_type = ti.function.ret,
.target = .{ .named = bare_name },
.prepends_ctx = self.l.implicit_ctx_enabled and ti.function.call_conv != .c,
};
}
}
}
} else if (c.callee.data == .field_access) {
const cfa = c.callee.data.field_access;
// Check if receiver is a protocol type → return protocol method type
const recv_ty = self.l.inferExprType(cfa.object);
{
if (self.l.getProtocolInfo(recv_ty)) |proto_info| {
for (proto_info.methods) |m| {
if (std.mem.eql(u8, m.name, cfa.field)) return m.ret_type;
}
// Receiver is a protocol type → protocol method dispatch.
if (self.l.getProtocolInfo(recv_ty)) |proto_info| {
for (proto_info.methods, 0..) |m, mi| {
if (std.mem.eql(u8, m.name, cfa.field)) return .{
.kind = .protocol_dispatch,
.return_type = m.ret_type,
.target = .{ .protocol_method = @intCast(mi) },
.prepends_receiver = true,
};
}
}
// Foreign-class instance method: look up the method's
// declared return type so chained calls (e.g.
// Foreign-class instance method: look up the method's declared
// return type so chained calls (e.g.
// `UIWindow.alloc().initWithWindowScene(scene)`) resolve.
{
var recv_inner = recv_ty;
@@ -127,7 +227,12 @@ pub const CallResolver = struct {
if (self.l.program_index.foreign_class_map.get(sn)) |fcd| {
for (fcd.members) |m| switch (m) {
.method => |md| if (!md.is_static and std.mem.eql(u8, md.name, cfa.field)) {
return self.l.resolveForeignMethodReturnType(fcd, md);
return .{
.kind = .foreign_instance,
.return_type = self.l.resolveForeignMethodReturnType(fcd, md),
.target = .{ .foreign_method = .{ .name = md.name, .is_static = false } },
.prepends_receiver = true,
};
},
else => {},
};
@@ -135,7 +240,7 @@ pub const CallResolver = struct {
}
}
}
// Instance method call: obj.method(args) → look up StructName.method
// Instance method call: obj.method(args) → StructName.method.
{
var obj_ty = recv_ty;
if (!obj_ty.isBuiltin()) {
@@ -147,20 +252,34 @@ pub const CallResolver = struct {
if (oi == .@"struct") {
const struct_name = self.l.module.types.getString(oi.@"struct".name);
const qualified = std.fmt.allocPrint(self.l.alloc, "{s}.{s}", .{ struct_name, cfa.field }) catch cfa.field;
// Generic #compiler method dispatch — return type from declaration
// Generic #compiler method dispatch — return type from declaration.
if (self.l.program_index.fn_ast_map.get(qualified)) |method_fd| {
if (method_fd.body.data == .compiler_expr) {
if (method_fd.return_type) |rt| return type_bridge.resolveAstType(rt, &self.l.module.types, &self.l.program_index.type_alias_map);
return .void;
return .{
.kind = .struct_method,
.return_type = if (method_fd.return_type) |rt| type_bridge.resolveAstType(rt, &self.l.module.types, &self.l.program_index.type_alias_map) else .void,
.target = .{ .named = qualified },
.prepends_receiver = true,
.expands_defaults = defaultsFor(method_fd, c.args.len + 1),
};
}
}
if (self.l.resolveFuncByName(qualified)) |fid| {
return self.l.module.functions.items[@intFromEnum(fid)].ret;
const func = &self.l.module.functions.items[@intFromEnum(fid)];
return .{
.kind = .struct_method,
.return_type = func.ret,
.target = .{ .func = fid },
.prepends_receiver = true,
.prepends_ctx = func.has_implicit_ctx,
.expands_defaults = if (self.l.program_index.fn_ast_map.get(qualified)) |fd| defaultsFor(fd, c.args.len + 1) else false,
};
}
}
}
}
// Type.variant(args) — qualified enum construction
// Type.variant(args) — qualified construction; foreign static; or a
// qualified namespace function.
const type_name = switch (cfa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
@@ -171,7 +290,11 @@ pub const CallResolver = struct {
if (self.l.program_index.foreign_class_map.get(tn)) |fcd| {
for (fcd.members) |m| switch (m) {
.method => |md| if (md.is_static and std.mem.eql(u8, md.name, cfa.field)) {
return self.l.resolveForeignMethodReturnType(fcd, md);
return .{
.kind = .foreign_static,
.return_type = self.l.resolveForeignMethodReturnType(fcd, md),
.target = .{ .foreign_method = .{ .name = md.name, .is_static = true } },
};
},
else => {},
};
@@ -179,37 +302,81 @@ pub const CallResolver = struct {
const type_name_id = self.l.module.types.internString(tn);
if (self.l.module.types.findByName(type_name_id)) |ty| {
const ti = self.l.module.types.get(ty);
if (ti == .tagged_union or ti == .@"enum") return ty;
if (ti == .tagged_union or ti == .@"enum") return .{
.kind = .enum_construct,
.return_type = ty,
.target = .{ .constructed = ty },
.variant = self.l.resolveVariantIndex(ty, cfa.field),
};
}
// Check for qualified function call. `resolveFuncByName`
// only finds ALREADY-LOWERED functions; namespace
// imports are typically lowered lazily on demand, so
// a fresh `pkg.hello()` call site may resolve through
// `fn_ast_map` first. Without this, the call's return
// type silently falls through to `.s64` and any
// pack-fn caller (e.g. `print("{}\n", pkg.hello())`)
// mangles the arg as s64, mis-tagging the actual
// string in the Any box.
// Qualified function call. `resolveFuncByName` only finds
// ALREADY-LOWERED functions; namespace imports are typically
// lowered lazily on demand, so a fresh `pkg.hello()` call site
// may resolve through `fn_ast_map` first. Without this, the
// call's return type silently falls through to `.unresolved`
// and any pack-fn caller (e.g. `print("{}\n", pkg.hello())`)
// mangles the arg, mis-tagging the actual string in the Any box.
const qualified = std.fmt.allocPrint(self.l.alloc, "{s}.{s}", .{ tn, cfa.field }) catch cfa.field;
if (self.l.resolveFuncByName(qualified)) |fid| {
return self.l.module.functions.items[@intFromEnum(fid)].ret;
const func = &self.l.module.functions.items[@intFromEnum(fid)];
return .{
.kind = .namespace_fn,
.return_type = func.ret,
.target = .{ .func = fid },
.prepends_ctx = func.has_implicit_ctx,
.expands_defaults = if (self.l.program_index.fn_ast_map.get(qualified)) |fd| defaultsFor(fd, c.args.len) else false,
};
}
if (self.l.program_index.fn_ast_map.get(qualified)) |qfd| {
if (qfd.return_type) |rt| return self.l.resolveType(rt);
return .void;
return .{
.kind = .namespace_fn,
.return_type = if (qfd.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .named = qualified },
.expands_defaults = defaultsFor(qfd, c.args.len),
};
}
// Namespace aliases sometimes register the function
// under its bare name (matches `lowerCall`'s effective-
// name resolution order).
// Namespace aliases sometimes register the function under its
// bare name (matches `lowerCall`'s effective-name resolution).
if (self.l.program_index.fn_ast_map.get(cfa.field)) |bfd| {
if (bfd.return_type) |rt| return self.l.resolveType(rt);
return .void;
return .{
.kind = .namespace_fn,
.return_type = if (bfd.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .named = cfa.field },
.expands_defaults = defaultsFor(bfd, c.args.len),
};
}
}
} else if (c.callee.data == .enum_literal) {
// .Variant(args) — dot-shorthand enum construction
return self.l.target_type orelse .unresolved;
// .Variant(args) — dot-shorthand construction. Result type is
// whatever target type is in scope; absent one it stays unresolved.
const rt = self.l.target_type orelse .unresolved;
var variant: ?u32 = null;
if (self.l.target_type) |tgt| {
if (!tgt.isBuiltin()) {
const ti = self.l.module.types.get(tgt);
if (ti == .tagged_union or ti == .@"enum")
variant = self.l.resolveVariantIndex(tgt, c.callee.data.enum_literal.name);
}
}
return .{
.kind = .enum_shorthand,
.return_type = rt,
.target = if (variant != null) .{ .constructed = rt } else .none,
.variant = variant,
};
}
return .unresolved;
return .{ .kind = .unresolved, .return_type = .unresolved };
}
fn refl(name: []const u8, rt: TypeId) CallPlan {
return .{ .kind = .reflection, .return_type = rt, .target = .{ .named = name } };
}
/// True when a call supplying `supplied` leading params (user args plus a
/// prepended receiver for methods) omits a trailing param the callee
/// defaults — i.e. lowering will splice that default in.
fn defaultsFor(fd: *const ast.FnDecl, supplied: usize) bool {
if (supplied >= fd.params.len) return false;
return fd.params[supplied].default_expr != null;
}
};

View File

@@ -42,6 +42,7 @@ pub const ResolveEnv = type_resolver.ResolveEnv;
pub const PackResolver = packs.PackResolver;
pub const ExprTyper = expr_typer.ExprTyper;
pub const CallResolver = calls.CallResolver;
pub const CallPlan = calls.CallPlan;
pub const compiler_hooks = @import("compiler_hooks.zig");
pub const emit_llvm = @import("emit_llvm.zig");

View File

@@ -48,19 +48,23 @@ fn isExportedEntryName(name: []const u8) bool {
// ── Scope ───────────────────────────────────────────────────────────────
const Binding = struct {
pub const Binding = struct {
ref: Ref,
ty: TypeId,
is_alloca: bool, // true if ref is a pointer that needs load
is_ref_capture: bool = false, // `for xs: (*x)` — `ref` is `*elem`; auto-deref in value positions
};
const Scope = struct {
// `init` / `deinit` / `put` are pub so collaborator unit tests (e.g.
// calls.test.zig) can stand up a lexical scope and exercise the
// scope-dependent call forms (closure / fn-pointer callees) without
// driving a full function lowering.
pub const Scope = struct {
map: std.StringHashMap(Binding),
fn_names: std.StringHashMap([]const u8), // bare name → mangled name for local functions
parent: ?*Scope,
fn init(alloc: Allocator, parent: ?*Scope) Scope {
pub fn init(alloc: Allocator, parent: ?*Scope) Scope {
return .{
.map = std.StringHashMap(Binding).init(alloc),
.fn_names = std.StringHashMap([]const u8).init(alloc),
@@ -68,12 +72,12 @@ const Scope = struct {
};
}
fn deinit(self: *Scope) void {
pub fn deinit(self: *Scope) void {
self.map.deinit();
self.fn_names.deinit();
}
fn put(self: *Scope, name: []const u8, binding: Binding) void {
pub fn put(self: *Scope, name: []const u8, binding: Binding) void {
self.map.put(name, binding) catch unreachable;
}
@@ -5617,7 +5621,7 @@ pub const Lowering = struct {
}
/// Resolve a variant name to its tag index within an enum or union type.
fn resolveVariantIndex(self: *Lowering, ty: TypeId, variant_name: []const u8) u32 {
pub fn resolveVariantIndex(self: *Lowering, ty: TypeId, variant_name: []const u8) u32 {
if (ty.isBuiltin()) return 0;
const info = self.module.types.get(ty);
const name_id = self.module.types.internString(variant_name);