feat(stdlib/S1.1): DeclId for every declaration — additive DeclTable [additive]

Build a DeclTable in parallel with the import facts: every RawDeclRef
(source / imported / namespaced / C-imported) gets a stable DeclId carrying
source path, display name, AST node identity, span, and DeclKind. Namespace
targets record their members' DeclIds (NamespaceTarget.member_ids). A generic
struct's template is keyed by DeclId in a parallel struct_template_by_decl
store, written alongside the live name-keyed struct_template_map.

A Debug-only round-trip cross-check (RawDeclRef -> DeclId -> AST node ptr)
asserts the table identifies the same node across the corpus, run from
buildDeclTable and pinned by a unit test.

Additive (S0.1 class: mirror): the old maps stay active and lowering still
consumes them; nothing reads the DeclTable / struct_template_by_decl for
selection yet (the S4 cutover does). Generated IR + output bytes are unchanged
by construction.

Gate over the baseline-green corpus: zig build, zig build test (424/424),
bash tests/run_examples.sh (540 passed) — all exit 0; single-author output
byte-identical (37 .ir snapshots unchanged).
This commit is contained in:
agra
2026-06-09 11:25:04 +03:00
parent 864a14e42b
commit 8058be2538
5 changed files with 348 additions and 1 deletions

View File

