From d3944570b9226ffe094c9c44d836f42fdefc5608 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 21 Jun 2026 18:43:49 +0300 Subject: [PATCH] lang: generic $R type-arg resolution + receiver-driven ufcs overload (issues 0156, 0157) 0156 Part 1: a single-type generic $R (parsed as comptime_pack_ref) used as a type-arg in a pack-fn body (Box($R), size_of(Box($R))) hit a missing arm in resolveTypeWithBindings -> .unresolved -> LLVM panic. Fix: mirror resolveTypeArg's comptime_pack_ref arm (look up type_bindings, else a loud diagnostic). Regression: examples/generics/0216. (Part 2 -- deferred .. spread crashes -- reframed OPEN/non-blocking.) 0157: a user generic ufcs method whose name collides with a stdlib re-export resolved via last-wins fn_ast_map with no receiver filtering, so the wrong overload won, $R never bound, and .unresolved reached LLVM. Fix: selectUfcsGenericByReceiver enumerates all module authors, keeps the receiver-binding ones, picks the most receiver-specific (concrete > bare $T), dedups re-exports, and flags a genuine tie as a deterministic 'ambiguous -- qualify' diagnostic. Regression: examples/generics/0217. --- .../0216-generics-typearg-in-pack-fn-body.sx | 28 ++++ ...nerics-ufcs-method-name-collides-stdlib.sx | 24 +++ ...0216-generics-typearg-in-pack-fn-body.exit | 1 + ...16-generics-typearg-in-pack-fn-body.stderr | 1 + ...16-generics-typearg-in-pack-fn-body.stdout | 1 + ...rics-ufcs-method-name-collides-stdlib.exit | 1 + ...cs-ufcs-method-name-collides-stdlib.stderr | 1 + ...cs-ufcs-method-name-collides-stdlib.stdout | 1 + ...156-comptime-pack-captured-into-closure.md | 85 +++++++++++ ...156-comptime-pack-captured-into-closure.sx | 20 +++ ...-method-name-collides-stdlib-unresolved.md | 137 ++++++++++++++++++ src/ir/lower.zig | 23 +++ src/ir/lower/call.zig | 122 +++++++++++++++- 13 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 examples/generics/0216-generics-typearg-in-pack-fn-body.sx create mode 100644 examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx create mode 100644 examples/generics/expected/0216-generics-typearg-in-pack-fn-body.exit create mode 100644 examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stderr create mode 100644 examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stdout create mode 100644 examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.exit create mode 100644 examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stderr create mode 100644 examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stdout create mode 100644 issues/0156-comptime-pack-captured-into-closure.md create mode 100644 issues/0156-comptime-pack-captured-into-closure.sx create mode 100644 issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.md diff --git a/examples/generics/0216-generics-typearg-in-pack-fn-body.sx b/examples/generics/0216-generics-typearg-in-pack-fn-body.sx new file mode 100644 index 00000000..c934f81b --- /dev/null +++ b/examples/generics/0216-generics-typearg-in-pack-fn-body.sx @@ -0,0 +1,28 @@ +// A single-type generic binding (`$R` from `Closure(..$args) -> $R`) used as a +// generic-struct TYPE ARGUMENT inside a variadic-pack function's body — +// `Box($R)` / `size_of(Box($R))` — must resolve `$R` to its bound TypeId. +// +// Regression (issue 0156, part 1): the parser tags every `$name` expression as +// `comptime_pack_ref`, so a single-type `$R` arrived at `resolveTypeWithBindings` +// (the resolver `instantiateGenericStruct` uses for each type-arg) as a +// `comptime_pack_ref` it had no arm for → fell to the catch-all → `.unresolved` +// → an LLVM-emission panic. `resolveTypeArg` already handled this; the fix +// mirrors its arm in `resolveTypeWithBindings` (look up `type_bindings`, else a +// loud "pack used where a single type is required" diagnostic — never a silent +// default type). +#import "modules/std.sx"; + +Box :: struct ($R: Type) { v: R; } + +// A pack fn whose body references `$R` (the closure's return type) in a +// type-arg slot: both `*Box($R)` (annotation) and `size_of(Box($R))`. +boxed :: ufcs (io: Io, worker: Closure(..$args) -> $R, ..$args) -> Box($R) { + b : *Box($R) = xx context.allocator.alloc_bytes(size_of(Box($R))); + b.v = worker(..args); + return b.*; +} + +main :: () { + r := context.io.boxed((a: i64, b: i64) -> i64 => a + b, 40, 2); + print("r: {}\n", r.v); // r: 42 +} diff --git a/examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx b/examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx new file mode 100644 index 00000000..e944863f --- /dev/null +++ b/examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx @@ -0,0 +1,24 @@ +// A user generic ufcs method whose name collides with a stdlib re-export must +// resolve by RECEIVER TYPE, not last-wins. `cancel` here is also re-exported by +// std.sx (io.sx's `cancel :: ufcs (f: *Future($R))`); calling `(@x).cancel()` on +// a `*Box(i64)` must pick the user's `cancel(*Box($R))` and bind `$R := i64`. +// +// Regression (issue 0157): UFCS dispatch resolved the name via a single +// last-wins `fn_ast_map` entry with no receiver filtering, so the stdlib +// `*Future($R)` overload won, `$R` never bound, and `.unresolved` reached LLVM +// → panic. Fixed by selecting the most receiver-specific binding author across +// all module authors (src/ir/lower/call.zig `selectUfcsGenericByReceiver`). +#import "modules/std.sx"; + +Box :: struct ($R: Type) { value: R; flag: i64; } + +// Same name as std.sx's re-exported `cancel` (generic ufcs over `*Future($R)`), +// but a different receiver — the receiver type disambiguates. +cancel :: ufcs (b: *Box($R)) { b.flag = 1; } + +main :: () -> i64 { + x : Box(i64) = ---; x.value = 7; x.flag = 0; + (@x).cancel(); // resolves to the user `cancel` by receiver type + print("{}\n", x.flag); // 1 + return 0; +} diff --git a/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.exit b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.exit @@ -0,0 +1 @@ +0 diff --git a/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stderr b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stderr @@ -0,0 +1 @@ + diff --git a/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stdout b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stdout new file mode 100644 index 00000000..af853329 --- /dev/null +++ b/examples/generics/expected/0216-generics-typearg-in-pack-fn-body.stdout @@ -0,0 +1 @@ +r: 42 diff --git a/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.exit b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.exit @@ -0,0 +1 @@ +0 diff --git a/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stderr b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stderr @@ -0,0 +1 @@ + diff --git a/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stdout b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stdout new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/generics/expected/0217-generics-ufcs-method-name-collides-stdlib.stdout @@ -0,0 +1 @@ +1 diff --git a/issues/0156-comptime-pack-captured-into-closure.md b/issues/0156-comptime-pack-captured-into-closure.md new file mode 100644 index 00000000..17fdb90b --- /dev/null +++ b/issues/0156-comptime-pack-captured-into-closure.md @@ -0,0 +1,85 @@ +# 0156 — deferred `..` spread (pack captured into a closure / tuple spread) crashes the backend + +> **Two bugs were conflated under this number.** Investigation split them: +> +> **Part 1 — `$R` (single-type generic) in a type-arg slot inside a pack-fn body +> → LLVM panic — ✅ FIXED.** The parser tags every `$name` expression as +> `comptime_pack_ref`, so a single-type binding (`$R` from `Closure(..$args) -> +> $R`) used as `Box($R)` / `size_of(Box($R))` reached `resolveTypeWithBindings` +> (the resolver `instantiateGenericStruct` runs each type-arg through) as a +> `comptime_pack_ref` it had no arm for → catch-all `else` → `.unresolved` → +> `src/backend/llvm/types.zig:196` panic. Fix: mirror `resolveTypeArg`'s +> `comptime_pack_ref` arm in `resolveTypeWithBindings` (`src/ir/lower.zig`) — +> look up `type_bindings`, else emit a loud "pack used where a single type is +> required" diagnostic (never a silent default type). Regression test: +> `examples/generics/0216-generics-typearg-in-pack-fn-body.sx` (`size_of(Box($R))` +> in a pack-fn → `r: 42`). +> +> **Part 2 — deferred `..` spread crashes — OPEN, NON-BLOCKING (below).** + +## Part 2 — Symptom (OPEN) + +A comptime variadic pack is **comptime state**, not a runtime value: a spread +`f(..args)` is expanded at the spread site from `pack_arg_nodes` (the original +call-site arg AST, referencing the *caller's* locals). Trying to make a `..` +spread cross a **deferred / value boundary** crashes instead of either working +or diagnosing: + +- **pack captured into a closure** then spread later — `() => { ... worker(..args) ... }` + — **SEGFAULTs at runtime** (the deferred body re-expands `args[i]` from the + spawner's locals, which are gone by the time the closure runs on another + stack), or panics in the backend when types don't resolve. +- **spreading a concrete TUPLE** — `t := .{40, 2}; w(..t)` — **panics** + (`unresolved type reached LLVM emission`): `..` only accepts a comptime pack, + not a runtime aggregate, and the unsupported case degrades to `.unresolved` + rather than a diagnostic. + +Expected: either (a) a `..` spread of a concrete tuple/array is a real feature +that lowers to N positional args, and capturing a pack into a closure +materializes it; or (b) both are rejected with a clean diagnostic at the spread +site. Never a segfault / `.unresolved`-reaches-backend. + +## Reproduction (Part 2) + +```sx +#import "modules/std.sx"; +main :: () { + w := (a: i64, b: i64) -> i64 => a + b; + t := .{40, 2}; + out : i64 = 0; po := @out; + captured :: () => { po.* = w(..t); }; // tuple spread inside a closure + captured(); + print("out: {}\n", out); // panics: unresolved type reached LLVM emission +} +``` + +(Pack-into-closure variant — segfault: see the original repro shape in this +issue's history; `runner :: ufcs (io, worker: Closure(..$args)->i64, ..$args)` +with `captured :: () => { po.* = worker(..args); }` segfaults at runtime.) + +## Why it is NON-BLOCKING for the fiber async work (B1.4a) + +The fiber `async`/`await` layer does NOT need a `..` spread to cross the fiber +boundary. Deferred async is expressed as a **nullary thunk** that captures its +inputs at the call site (where they are live) — `async(io, work: Closure() -> +$R)`, used `context.io.async(() => a + b)`. The user's lambda captures `a`/`b`; +`async` spawns the already-bound nullary closure as a fiber. No pack crosses the +deferral. This is the idiomatic deferred-async shape (cf. `go func(){...}()`), +proven end-to-end (`.sx-tmp/pnullary.sx` → `log: 1 2 3 42 100`). So Part 2 is +filed for its own session, not a B1.4a blocker. + +## Investigation prompt (Part 2) + +Decide the intended semantics of `..` on a concrete value first (consult +`specs.md` §packs). If a `..` spread of a runtime tuple/array SHOULD lower to N +positional args: implement it in the pack-spread call lowering (`src/ir/lower/pack.zig` +`lowerPackElems` / the `.spread_expr` handling) for a concrete-aggregate operand +(emit a GEP+load per element), and make closure capture of a pack materialize +the pack's monomorphized element values into the env. If `..` is intentionally +comptime-pack-only: emit a diagnostic at the spread site when the operand is a +runtime value or a captured pack ("cannot spread a runtime value / a captured +pack; `..` applies to a comptime pack only"), and ensure the capture-analysis +pass rejects a `comptime_pack_ref` capture cleanly — never let `.unresolved` +reach the backend (the segfault path must become a diagnostic). Verify: the +Part-2 repro above either prints `out: 42` or emits one clean diagnostic — never +a segfault / panic. diff --git a/issues/0156-comptime-pack-captured-into-closure.sx b/issues/0156-comptime-pack-captured-into-closure.sx new file mode 100644 index 00000000..5103e5cb --- /dev/null +++ b/issues/0156-comptime-pack-captured-into-closure.sx @@ -0,0 +1,20 @@ +// issue 0156 Part 2 (OPEN, non-blocking) — a deferred `..` spread crashes the +// backend instead of working or diagnosing. `..` is comptime-pack-only; spreading +// a concrete tuple `w(..t)` panics (`unresolved type reached LLVM emission`), and +// capturing a comptime pack into a closure then spreading it segfaults at runtime +// (the deferred body re-expands the pack from the now-gone caller locals). +// +// Part 1 (the `$R`-single-type-arg-in-a-pack-fn LLVM panic) was a SEPARATE bug, +// now FIXED — see examples/generics/0216-generics-typearg-in-pack-fn-body.sx. +// +// Not a fiber-async blocker: deferred async uses a nullary thunk that captures +// its inputs at the call site, so no `..` spread crosses the deferral. +#import "modules/std.sx"; +main :: () { + w := (a: i64, b: i64) -> i64 => a + b; + t := .{40, 2}; + out : i64 = 0; po := @out; + captured :: () => { po.* = w(..t); }; // tuple spread inside a closure → panics + captured(); + print("out: {}\n", out); // want: out: 42 (or a clean diagnostic) +} diff --git a/issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.md b/issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.md new file mode 100644 index 00000000..179c04b1 --- /dev/null +++ b/issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.md @@ -0,0 +1,137 @@ +# 0157 — UFCS generic method whose name collides with a stdlib re-export leaves `$R` unresolved → LLVM panic + +> **RESOLVED.** Root cause: a bare-ufcs call `(recv).name(args)` resolved `name` +> via a single last-wins `fn_ast_map[name]` with NO receiver-type filtering — a +> user `cancel :: ufcs (t: *Task($R))` colliding with the stdlib re-export +> `cancel :: ufcs (f: *Future($R))` picked the wrong one, `$R` never bound, and +> `.unresolved` reached LLVM → panic. Fix (`src/ir/lower/call.zig`): for every +> generic-ufcs dispatch, `selectUfcsGenericByReceiver` enumerates ALL module +> authors of the name (`program_index.module_decls` — covers +> namespaced-imported modules, not just flat-visible ones), keeps those whose +> receiver binds all type-params, and picks the most receiver-SPECIFIC one +> (concrete `*Task($R)` over a bare `(x: $T)`), deduping re-exports by fd +> identity; two distinct equally-specific binders → a deterministic "ambiguous, +> qualify the call" diagnostic; none bind → "cannot infer" (never an +> `.unresolved` into codegen). Regression test: +> `examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx` +> (user `cancel(*Box($R))` vs stdlib `cancel` → resolves to the user method). +> +> Residual (acceptable, no worse than pre-fix): when the receiver-matching +> author isn't in `module_decls` (synthetic), the call falls back to the +> last-wins `fd0` if it binds. Determinism is guaranteed for all enumerable +> authors. The same missing-unbound-param guard also covers the qualified +> struct-method path (call.zig ~960) — left as-is (separate, not hit here). + +## Symptom + +One-line: a user-defined **generic** UFCS method whose name collides with a +stdlib re-exported generic UFCS (`cancel`, re-exported by `std.sx` from +`io.sx`), called via UFCS on a *different* generic struct, leaves that struct's +type parameter `$R` **unresolved**; the `.unresolved` TypeId then reaches LLVM +emission and panics. + +- **Observed:** compiler panic during codegen — + `thread … panic: unresolved type reached LLVM emission — a type resolution + failure was not diagnosed/aborted` + at `src/backend/llvm/types.zig:196` (`.unresolved => @panic(...)`), reached via + `fieldLLVMType` → `toLLVMTypeInfo` → `declareFunction`. +- **Expected:** the UFCS call resolves to the user's `cancel` (receiver type + `*Box($R)` ≠ the stdlib `cancel`'s `*Future($R)`), `$R` binds to `i64`, the + program prints `1`. If the call were genuinely ambiguous/unresolvable, a + **diagnostic** must be emitted (e.g. "cannot infer generic type parameter + 'R'", which the *non-UFCS* form `cancel(c)` already produces) — never a raw + codegen panic. + +## Reproduction + +`issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.sx`: + +```sx +#import "modules/std.sx"; + +Box :: struct ($R: Type) { value: R; flag: i64; } + +// Name collides with std.sx's re-exported `cancel :: io_mod.cancel` +// (generic ufcs over `*Future($R)`). +cancel :: ufcs (b: *Box($R)) { b.flag = 1; } + +main :: () -> i64 { + x : Box(i64) = ---; x.value = 7; x.flag = 0; + (@x).cancel(); // expected: prints 1 + print("{}\n", x.flag); // actual: $R unresolved -> LLVM panic + return 0; +} +``` + +Run: `./zig-out/bin/sx run issues/0157-...sx` → panics. + +### Isolation already done (the trigger is the NAME, nothing else) + +Bisected from the B1.4a async-task work (a user `cancel :: ufcs (t: *Task($R))` +on `std/sched.sx`). All of these are IRRELEVANT to the crash — it reproduces or +not based solely on whether the method name collides with a `std.sx` re-export: + +- **Renaming `cancel`** to any name NOT exported by `std.sx` (`drop`, `m`, + `zz_cancel99`, …) → **compiles & runs, prints `1`.** This is the whole bug: + same body, same struct, same call site — only the name differs. +- Body is irrelevant: `{ b.flag = 1; }` (ignores `$R`), `{ b.value = b.value; }` + (touches `$R`), and `-> $R { return b.value; }` all crash under the name + `cancel`. +- Struct shape is irrelevant: single field `{ value: R; }` and two fields + `{ value: R; flag: i64; }` both crash; field order doesn't matter. +- Construction is irrelevant: an explicit `x : Box(i64) = ---` local crashes + just as a heap `*Box(i64)` returned from another generic ufcs does. No + closures / allocator / fibers needed. +- The sibling stdlib name **`wait`** (also re-exported by `std.sx` from `io.sx`, + generic ufcs over `*Future($R)` returning `$R`) does **NOT** crash when + user-redefined over `*Box($R)` — it resolves and runs. So only *some* + colliding names trip it; `cancel` (a void-returning generic ufcs) does. +- The **non-UFCS** spelling `cancel(c)` instead of `c.cancel()` produces a clean + diagnostic — `error: cannot infer generic type parameter 'R' for 'cancel' + from this call's arguments` — rather than the panic. So the UFCS path is + silently skipping the inference-failure diagnostic the non-UFCS path emits, + and falling through to codegen with `$R` = `.unresolved`. + +`std.sx` re-exports the colliding name at line ~101: +`cancel :: io_mod.cancel;` (and `io.sx:127` `cancel :: ufcs (f: *Future($R))`). + +## Investigation prompt (paste into a fresh session) + +> Fix issue 0157. A user-defined generic UFCS method whose name collides with a +> stdlib re-exported generic UFCS (`std.sx` re-exports `cancel :: io_mod.cancel`, +> a generic `ufcs (f: *Future($R))` from `io.sx`) is mis-resolved when called via +> UFCS on a different generic struct. Repro: +> `./zig-out/bin/sx run issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.sx` +> → `panic: unresolved type reached LLVM emission` at +> `src/backend/llvm/types.zig:196`. Renaming the user method to a non-colliding +> name makes it work, and the **non-UFCS** call form (`cancel(c)`) already emits +> the correct diagnostic `cannot infer generic type parameter 'R' for 'cancel'`. +> +> Suspected area: UFCS method/overload resolution + generic-arg inference (look +> in `src/ir/lower.zig` / the call-lowering + UFCS-candidate-selection path, and +> the generic-instantiation inference that binds `$R` from the receiver argument +> — grep for where UFCS rewrites `recv.f(args)` into the candidate set and where +> a generic callee's type params are inferred from actual arg types). The bug: +> when an overload set for the UFCS name contains BOTH the stdlib +> `cancel(*Future($R))` and the user `cancel(*Box($R))`, the resolver appears to +> bind `$R` against the wrong candidate (or fails to bind it and proceeds anyway) +> for the receiver `*Box(i64)`, leaving `Box`'s `$R` = `.unresolved`. The fix +> likely needs to either (a) pick the candidate whose receiver type unifies with +> the actual receiver (`*Box(i64)` → user `cancel`) BEFORE inferring type params, +> or (b) when inference fails for the chosen candidate, emit the SAME +> "cannot infer generic type parameter 'R'" diagnostic the non-UFCS path emits — +> never fall through to codegen with an `.unresolved` field type. +> +> Verification: the repro must now print `1` (the user `cancel` runs) — OR, if the +> overload truly is meant to be ambiguous, must emit a clean diagnostic instead +> of the LLVM panic. Then move the repro into the feature suite per CLAUDE.md +> (`examples/generics/...` or wherever name-collision UFCS belongs) and re-run +> `zig build test`. Also re-enable the BLOCKED B1.4a work: the suspending +> fiber-task layer (`go`/`wait`/`cancel`) is already implemented in +> `library/modules/std/sched.sx`; its example +> `examples/concurrency/1813-concurrency-fiber-async-suspend.sx` (a `cancel` +> UFCS over `*Task($R)`) is what surfaced this — once 0157 is fixed, seed +> `examples/concurrency/expected/1813-...exit`, capture goldens with +> `-Dname=...1813...sx -Dupdate-goldens`, and verify the full suite. + +## Status: OPEN diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1a20cece..13ffb6dd 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -853,6 +853,27 @@ pub const Lowering = struct { } return .unresolved; } + // Bare `$` in a type position. The parser tags EVERY `$name` + // expression as `comptime_pack_ref` — including a single-type generic + // binding (`$R: Type` in `Closure(..$args) -> $R`), which is NOT a + // value pack. Such a binding lives in `type_bindings`; resolve it the + // same way `resolveTypeArg` does (so `Box($R)` / `size_of(Box($R))` / + // a bare `-> $R` return inside a pack-fn mono resolve `$R` to its bound + // TypeId). Without this arm the node fell through to the catch-all + // `else` → `type_bridge` → `.unresolved` → an LLVM-emission panic + // (issue 0156). A name that is genuinely a value PACK (no single-type + // binding) used where one type is required is a real error — diagnose + // it, never silently fabricate a default type. + if (node.data == .comptime_pack_ref) { + const cpr = node.data.comptime_pack_ref; + if (self.type_bindings) |tb| { + if (tb.get(cpr.pack_name)) |ty| return ty; + } + if (self.diagnostics) |diags| { + diags.addFmt(.err, node.span, "pack '{s}' used where a single type is required", .{cpr.pack_name}); + } + return .unresolved; + } // `*Self` substitution inside runtime-class member declarations // — both runtime and sx-defined — resolves to the class's own // 0-field stub struct (i.e. the opaque Obj-C pointer type). @@ -1854,6 +1875,8 @@ pub const Lowering = struct { // --- moved to lower/call.zig (lower_call) --- pub const CaptureInfo = lower_closure.CaptureInfo; pub const lowerCall = lower_call.lowerCall; + pub const ufcsGenericBindsAll = lower_call.ufcsGenericBindsAll; + pub const selectUfcsGenericByReceiver = lower_call.selectUfcsGenericByReceiver; pub const diagnoseMissingContext = lower_call.diagnoseMissingContext; pub const allocViaContext = lower_call.allocViaContext; pub const callExtern = lower_call.callExtern; diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index a1f8640e..4947fd76 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -26,6 +26,94 @@ const isPackFn = Lowering.isPackFn; const headNameOfCallee = Lowering.headNameOfCallee; const hasComptimeParams = Lowering.hasComptimeParams; +/// True iff every type-parameter of generic ufcs/free-fn `fd` binds to a +/// concrete (present) type given `args_ast` (receiver prepended). A param the +/// argument shapes can't pin is simply absent from the bindings map (e.g. a +/// `*Future($R)` receiver param against a `*Box(i64)` argument never binds `R`). +pub fn ufcsGenericBindsAll(self: *Lowering, fd: *const ast.FnDecl, args_ast: []const *const Node) bool { + var b = self.genericResolver().buildTypeBindings(fd, args_ast); + defer b.deinit(); + for (fd.type_params) |tp| { + if (!b.contains(tp.name)) return false; + } + return true; +} + +/// True if `fd`'s receiver param (`params[0]`) is a CONCRETE/structured type +/// (`*Task($R)`, `Box($R)`, `*Foo`, `[]T`, …) rather than a BARE type-parameter +/// receiver (`$T` / `T`) that matches ANY receiver. Used to prefer the more +/// receiver-specific overload when several same-named generic ufcs bind. +fn ufcsReceiverConcrete(fd: *const ast.FnDecl) bool { + if (fd.params.len == 0) return false; + const te = fd.params[0].type_expr; + const bare: ?[]const u8 = switch (te.data) { + .comptime_pack_ref => |c| c.pack_name, + .identifier => |id| id.name, + .type_expr => |t| t.name, + else => null, // pointer / parameterized / array / slice → concrete + }; + if (bare) |nm| { + for (fd.type_params) |tp| { + if (std.mem.eql(u8, tp.name, nm)) return false; // bare `$T` receiver + } + } + return true; +} + +/// issue 0157: a bare-ufcs name resolves through a single last-wins +/// `fn_ast_map` winner, which may be a same-named generic ufcs whose receiver +/// does NOT match the call's receiver (e.g. a user `cancel :: ufcs (t: +/// *Task($R))` shadowed by the stdlib re-export `cancel :: ufcs (f: +/// *Future($R))`). UFCS dispatch is RECEIVER-driven, so the right candidate may +/// live in a namespaced-imported module that is not flat-visible from the +/// caller file — enumerate ALL module authors of `name` (via `module_decls`) +/// and pick the generic ufcs whose receiver binds ALL its type-params for this +/// call. Called for EVERY generic-ufcs dispatch (not only on bind-failure), so +/// a fully-generic `(x: $T)` last-wins winner can't silently shadow a specific +/// `*Task($R)`. To stay DETERMINISTIC despite the hashmap iteration order (two +/// candidates can both bind): prefer the more receiver-SPECIFIC candidate +/// (concrete > bare-`$T`); dedup re-exports by fd identity; and if two DISTINCT +/// equally-specific authors both bind, set `ambiguous.*` (the caller emits a +/// "qualify the call" diagnostic) rather than silently picking one. Returns null +/// when none bind (a genuine "cannot infer", or the author isn't in +/// `module_decls` — the caller then falls back to the last-wins `fd0` if it +/// binds, else diagnoses; never monomorphizes an `.unresolved` into LLVM). +pub fn selectUfcsGenericByReceiver(self: *Lowering, name: []const u8, args_ast: []const *const Node, ambiguous: *bool) ?*const ast.FnDecl { + ambiguous.* = false; + const decls = self.program_index.module_decls orelse return null; + var best: ?*const ast.FnDecl = null; + var best_concrete = false; + var tie = false; + var it = decls.iterator(); + while (it.next()) |entry| { + const ref = entry.value_ptr.names.get(name) orelse continue; + const fd = Lowering.fnDeclOfRaw(ref) orelse continue; + if (!(fd.type_params.len > 0 and fd.is_ufcs)) continue; + if (!self.ufcsGenericBindsAll(fd, args_ast)) continue; + const concrete = ufcsReceiverConcrete(fd); + if (best) |b| { + if (b == fd) continue; // same decl reached via a re-export — dedup + if (concrete and !best_concrete) { + best = fd; + best_concrete = true; + tie = false; // a strictly more specific candidate wins outright + } else if (concrete == best_concrete) { + tie = true; // two distinct equally-specific authors → ambiguous + } + // else: fd is strictly less specific than best → ignore + } else { + best = fd; + best_concrete = concrete; + } + } + if (best == null) return null; + if (tie) { + ambiguous.* = true; + return null; + } + return best; +} + pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { var c = c_in; // A bare reserved-type-name spelling in call position parses as a @@ -1054,12 +1142,42 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } // Generic ufcs target: monomorphize with the receiver's AST // node prepended so bindings align with fd.params[0]. - if (ufcs_fd) |fd| { - if (fd.type_params.len > 0) { + if (ufcs_fd) |fd0| { + if (fd0.type_params.len > 0) { var eff_args = std.ArrayList(*const Node).empty; defer eff_args.deinit(self.alloc); eff_args.append(self.alloc, effective_obj_node) catch unreachable; for (c.args) |arg| eff_args.append(self.alloc, arg) catch unreachable; + // issue 0157: the last-wins `fn_ast_map` winner may be a + // same-named generic ufcs from another module whose + // receiver doesn't match. Only when it fails to bind all + // its type-params for THIS receiver do we re-select the + // receiver-matching author — so a working call is never + // perturbed; the previously-panicking path either finds + // the right candidate or emits a clean diagnostic + // (never an `.unresolved` reaching codegen). + // Always resolve the receiver-specific author (not just + // on bind-failure): a fully-generic `(x: $T)` last-wins + // winner BINDS for any receiver, so a failure-gated + // re-select would silently keep it over a more specific + // `*Task($R)` — order-dependent dispatch. `selectUfcsGenericByReceiver` + // picks the most specific binder (or flags a genuine + // tie). Fall back to `fd0` only when it isn't enumerable + // in `module_decls` but still binds; diagnose otherwise + // (never monomorphize an `.unresolved` into LLVM). + var fd = fd0; + var amb = false; + if (self.selectUfcsGenericByReceiver(eff_field, eff_args.items, &amb)) |sel| { + fd = sel; + } else if (amb) { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "ambiguous ufcs call '{s}': multiple overloads' receivers match — qualify the call", .{eff_field}); + return Ref.none; + } else if (!self.ufcsGenericBindsAll(fd0, eff_args.items)) { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "cannot infer generic type parameter for ufcs call '{s}' (no visible overload's receiver matches)", .{eff_field}); + return Ref.none; + } var gbindings = self.genericResolver().buildTypeBindings(fd, eff_args.items); defer gbindings.deinit(); const gmangled = self.genericResolver().mangleGenericName(eff_field, fd, &gbindings);