From eb7636d0f36d7420f58077f3c99bbbbe02fea92c Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 8 Jun 2026 17:19:41 +0300 Subject: [PATCH] fix(stdlib/E4): qualified generic head ns.Box(..) selects the namespace author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A qualified generic type head `ns.Box(args)` was stripped to its bare name and read from the last-wins `struct_template_map`, so the namespace qualifier never selected the template author: `a.Box(s64)` and `b.Box(s64)` (two namespaces each authoring a same-name `Box($T)` with different layouts) both instantiated the global same-name template. The documented ambiguity escape hatch ("qualify it as ns.Box") silently produced the wrong layout. Select the template via the namespace edge (importer -> alias -> NamespaceTarget) instead of the bare map, at both the .call and parameterized-type-expr head sites. Two same-name templates instantiated with the same args would also collide on the mangled name `Box__s64`, so tag the non-canonical author's mangled name with its source (the canonical bare-map author keeps the untagged name -> no churn for single-author generics). Extract `buildGenericStructTemplate` so the bare registration and the new namespace-qualified selection share one template builder. Regression: examples/0772 — two namespaces each authoring Box($T) with different layouts; ns_a.Box(s64) and ns_b.Box(s64) resolve to their own module's template (sizes 8 and 16). Fail-before on 566de96 (a=16 b=16), pass-after (a=8 b=16). --- ...2-modules-qualified-generic-head-author.sx | 29 ++++ .../a.sx | 2 + .../b.sx | 3 + ...modules-qualified-generic-head-author.exit | 1 + ...dules-qualified-generic-head-author.stderr | 1 + ...dules-qualified-generic-head-author.stdout | 2 + readme.md | 7 +- src/ir/lower.zig | 162 +++++++++++++----- 8 files changed, 161 insertions(+), 46 deletions(-) create mode 100644 examples/0772-modules-qualified-generic-head-author.sx create mode 100644 examples/0772-modules-qualified-generic-head-author/a.sx create mode 100644 examples/0772-modules-qualified-generic-head-author/b.sx create mode 100644 examples/expected/0772-modules-qualified-generic-head-author.exit create mode 100644 examples/expected/0772-modules-qualified-generic-head-author.stderr create mode 100644 examples/expected/0772-modules-qualified-generic-head-author.stdout diff --git a/examples/0772-modules-qualified-generic-head-author.sx b/examples/0772-modules-qualified-generic-head-author.sx new file mode 100644 index 0000000..b5707c8 --- /dev/null +++ b/examples/0772-modules-qualified-generic-head-author.sx @@ -0,0 +1,29 @@ +// A qualified generic type head `ns.Box(args)` must instantiate the template +// AUTHORED by `ns`'s module — not the global same-name template that happened to +// win the last-wins `struct_template_map`. `main` imports two namespaces that +// each author a same-name generic `Box($T)` with a DIFFERENT layout (a: one +// field, b: two fields). `a.Box(s64)` and `b.Box(s64)` must resolve to their OWN +// module's template (sizes 8 and 16) and be DISTINCT types, so a field unique to +// b's layout (`y`) is reachable only through `b.Box`. +// +// This is the ambiguity escape hatch made real: when a bare `Box(s64)` is +// ambiguous (two flat same-name authors), the diagnostic tells the user to +// "qualify the reference"; that advice only works if `ns.Box(..)` actually +// selects ns's author. +// +// Regression (Phase E4): before qualified generic-head selection, the head was +// stripped to the bare name and read from the global `struct_template_map`, so +// `a.Box(s64)` and `b.Box(s64)` both instantiated the last-wins template (both +// size 16) — the namespace qualifier was ignored. + +#import "modules/std.sx"; +a :: #import "0772-modules-qualified-generic-head-author/a.sx"; +b :: #import "0772-modules-qualified-generic-head-author/b.sx"; + +main :: () -> s32 { + pa : a.Box(s64) = .{ x = 1 }; + pb : b.Box(s64) = .{ x = 10, y = 20 }; + print("a={} b={}\n", size_of(a.Box(s64)), size_of(b.Box(s64))); + print("pa.x={} pb.x={} pb.y={}\n", pa.x, pb.x, pb.y); + 0 +} diff --git a/examples/0772-modules-qualified-generic-head-author/a.sx b/examples/0772-modules-qualified-generic-head-author/a.sx new file mode 100644 index 0000000..37242c9 --- /dev/null +++ b/examples/0772-modules-qualified-generic-head-author/a.sx @@ -0,0 +1,2 @@ +// Author A's generic `Box` — one s64 field (size 8). +Box :: struct($T: Type) { x: T; } diff --git a/examples/0772-modules-qualified-generic-head-author/b.sx b/examples/0772-modules-qualified-generic-head-author/b.sx new file mode 100644 index 0000000..1167771 --- /dev/null +++ b/examples/0772-modules-qualified-generic-head-author/b.sx @@ -0,0 +1,3 @@ +// Author B's generic `Box` — two s64 fields (size 16). Same template NAME as +// A's, different layout: the qualified head must select by namespace author. +Box :: struct($T: Type) { x: T; y: T; } diff --git a/examples/expected/0772-modules-qualified-generic-head-author.exit b/examples/expected/0772-modules-qualified-generic-head-author.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0772-modules-qualified-generic-head-author.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0772-modules-qualified-generic-head-author.stderr b/examples/expected/0772-modules-qualified-generic-head-author.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0772-modules-qualified-generic-head-author.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0772-modules-qualified-generic-head-author.stdout b/examples/expected/0772-modules-qualified-generic-head-author.stdout new file mode 100644 index 0000000..d1607aa --- /dev/null +++ b/examples/expected/0772-modules-qualified-generic-head-author.stdout @@ -0,0 +1,2 @@ +a=8 b=16 +pa.x=1 pb.x=10 pb.y=20 diff --git a/readme.md b/readme.md index 1a36a0d..82b5fab 100644 --- a/readme.md +++ b/readme.md @@ -422,7 +422,12 @@ enforced at every one of those sites as well: a bare type (including a type-retu function head) that two or more flat imports each declare — with no own author to win — is **ambiguous and rejected** (`type 'X' is ambiguous: it is declared in multiple flat-imported modules; qualify the reference or remove the duplicate -import`) — never a silent pick of one author. (A library's own *internal* type references still resolve: a generic +import`) — never a silent pick of one author. Qualifying the reference is a real +escape hatch for a **generic head** too: `ns.Box(args)` selects the template +AUTHORED by `ns`'s module, so two namespaces each declaring a same-name +`Box($T)` with different layouts stay distinct types (`a.Box(s64)` and +`b.Box(s64)` instantiate their own author's fields), never the global last-wins +template. (A library's own *internal* type references still resolve: a generic struct / pack fn / protocol body is instantiated in the module that defines it, so e.g. `List(T).append`'s `alloc: Allocator` is visible there regardless of the call site.) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index eaba1a4..53389b7 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -14148,6 +14148,15 @@ pub const Lowering = struct { const elem = self.resolveTypeWithBindings(cl.args[1]); return self.module.types.vectorOf(elem, length); } + // A qualified head `ns.Box(..)` selects ns's OWN template via the + // namespace edge, not the bare last-wins `struct_template_map` (so the + // ambiguity escape hatch "qualify it as ns.Box" picks the right author). + if (is_qualified and cl.callee.data.field_access.object.data == .identifier) { + const alias = cl.callee.data.field_access.object.data.identifier.name; + if (self.qualifiedStructTemplate(alias, callee_name)) |tmpl| { + return self.instantiateGenericStruct(&tmpl, cl.args); + } + } // User-defined generic struct if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| { if (!is_qualified and self.headTypeLeak(callee_name, cl.callee.span)) return .unresolved; @@ -14191,6 +14200,18 @@ pub const Lowering = struct { } } + // A qualified base `ns.Box(..)` selects ns's OWN template via the + // namespace edge, not the bare last-wins `struct_template_map` (so the + // ambiguity escape hatch "qualify it as ns.Box" picks the right author). + if (is_qualified) { + if (std.mem.indexOfScalar(u8, pt.name, '.')) |dot| { + const alias = pt.name[0..dot]; + if (self.qualifiedStructTemplate(alias, base_name)) |tmpl| { + return self.instantiateGenericStruct(&tmpl, pt.args); + } + } + } + // User-defined generic struct: look up template and instantiate if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| { if (!is_qualified and self.headTypeLeak(base_name, span)) return .unresolved; @@ -14240,6 +14261,23 @@ pub const Lowering = struct { var name_parts = std.ArrayList(u8).empty; name_parts.appendSlice(self.alloc, tmpl.name) catch {}; + // A qualified `ns.Box(..)` head can select a generic template whose bare + // name also belongs to a DIFFERENT module's same-name template (the one + // that won the last-wins `struct_template_map`). Both would mangle to + // `Box__s64` and the second instantiation would alias the first's layout. + // Tag the NON-canonical author's mangled name with its source so each + // author's instantiation is a distinct type. The canonical (bare-map) + // author keeps the untagged name — no churn for single-author generics. + if (self.program_index.struct_template_map.get(tmpl.name)) |canon| { + const canon_src = canon.source_file orelse ""; + const this_src = tmpl.source_file orelse ""; + if (!std.mem.eql(u8, canon_src, this_src)) { + var tag_buf: [24]u8 = undefined; + const tag = std.fmt.bufPrint(&tag_buf, "$m{x}", .{std.hash.Wyhash.hash(0, this_src)}) catch ""; + name_parts.appendSlice(self.alloc, tag) catch {}; + } + } + // Bind type params to args and build name suffix const saved_type_bindings = self.type_bindings; const saved_value_bindings = self.comptime_value_bindings; @@ -14783,57 +14821,91 @@ pub const Lowering = struct { }; } + /// Build an owned generic-struct template (type params, field names, field + /// type nodes) for `sd`, pinned to its declaring `source_file`. The returned + /// template is heap-owned via `self.alloc`; callers register it under a bare + /// or namespace-qualified key. Null on OOM. + fn buildGenericStructTemplate(self: *Lowering, sd: *const ast.StructDecl, source_file: ?[]const u8) ?StructTemplate { + const owned_name = self.alloc.dupe(u8, sd.name) catch return null; + + const tps = self.alloc.alloc(TemplateParam, sd.type_params.len) catch return null; + for (sd.type_params, 0..) |tp, i| { + const is_type_param = tp.is_variadic or (if (tp.constraint.data == .type_expr) blk: { + const cname = tp.constraint.data.type_expr.name; + // "Type" or a protocol name → type param + break :blk std.mem.eql(u8, cname, "Type") or + self.program_index.protocol_decl_map.contains(cname) or + self.program_index.protocol_ast_map.contains(cname); + } else false); + tps[i] = .{ + .name = self.alloc.dupe(u8, tp.name) catch return null, + // $T: Type, $T: Lerpable, $T: Type/Eq — all are type params. + // `..$Ts: []Type` (variadic) is a type-pack param. Only value + // params like $N: u32 are non-type. + .is_type_param = is_type_param, + .is_variadic = tp.is_variadic, + // Capture a value param's declared type name (`$K: u32` → + // "u32") so instantiation can range-check the folded arg. + .value_type = if (!is_type_param and tp.constraint.data == .type_expr) + (self.alloc.dupe(u8, tp.constraint.data.type_expr.name) catch null) + else + null, + }; + } + + const fnames = self.alloc.alloc([]const u8, sd.field_names.len) catch return null; + for (sd.field_names, 0..) |fn_str, i| { + fnames[i] = self.alloc.dupe(u8, fn_str) catch return null; + } + + // Field type nodes are *Node pointers into the AST; copy the slice of + // pointers (the nodes themselves are heap-allocated). + const ftype_nodes = self.alloc.dupe(*const Node, sd.field_types) catch return null; + + return .{ + .name = owned_name, + .type_params = tps, + .field_names = fnames, + .field_type_nodes = ftype_nodes, + .source_file = source_file, + }; + } + + /// Select the generic struct template AUTHORED by namespace `alias`'s target + /// module (the `importer → alias → NamespaceTarget` edge), not the bare + /// last-wins `struct_template_map`. A qualified head `ns.Box(..)` must + /// instantiate ns's OWN `Box`, even when another module's same-name `Box` won + /// the bare map. Null when the alias is unknown in the current source or its + /// module authors no such generic struct — the caller then falls back to the + /// legacy bare lookup. + fn qualifiedStructTemplate(self: *Lowering, alias: []const u8, member: []const u8) ?StructTemplate { + const edges = self.program_index.namespace_edges orelse return null; + const from = self.current_source_file orelse return null; + const alias_map = edges.getPtr(from) orelse return null; + const target = alias_map.get(alias) orelse return null; + for (target.own_decls) |decl| { + // A top-level struct is authored either as a bare `struct_decl` node + // or a `const_decl` whose value is one (`Box :: struct($T){...}`). + const sd: *const ast.StructDecl = switch (decl.data) { + .struct_decl => |*s| s, + .const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else continue, + else => continue, + }; + if (!std.mem.eql(u8, sd.name, member)) continue; + if (sd.type_params.len == 0) continue; + return self.buildGenericStructTemplate(sd, decl.source_file orelse target.target_module_path); + } + return null; + } + fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl, source_file: ?[]const u8) void { const table = &self.module.types; const name_id = table.internString(sd.name); // Generic structs: store as owned template, don't resolve fields yet if (sd.type_params.len > 0) { - const owned_name = self.alloc.dupe(u8, sd.name) catch return; - - // Build owned type_params - const tps = self.alloc.alloc(TemplateParam, sd.type_params.len) catch return; - for (sd.type_params, 0..) |tp, i| { - const is_type_param = tp.is_variadic or (if (tp.constraint.data == .type_expr) blk: { - const cname = tp.constraint.data.type_expr.name; - // "Type" or a protocol name → type param - break :blk std.mem.eql(u8, cname, "Type") or - self.program_index.protocol_decl_map.contains(cname) or - self.program_index.protocol_ast_map.contains(cname); - } else false); - tps[i] = .{ - .name = self.alloc.dupe(u8, tp.name) catch return, - // $T: Type, $T: Lerpable, $T: Type/Eq — all are type params. - // `..$Ts: []Type` (variadic) is a type-pack param. Only value - // params like $N: u32 are non-type. - .is_type_param = is_type_param, - .is_variadic = tp.is_variadic, - // Capture a value param's declared type name (`$K: u32` → - // "u32") so instantiation can range-check the folded arg. - .value_type = if (!is_type_param and tp.constraint.data == .type_expr) - (self.alloc.dupe(u8, tp.constraint.data.type_expr.name) catch null) - else - null, - }; - } - - // Copy field names - const fnames = self.alloc.alloc([]const u8, sd.field_names.len) catch return; - for (sd.field_names, 0..) |fn_str, i| { - fnames[i] = self.alloc.dupe(u8, fn_str) catch return; - } - - // Field type nodes: these are *Node pointers into the AST. - // Copy the slice of pointers (the nodes themselves are heap-allocated). - const ftype_nodes = self.alloc.dupe(*const Node, sd.field_types) catch return; - - self.program_index.struct_template_map.put(owned_name, .{ - .name = owned_name, - .type_params = tps, - .field_names = fnames, - .field_type_nodes = ftype_nodes, - .source_file = source_file, - }) catch {}; + const tmpl = self.buildGenericStructTemplate(sd, source_file) orelse return; + self.program_index.struct_template_map.put(tmpl.name, tmpl) catch {}; // Register methods under "TemplateName.method" in fn_ast_map for (sd.methods) |method_node| {