@@ -38,6 +38,10 @@ pub const Compilation = struct {
/// Namespace import edges (`importer → alias → NamespaceTarget`), built by
/// `imports.buildImportFacts`. Borrowed by `ProgramIndex.namespace_edges`.
namespace_edges: imports.NamespaceEdges,
/// Stable `DeclId` for every declaration (Fork C S1), built by
/// `imports.buildDeclTable` in parallel with the import facts. Borrowed by
/// `ProgramIndex.decl_table`.
decl_table: imports.DeclTable,
ir_emitter: ?ir.LLVMEmitter = null,
/// Lowered IR module, kept alive past `generateCode` so post-link
/// callbacks can re-enter the interpreter to invoke sx functions
@@ -68,6 +72,7 @@ pub const Compilation = struct {
.module_fns = imports.ModuleFns.init(allocator),
.module_decls = imports.ModuleDecls.init(allocator),
.namespace_edges = imports.NamespaceEdges.init(allocator),
.decl_table = imports.DeclTable.init(allocator),
.target_config = target_config,
.stdlib_paths = stdlib_paths,
};
@@ -143,6 +148,12 @@ pub const Compilation = struct {
self.module_decls = facts.decls;
self.namespace_edges = facts.ns_edges;
// DeclTable (Fork C S1): a stable DeclId for every declaration, built in
// parallel from the SAME modules. Additive — nothing consumes it for
// selection yet, so generated IR + bytes are unchanged. Updates
// `namespace_edges` in place to record each target's member ids.
self.decl_table = try imports.buildDeclTable(self.allocator, self.file_path, mod, &cache, &self.module_decls, &self.namespace_edges);
// Store main file source in import_sources so error reporting can find it
self.import_sources.put(self.file_path, self.source) catch {};
@@ -312,6 +323,7 @@ pub const Compilation = struct {
lowering.program_index.module_fns = &self.module_fns;
lowering.program_index.module_decls = &self.module_decls;
lowering.program_index.namespace_edges = &self.namespace_edges;
lowering.program_index.decl_table = &self.decl_table;
lowering.lowerRoot(root);
if (self.diagnostics.hasErrors()) return error.CompileError;

View File

@@ -503,3 +503,103 @@ test "buildImportFacts: namespace-alias-then-fn same-module collision is diagnos
const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex;
try expectTag(m_idx.names.get("dup") orelse return error.MissingDup, .namespace_decl);
}
// ── DeclTable unit tests (Fork C S1.1) ──
// Every source / imported / namespaced declaration gets a stable DeclId; the
// RawDeclRef → DeclId → AST node round-trip holds; a generic struct is keyable
// by DeclId; and the namespace target records its members' ids. The OLD facts
// (`module_decls` / `ns_edges`) are untouched — the table is built in parallel.
test "buildDeclTable: stable DeclId per decl, round-trip, struct keying, namespace member ids" {
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 :: () -> s64 { 9 }\nBox :: struct($T: Type) { v: T; }\n" });
try tmp.dir.writeFile(io, .{ .sub_path = "geom.sx", .data = "Point :: struct { x: s64 }\n" });
try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "#import \"lib.sx\";\ng :: #import \"geom.sx\";\nmain :: () -> s32 { 0 }\n" });
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});
const lib_path = try std.fmt.allocPrint(alloc, "{s}/lib.sx", .{absdir});
const main_bytes = try std.Io.Dir.readFileAlloc(.cwd(), io, main_path, alloc, .limited(1 << 20));
const main_source = try alloc.dupeZ(u8, main_bytes);
var p = parser.Parser.init(alloc, main_source);
const root = p.parse() catch return error.ParseFailed;
var diags = errors.DiagnosticList.init(alloc, main_source, main_path);
var chain = std.StringHashMap(void).init(alloc);
var cache = imports.ModuleCache.init(alloc);
var import_graph = std.StringHashMap(std.StringHashMap(void)).init(alloc);
var flat_import_graph = std.StringHashMap(std.StringHashMap(void)).init(alloc);
const stdlib_paths = [_][]const u8{};
const mod = try imports.resolveImports(
alloc,
io,
root,
absdir,
main_path,
&chain,
&cache,
null,
&diags,
&stdlib_paths,
&import_graph,
&flat_import_graph,
.{},
);
var facts = try imports.buildImportFacts(alloc, main_path, mod, &cache);
var table = try imports.buildDeclTable(alloc, main_path, mod, &cache, &facts.decls, &facts.ns_edges);
defer table.deinit();
// Every module author resolves to a DeclId that round-trips to the same node
// and carries the matching name + source. (verifyRoundTrip also asserts this
// in Debug; this pins the public lookup API too.)
var mit = facts.decls.iterator();
var seen: usize = 0;
while (mit.next()) |m| {
var nit = m.value_ptr.names.iterator();
while (nit.next()) |kv| {
const ref = kv.value_ptr.*;
const id = table.declIdForRef(ref) orelse return error.MissingDeclId;
const info = table.get(id);
try std.testing.expectEqual(imports.authorNodePtrOf(ref), imports.authorNodePtrOf(info.ref));
try std.testing.expectEqualStrings(kv.key_ptr.*, info.name);
try std.testing.expectEqualStrings(m.value_ptr.source, info.source);
seen += 1;
}
}
try std.testing.expect(seen > 0);
// The generic struct `Box` (authored in lib.sx) is keyable by DeclId via its
// inner *StructDecl, and the id reports DeclKind.@"struct" + name "Box".
const lib_idx = facts.decls.get(lib_path) orelse return error.MissingLibIndex;
const box_ref = lib_idx.names.get("Box") orelse return error.MissingBox;
const box_sd = switch (box_ref) {
.struct_decl => |sd| sd,
.const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else return error.BoxNotStruct,
else => return error.BoxNotStruct,
};
const box_id = table.declIdForStructDecl(box_sd) orelse return error.BoxNoDeclId;
try std.testing.expectEqual(imports.DeclKind.@"struct", table.get(box_id).kind);
try std.testing.expectEqualStrings("Box", table.get(box_id).name);
// The namespaced `geom.sx` target records its members' DeclIds (here: Point),
// each round-tripping to a DeclInfo named "Point".
const aliases = facts.ns_edges.get(main_path) orelse return error.MissingNsEdges;
const target = aliases.get("g") orelse return error.MissingAlias;
try std.testing.expect(target.member_ids.len >= 1);
var found_point = false;
for (target.member_ids) |id| {
if (std.mem.eql(u8, table.get(id).name, "Point")) found_point = true;
}
try std.testing.expect(found_point);
}

View File

