feat(stdlib/S2.1b): namespace-qualified + 3 head domains on the owning pass [additive]

On the S2.1a exhaustive traversal, populate four more ResolvedProgram side
tables, still RAW / PARALLEL / UNCONSUMED:

- namespace-qualified references: an `alias.member` field_access whose base
  alias is a NamespaceEdges[ambient_source] target resolves via
  collectNamespaceAuthors into namespace_refs, keyed by the access node.
- the three HEAD domains at parameterized_type_expr heads, binned by the
  resolved author's decl kind: a struct with type params -> generic_struct_heads,
  a fn/const-wrapped fn with type params -> type_fn_heads, a protocol ->
  protocol_heads. RAW: the whole author set is recorded with no winner picked;
  a name authored as >1 head kind lands a distinct entry in every matching table.

Lowering still reads the old selectors and resolved_program has no consumer, so
generated output is byte-identical. ResolvedRef stays RAW (selection is S2.2);
generics stay symbolic. S2.1c (foreign-class / struct-const / UFCS) owns the
remaining three tables.

Extends the population proof: a resolver unit test asserting all four tables are
non-empty + node-keyed with the expected RAW authors.

Gate (all exit 0): zig build; zig build test (All 427 mod + exe + LSP sweep 574);
tests/run_examples.sh (540 passed, byte-identical); tests/resolver-target
(18 xfail, 0 leaked); m3te ios-sim via the main sx binary.
This commit is contained in:
agra
2026-06-09 13:38:59 +03:00
parent 51ab730b74
commit 6b41d113f2
2 changed files with 245 additions and 10 deletions

View File

