feat(resolver): route plain bare-call author through Phase B collector via SelectedFunc [stdlib C]

Phase C of the unified resolver (R5 §C, §#3). Re-base the plain bare-name
call author onto the Phase B collector behind one shared SelectedFunc, so
every call-path consumer reads ONE author and they can no longer disagree
(fix-0102 F2). Behavior-preserving: 0722-0735 byte-identical, run_examples
stays at 475.

- SelectedFunc {decl, source, materialized?} replaces ResolvedAuthor in
  BareCallee.func; CallPlan.Target gains a `selected` arm (calls.zig).
- selectPlainCallableAuthor: resolveBareCallee's body verbatim over
  resolver.collectVisibleAuthors (.user_bare_flat) — the ONE graph-walk.
  fnDeclOfRaw mirrors imports.fnDeclOf so the collector's all-domain authors
  reproduce module_fns' fn-only view; every byte of the negative space is
  preserved (own==winner → .none; non-plain-free → .none; filter-before-count;
  ≥2 distinct → .ambiguous). No eager materialization.
- selectedFuncId materializes the FuncId on demand (shadow-only), caching into
  materialized — null until a site needs it (0102d: a shadow taken as a value
  never lowers the winner).
- Six consumers route through the one selector: lowerCall variadic packing,
  free-fn UFCS, fn-value, closure(fn), resolveCallParamTypes, and
  expandCallDefaults (decl-only, no materialization). plan() produces the
  SelectedFunc as `.selected`. Generic/comptime/foreign/builtin stay legacy.
- lower.test.zig: wire module_decls; selectPlainCallableAuthor verdicts
  (own-winner → .none; ≥2 flat → .ambiguous; own-shadow → decl+source, fid
  round-trips, materialized null).

Gate: zig build + zig build test (412 ok) + run_examples (475, byte-identical)
+ m3te ios-sim build exit 0.
This commit is contained in:
agra
2026-06-07 11:02:08 +03:00
parent f2de1a9710
commit 9568f7689f
3 changed files with 199 additions and 86 deletions

View File

@@ -72,6 +72,13 @@ pub const CallPlan = struct {
/// A callee carried by name — reflection builtin, generic / lazy fn,
/// closure / fn-pointer binding, or a not-yet-lowered namespace fn.
named: []const u8,
/// The single bare-call author `selectPlainCallableAuthor` selected for a
/// genuine flat same-name collision (R5 §#3). Carries the resolved
/// `*FnDecl` + source so `plan` and the lowering call-path read ONE
/// author and can no longer disagree (fix-0102 F2); the FuncId is
/// materialized on demand. Only set when the bare name reroutes away from
/// the first-wins winner; the common path still uses `func` / `named`.
selected: Lowering.SelectedFunc,
/// Protocol method, by index in the protocol's method table.
protocol_method: u32,
/// Foreign-class method (Obj-C / JNI), with its static-ness.
@@ -149,6 +156,28 @@ pub const CallResolver = struct {
if (std.mem.eql(u8, bare_name, "type_is_unsigned")) 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);
// Plain bare same-name flat collision (R5 §C): route through the ONE
// selector so `plan` reads the SAME author the lowering call-path
// binds — they can no longer disagree (fix-0102 F2). The gate mirrors
// `lowerCall`'s: a plain top-level identifier with no scope-mangle /
// local shadow. A generic / foreign / builtin author is not plain-free
// so the selector returns `.none`; `.ambiguous` / `.none` fall through
// to the first-wins path below, byte-for-byte.
if (std.mem.eql(u8, name, bare_name) and
(if (self.l.scope) |scope| scope.lookup(bare_name) == null else true))
{
if (self.l.current_source_file) |caller_file| {
switch (self.l.selectPlainCallableAuthor(bare_name, caller_file)) {
.func => |sf| return .{
.kind = .direct_fn,
.return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .selected = sf },
.expands_defaults = defaultsFor(sf.decl, c.args.len),
},
.ambiguous, .none => {},
}
}
}
// 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) {

View File

@@ -1361,6 +1361,10 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102
var module_fns = imports.ModuleFns.init(alloc);
try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns);
// Phase A raw facts: `selectPlainCallableAuthor` (Phase C) collects authors
// over `module_decls`, not `module_fns`. Wired exactly as `core.zig` does.
var facts = try imports.buildImportFacts(alloc, main_path, mod, &cache);
const resolved_root = try alloc.create(Node);
resolved_root.* = .{ .span = root.span, .data = .{ .root = .{ .decls = mod.decls } } };
@@ -1375,6 +1379,7 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102
lowering.program_index.import_graph = &import_graph;
lowering.program_index.flat_import_graph = &flat_import_graph;
lowering.program_index.module_fns = &module_fns;
lowering.program_index.module_decls = &facts.decls;
lowering.lowerRoot(resolved_root);
try std.testing.expect(!diagnostics.hasErrors());
@@ -1428,19 +1433,27 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102
try std.testing.expect(shadow_fid != null);
try std.testing.expect(shadow_fid.? != winner_fid.?);
// fix-0102c: THE bare-name resolver routes per caller file. `main` flat-
// imports two `greet` authors and is its own author of neither → a bare
// `greet()` from `main` is ambiguous. a.sx authors the WINNER, so its bare
// `greet` resolves through the existing path (`.none`). b.sx authors the
// SHADOW, so own-author-wins binds b.sx's distinct FuncId — not first-wins.
// fix-0102c / Phase C: THE bare-name selector routes per caller file over the
// Phase A author collector. `main` flat-imports two `greet` authors and is its
// own author of neither → a bare `greet()` from `main` is ambiguous. a.sx
// authors the WINNER, so its bare `greet` resolves through the existing path
// (`.none`). b.sx authors the SHADOW, so own-author-wins selects b.sx's
// author — its `*FnDecl` + source, NOT first-wins. The selector does NOT
// eagerly materialize: it returns the decl, and the FuncId still round-trips
// to the shadow slot via the identity map (`fn_decl_fids`).
const a_path = try std.fmt.allocPrint(alloc, "{s}/a.sx", .{absdir});
const b_path = try std.fmt.allocPrint(alloc, "{s}/b.sx", .{absdir});
try std.testing.expect(lowering.resolveBareCallee("greet", main_path) == .ambiguous);
try std.testing.expect(lowering.resolveBareCallee("greet", a_path) == .none);
switch (lowering.resolveBareCallee("greet", b_path)) {
.func => |resolved| try std.testing.expectEqual(shadow_fid.?, resolved.fid),
try std.testing.expect(lowering.selectPlainCallableAuthor("greet", main_path) == .ambiguous);
try std.testing.expect(lowering.selectPlainCallableAuthor("greet", a_path) == .none);
switch (lowering.selectPlainCallableAuthor("greet", b_path)) {
.func => |sf| {
try std.testing.expectEqual(shadow_fd.?, sf.decl);
try std.testing.expectEqualStrings(b_path, sf.source);
try std.testing.expect(sf.materialized == null);
try std.testing.expectEqual(shadow_fid.?, lowering.fn_decl_fids.get(sf.decl).?);
},
else => return error.TestUnexpectedResult,
}
// A name no module authors (and no flat import provides) never routes.
try std.testing.expect(lowering.resolveBareCallee("nonexistent", b_path) == .none);
try std.testing.expect(lowering.selectPlainCallableAuthor("nonexistent", b_path) == .none);
}

