From b5ec121645d8f9fd4796411fbf5f1929ce1d7684 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 23:34:32 +0300 Subject: [PATCH] feat(imports): buildImportFacts raw-fact store (ModuleRawDeclIndex + NamespaceEdges) [stdlib A] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of the unified resolver (R5 locked design). Additive infrastructure with NO behavior change — builds the import-side raw-fact store; nothing consumes it yet. - imports.zig: add RawDeclRef / RawAuthor / ModuleRawDeclIndex / ModuleDecls / NamespaceTarget / NamespaceEdges, plus buildImportFacts (mirrors buildModuleFns) producing a scalar per-module name→RawDeclRef index + the namespace edges. Callable without IR lowering (LSP reuses it later). - ast.zig: NamespaceDecl gains target_module_path, captured at resolution time (the resolved_path otherwise lost on the node) so the namespace edge records the alias target. - imports.zig: same-module duplicate top-level name is now DIAGNOSED ("duplicate top-level declaration 'X'") where addOwnDecl would silently drop the second author — replaces the discarded `_ =` at the three call sites. - program_index.zig: borrowed views module_decls / namespace_edges (like module_fns); deinit does not free them. - core.zig: build the facts alongside buildModuleFns and point the borrowed views at them. - imports.test.zig: index unit tests (flat / directory / namespaced file / namespaced directory / C-import namespace / same-name fn / same-name struct / value-vs-type same spelling / raw const_decl) + the duplicate-name diagnostic regression (fails pre-fix, passes after). Gate (worktree): zig build, zig build test (incl. LSP corpus sweep), and run_examples (471, byte-identical) all green; m3te ios-sim build exits 0. --- src/ast.zig | 6 + src/core.zig | 19 +++ src/imports.test.zig | 263 +++++++++++++++++++++++++++++++++++++++ src/imports.zig | 149 +++++++++++++++++++++- src/ir/program_index.zig | 10 +- 5 files changed, 443 insertions(+), 4 deletions(-) diff --git a/src/ast.zig b/src/ast.zig index a0733a4..9122a10 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -680,6 +680,12 @@ pub const NamespaceDecl = struct { /// (`ns.fn`) so `pkg.fn(...)` resolves to a unique FuncId distinct from a /// same-named function in another module (issue 0100). own_decls: []const *Node = &.{}, + /// The resolved path of the module this alias targets — the importing file's + /// own path for a `#import c` namespace (its members are synthesized there). + /// Captured at import-resolution time (the `resolved_path` that is otherwise + /// not retained on the node) so `buildImportFacts` can record the namespace + /// edge without re-walking the import graph. + target_module_path: []const u8, /// True when the namespace NAME was a backtick raw identifier — exempt /// from the reserved-type-name decl check (issue 0089). is_raw: bool = false, diff --git a/src/core.zig b/src/core.zig index 9cc4229..b92fb5b 100644 --- a/src/core.zig +++ b/src/core.zig @@ -32,6 +32,12 @@ pub const Compilation = struct { /// Per-module authored-function index (`path → name → *const FnDecl`). /// Borrowed by `ProgramIndex.module_fns`. module_fns: imports.ModuleFns, + /// Per-module scalar raw-decl index (`path → name → RawDeclRef`), built by + /// `imports.buildImportFacts`. Borrowed by `ProgramIndex.module_decls`. + module_decls: imports.ModuleDecls, + /// Namespace import edges (`importer → alias → NamespaceTarget`), built by + /// `imports.buildImportFacts`. Borrowed by `ProgramIndex.namespace_edges`. + namespace_edges: imports.NamespaceEdges, 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 @@ -60,6 +66,8 @@ pub const Compilation = struct { .import_graph = std.StringHashMap(std.StringHashMap(void)).init(allocator), .flat_import_graph = std.StringHashMap(std.StringHashMap(void)).init(allocator), .module_fns = imports.ModuleFns.init(allocator), + .module_decls = imports.ModuleDecls.init(allocator), + .namespace_edges = imports.NamespaceEdges.init(allocator), .target_config = target_config, .stdlib_paths = stdlib_paths, }; @@ -125,6 +133,15 @@ pub const Compilation = struct { // cross-module authors stay distinct under their own paths. imports.buildModuleFns(self.allocator, self.file_path, mod, &cache, &self.module_fns) catch {}; + // Raw import facts (the unified-resolver store): scalar per-module + // raw-decl index + namespace edges, built from the SAME modules. Nothing + // consumes these yet — they are borrowed by `ProgramIndex` for later + // phases (and the LSP). Built without IR lowering. + if (imports.buildImportFacts(self.allocator, self.file_path, mod, &cache)) |facts| { + self.module_decls = facts.decls; + self.namespace_edges = facts.ns_edges; + } else |_| {} + // Store main file source in import_sources so error reporting can find it self.import_sources.put(self.file_path, self.source) catch {}; @@ -292,6 +309,8 @@ pub const Compilation = struct { lowering.program_index.import_graph = &self.import_graph; lowering.program_index.flat_import_graph = &self.flat_import_graph; 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.lowerRoot(root); if (self.diagnostics.hasErrors()) return error.CompileError; diff --git a/src/imports.test.zig b/src/imports.test.zig index 73a34a2..662e623 100644 --- a/src/imports.test.zig +++ b/src/imports.test.zig @@ -4,6 +4,7 @@ const std = @import("std"); const ast = @import("ast.zig"); const parser = @import("parser.zig"); const imports = @import("imports.zig"); +const errors = @import("errors.zig"); var g_test_threaded: ?std.Io.Threaded = null; fn testIo() std.Io { @@ -13,6 +14,61 @@ fn testIo() std.Io { return g_test_threaded.?.io(); } +// ── buildImportFacts unit tests (Phase A: import-side raw facts) ── + +const Facts = struct { + decls: imports.ModuleDecls, + ns_edges: imports.NamespaceEdges, + diags: errors.DiagnosticList, +}; + +/// Parse `main_path`, resolve its imports, then build the raw import facts — +/// the exact path `core.zig` drives. `alloc` must be an arena that outlives the +/// returned views (they point into AST + cache memory it owns). +fn buildFacts(alloc: std.mem.Allocator, io: std.Io, absdir: []const u8, main_path: []const u8) !Facts { + 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, + .{}, + ); + + const facts = try imports.buildImportFacts(alloc, main_path, mod, &cache); + return .{ .decls = facts.decls, .ns_edges = facts.ns_edges, .diags = diags }; +} + +fn expectTag(ref: imports.RawDeclRef, expected: std.meta.Tag(imports.RawDeclRef)) !void { + try std.testing.expectEqual(expected, std.meta.activeTag(ref)); +} + +fn hasErr(diags: *const errors.DiagnosticList, needle: []const u8) bool { + for (diags.items.items) |d| { + if (d.level == .err and std.mem.indexOf(u8, d.message, needle) != null) return true; + } + return false; +} + // Two flat-imported modules each author `greet`; a third is namespaced. The // step retains BOTH `greet` authors under their own paths in `module_fns` and // records the namespaced import in `import_graph` but NOT in `flat_import_graph` @@ -186,3 +242,210 @@ test "imports: mixed non-fn/fn same-name collision stays first-wins in merged sc try std.testing.expectEqual(@as(usize, 1), widget_count); try std.testing.expect(merged_is_struct); } + +// Flat imports: each module's authored decls land in ITS OWN scalar index keyed +// by path. Two modules authoring the same `fn`, the same `struct`, and a +// value-vs-type same spelling are ALL retained per-source — no cross-module +// first-wins at the index level. A `const_decl` is stored raw (`.const_decl`), +// not pre-classified into value/fn. +test "buildImportFacts: flat imports keep same-name fn/struct + value-vs-type per source; const stays raw" { + 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(); + + // a.sx: dup() fn, Box struct, Shape as a VALUE const. + try tmp.dir.writeFile(io, .{ .sub_path = "a.sx", .data = "dup :: () -> s64 { 1 }\nBox :: struct { x: s64 }\nShape :: 7;\n" }); + // b.sx: dup() fn, Box struct, Shape as a TYPE (same spelling as a.sx's value). + try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "dup :: () -> s64 { 2 }\nBox :: struct { y: s64 }\nShape :: struct { z: s64 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "#import \"a.sx\";\n#import \"b.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 a_path = try std.fmt.allocPrint(alloc, "{s}/a.sx", .{absdir}); + const b_path = try std.fmt.allocPrint(alloc, "{s}/b.sx", .{absdir}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + const a_idx = facts.decls.get(a_path) orelse return error.MissingAIndex; + const b_idx = facts.decls.get(b_path) orelse return error.MissingBIndex; + const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex; + + // The index records its own source path. + try std.testing.expectEqualStrings(a_path, a_idx.source); + + // main authors `main` as a fn. + try expectTag(m_idx.names.get("main") orelse return error.MissingMain, .fn_decl); + + // Same-name fn retained per source — two DISTINCT FnDecls. + const a_dup = a_idx.names.get("dup") orelse return error.MissingADup; + const b_dup = b_idx.names.get("dup") orelse return error.MissingBDup; + try expectTag(a_dup, .fn_decl); + try expectTag(b_dup, .fn_decl); + try std.testing.expect(a_dup.fn_decl != b_dup.fn_decl); + + // Same-name struct retained per source — two DISTINCT StructDecls. + const a_box = a_idx.names.get("Box") orelse return error.MissingABox; + const b_box = b_idx.names.get("Box") orelse return error.MissingBBox; + try expectTag(a_box, .struct_decl); + try expectTag(b_box, .struct_decl); + try std.testing.expect(a_box.struct_decl != b_box.struct_decl); + + // Value-vs-type same spelling across modules: a.sx's `Shape` is a raw const + // (NOT pre-classified), b.sx's `Shape` is a struct. Both coexist by source. + try expectTag(a_idx.names.get("Shape") orelse return error.MissingAShape, .const_decl); + try expectTag(b_idx.names.get("Shape") orelse return error.MissingBShape, .struct_decl); + + // No spurious diagnostics — these are distinct files, not same-module dups. + try std.testing.expect(!hasErr(&facts.diags, "duplicate top-level")); +} + +// Directory import: the combined module (keyed by the directory path) carries +// the UNION of every file's authored decls in its scalar index. +test "buildImportFacts: directory import unions member-file decls under the dir path" { + 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.createDirPath(io, "lib"); + try tmp.dir.writeFile(io, .{ .sub_path = "lib/one.sx", .data = "from_one :: () -> s64 { 1 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "lib/two.sx", .data = "Two :: struct { v: s64 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "#import \"lib\";\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", .{absdir}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + const lib_idx = facts.decls.get(lib_path) orelse return error.MissingLibIndex; + try expectTag(lib_idx.names.get("from_one") orelse return error.MissingFromOne, .fn_decl); + try expectTag(lib_idx.names.get("Two") orelse return error.MissingTwo, .struct_decl); +} + +// Namespaced file import (`g :: #import "point.sx"`): recorded as a namespace +// edge whose `target_module_path` is the aliased file (the fact lost today), +// AND as a `.namespace_decl` in the importer's scalar index. +test "buildImportFacts: namespaced file import captures target_module_path" { + 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 = "point.sx", .data = "Point :: struct { x: s64 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "g :: #import \"point.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 point_path = try std.fmt.allocPrint(alloc, "{s}/point.sx", .{absdir}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + const main_edges = facts.ns_edges.get(main_path) orelse return error.MissingMainEdges; + const g = main_edges.get("g") orelse return error.MissingGEdge; + try std.testing.expectEqualStrings("g", g.alias); + try std.testing.expectEqualStrings(main_path, g.importer_source); + try std.testing.expectEqualStrings(point_path, g.target_module_path); + try std.testing.expect(g.own_decls.len >= 1); + try std.testing.expect(!g.is_pub); + + // The alias is also a `.namespace_decl` in the importer's scalar index. + const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex; + try expectTag(m_idx.names.get("g") orelse return error.MissingGRef, .namespace_decl); +} + +// Namespaced directory import: same edge capture, target is the directory path. +test "buildImportFacts: namespaced directory import captures dir path as target" { + 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.createDirPath(io, "pkg"); + try tmp.dir.writeFile(io, .{ .sub_path = "pkg/m.sx", .data = "helper :: () -> s64 { 9 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "pkg :: #import \"pkg\";\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 pkg_path = try std.fmt.allocPrint(alloc, "{s}/pkg", .{absdir}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + const main_edges = facts.ns_edges.get(main_path) orelse return error.MissingMainEdges; + const pkg = main_edges.get("pkg") orelse return error.MissingPkgEdge; + try std.testing.expectEqualStrings(pkg_path, pkg.target_module_path); +} + +// C-import namespace (`c :: #import c { #include ... }`): recorded as a namespace +// edge. With no separate sx module, the target is the importing file itself. +test "buildImportFacts: c-import namespace recorded as an edge" { + 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 = "ch.h", .data = "int cm_add(int a, int b);\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "cmod :: #import c {\n #include \"ch.h\";\n};\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}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + const main_edges = facts.ns_edges.get(main_path) orelse return error.MissingMainEdges; + const cmod = main_edges.get("cmod") orelse return error.MissingCmodEdge; + try std.testing.expectEqualStrings("cmod", cmod.alias); + try std.testing.expectEqualStrings(main_path, cmod.target_module_path); + + const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex; + try expectTag(m_idx.names.get("cmod") orelse return error.MissingCmodRef, .namespace_decl); +} + +// Duplicate-name invariant (R5 #2): a same-module authored duplicate top-level +// name is DIAGNOSED, not silently dropped. The parser/decl-checker does not +// catch this today (verified: `sx run` of a same-file double decl exits 0 with +// no diagnostic), so `resolveImports` surfaces it where `addOwnDecl` refuses the +// second author. This test FAILS on the pre-diagnostic code and PASSES after. +test "buildImportFacts: same-module duplicate top-level name is diagnosed" { + 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 = "main.sx", .data = "foo :: () -> s64 { 1 }\nfoo :: () -> s64 { 2 }\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}); + + var facts = try buildFacts(alloc, io, absdir, main_path); + + try std.testing.expect(hasErr(&facts.diags, "duplicate top-level declaration 'foo'")); + // The surviving author is still in the scalar index (first-wins, not lost). + const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex; + try expectTag(m_idx.names.get("foo") orelse return error.MissingFoo, .fn_decl); +} diff --git a/src/imports.zig b/src/imports.zig index a2943be..76401ee 100644 --- a/src/imports.zig +++ b/src/imports.zig @@ -378,6 +378,9 @@ pub const ResolvedModule = struct { // to (issue 0100). `decls` stays the full transitive list so // the lowering pass can still resolve transitive callees. .own_decls = other.own_decls, + // The aliased module's resolved path (== the `resolved_path` + // computed for this import). Retained for `buildImportFacts`. + .target_module_path = other.path, // Carry the backtick raw escape from the `name :: #import …` // form so a reserved-name namespace is exempt from the decl // check, symmetric to every other decl site (issue 0089). @@ -441,6 +444,143 @@ pub fn buildModuleFns(allocator: std.mem.Allocator, main_path: []const u8, main_ } } +// ── Raw import facts (the unified-resolver store; nothing consumes it yet) ── +// +// `buildImportFacts` produces two source-keyed views over the resolved program, +// callable WITHOUT IR lowering (the LSP reuses it later): a scalar per-module +// raw-decl index (`name → RawDeclRef`) and the namespace import edges +// (`importer → alias → NamespaceTarget`). Both are built from each module's +// `own_decls` — exactly the same modules `buildModuleFns` walks. + +/// A named top-level declaration the resolver may select, kept as the raw AST +/// node pointer (NOT pre-classified — a `const_decl` whose value is a function +/// stays a `.const_decl`; classification is a later phase's job). `impl_block` +/// is deliberately absent: it has no `declName` and is deduped by node identity +/// (`mergeFlat`), so it never enters the scalar index. +pub const RawDeclRef = union(enum) { + fn_decl: *const ast.FnDecl, + const_decl: *const ast.ConstDecl, + struct_decl: *const ast.StructDecl, + enum_decl: *const ast.EnumDecl, + union_decl: *const ast.UnionDecl, + error_set_decl: *const ast.ErrorSetDecl, + protocol_decl: *const ast.ProtocolDecl, + foreign_class_decl: *const ast.ForeignClassDecl, + namespace_decl: *const ast.NamespaceDecl, +}; + +/// A raw declaration paired with the source file that authors it. +pub const RawAuthor = struct { raw: RawDeclRef, source: []const u8 }; + +/// One module's scalar raw-decl index: `name → ONE RawDeclRef`. Scalar because +/// `addOwnDecl` refuses a same-module same-name second author (returns false), +/// so a module's `own_decls` carries at most one author per name. Cross-module +/// multiplicity lives one level up, keyed by path in `ModuleDecls`. +pub const ModuleRawDeclIndex = struct { source: []const u8, names: std.StringHashMap(RawDeclRef) }; + +/// `path → ModuleRawDeclIndex`. Two modules each authoring `f` are retained +/// under their own paths — the cross-module same-name authors the unified +/// resolver's collector will surface. +pub const ModuleDecls = std.StringHashMap(ModuleRawDeclIndex); + +/// One namespace import edge: `alias :: #import "…"` (or `alias :: #import c …`). +/// `target_module_path` is captured at resolution time (otherwise lost — it is +/// not derivable from the namespace node alone). `is_pub` stays false until the +/// `pub`-import phase lands the front-end form. +pub const NamespaceTarget = struct { + alias: []const u8, + importer_source: []const u8, + target_module_path: []const u8, + own_decls: []const *Node, + is_pub: bool = false, +}; + +/// `importer_source → alias → NamespaceTarget`. +pub const NamespaceEdges = std.StringHashMap(std.StringHashMap(NamespaceTarget)); + +/// The `RawDeclRef` a top-level node carries, or null when the node is not a +/// selectable named declaration (e.g. `impl_block`, `var_decl`, `ufcs_alias`, +/// a flat `c_import_decl`). +fn rawDeclRefOf(decl: *const Node) ?RawDeclRef { + return switch (decl.data) { + .fn_decl => |*d| .{ .fn_decl = d }, + .const_decl => |*d| .{ .const_decl = d }, + .struct_decl => |*d| .{ .struct_decl = d }, + .enum_decl => |*d| .{ .enum_decl = d }, + .union_decl => |*d| .{ .union_decl = d }, + .error_set_decl => |*d| .{ .error_set_decl = d }, + .protocol_decl => |*d| .{ .protocol_decl = d }, + .foreign_class_decl => |*d| .{ .foreign_class_decl = d }, + .namespace_decl => |*d| .{ .namespace_decl = d }, + else => null, + }; +} + +/// Index one module's authored decls (`own_decls`) into `decls[path]` and record +/// any namespace aliases into `ns_edges[path]`. First-wins WITHIN a module +/// mirrors `indexModuleFns`; `own_decls` is already name-deduped by `addOwnDecl`, +/// so the first-wins guard never actually fires here. +fn indexModuleDecls( + allocator: std.mem.Allocator, + decls: *ModuleDecls, + ns_edges: *NamespaceEdges, + path: []const u8, + own_decls: []const *Node, +) !void { + const gop = try decls.getOrPut(path); + if (!gop.found_existing) gop.value_ptr.* = .{ .source = path, .names = std.StringHashMap(RawDeclRef).init(allocator) }; + const index = gop.value_ptr; + for (own_decls) |decl| { + const ref = rawDeclRefOf(decl) orelse continue; + const name = decl.data.declName() orelse continue; + const name_gop = try index.names.getOrPut(name); + if (!name_gop.found_existing) name_gop.value_ptr.* = ref; + + if (decl.data == .namespace_decl) { + const ns = &decl.data.namespace_decl; + const edge_gop = try ns_edges.getOrPut(path); + if (!edge_gop.found_existing) edge_gop.value_ptr.* = std.StringHashMap(NamespaceTarget).init(allocator); + const tgt_gop = try edge_gop.value_ptr.getOrPut(ns.name); + if (!tgt_gop.found_existing) tgt_gop.value_ptr.* = .{ + .alias = ns.name, + .importer_source = path, + .target_module_path = ns.target_module_path, + .own_decls = ns.own_decls, + }; + } + } +} + +/// Build the raw import facts from a resolved program: the main module (keyed by +/// `main_path`) plus every cached module (keyed by its own path). The same module +/// set `buildModuleFns` walks. No IR lowering required. +pub fn buildImportFacts( + allocator: std.mem.Allocator, + main_path: []const u8, + main_mod: ResolvedModule, + cache: *const ModuleCache, +) !struct { decls: ModuleDecls, ns_edges: NamespaceEdges } { + var decls = ModuleDecls.init(allocator); + var ns_edges = NamespaceEdges.init(allocator); + try indexModuleDecls(allocator, &decls, &ns_edges, main_path, main_mod.own_decls); + var it = cache.iterator(); + while (it.next()) |entry| { + try indexModuleDecls(allocator, &decls, &ns_edges, entry.key_ptr.*, entry.value_ptr.own_decls); + } + return .{ .decls = decls, .ns_edges = ns_edges }; +} + +/// Surface a same-module duplicate top-level declaration as a hard error. +/// `addOwnDecl` returns `false` when the name is already in this module's scope +/// and drops the second author; without this the drop is silent, and the scalar +/// `ModuleRawDeclIndex` would lose an authored name with no diagnostic. +fn reportDuplicateDecl(diagnostics: ?*errors.DiagnosticList, added: bool, decl: *const Node) void { + if (added) return; + const diags = diagnostics orelse return; + const name = decl.data.declName() orelse return; + diags.addFmt(.err, decl.span, "duplicate top-level declaration '{s}'", .{name}); +} + pub fn resolveImports( allocator: std.mem.Allocator, io: std.Io, @@ -559,6 +699,9 @@ pub fn resolveImports( // A C-import namespace authors exactly the wrapped fn // decls — they ARE its own decls (issue 0100). .own_decls = ns_slice, + // No separate sx module: the synthesized members are + // authored in THIS file. Record the importer's path. + .target_module_path = file_path, .is_raw = ci.is_raw, } }, }; @@ -571,16 +714,16 @@ pub fn resolveImports( // Flat: add fn_decls directly + keep c_import_decl for (result.fn_decls) |fd| { fd.source_file = file_path; - _ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, fd); + reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, fd), fd); } decl.source_file = file_path; - _ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl); + reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl), decl); } continue; } if (decl.data != .import_decl) { decl.source_file = file_path; - _ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl); + reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl), decl); continue; } const imp = decl.data.import_decl; diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 2612b18..6ae2ca7 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -599,6 +599,14 @@ pub const ProgramIndex = struct { /// fix-0102c can resolve a flat call to the right module's function. /// Borrowed view. module_fns: ?*imports.ModuleFns = null, + /// Per-module scalar raw-decl index (`path → name → RawDeclRef`), built by + /// `imports.buildImportFacts`. The unified resolver's raw-fact store. + /// Borrowed view. + module_decls: ?*imports.ModuleDecls = null, + /// Namespace import edges (`importer → alias → NamespaceTarget`), built by + /// `imports.buildImportFacts`, carrying each alias's resolved target path. + /// Borrowed view. + namespace_edges: ?*imports.NamespaceEdges = null, // ── Declaration maps ── /// Function name → AST decl. @@ -640,7 +648,7 @@ pub const ProgramIndex = struct { pub fn deinit(self: *ProgramIndex) void { // Owned maps only — module_scopes / import_graph / flat_import_graph / - // module_fns are borrowed. + // module_fns / module_decls / namespace_edges are borrowed. self.import_flags.deinit(); self.fn_ast_map.deinit(); self.qualified_fn_source.deinit();