@@ -288,6 +288,7 @@ test "resolver: visibility edge-walk — own + flat visible; namespaced-only onl
const Resolved = struct {
root: *ast.Node,
decls: imports.ModuleDecls,
ns_edges: imports.NamespaceEdges,
flat_import_graph: Graph,
import_graph: Graph,
};
@@ -329,6 +330,7 @@ fn buildResolved(alloc: std.mem.Allocator, io: std.Io, absdir: []const u8, main_
return .{
.root = root,
.decls = facts.decls,
.ns_edges = facts.ns_edges,
.flat_import_graph = flat_import_graph,
.import_graph = import_graph,
};
@@ -473,7 +475,10 @@ test "resolver: resolve — bare-name domains populated, keyed by node, symbolic
try std.testing.expect(pack_ref == .pack);
try std.testing.expectEqual(@as(?u32, 0), pack_ref.pack.index);
// (5) The seven domains S2.1b/c own stay EMPTY — S2.1a is parallel/unconsumed.
// (5) This fixture exercises NONE of the S2.1b/c domains (no namespaced import,
// no parameterized heads, no foreign/const/UFCS sites) — and the index has
// no `namespace_edges` wired — so all seven stay EMPTY. The dedicated S2.1b
// test below proves the four it owns populate when exercised.
try std.testing.expectEqual(@as(u32, 0), rp.namespace_refs.count());
try std.testing.expectEqual(@as(u32, 0), rp.generic_struct_heads.count());
try std.testing.expectEqual(@as(u32, 0), rp.type_fn_heads.count());
@@ -535,3 +540,117 @@ test "resolver: resolve — generic constraints and named error sets are type re
try std.testing.expect(err_ty.data == .error_type_expr);
try expectTypeRefOwnTag(&rp, err_ty, error_tag);
}
// ── the owning resolution pass — S2.1b namespace-qualified + head domains ──
// S2.1b populates four more domains on the SAME traversal, still RAW /
// PARALLEL / UNCONSUMED. (1) A namespace-qualified `g.helper_fn` resolves via
// collectNamespaceAuthors into namespace_refs, keyed by its field_access node.
// (2..4) Parameterized heads are binned by the resolved author's decl kind: a
// generic struct (`Box(s64)`) → generic_struct_heads, a type-function
// (`Make(s64)`) → type_fn_heads, a parameterized protocol used as a value type
// (`Cmp(s64)`) → protocol_heads — each keyed by its parameterized_type_expr node.
test "resolver: resolve — namespace-qualified + generic-struct/type-fn/protocol heads" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
const io = testIo();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "lib.sx", .data =
\\helper_fn :: () -> s64 { 7 }
\\
});
try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data =
\\g :: #import "lib.sx";
\\Box :: struct($T: Type) { value: T }
\\Make :: ($T: Type) -> Type { return [3]T; }
\\Cmp :: protocol(T: Type) { get :: () -> T; }
\\use_box :: (b: Box(s64)) -> s64 { return 0; }
\\use_make :: (m: Make(s64)) -> s64 { return 0; }
\\use_cmp :: (c: Cmp(s64)) -> s64 { return 0; }
\\read_ns :: () -> s64 { return g.helper_fn(); }
\\main :: () -> s32 { return 0; }
\\
});
var dirbuf: [4096]u8 = undefined;
const absdir = dirbuf[0..try tmp.dir.realPath(io, &dirbuf)];
const main_path = try std.fmt.allocPrint(alloc, "{s}/main.sx", .{absdir});
var prog = try buildResolved(alloc, io, absdir, main_path);
var idx = ProgramIndex.init(alloc);
defer idx.deinit();
idx.module_decls = &prog.decls;
idx.namespace_edges = &prog.ns_edges;
idx.flat_import_graph = &prog.flat_import_graph;
idx.import_graph = &prog.import_graph;
var rp = resolver.resolve(prog.root, &idx, main_path, alloc);
defer rp.deinit();
// (0) All four S2.1b tables are NON-EMPTY.
try std.testing.expect(rp.namespace_refs.count() > 0);
try std.testing.expect(rp.generic_struct_heads.count() > 0);
try std.testing.expect(rp.type_fn_heads.count() > 0);
try std.testing.expect(rp.protocol_heads.count() > 0);
const struct_tag = std.meta.Tag(resolver.RawDeclRef).struct_decl;
const fn_tag = std.meta.Tag(resolver.RawDeclRef).fn_decl;
const protocol_tag = std.meta.Tag(resolver.RawDeclRef).protocol_decl;
// (1) Namespace-qualified: keyed by a `field_access` node naming `helper_fn`,
// resolved RAW to lib.sx's fn author (own of the namespace target).
var saw_ns = false;
var nit = rp.namespace_refs.iterator();
while (nit.next()) |e| {
const k = e.key_ptr.*;
try std.testing.expect(k.data == .field_access); // namespace refs key the access node
if (std.mem.eql(u8, k.data.field_access.field, "helper_fn")) {
try std.testing.expect(e.value_ptr.* == .authors);
try std.testing.expect(e.value_ptr.authors.own != null);
try std.testing.expectEqual(fn_tag, std.meta.activeTag(e.value_ptr.authors.own.?.raw));
saw_ns = true;
}
}
try std.testing.expect(saw_ns);
// (2) Generic-struct head: the `Box(s64)` param type-expr is keyed in
// generic_struct_heads and resolves RAW to the own generic struct.
const use_box = findFn(prog.root, "use_box") orelse return error.MissingFn;
const box_head = use_box.data.fn_decl.params[0].type_expr;
try std.testing.expect(box_head.data == .parameterized_type_expr);
const box_ref = rp.generic_struct_heads.get(box_head) orelse return error.BoxHeadNotKeyed;
try std.testing.expect(box_ref == .authors);
try std.testing.expect(box_ref.authors.own != null);
try std.testing.expectEqual(struct_tag, std.meta.activeTag(box_ref.authors.own.?.raw));
// and it is NOT mis-binned into the other head tables.
try std.testing.expect(rp.type_fn_heads.get(box_head) == null);
try std.testing.expect(rp.protocol_heads.get(box_head) == null);
// (3) Type-function head: `Make(s64)` keyed in type_fn_heads, RAW fn author.
const use_make = findFn(prog.root, "use_make") orelse return error.MissingFn;
const make_head = use_make.data.fn_decl.params[0].type_expr;
try std.testing.expect(make_head.data == .parameterized_type_expr);
const make_ref = rp.type_fn_heads.get(make_head) orelse return error.MakeHeadNotKeyed;
try std.testing.expect(make_ref == .authors);
try std.testing.expect(make_ref.authors.own != null);
try std.testing.expectEqual(fn_tag, std.meta.activeTag(make_ref.authors.own.?.raw));
// (4) Protocol head: the `Cmp(s64)` value-type param keyed in protocol_heads.
const use_cmp = findFn(prog.root, "use_cmp") orelse return error.MissingFn;
const cmp_head = use_cmp.data.fn_decl.params[0].type_expr;
try std.testing.expect(cmp_head.data == .parameterized_type_expr);
const cmp_ref = rp.protocol_heads.get(cmp_head) orelse return error.CmpHeadNotKeyed;
try std.testing.expect(cmp_ref == .authors);
try std.testing.expect(cmp_ref.authors.own != null);
try std.testing.expectEqual(protocol_tag, std.meta.activeTag(cmp_ref.authors.own.?.raw));
// (5) The three S2.1c domains remain UNTOUCHED by S2.1b.
try std.testing.expectEqual(@as(u32, 0), rp.foreign_class_refs.count());
try std.testing.expectEqual(@as(u32, 0), rp.struct_const_refs.count());
try std.testing.expectEqual(@as(u32, 0), rp.ufcs_refs.count());
}