View File

@@ -1461,7 +1461,7 @@ pub const Lowering = struct {
// A `#run` body lowers in its OWN module's source context (fix-0102d
// site 4): `NAME :: #run f()` written in an imported module must
// resolve a bare `f` from that module's flat imports, not the main
// file's. Without this, `resolveBareCallee` runs with the main
// file's. Without this, `selectPlainCallableAuthor` runs with the main
// file's perspective and reports a genuine per-source author as
// ambiguous. Mirrors `scanDecls` / `lowerDecls`, which already set
// the source file per decl.
@@ -1552,14 +1552,16 @@ pub const Lowering = struct {
}
}
/// Result of bare-call disambiguation (fix-0102c).
/// Result of bare-call disambiguation (fix-0102c, now over the Phase B
/// author collector).
pub const BareCallee = union(enum) {
/// Bind the call to this specific author — its identity-addressable
/// FuncId (fix-0102b's `bareAuthorFuncId`) AND its `*FnDecl`. The decl
/// travels with the FuncId so every callee-signature decision in the
/// call path (variadic packing, …) reads the RESOLVED author, never a
/// first-wins re-lookup by name (fix-0102c F1).
func: ResolvedAuthor,
/// Bind the call to this specific author, carried as the shared
/// `SelectedFunc` (R5 §#3): its `*FnDecl` + authoring source, FuncId
/// materialized on demand. Every callee-signature decision in the call
/// path (variadic packing, param typing, default expansion) reads the
/// RESOLVED author from this one object — never a first-wins re-lookup
/// by name (fix-0102c F1).
func: SelectedFunc,
/// ≥2 distinct flat authors are reachable from the caller and none is
/// the caller's own — the bare call can't pick one; require a qualifier.
ambiguous,
@@ -1568,78 +1570,122 @@ pub const Lowering = struct {
none,
};
/// A resolved bare-call author: its FuncId and the `*FnDecl` that defined
/// it, kept together so the call path has ONE source of truth for the
/// callee (no re-fetch by name after resolution).
pub const ResolvedAuthor = struct { fid: FuncId, decl: *const ast.FnDecl };
/// The single bare-call author object (R5 §#3): the `*FnDecl` that defines
/// the call and the SOURCE file that authors it, kept together so the call
/// path has ONE source of truth for the callee. `materialized` holds the
/// author's FuncId once a site needs it; it is filled on demand by
/// `selectedFuncId` (→ `bareAuthorFuncId`), NOT during selection — so a
/// selection that only needs the decl (default-arg expansion), or a shadow
/// taken purely as a value, never lowers the first-wins winner (0102d).
pub const SelectedFunc = struct {
decl: *const ast.FnDecl,
source: []const u8,
materialized: ?FuncId = null,
};
/// THE bare-name call resolver (fix-0102c). One canonical traversal over
/// fix-0102a's `module_fns` + `flat_import_graph` that routes a bare
/// identifier call `name` from `caller_file` to the right same-name author
/// when flat imports introduce a genuine collision. Every single-author /
/// local / parameter / std / qualified name resolves through the EXISTING
/// path unchanged: the resolver returns `.none` whenever the outcome would
/// match first-wins, so nothing on the common path is perturbed.
/// THE plain bare-name call selector (fix-0102c, R5 §C). `resolveBareCallee`'s
/// body verbatim, now over the Phase B author collector
/// (`resolver.collectVisibleAuthors` — the ONE graph-walk) instead of a direct
/// `module_fns` + `flat_import_graph` traversal. Routes a bare identifier call
/// `name` from `caller_file` to the right same-name author when flat imports
/// introduce a genuine collision. Every single-author / local / parameter /
/// std / qualified name resolves through the EXISTING path unchanged: the
/// selector returns `.none` whenever the outcome would match first-wins, so
/// nothing on the common path is perturbed.
///
/// - **own-author wins**: if `caller_file` authors `name` and the bare-name
/// first-wins winner is a DIFFERENT author, bind the caller's own author.
/// (When the winner already IS the caller's own — the single-author and
/// first-importer cases — `.none` lets the existing path bind it.)
/// - else collect the authors reachable via `caller_file`'s FLAT import
/// The collector returns RAW authors across ALL decl domains; this selector
/// reproduces `module_fns`' fn-only view by filtering each author through
/// `fnDeclOfRaw` (a `const`-wrapped fn unwraps to its inner fn — the exact
/// `*FnDecl` `module_fns` stored; every other domain drops out), preserving
/// resolveBareCallee's negative space byte-for-byte.
///
/// - **own-author wins**: if `caller_file` authors `name` as a fn and the
/// bare-name first-wins winner is a DIFFERENT author, select the caller's
/// own author. (When the winner already IS the caller's own — the
/// single-author and first-importer cases — `.none` lets the existing path
/// bind it.)
/// - else select among the authors reachable via `caller_file`'s FLAT import
/// edges (bare `#import` of a file or directory, never a namespaced
/// `ns :: #import`), deduped by `FnDecl` identity (a diamond import of the
/// `ns :: #import`), deduped by author identity (a diamond import of the
/// same module is one author): `≥2 distinct` → `.ambiguous`; exactly one
/// that DIFFERS from the winner → bind it; otherwise `.none`.
/// that DIFFERS from the winner → select it; otherwise `.none`.
///
/// Generic / comptime / foreign / builtin authors are never rerouted — the
/// existing dispatch owns those shapes — so the resolver returns `.none`.
pub fn resolveBareCallee(self: *Lowering, name: []const u8, caller_file: []const u8) BareCallee {
const module_fns = self.program_index.module_fns orelse return .none;
/// existing dispatch owns those shapes; `isPlainFreeFn` filters them out
/// BEFORE the count gate (so a same-name collision of non-plain authors is
/// NOT ambiguous), and the selector returns `.none`. No eager
/// materialization: the returned `SelectedFunc` carries decl + source and
/// `materialized = null`; a consumer fills the FuncId via `selectedFuncId`
/// only when it truly needs it (0102d).
pub fn selectPlainCallableAuthor(self: *Lowering, name: []const u8, caller_file: []const u8) BareCallee {
const winner = self.program_index.fn_ast_map.get(name);
var res = self.resolver();
const set = res.collectVisibleAuthors(name, caller_file, .user_bare_flat);
defer if (set.flat.len > 0) self.alloc.free(set.flat);
// own-author wins.
if (module_fns.get(caller_file)) |own_fns| {
if (own_fns.get(name)) |own| {
// own-author wins. The collector's `own` spans all domains; a non-fn
// (or a const not bound to a function) means `caller_file` has no fn
// `name` — fall through to the flat authors, exactly as the fn-only
// `module_fns` walk did.
if (set.own) |own_author| {
if (fnDeclOfRaw(own_author.raw)) |own| {
if (winner != null and winner.? == own) return .none;
if (!isPlainFreeFn(own)) return .none;
return .{ .func = .{ .fid = self.bareAuthorFuncId(own, name, caller_file), .decl = own } };
return .{ .func = .{ .decl = own, .source = own_author.source } };
}
}
// Caller does not author `name` → collect its flat-reachable authors.
const flat_graph = self.program_index.flat_import_graph orelse return .none;
const edges = flat_graph.get(caller_file) orelse return .none;
var distinct = std.AutoHashMap(*const ast.FnDecl, []const u8).init(self.alloc);
defer distinct.deinit();
var edge_it = edges.iterator();
while (edge_it.next()) |e| {
const fns = module_fns.get(e.key_ptr.*) orelse continue;
// Only plain free functions are eligible for rerouting; generic /
// foreign / builtin / #compiler authors keep their existing
// dispatch. Filtering BEFORE the count gate means a same-name
// collision of non-plain authors (e.g. two flat-imported modules
// each `#foreign`ing the same symbol) is NOT counted as ambiguous
// it falls through to `.none` and the existing first-wins path.
if (fns.get(name)) |fd| {
if (!isPlainFreeFn(fd)) continue;
distinct.put(fd, e.key_ptr.*) catch {};
}
// Caller does not author `name` as a fn → its flat-reachable authors.
// Filter to plain free functions BEFORE counting: a same-name collision
// of non-plain authors (e.g. two flat-imported modules each `#foreign`ing
// the same symbol) is NOT counted as ambiguous — it falls through to
// `.none` and the existing first-wins path.
var the_one: ?*const ast.FnDecl = null;
var the_source: []const u8 = &.{};
var count: usize = 0;
for (set.flat) |fa| {
const fd = fnDeclOfRaw(fa.raw) orelse continue;
if (!isPlainFreeFn(fd)) continue;
count += 1;
if (count >= 2) return .ambiguous;
the_one = fd;
the_source = fa.source;
}
if (distinct.count() == 0) return .none;
if (distinct.count() >= 2) return .ambiguous;
if (count == 0) return .none;
if (winner != null and winner.? == the_one.?) return .none;
return .{ .func = .{ .decl = the_one.?, .source = the_source } };
}
var one_it = distinct.iterator();
const entry = one_it.next().?;
const the_one = entry.key_ptr.*;
const the_path = entry.value_ptr.*;
if (winner != null and winner.? == the_one) return .none;
return .{ .func = .{ .fid = self.bareAuthorFuncId(the_one, name, the_path), .decl = the_one } };
/// The `*FnDecl` a raw author wraps, or null when the author is not a
/// function — `imports.fnDeclOf` over a `RawDeclRef` so the collector's
/// all-domain authors reproduce `module_fns`' fn-only view (a `const`-wrapped
/// fn unwraps to its inner fn, the same pointer `module_fns` holds; every
/// other domain → null).
fn fnDeclOfRaw(ref: resolver_mod.RawDeclRef) ?*const ast.FnDecl {
return switch (ref) {
.fn_decl => |fd| fd,
.const_decl => |cd| if (cd.value.data == .fn_decl) &cd.value.data.fn_decl else null,
else => null,
};
}
/// Materialize (lower-on-demand) the FuncId for a selected bare-call author,
/// caching into `sf.materialized`. Shadow-only: the winner owns the
/// name-keyed slot and lowers through the lazy path, so
/// `selectPlainCallableAuthor` returns `.none` for it and this is never asked
/// to lower the winner (0102d). `name` is the call name (== the author's
/// registered name); `sf.source` pins the author's own visibility context.
fn selectedFuncId(self: *Lowering, sf: *SelectedFunc, name: []const u8) FuncId {
if (sf.materialized) |fid| return fid;
const fid = self.bareAuthorFuncId(sf.decl, name, sf.source);
sf.materialized = fid;
return fid;
}
/// The FuncId for a resolved bare-call author, ensuring its body is lowered.
/// Only ever called for a SHADOW (an author that is not the name-keyed
/// winner): the winner owns the name-keyed slot and lowers through the
/// normal lazy path, so `resolveBareCallee` returns `.none` for it. A shadow
/// normal lazy path, so `selectPlainCallableAuthor` returns `.none` for it. A shadow
/// is declared a fresh same-name FuncId in its OWN module's visibility
/// context and its body lowered into that slot via fix-0102b's identity-
/// addressable `lowerFunctionBodyInto`. Idempotent: `lowered_fids` tracks
@@ -3325,8 +3371,11 @@ pub const Lowering = struct {
(if (self.scope) |scope| scope.lookup(id.name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(id.name, caller_file)) {
.func => |resolved| break :blk_fv resolved.fid,
switch (self.selectPlainCallableAuthor(id.name, caller_file)) {
.func => |sf| {
var selected = sf;
break :blk_fv self.selectedFuncId(&selected, id.name);
},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{id.name});
@@ -7377,8 +7426,11 @@ pub const Lowering = struct {
(if (self.scope) |scope| scope.lookup(fn_name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(fn_name, caller_file)) {
.func => |resolved| break :blk_cl resolved.fid,
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});
@@ -7682,27 +7734,28 @@ pub const Lowering = struct {
// author. Only a plain top-level identifier call routes here:
// scope-mangled / UFCS-aliased / locally-shadowed names and
// 0/1-author names fall straight to the existing path below
// (`resolveBareCallee` returns `.none`).
// (`selectPlainCallableAuthor` returns `.none`).
if (std.mem.eql(u8, func_name, id.name) and
(if (self.scope) |scope| scope.lookup(id.name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(func_name, caller_file)) {
switch (self.selectPlainCallableAuthor(func_name, caller_file)) {
.none => {},
.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;
},
.func => |resolved| {
const fid = resolved.fid;
.func => |sf| {
var selected = sf;
const fid = self.selectedFuncId(&selected, 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 (fix-0102c F1).
self.packVariadicCallArgs(resolved.decl, c, &args);
self.packVariadicCallArgs(selected.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);
@@ -8202,8 +8255,11 @@ pub const Lowering = struct {
// → existing first-wins path.
const ufcs_fid: ?FuncId = blk_uf: {
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(fa.field, caller_file)) {
.func => |resolved| break :blk_uf resolved.fid,
switch (self.selectPlainCallableAuthor(fa.field, caller_file)) {
.func => |sf| {
var selected = sf;
break :blk_uf self.selectedFuncId(&selected, fa.field);
},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field});
@@ -12054,8 +12110,14 @@ pub const Lowering = struct {
(if (self.scope) |scope| scope.lookup(id.name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(id.name, caller_file)) {
.func => |resolved| break :blk resolved.decl,
switch (self.selectPlainCallableAuthor(id.name, caller_file)) {
// Default expansion needs only the author's decl
// (its param defaults) — never the FuncId. Reading
// `sf.decl` here keeps `materialized` null, so a
// bare call whose body is never emitted (e.g. only
// its defaults are inspected) does not lower the
// author (0102d).
.func => |sf| break :blk sf.decl,
.ambiguous => return null,
.none => {},
}
@@ -12296,9 +12358,11 @@ pub const Lowering = struct {
(if (self.scope) |scope| scope.lookup(bare_name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.resolveBareCallee(bare_name, caller_file)) {
.func => |resolved| {
const func = &self.module.functions.items[@intFromEnum(resolved.fid)];
switch (self.selectPlainCallableAuthor(bare_name, caller_file)) {
.func => |sf| {
var selected = sf;
const fid = self.selectedFuncId(&selected, bare_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
},
.ambiguous, .none => {},
@@ -14548,6 +14612,13 @@ pub const Lowering = struct {
return .{ .l = self };
}
/// A `Resolver` facade over the borrowed Phase A import facts (Phase B). Cheap
/// by-value; `collectVisibleAuthors`'s `AuthorSet.flat` slice is backed by
/// `self.alloc` and owned by the caller (`selectPlainCallableAuthor` frees it).
fn resolver(self: *Lowering) resolver_mod.Resolver {
return resolver_mod.Resolver.init(&self.program_index, self.alloc);
}
pub fn genericResolver(self: *Lowering) GenericResolver {
return .{ .l = self };
}