// Tests for imports.zig — flat-import name-resolution data retention (fix-0102a). 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 { if (g_test_threaded == null) { g_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); } 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` // — WITHOUT touching the merged scope: `mod.decls` stays byte-for-byte // first-wins (one `greet`, a.sx's), exactly as on `wt-fix-0102-base`. test "imports: module_fns retains same-name cross-module fns; flat_import_graph excludes namespaced 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 = "a.sx", .data = "greet :: () -> s64 { 1 }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "greet :: () -> s64 { 2 }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "nsmod.sx", .data = "helper :: () -> s64 { 3 }\n" }); const main_src = \\#import "a.sx"; \\#import "b.sx"; \\ns :: #import "nsmod.sx"; \\main :: () -> s32 { 0 } \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_src }); var dirbuf: [4096]u8 = undefined; const dirlen = try tmp.dir.realPath(io, &dirbuf); const absdir = dirbuf[0..dirlen]; 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}); const ns_path = try std.fmt.allocPrint(alloc, "{s}/nsmod.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 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, null, &stdlib_paths, &import_graph, &flat_import_graph, .{}, ); var module_fns = imports.ModuleFns.init(alloc); try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns); // The MERGED scope the first-wins resolver consumes is unchanged: mergeFlat // still drops the second `greet`, so `mod.decls` carries exactly ONE — and // it is a.sx's author (the first flat import), not b.sx's. var greet_count: usize = 0; var merged_greet: ?*const ast.FnDecl = null; for (mod.decls) |decl| { const name = decl.data.declName() orelse continue; if (!std.mem.eql(u8, name, "greet")) continue; greet_count += 1; if (decl.data == .fn_decl) merged_greet = &decl.data.fn_decl; } try std.testing.expectEqual(@as(usize, 1), greet_count); // module_fns retains BOTH authors of `greet`, keyed by their own paths — // the dropped author is recorded here (side index), not in the merged scope. const a_fns = module_fns.get(a_path) orelse return error.MissingAFns; const b_fns = module_fns.get(b_path) orelse return error.MissingBFns; const a_greet = a_fns.get("greet") orelse return error.MissingAGreet; const b_greet = b_fns.get("greet") orelse return error.MissingBGreet; // Distinct authoring decls — not the same node deduped down to one. try std.testing.expect(a_greet != b_greet); // First-wins: the surviving merged-scope `greet` is a.sx's author. try std.testing.expect(merged_greet == a_greet); // flat_import_graph carries the two bare `#import` edges, NOT the // namespaced `ns :: #import` edge. const flat = flat_import_graph.get(main_path) orelse return error.MissingFlatEdges; try std.testing.expect(flat.contains(a_path)); try std.testing.expect(flat.contains(b_path)); try std.testing.expect(!flat.contains(ns_path)); // The full import_graph DOES record the namespaced edge (the contrast that // makes the flat-graph exclusion meaningful). const full = import_graph.get(main_path) orelse return error.MissingFullEdges; try std.testing.expect(full.contains(a_path)); try std.testing.expect(full.contains(b_path)); try std.testing.expect(full.contains(ns_path)); } // Mixed collision: a.sx authors `Widget` as a STRUCT (non-fn), b.sx authors it // as a FUNCTION. fix-0102a must NOT let the function-author retention shift the // merged scope — first-wins keeps a.sx's struct and drops b.sx's function, // exactly as on `wt-fix-0102-base`. (The fn author may still be indexed in // module_fns; resolution is what must be untouched.) test "imports: mixed non-fn/fn same-name collision stays first-wins in merged scope" { 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 = "a.sx", .data = "Widget :: struct { x: s64 }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "Widget :: () -> s64 { 7 }\n" }); const main_src = \\#import "a.sx"; \\#import "b.sx"; \\main :: () -> s32 { 0 } \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_src }); var dirbuf: [4096]u8 = undefined; const dirlen = try tmp.dir.realPath(io, &dirbuf); const absdir = dirbuf[0..dirlen]; const main_path = try std.fmt.allocPrint(alloc, "{s}/main.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 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, null, &stdlib_paths, &import_graph, &flat_import_graph, .{}, ); // Exactly ONE `Widget` survives the merged scope, and it is a.sx's STRUCT — // the function author did not displace or duplicate it. var widget_count: usize = 0; var merged_is_struct = false; for (mod.decls) |decl| { const name = decl.data.declName() orelse continue; if (!std.mem.eql(u8, name, "Widget")) continue; widget_count += 1; merged_is_struct = decl.data == .struct_decl; } 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); } // F1: the duplicate-name invariant must also cover NAMESPACE ALIASES. A // `dup :: #import "…"` alias colliding with a same-module authored name is a // duplicate in EITHER order — `addNamespace` (alias second) and `addOwnDecl` // (alias first) each refuse the second author and the site diagnoses it. Before // the fix the fn-then-alias order compiled clean (silent first-win in the scalar // index). Surviving author is whichever came FIRST. test "buildImportFacts: fn-then-namespace-alias same-module collision 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 = "lib.sx", .data = "helper :: () -> s64 { 9 }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "dup :: () -> s64 { 1 }\ndup :: #import \"lib.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}); var facts = try buildFacts(alloc, io, absdir, main_path); try std.testing.expect(hasErr(&facts.diags, "duplicate top-level declaration 'dup'")); // The fn came first, so it survives in the scalar index; the alias dropped. const m_idx = facts.decls.get(main_path) orelse return error.MissingMainIndex; try expectTag(m_idx.names.get("dup") orelse return error.MissingDup, .fn_decl); } test "buildImportFacts: namespace-alias-then-fn same-module collision 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 = "lib.sx", .data = "helper :: () -> s64 { 9 }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = "dup :: #import \"lib.sx\";\ndup :: () -> s64 { 1 }\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 'dup'")); // The alias came first, so the namespace_decl survives; the fn dropped. 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); }