View File

@@ -184,9 +184,11 @@ fn containsAuthor(list: []const RawAuthor, b: RawAuthor) bool {
//
// S2.1a populates the three BARE-NAME domains (type / value-const / callable) via
// `collectVisibleAuthors`, and records generic-param references ($T, ..$Ts,
// $pack[i]) SYMBOLICALLY (template/pack ids, never TypeIds). The remaining seven
// tables are DECLARED but stay empty until S2.1b (namespace-qualified + the three
// head domains) and S2.1c (foreign-class / struct-const / UFCS) populate them.
// $pack[i]) SYMBOLICALLY (template/pack ids, never TypeIds). S2.1b adds the
// namespace-qualified table (`alias.member` resolved via `collectNamespaceAuthors`)
// and the three HEAD tables (generic-struct / type-fn / protocol), binned by the
// resolved author's decl kind at `parameterized_type_expr` heads. The remaining
// three tables (foreign-class / struct-const / UFCS) stay empty until S2.1c.
/// A symbolic id for one enclosing generic TYPE/VALUE param (`$T`, `$N`), assigned
/// by the pass and indexing `ResolvedProgram.template_params`. Process-local.
@@ -375,6 +377,34 @@ fn lookupGeneric(scope: ?*const Frame, name: []const u8) ?GenericMatch {
return null;
}
/// Bin ONE raw author by the head kind it can author: a struct with type params (a
/// generic-struct head), a fn / const-wrapped fn with type params (a type-function
/// head), or a protocol. The `type_params.len > 0` gate is the head test — a
/// non-generic struct or a zero-type-param fn authors no head kind and sets
/// nothing. The `const_decl` arm unwraps a `Name :: struct/fn(...)` const exactly
/// as `structDeclOfRaw` / `fnDeclOfRaw` do.
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 => {},
}
}
/// The single owning traversal. Holds the author collector + the `ResolvedProgram`
/// it populates; threads `Ctx` (ambient source + generic scope) down the tree.
const ResolvePass = struct {
@@ -464,10 +494,17 @@ const ResolvePass = struct {
self.visitAll(c.args, here);
},
.field_access => |*fa| {
// namespace-qualified / struct-const / UFCS receivers are
// S2.1b/c — a BARE identifier receiver is left unclassified here;
// a compound receiver is recursed so its inner refs are collected.
if (fa.object.data != .identifier) self.visit(fa.object, here);
// `alias.member` whose base alias is a namespace import edge of the
// ambient source resolves via `collectNamespaceAuthors` into the
// namespace-qualified table (S2.1b). A non-alias bare receiver
// (struct-const / UFCS / local value) stays unclassified — S2.1c —
// and is not walked as a value ref; a compound receiver is recursed
// so its inner refs are collected.
if (fa.object.data == .identifier) {
self.classifyNamespaceQualified(node, fa.object.data.identifier.name, fa.field, here.source);
} else {
self.visit(fa.object, here);
}
},
.pack_index_type_expr => |*p| self.recordPack(&self.out.type_refs, node, p.pack_name, p.index, here.scope),
.comptime_pack_ref => |*p| self.recordPack(&self.out.value_refs, node, p.pack_name, null, here.scope),
@@ -475,8 +512,10 @@ const ResolvePass = struct {
if (e.name) |name| self.recordAuthors(&self.out.type_refs, node, name, here.source);
},
.parameterized_type_expr => |*p| {
// the head (generic-struct / type-fn / protocol) is S2.1b; the
// type args are ordinary references, collected now.
// the head (`Name(args)`) is binned by its resolved author's decl
// kind into the generic-struct / type-fn / protocol head tables
// (S2.1b); the type args are ordinary references, collected now.
self.classifyHead(node, p.name, p.is_raw, here);
self.visitAll(p.args, here);
},
@@ -690,6 +729,83 @@ const ResolvePass = struct {
self.replaceRef(table, node, .{ .authors = set });
}
/// `alias.member`: when `alias` is a namespace import edge of `from`, resolve
/// `member` against that already-selected target via `collectNamespaceAuthors`
/// (NO graph walk) and record it into the namespace-qualified table. A base that
/// is not a namespace alias (struct-const / UFCS / local value — S2.1c) records
/// nothing here.
fn classifyNamespaceQualified(self: *ResolvePass, node: *const ast.Node, alias: []const u8, member: []const u8, from: []const u8) void {
const edges = self.res.index.namespace_edges orelse return;
const aliases = edges.get(from) orelse return;
const target = aliases.get(alias) orelse return;
const set = self.res.collectNamespaceAuthors(target, member);
if (set.distinctCount() == 0) return;
self.replaceRef(&self.out.namespace_refs, node, .{ .authors = set });
}
/// A parameterized head (`Name(args)`) binned by its resolved author's decl
/// kind: a generic struct (struct with type params) → `generic_struct_heads`;
/// a type-function (fn / const-wrapped fn with type params) → `type_fn_heads`; a
/// protocol → `protocol_heads`. RAW — the whole author set is recorded with no
/// winner picked, so a name authored as more than one head kind across modules
/// lands a distinct entry in every matching table. A head naming a generic param
/// in scope is symbolic (not an author); a name with no user author (builtins
/// like `Vector`, undeclared) or only non-head authors is omitted.
fn classifyHead(self: *ResolvePass, node: *const ast.Node, name: []const u8, is_raw: bool, ctx: Ctx) void {
if (!is_raw and lookupGeneric(ctx.scope, name) != null) return;
const set = self.res.collectVisibleAuthors(name, ctx.source, .user_bare_flat);
if (set.distinctCount() == 0) return;
var gs = false;
var tf = false;
var pr = false;
if (set.own) |a| classifyHeadKind(a.raw, &gs, &tf, &pr);
for (set.flat) |a| classifyHeadKind(a.raw, &gs, &tf, &pr);
var tables: [3]*NodeRefTable = undefined;
var n: usize = 0;
if (gs) {
tables[n] = &self.out.generic_struct_heads;
n += 1;
}
if (tf) {
tables[n] = &self.out.type_fn_heads;
n += 1;
}
if (pr) {
tables[n] = &self.out.protocol_heads;
n += 1;
}
if (n == 0) {
// an author exists but is not a head kind (e.g. a non-generic struct or
// a zero-type-param fn) — own this set's allocation, then drop it.
if (set.flat.len > 0) self.out.alloc.free(set.flat);
return;
}
// each table OWNS its `AuthorSet.flat`; give the first match the collected
// slice and a fresh copy to every subsequent table so `deinit` frees each
// exactly once.
self.replaceRef(tables[0], node, .{ .authors = set });
var i: usize = 1;
while (i < n) : (i += 1) {
self.replaceRef(tables[i], node, .{ .authors = self.dupAuthorSet(set) });
}
}
/// A shallow copy of an `AuthorSet` with its own freshly-allocated `flat` slice
/// (the `RawAuthor` elements are borrowed AST pointers + source strings, so the
/// copy is shallow). Lets one head reference be recorded into several head
/// tables without aliasing the owned slice.
fn dupAuthorSet(self: *ResolvePass, set: AuthorSet) AuthorSet {
return .{
.own = set.own,
.flat = if (set.flat.len > 0)
(self.out.alloc.dupe(RawAuthor, set.flat) catch @panic("resolve: OOM"))
else
&.{},
};
}
fn recordTemplate(self: *ResolvePass, table: *NodeRefTable, node: *const ast.Node, m: GenericMatch) void {
self.replaceRef(table, node, .{ .template = self.internTemplate(m) });
}