fix: resolve closure/fn-pointer struct-field call types (issue 0201)

Calling a closure or function-pointer value stored in a struct data field
(`box.run(args)`) typed the call as 'unresolved': value returns marshalled
as garbage, failable fields could not be try/catch-ed. Lowering already
dispatched these (call_closure / call_indirect); only CallResolver.plan
lacked a field-access arm. Add a closure/fn-pointer field arm to plan
(before the instance-method check, mirroring lowering's precedence — a
closure-typed field shadows a same-named method) and extend the lowering
closure-field arm to also handle bare .function fields via call_indirect.

Lock: examples/closures/0315-closures-struct-field-call.sx.
This commit is contained in:
agra
2026-06-28 09:18:30 +03:00
parent 69a6ecfb57
commit 45bd561a0d
7 changed files with 201 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
> **RESOLVED** (2026-06-28). Root cause: `CallResolver.plan`'s `.field_access`
> branch had no arm for a struct field whose type is a `.closure` / `.function`
> value, so the call typed as `.unresolved` (the lowering side already dispatched
> it via `call_closure`/`call_indirect`, but the type used for arg-boxing /
> `try`/`catch` came from `plan`). Fix: added a closure/fn-pointer field arm in
> `src/ir/calls.zig` (`plan`), placed before the instance-method check to mirror
> lowering's precedence (a closure-typed field shadows a same-named method), and
> extended the lowering closure-field arm in `src/ir/lower/call.zig` to also
> handle bare `.function` fields (`call_indirect`, ctx-prepend gated on the
> fn-ptr ABI). Regression test:
> `examples/closures/0315-closures-struct-field-call.sx` (closure field with
> args, failable field via `*self` receiver — success + error, and a bare
> fn-pointer field). Suite 852/0.
# 0201 — calling a closure stored in a struct field types as `unresolved`
**Symptom** — Calling a closure value held in a **struct data field**
(`box.run()` where `run: Closure(...) -> R`) does not resolve the call's
return type: the result types as `unresolved`. For a value-returning closure
this silently produces garbage (the result is never marshaled); for a failable
closure (`Closure() -> (T, !)`) `try`/`catch` reject the call with "`catch`
requires a failable expression; operand has type 'unresolved'".
Observed vs expected:
- `b.run()` where `run: Closure() -> i64` prints a garbage pointer-ish integer
(e.g. `4313325408`) instead of the closure's actual return value `7`.
- The IDENTICAL closure bound to a **local variable** (`f := () => {7}; f()`)
works and prints `7`.
- A **void**-returning closure field (`run: Closure() -> void; b.run()`) works
for its side effects (no result to marshal), which is why `std/io.sx`'s
`ThunkBox { run: Closure() -> void }` is unaffected.
**Scope / impact** — Pre-existing, independent of the PLAN-IO-UNIFY Phase 3
capture-typing fix (it reproduces at top level with no closures-capturing-
closures and no nesting). Does **NOT** block Phase 3: the async layer routes all
generic-ness through a captured worker + a void completion closure field, both of
which work. Worth fixing because the value-return case is silent corruption.
**Reproduction** (standalone, only needs the prelude):
```sx
#import "modules/std.sx";
Box :: struct { run: Closure() -> i64; }
main :: () -> i64 {
b : Box = ---;
b.run = () => { 7 };
print("{}\n", b.run()); // prints garbage; expected 7
return 0;
}
```
Failable variant (the shape that surfaced it):
```sx
#import "modules/std.sx";
Box :: struct { run: Closure() -> (i64, !); }
main :: () -> i64 {
b : Box = ---;
b.run = () -> (i64, !) => { 7 };
r := b.run() catch { return -1; }; // error: catch requires a failable
print("{}\n", r); // expression; operand 'unresolved'
return 0;
}
```
**Investigation prompt** — The call-type resolver `CallResolver.plan` in
[src/ir/calls.zig](../src/ir/calls.zig) has a `field_access` callee branch
(~line 230 onward) that handles protocol dispatch, runtime-class instance
methods, `StructName.method` instance methods, and free-fn UFCS — but has **no
arm for a struct field whose type is a `.closure` (or `.function`) value**. When
none of those match it falls through to `.unresolved` (e.g. line ~315 / the tail
return). Compare the BARE-identifier path (~lines 211227) which already handles
`ti == .closure → ti.closure.ret` / `ti == .function → ti.function.ret` for a
local binding — the field-access path needs the equivalent: resolve the
receiver's struct type, look up the named field, and if the field's type is a
closure/function, return its `.ret` with the right call kind (an indirect/closure
call on the loaded field value).
The fix likely needs a new `CallPlan.kind` (or reuse of the closure-call kind)
for "call a closure loaded from a struct field", and the lowering side
(`lowerCall` field-access path in [src/ir/lower/expr.zig](../src/ir/lower/expr.zig))
must load the field then perform an indirect closure call (env + fn-ptr), the
same machinery a local closure-variable call uses. Mind the failable case: once
the return type resolves to a `(T, !)` tuple, `errorChannelOf` and the
`try`/`catch` paths work automatically (verified: a local failable closure call
already does).
**Verification** — run both repros above; expect `7` from the first and `7`
(success) / `-1` (error) from the failable variant, with no `unresolved`
diagnostic. Add a regression example under `examples/closures/` (value + failable
field-call) once fixed.