refactor(ir): distinguish free-function UFCS from namespace calls in CallPlan (A3.2 review fix)

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.
This commit is contained in:
agra
2026-06-02 20:33:26 +03:00
parent 61f1f2368a
commit fa59a9dc25
2 changed files with 91 additions and 1 deletions

View File

@@ -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.