@@ -537,6 +537,10 @@ pub const NamespaceTarget = struct {
target_module_path: []const u8,
own_decls: []const *Node,
is_pub: bool = false,
/// The `DeclId` of each member in `own_decls`, in slice order. Filled by
/// `buildDeclTable` (empty until then). Lets a member be addressed by stable
/// id without re-deriving it from the node pointer.
member_ids: []const DeclId = &.{},
};
/// `importer_source → alias → NamespaceTarget`.
@@ -615,6 +619,216 @@ pub fn buildImportFacts(
return .{ .decls = decls, .ns_edges = ns_edges };
}
// ── DeclTable: a stable DeclId for every declaration (Fork C S1, additive) ──
//
// `buildDeclTable` lifts every `RawDeclRef` the import facts hold into a stable
// `DeclId` carrying source + name + AST node identity + span + `DeclKind`. It is
// built in PARALLEL with the old maps and nothing in lowering consumes it for
// selection yet (S4 makes it the fact-store key), so generated IR + bytes are
// unchanged by construction.
/// The taxonomy of a declaration, mirroring the `RawDeclRef` variants so a
/// `DeclTable` row carries its kind without re-switching on the AST node.
pub const DeclKind = enum {
function,
constant,
@"struct",
@"enum",
@"union",
error_set,
protocol,
foreign_class,
namespace,
};
fn declKindOf(ref: RawDeclRef) DeclKind {
return switch (ref) {
.fn_decl => .function,
.const_decl => .constant,
.struct_decl => .@"struct",
.enum_decl => .@"enum",
.union_decl => .@"union",
.error_set_decl => .error_set,
.protocol_decl => .protocol,
.foreign_class_decl => .foreign_class,
.namespace_decl => .namespace,
};
}
/// The AST node identity a `RawDeclRef` wraps — the inner decl pointer every
/// variant holds (the same identity `resolver.zig` selects authors by). This is
/// the key the `DeclTable` indexes and round-trips on.
pub fn authorNodePtrOf(ref: RawDeclRef) usize {
return switch (ref) {
inline else => |p| @intFromPtr(p),
};
}
/// The `*const ast.StructDecl` a top-level decl node carries, or null when it is
/// not a struct — a bare `struct_decl` or a `const_decl` whose value is one,
/// both unwrapping to the same inner decl (mirrors lower's `structDeclOfRaw`).
fn structDeclPtrOf(decl: *const Node) ?*const ast.StructDecl {
return switch (decl.data) {
.struct_decl => &decl.data.struct_decl,
.const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else null,
else => null,
};
}
/// A stable identifier for one declaration, assigned by `DeclTable` in module-
/// walk order. Process-local: it indexes the table's `entries` (S5 stabilizes it
/// to `(source, index)` for the LSP, per the deep-dive's R5).
pub const DeclId = enum(u32) { _ };
/// One `DeclTable` row: a `RawDeclRef` lifted to a stable `DeclId`, with its
/// authoring source path, display name, AST span, and `DeclKind`. `ref` is the
/// same raw author the import facts hold (its AST node identity is `id`'s key).
pub const DeclInfo = struct {
id: DeclId,
source: []const u8,
name: []const u8,
ref: RawDeclRef,
span: ast.Span,
kind: DeclKind,
};
/// Stable `DeclId` for every source / namespaced / imported / C-imported decl.
/// `entries` is indexed by `DeclId`; `by_node` reverse-maps the AST node
/// identity (`authorNodePtrOf`) to its id; `by_struct` maps a generic struct's
/// inner `*StructDecl` to its id (so a template registered during lowering can
/// be keyed by `DeclId`). Borrowed by `ProgramIndex.decl_table`.
pub const DeclTable = struct {
alloc: std.mem.Allocator,
entries: std.ArrayList(DeclInfo) = .empty,
by_node: std.AutoHashMap(usize, DeclId),
by_struct: std.AutoHashMap(usize, DeclId),
pub fn init(alloc: std.mem.Allocator) DeclTable {
return .{
.alloc = alloc,
.by_node = std.AutoHashMap(usize, DeclId).init(alloc),
.by_struct = std.AutoHashMap(usize, DeclId).init(alloc),
};
}
pub fn deinit(self: *DeclTable) void {
self.entries.deinit(self.alloc);
self.by_node.deinit();
self.by_struct.deinit();
}
pub fn get(self: *const DeclTable, id: DeclId) DeclInfo {
return self.entries.items[@intFromEnum(id)];
}
/// The `DeclId` for an AST node (by its `RawDeclRef` identity), or null when
/// the node never entered the table.
pub fn declIdForRef(self: *const DeclTable, ref: RawDeclRef) ?DeclId {
return self.by_node.get(authorNodePtrOf(ref));
}
/// The `DeclId` for a generic struct template's inner `*StructDecl`, or null.
pub fn declIdForStructDecl(self: *const DeclTable, sd: *const ast.StructDecl) ?DeclId {
return self.by_struct.get(@intFromPtr(sd));
}
/// Intern one top-level decl node, returning its (possibly pre-existing)
/// `DeclId`. First-wins / diamond dedup by node identity, matching how the
/// scalar import facts dedup. The caller guarantees `rawDeclRefOf(decl)` is
/// non-null (so `declName` is too).
fn intern(self: *DeclTable, source: []const u8, decl: *const Node) !DeclId {
const ref = rawDeclRefOf(decl).?;
const key = authorNodePtrOf(ref);
if (self.by_node.get(key)) |existing| return existing;
const id: DeclId = @enumFromInt(@as(u32, @intCast(self.entries.items.len)));
try self.entries.append(self.alloc, .{
.id = id,
.source = source,
.name = decl.data.declName().?,
.ref = ref,
.span = decl.span,
.kind = declKindOf(ref),
});
try self.by_node.put(key, id);
if (structDeclPtrOf(decl)) |sd| try self.by_struct.put(@intFromPtr(sd), id);
return id;
}
fn internModule(self: *DeclTable, source: []const u8, own_decls: []const *Node) !void {
for (own_decls) |decl| {
if (rawDeclRefOf(decl) == null) continue;
_ = try self.intern(source, decl);
}
}
/// Debug cross-check (S1.1 acceptance): every `RawDeclRef` the import facts
/// hold round-trips `RawDeclRef → DeclId → AST node ptr` back to the same
/// node, with matching name. Asserts; call only under `builtin.mode == .Debug`.
pub fn verifyRoundTrip(self: *const DeclTable, decls: *const ModuleDecls, ns_edges: *const NamespaceEdges) void {
var mit = decls.iterator();
while (mit.next()) |m| {
var nit = m.value_ptr.names.iterator();
while (nit.next()) |kv| {
const ref = kv.value_ptr.*;
const id = self.declIdForRef(ref) orelse @panic("DeclTable round-trip: module ref has no DeclId");
const info = self.get(id);
std.debug.assert(authorNodePtrOf(info.ref) == authorNodePtrOf(ref));
std.debug.assert(std.mem.eql(u8, info.name, kv.key_ptr.*));
}
}
var nsit = ns_edges.iterator();
while (nsit.next()) |imp| {
var ait = imp.value_ptr.valueIterator();
while (ait.next()) |target| {
for (target.own_decls) |decl| {
const ref = rawDeclRefOf(decl) orelse continue;
const id = self.declIdForRef(ref) orelse @panic("DeclTable round-trip: ns member has no DeclId");
std.debug.assert(authorNodePtrOf(self.get(id).ref) == authorNodePtrOf(ref));
}
}
}
}
};
/// Build the `DeclTable` from the resolved program + the import facts: every
/// module author (main + cache) interned first, then every namespace member
/// (reusing the module author's id when it is also a module decl, minting a new
/// id for a synthetic C-import member). `ns_edges` is updated in place so each
/// `NamespaceTarget.member_ids` lists its members' ids. Built from the SAME
/// modules `buildImportFacts` walks; no IR lowering required.
pub fn buildDeclTable(
allocator: std.mem.Allocator,
main_path: []const u8,
main_mod: ResolvedModule,
cache: *const ModuleCache,
decls: *const ModuleDecls,
ns_edges: *NamespaceEdges,
) !DeclTable {
var table = DeclTable.init(allocator);
try table.internModule(main_path, main_mod.own_decls);
var it = cache.iterator();
while (it.next()) |entry| {
try table.internModule(entry.key_ptr.*, entry.value_ptr.own_decls);
}
var nsit = ns_edges.iterator();
while (nsit.next()) |imp| {
var ait = imp.value_ptr.valueIterator();
while (ait.next()) |target| {
var ids = std.ArrayList(DeclId).empty;
for (target.own_decls) |decl| {
if (rawDeclRefOf(decl) == null) continue;
const id = try table.intern(target.target_module_path, decl);
try ids.append(allocator, id);
}
target.member_ids = try ids.toOwnedSlice(allocator);
}
}
if (builtin.mode == .Debug) table.verifyRoundTrip(decls, ns_edges);
return table;
}
/// Surface a same-module duplicate top-level declaration as a hard error at an
/// explicit name + span. `addOwnDecl` / `addNamespace` return `false` when the
/// name is already in this module's scope and drop the second author; without

