Files
sx/src/ir/resolver.zig
agra 5ed54a08ee refactor(R0): delete ResolvedProgram pre-pass; add resolveBare/resolveQualified
Removes the S2.x pre-pass and its 10 NodeRefTable maps — 1934 net lines
deleted. The Resolver gains two lazy functions: resolveBare(name, from,
domain) and resolveQualified(target, name), each returning ResolvedAuthors
(verdict + author set). verdictOver and authoredAsDomainAnywhere move from
ResolvePass to Resolver as private methods. All domain-predicate helpers
(eligibleKind, structDeclOf, fnDeclOf, etc.) are promoted to pub.

Test file trimmed from 1352 to 396 lines; old pre-pass population tests
replaced by focused resolveBare / resolveQualified verdict tests.

540/540 regression tests pass. Zero behavior change.
2026-06-09 21:55:53 +03:00

421 lines
18 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The unified sx name/type resolver — the shared author-collection layer.
//!
//! A read-only facade over the borrowed Phase A import facts on a
//! `*ProgramIndex` (`module_decls` / `namespace_edges`) and the existing
//! `import_graph` / `flat_import_graph` views. It OWNS nothing import-derived;
//! those maps live in `imports.zig`/`core.zig` and are borrowed here.
//!
//! Two collectors sit on top of these facts (R5 §1 #1):
//! - `collectVisibleAuthors` — own author the flat-import edge walk. THE one
//! graph-walk; the permanent flat-import F-series root.
//! - `collectNamespaceAuthors` — a single already-selected namespace target's
//! members. NO graph walk.
//!
//! Two lazy resolution functions build on the collectors:
//! - `resolveBare(name, from, domain)` — collect bare authors + compute verdict.
//! The caller owns the returned `ResolvedAuthors.set.flat` slice.
//! - `resolveQualified(target, name)` — collect namespace authors + compute verdict.
//! Returns no allocation (namespace sets are always single-module, flat=&.{}).
//!
//! Both are RAW-author collectors returning verdicts: they say WHO authors a name
//! and whether that author wins outright, is ambiguous, not-visible, etc.
//! Per-domain lowering (R2+) decides what to do with the verdict.
//!
//! Falsifiable invariant (R5 §1 #1): there is EXACTLY ONE iterator over
//! `flat_import_graph`/`import_graph` in this file — inside
//! `collectVisibleAuthors`. `collectNamespaceAuthors` iterates one
//! `NamespaceTarget.own_decls` slice and touches no graph. This is what keeps
//! 0102 (callable) and 0105 (type) the SAME cross-module edge-walk.
const std = @import("std");
const ast = @import("../ast.zig");
const imports = @import("../imports.zig");
const program_index = @import("program_index.zig");
const ProgramIndex = program_index.ProgramIndex;
// ── Raw-fact aliases (defined in imports.zig by buildImportFacts, Phase A) ──
pub const RawDeclRef = imports.RawDeclRef;
pub const RawAuthor = imports.RawAuthor;
pub const NamespaceTarget = imports.NamespaceTarget;
/// Author multiplicity for ONE name as seen from ONE querying module: the
/// own-module author (tier-2) plus the distinct flat-import authors (tier-3),
/// diamond-deduped by author identity. RAW — no verdict, no domain, no pick.
pub const AuthorSet = struct {
/// The author declared in the querying module itself, if any.
own: ?RawAuthor,
/// Distinct flat-import authors. Diamond imports of the SAME author (same
/// AST node reached over two edges, e.g. a directory aggregate and one of
/// its member files) collapse to a single entry. Always disjoint from `own`.
flat: []const RawAuthor,
/// own + flat, counted by author identity. `flat` is already deduped and
/// disjoint from `own`, so this is a plain sum.
pub fn distinctCount(self: AuthorSet) usize {
return (if (self.own != null) @as(usize, 1) else 0) + self.flat.len;
}
};
/// How a name's cross-module visibility is computed. The author collector and
/// the lowering-side visibility predicate (`Lowering.isVisible`) both switch on
/// this single vocabulary.
pub const VisibilityMode = enum {
/// own scope `flat_import_graph`. The PERMANENT core for bare-name lookup
/// under flat imports (Agra constraint) — never a transitional path.
user_bare_flat,
/// `user_bare_flat` plus the foreign-C gate (today's `isCImportVisible`):
/// only C-import `fn_decl`s without a `library_ref` are policed; everything
/// else is unconditionally visible.
c_import_bare,
/// own scope the TRANSITIVE import relation (specs.md:793-801). Owned by
/// `ProtocolResolver.findVisibleImpls`; the single-hop author collector
/// never serves it.
impl_transitive,
/// Registration / lazy lowering: falls open (visible), emits no user
/// diagnostic, performs no graph walk.
lowering_internal,
};
/// The selection verdict computed above a reference's collected author set —
/// the own-wins / single-flat-visible / ≥2-ambiguous layer. Evaluated over the
/// DOMAIN-ELIGIBLE subset of the author set (`eligibleKind`), so a same-name
/// VALUE never decides a TYPE reference — the type-vs-value `domain_filtered`
/// outcome.
pub const Verdict = enum {
/// The querying module's OWN author is eligible — it wins outright,
/// regardless of how many same-name flat authors exist.
own_wins,
/// Exactly ONE eligible flat-visible author, no own — the byte-identical
/// single-author path.
single,
/// ≥2 distinct eligible flat-visible authors, no own — a genuine collision
/// the source cannot disambiguate (the LOUD diagnostic at R2+).
ambiguous,
/// No eligible author is flat-visible, but the name IS authored for this
/// domain in some module — reachable only over a namespace edge ⇒ a
/// not-visible leak.
not_visible,
/// Visible same-name author(s) exist but NONE is eligible for this domain
/// (e.g. a same-name VALUE for a TYPE reference), or the name is authored
/// for this domain nowhere. The caller disambiguates by checking
/// `set.distinctCount()`: 0 = undeclared / builtin / local; >0 = wrong domain.
domain_filtered,
};
/// A collected author set paired with the verdict computed over it.
/// `set.flat` is owned by the caller and must be freed when no longer needed
/// (pass `self.alloc` to the same allocator used by the `Resolver`).
/// `resolveQualified` always returns `flat = &.{}` (no allocation).
pub const ResolvedAuthors = struct {
set: AuthorSet,
verdict: Verdict,
};
/// The reference domains a verdict is computed over. Each carries its own
/// eligibility filter (`eligibleKind`), so the own-wins / ambiguity count
/// surveys only the authors that can actually decide THIS kind of reference.
pub const Domain = enum {
bare_type,
value_const,
callable,
generic_struct_head,
type_fn_head,
protocol_head,
foreign_class,
struct_const,
namespace_member,
ufcs,
};
/// Whether `raw` is an author ELIGIBLE to decide a reference in `domain`.
/// `field` is the accessed member name (struct-const domain only; ignored
/// elsewhere). Mirrors the per-kind author predicates the lowering selectors
/// gate on (`isNamedTypeKind`, `isPlainFreeFn`, `typeFnAuthor`, etc.).
pub fn eligibleKind(domain: Domain, raw: RawDeclRef, field: ?[]const u8) bool {
return switch (domain) {
.bare_type => switch (raw) {
.struct_decl, .enum_decl, .union_decl, .error_set_decl,
.protocol_decl, .foreign_class_decl => true,
else => false,
},
.value_const => raw == .const_decl,
.callable => if (fnDeclOf(raw)) |fd| isPlainFreeFnDecl(fd) else false,
.generic_struct_head => if (structDeclOf(raw)) |sd| sd.type_params.len > 0 else false,
.type_fn_head => if (fnDeclOf(raw)) |fd| fd.type_params.len > 0 else false,
.protocol_head => raw == .protocol_decl,
.foreign_class => raw == .foreign_class_decl,
.struct_const => structHasConstMember(raw, field orelse return false),
.namespace_member => true,
.ufcs => fnDeclOf(raw) != null,
};
}
// ── Domain-predicate helpers ─────────────────────────────────────────────────
/// The `*StructDecl` a raw author wraps (bare or `Name :: struct(...)` const),
/// or null when the author is not a struct.
pub fn structDeclOf(raw: RawDeclRef) ?*const ast.StructDecl {
return switch (raw) {
.struct_decl => |sd| sd,
.const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else null,
else => null,
};
}
/// The `*FnDecl` a raw author wraps (bare or `Name :: fn(...)` const), or null
/// when the author is not a function.
pub fn fnDeclOf(raw: RawDeclRef) ?*const ast.FnDecl {
return switch (raw) {
.fn_decl => |fd| fd,
.const_decl => |cd| if (cd.value.data == .fn_decl) &cd.value.data.fn_decl else null,
else => null,
};
}
/// A PLAIN free function — no type params, an ordinary (non-`#foreign`/
/// `#builtin`/`#compiler`) body — the only callable kind the bare-call verdict
/// counts.
pub fn isPlainFreeFnDecl(fd: *const ast.FnDecl) bool {
if (fd.type_params.len > 0) return false;
return switch (fd.body.data) {
.foreign_expr, .builtin_expr, .compiler_expr => false,
else => true,
};
}
/// True when the raw author is a struct carrying a const member named `field`.
pub fn structHasConstMember(raw: RawDeclRef, field: []const u8) bool {
return switch (raw) {
.struct_decl => |sd| blk: {
for (sd.constants) |c| {
if (c.data == .const_decl and std.mem.eql(u8, c.data.const_decl.name, field))
break :blk true;
}
break :blk false;
},
else => false,
};
}
/// True when any author in the set (own or flat) is a struct with const member
/// `field`. Used by resolveStructConst to short-circuit empty sets.
pub fn authorSetHasStructConst(set: AuthorSet, field: []const u8) bool {
if (set.own) |a| if (structHasConstMember(a.raw, field)) return true;
for (set.flat) |a| if (structHasConstMember(a.raw, field)) return true;
return false;
}
/// Bin ONE raw author by the head kind(s) it can author: generic struct, type
/// function, or protocol. Used by lowering's parameterized-head classification.
pub fn classifyHeadKind(raw: RawDeclRef, gs: *bool, tf: *bool, pr: *bool) void {
switch (raw) {
.struct_decl => |sd| if (sd.type_params.len > 0) { gs.* = true; },
.fn_decl => |fd| if (fd.type_params.len > 0) { tf.* = true; },
.const_decl => |cd| switch (cd.value.data) {
.struct_decl => |*sd| if (sd.type_params.len > 0) { gs.* = true; },
.fn_decl => |*fd| if (fd.type_params.len > 0) { tf.* = true; },
else => {},
},
.protocol_decl => { pr.* = true; },
else => {},
}
}
/// True when the bare-type verdict selected a foreign-class author
/// unambiguously. Used by lowering to route to the foreign-class path.
pub fn foreignClassWinsType(set: AuthorSet, verdict: Verdict) bool {
return switch (verdict) {
.own_wins => if (set.own) |a| std.meta.activeTag(a.raw) == .foreign_class_decl else false,
.single => blk: {
var selected: ?RawAuthor = null;
for (set.flat) |a| {
if (!eligibleKind(.bare_type, a.raw, null)) continue;
if (selected != null) break :blk false;
selected = a;
}
const a = selected orelse break :blk false;
break :blk std.meta.activeTag(a.raw) == .foreign_class_decl;
},
.ambiguous, .not_visible, .domain_filtered => false,
};
}
// ── Resolver ─────────────────────────────────────────────────────────────────
/// Read-only facade over the borrowed import facts. `alloc` backs the
/// `AuthorSet.flat` slices the collectors return (the caller owns + frees them).
pub const Resolver = struct {
index: *ProgramIndex,
alloc: std.mem.Allocator,
pub fn init(index: *ProgramIndex, alloc: std.mem.Allocator) Resolver {
return .{ .index = index, .alloc = alloc };
}
/// THE single graph-walk in this file (falsifiable invariant, R5 §1 #1):
/// the own author declared in `from` the flat-import authors reachable
/// over the edge set `vis` chooses. RAW — selectors decide eligibility, not
/// this. `from` is the querying module's source path.
///
/// Edge set by mode: `flat_import_graph` for `user_bare_flat`/`c_import_bare`.
/// `impl_transitive` and `lowering_internal` are not single-hop author walks —
/// reaching them here is a wiring bug, so we trip loudly.
pub fn collectVisibleAuthors(
self: *Resolver,
name: []const u8,
from: []const u8,
vis: VisibilityMode,
) AuthorSet {
const decls = self.index.module_decls orelse return .{ .own = null, .flat = &.{} };
const own: ?RawAuthor = blk: {
const mod = decls.get(from) orelse break :blk null;
const ref = mod.names.get(name) orelse break :blk null;
break :blk .{ .raw = ref, .source = mod.source };
};
const graph = (switch (vis) {
.user_bare_flat, .c_import_bare => self.index.flat_import_graph,
.impl_transitive, .lowering_internal => @panic(
"collectVisibleAuthors: vis mode performs no single-hop author walk",
),
}) orelse return .{ .own = own, .flat = &.{} };
const direct = graph.get(from) orelse return .{ .own = own, .flat = &.{} };
var flat = std.ArrayList(RawAuthor).empty;
var it = direct.iterator(); // ← the one graph iterator (invariant)
while (it.next()) |kv| {
const dep = decls.get(kv.key_ptr.*) orelse continue;
const ref = dep.names.get(name) orelse continue;
const cand = RawAuthor{ .raw = ref, .source = dep.source };
if (sameAuthor(own, cand)) continue;
if (containsAuthor(flat.items, cand)) continue;
flat.append(self.alloc, cand) catch @panic("collectVisibleAuthors: OOM");
}
return .{
.own = own,
.flat = flat.toOwnedSlice(self.alloc) catch @panic("collectVisibleAuthors: OOM"),
};
}
/// Container collector for ONE already-selected namespace target. Iterates
/// the target's `own_decls` and touches NO import graph (R5 §1 #1). A
/// namespace's `own_decls` is name-deduped, so a name has at most one author
/// here — returned as `own`, sourced to the target's module path.
pub fn collectNamespaceAuthors(
self: *Resolver,
target: NamespaceTarget,
name: []const u8,
) AuthorSet {
_ = self;
for (target.own_decls) |decl| {
const dn = decl.data.declName() orelse continue;
if (!std.mem.eql(u8, dn, name)) continue;
const ref = imports.rawDeclRefOf(decl) orelse continue;
return .{ .own = .{ .raw = ref, .source = target.target_module_path }, .flat = &.{} };
}
return .{ .own = null, .flat = &.{} };
}
/// Collect bare-name authors for `name` as seen from `from`, filter by
/// `domain` eligibility, and compute the selection verdict. The caller owns
/// the returned `ResolvedAuthors.set.flat` slice (allocator = `self.alloc`).
///
/// Returns `.domain_filtered` with an empty set when `name` has no author
/// anywhere in the domain (builtin / local variable / undeclared name).
pub fn resolveBare(
self: *Resolver,
name: []const u8,
from: []const u8,
domain: Domain,
) ResolvedAuthors {
const set = self.collectVisibleAuthors(name, from, .user_bare_flat);
const verdict = self.verdictOver(domain, name, set, null);
return .{ .set = set, .verdict = verdict };
}
/// Collect namespace-qualified authors for `target.member` and compute the
/// verdict. Namespace resolution has no own-wins / ambiguity — the target is
/// already selected, so "found" is `.single` and "not found" is
/// `.domain_filtered`. Returns no allocation (`flat = &.{}`).
pub fn resolveQualified(
self: *Resolver,
target: NamespaceTarget,
name: []const u8,
) ResolvedAuthors {
const set = self.collectNamespaceAuthors(target, name);
const verdict: Verdict = if (set.own != null) .single else .domain_filtered;
return .{ .set = set, .verdict = verdict };
}
// ── Private helpers ───────────────────────────────────────────────────────
/// Compute the verdict over a collected author set for `domain`: own-wins
/// when the querying module's own author is eligible; ≥2 distinct eligible
/// flat authors → ambiguous; exactly one → single; none eligible but authored
/// for this domain anywhere (non-flat-visible) → not_visible; otherwise
/// domain_filtered.
fn verdictOver(
self: *Resolver,
domain: Domain,
name: []const u8,
set: AuthorSet,
field: ?[]const u8,
) Verdict {
if (set.own) |o| {
if (eligibleKind(domain, o.raw, field)) return .own_wins;
}
var n: usize = 0;
for (set.flat) |fa| {
if (eligibleKind(domain, fa.raw, field)) {
n += 1;
if (n >= 2) return .ambiguous;
}
}
if (n == 1) return .single;
if (self.authoredAsDomainAnywhere(domain, name, field)) return .not_visible;
return .domain_filtered;
}
/// True iff `name` is authored for `domain` in ANY module's raw facts —
/// the not-visible leak detector. Reached only with zero eligible
/// flat-visible authors, so a hit means the author is reachable only over a
/// namespace edge.
fn authoredAsDomainAnywhere(
self: *Resolver,
domain: Domain,
name: []const u8,
field: ?[]const u8,
) bool {
const decls = self.index.module_decls orelse return false;
var it = decls.valueIterator();
while (it.next()) |m| {
if (m.names.get(name)) |ref| {
if (eligibleKind(domain, ref, field)) return true;
}
}
return false;
}
};
// ── Private identity helpers ─────────────────────────────────────────────────
fn authorNodePtr(ref: RawDeclRef) usize {
return switch (ref) {
inline else => |p| @intFromPtr(p),
};
}
fn sameAuthor(a: ?RawAuthor, b: RawAuthor) bool {
const aa = a orelse return false;
return authorNodePtr(aa.raw) == authorNodePtr(b.raw);
}
fn containsAuthor(list: []const RawAuthor, b: RawAuthor) bool {
for (list) |x| {
if (authorNodePtr(x.raw) == authorNodePtr(b.raw)) return true;
}
return false;
}