diff --git a/examples/0740-modules-flat-same-name-ufcs-typing.sx b/examples/0740-modules-flat-same-name-ufcs-typing.sx new file mode 100644 index 0000000..ba37859 --- /dev/null +++ b/examples/0740-modules-flat-same-name-ufcs-typing.sx @@ -0,0 +1,17 @@ +// Regression (issue 0102, Phase C): value-receiver free-function UFCS under a +// flat same-name collision must be TYPED as the author lowering dispatches. +// a.sx (imported first → first-wins winner) authors `tag -> string`; b.sx +// authors its OWN `tag -> s64`. In b.sx, `v.tag()` dispatches b.tag (s64), but +// before the fix the call PLAN typed it as a.tag (string, first-wins) — so the +// pack-fn `print` boxed the raw s64 110 as a string pointer and dereferenced +// 0x6e → segfault. `CallResolver.plan` now selects the SAME author the lowering +// call-path binds, so plan-typing and dispatch can't disagree (fix-0102 F2). +#import "modules/std.sx"; +#import "0740-modules-flat-same-name-ufcs-typing/a.sx"; +#import "0740-modules-flat-same-name-ufcs-typing/b.sx"; + +main :: () -> s32 { + show_a(); // a-side: own == winner → string, byte-for-byte unchanged + show_b(); // b-side: shadow author → s64, typed + dispatched as b.tag + 0 +} diff --git a/examples/0740-modules-flat-same-name-ufcs-typing/a.sx b/examples/0740-modules-flat-same-name-ufcs-typing/a.sx new file mode 100644 index 0000000..bdde05a --- /dev/null +++ b/examples/0740-modules-flat-same-name-ufcs-typing/a.sx @@ -0,0 +1,6 @@ +#import "modules/std.sx"; +// a.sx authors `tag` returning a string; imported first → first-wins winner. +// `show_a`'s `v.tag()` is the caller's OWN author (own == winner → existing UFCS +// path, byte-for-byte unchanged): typed AND dispatched as a.tag (string). +tag :: (x: s64) -> string { return "a-string"; } +show_a :: () { v : s64 = 10; print("a: v.tag() = {}\n", v.tag()); } diff --git a/examples/0740-modules-flat-same-name-ufcs-typing/b.sx b/examples/0740-modules-flat-same-name-ufcs-typing/b.sx new file mode 100644 index 0000000..3e415f0 --- /dev/null +++ b/examples/0740-modules-flat-same-name-ufcs-typing/b.sx @@ -0,0 +1,7 @@ +#import "modules/std.sx"; +// b.sx authors its OWN `tag` returning s64. `show_b`'s `v.tag()` must be both +// dispatched AND typed as b.tag (s64 = 110), not the first-wins winner from a.sx +// (string). `print` types each arg from the call plan, so a mistype here boxes +// the s64 as a string pointer → segfault before the fix. +tag :: (x: s64) -> s64 { return x + 100; } +show_b :: () { v : s64 = 10; print("b: v.tag() = {}\n", v.tag()); } diff --git a/examples/expected/0740-modules-flat-same-name-ufcs-typing.exit b/examples/expected/0740-modules-flat-same-name-ufcs-typing.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0740-modules-flat-same-name-ufcs-typing.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0740-modules-flat-same-name-ufcs-typing.stderr b/examples/expected/0740-modules-flat-same-name-ufcs-typing.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0740-modules-flat-same-name-ufcs-typing.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0740-modules-flat-same-name-ufcs-typing.stdout b/examples/expected/0740-modules-flat-same-name-ufcs-typing.stdout new file mode 100644 index 0000000..aa5e951 --- /dev/null +++ b/examples/expected/0740-modules-flat-same-name-ufcs-typing.stdout @@ -0,0 +1,2 @@ +a: v.tag() = a-string +b: v.tag() = 110 diff --git a/src/ir/calls.zig b/src/ir/calls.zig index bcc71a6..bf7f8f4 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -157,26 +157,19 @@ pub const CallResolver = struct { if (std.mem.eql(u8, bare_name, "type_of")) return refl(bare_name, .any); if (std.mem.eql(u8, bare_name, "field_value")) return refl(bare_name, .any); // Plain bare same-name flat collision (R5 §C): route through the ONE - // selector so `plan` reads the SAME author the lowering call-path - // binds — they can no longer disagree (fix-0102 F2). The gate mirrors - // `lowerCall`'s: a plain top-level identifier with no scope-mangle / - // local shadow. A generic / foreign / builtin author is not plain-free - // so the selector returns `.none`; `.ambiguous` / `.none` fall through - // to the first-wins path below, byte-for-byte. - if (std.mem.eql(u8, name, bare_name) and - (if (self.l.scope) |scope| scope.lookup(bare_name) == null else true)) - { - if (self.l.current_source_file) |caller_file| { - switch (self.l.selectPlainCallableAuthor(bare_name, caller_file)) { - .func => |sf| return .{ - .kind = .direct_fn, - .return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void, - .target = .{ .selected = sf }, - .expands_defaults = defaultsFor(sf.decl, c.args.len), - }, - .ambiguous, .none => {}, - } - } + // author producer `selectedFreeAuthor` so `plan` types the call as the + // SAME author the lowering call-path binds — they can no longer + // disagree (fix-0102 F2). A generic / foreign / builtin author is not + // plain-free so the producer returns `.none`; `.ambiguous` / `.none` + // fall through to the first-wins path below, byte-for-byte. + switch (self.selectedFreeAuthor(c)) { + .func => |sf| return .{ + .kind = .direct_fn, + .return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void, + .target = .{ .selected = sf }, + .expands_defaults = defaultsFor(sf.decl, c.args.len), + }, + .ambiguous, .none => {}, } // Generic function — infer return type via type bindings. if (self.l.program_index.fn_ast_map.get(name)) |fd| { @@ -322,6 +315,26 @@ pub const CallResolver = struct { // the plan carries `prepends_receiver`, distinct from a true // namespace call (`pkg.fn()`), which must NOT prepend. if (self.objectIsValue(cfa.object)) { + // Value-receiver free-fn UFCS (`recv.fn(args)` → `fn(recv, args)`) + // routes through the SAME author producer `selectedFreeAuthor` as a + // bare call, so the planned target / return type IS the author + // lowering dispatches — they can't disagree under a flat same-name + // collision (fix-0102 F2 / R5 §C). Without this, plan typed the + // first-wins winner while lowering bound the selected shadow, + // mis-tagging the call's result (a string-typed winner over an s64 + // shadow boxes a raw int as a string pointer → segfault). + // `.ambiguous` / `.none` fall through to the first-wins path below, + // unchanged. + switch (self.selectedFreeAuthor(c)) { + .func => |sf| return .{ + .kind = .free_fn_ufcs, + .return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void, + .target = .{ .selected = sf }, + .prepends_receiver = true, + .expands_defaults = defaultsFor(sf.decl, c.args.len + 1), + }, + .ambiguous, .none => {}, + } if (self.l.resolveFuncByName(cfa.field)) |fid| { const func = &self.l.module.functions.items[@intFromEnum(fid)]; return .{ @@ -434,6 +447,47 @@ pub const CallResolver = struct { return .{ .kind = .unresolved, .return_type = .unresolved }; } + /// THE single producer of the bare / value-UFCS same-name call author + /// verdict (R5 §#3). Both `plan` (typing, via its `.selected` arm) and + /// `lowerCall` (default expansion / param typing / dispatch) consume THIS one + /// result, so they can never pick different same-name authors for the same + /// call (fix-0102 F2). Side-effect-free: it consults ONLY the author selector + /// (`selectPlainCallableAuthor`) — never return-type inference or type-arg + /// resolution — so `lowerCall` can compute it eagerly without emitting a + /// premature diagnostic the full `plan` would (e.g. `cast(type)`'s type-arg). + /// + /// - identifier callee: a plain bare call. The gate mirrors `plan`/`lowerCall` + /// — a builtin, a scope-mangled / UFCS-aliased name, or a locally-shadowed + /// name is never a same-name free-fn collision → `.none`. + /// - field-access callee with a VALUE receiver: a free-function UFCS + /// (`recv.fn(args)`). A namespace / type prefix receiver → `.none`. The + /// verdict over-selects a struct-method / protocol / foreign call whose + /// field happens to name a free fn, but those dispatch BEFORE the free-fn + /// UFCS path in both `plan` and `lowerCall`, so the verdict is consumed only + /// when the call truly is a free-fn UFCS. + pub fn selectedFreeAuthor(self: CallResolver, c: *const ast.Call) Lowering.BareCallee { + const caller_file = self.l.current_source_file orelse return .none; + switch (c.callee.data) { + .identifier => |id| { + const bare_name = id.name; + if (Lowering.resolveBuiltin(bare_name) != null) return .none; + const scoped = if (self.l.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name; + const name = if (self.l.program_index.ufcs_alias_map.get(bare_name)) |target| + (if (self.l.scope) |scope| scope.lookupFn(target) orelse target else target) + else + scoped; + if (!std.mem.eql(u8, name, bare_name)) return .none; + if (self.l.scope) |scope| if (scope.lookup(bare_name) != null) return .none; + return self.l.selectPlainCallableAuthor(bare_name, caller_file); + }, + .field_access => |cfa| { + if (!self.objectIsValue(cfa.object)) return .none; + return self.l.selectPlainCallableAuthor(cfa.field, caller_file); + }, + else => return .none, + } + } + fn refl(name: []const u8, rt: TypeId) CallPlan { return .{ .kind = .reflection, .return_type = rt, .target = .{ .named = name } }; } diff --git a/src/ir/lower.zig b/src/ir/lower.zig index bc8cc93..775a321 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -7361,10 +7361,27 @@ pub const Lowering = struct { c = rewritten; }; } + // fix-0102 F2 / R5 §C: select the bare / value-UFCS same-name call author + // ONCE, via `CallResolver.selectedFreeAuthor` — the SINGLE producer of + // this verdict, the exact same one `CallResolver.plan` consumes for typing. + // The call-path consumers (default expansion, param typing, dispatch) all + // read THIS one author object, so plan-typing and lowering-dispatch can no + // longer disagree about which same-name function the call names, and the + // shadow's FuncId is materialized at most once (into `author_verdict`). + // `selectedFreeAuthor` is side-effect-free (it only runs the author + // selector — no return-type inference / type-arg resolution), so computing + // it eagerly here can't emit a premature diagnostic the way the full plan + // would. + var author_verdict = self.callResolver().selectedFreeAuthor(c); + const sel_author: ?*SelectedFunc = switch (author_verdict) { + .func => |*sf| sf, + else => null, + }; + const author_ambiguous = author_verdict == .ambiguous; // Expand default parameter values for bare identifier callees: // when the caller omits trailing positional args, fill them in // from the callee's `param: T = expr` declarations. - if (self.expandCallDefaults(c)) |expanded| c = expanded; + if (self.expandCallDefaults(c, sel_author, author_ambiguous)) |expanded| c = expanded; // Check reflection builtins first (before lowering args — some args are type names, not values) if (c.callee.data == .identifier) { if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref; @@ -7515,7 +7532,7 @@ pub const Lowering = struct { var args = std.ArrayList(Ref).empty; defer args.deinit(self.alloc); // Try to resolve param types for target_type context - const param_types = self.resolveCallParamTypes(c); + const param_types = self.resolveCallParamTypes(c, sel_author); // For enum_literal callees (.Variant(payload)), resolve the payload target type // from the union field type so struct literal fields get proper coercion var enum_payload_ty: ?TypeId = null; @@ -7728,41 +7745,33 @@ 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 - // (`selectPlainCallableAuthor` 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.selectPlainCallableAuthor(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 => |sf| { - var selected = sf; - const fid = self.selectedFuncId(&selected, func_name); - const func = &self.module.functions.items[@intFromEnum(fid)]; - const ret_ty = func.ret; - const params = func.params; - // 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(selected.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); - return self.builder.call(fid, final_args, ret_ty); - }, - } - } + // fix-0102c / R5 §C: a genuine flat same-name collision — bind the + // author the call resolver selected (own-author-wins, or the single + // flat-reachable author), or reject a bare call to a name ≥2 + // imported modules author. `selectedFreeAuthor` (computed once + // above, and the exact verdict `plan` consumes for typing) is the + // single producer; lowering CONSUMES it rather than re-resolving + // the name, so typing and dispatch read the SAME author and can't + // disagree (fix-0102 F2). Reached only for an identifier callee, so + // `sel_author` / `author_ambiguous` here are the bare verdict. + if (author_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; + } + if (sel_author) |sf| { + const fid = self.selectedFuncId(sf, func_name); + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + // 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(sf.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); + 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| { @@ -8247,26 +8256,24 @@ pub const Lowering = struct { // a function reached ONLY via UFCS would otherwise be declared // but never emitted (issue 0063: undefined symbol at link). // - // 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. + // fix-0102d site 3 / R5 §C: a free-function UFCS target with a + // genuine flat same-name collision dispatches to the author the + // call PLAN selected for the receiver's source — the SAME author + // plan typed the call's result as, so dispatch and typing can't + // disagree (fix-0102 F2; without this, a string-typed winner over + // an s64 shadow boxes a raw int as a string pointer → segfault). + // The plan is the single producer; lowering consumes its verdict + // (`sel_author` / `cplan.ambiguous_collision`, computed once above) + // rather than re-resolving the field name. `.ambiguous` → loud + // diagnostic; otherwise the existing first-wins lazy path. const ufcs_fid: ?FuncId = blk_uf: { - if (self.current_source_file) |caller_file| { - switch (self.selectPlainCallableAuthor(fa.field, caller_file)) { - .func => |sf| { - var selected = sf; - break :blk_uf self.selectedFuncId(&selected, fa.field); - }, - .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 (author_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; + } + if (sel_author) |sf| { + break :blk_uf self.selectedFuncId(sf, fa.field); } if (self.program_index.fn_ast_map.get(fa.field)) |_| { if (!self.lowered_functions.contains(fa.field)) { @@ -12088,7 +12095,7 @@ pub const Lowering = struct { /// callee's signature provides defaults for them, return a fresh Call /// node with the defaults filled in. Returns null when no expansion is /// needed (callee unknown, all args provided, or no defaults available). - fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call { + fn expandCallDefaults(self: *Lowering, c: *const ast.Call, sel_author: ?*const SelectedFunc, author_ambiguous: bool) ?*ast.Call { const fd = blk: { switch (c.callee.data) { .identifier => |id| { @@ -12099,30 +12106,19 @@ 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.selectPlainCallableAuthor(id.name, caller_file)) { - // Default expansion needs only the author's decl - // (its param defaults) — never the FuncId. Reading - // `sf.decl` here keeps `materialized` null, so a - // bare call whose body is never emitted (e.g. only - // its defaults are inspected) does not lower the - // author (0102d). - .func => |sf| break :blk sf.decl, - .ambiguous => return null, - .none => {}, - } - } - } + // fix-0102d site 1 / R5 §C: for a genuine flat same-name + // collision the omitted trailing args are filled from the + // author the call resolver selected — its `*FnDecl` defaults — + // not the first-wins winner's. lowering consumes the ONE author + // verdict (`selectedFreeAuthor`, computed once in `lowerCall`) + // rather than re-resolving the name, so default expansion and + // dispatch agree on the author. `.ambiguous` declines to expand + // (the call path emits the single diagnostic); a non-collision + // call keeps the existing first-wins winner, byte-for-byte. + // Reading `.decl` only keeps `materialized` null — inspecting + // defaults must not lower the author (0102d). + if (author_ambiguous) return null; + if (sel_author) |sf| break :blk sf.decl; break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null; }, // Namespace call `mod.fn(args)` — args map directly to params @@ -12192,7 +12188,7 @@ pub const Lowering = struct { return types_list.items; } - fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call) []const TypeId { + fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?*SelectedFunc) []const TypeId { // Method calls: obj.method(args) — resolve param types from the method signature, // skipping the first param (self) since it's prepended later. if (c.callee.data == .field_access) { @@ -12345,29 +12341,20 @@ 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.selectPlainCallableAuthor(bare_name, caller_file)) { - .func => |sf| { - var selected = sf; - const fid = self.selectedFuncId(&selected, bare_name); - const func = &self.module.functions.items[@intFromEnum(fid)]; - return self.userParamTypes(func); - }, - .ambiguous, .none => {}, - } - } + // fix-0102c F2 / R5 §C: a genuine flat same-name collision must type this + // call's args against the author the call resolver selected, not the + // first-wins winner's params. lowering consumes the ONE author verdict + // (`selectedFreeAuthor`, computed once in `lowerCall`) rather than + // re-resolving the name, so arg lowering (implicit address-of, coercion) + // matches the author actually dispatched — otherwise a `*T`-param shadow + // gets a `T` value arg that is later bit-cast to a pointer (segfault). The + // FuncId materializes into the SHARED verdict (once), so dispatch reuses + // it. A non-collision call falls to the existing first-wins path below, + // byte-for-byte. + if (sel_author) |sf| { + const fid = self.selectedFuncId(sf, bare_name); + const func = &self.module.functions.items[@intFromEnum(fid)]; + return self.userParamTypes(func); } // Check declared functions