View File

@@ -15487,6 +15487,17 @@ pub const Lowering = struct {
const tmpl = self.buildGenericStructTemplate(sd, source_file) orelse return;
self.program_index.struct_template_map.put(tmpl.name, tmpl) catch {};
// S1.1 (additive): key the template by DeclId in parallel. Nothing
// reads this for selection yet; `struct_template_map` stays the live
// consumer. A template whose decl is not in the table (comptime /
// block-local registration with facts unwired) keeps only the
// name-keyed entry.
if (self.program_index.decl_table) |dt| {
if (dt.declIdForStructDecl(sd)) |id| {
self.program_index.struct_template_by_decl.put(id, tmpl) catch {};
}
}
// Register methods under "TemplateName.method" in fn_ast_map
for (sd.methods) |method_node| {
if (method_node.data == .fn_decl) {

View File

@@ -628,6 +628,10 @@ pub const ProgramIndex = struct {
/// `imports.buildImportFacts`, carrying each alias's resolved target path.
/// Borrowed view.
namespace_edges: ?*imports.NamespaceEdges = null,
/// Stable `DeclId` for every declaration, built by `imports.buildDeclTable`
/// in parallel with the import facts. Borrowed view; nothing in lowering
/// consumes it for selection yet (additive — S4 makes it the fact-store key).
decl_table: ?*imports.DeclTable = null,
// ── Declaration maps ──
/// Function name → AST decl.
@@ -649,6 +653,11 @@ pub const ProgramIndex = struct {
type_alias_map: std.StringHashMap(TypeId) = std.StringHashMap(TypeId).init(std.heap.page_allocator),
/// Generic struct name → template.
struct_template_map: std.StringHashMap(StructTemplate) = std.StringHashMap(StructTemplate).init(std.heap.page_allocator),
/// `DeclId` → generic struct template — the DeclId-keyed analogue of
/// `struct_template_map`, built in parallel during `registerStructDecl`.
/// Nothing reads it for selection yet; `struct_template_map` stays the live
/// consumer until the S4 cutover.
struct_template_by_decl: std.AutoHashMap(imports.DeclId, StructTemplate) = std.AutoHashMap(imports.DeclId, StructTemplate).init(std.heap.page_allocator),
/// Protocol name → protocol info.
protocol_decl_map: std.StringHashMap(ProtocolDeclInfo) = std.StringHashMap(ProtocolDeclInfo).init(std.heap.page_allocator),
/// Protocol name → AST node.
@@ -687,7 +696,7 @@ pub const ProgramIndex = struct {
pub fn deinit(self: *ProgramIndex) void {
// Owned maps only — module_scopes / import_graph / flat_import_graph /
// module_fns / module_decls / namespace_edges are borrowed.
// module_fns / module_decls / namespace_edges / decl_table are borrowed.
self.import_flags.deinit();
self.fn_ast_map.deinit();
self.qualified_fn_source.deinit();
@@ -695,6 +704,7 @@ pub const ProgramIndex = struct {
self.global_names.deinit();
self.type_alias_map.deinit();
self.struct_template_map.deinit();
self.struct_template_by_decl.deinit();
self.protocol_decl_map.deinit();
self.protocol_ast_map.deinit();
self.module_const_map.deinit();