refactor(ir): extract CallResolver for call result typing (A3.2 relocation)

Move call-result-type discovery out of Lowering into a new src/ir/calls.zig
(CallResolver): the A3.1 Lowering.inferCallType body moves verbatim into
CallResolver.resultType. inferExprType's `.call` arm now delegates via
callResolver(); Lowering.inferCallType is gone.

CallResolver is 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. Transform
was `self.` -> `self.l.` plus the file-local static `resolveBuiltin(` ->
`Lowering.resolveBuiltin(`.

Widened to pub only what the facade actually consumes: resolveTypeArg,
inferGenericReturnType, resolveFuncByName, getProtocolInfo,
resolveForeignMethodReturnType, the static resolveBuiltin, and Scope.lookupFn.
resolveTypeArg widening is genuinely required here — the `cast` builtin's
result type calls it.

calls.test.zig adds focused tests (builtin/reflection classification, unknown
callee -> unresolved) for the scope-free paths. Barrel-wired in ir.zig.

This is the relocation half of PLAN-ARCH A3.2; call LOWERING (lowerCall) still
owns its own dispatch, and the CallPlan convergence (one plan shared by typing
and lowering, deleting the duplicated qualified/bare/lazy logic) remains.

Behavior-preserving. Gate: zig build, zig build test (incl. new CallResolver
tests), bash tests/run_examples.sh -> 356/0. lower.zig 18598 -> 18413.
This commit is contained in:
agra
2026-06-02 18:44:08 +03:00
parent 7d069107c8
commit 7f3a7b35ef
4 changed files with 275 additions and 196 deletions

215
src/ir/calls.zig Normal file
View File

@@ -0,0 +1,215 @@
const std = @import("std");
const ast = @import("../ast.zig");
const types = @import("types.zig");
const type_bridge = @import("type_bridge.zig");
const lower = @import("lower.zig");
const Node = ast.Node;
const TypeId = types.TypeId;
const Lowering = lower.Lowering;
/// 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
/// and plain free functions (lowered or lazy via `fn_ast_map`), closure /
/// function-typed locals, protocol dispatch, foreign-class instance/static
/// methods, struct (UFCS) methods, qualified namespace calls, and
/// enum/tagged-union construction.
///
/// 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.
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 {
if (c.callee.data == .identifier) {
const bare_name = c.callee.data.identifier.name;
// Resolve local function name (bare → mangled) and UFCS aliases
const name = blk: {
const scoped = if (self.l.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
if (self.l.program_index.ufcs_alias_map.get(bare_name)) |target| {
break :blk if (self.l.scope) |scope| scope.lookupFn(target) orelse target else target;
}
break :blk scoped;
};
if (Lowering.resolveBuiltin(bare_name)) |bid| {
return switch (bid) {
.sqrt, .sin, .cos, .floor => blk: {
if (c.args.len > 0) {
const arg_ty = self.l.inferExprType(c.args[0]);
if (arg_ty == .f32) break :blk TypeId.f32;
}
break :blk TypeId.f64;
},
.size_of, .align_of => .s64,
.cast => if (c.args.len > 0) self.l.resolveTypeArg(c.args[0]) else .unresolved,
else => .unresolved,
};
}
// 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, "__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
if (self.l.program_index.fn_ast_map.get(name)) |fd| {
if (fd.type_params.len > 0) {
return self.l.inferGenericReturnType(fd, c);
}
}
// Check declared functions for return type
if (self.l.resolveFuncByName(name)) |fid| {
return self.l.module.functions.items[@intFromEnum(fid)].ret;
}
// 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;
}
// 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.
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;
}
}
}
} 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;
}
}
}
// 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;
if (!recv_inner.isBuiltin()) {
const ri = self.l.module.types.get(recv_inner);
if (ri == .pointer) recv_inner = ri.pointer.pointee;
}
if (!recv_inner.isBuiltin()) {
const inner_info = self.l.module.types.get(recv_inner);
if (inner_info == .@"struct") {
const sn = self.l.module.types.getString(inner_info.@"struct".name);
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);
},
else => {},
};
}
}
}
}
// Instance method call: obj.method(args) → look up StructName.method
{
var obj_ty = recv_ty;
if (!obj_ty.isBuiltin()) {
const oi = self.l.module.types.get(obj_ty);
if (oi == .pointer) obj_ty = oi.pointer.pointee;
}
if (!obj_ty.isBuiltin()) {
const oi = self.l.module.types.get(obj_ty);
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
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;
}
}
if (self.l.resolveFuncByName(qualified)) |fid| {
return self.l.module.functions.items[@intFromEnum(fid)].ret;
}
}
}
}
// Type.variant(args) — qualified enum construction
const type_name = switch (cfa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => null,
};
if (type_name) |tn| {
// Foreign-class static method: `Alias.static_method(args)`.
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);
},
else => {},
};
}
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;
}
// 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.
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;
}
if (self.l.program_index.fn_ast_map.get(qualified)) |qfd| {
if (qfd.return_type) |rt| return self.l.resolveType(rt);
return .void;
}
// Namespace aliases sometimes register the function
// under its bare name (matches `lowerCall`'s effective-
// name resolution order).
if (self.l.program_index.fn_ast_map.get(cfa.field)) |bfd| {
if (bfd.return_type) |rt| return self.l.resolveType(rt);
return .void;
}
}
} else if (c.callee.data == .enum_literal) {
// .Variant(args) — dot-shorthand enum construction
return self.l.target_type orelse .unresolved;
}
return .unresolved;
}
};