From fa59a9dc259e409709000cf36724465b9bc4dfcd Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 2 Jun 2026 20:33:26 +0300 Subject: [PATCH] refactor(ir): distinguish free-function UFCS from namespace calls in CallPlan (A3.2 review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallPlan collapsed two different field-access dispatches onto namespace_fn: a true namespace call (`pkg.fn()`, no receiver) and free-function UFCS (`c.bump()`, receiver prepended + `*T` fixup). Return typing was preserved either way, but sub-step 3 could not consume the plan — it would have had to re-classify the AST to decide whether to prepend the receiver. Add a distinct `free_fn_ufcs` kind and a plan(c) branch, inserted after the struct-method block and gated on `objectIsValue` (the negation of lowerCall's `is_namespace`: a non-identifier receiver is always a value; an identifier/type_expr is a value iff it names a local or a global). The branch sets prepends_receiver = true and reads prepends_ctx from the resolved FuncId (best-effort, like direct_fn). namespace_fn now means strictly "receiver is a namespace/type prefix". New test `plan: free-function UFCS prepends receiver, distinct from namespace_fn` covers a scope-bound `c.bump()` against a lowered free fn: asserts free_fn_ufcs kind, func target, prepends_receiver, prepends_ctx, and preserved s32 return type. zig build, zig build test, tests/run_examples.sh (357/0) all green; return typing unchanged. --- src/ir/calls.test.zig | 37 +++++++++++++++++++++++++++++ src/ir/calls.zig | 55 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/ir/calls.test.zig b/src/ir/calls.test.zig index d3b091f..9b409e3 100644 --- a/src/ir/calls.test.zig +++ b/src/ir/calls.test.zig @@ -420,6 +420,43 @@ test "plan: enum construction (qualified + dot-shorthand) carries variant tag" { } } +test "plan: free-function UFCS prepends receiver, distinct from namespace_fn" { + 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 Counter, and a FREE function `bump :: (c: Counter) -> s32` — NOT + // registered as `Counter.bump`, so it can only be reached via UFCS. + const counter = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Counter"), .fields = &.{} } }); + const c_param = ast.Param{ .name = "c", .name_span = .{ .start = 0, .end = 0 }, .type_expr = typeExpr(alloc, "Counter") }; + const params = [_]ast.Param{c_param}; + const ret_stmt = mk(alloc, .{ .return_stmt = .{ .value = intLit(alloc, 7) } }); + const body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{ret_stmt} } }); + const fd = ast.FnDecl{ .name = "bump", .params = ¶ms, .return_type = typeExpr(alloc, "s32"), .body = body }; + l.lowerFunction(&fd, "bump", false); + const fid = l.resolveFuncByName("bump").?; + module.functions.items[@intFromEnum(fid)].has_implicit_ctx = true; + + // A value receiver in scope: `c : Counter`. `c.bump()` is UFCS, not a + // namespace call — the receiver must be prepended. + var scope = Scope.init(alloc, null); + defer scope.deinit(); + scope.put("c", .{ .ref = Ref.none, .ty = counter, .is_alloca = false }); + l.scope = &scope; + + const call = callNode(alloc, fieldAccess(alloc, ident(alloc, "c"), "bump"), &.{}); + const p = cr.plan(&call.data.call); + try std.testing.expectEqual(CallPlan.Kind.free_fn_ufcs, p.kind); + try std.testing.expectEqual(fid, p.target.func); + try std.testing.expect(p.prepends_receiver); + try std.testing.expect(p.prepends_ctx); + try std.testing.expectEqual(TypeId.s32, p.return_type); +} + test "plan: qualified namespace function" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/src/ir/calls.zig b/src/ir/calls.zig index 94bbcaa..daa502f 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -46,8 +46,15 @@ pub const CallPlan = struct { fn_pointer, protocol_dispatch, struct_method, + /// Free-function UFCS: `recv.fn(args)` → `fn(recv, args)`, where `fn` + /// is a plain free function and `recv` is a value (not a namespace / + /// type prefix). Distinct from `namespace_fn` precisely because the + /// receiver IS prepended (`prepends_receiver`). + free_fn_ufcs, foreign_instance, foreign_static, + /// `pkg.fn(args)` — the receiver is a namespace / module prefix, NOT a + /// value, so nothing is prepended. namespace_fn, enum_construct, enum_shorthand, @@ -278,8 +285,37 @@ pub const CallResolver = struct { } } } + // Free-function UFCS: `recv.fn(args)` → `fn(recv, args)`. lowerCall + // reaches this only when the receiver is a VALUE (the + // `is_namespace == false` path), in which case it prepends the + // receiver and fixes up a `*T` first param. Mirror that boundary so + // the plan carries `prepends_receiver`, distinct from a true + // namespace call (`pkg.fn()`), which must NOT prepend. + if (self.objectIsValue(cfa.object)) { + if (self.l.resolveFuncByName(cfa.field)) |fid| { + const func = &self.l.module.functions.items[@intFromEnum(fid)]; + return .{ + .kind = .free_fn_ufcs, + .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(cfa.field)) |fd| defaultsFor(fd, c.args.len + 1) else false, + }; + } + if (self.l.program_index.fn_ast_map.get(cfa.field)) |bfd| { + return .{ + .kind = .free_fn_ufcs, + .return_type = if (bfd.return_type) |rt| self.l.resolveType(rt) else .void, + .target = .{ .named = cfa.field }, + .prepends_receiver = true, + .expands_defaults = defaultsFor(bfd, c.args.len + 1), + }; + } + } // Type.variant(args) — qualified construction; foreign static; or a - // qualified namespace function. + // qualified namespace function. Reached for namespace / type + // prefixes (and inert for value receivers handled above). const type_name = switch (cfa.object.data) { .identifier => |id| id.name, .type_expr => |te| te.name, @@ -372,6 +408,23 @@ pub const CallResolver = struct { return .{ .kind = .reflection, .return_type = rt, .target = .{ .named = name } }; } + /// True when a field-access receiver is a value (so `recv.fn(...)` is a + /// method / UFCS call), false when it is a bare namespace / type prefix + /// (so `pkg.fn(...)` is a namespace call). This is exactly the negation of + /// `lowerCall`'s `is_namespace`: a non-identifier object is always a value; + /// an identifier / type_expr is a value iff it names a local or a global. + fn objectIsValue(self: CallResolver, obj: *const Node) bool { + const obj_name: []const u8 = switch (obj.data) { + .identifier => |id| id.name, + .type_expr => |te| te.name, + else => return true, + }; + if (self.l.scope) |scope| { + if (scope.lookup(obj_name) != null) return true; + } + return self.l.program_index.global_names.contains(obj_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.