From ff9cb50079512ee10f33d3d4563d21646b195e02 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 11:27:11 +0300 Subject: [PATCH 01/10] refactor(imports): retain dup same-name fn authors + build identity indexes [0102a] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of four fix-0102 sub-steps. Purely additive: retains data that the flat/directory merge currently first-wins-drops and builds two identity indexes for later bare-name disambiguation (fix-0102c). No resolution change — the existing first-wins bare path still wins; suite unchanged. - mergeFlat + directory merge: stop dropping a same-name FUNCTION authored by a different module/file. Non-function decls keep first-wins dedup; node identity dedup is untouched. - flat_import_graph: a flat-only subset of import_graph, recording an edge only for a bare `#import` (imp.name == null), never a namespaced `ns :: #import`. Threaded through resolveImports/resolveDirectoryImport and into ProgramIndex. - module_fns (path -> name -> *const FnDecl): per-module authored-function index mirroring module_scopes, built in core.zig from the main module + cache. Same-name cross-module authors stay distinct under their own paths. - imports.test.zig: asserts both a.sx/b.sx greet authors are retained in module_fns and in the global flat list, and that flat_import_graph excludes the namespaced edge while import_graph includes it. Gate (this worktree): zig build, zig build test (398/398), bash tests/run_examples.sh (457 passed) all green. --- src/core.zig | 16 ++++++ src/imports.test.zig | 109 +++++++++++++++++++++++++++++++++++++++ src/imports.zig | 109 ++++++++++++++++++++++++++++++++++++--- src/ir/program_index.zig | 21 ++++++-- src/root.zig | 1 + 5 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 src/imports.test.zig diff --git a/src/core.zig b/src/core.zig index fdc9cd1..9cc4229 100644 --- a/src/core.zig +++ b/src/core.zig @@ -26,6 +26,12 @@ pub const Compilation = struct { import_sources: std.StringHashMap([:0]const u8), module_scopes: std.StringHashMap(std.StringHashMap(void)), import_graph: std.StringHashMap(std.StringHashMap(void)), + /// Flat-only subset of `import_graph` (bare `#import` edges, no namespaced + /// `ns :: #import`). Borrowed by `ProgramIndex.flat_import_graph`. + flat_import_graph: std.StringHashMap(std.StringHashMap(void)), + /// Per-module authored-function index (`path → name → *const FnDecl`). + /// Borrowed by `ProgramIndex.module_fns`. + module_fns: imports.ModuleFns, 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 @@ -52,6 +58,8 @@ pub const Compilation = struct { .import_sources = std.StringHashMap([:0]const u8).init(allocator), .module_scopes = std.StringHashMap(std.StringHashMap(void)).init(allocator), .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), .target_config = target_config, .stdlib_paths = stdlib_paths, }; @@ -101,6 +109,7 @@ pub const Compilation = struct { &self.diagnostics, self.stdlib_paths, &self.import_graph, + &self.flat_import_graph, self.comptimeContext(), ) catch return error.CompileError; @@ -111,6 +120,11 @@ pub const Compilation = struct { self.module_scopes.put(entry.key_ptr.*, entry.value_ptr.scope) catch {}; } + // Per-module authored-function index, built from the SAME modules as + // `module_scopes` (main + every cache entry). Keyed by path; same-name + // cross-module authors stay distinct under their own paths. + imports.buildModuleFns(self.allocator, self.file_path, mod, &cache, &self.module_fns) catch {}; + // Store main file source in import_sources so error reporting can find it self.import_sources.put(self.file_path, self.source) catch {}; @@ -276,6 +290,8 @@ pub const Compilation = struct { lowering.diagnostics = &self.diagnostics; lowering.program_index.module_scopes = &self.module_scopes; 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.lowerRoot(root); if (self.diagnostics.hasErrors()) return error.CompileError; diff --git a/src/imports.test.zig b/src/imports.test.zig new file mode 100644 index 0000000..81f45a4 --- /dev/null +++ b/src/imports.test.zig @@ -0,0 +1,109 @@ +// 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"); + +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(); +} + +// 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`. +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); + + // mergeFlat no longer first-wins-drops the second `greet`: the global flat + // decl list the lowering pass walks now carries BOTH authors. + var greet_count: usize = 0; + for (mod.decls) |decl| { + const name = decl.data.declName() orelse continue; + if (std.mem.eql(u8, name, "greet")) greet_count += 1; + } + try std.testing.expectEqual(@as(usize, 2), greet_count); + + // module_fns retains BOTH authors of `greet`, keyed by their own paths. + 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); + + // 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)); +} diff --git a/src/imports.zig b/src/imports.zig index 8517b4f..34e71cd 100644 --- a/src/imports.zig +++ b/src/imports.zig @@ -5,6 +5,32 @@ const errors = @import("errors.zig"); const c_import = @import("c_import.zig"); const Node = ast.Node; +/// True iff `decl` authors a top-level FUNCTION — either a bare `fn_decl` +/// (`f :: (…) -> T { … }`) or a `const_decl` whose value is a function +/// (`f :: (…) { … }` parsed as a const-bound fn). Used by the flat / directory +/// merge to retain a same-name function authored by a DIFFERENT module instead +/// of first-wins dropping it (fix-0102a): a flat importer that pulls two modules +/// each authoring `f` needs BOTH decls reachable downstream. Non-function decls +/// keep first-wins dedup. +fn declAuthorsFn(decl: *const Node) bool { + return switch (decl.data) { + .fn_decl => true, + .const_decl => |cd| cd.value.data == .fn_decl, + else => false, + }; +} + +/// The `*const ast.FnDecl` a function-authoring decl carries, or null when the +/// decl is not a function (mirrors `declAuthorsFn`). Drives the per-module +/// `module_fns` identity index (fix-0102a). +fn fnDeclOf(decl: *const Node) ?*const ast.FnDecl { + return switch (decl.data) { + .fn_decl => &decl.data.fn_decl, + .const_decl => |cd| if (cd.value.data == .fn_decl) &cd.value.data.fn_decl else null, + else => null, + }; +} + /// Comptime evaluation context for the inline-if hoisting pass below. /// Mirrors the values `injectComptimeConstants` will later push into the /// lowering's `comptime_constants` map (OS / ARCH / POINTER_SIZE), but @@ -334,8 +360,13 @@ pub const ResolvedModule = struct { for (other.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { - if (seen_list.contains(name)) continue; - try seen_list.put(name, {}); + if (seen_list.contains(name)) { + // First-wins dedup for non-functions; retain a same-name + // FUNCTION authored by a different module (fix-0102a). + if (!declAuthorsFn(decl)) continue; + } else { + try seen_list.put(name, {}); + } } try seen_nodes.put(decl, {}); try list.append(allocator, decl); @@ -392,6 +423,43 @@ pub const ResolvedModule = struct { /// Module cache: maps resolved file paths to their ResolvedModules. pub const ModuleCache = std.StringHashMap(ResolvedModule); +/// Per-module function identity index: function NAME → the `*const FnDecl` that +/// module AUTHORS. Mirrors a single module's slice of `module_scopes`. +pub const FnIndex = std.StringHashMap(*const ast.FnDecl); + +/// `path → name → *const FnDecl`, mirroring `module_scopes`. One entry per +/// resolved module keyed by its path (a directory's combined module keyed by +/// `dir_path`); each entry indexes only what that module AUTHORS. Two modules +/// each authoring `f` are retained under their own paths — the identity index +/// fix-0102c's bare-name disambiguation consults to bind a flat call to the +/// right author. +pub const ModuleFns = std.StringHashMap(FnIndex); + +/// Index a single module's authored functions (`own_decls`) into `out[path]`. +/// First-wins WITHIN a module mirrors the scan pass; cross-module same-name +/// authors live under their own `path` keys. +fn indexModuleFns(allocator: std.mem.Allocator, out: *ModuleFns, path: []const u8, own_decls: []const *Node) !void { + const gop = try out.getOrPut(path); + if (!gop.found_existing) gop.value_ptr.* = FnIndex.init(allocator); + for (own_decls) |decl| { + const fd = fnDeclOf(decl) orelse continue; + const name = decl.data.declName() orelse continue; + if (gop.value_ptr.contains(name)) continue; + try gop.value_ptr.put(name, fd); + } +} + +/// Build the per-module function index from a resolved program: the main module +/// (keyed by `main_path`) plus every cached module (keyed by its own path). +/// Mirrors how `core.zig` fills `module_scopes` from `mod.scope` + the cache. +pub fn buildModuleFns(allocator: std.mem.Allocator, main_path: []const u8, main_mod: ResolvedModule, cache: *const ModuleCache, out: *ModuleFns) !void { + try indexModuleFns(allocator, out, main_path, main_mod.own_decls); + var it = cache.iterator(); + while (it.next()) |entry| { + try indexModuleFns(allocator, out, entry.key_ptr.*, entry.value_ptr.own_decls); + } +} + pub fn resolveImports( allocator: std.mem.Allocator, io: std.Io, @@ -404,6 +472,7 @@ pub fn resolveImports( diagnostics: ?*errors.DiagnosticList, stdlib_paths: []const []const u8, import_graph: ?*std.StringHashMap(std.StringHashMap(void)), + flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)), comptime_ctx: ComptimeContext, ) !ResolvedModule { // Record this file's edge set so `param_impl_map` lookups can filter @@ -414,6 +483,15 @@ pub fn resolveImports( try g.put(file_path, std.StringHashMap(void).init(allocator)); } } + // FLAT-only edge set: identical to `import_graph` but records ONLY bare + // `#import "…"` edges (`imp.name == null`), never a namespaced + // `ns :: #import "…"`. fix-0102c's bare-name disambiguation walks this to + // decide which same-name authors a flat importer can actually reach. + if (flat_import_graph) |g| { + if (!g.contains(file_path)) { + try g.put(file_path, std.StringHashMap(void).init(allocator)); + } + } var mod = ResolvedModule{ .path = file_path, .decls = &.{}, @@ -536,6 +614,17 @@ pub fn resolveImports( set.put(resolved_path, {}) catch {}; } } + // The same edge, FLAT-only: recorded only for a bare `#import` + // (`imp.name == null`), excluding a namespaced `ns :: #import`. Covers + // both a flat file import and a flat directory import (`resolved_path` + // is the directory in the latter case). + if (imp.name == null) { + if (flat_import_graph) |g| { + if (g.getPtr(file_path)) |set| { + set.put(resolved_path, {}) catch {}; + } + } + } // Circular import check — only along the current chain if (chain.contains(resolved_path)) continue; @@ -563,7 +652,7 @@ pub fn resolveImports( // Push onto chain before recursing, pop after try chain.put(resolved_path, {}); const imp_dir = dirName(resolved_path); - const result = try resolveImports(allocator, io, imp_root, imp_dir, resolved_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, comptime_ctx); + const result = try resolveImports(allocator, io, imp_root, imp_dir, resolved_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, flat_import_graph, comptime_ctx); _ = chain.remove(resolved_path); // Cache @@ -571,7 +660,7 @@ pub fn resolveImports( break :blk result; } else |_| { // File read failed — try as directory import - const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph, comptime_ctx) catch { + const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph, flat_import_graph, comptime_ctx) catch { if (diagnostics) |diags| { diags.addFmt(.err, decl.span, "cannot read import '{s}' (not a file or directory)", .{resolved_path}); } @@ -605,6 +694,7 @@ fn resolveDirectoryImport( span: ast.Span, stdlib_paths: []const []const u8, import_graph: ?*std.StringHashMap(std.StringHashMap(void)), + flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)), comptime_ctx: ComptimeContext, ) anyerror!ResolvedModule { // Open the directory with iteration capability @@ -679,7 +769,7 @@ fn resolveDirectoryImport( }; try chain.put(file_path, {}); - const result = try resolveImports(allocator, io, imp_root, dir_path, file_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, comptime_ctx); + const result = try resolveImports(allocator, io, imp_root, dir_path, file_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, flat_import_graph, comptime_ctx); _ = chain.remove(file_path); try cache.put(file_path, result); @@ -697,8 +787,13 @@ fn resolveDirectoryImport( for (file_mod.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { - if (seen_in_list.contains(name)) continue; - try seen_in_list.put(name, {}); + if (seen_in_list.contains(name)) { + // First-wins dedup for non-functions; retain a same-name + // FUNCTION authored by a different file (fix-0102a). + if (!declAuthorsFn(decl)) continue; + } else { + try seen_in_list.put(name, {}); + } } try seen_nodes.put(decl, {}); try decl_list.append(allocator, decl); diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 6b6cc82..2612b18 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -1,5 +1,6 @@ const std = @import("std"); const ast = @import("../ast.zig"); +const imports = @import("../imports.zig"); const types = @import("types.zig"); const inst = @import("inst.zig"); const errors = @import("../errors.zig"); @@ -570,9 +571,10 @@ pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// `self.program_index.`; later phases hand collaborator modules a /// `*ProgramIndex` instead of `*Lowering`. /// -/// OWNS the declaration maps below. BORROWS `module_scopes` / `import_graph` -/// (pointers into maps owned by the compilation driver, `core.zig`) — those -/// are read-only views and are never freed here. +/// OWNS the declaration maps below. BORROWS `module_scopes` / `import_graph` / +/// `flat_import_graph` / `module_fns` (pointers into maps owned by the +/// compilation driver, `core.zig`) — those are read-only views and are never +/// freed here. /// /// Per-map allocators are preserved exactly as they were on `Lowering`: /// `import_flags` / `fn_ast_map` / `global_names` use the lowering allocator @@ -587,6 +589,16 @@ pub const ProgramIndex = struct { /// Module path → set of directly imported paths (param_impl visibility /// filter). Borrowed view. import_graph: ?*std.StringHashMap(std.StringHashMap(void)) = null, + /// Module path → set of directly FLAT-imported paths — the subset of + /// `import_graph` edges from a bare `#import` (never a namespaced + /// `ns :: #import`). fix-0102c's bare-name disambiguation walks this to + /// decide which same-name authors a flat importer can reach. Borrowed view. + flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)) = null, + /// Module path → (function name → authoring `*const FnDecl`), mirroring + /// `module_scopes`. Retains every same-name author under its own path so + /// fix-0102c can resolve a flat call to the right module's function. + /// Borrowed view. + module_fns: ?*imports.ModuleFns = null, // ── Declaration maps ── /// Function name → AST decl. @@ -627,7 +639,8 @@ pub const ProgramIndex = struct { } pub fn deinit(self: *ProgramIndex) void { - // Owned maps only — module_scopes / import_graph are borrowed. + // Owned maps only — module_scopes / import_graph / flat_import_graph / + // module_fns are borrowed. self.import_flags.deinit(); self.fn_ast_map.deinit(); self.qualified_fn_source.deinit(); diff --git a/src/root.zig b/src/root.zig index faf2280..767d19f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -13,6 +13,7 @@ pub const trace_runtime_tests = @import("runtime_trace.test.zig"); pub const sema = @import("sema.zig"); pub const sema_tests = @import("sema.test.zig"); pub const imports = @import("imports.zig"); +pub const imports_tests = @import("imports.test.zig"); pub const core = @import("core.zig"); pub const c_import = @import("c_import.zig"); pub const ir = @import("ir/ir.zig"); From 3dbc6f8434a2d995be018da0e5e9d8093d7bd9e8 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 11:53:16 +0300 Subject: [PATCH 02/10] fix(imports): keep merged scope first-wins; index dups in module_fns only [0102a] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempt-1 retained a same-name cross-module FUNCTION author in the merged decl list (mergeFlat + the directory merge), which is the list the existing first-wins resolver consumes. That changed the data feeding resolution (`mod.decls` carried two `greet`), violating this step's purpose: additive indexes with ZERO resolution change. Revert both merge sites to byte-for-byte first-wins, exactly as on wt-fix-0102-base. The dropped same-name author is still retained — but only in the SEPARATE `module_fns` index, which is built from each module's `own_decls` (un-deduped, per-path) and which nothing reads yet. The `flat_import_graph` side data is likewise untouched. Both are foundation for fix-0102c's bare-name disambiguation; current resolution is unchanged. Drop the now-unused `declAuthorsFn` helper (its only callers were the two merge sites). `fnDeclOf` stays — it feeds the index. Tests: the existing unit test now asserts the merged scope stays first-wins (one `greet`, a.sx's author) while `module_fns` still retains BOTH authors and `flat_import_graph` excludes the namespaced edge. Add a mixed non-fn/fn collision test asserting the merged scope keeps a.sx's struct (first-wins), unchanged by the function author. --- src/imports.test.zig | 93 ++++++++++++++++++++++++++++++++++++++++---- src/imports.zig | 38 ++++-------------- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/src/imports.test.zig b/src/imports.test.zig index 81f45a4..73a34a2 100644 --- a/src/imports.test.zig +++ b/src/imports.test.zig @@ -14,8 +14,10 @@ fn testIo() std.Io { } // 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`. +// 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(); @@ -76,22 +78,29 @@ test "imports: module_fns retains same-name cross-module fns; flat_import_graph var module_fns = imports.ModuleFns.init(alloc); try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns); - // mergeFlat no longer first-wins-drops the second `greet`: the global flat - // decl list the lowering pass walks now carries BOTH authors. + // 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")) greet_count += 1; + 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, 2), greet_count); + try std.testing.expectEqual(@as(usize, 1), greet_count); - // module_fns retains BOTH authors of `greet`, keyed by their own paths. + // 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. @@ -107,3 +116,73 @@ test "imports: module_fns retains same-name cross-module fns; flat_import_graph 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); +} diff --git a/src/imports.zig b/src/imports.zig index 34e71cd..a2943be 100644 --- a/src/imports.zig +++ b/src/imports.zig @@ -5,24 +5,10 @@ const errors = @import("errors.zig"); const c_import = @import("c_import.zig"); const Node = ast.Node; -/// True iff `decl` authors a top-level FUNCTION — either a bare `fn_decl` -/// (`f :: (…) -> T { … }`) or a `const_decl` whose value is a function -/// (`f :: (…) { … }` parsed as a const-bound fn). Used by the flat / directory -/// merge to retain a same-name function authored by a DIFFERENT module instead -/// of first-wins dropping it (fix-0102a): a flat importer that pulls two modules -/// each authoring `f` needs BOTH decls reachable downstream. Non-function decls -/// keep first-wins dedup. -fn declAuthorsFn(decl: *const Node) bool { - return switch (decl.data) { - .fn_decl => true, - .const_decl => |cd| cd.value.data == .fn_decl, - else => false, - }; -} - /// The `*const ast.FnDecl` a function-authoring decl carries, or null when the -/// decl is not a function (mirrors `declAuthorsFn`). Drives the per-module -/// `module_fns` identity index (fix-0102a). +/// decl is not a function — either a bare `fn_decl` (`f :: (…) -> T { … }`) or a +/// `const_decl` whose value is a function. Drives the per-module `module_fns` +/// identity index (fix-0102a). fn fnDeclOf(decl: *const Node) ?*const ast.FnDecl { return switch (decl.data) { .fn_decl => &decl.data.fn_decl, @@ -360,13 +346,8 @@ pub const ResolvedModule = struct { for (other.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { - if (seen_list.contains(name)) { - // First-wins dedup for non-functions; retain a same-name - // FUNCTION authored by a different module (fix-0102a). - if (!declAuthorsFn(decl)) continue; - } else { - try seen_list.put(name, {}); - } + if (seen_list.contains(name)) continue; + try seen_list.put(name, {}); } try seen_nodes.put(decl, {}); try list.append(allocator, decl); @@ -787,13 +768,8 @@ fn resolveDirectoryImport( for (file_mod.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { - if (seen_in_list.contains(name)) { - // First-wins dedup for non-functions; retain a same-name - // FUNCTION authored by a different file (fix-0102a). - if (!declAuthorsFn(decl)) continue; - } else { - try seen_in_list.put(name, {}); - } + if (seen_in_list.contains(name)) continue; + try seen_in_list.put(name, {}); } try seen_nodes.put(decl, {}); try decl_list.append(allocator, decl); From 237f794585646d7ead34f59b7df7983e7dce9b14 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 13:02:49 +0300 Subject: [PATCH 03/10] feat(lower): identity-addressable function body lowering [0102b] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of four fix-0102 sub-steps. Makes function declaration + body lowering addressable by decl/FuncId IDENTITY instead of name-first-wins, so two same-name authors can each carry their OWN body in their OWN FuncId. Purely additive: the existing name path stays the sole resolver, so the suite is byte-for-byte unchanged (no call rerouting — that is 0102c). - declareFunction records `*const FnDecl -> FuncId` in a new identity map (`fn_decl_fids`), alongside the existing name-keyed function table. - Extract the body-lowering tail of lazyLowerFunction into a reusable `lowerFunctionBodyInto(fd, fid, name)` that promotes a SPECIFIC extern stub into a real body by EXPLICIT FuncId — not by name lookup (which returns the first author). The shared save/restore preamble becomes a `FnBodyReentry` guard struct, used by both lazyLowerFunction's found path and the null-FuncId `ns.fn` alias path; issue-0100 F1/F2 behaviour (own-import source context, block_terminated transparency) is preserved. - Add `lowerRetainedSameNameAuthors`: walks fix-0102a's `module_fns`, and for each SHADOWED flat author (a same-name author that is not the fn_ast_map winner, in a direct flat import of the main file) declares a fresh same-name FuncId + lowers its body in its own module's visibility context. FuncId-keyed `lowered_fids` tracks which slots already have a body. Not invoked during a default compile (the name path stays the default); 0102c wires it into bare-flat-call routing. - lower.test.zig: regression that compiles two flat-imported modules each authoring `greet` and asserts ONE real body before the pass (winner only; shadow dropped) and TWO distinct non-extern bodies after — the shadowed author is no longer dropped/extern. Gate (this worktree): zig build, zig build test (400/400), bash tests/run_examples.sh (457 passed) all green. --- src/ir/lower.test.zig | 137 +++++++++++++ src/ir/lower.zig | 438 +++++++++++++++++++++++++++--------------- 2 files changed, 417 insertions(+), 158 deletions(-) diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 765e36d..7a97a16 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -10,6 +10,9 @@ const Ref = ir_mod.Ref; const FuncId = ir_mod.FuncId; const Lowering = ir_mod.Lowering; +const parser = @import("../parser.zig"); +const imports = @import("../imports.zig"); + test "lower: simple function with arithmetic" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); @@ -1264,3 +1267,137 @@ test "lower: reflectionArgIsType accepts spelled types, rejects plain values (is try std.testing.expect(!l.reflectionArgIsType(&float_node)); try std.testing.expect(!l.reflectionArgIsType(&bool_node)); } + +var g_lower_test_threaded: ?std.Io.Threaded = null; +fn lowerTestIo() std.Io { + if (g_lower_test_threaded == null) { + g_lower_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); + } + return g_lower_test_threaded.?.io(); +} + +/// Count functions named `name` that carry a REAL body (promoted from the extern +/// stub: not `is_extern`, at least one basic block). +fn countRealBodies(module: *ir_mod.Module, name: []const u8) usize { + var n: usize = 0; + for (module.functions.items) |func| { + if (!std.mem.eql(u8, module.types.getString(func.name), name)) continue; + if (func.is_extern) continue; + if (func.blocks.items.len == 0) continue; + n += 1; + } + return n; +} + +// fix-0102b: two flat-imported modules each author `greet`. The first-wins merge +// keeps a.sx's author in the merged decl list (the WINNER — lowered when `main` +// calls `greet()`) and drops b.sx's, which `module_fns` still retains (0102a). +// BEFORE the identity-addressable pass, only the winner has a real body — the +// shadowed author has no slot at all (the pre-fix symptom: one `greet`). +// `lowerRetainedSameNameAuthors` declares the shadowed author its OWN same-name +// FuncId and lowers its body there, so BOTH authors carry distinct, non-extern +// bodies. Call resolution is untouched: `resolveFuncByName` still returns the +// winner, so `main`'s `greet()` binds first-wins (rerouting is fix-0102c). +test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102b)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = lowerTestIo(); + + 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" }); + const main_src = + \\#import "a.sx"; + \\#import "b.sx"; + \\main :: () -> s64 { greet() } + \\ + ; + 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, + .{}, + ); + + // Per-module visibility scopes + authored-function index, wired exactly as + // `core.zig` does before `lowerRoot`. + var module_scopes = std.StringHashMap(std.StringHashMap(void)).init(alloc); + try module_scopes.put(main_path, mod.scope); + var cache_it = cache.iterator(); + while (cache_it.next()) |entry| { + try module_scopes.put(entry.key_ptr.*, entry.value_ptr.scope); + } + var module_fns = imports.ModuleFns.init(alloc); + try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns); + + const resolved_root = try alloc.create(Node); + resolved_root.* = .{ .span = root.span, .data = .{ .root = .{ .decls = mod.decls } } }; + + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var diagnostics = errors.DiagnosticList.init(alloc, main_source, main_path); + var lowering = Lowering.init(&module); + lowering.main_file = main_path; + lowering.resolved_root = resolved_root; + lowering.diagnostics = &diagnostics; + lowering.program_index.module_scopes = &module_scopes; + lowering.program_index.import_graph = &import_graph; + lowering.program_index.flat_import_graph = &flat_import_graph; + lowering.program_index.module_fns = &module_fns; + + lowering.lowerRoot(resolved_root); + try std.testing.expect(!diagnostics.hasErrors()); + + // Pre-fix symptom: only the winner `greet` (a.sx) has a real body — lowered + // because `main` calls it; the shadowed author (b.sx) was dropped entirely. + try std.testing.expectEqual(@as(usize, 1), countRealBodies(&module, "greet")); + + // Identity-addressable pass: the shadowed author gets its OWN FuncId + body. + lowering.lowerRetainedSameNameAuthors(); + try std.testing.expect(!diagnostics.hasErrors()); + + // Both `greet` authors now carry distinct, real (non-extern) bodies, and the + // two FuncIds are distinct. + try std.testing.expectEqual(@as(usize, 2), countRealBodies(&module, "greet")); + + const name_id = module.types.internString("greet"); + var first: ?FuncId = null; + var second: ?FuncId = null; + for (module.functions.items, 0..) |func, i| { + if (func.name != name_id) continue; + if (func.is_extern or func.blocks.items.len == 0) continue; + if (first == null) first = FuncId.fromIndex(@intCast(i)) else second = FuncId.fromIndex(@intCast(i)); + } + try std.testing.expect(first != null and second != null); + try std.testing.expect(first.? != second.?); +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 12231f5..f65ec13 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -126,6 +126,17 @@ pub const Lowering = struct { comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions target_type: ?TypeId = null, // target type for struct/enum literals without explicit names lowered_functions: std.StringHashMap(void), // tracks which functions have been fully lowered + /// Identity map: authoring `*const ast.FnDecl` → the FuncId `declareFunction` + /// created for it. The name-keyed function table (`resolveFuncByName`) returns + /// the FIRST author of a name, so two same-name authors collide there; this + /// map addresses each author's OWN slot by decl identity (fix-0102b), letting + /// a SHADOWED author lower its body into a distinct FuncId. + fn_decl_fids: std.AutoHashMap(*const ast.FnDecl, FuncId), + /// FuncId-keyed lowered tracking — the identity twin of `lowered_functions` + /// (which keys by name). A shadowed same-name author shares the winner's name + /// but not its FuncId, so name-keyed tracking can't tell them apart; this + /// records which specific FuncIds have had a real body lowered (fix-0102b). + lowered_fids: std.AutoHashMap(FuncId, void), local_fn_counter: u32 = 0, // unique counter for mangling local function names /// Declaration-name / import / visibility facts (architecture phase A1, /// `ProgramIndex`). Owns `import_flags`; borrows `module_scopes` / @@ -286,12 +297,89 @@ pub const Lowering = struct { ret_var_name: ?[]const u8, }; + /// Caller-state protection for lowering a function body re-entrantly — a + /// lazily lowered callee, a qualified `ns.fn` alias, or an out-of-line + /// same-name author. `enter` snapshots the in-progress builder / scope / + /// flag / pack / jni state and installs a fresh set for the nested body; + /// `restore` puts the caller's state back. Lowering a callee must be + /// transparent to the caller's own lowering — notably `block_terminated`, + /// which leaking back would mark the caller's trailing statements + /// dead-after-terminator (issue 0100 F2). + const FnBodyReentry = struct { + l: *Lowering, + func: ?FuncId, + block: ?BlockId, + counter: u32, + scope: ?*Scope, + defer_base: usize, + block_terminated: bool, + force_block_value: bool, + source_file: ?[]const u8, + jni_env_base: usize, + pack_arg_nodes: ?std.StringHashMap([]const *const Node), + pack_param_count: ?std.StringHashMap(u32), + pack_arg_types: ?std.StringHashMap([]const TypeId), + inline_return_target: ?InlineReturnInfo, + + fn enter(l: *Lowering) FnBodyReentry { + const g = FnBodyReentry{ + .l = l, + .func = l.builder.func, + .block = l.builder.current_block, + .counter = l.builder.inst_counter, + .scope = l.scope, + .defer_base = l.func_defer_base, + .block_terminated = l.block_terminated, + .force_block_value = l.force_block_value, + .source_file = l.current_source_file, + .jni_env_base = l.jni_env_stack_base, + .pack_arg_nodes = l.pack_arg_nodes, + .pack_param_count = l.pack_param_count, + .pack_arg_types = l.pack_arg_types, + .inline_return_target = l.inline_return_target, + }; + // The `#jni_env` Ref stack is lexical to ONE function's instruction + // stream; move the visible base to the current top. Pack-fn mono + // state is likewise lexical to the pack-fn body — null it so a + // callee sharing a param NAME with the active pack doesn't fold the + // outer mono's arity into its own `.len`. + l.jni_env_stack_base = l.jni_env_stack.items.len; + l.pack_arg_nodes = null; + l.pack_param_count = null; + l.pack_arg_types = null; + l.inline_return_target = null; + l.func_defer_base = l.defer_stack.items.len; + l.block_terminated = false; + l.force_block_value = false; + return g; + } + + fn restore(g: FnBodyReentry) void { + const l = g.l; + l.setCurrentSourceFile(g.source_file); + l.scope = g.scope; + l.func_defer_base = g.defer_base; + l.block_terminated = g.block_terminated; + l.force_block_value = g.force_block_value; + l.builder.func = g.func; + l.builder.current_block = g.block; + l.builder.inst_counter = g.counter; + l.jni_env_stack_base = g.jni_env_base; + l.pack_arg_nodes = g.pack_arg_nodes; + l.pack_param_count = g.pack_param_count; + l.pack_arg_types = g.pack_arg_types; + l.inline_return_target = g.inline_return_target; + } + }; + pub fn init(module: *Module) Lowering { return .{ .module = module, .builder = Builder.init(module), .alloc = module.alloc, .lowered_functions = std.StringHashMap(void).init(module.alloc), + .fn_decl_fids = std.AutoHashMap(*const ast.FnDecl, FuncId).init(module.alloc), + .lowered_fids = std.AutoHashMap(FuncId, void).init(module.alloc), .program_index = ProgramIndex.init(module.alloc), }; } @@ -1367,6 +1455,82 @@ pub const Lowering = struct { } } + /// Lower every SHADOWED same-name function author into its OWN FuncId with a + /// real (non-extern) body — the identity-addressable lowering PATH this step + /// adds (fix-0102b). It does NOT run during a default compile: the name path + /// stays the sole resolver, so the suite is byte-for-byte unchanged. fix-0102c + /// invokes it as part of routing bare flat calls to the right author; until + /// then it is exercised by the lower-test regression that asserts two distinct + /// non-extern bodies for a same-name collision. + /// + /// The first-wins flat/directory merge keeps exactly one author per name in + /// the merged decl list; `scanDecls` declares that WINNER (lowered on demand + /// through the name-keyed `lazyLowerFunction`). fix-0102a retained every + /// dropped same-name author in `module_fns` (path → name → `*FnDecl`) without + /// touching resolution; this walks that index and gives each shadowed author + /// its own slot: `declareFunction` (identity-mapped to a fresh same-name + /// FuncId) + `lowerFunctionBodyInto` (its body, in its own module's + /// visibility context). Two same-name authors then carry distinct FuncIds and + /// distinct bodies, while `resolveFuncByName` still returns the first (winner) + /// author so existing calls bind first-wins. + /// + /// Scoped to DIRECT flat imports of the main file: a `module_fns` entry whose + /// path is the main file or one of its bare `#import` edges. A namespaced + /// (`ns :: #import`) author has no bare-name winner and is excluded both by + /// that flat-edge gate and by the `fn_ast_map` winner lookup below. + pub fn lowerRetainedSameNameAuthors(self: *Lowering) void { + const module_fns = self.program_index.module_fns orelse return; + const main_file = self.main_file orelse return; + const flat_graph = self.program_index.flat_import_graph orelse return; + const main_flat_edges = flat_graph.get(main_file); + + var path_it = module_fns.iterator(); + while (path_it.next()) |path_entry| { + const path = path_entry.key_ptr.*; + const is_eligible = std.mem.eql(u8, path, main_file) or + (main_flat_edges != null and main_flat_edges.?.contains(path)); + if (!is_eligible) continue; + + var fn_it = path_entry.value_ptr.iterator(); + while (fn_it.next()) |fn_entry| { + const name = fn_entry.key_ptr.*; + const fd = fn_entry.value_ptr.*; + + // A name with no bare winner is namespaced-only (`ns.fn`) — it + // never participated in the flat merge, so it has no shadow to + // lower. The author already owning the name-keyed slot (the + // first-wins winner) lowers through the normal lazy path. + const winner = self.program_index.fn_ast_map.get(name) orelse continue; + if (winner == fd) continue; + + // Only plain free functions get an out-of-line slot; generic / + // foreign / builtin / #compiler authors keep their existing + // dispatch (mirrors lazyLowerFunction / declareFunction guards). + if (fd.type_params.len > 0) continue; + switch (fd.body.data) { + .foreign_expr, .builtin_expr, .compiler_expr => continue, + else => {}, + } + + // Already given its own slot + body? (idempotent across reruns.) + if (self.fn_decl_fids.get(fd)) |existing| { + if (self.lowered_fids.contains(existing)) continue; + } + + // Declare a fresh same-name FuncId for this author and lower its + // body in its OWN module's visibility context (the path key IS + // the author's source file, matching `module_scopes`). + const saved_src = self.current_source_file; + self.setCurrentSourceFile(path); + if (!self.fn_decl_fids.contains(fd)) self.declareFunction(fd, name); + self.setCurrentSourceFile(saved_src); + const fid = self.fn_decl_fids.get(fd) orelse continue; + self.lowerFunctionBodyInto(fd, fid, name); + self.lowered_fids.put(fid, {}) catch {}; + } + } + } + /// Declare a function as an extern stub (signature only, no body). pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) void { // Skip generic templates — they're monomorphized on demand, not declared as extern @@ -1421,6 +1585,7 @@ pub const Lowering = struct { func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; self.foreign_name_map.put(name, c_name) catch {}; + self.fn_decl_fids.put(fd, fid) catch {}; return; } } @@ -1432,6 +1597,7 @@ pub const Lowering = struct { func.source_file = self.current_source_file; func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; + self.fn_decl_fids.put(fd, fid) catch {}; } /// Register a namespaced import's OWN functions under their module-qualified @@ -1581,73 +1747,11 @@ pub const Lowering = struct { // Mark as lowered before lowering (prevents infinite recursion) self.lowered_functions.put(name, {}) catch {}; - // Save builder state (same pattern as lambda lowering) - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - const saved_scope = self.scope; - const saved_defer_base = self.func_defer_base; - const saved_block_terminated = self.block_terminated; - const saved_force_block_value = self.force_block_value; - const saved_source_file = self.current_source_file; - // Lowering a callee must be transparent to the caller's lowering - // state: restore the FULL saved context on EVERY exit path through one - // defer so the three exits (non-null branch, already-promoted early - // return, null-FuncId `ns.fn` alias branch) cannot drift. Notably - // `block_terminated` — a qualified alias whose body terminates (e.g. a - // constant-folded `if true { return … }`) leaves it true, and leaking - // that into the caller marks the caller's own trailing statements - // dead-after-terminator (issue 0100 F2). The jni/pack/foreign-class - // fields keep their own defers above. - defer { - self.setCurrentSourceFile(saved_source_file); - self.scope = saved_scope; - self.func_defer_base = saved_defer_base; - self.block_terminated = saved_block_terminated; - self.force_block_value = saved_force_block_value; - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - // The `#jni_env` Ref stack is lexical within ONE function's instruction - // stream — Refs from the caller don't dereference correctly in this - // callee's body. Move the visible base to the current top so - // omitted-env `#jni_call` in this fn doesn't accidentally pick up the - // caller's Refs. Defer covers all the early-return paths below. - const saved_jni_env_base = self.jni_env_stack_base; - self.jni_env_stack_base = self.jni_env_stack.items.len; - defer self.jni_env_stack_base = saved_jni_env_base; - // Pack-fn mono state is lexical to the pack-fn body. A lazily - // lowered callee may share a param NAME with the active pack - // (e.g. `walk(args: []Any)` called from `probe(..$args)`); without - // isolation, `lowerFieldAccess`'s `.len` intercept - // folds the callee's `args.len` to the outer mono's arity and - // bakes the constant into the IR. Same shape for the AST-node - // and per-element-type maps. Null out for the duration of the - // body lowering and restore on exit. - const saved_pan = self.pack_arg_nodes; - const saved_ppc = self.pack_param_count; - const saved_pat = self.pack_arg_types; - const saved_iri = self.inline_return_target; - self.pack_arg_nodes = null; - self.pack_param_count = null; - self.pack_arg_types = null; - self.inline_return_target = null; - defer { - self.pack_arg_nodes = saved_pan; - self.pack_param_count = saved_ppc; - self.pack_arg_types = saved_pat; - self.inline_return_target = saved_iri; - } - self.func_defer_base = self.defer_stack.items.len; - self.block_terminated = false; - self.force_block_value = false; - - // Find the existing extern stub and replace it with a full body + // Find the existing extern stub (from scanDecls), keyed by NAME — the + // FIRST author of a name owns this slot. A shadowed same-name author is + // not here (it has no name-keyed slot); it is lowered out-of-line into + // its OWN FuncId by `lowerRetainedSameNameAuthors` (fix-0102b). const name_id = self.module.types.internString(name); - const ret_ty = self.resolveReturnType(fd); - - // Look up the existing function declaration (from scanDecls) var func_id: ?FuncId = null; for (self.module.functions.items, 0..) |func, i| { if (func.name == name_id) { @@ -1656,100 +1760,118 @@ pub const Lowering = struct { } } - if (func_id == null) { - // Function not yet declared — create it fresh via lowerFunction. - // A module-qualified alias (`ns.fn`, issue 0100) is registered in - // `fn_ast_map` without an eager `declareFunction`, so there's no - // `Function.source_file` to switch to (the path above). Restore the - // alias's OWN declaring source before lowering its body, otherwise - // it lowers in the caller's visibility context and an own-import - // callee (`foo` calling `helper` from `foo`'s module's flat import) - // is reported "not visible" (issue 0100 F1). - if (self.program_index.qualified_fn_source.get(name)) |src| { - self.setCurrentSourceFile(src); - } - self.lowerFunction(fd, name, false); - return; // caller state restored by the top-level defer - } - if (func_id) |fid| { - // Re-use the existing function slot — switch builder to it - self.builder.func = fid; - const func = &self.module.functions.items[@intFromEnum(fid)]; - self.setCurrentSourceFile(func.source_file); - if (!func.is_extern) { - // Already promoted (e.g., via lowerComptimeDeps) — skip. - // Caller state restored by the top-level defer. - return; - } - func.is_extern = false; // promote from extern stub to real function - func.linkage = if (isExportedEntryName(name)) .external else .internal; - if (fd.call_conv == .c) func.call_conv = .c; - // Set inst_counter to param count (params occupy refs 0..N-1). - // IR params = AST params + 1 if the function carries `__sx_ctx` - // at slot 0. - const ctx_slots: usize = if (func.has_implicit_ctx) 1 else 0; - std.debug.assert(func.params.len == fd.params.len + ctx_slots); - self.builder.inst_counter = @intCast(func.params.len); - - // Create entry block - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // Create scope and bind params - var scope = Scope.init(self.alloc, null); - defer scope.deinit(); - self.scope = &scope; - - // The implicit `__sx_ctx` param (when present) lives at slot 0; - // user params shift by one. `current_ctx_ref` is bound to slot 0 - // so call-site lowering can prepend it to every sx-to-sx call. - // For OS-called entry points (main / JNI hooks), there's no - // ctx param at all — we synthesise `&__sx_default_context` and - // bind `current_ctx_ref` to its address so the body's sx-to-sx - // calls have a sensible Context to forward. - const wants_ctx = self.funcWantsImplicitCtx(fd); - const saved_ctx_ref = self.current_ctx_ref; - defer self.current_ctx_ref = saved_ctx_ref; - const user_param_base: u32 = if (wants_ctx) 1 else 0; - if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0); - - for (fd.params, 0..) |p, i| { - const pty = self.resolveParamType(&p); - const slot = self.builder.alloca(pty); - const param_ref = Ref.fromIndex(@intCast(i + user_param_base)); - self.builder.store(slot, param_ref); - scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); - } - - // Inbound entry points and callconv(.c) sx functions: bind - // current_ctx_ref to the static default before any user code - // runs. C-callable sx functions don't get a __sx_ctx param, - // but their bodies may call ctx-aware sx functions / fn-ptrs - // and need a real Context to forward. - if (!wants_ctx and self.implicit_ctx_enabled) { - if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { - self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void)); - } - } - - // Lower the function body (set target_type to return type for implicit returns) - const saved_target = self.target_type; - self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; - if (ret_ty != .void and ret_ty != .noreturn) { - self.lowerValueBody(fd.body, ret_ty); - } else { - // void / noreturn: no value to return — lower as statements and - // let `ensureTerminator` close the block (ret void / unreachable). - self.lowerBlock(fd.body); - self.ensureTerminator(ret_ty); - } - self.target_type = saved_target; - - self.builder.finalize(); + self.lowerFunctionBodyInto(fd, fid, name); + return; } - // Caller state restored by the top-level defer. + + // Function not yet declared — create it fresh via lowerFunction. A + // module-qualified alias (`ns.fn`, issue 0100) is registered in + // `fn_ast_map` without an eager `declareFunction`, so there's no + // `Function.source_file` to switch to. Restore the alias's OWN declaring + // source before lowering its body, otherwise it lowers in the caller's + // visibility context and an own-import callee (`foo` calling `helper` + // from `foo`'s module's flat import) is reported "not visible" (0100 F1). + // The reentry guard keeps the nested lowering transparent to the caller. + var reentry = FnBodyReentry.enter(self); + defer reentry.restore(); + if (self.program_index.qualified_fn_source.get(name)) |src| { + self.setCurrentSourceFile(src); + } + self.lowerFunction(fd, name, false); + } + + /// Lower `fd`'s body into the SPECIFIC `fid`, promoting its extern stub to a + /// real function. Identity-addressable: the caller passes the exact FuncId, + /// so a SHADOWED same-name author lowers into its OWN slot instead of + /// colliding on the name-keyed `resolveFuncByName` (which returns the first + /// author, the very split that trips issue 0100's param-count assert). Self- + /// contained — the `FnBodyReentry` guard makes the nested lowering + /// transparent to any in-progress caller body (issue 0100 F2) — so it serves + /// both `lazyLowerFunction`'s name-keyed found path and the out-of-line + /// `lowerRetainedSameNameAuthors` pass. + fn lowerFunctionBodyInto(self: *Lowering, fd: *const ast.FnDecl, fid: FuncId, name: []const u8) void { + // objc-defined-class method context for `*Self` substitution (M1.2 A.2b); + // the resolveReturnType / resolveParamType calls below consult it. + const saved_fc = self.current_foreign_class; + defer self.current_foreign_class = saved_fc; + if (self.lookupObjcDefinedClassForMethod(name)) |fcd| { + self.current_foreign_class = fcd; + } + + var reentry = FnBodyReentry.enter(self); + defer reentry.restore(); + + const ret_ty = self.resolveReturnType(fd); + + // Re-use the existing function slot — switch builder to it. + self.builder.func = fid; + const func = &self.module.functions.items[@intFromEnum(fid)]; + self.setCurrentSourceFile(func.source_file); + if (!func.is_extern) { + // Already promoted (e.g., via lowerComptimeDeps) — skip. + return; + } + func.is_extern = false; // promote from extern stub to real function + func.linkage = if (isExportedEntryName(name)) .external else .internal; + if (fd.call_conv == .c) func.call_conv = .c; + // Set inst_counter to param count (params occupy refs 0..N-1). IR params + // = AST params + 1 if the function carries `__sx_ctx` at slot 0. + const ctx_slots: usize = if (func.has_implicit_ctx) 1 else 0; + std.debug.assert(func.params.len == fd.params.len + ctx_slots); + self.builder.inst_counter = @intCast(func.params.len); + + // Create entry block + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // Create scope and bind params + var scope = Scope.init(self.alloc, null); + defer scope.deinit(); + self.scope = &scope; + + // The implicit `__sx_ctx` param (when present) lives at slot 0; user + // params shift by one. `current_ctx_ref` is bound to slot 0 so call-site + // lowering can prepend it to every sx-to-sx call. For OS-called entry + // points (main / JNI hooks) there's no ctx param — synthesise + // `&__sx_default_context` and bind `current_ctx_ref` to its address. + const wants_ctx = self.funcWantsImplicitCtx(fd); + const saved_ctx_ref = self.current_ctx_ref; + defer self.current_ctx_ref = saved_ctx_ref; + const user_param_base: u32 = if (wants_ctx) 1 else 0; + if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0); + + for (fd.params, 0..) |p, i| { + const pty = self.resolveParamType(&p); + const slot = self.builder.alloca(pty); + const param_ref = Ref.fromIndex(@intCast(i + user_param_base)); + self.builder.store(slot, param_ref); + scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); + } + + // Inbound entry points + callconv(.c) sx functions: bind current_ctx_ref + // to the static default before any user code runs. + if (!wants_ctx and self.implicit_ctx_enabled) { + if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { + self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void)); + } + } + + // Lower the function body (set target_type to return type for implicit returns) + const saved_target = self.target_type; + self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; + if (ret_ty != .void and ret_ty != .noreturn) { + self.lowerValueBody(fd.body, ret_ty); + } else { + // void / noreturn: no value to return — lower as statements and let + // `ensureTerminator` close the block (ret void / unreachable). + self.lowerBlock(fd.body); + self.ensureTerminator(ret_ty); + } + self.target_type = saved_target; + + self.builder.finalize(); } /// Lower a single function declaration. From bb1ed7294b8c5e9e0f6dd86674af898b9744ed64 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 13:22:11 +0300 Subject: [PATCH 04/10] fix(lower): key fn_decl_fids by stable AST pointer in scanDecls [0102b F1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanDecls declared a bare `.fn_decl` via `declareFunction(&fd, ...)`, where `fd` is the switch-capture COPY of `decl.data.fn_decl`. Its address is a per-iteration stack temporary, so the winner author's `fn_decl_fids` entry was keyed by an address no later decl-identity lookup can reproduce — `fn_ast_map` and `module_fns` carry the stable `&decl.data.fn_decl`, so a lookup by that pointer missed the winner's FuncId. fix-0102c routes calls through exactly these stable pointers, so the key has to match. Record the entry under `&decl.data.fn_decl` (the persistent AST node field) to match `fn_ast_map`/`module_fns`. The other declareFunction sites already pass stable pointers (const_decl field, module_fns entry, fn_ast_map entry, struct-method node field, heap-synthesized objc decl); `lowered_fids` keys by FuncId value, so neither has the temporary-address mistake. Strengthen the fix-0102b regression test: assert the identity map round-trips by the STABLE pointer for BOTH same-name authors — the winner's `fn_ast_map` pointer resolves to the first-wins FuncId, and the shadow's `module_fns` pointer resolves to a distinct FuncId. This assertion fails on the pre-fix code (winner keyed by `&fd` → null) and passes after. Call resolution unchanged (name path still default). Gate (this worktree): zig build, zig build test (400/400), bash tests/run_examples.sh (457 passed) all green. --- src/ir/lower.test.zig | 26 ++++++++++++++++++++++++++ src/ir/lower.zig | 10 ++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 7a97a16..eb682e9 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -1400,4 +1400,30 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102 } try std.testing.expect(first != null and second != null); try std.testing.expect(first.? != second.?); + + // F1 (attempt-2): the identity map must be keyed by the STABLE AST field + // pointer for BOTH same-name authors — the exact pointers `fn_ast_map` and + // `module_fns` carry — not a per-iteration switch-capture temporary. If the + // winner were keyed by `&fd` (the scanDecls bug), this lookup by the stable + // `fn_ast_map` pointer would miss (null). fix-0102c routes calls through + // exactly these pointers, so the round-trip must hold here. + const winner_fd = lowering.program_index.fn_ast_map.get("greet").?; + const winner_fid = lowering.fn_decl_fids.get(winner_fd); + try std.testing.expect(winner_fid != null); + // Round-trips to the first-wins winner FuncId (resolveFuncByName's pick). + try std.testing.expectEqual(lowering.resolveFuncByName("greet").?, winner_fid.?); + + // The shadowed author's stable pointer lives in `module_fns`; find the one + // that is NOT the winner and confirm IT round-trips to a DISTINCT FuncId. + var shadow_fd: ?*const ast.FnDecl = null; + var mf_it = module_fns.iterator(); + while (mf_it.next()) |path_entry| { + if (path_entry.value_ptr.get("greet")) |fd| { + if (fd != winner_fd) shadow_fd = fd; + } + } + try std.testing.expect(shadow_fd != null); + const shadow_fid = lowering.fn_decl_fids.get(shadow_fd.?); + try std.testing.expect(shadow_fid != null); + try std.testing.expect(shadow_fid.? != winner_fid.?); } diff --git a/src/ir/lower.zig b/src/ir/lower.zig index f65ec13..5c7f48b 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -791,8 +791,14 @@ pub const Lowering = struct { self.program_index.fn_ast_map.put(fd.name, &decl.data.fn_decl) catch {}; self.program_index.import_flags.put(fd.name, is_imported) catch {}; } - // Declare extern stub for all functions (bodies lowered lazily) - self.declareFunction(&fd, fd.name); + // Declare extern stub for all functions (bodies lowered + // lazily). Key the identity map (`fn_decl_fids`, inside + // `declareFunction`) by the STABLE AST field pointer — the + // same `&decl.data.fn_decl` stored in `fn_ast_map` and + // `module_fns` — not the switch-capture copy `fd`, whose + // address is a per-iteration stack temporary that no later + // decl-identity lookup can reproduce. + self.declareFunction(&decl.data.fn_decl, fd.name); }, .const_decl => |cd| { if (cd.value.data == .fn_decl) { From ea35a05b267ef66816b44beb49f2596d49431ba5 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 14:04:03 +0300 Subject: [PATCH 05/10] fix(lower): bare-call resolver binds same-name flat authors per source [0102c] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of four fix-0102 sub-steps — the behaviour fix for NORMAL call sites. Adds THE bare-name resolver `resolveBareCallee(name, caller_file)` over fix-0102a's `module_fns` + `flat_import_graph` and routes the primary call path through it: - own-author wins: a file's bare call to a name IT authors binds its OWN author, not the first-wins merge winner. (When the winner already is the caller's own — every single-author and first-importer case — the resolver returns `.none` so the existing path binds it byte-for-byte.) - a bare call to a name two or more FLAT imports both provide is `.ambiguous` and rejected with a loud diagnostic ("declared by multiple imported modules — qualify the call"); a namespaced author never collides. - a single flat-reachable author that differs from the winner binds that author; otherwise `.none`. The resolved shadow author lowers into its OWN FuncId via fix-0102b's identity-addressable `lowerFunctionBodyInto` (shared `bareAuthorFuncId` helper, also used by `lowerRetainedSameNameAuthors`). Only plain free functions route — generic / comptime / foreign / builtin authors and any scope-mangled / UFCS-aliased / locally-shadowed name fall straight to the existing dispatch, so single-author / local / std / qualified resolution is unchanged (full example suite stays green, including bundle.sx and the comptime format/pack examples). Examples 0722 (flat file per-source bind), 0723 (flat vs namespaced, no false ambiguity), 0724 (ambiguous → diagnostic), 0725 (flat directory per-source bind), 0727 (user namespace literally named __m0). Each fails on wt-fix-0102-base (first-wins mis-bind / no diagnostic) and passes here. The fix-0102b unit test now calls a per-module wrapper (main can't bare-call the 2-author name) and asserts the resolver's three variants directly. Gate: zig build, zig build test (400/400), bash tests/run_examples.sh (462 passed) all green. --- examples/0722-modules-flat-same-name-own.sx | 18 ++ examples/0722-modules-flat-same-name-own/a.sx | 5 + examples/0722-modules-flat-same-name-own/b.sx | 4 + examples/0723-modules-flat-vs-namespaced.sx | 17 ++ .../0723-modules-flat-vs-namespaced/flat.sx | 3 + .../0723-modules-flat-vs-namespaced/named.sx | 4 + .../0724-modules-flat-same-name-ambiguous.sx | 12 ++ .../a.sx | 3 + .../b.sx | 2 + examples/0725-modules-flat-dir-same-name.sx | 17 ++ .../0725-modules-flat-dir-same-name/d1/one.sx | 3 + .../0725-modules-flat-dir-same-name/d2/two.sx | 4 + examples/0727-modules-user-ns-m0.sx | 20 +++ examples/0727-modules-user-ns-m0/a.sx | 3 + examples/0727-modules-user-ns-m0/b.sx | 3 + examples/0727-modules-user-ns-m0/m.sx | 3 + .../0722-modules-flat-same-name-own.exit | 1 + .../0722-modules-flat-same-name-own.stderr | 1 + .../0722-modules-flat-same-name-own.stdout | 2 + .../0723-modules-flat-vs-namespaced.exit | 1 + .../0723-modules-flat-vs-namespaced.stderr | 1 + .../0723-modules-flat-vs-namespaced.stdout | 2 + ...0724-modules-flat-same-name-ambiguous.exit | 1 + ...24-modules-flat-same-name-ambiguous.stderr | 5 + ...24-modules-flat-same-name-ambiguous.stdout | 1 + .../0725-modules-flat-dir-same-name.exit | 1 + .../0725-modules-flat-dir-same-name.stderr | 1 + .../0725-modules-flat-dir-same-name.stdout | 2 + .../expected/0727-modules-user-ns-m0.exit | 1 + .../expected/0727-modules-user-ns-m0.stderr | 1 + .../expected/0727-modules-user-ns-m0.stdout | 3 + readme.md | 6 + src/ir/lower.test.zig | 29 +++- src/ir/lower.zig | 162 +++++++++++++++--- 34 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 examples/0722-modules-flat-same-name-own.sx create mode 100644 examples/0722-modules-flat-same-name-own/a.sx create mode 100644 examples/0722-modules-flat-same-name-own/b.sx create mode 100644 examples/0723-modules-flat-vs-namespaced.sx create mode 100644 examples/0723-modules-flat-vs-namespaced/flat.sx create mode 100644 examples/0723-modules-flat-vs-namespaced/named.sx create mode 100644 examples/0724-modules-flat-same-name-ambiguous.sx create mode 100644 examples/0724-modules-flat-same-name-ambiguous/a.sx create mode 100644 examples/0724-modules-flat-same-name-ambiguous/b.sx create mode 100644 examples/0725-modules-flat-dir-same-name.sx create mode 100644 examples/0725-modules-flat-dir-same-name/d1/one.sx create mode 100644 examples/0725-modules-flat-dir-same-name/d2/two.sx create mode 100644 examples/0727-modules-user-ns-m0.sx create mode 100644 examples/0727-modules-user-ns-m0/a.sx create mode 100644 examples/0727-modules-user-ns-m0/b.sx create mode 100644 examples/0727-modules-user-ns-m0/m.sx create mode 100644 examples/expected/0722-modules-flat-same-name-own.exit create mode 100644 examples/expected/0722-modules-flat-same-name-own.stderr create mode 100644 examples/expected/0722-modules-flat-same-name-own.stdout create mode 100644 examples/expected/0723-modules-flat-vs-namespaced.exit create mode 100644 examples/expected/0723-modules-flat-vs-namespaced.stderr create mode 100644 examples/expected/0723-modules-flat-vs-namespaced.stdout create mode 100644 examples/expected/0724-modules-flat-same-name-ambiguous.exit create mode 100644 examples/expected/0724-modules-flat-same-name-ambiguous.stderr create mode 100644 examples/expected/0724-modules-flat-same-name-ambiguous.stdout create mode 100644 examples/expected/0725-modules-flat-dir-same-name.exit create mode 100644 examples/expected/0725-modules-flat-dir-same-name.stderr create mode 100644 examples/expected/0725-modules-flat-dir-same-name.stdout create mode 100644 examples/expected/0727-modules-user-ns-m0.exit create mode 100644 examples/expected/0727-modules-user-ns-m0.stderr create mode 100644 examples/expected/0727-modules-user-ns-m0.stdout diff --git a/examples/0722-modules-flat-same-name-own.sx b/examples/0722-modules-flat-same-name-own.sx new file mode 100644 index 0000000..911e594 --- /dev/null +++ b/examples/0722-modules-flat-same-name-own.sx @@ -0,0 +1,18 @@ +// fix-0102c (issue 0102): two flat FILE imports each author a same-name free +// function `greet`. The first-wins import merge keeps exactly one `greet` in +// the merged scope, but each module's OWN code must bind its OWN author when it +// calls `greet` bare. `from_a` (in a.sx) returns 1; `from_b` (in b.sx) returns +// 2 — per-source binding, resolved by identity, not first-wins. +#import "modules/std.sx"; +#import "0722-modules-flat-same-name-own/a.sx"; +#import "0722-modules-flat-same-name-own/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.greet", from_a() == 1); + report("from_b binds b.greet", from_b() == 2); + 0 +} diff --git a/examples/0722-modules-flat-same-name-own/a.sx b/examples/0722-modules-flat-same-name-own/a.sx new file mode 100644 index 0000000..07aa08f --- /dev/null +++ b/examples/0722-modules-flat-same-name-own/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `greet`. Its own `from_a` calls `greet` bare — under fix-0102c +// that binds a.sx's OWN author (own-author wins), even though b.sx also +// authors `greet` and the first-wins merge keeps only one in the merged scope. +greet :: () -> s64 { return 1; } +from_a :: () -> s64 { return greet(); } diff --git a/examples/0722-modules-flat-same-name-own/b.sx b/examples/0722-modules-flat-same-name-own/b.sx new file mode 100644 index 0000000..0c684d2 --- /dev/null +++ b/examples/0722-modules-flat-same-name-own/b.sx @@ -0,0 +1,4 @@ +// b.sx authors its OWN `greet`. `from_b`'s bare `greet` must bind b.sx's +// author (2), not the first-wins winner from a.sx. +greet :: () -> s64 { return 2; } +from_b :: () -> s64 { return greet(); } diff --git a/examples/0723-modules-flat-vs-namespaced.sx b/examples/0723-modules-flat-vs-namespaced.sx new file mode 100644 index 0000000..ac63e62 --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced.sx @@ -0,0 +1,17 @@ +// fix-0102c (issue 0102): one FLAT and one NAMESPACED author of `value`. The +// bare call `value()` binds the FLAT author (10); the namespaced author is +// reached only through `nm.value()` (20). A namespaced author must NOT make the +// bare call ambiguous — only flat authors collide. +#import "modules/std.sx"; +#import "0723-modules-flat-vs-namespaced/flat.sx"; +nm :: #import "0723-modules-flat-vs-namespaced/named.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("bare binds flat", value() == 10); + report("nm.value binds named", nm.value() == 20); + 0 +} diff --git a/examples/0723-modules-flat-vs-namespaced/flat.sx b/examples/0723-modules-flat-vs-namespaced/flat.sx new file mode 100644 index 0000000..7dc0cfd --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced/flat.sx @@ -0,0 +1,3 @@ +// Flat-imported author of `value`. A bare `value()` in the consumer binds THIS +// one — the only bare (flat) author of the name. +value :: () -> s64 { return 10; } diff --git a/examples/0723-modules-flat-vs-namespaced/named.sx b/examples/0723-modules-flat-vs-namespaced/named.sx new file mode 100644 index 0000000..132a422 --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced/named.sx @@ -0,0 +1,4 @@ +// Namespaced-imported author of `value`. Reachable only as `nm.value`; it never +// enters the flat merge, so it neither shadows the flat author nor makes the +// bare call ambiguous. +value :: () -> s64 { return 20; } diff --git a/examples/0724-modules-flat-same-name-ambiguous.sx b/examples/0724-modules-flat-same-name-ambiguous.sx new file mode 100644 index 0000000..7a623f0 --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous.sx @@ -0,0 +1,12 @@ +// fix-0102c (issue 0102): a genuinely-ambiguous bare call. `main` flat-imports +// two modules that each author `dup` and neither is `main`'s own — a bare +// `dup()` can't pick one, so the compiler rejects it with a loud diagnostic +// instead of silently first-wins-binding one. Qualify the call to disambiguate. +#import "modules/std.sx"; +#import "0724-modules-flat-same-name-ambiguous/a.sx"; +#import "0724-modules-flat-same-name-ambiguous/b.sx"; + +main :: () -> s32 { + print("{}\n", dup()); + 0 +} diff --git a/examples/0724-modules-flat-same-name-ambiguous/a.sx b/examples/0724-modules-flat-same-name-ambiguous/a.sx new file mode 100644 index 0000000..cb94ede --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous/a.sx @@ -0,0 +1,3 @@ +// One of two flat authors of `dup`. A consumer that flat-imports BOTH and calls +// `dup` bare cannot pick between them. +dup :: () -> s64 { return 1; } diff --git a/examples/0724-modules-flat-same-name-ambiguous/b.sx b/examples/0724-modules-flat-same-name-ambiguous/b.sx new file mode 100644 index 0000000..6bd821b --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous/b.sx @@ -0,0 +1,2 @@ +// The second flat author of `dup`. +dup :: () -> s64 { return 2; } diff --git a/examples/0725-modules-flat-dir-same-name.sx b/examples/0725-modules-flat-dir-same-name.sx new file mode 100644 index 0000000..910716e --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name.sx @@ -0,0 +1,17 @@ +// fix-0102c (issue 0102): two flat DIRECTORY imports each author a same-name +// `tag`. A directory flat-import exposes the directory's authored functions, so +// `caller1`/`caller2` are visible here, and each binds its OWN directory's `tag` +// when it calls bare — per-source binding across directory imports (100 / 200). +#import "modules/std.sx"; +#import "0725-modules-flat-dir-same-name/d1"; +#import "0725-modules-flat-dir-same-name/d2"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("caller1 binds d1.tag", caller1() == 100); + report("caller2 binds d2.tag", caller2() == 200); + 0 +} diff --git a/examples/0725-modules-flat-dir-same-name/d1/one.sx b/examples/0725-modules-flat-dir-same-name/d1/one.sx new file mode 100644 index 0000000..47db691 --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name/d1/one.sx @@ -0,0 +1,3 @@ +// d1's author of `tag`. `caller1` (also in d1) binds d1's own `tag` (100). +tag :: () -> s64 { return 100; } +caller1 :: () -> s64 { return tag(); } diff --git a/examples/0725-modules-flat-dir-same-name/d2/two.sx b/examples/0725-modules-flat-dir-same-name/d2/two.sx new file mode 100644 index 0000000..026037d --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name/d2/two.sx @@ -0,0 +1,4 @@ +// d2's author of `tag`. `caller2` (also in d2) binds d2's own `tag` (200), +// even though d1's `tag` is the first-wins merge winner. +tag :: () -> s64 { return 200; } +caller2 :: () -> s64 { return tag(); } diff --git a/examples/0727-modules-user-ns-m0.sx b/examples/0727-modules-user-ns-m0.sx new file mode 100644 index 0000000..5740500 --- /dev/null +++ b/examples/0727-modules-user-ns-m0.sx @@ -0,0 +1,20 @@ +// fix-0102c (issue 0102): a user namespace alias literally named `__m0` +// coexists with flat same-name imports. fix-0102 resolves same-name authors by +// FnDecl IDENTITY — there are no synthetic `__m0`-style names to collide with — +// so a user namespace spelled `__m0` is just an ordinary namespace: `call_a` +// binds a.ping (1), `call_b` binds b.ping (2), and `__m0.ping` reaches m.ping (99). +#import "modules/std.sx"; +#import "0727-modules-user-ns-m0/a.sx"; +#import "0727-modules-user-ns-m0/b.sx"; +__m0 :: #import "0727-modules-user-ns-m0/m.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("call_a binds a.ping", call_a() == 1); + report("call_b binds b.ping", call_b() == 2); + report("__m0.ping binds m.ping", __m0.ping() == 99); + 0 +} diff --git a/examples/0727-modules-user-ns-m0/a.sx b/examples/0727-modules-user-ns-m0/a.sx new file mode 100644 index 0000000..f46b7f6 --- /dev/null +++ b/examples/0727-modules-user-ns-m0/a.sx @@ -0,0 +1,3 @@ +// Flat author of `ping`; `call_a` binds a.sx's own `ping` (1). +ping :: () -> s64 { return 1; } +call_a :: () -> s64 { return ping(); } diff --git a/examples/0727-modules-user-ns-m0/b.sx b/examples/0727-modules-user-ns-m0/b.sx new file mode 100644 index 0000000..af3fbfc --- /dev/null +++ b/examples/0727-modules-user-ns-m0/b.sx @@ -0,0 +1,3 @@ +// Second flat author of `ping`; `call_b` binds b.sx's own `ping` (2). +ping :: () -> s64 { return 2; } +call_b :: () -> s64 { return ping(); } diff --git a/examples/0727-modules-user-ns-m0/m.sx b/examples/0727-modules-user-ns-m0/m.sx new file mode 100644 index 0000000..db250e1 --- /dev/null +++ b/examples/0727-modules-user-ns-m0/m.sx @@ -0,0 +1,3 @@ +// Imported under a user namespace literally named `__m0`. Reached as +// `__m0.ping` (99); coexists with the flat `ping` collision. +ping :: () -> s64 { return 99; } diff --git a/examples/expected/0722-modules-flat-same-name-own.exit b/examples/expected/0722-modules-flat-same-name-own.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0722-modules-flat-same-name-own.stderr b/examples/expected/0722-modules-flat-same-name-own.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0722-modules-flat-same-name-own.stdout b/examples/expected/0722-modules-flat-same-name-own.stdout new file mode 100644 index 0000000..d9c2011 --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.stdout @@ -0,0 +1,2 @@ +from_a binds a.greet: ok +from_b binds b.greet: ok diff --git a/examples/expected/0723-modules-flat-vs-namespaced.exit b/examples/expected/0723-modules-flat-vs-namespaced.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0723-modules-flat-vs-namespaced.stderr b/examples/expected/0723-modules-flat-vs-namespaced.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0723-modules-flat-vs-namespaced.stdout b/examples/expected/0723-modules-flat-vs-namespaced.stdout new file mode 100644 index 0000000..9e6841a --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.stdout @@ -0,0 +1,2 @@ +bare binds flat: ok +nm.value binds named: ok diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.exit b/examples/expected/0724-modules-flat-same-name-ambiguous.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.stderr b/examples/expected/0724-modules-flat-same-name-ambiguous.stderr new file mode 100644 index 0000000..46bf580 --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.stderr @@ -0,0 +1,5 @@ +error: 'dup' is ambiguous; declared by multiple imported modules — qualify the call + --> examples/0724-modules-flat-same-name-ambiguous.sx:10:19 + | +10 | print("{}\n", dup()); + | ^^^ diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.stdout b/examples/expected/0724-modules-flat-same-name-ambiguous.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/0725-modules-flat-dir-same-name.exit b/examples/expected/0725-modules-flat-dir-same-name.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0725-modules-flat-dir-same-name.stderr b/examples/expected/0725-modules-flat-dir-same-name.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0725-modules-flat-dir-same-name.stdout b/examples/expected/0725-modules-flat-dir-same-name.stdout new file mode 100644 index 0000000..89953af --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.stdout @@ -0,0 +1,2 @@ +caller1 binds d1.tag: ok +caller2 binds d2.tag: ok diff --git a/examples/expected/0727-modules-user-ns-m0.exit b/examples/expected/0727-modules-user-ns-m0.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0727-modules-user-ns-m0.stderr b/examples/expected/0727-modules-user-ns-m0.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0727-modules-user-ns-m0.stdout b/examples/expected/0727-modules-user-ns-m0.stdout new file mode 100644 index 0000000..65286e5 --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.stdout @@ -0,0 +1,3 @@ +call_a binds a.ping: ok +call_b binds b.ping: ok +__m0.ping binds m.ping: ok diff --git a/readme.md b/readme.md index 9e4a688..7ddb706 100644 --- a/readme.md +++ b/readme.md @@ -393,6 +393,12 @@ Direct C header import: math :: #import "modules/math.sx"; // namespaced import ``` +When two flat-imported modules each define a function of the same name, every +module's own code binds its OWN author — a bare call resolves to the same-name +function in the caller's module (or in its single flat import that provides it). +A bare call to a name that two or more flat imports both provide is ambiguous and +is rejected; qualify it with a namespaced import (`m :: #import …; m.fn()`). + ### Implicit Context Every program gets an implicit `context` with a default allocator: diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index eb682e9..8a853f2 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -1290,14 +1290,15 @@ fn countRealBodies(module: *ir_mod.Module, name: []const u8) usize { } // fix-0102b: two flat-imported modules each author `greet`. The first-wins merge -// keeps a.sx's author in the merged decl list (the WINNER — lowered when `main` -// calls `greet()`) and drops b.sx's, which `module_fns` still retains (0102a). +// keeps a.sx's author in the merged decl list (the WINNER) and drops b.sx's, +// which `module_fns` still retains (0102a). `main` itself can't bare-call `greet` +// — under fix-0102c two flat authors make that ambiguous — so it calls a.sx's +// `use_greet` wrapper, whose own-author call to `greet` binds a.sx's winner. // BEFORE the identity-addressable pass, only the winner has a real body — the // shadowed author has no slot at all (the pre-fix symptom: one `greet`). // `lowerRetainedSameNameAuthors` declares the shadowed author its OWN same-name // FuncId and lowers its body there, so BOTH authors carry distinct, non-extern -// bodies. Call resolution is untouched: `resolveFuncByName` still returns the -// winner, so `main`'s `greet()` binds first-wins (rerouting is fix-0102c). +// bodies, and `resolveFuncByName` still returns the winner (the name-keyed slot). test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102b)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -1307,12 +1308,12 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102 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 = "a.sx", .data = "greet :: () -> s64 { 1 }\nuse_greet :: () -> s64 { greet() }\n" }); try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "greet :: () -> s64 { 2 }\n" }); const main_src = \\#import "a.sx"; \\#import "b.sx"; - \\main :: () -> s64 { greet() } + \\main :: () -> s64 { use_greet() } \\ ; try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_src }); @@ -1426,4 +1427,20 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102 const shadow_fid = lowering.fn_decl_fids.get(shadow_fd.?); try std.testing.expect(shadow_fid != null); try std.testing.expect(shadow_fid.? != winner_fid.?); + + // fix-0102c: THE bare-name resolver routes per caller file. `main` flat- + // imports two `greet` authors and is its own author of neither → a bare + // `greet()` from `main` is ambiguous. a.sx authors the WINNER, so its bare + // `greet` resolves through the existing path (`.none`). b.sx authors the + // SHADOW, so own-author-wins binds b.sx's distinct FuncId — not first-wins. + const a_path = try std.fmt.allocPrint(alloc, "{s}/a.sx", .{absdir}); + const b_path = try std.fmt.allocPrint(alloc, "{s}/b.sx", .{absdir}); + try std.testing.expect(lowering.resolveBareCallee("greet", main_path) == .ambiguous); + try std.testing.expect(lowering.resolveBareCallee("greet", a_path) == .none); + switch (lowering.resolveBareCallee("greet", b_path)) { + .func => |fid| try std.testing.expectEqual(shadow_fid.?, fid), + else => return error.TestUnexpectedResult, + } + // A name no module authors (and no flat import provides) never routes. + try std.testing.expect(lowering.resolveBareCallee("nonexistent", b_path) == .none); } diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 5c7f48b..03209d1 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1512,31 +1512,107 @@ pub const Lowering = struct { // Only plain free functions get an out-of-line slot; generic / // foreign / builtin / #compiler authors keep their existing // dispatch (mirrors lazyLowerFunction / declareFunction guards). - if (fd.type_params.len > 0) continue; - switch (fd.body.data) { - .foreign_expr, .builtin_expr, .compiler_expr => continue, - else => {}, - } + if (!isPlainFreeFn(fd)) continue; - // Already given its own slot + body? (idempotent across reruns.) - if (self.fn_decl_fids.get(fd)) |existing| { - if (self.lowered_fids.contains(existing)) continue; - } - - // Declare a fresh same-name FuncId for this author and lower its - // body in its OWN module's visibility context (the path key IS - // the author's source file, matching `module_scopes`). - const saved_src = self.current_source_file; - self.setCurrentSourceFile(path); - if (!self.fn_decl_fids.contains(fd)) self.declareFunction(fd, name); - self.setCurrentSourceFile(saved_src); - const fid = self.fn_decl_fids.get(fd) orelse continue; - self.lowerFunctionBodyInto(fd, fid, name); - self.lowered_fids.put(fid, {}) catch {}; + _ = self.bareAuthorFuncId(fd, name, path); } } } + /// Result of bare-call disambiguation (fix-0102c). + pub const BareCallee = union(enum) { + /// Bind the call to this specific author's FuncId — the identity- + /// addressable body lowered by `bareAuthorFuncId` (fix-0102b). + func: FuncId, + /// ≥2 distinct flat authors are reachable from the caller and none is + /// the caller's own — the bare call can't pick one; require a qualifier. + ambiguous, + /// 0 or 1 reachable author, or the resolved author IS the existing + /// bare-name winner — defer to the existing path, byte-for-byte. + none, + }; + + /// THE bare-name call resolver (fix-0102c). One canonical traversal over + /// fix-0102a's `module_fns` + `flat_import_graph` that routes a bare + /// identifier call `name` from `caller_file` to the right same-name author + /// when flat imports introduce a genuine collision. Every single-author / + /// local / parameter / std / qualified name resolves through the EXISTING + /// path unchanged: the resolver returns `.none` whenever the outcome would + /// match first-wins, so nothing on the common path is perturbed. + /// + /// - **own-author wins**: if `caller_file` authors `name` and the bare-name + /// first-wins winner is a DIFFERENT author, bind the caller's own author. + /// (When the winner already IS the caller's own — the single-author and + /// first-importer cases — `.none` lets the existing path bind it.) + /// - else collect the authors reachable via `caller_file`'s FLAT import + /// edges (bare `#import` of a file or directory, never a namespaced + /// `ns :: #import`), deduped by `FnDecl` identity (a diamond import of the + /// same module is one author): `≥2 distinct` → `.ambiguous`; exactly one + /// that DIFFERS from the winner → bind it; otherwise `.none`. + /// + /// Generic / comptime / foreign / builtin authors are never rerouted — the + /// existing dispatch owns those shapes — so the resolver returns `.none`. + pub fn resolveBareCallee(self: *Lowering, name: []const u8, caller_file: []const u8) BareCallee { + const module_fns = self.program_index.module_fns orelse return .none; + const winner = self.program_index.fn_ast_map.get(name); + + // own-author wins. + if (module_fns.get(caller_file)) |own_fns| { + if (own_fns.get(name)) |own| { + if (winner != null and winner.? == own) return .none; + if (!isPlainFreeFn(own)) return .none; + return .{ .func = self.bareAuthorFuncId(own, name, caller_file) }; + } + } + + // Caller does not author `name` → collect its flat-reachable authors. + const flat_graph = self.program_index.flat_import_graph orelse return .none; + const edges = flat_graph.get(caller_file) orelse return .none; + var distinct = std.AutoHashMap(*const ast.FnDecl, []const u8).init(self.alloc); + defer distinct.deinit(); + var edge_it = edges.iterator(); + while (edge_it.next()) |e| { + const fns = module_fns.get(e.key_ptr.*) orelse continue; + if (fns.get(name)) |fd| distinct.put(fd, e.key_ptr.*) catch {}; + } + if (distinct.count() == 0) return .none; + if (distinct.count() >= 2) return .ambiguous; + + var one_it = distinct.iterator(); + const entry = one_it.next().?; + const the_one = entry.key_ptr.*; + const the_path = entry.value_ptr.*; + if (winner != null and winner.? == the_one) return .none; + if (!isPlainFreeFn(the_one)) return .none; + return .{ .func = self.bareAuthorFuncId(the_one, name, the_path) }; + } + + /// The FuncId for a resolved bare-call author, ensuring its body is lowered. + /// Only ever called for a SHADOW (an author that is not the name-keyed + /// winner): the winner owns the name-keyed slot and lowers through the + /// normal lazy path, so `resolveBareCallee` returns `.none` for it. A shadow + /// is declared a fresh same-name FuncId in its OWN module's visibility + /// context and its body lowered into that slot via fix-0102b's identity- + /// addressable `lowerFunctionBodyInto`. Idempotent: `lowered_fids` tracks + /// which slots already carry a body. + fn bareAuthorFuncId(self: *Lowering, fd: *const ast.FnDecl, name: []const u8, path: []const u8) FuncId { + if (self.fn_decl_fids.get(fd)) |fid| { + if (!self.lowered_fids.contains(fid)) { + self.lowered_fids.put(fid, {}) catch {}; + self.lowerFunctionBodyInto(fd, fid, name); + } + return fid; + } + const saved_src = self.current_source_file; + self.setCurrentSourceFile(path); + self.declareFunction(fd, name); + self.setCurrentSourceFile(saved_src); + const fid = self.fn_decl_fids.get(fd).?; + self.lowered_fids.put(fid, {}) catch {}; + self.lowerFunctionBodyInto(fd, fid, name); + return fid; + } + /// Declare a function as an extern stub (signature only, no body). pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) void { // Skip generic templates — they're monomorphized on demand, not declared as extern @@ -7466,6 +7542,39 @@ pub const Lowering = struct { } } } + // fix-0102c: a genuine flat same-name collision — bind the + // caller file's OWN author (or its single flat-reachable + // author), or reject a bare call to a name ≥2 imported modules + // author. Only a plain top-level identifier call routes here: + // scope-mangled / UFCS-aliased / locally-shadowed names and + // 0/1-author names fall straight to the existing path below + // (`resolveBareCallee` returns `.none`). + if (std.mem.eql(u8, func_name, id.name) and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(func_name, caller_file)) { + .none => {}, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name}); + return Ref.none; + }, + .func => |fid| { + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + if (self.program_index.fn_ast_map.get(func_name)) |fd| { + self.packVariadicCallArgs(fd, c, &args); + } + const final_args = self.prependCtxIfNeeded(func, args.items); + self.coerceCallArgs(final_args, params); + if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len); + return self.builder.call(fid, final_args, ret_ty); + }, + } + } + } // Check for comptime-expanded or generic functions if (self.program_index.fn_ast_map.get(func_name)) |fd| { if (hasComptimeParams(fd)) { @@ -12018,6 +12127,19 @@ pub const Lowering = struct { return false; } + /// A plain free function: no type params (not generic) and an ordinary sx + /// body (not `#foreign` / `#builtin` / `#compiler`). Only these get an + /// out-of-line identity-addressable slot — the bare-call disambiguation + /// (fix-0102c) and the shadow-author lowering pass leave every other shape + /// to the existing name-keyed dispatch. + fn isPlainFreeFn(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, + }; + } + /// Pack-fn: has a trailing heterogeneous pack param (`is_variadic /// AND is_comptime`). Mixed shapes — non-pack comptime params /// before the pack — are also accepted; the mono folds those From 8f9c00dcdbde09d357b0046211f6b5918efb40a9 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 14:28:00 +0300 Subject: [PATCH 06/10] fix(lower): resolved author drives variadic packing in bare call [0102c F1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempt-2 fix for the F1 review finding. After `resolveBareCallee` picks a shadowed same-name author's FuncId at a normal call site, the call path still re-fetched the FIRST-WINS function AST by name to drive variadic argument packing. When the resolved (shadow) author's variadic shape differs from the first-wins author's, arguments were packed against the WRONG signature — a fixed-arity shadow packed as if variadic, or a variadic shadow not packed at all — producing IR with the wrong argument count (LLVM verification failure). The `.func` arm now carries the resolved `*FnDecl` alongside its FuncId (`BareCallee.func: ResolvedAuthor`), so `packVariadicCallArgs` reads THE resolved author's signature. The rest of the arm already used the resolved FuncId's IR function (ret/params/ctx/coercion), so the callee now has one source of truth in the whole call lowering — no re-fetch by name after resolution. Default-arg / closure / UFCS / comptime *sites* remain first-wins (fix-0102d); `expandCallDefaults` runs before resolution and is a default site. Regression: examples/0726-modules-flat-same-name-variadic — two flat file imports each author `combine` and `pick` with OPPOSITE variadic shapes (a.sx fixed `combine` / variadic `pick`; b.sx variadic `combine` / fixed `pick`). Each module's bare call must pack against ITS OWN author. Fails on the pre-fix re-lookup (LLVM "Incorrect number of arguments passed to called function" for both `combine.1` and `pick.2`); passes after. Gate: zig build, zig build test (400/400), bash tests/run_examples.sh (463 passed) all green. Matrix 0722-0725/0727 unchanged; single-author / local resolution byte-for-byte unchanged (the `.func` arm never runs for them). --- .../0726-modules-flat-same-name-variadic.sx | 23 +++++++++++++++ .../0726-modules-flat-same-name-variadic/a.sx | 11 ++++++++ .../0726-modules-flat-same-name-variadic/b.sx | 12 ++++++++ .../0726-modules-flat-same-name-variadic.exit | 1 + ...726-modules-flat-same-name-variadic.stderr | 1 + ...726-modules-flat-same-name-variadic.stdout | 4 +++ src/ir/lower.test.zig | 2 +- src/ir/lower.zig | 28 +++++++++++++------ 8 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 examples/0726-modules-flat-same-name-variadic.sx create mode 100644 examples/0726-modules-flat-same-name-variadic/a.sx create mode 100644 examples/0726-modules-flat-same-name-variadic/b.sx create mode 100644 examples/expected/0726-modules-flat-same-name-variadic.exit create mode 100644 examples/expected/0726-modules-flat-same-name-variadic.stderr create mode 100644 examples/expected/0726-modules-flat-same-name-variadic.stdout diff --git a/examples/0726-modules-flat-same-name-variadic.sx b/examples/0726-modules-flat-same-name-variadic.sx new file mode 100644 index 0000000..55f84ee --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic.sx @@ -0,0 +1,23 @@ +// fix-0102c F1 (issue 0102): two flat FILE imports each author same-name free +// functions whose VARIADIC SHAPE differs. `combine` is fixed-arity in a.sx +// (the first-wins winner) but variadic in b.sx (the shadow); `pick` is the +// reverse. Each module's bare call must pack arguments against ITS OWN +// author's signature — not the first-wins author's. Pre-fix the call path +// re-fetched the first-wins AST by name to drive variadic packing, so b.sx's +// variadic `combine` was packed as if fixed (and its fixed `pick` as if +// variadic) → wrong lowering. Regression for the F1 review finding. +#import "modules/std.sx"; +#import "0726-modules-flat-same-name-variadic/a.sx"; +#import "0726-modules-flat-same-name-variadic/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a combine fixed", from_a_combine() == 30); + report("from_b combine variadic", from_b_combine() == 10); + report("from_a pick variadic", from_a_pick() == 6); + report("from_b pick fixed", from_b_pick() == 5); + 0 +} diff --git a/examples/0726-modules-flat-same-name-variadic/a.sx b/examples/0726-modules-flat-same-name-variadic/a.sx new file mode 100644 index 0000000..55a1456 --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic/a.sx @@ -0,0 +1,11 @@ +// a.sx is the first-wins winner for both names. `combine` is FIXED arity; +// `pick` is VARIADIC. `from_a_*` call them bare — a authors the winner, so +// they resolve through the existing path and pack against a's own shapes. +combine :: (x: s64, y: s64) -> s64 { return x + y; } +pick :: (..xs: []s64) -> s64 { + result := 0; + for xs: (it) { result = result + it; } + result +} +from_a_combine :: () -> s64 { return combine(10, 20); } +from_a_pick :: () -> s64 { return pick(1, 2, 3); } diff --git a/examples/0726-modules-flat-same-name-variadic/b.sx b/examples/0726-modules-flat-same-name-variadic/b.sx new file mode 100644 index 0000000..654f21c --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic/b.sx @@ -0,0 +1,12 @@ +// b.sx is the SHADOW author for both names, with the OPPOSITE shapes: +// `combine` is VARIADIC, `pick` is FIXED. Each `from_b_*` bare call must pack +// against b's OWN author's signature (the F1 fix) — combine sums its variadic +// pack, pick subtracts its two fixed args. +combine :: (..xs: []s64) -> s64 { + result := 0; + for xs: (it) { result = result + it; } + result +} +pick :: (a: s64, b: s64) -> s64 { return b - a; } +from_b_combine :: () -> s64 { return combine(1, 2, 3, 4); } +from_b_pick :: () -> s64 { return pick(2, 7); } diff --git a/examples/expected/0726-modules-flat-same-name-variadic.exit b/examples/expected/0726-modules-flat-same-name-variadic.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0726-modules-flat-same-name-variadic.stderr b/examples/expected/0726-modules-flat-same-name-variadic.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0726-modules-flat-same-name-variadic.stdout b/examples/expected/0726-modules-flat-same-name-variadic.stdout new file mode 100644 index 0000000..2942265 --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.stdout @@ -0,0 +1,4 @@ +from_a combine fixed: ok +from_b combine variadic: ok +from_a pick variadic: ok +from_b pick fixed: ok diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 8a853f2..d827be3 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -1438,7 +1438,7 @@ test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102 try std.testing.expect(lowering.resolveBareCallee("greet", main_path) == .ambiguous); try std.testing.expect(lowering.resolveBareCallee("greet", a_path) == .none); switch (lowering.resolveBareCallee("greet", b_path)) { - .func => |fid| try std.testing.expectEqual(shadow_fid.?, fid), + .func => |resolved| try std.testing.expectEqual(shadow_fid.?, resolved.fid), else => return error.TestUnexpectedResult, } // A name no module authors (and no flat import provides) never routes. diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 03209d1..afc9bb1 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1521,9 +1521,12 @@ pub const Lowering = struct { /// Result of bare-call disambiguation (fix-0102c). pub const BareCallee = union(enum) { - /// Bind the call to this specific author's FuncId — the identity- - /// addressable body lowered by `bareAuthorFuncId` (fix-0102b). - func: FuncId, + /// Bind the call to this specific author — its identity-addressable + /// FuncId (fix-0102b's `bareAuthorFuncId`) AND its `*FnDecl`. The decl + /// travels with the FuncId so every callee-signature decision in the + /// call path (variadic packing, …) reads the RESOLVED author, never a + /// first-wins re-lookup by name (fix-0102c F1). + func: ResolvedAuthor, /// ≥2 distinct flat authors are reachable from the caller and none is /// the caller's own — the bare call can't pick one; require a qualifier. ambiguous, @@ -1532,6 +1535,11 @@ pub const Lowering = struct { none, }; + /// A resolved bare-call author: its FuncId and the `*FnDecl` that defined + /// it, kept together so the call path has ONE source of truth for the + /// callee (no re-fetch by name after resolution). + pub const ResolvedAuthor = struct { fid: FuncId, decl: *const ast.FnDecl }; + /// THE bare-name call resolver (fix-0102c). One canonical traversal over /// fix-0102a's `module_fns` + `flat_import_graph` that routes a bare /// identifier call `name` from `caller_file` to the right same-name author @@ -1561,7 +1569,7 @@ pub const Lowering = struct { if (own_fns.get(name)) |own| { if (winner != null and winner.? == own) return .none; if (!isPlainFreeFn(own)) return .none; - return .{ .func = self.bareAuthorFuncId(own, name, caller_file) }; + return .{ .func = .{ .fid = self.bareAuthorFuncId(own, name, caller_file), .decl = own } }; } } @@ -1584,7 +1592,7 @@ pub const Lowering = struct { const the_path = entry.value_ptr.*; if (winner != null and winner.? == the_one) return .none; if (!isPlainFreeFn(the_one)) return .none; - return .{ .func = self.bareAuthorFuncId(the_one, name, the_path) }; + return .{ .func = .{ .fid = self.bareAuthorFuncId(the_one, name, the_path), .decl = the_one } }; } /// The FuncId for a resolved bare-call author, ensuring its body is lowered. @@ -7560,13 +7568,15 @@ pub const Lowering = struct { d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name}); return Ref.none; }, - .func => |fid| { + .func => |resolved| { + const fid = resolved.fid; const func = &self.module.functions.items[@intFromEnum(fid)]; const ret_ty = func.ret; const params = func.params; - if (self.program_index.fn_ast_map.get(func_name)) |fd| { - self.packVariadicCallArgs(fd, c, &args); - } + // The RESOLVED author's decl drives variadic + // packing — not a first-wins re-lookup by name, + // whose variadic shape may differ (fix-0102c F1). + self.packVariadicCallArgs(resolved.decl, c, &args); const final_args = self.prependCtxIfNeeded(func, args.items); self.coerceCallArgs(final_args, params); if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len); From 8c885048497349397b8a8ce1586c33c48640444e Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 15:07:51 +0300 Subject: [PATCH 07/10] fix(lower): resolved author drives call param target typing [0102c F2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempt-3 fix for the F2 review finding. After resolveBareCallee picks a shadowed same-name author at a normal call site, the call's PARAMETER TARGET TYPING still ran first-wins: resolveCallParamTypes' bare-identifier branch resolved param types via resolveFuncByName(name) / fn_ast_map.get(name) — both keyed by name, not by the resolved author. Because that runs in lowerCall BEFORE the resolveBareCallee routing, a shadow author whose parameter TYPE differs from the first-wins winner had its args lowered against the WINNER's signature (no implicit address-of for a *T param typed as T), then the correctly-resolved shadow FuncId was called with the mis-typed arg — a value bit-cast to a pointer → segfault. The bare-identifier branch now routes through the SAME resolveBareCallee resolver one layer earlier and takes the param target types from the RESOLVED author's lowered func.params (userParamTypes). Only the .func (single resolved author) outcome reroutes; .ambiguous keeps the existing loud call-site diagnostic and .none keeps the first-wins fallback, so single-author / local / std / qualified resolution is byte-for-byte unchanged. Method-call / namespace / foreign / generic branches of resolveCallParamTypes are untouched. The resolver is idempotent (bareAuthorFuncId guards body lowering via lowered_fids) so the extra call from param-type resolution is safe; lowerFunctionBodyInto already saves/restores all lowering state for mid-call reentry. Regression: examples/0728-modules-flat-same-name-paramtype — two flat file imports each author `apply` with a divergent param type (a.sx value `s64` winner, b.sx pointer `*s64` shadow). b.sx's from_b passes a value local to its pointer-param author via implicit address-of (×2 → 42); a.sx's from_a (own == winner) is unchanged (value + 1 → 11). Fails on the pre-fix typing (segfault at from_b); passes after. Gate (worktree): zig build, zig build test (400/400), bash tests/run_examples.sh (464 passed / 0 failed) all green. Matrix 0722-0727 unchanged. Guardrail: m3te builds via the worktree binary (sx build --target ios-sim, exit 0) — single- author / local resolution intact. Default-arg / closure / UFCS / comptime SITES remain first-wins (fix-0102d). --- .../0728-modules-flat-same-name-paramtype.sx | 22 ++++++++++++++++++ .../a.sx | 5 ++++ .../b.sx | 7 ++++++ ...0728-modules-flat-same-name-paramtype.exit | 1 + ...28-modules-flat-same-name-paramtype.stderr | 1 + ...28-modules-flat-same-name-paramtype.stdout | 2 ++ src/ir/lower.zig | 23 +++++++++++++++++++ 7 files changed, 61 insertions(+) create mode 100644 examples/0728-modules-flat-same-name-paramtype.sx create mode 100644 examples/0728-modules-flat-same-name-paramtype/a.sx create mode 100644 examples/0728-modules-flat-same-name-paramtype/b.sx create mode 100644 examples/expected/0728-modules-flat-same-name-paramtype.exit create mode 100644 examples/expected/0728-modules-flat-same-name-paramtype.stderr create mode 100644 examples/expected/0728-modules-flat-same-name-paramtype.stdout diff --git a/examples/0728-modules-flat-same-name-paramtype.sx b/examples/0728-modules-flat-same-name-paramtype.sx new file mode 100644 index 0000000..2810bb9 --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype.sx @@ -0,0 +1,22 @@ +// fix-0102c F2 (issue 0102): two flat FILE imports each author a same-name free +// function `apply` with a DIFFERENT parameter TYPE — a.sx takes a value +// (`x: s64`), b.sx takes a pointer (`x: *s64`). The first-wins import merge +// keeps a.sx's value-typed `apply`, but each module's bare call must type its +// arguments against ITS OWN author. b.sx's `from_b` passes a local `v` to its +// pointer-param `apply` via implicit address-of; before the fix the arg was +// typed against the first-wins (value) winner, lowered as a value, then the +// resolved pointer-param author was called with that value bit-cast to a +// pointer — a segfault. Regression: per-source parameter target typing. +#import "modules/std.sx"; +#import "0728-modules-flat-same-name-paramtype/a.sx"; +#import "0728-modules-flat-same-name-paramtype/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.apply (value param)", from_a() == 11); + report("from_b binds b.apply (pointer param)", from_b() == 42); + 0 +} diff --git a/examples/0728-modules-flat-same-name-paramtype/a.sx b/examples/0728-modules-flat-same-name-paramtype/a.sx new file mode 100644 index 0000000..4b15eb4 --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `apply` taking a VALUE. It is imported first, so it is the +// first-wins merge winner. `from_a` calls `apply` bare on a value local — its +// own author wins (own == winner → existing path, byte-for-byte unchanged). +apply :: (x: s64) -> s64 { return x + 1; } +from_a :: () -> s64 { v : s64 = 10; return apply(v); } diff --git a/examples/0728-modules-flat-same-name-paramtype/b.sx b/examples/0728-modules-flat-same-name-paramtype/b.sx new file mode 100644 index 0000000..ebaa0ec --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype/b.sx @@ -0,0 +1,7 @@ +// b.sx authors its OWN `apply` taking a POINTER. `from_b` passes a value local +// `v` bare; the pointer param must drive implicit address-of so the callee +// mutates `v` in place (×2 → 42). Before the fix, `v` was typed against a.sx's +// value-param winner, lowered as a value, then the resolved pointer-param +// author was called with that value forced to a pointer (segfault). +apply :: (x: *s64) { x.* = x.* * 2; } +from_b :: () -> s64 { v : s64 = 21; apply(v); return v; } diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.exit b/examples/expected/0728-modules-flat-same-name-paramtype.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.stderr b/examples/expected/0728-modules-flat-same-name-paramtype.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.stdout b/examples/expected/0728-modules-flat-same-name-paramtype.stdout new file mode 100644 index 0000000..7b8008c --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.stdout @@ -0,0 +1,2 @@ +from_a binds a.apply (value param): ok +from_b binds b.apply (pointer param): ok diff --git a/src/ir/lower.zig b/src/ir/lower.zig index afc9bb1..7530a24 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -12080,6 +12080,29 @@ pub const Lowering = struct { break :blk scoped; }; + // fix-0102c F2: a genuine flat same-name collision must type this + // call's args against the RESOLVED author's params, not the first-wins + // winner's. Mirror the `lowerCall` routing one layer earlier so arg + // lowering (implicit address-of, coercion) matches the author actually + // called — otherwise a `*T`-param shadow gets a `T` value arg that is + // later bit-cast to a pointer (segfault). Only a plain top-level + // identifier with no scope-mangle / UFCS alias / local shadow routes + // here; `.ambiguous` / `.none` fall to the existing first-wins path so + // single-author / local / std resolution is byte-for-byte unchanged. + if (std.mem.eql(u8, name, bare_name) and + (if (self.scope) |scope| scope.lookup(bare_name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(bare_name, caller_file)) { + .func => |resolved| { + const func = &self.module.functions.items[@intFromEnum(resolved.fid)]; + return self.userParamTypes(func); + }, + .ambiguous, .none => {}, + } + } + } + // Check declared functions if (self.resolveFuncByName(name)) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; From 2131557669aef571df5c6d1333baa2f6c519690e Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 15:31:14 +0300 Subject: [PATCH 08/10] fix(lower): bare-call resolver skips non-plain authors before ambiguity gate [0102c F3] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBareCallee's flat-collect branch counted ALL same-name authors — including #foreign / generic / builtin / #compiler — before the isPlainFreeFn filter, so two flat-imported modules each #foreign-ing the same libc symbol under one sx name returned `.ambiguous` and errored, instead of falling to `.none` and the existing first-wins foreign path (master behavior). Filter authors to plain free functions DURING collection, before the count/ambiguity determination: a non-plain collision now yields 0 reroutable authors -> `.none`; genuine plain-fn collisions still yield >= 2 -> `.ambiguous` (0724 unchanged). The now-redundant single-author isPlainFreeFn check is dropped. Regression: examples/0729-modules-flat-same-name-foreign — two flat FILE imports each #foreign the same libc "abs" under name `absval`; a bare call resolves first-wins and runs (exit 0). Fails-before on this branch (ambiguity error), passes-after. --- examples/0729-modules-flat-same-name-foreign.sx | 14 ++++++++++++++ examples/0729-modules-flat-same-name-foreign/a.sx | 5 +++++ examples/0729-modules-flat-same-name-foreign/b.sx | 2 ++ .../0729-modules-flat-same-name-foreign.exit | 1 + .../0729-modules-flat-same-name-foreign.stderr | 1 + .../0729-modules-flat-same-name-foreign.stdout | 1 + src/ir/lower.zig | 12 ++++++++++-- 7 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 examples/0729-modules-flat-same-name-foreign.sx create mode 100644 examples/0729-modules-flat-same-name-foreign/a.sx create mode 100644 examples/0729-modules-flat-same-name-foreign/b.sx create mode 100644 examples/expected/0729-modules-flat-same-name-foreign.exit create mode 100644 examples/expected/0729-modules-flat-same-name-foreign.stderr create mode 100644 examples/expected/0729-modules-flat-same-name-foreign.stdout diff --git a/examples/0729-modules-flat-same-name-foreign.sx b/examples/0729-modules-flat-same-name-foreign.sx new file mode 100644 index 0000000..1fcb5b1 --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign.sx @@ -0,0 +1,14 @@ +// fix-0102c (issue 0102) F3 regression: two flat FILE imports each `#foreign` +// the SAME libc symbol under the SAME sx name `absval`. The bare-call resolver +// must NOT count `#foreign` (non-plain) authors when deciding ambiguity — it +// filters them out, returns "no rerouting", and the existing first-wins foreign +// dispatch binds the call. A same-name foreign collision therefore compiles and +// runs (master behavior), it does NOT error as ambiguous. +#import "modules/std.sx"; +#import "0729-modules-flat-same-name-foreign/a.sx"; +#import "0729-modules-flat-same-name-foreign/b.sx"; + +main :: () -> s32 { + print("absval = {}\n", absval(-7)); + 0 +} diff --git a/examples/0729-modules-flat-same-name-foreign/a.sx b/examples/0729-modules-flat-same-name-foreign/a.sx new file mode 100644 index 0000000..13b27f3 --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign/a.sx @@ -0,0 +1,5 @@ +// One of two flat authors of `absval`, a `#foreign` libc binding. A consumer +// flat-importing BOTH must NOT see this as an ambiguous bare-call collision — +// foreign authors are never rerouted by the bare-call resolver, so the call +// falls to the existing first-wins foreign dispatch. +absval :: (n: s32) -> s32 #foreign libc "abs"; diff --git a/examples/0729-modules-flat-same-name-foreign/b.sx b/examples/0729-modules-flat-same-name-foreign/b.sx new file mode 100644 index 0000000..527122e --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign/b.sx @@ -0,0 +1,2 @@ +// The second flat author of `absval` — the identical `#foreign` libc binding. +absval :: (n: s32) -> s32 #foreign libc "abs"; diff --git a/examples/expected/0729-modules-flat-same-name-foreign.exit b/examples/expected/0729-modules-flat-same-name-foreign.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0729-modules-flat-same-name-foreign.stderr b/examples/expected/0729-modules-flat-same-name-foreign.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0729-modules-flat-same-name-foreign.stdout b/examples/expected/0729-modules-flat-same-name-foreign.stdout new file mode 100644 index 0000000..bb4defe --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.stdout @@ -0,0 +1 @@ +absval = 7 diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 7530a24..4ccbedd 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1581,7 +1581,16 @@ pub const Lowering = struct { var edge_it = edges.iterator(); while (edge_it.next()) |e| { const fns = module_fns.get(e.key_ptr.*) orelse continue; - if (fns.get(name)) |fd| distinct.put(fd, e.key_ptr.*) catch {}; + // Only plain free functions are eligible for rerouting; generic / + // foreign / builtin / #compiler authors keep their existing + // dispatch. Filtering BEFORE the count gate means a same-name + // collision of non-plain authors (e.g. two flat-imported modules + // each `#foreign`ing the same symbol) is NOT counted as ambiguous — + // it falls through to `.none` and the existing first-wins path. + if (fns.get(name)) |fd| { + if (!isPlainFreeFn(fd)) continue; + distinct.put(fd, e.key_ptr.*) catch {}; + } } if (distinct.count() == 0) return .none; if (distinct.count() >= 2) return .ambiguous; @@ -1591,7 +1600,6 @@ pub const Lowering = struct { const the_one = entry.key_ptr.*; const the_path = entry.value_ptr.*; if (winner != null and winner.? == the_one) return .none; - if (!isPlainFreeFn(the_one)) return .none; return .{ .func = .{ .fid = self.bareAuthorFuncId(the_one, name, the_path), .decl = the_one } }; } From bd24996d8b6ddada4cc647d8100ce9eb7a3ab591 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 16:16:57 +0300 Subject: [PATCH 09/10] fix(lower): route remaining bare-name sites through resolver + close 0102 [0102d] Final 0102 sub-step. fix-0102c landed resolveBareCallee and routed the primary call path + parameter target typing through it, leaving four other bare-name consumer sites on the old first-wins path. Route the SAME resolver through all four, gated exactly as the call path (plain top-level identifier, no scope-mangle / UFCS alias / local shadow; act on .func / .ambiguous, fall through on .none so single-author / local / std / qualified / foreign-single resolution is byte-for-byte unchanged): 1. Default-argument expansion (expandCallDefaults): omitted trailing args fill from the RESOLVED author's defaults, not the winner's. 2. Function-value conversion (closure(fn) and the bare-fn-as-value func_ref / fn-ptr / closure-coercion path): captures the resolved author's FuncId. 3. Free-function UFCS (recv.fn() -> fn(recv, ...)): dispatches the resolved author for the receiver's source. 4. Comptime #run of a bare call: lowerMainAndComptime now sets current_source_file per decl, so a `NAME :: #run f()` in an imported module resolves f from THAT module's flat imports (own-author wins) instead of the main file's perspective (which made it spuriously ambiguous). Regression tests: examples/0730-0734 (default-arg, closure+fn-value, UFCS, comptime #run, UFCS-ambiguity), each fails on pre-fix code and passes after. issues/0102-flat-import-same-signature-collision.md written RESOLVED with the 4-sub-step root cause and regression-test paths. --- ...0730-modules-flat-same-name-default-arg.sx | 20 ++++ .../a.sx | 5 + .../b.sx | 5 + .../0731-modules-flat-same-name-closure.sx | 22 ++++ .../0731-modules-flat-same-name-closure/a.sx | 6 + .../0731-modules-flat-same-name-closure/b.sx | 6 + examples/0732-modules-flat-same-name-ufcs.sx | 19 +++ .../0732-modules-flat-same-name-ufcs/a.sx | 5 + .../0732-modules-flat-same-name-ufcs/b.sx | 4 + ...733-modules-flat-same-name-comptime-run.sx | 21 ++++ .../a.sx | 6 + .../b.sx | 6 + ...4-modules-flat-same-name-ufcs-ambiguous.sx | 16 +++ .../a.sx | 2 + .../b.sx | 3 + ...30-modules-flat-same-name-default-arg.exit | 1 + ...-modules-flat-same-name-default-arg.stderr | 1 + ...-modules-flat-same-name-default-arg.stdout | 2 + .../0731-modules-flat-same-name-closure.exit | 1 + ...0731-modules-flat-same-name-closure.stderr | 1 + ...0731-modules-flat-same-name-closure.stdout | 4 + .../0732-modules-flat-same-name-ufcs.exit | 1 + .../0732-modules-flat-same-name-ufcs.stderr | 1 + .../0732-modules-flat-same-name-ufcs.stdout | 2 + ...3-modules-flat-same-name-comptime-run.exit | 1 + ...modules-flat-same-name-comptime-run.stderr | 1 + ...modules-flat-same-name-comptime-run.stdout | 2 + ...modules-flat-same-name-ufcs-ambiguous.exit | 1 + ...dules-flat-same-name-ufcs-ambiguous.stderr | 5 + ...dules-flat-same-name-ufcs-ambiguous.stdout | 1 + ...02-flat-import-same-signature-collision.md | 95 +++++++++++++++ src/ir/lower.zig | 113 ++++++++++++++++-- 32 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 examples/0730-modules-flat-same-name-default-arg.sx create mode 100644 examples/0730-modules-flat-same-name-default-arg/a.sx create mode 100644 examples/0730-modules-flat-same-name-default-arg/b.sx create mode 100644 examples/0731-modules-flat-same-name-closure.sx create mode 100644 examples/0731-modules-flat-same-name-closure/a.sx create mode 100644 examples/0731-modules-flat-same-name-closure/b.sx create mode 100644 examples/0732-modules-flat-same-name-ufcs.sx create mode 100644 examples/0732-modules-flat-same-name-ufcs/a.sx create mode 100644 examples/0732-modules-flat-same-name-ufcs/b.sx create mode 100644 examples/0733-modules-flat-same-name-comptime-run.sx create mode 100644 examples/0733-modules-flat-same-name-comptime-run/a.sx create mode 100644 examples/0733-modules-flat-same-name-comptime-run/b.sx create mode 100644 examples/0734-modules-flat-same-name-ufcs-ambiguous.sx create mode 100644 examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx create mode 100644 examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx create mode 100644 examples/expected/0730-modules-flat-same-name-default-arg.exit create mode 100644 examples/expected/0730-modules-flat-same-name-default-arg.stderr create mode 100644 examples/expected/0730-modules-flat-same-name-default-arg.stdout create mode 100644 examples/expected/0731-modules-flat-same-name-closure.exit create mode 100644 examples/expected/0731-modules-flat-same-name-closure.stderr create mode 100644 examples/expected/0731-modules-flat-same-name-closure.stdout create mode 100644 examples/expected/0732-modules-flat-same-name-ufcs.exit create mode 100644 examples/expected/0732-modules-flat-same-name-ufcs.stderr create mode 100644 examples/expected/0732-modules-flat-same-name-ufcs.stdout create mode 100644 examples/expected/0733-modules-flat-same-name-comptime-run.exit create mode 100644 examples/expected/0733-modules-flat-same-name-comptime-run.stderr create mode 100644 examples/expected/0733-modules-flat-same-name-comptime-run.stdout create mode 100644 examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit create mode 100644 examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr create mode 100644 examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout create mode 100644 issues/0102-flat-import-same-signature-collision.md diff --git a/examples/0730-modules-flat-same-name-default-arg.sx b/examples/0730-modules-flat-same-name-default-arg.sx new file mode 100644 index 0000000..98da771 --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg.sx @@ -0,0 +1,20 @@ +// fix-0102d site 1 (issue 0102): two flat FILE imports each author a same-name +// free function `cfg` with a DIFFERENT default value for its trailing param — +// a.sx defaults to 10, b.sx to 20. Each module calls `cfg()` bare with the arg +// OMITTED. The omitted trailing arg must be filled from the RESOLVED author's +// default (own-author wins), not the first-wins winner's. Before the fix, +// `from_b`'s `cfg()` expanded to the winner a.sx's default (10) and returned 10. +// Regression: per-source default-argument expansion. +#import "modules/std.sx"; +#import "0730-modules-flat-same-name-default-arg/a.sx"; +#import "0730-modules-flat-same-name-default-arg/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.cfg default (10)", from_a() == 10); + report("from_b binds b.cfg default (20)", from_b() == 20); + 0 +} diff --git a/examples/0730-modules-flat-same-name-default-arg/a.sx b/examples/0730-modules-flat-same-name-default-arg/a.sx new file mode 100644 index 0000000..7d2fa02 --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `cfg` defaulting to 10. Imported first, so it is the first-wins +// merge winner. `from_a` calls `cfg()` with the arg omitted — own == winner → +// existing default-expansion path, byte-for-byte unchanged. +cfg :: (n: s64 = 10) -> s64 { return n; } +from_a :: () -> s64 { return cfg(); } diff --git a/examples/0730-modules-flat-same-name-default-arg/b.sx b/examples/0730-modules-flat-same-name-default-arg/b.sx new file mode 100644 index 0000000..909827c --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg/b.sx @@ -0,0 +1,5 @@ +// b.sx authors its OWN `cfg` defaulting to 20. `from_b`'s `cfg()` omits the +// arg; the omitted trailing default must come from b.sx's author (20), not the +// first-wins winner from a.sx (10). +cfg :: (n: s64 = 20) -> s64 { return n; } +from_b :: () -> s64 { return cfg(); } diff --git a/examples/0731-modules-flat-same-name-closure.sx b/examples/0731-modules-flat-same-name-closure.sx new file mode 100644 index 0000000..53c11a9 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure.sx @@ -0,0 +1,22 @@ +// fix-0102d site 2 (issue 0102): two flat FILE imports each author a same-name +// free function `pick` (a.sx returns 1, b.sx returns 2). Each module takes +// `pick` as a function VALUE — both as `closure(pick)` and as a bare-name +// fn-pointer binding (`g : () -> s64 = pick`). The captured FuncId must be the +// RESOLVED author's (own-author wins), not the first-wins winner's. Before the +// fix, b.sx's `closure(pick)` / `pick`-as-value both captured a.sx's winner +// (1). Regression: per-source function-value conversion (closure + func_ref). +#import "modules/std.sx"; +#import "0731-modules-flat-same-name-closure/a.sx"; +#import "0731-modules-flat-same-name-closure/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a closure binds a.pick (1)", from_a_closure() == 1); + report("from_b closure binds b.pick (2)", from_b_closure() == 2); + report("from_a fn-value binds a.pick (1)", from_a_value() == 1); + report("from_b fn-value binds b.pick (2)", from_b_value() == 2); + 0 +} diff --git a/examples/0731-modules-flat-same-name-closure/a.sx b/examples/0731-modules-flat-same-name-closure/a.sx new file mode 100644 index 0000000..5420616 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure/a.sx @@ -0,0 +1,6 @@ +// a.sx authors `pick` returning 1. Imported first → first-wins winner. +// `from_a_closure` / `from_a_value` take a.sx's own author (own == winner → +// existing path, byte-for-byte unchanged). +pick :: () -> s64 { return 1; } +from_a_closure :: () -> s64 { f := closure(pick); return f(); } +from_a_value :: () -> s64 { g : () -> s64 = pick; return g(); } diff --git a/examples/0731-modules-flat-same-name-closure/b.sx b/examples/0731-modules-flat-same-name-closure/b.sx new file mode 100644 index 0000000..b70cd11 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure/b.sx @@ -0,0 +1,6 @@ +// b.sx authors its OWN `pick` returning 2. Taking `pick` as a value — +// `closure(pick)` or `g : () -> s64 = pick` — must capture b.sx's author (2), +// not the first-wins winner from a.sx (1). +pick :: () -> s64 { return 2; } +from_b_closure :: () -> s64 { f := closure(pick); return f(); } +from_b_value :: () -> s64 { g : () -> s64 = pick; return g(); } diff --git a/examples/0732-modules-flat-same-name-ufcs.sx b/examples/0732-modules-flat-same-name-ufcs.sx new file mode 100644 index 0000000..e817480 --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs.sx @@ -0,0 +1,19 @@ +// fix-0102d site 3 (issue 0102): two flat FILE imports each author a same-name +// free function `bump` (a.sx adds 1, b.sx adds 100). Each module dispatches it +// via free-function UFCS — `v.bump()` lowers to `bump(v)`. The dispatched +// author must be the RESOLVED one for the receiver's source (own-author wins), +// not the first-wins winner. Before the fix, b.sx's `v.bump()` dispatched +// a.sx's winner (+1 → 11). Regression: per-source free-function UFCS dispatch. +#import "modules/std.sx"; +#import "0732-modules-flat-same-name-ufcs/a.sx"; +#import "0732-modules-flat-same-name-ufcs/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a v.bump() binds a.bump (+1)", from_a_ufcs() == 11); + report("from_b v.bump() binds b.bump (+100)", from_b_ufcs() == 110); + 0 +} diff --git a/examples/0732-modules-flat-same-name-ufcs/a.sx b/examples/0732-modules-flat-same-name-ufcs/a.sx new file mode 100644 index 0000000..b96726c --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `bump` adding 1. Imported first → first-wins winner. `from_a`'s +// `v.bump()` resolves a.sx's own author (own == winner → existing UFCS path, +// byte-for-byte unchanged). +bump :: (x: s64) -> s64 { return x + 1; } +from_a_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0732-modules-flat-same-name-ufcs/b.sx b/examples/0732-modules-flat-same-name-ufcs/b.sx new file mode 100644 index 0000000..f7e928b --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs/b.sx @@ -0,0 +1,4 @@ +// b.sx authors its OWN `bump` adding 100. `from_b`'s `v.bump()` must dispatch +// b.sx's author (+100 → 110), not the first-wins winner from a.sx (+1). +bump :: (x: s64) -> s64 { return x + 100; } +from_b_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0733-modules-flat-same-name-comptime-run.sx b/examples/0733-modules-flat-same-name-comptime-run.sx new file mode 100644 index 0000000..9b7d24c --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run.sx @@ -0,0 +1,21 @@ +// fix-0102d site 4 (issue 0102): two flat FILE imports each author a same-name +// free function `compute` (a.sx returns 7, b.sx returns 70) and each evaluates +// it at comptime via `NAME :: #run compute();`. The #run body must resolve the +// bare callee from ITS OWN module's source context (own-author wins), so a.sx's +// const is 7 and b.sx's is 70. Before the fix, the #run body lowered with the +// main file's source perspective, where `compute` is authored by two flat +// imports and neither is main's own — so it was reported AMBIGUOUS and the +// build failed. Regression: per-source comptime #run callee resolution. +#import "modules/std.sx"; +#import "0733-modules-flat-same-name-comptime-run/a.sx"; +#import "0733-modules-flat-same-name-comptime-run/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("a.sx #run binds a.compute (7)", get_a() == 7); + report("b.sx #run binds b.compute (70)", get_b() == 70); + 0 +} diff --git a/examples/0733-modules-flat-same-name-comptime-run/a.sx b/examples/0733-modules-flat-same-name-comptime-run/a.sx new file mode 100644 index 0000000..a2e9489 --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run/a.sx @@ -0,0 +1,6 @@ +// a.sx authors `compute` returning 7 and evaluates it at comptime. Imported +// first → first-wins winner; own == winner, but the #run must still lower in +// a.sx's source context so the bare `compute` resolves at all (not ambiguous). +compute :: () -> s64 { return 7; } +A_VAL :: #run compute(); +get_a :: () -> s64 { return A_VAL; } diff --git a/examples/0733-modules-flat-same-name-comptime-run/b.sx b/examples/0733-modules-flat-same-name-comptime-run/b.sx new file mode 100644 index 0000000..c64d1c6 --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run/b.sx @@ -0,0 +1,6 @@ +// b.sx authors its OWN `compute` returning 70. Its `#run compute()` must bind +// b.sx's author (70) — own-author wins in b.sx's source context — not the +// first-wins winner from a.sx (7). +compute :: () -> s64 { return 70; } +B_VAL :: #run compute(); +get_b :: () -> s64 { return B_VAL; } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx new file mode 100644 index 0000000..ca2bb7c --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx @@ -0,0 +1,16 @@ +// fix-0102d site 3 ambiguity (issue 0102): two flat FILE imports each author a +// same-name free function `dup`, and the MAIN file (which authors neither) +// dispatches it via free-function UFCS `v.dup()`. With two distinct flat +// authors reachable and no own-author to prefer, the call is ambiguous — the +// UFCS dispatch site must emit the loud "qualify the call" diagnostic rather +// than silently binding the first-wins winner. Mirrors 0724 (the bare-call +// ambiguity) one site over. +#import "modules/std.sx"; +#import "0734-modules-flat-same-name-ufcs-ambiguous/a.sx"; +#import "0734-modules-flat-same-name-ufcs-ambiguous/b.sx"; + +main :: () -> s32 { + v : s64 = 10; + print("{}\n", v.dup()); + 0 +} diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx new file mode 100644 index 0000000..1381062 --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx @@ -0,0 +1,2 @@ +// a.sx authors `dup` (+1). One of two distinct flat authors of `dup`. +dup :: (x: s64) -> s64 { return x + 1; } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx new file mode 100644 index 0000000..eb8120b --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx @@ -0,0 +1,3 @@ +// b.sx authors its OWN `dup` (+2) — the second distinct flat author. Main +// imports both and authors neither, so `v.dup()` from main is ambiguous. +dup :: (x: s64) -> s64 { return x + 2; } diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.exit b/examples/expected/0730-modules-flat-same-name-default-arg.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.stderr b/examples/expected/0730-modules-flat-same-name-default-arg.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.stdout b/examples/expected/0730-modules-flat-same-name-default-arg.stdout new file mode 100644 index 0000000..b8b77f3 --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.stdout @@ -0,0 +1,2 @@ +from_a binds a.cfg default (10): ok +from_b binds b.cfg default (20): ok diff --git a/examples/expected/0731-modules-flat-same-name-closure.exit b/examples/expected/0731-modules-flat-same-name-closure.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0731-modules-flat-same-name-closure.stderr b/examples/expected/0731-modules-flat-same-name-closure.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0731-modules-flat-same-name-closure.stdout b/examples/expected/0731-modules-flat-same-name-closure.stdout new file mode 100644 index 0000000..3284dbe --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.stdout @@ -0,0 +1,4 @@ +from_a closure binds a.pick (1): ok +from_b closure binds b.pick (2): ok +from_a fn-value binds a.pick (1): ok +from_b fn-value binds b.pick (2): ok diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.exit b/examples/expected/0732-modules-flat-same-name-ufcs.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.stderr b/examples/expected/0732-modules-flat-same-name-ufcs.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.stdout b/examples/expected/0732-modules-flat-same-name-ufcs.stdout new file mode 100644 index 0000000..5fed027 --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.stdout @@ -0,0 +1,2 @@ +from_a v.bump() binds a.bump (+1): ok +from_b v.bump() binds b.bump (+100): ok diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.exit b/examples/expected/0733-modules-flat-same-name-comptime-run.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.stderr b/examples/expected/0733-modules-flat-same-name-comptime-run.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.stdout b/examples/expected/0733-modules-flat-same-name-comptime-run.stdout new file mode 100644 index 0000000..01a9d4d --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.stdout @@ -0,0 +1,2 @@ +a.sx #run binds a.compute (7): ok +b.sx #run binds b.compute (70): ok diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr new file mode 100644 index 0000000..c2e2c2c --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr @@ -0,0 +1,5 @@ +error: 'dup' is ambiguous; declared by multiple imported modules — qualify the call + --> examples/0734-modules-flat-same-name-ufcs-ambiguous.sx:14:19 + | +14 | print("{}\n", v.dup()); + | ^^^^^ diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout @@ -0,0 +1 @@ + diff --git a/issues/0102-flat-import-same-signature-collision.md b/issues/0102-flat-import-same-signature-collision.md new file mode 100644 index 0000000..6799a91 --- /dev/null +++ b/issues/0102-flat-import-same-signature-collision.md @@ -0,0 +1,95 @@ +# 0102 — flat-import same-name function collision (per-source binding) + +**RESOLVED.** Two **flat** imports (bare `#import "a.sx"` / a flat directory +import, NOT a namespaced `ns :: #import`) that each author a top-level free +function with the **same short name** collided in IR lowering. The flat/ +directory merge keeps exactly **one** author per name in the merged decl list +(first-wins), and every bare-name consumer site — call dispatch, default-arg +expansion, function-value capture, free-function UFCS, comptime `#run` — read +that one **name-keyed** winner. So when module `b.sx` authored its own `greet` +but `a.sx` was imported first, `b.sx`'s own bare `greet()` silently bound +`a.sx`'s author. Unlike issue 0100 (which crashed on a param-count assert when +the AST/FuncId split across modules), this miscompiled **silently**: the wrong +same-name author ran, with no diagnostic. + +The defect had two faces, both rooted in name-keyed identity across a flat +collision: + +1. **Lowering** keyed function bodies by short name (`fn_ast_map` / + `resolveFuncByName` are first-wins), so a shadowed author never got its own + FuncId or body — there was nothing to bind even if a consumer wanted the + per-source author. +2. **Resolution** at every bare-name consumer site re-looked-up the winner by + name, so even once shadow authors had distinct FuncIds, the consumer sites + kept binding the first-wins winner. + +## Fix — four sub-steps (`src/imports.zig`, `src/ir/lower.zig`) + +- **0102a — retain dup authors + identity indexes.** The flat/directory merge + keeps first-wins in the merged scope (unchanged), but now *also* retains + every dropped same-name author in `program_index.module_fns` + (`path → name → *FnDecl`) plus a `flat_import_graph` (`file → flat-import + edges`). Resolution is untouched at this step — the indexes just make the + shadowed authors addressable. + +- **0102b — identity-addressable function lowering.** `fn_decl_fids` + (`*const ast.FnDecl → FuncId`) lets a body be declared + lowered against a + **specific** `*FnDecl` (`lowerFunctionBodyInto` / `bareAuthorFuncId`) instead + of a name. A shadow author gets a fresh same-name FuncId in its own module's + visibility context; the winner keeps the name-keyed slot. `scanDecls` keys + `fn_decl_fids` by the stable `module_fns` `*FnDecl`. + +- **0102c — THE resolver + call path + param typing.** + `resolveBareCallee(name, caller_file) -> .func(ResolvedAuthor) | .ambiguous | + .none` (`src/ir/lower.zig`). It returns `.none` whenever the outcome would + equal first-wins (single author, or own-author == winner), so every + single-author / local / parameter / std / qualified / foreign / generic / + builtin name resolves byte-for-byte as before. Only a genuine flat collision + reroutes: own-author wins; else the caller's flat-reachable authors — `≥2` + distinct → `.ambiguous` (loud "qualify the call" diagnostic), exactly one + differing from the winner → bind it. Routed the **primary call path** and the + call's **parameter target typing** (so a `*T`-param shadow gets implicit + address-of, not a value bit-cast to a pointer → segfault). + +- **0102d — the four remaining bare-name sites.** Routed the SAME resolver + through every other site that resolved a bare callee/function-name by + first-wins, each gated exactly as the call path (plain top-level identifier, + no scope-mangle / UFCS alias / local shadow; act on `.func` / `.ambiguous`, + fall through on `.none`): + 1. **Default-argument expansion** (`expandCallDefaults`): omitted trailing + args fill from the RESOLVED author's defaults, not the winner's. + 2. **Function-value conversion** (`closure(fn)` and the bare-fn-as-value + `func_ref` / fn-ptr / closure-coercion path): captures the resolved + author's FuncId. + 3. **Free-function UFCS** (`recv.fn()` → `fn(recv, …)`): dispatches the + resolved author for the receiver's source. + 4. **Comptime `#run`** of a bare call: `lowerMainAndComptime` now sets + `current_source_file` per decl, so a `NAME :: #run f()` in an imported + module resolves `f` from THAT module's flat imports (own-author wins) + rather than the main file's perspective (where two flat authors made it + spuriously `.ambiguous` and failed the build). + +## Regression tests + +`examples/0722`–`0734` (each a focused multi-file flat-collision scene that +fails on pre-fix code and passes after): + +- `0722-modules-flat-same-name-own` — own-author wins on the call path. +- `0723-modules-flat-vs-namespaced` — a flat author + a namespaced same-name + author don't collide. +- `0724-modules-flat-same-name-ambiguous` — `≥2` flat authors, bare call → + loud diagnostic. +- `0725-modules-flat-dir-same-name` — flat **directory** import collision. +- `0726-modules-flat-same-name-variadic` — per-source variadic packing. +- `0728-modules-flat-same-name-paramtype` — per-source parameter target typing + (value vs pointer param). +- `0729-modules-flat-same-name-foreign` — same-name `#foreign` authors are NOT + rerouted (non-plain authors keep first-wins). +- `0730-modules-flat-same-name-default-arg` — per-source default-arg expansion. +- `0731-modules-flat-same-name-closure` — per-source `closure(fn)` + bare + fn-value capture. +- `0732-modules-flat-same-name-ufcs` — per-source free-function UFCS dispatch. +- `0733-modules-flat-same-name-comptime-run` — per-source comptime `#run` + callee. +- `0734-modules-flat-same-name-ufcs-ambiguous` — `≥2` flat authors, UFCS call + → loud diagnostic (pre-fix: silently bound the winner). diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 4ccbedd..fa6de4b 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1433,6 +1433,14 @@ pub const Lowering = struct { /// Pass 2: Lower main function body and comptime side-effects. fn lowerMainAndComptime(self: *Lowering, decls: []const *const Node) void { for (decls) |decl| { + // A `#run` body lowers in its OWN module's source context (fix-0102d + // site 4): `NAME :: #run f()` written in an imported module must + // resolve a bare `f` from that module's flat imports, not the main + // file's. Without this, `resolveBareCallee` runs with the main + // file's perspective and reports a genuine per-source author as + // ambiguous. Mirrors `scanDecls` / `lowerDecls`, which already set + // the source file per decl. + self.setCurrentSourceFile(decl.source_file); switch (decl.data) { .const_decl => |cd| { if (cd.value.data == .fn_decl) { @@ -3245,7 +3253,31 @@ pub const Lowering = struct { if (!self.lowered_functions.contains(eff_fn_name)) { self.lazyLowerFunction(eff_fn_name); } - if (self.resolveFuncByName(eff_fn_name)) |fid| { + // fix-0102d site 2: taking a bare same-name fn as a VALUE + // (func_ref, fn-ptr / closure coercion) must capture the + // RESOLVED author's FuncId for a genuine flat collision, not + // the first-wins winner's. Plain bare name only; `.ambiguous` + // → loud diagnostic; `.none` → existing first-wins path. + const value_fid: ?FuncId = blk_fv: { + if (std.mem.eql(u8, eff_fn_name, id.name) and + self.program_index.ufcs_alias_map.get(id.name) == null and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(id.name, caller_file)) { + .func => |resolved| break :blk_fv resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, node.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{id.name}); + break :blk self.emitError(id.name, node.span); + }, + .none => {}, + } + } + } + break :blk_fv self.resolveFuncByName(eff_fn_name); + }; + if (value_fid) |fid| { // Auto-promote bare function → closure when target_type is closure if (self.target_type) |tt| { if (!tt.isBuiltin()) { @@ -7272,10 +7304,32 @@ pub const Lowering = struct { // If argument is a bare function name, create a proper closure from it if (arg.data == .identifier) { const fn_name = arg.data.identifier.name; - if (!self.lowered_functions.contains(fn_name)) { - self.lazyLowerFunction(fn_name); - } - if (self.resolveFuncByName(fn_name)) |fid| { + // fix-0102d site 2: `closure(fn)` over a genuine flat same-name + // collision must capture the RESOLVED author's FuncId, not the + // first-wins winner's. Plain bare name only; `.ambiguous` + // → loud diagnostic; `.none` → existing first-wins path. + const closure_fid: ?FuncId = blk_cl: { + if (self.program_index.ufcs_alias_map.get(fn_name) == null and + (if (self.scope) |scope| scope.lookup(fn_name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(fn_name, caller_file)) { + .func => |resolved| break :blk_cl resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, arg.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fn_name}); + return Ref.none; + }, + .none => {}, + } + } + } + if (!self.lowered_functions.contains(fn_name)) { + self.lazyLowerFunction(fn_name); + } + break :blk_cl self.resolveFuncByName(fn_name); + }; + if (closure_fid) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; // Build closure type from user-visible params only — // skip the implicit __sx_ctx param. @@ -8075,12 +8129,33 @@ pub const Lowering = struct { // `recv.fn(args)` → `fn(recv, args)`). Lazily lower the body — // a function reached ONLY via UFCS would otherwise be declared // but never emitted (issue 0063: undefined symbol at link). - if (self.program_index.fn_ast_map.get(fa.field)) |_| { - if (!self.lowered_functions.contains(fa.field)) { - self.lazyLowerFunction(fa.field); + // + // fix-0102d site 3: a free-function UFCS target with a genuine + // flat same-name collision must dispatch to the RESOLVED author + // for the receiver's source, not the first-wins winner. The + // field name is never scope-mangled, so the only gate is a + // known source file; `.ambiguous` → loud diagnostic; `.none` + // → existing first-wins path. + const ufcs_fid: ?FuncId = blk_uf: { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(fa.field, caller_file)) { + .func => |resolved| break :blk_uf resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field}); + return Ref.none; + }, + .none => {}, + } } - } - if (self.resolveFuncByName(fa.field)) |fid| { + if (self.program_index.fn_ast_map.get(fa.field)) |_| { + if (!self.lowered_functions.contains(fa.field)) { + self.lazyLowerFunction(fa.field); + } + } + break :blk_uf self.resolveFuncByName(fa.field); + }; + if (ufcs_fid) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; const ret_ty = func.ret; const params = func.params; @@ -11866,6 +11941,24 @@ pub const Lowering = struct { } break :blk2 scoped; }; + // fix-0102d site 1: for a genuine flat same-name collision the + // omitted trailing args must be filled from the RESOLVED + // author's defaults, not the first-wins winner's. Only a plain + // top-level identifier with no scope-mangle / UFCS alias / + // local shadow routes here; `.ambiguous` declines to expand + // (the call path emits the single diagnostic); `.none` keeps + // the existing first-wins winner, byte-for-byte. + if (std.mem.eql(u8, eff_name, id.name) and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(id.name, caller_file)) { + .func => |resolved| break :blk resolved.decl, + .ambiguous => return null, + .none => {}, + } + } + } break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null; }, // Namespace call `mod.fn(args)` — args map directly to params From 96e3d2d5ae8db5fbb2056fe08daa7fe001a3ad72 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 16:41:01 +0300 Subject: [PATCH 10/10] fix(lower): fn-value site lazily lowers winner only on resolver .none [0102d F1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-fn-as-value site (func_ref / fn-ptr / closure coercion) eagerly lazily-lowered the name-keyed first-wins WINNER before the resolveBareCallee block could reroute a genuine flat same-name collision to its per-source author. Taking a SHADOW author's fn value therefore lowered (and could mis-diagnose) the unused winner's body. Move lazyLowerFunction INSIDE blk_fv onto the `.none` fallback only, mirroring the closure(fn) and free-function UFCS sites: on `.func` use the resolved author's FuncId and never touch the winner; on `.none` fall through to lazy-lower + resolveFuncByName the winner. Regression: examples/0735-modules-flat-same-name-fn-value-winner — the first-wins winner's body is independently broken and never used; a shadow taken as a function value binds the shadow and runs (exit 0) while the winner is not lowered. Fails-before (unresolved symbol in the winner), passes-after. --- ...35-modules-flat-same-name-fn-value-winner.sx | 17 +++++++++++++++++ .../a.sx | 4 ++++ .../b.sx | 7 +++++++ ...-modules-flat-same-name-fn-value-winner.exit | 1 + ...odules-flat-same-name-fn-value-winner.stderr | 1 + ...odules-flat-same-name-fn-value-winner.stdout | 1 + ...0102-flat-import-same-signature-collision.md | 11 +++++++++-- src/ir/lower.zig | 10 ++++++---- 8 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 examples/0735-modules-flat-same-name-fn-value-winner.sx create mode 100644 examples/0735-modules-flat-same-name-fn-value-winner/a.sx create mode 100644 examples/0735-modules-flat-same-name-fn-value-winner/b.sx create mode 100644 examples/expected/0735-modules-flat-same-name-fn-value-winner.exit create mode 100644 examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr create mode 100644 examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout diff --git a/examples/0735-modules-flat-same-name-fn-value-winner.sx b/examples/0735-modules-flat-same-name-fn-value-winner.sx new file mode 100644 index 0000000..639298e --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner.sx @@ -0,0 +1,17 @@ +// fix-0102d site 2 / attempt-2 (issue 0102): the first-wins winner's body is +// independently BROKEN (references an undefined symbol) and is never used. A +// shadow author from a later flat import takes its OWN `pick` as a function +// VALUE (`g : () -> s64 = pick`). The value must bind the shadow (own-author +// wins) and the broken winner must NOT be lowered — a rerouted fn value never +// uses the winner. Before the fix the fn-value site eagerly lazily-lowered the +// name-keyed winner BEFORE the resolver rerouted, surfacing the winner's +// `unresolved 'missing_from_a'` for a function the value never touches. +// Regression: per-source function-value conversion must not pre-lower the winner. +#import "modules/std.sx"; +#import "0735-modules-flat-same-name-fn-value-winner/a.sx"; +#import "0735-modules-flat-same-name-fn-value-winner/b.sx"; + +main :: () -> s32 { + print("from_b_value = {}\n", from_b_value()); + 0 +} diff --git a/examples/0735-modules-flat-same-name-fn-value-winner/a.sx b/examples/0735-modules-flat-same-name-fn-value-winner/a.sx new file mode 100644 index 0000000..c279dbf --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner/a.sx @@ -0,0 +1,4 @@ +// a.sx authors `pick` (imported first → the first-wins name-keyed winner) but +// its body references an undefined symbol, so lowering a.pick AT ALL is an +// error. Nothing uses a.pick — taking b.pick as a value must not pre-lower it. +pick :: () -> s64 { return missing_from_a(); } diff --git a/examples/0735-modules-flat-same-name-fn-value-winner/b.sx b/examples/0735-modules-flat-same-name-fn-value-winner/b.sx new file mode 100644 index 0000000..3ddcb3e --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner/b.sx @@ -0,0 +1,7 @@ +// b.sx authors its OWN `pick` (returns 2) and takes it as a function VALUE. The +// value binds b.pick (own-author wins), never the broken winner from a.sx. +pick :: () -> s64 { return 2; } +from_b_value :: () -> s64 { + g : () -> s64 = pick; + return g(); +} diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit b/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout new file mode 100644 index 0000000..09f6872 --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout @@ -0,0 +1 @@ +from_b_value = 2 diff --git a/issues/0102-flat-import-same-signature-collision.md b/issues/0102-flat-import-same-signature-collision.md index 6799a91..477057d 100644 --- a/issues/0102-flat-import-same-signature-collision.md +++ b/issues/0102-flat-import-same-signature-collision.md @@ -60,7 +60,9 @@ collision: args fill from the RESOLVED author's defaults, not the winner's. 2. **Function-value conversion** (`closure(fn)` and the bare-fn-as-value `func_ref` / fn-ptr / closure-coercion path): captures the resolved - author's FuncId. + author's FuncId. The winner's body is lazily lowered ONLY on the `.none` + fallback — a rerouted value never uses the winner, so taking a shadow as a + value must not pre-lower (and possibly mis-diagnose) the winner's body. 3. **Free-function UFCS** (`recv.fn()` → `fn(recv, …)`): dispatches the resolved author for the receiver's source. 4. **Comptime `#run`** of a bare call: `lowerMainAndComptime` now sets @@ -71,7 +73,7 @@ collision: ## Regression tests -`examples/0722`–`0734` (each a focused multi-file flat-collision scene that +`examples/0722`–`0735` (each a focused multi-file flat-collision scene that fails on pre-fix code and passes after): - `0722-modules-flat-same-name-own` — own-author wins on the call path. @@ -93,3 +95,8 @@ fails on pre-fix code and passes after): callee. - `0734-modules-flat-same-name-ufcs-ambiguous` — `≥2` flat authors, UFCS call → loud diagnostic (pre-fix: silently bound the winner). +- `0735-modules-flat-same-name-fn-value-winner` — the first-wins winner's body + is independently broken and never used; a shadow taken as a function value + binds the shadow and runs while the winner is NOT lowered (pre-fix: the + fn-value site eagerly lowered the winner before the resolver rerouted, + surfacing the winner's error for a function the value never touches). diff --git a/src/ir/lower.zig b/src/ir/lower.zig index fa6de4b..ed02032 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -3250,14 +3250,13 @@ pub const Lowering = struct { const str = self.builder.constString(sid); break :blk self.builder.boxAny(str, .string); } - if (!self.lowered_functions.contains(eff_fn_name)) { - self.lazyLowerFunction(eff_fn_name); - } // fix-0102d site 2: taking a bare same-name fn as a VALUE // (func_ref, fn-ptr / closure coercion) must capture the // RESOLVED author's FuncId for a genuine flat collision, not // the first-wins winner's. Plain bare name only; `.ambiguous` - // → loud diagnostic; `.none` → existing first-wins path. + // → loud diagnostic; `.none` → existing first-wins path. The + // winner is lazily lowered ONLY on `.none` — a rerouted value + // never uses the winner, so its body must not be lowered. const value_fid: ?FuncId = blk_fv: { if (std.mem.eql(u8, eff_fn_name, id.name) and self.program_index.ufcs_alias_map.get(id.name) == null and @@ -3275,6 +3274,9 @@ pub const Lowering = struct { } } } + if (!self.lowered_functions.contains(eff_fn_name)) { + self.lazyLowerFunction(eff_fn_name); + } break :blk_fv self.resolveFuncByName(eff_fn_name); }; if (value_fid) |fid| {