From 45bd561a0d8eec3ee82184bf1ec5defd464acf57 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 28 Jun 2026 09:18:30 +0300 Subject: [PATCH] fix: resolve closure/fn-pointer struct-field call types (issue 0201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0315-closures-struct-field-call.sx | 41 +++++++++ .../0315-closures-struct-field-call.exit | 1 + .../0315-closures-struct-field-call.stderr | 1 + .../0315-closures-struct-field-call.stdout | 4 + ...01-closure-struct-field-call-unresolved.md | 91 +++++++++++++++++++ src/ir/calls.zig | 41 +++++++++ src/ir/lower/call.zig | 22 +++++ 7 files changed, 201 insertions(+) create mode 100644 examples/closures/0315-closures-struct-field-call.sx create mode 100644 examples/closures/expected/0315-closures-struct-field-call.exit create mode 100644 examples/closures/expected/0315-closures-struct-field-call.stderr create mode 100644 examples/closures/expected/0315-closures-struct-field-call.stdout create mode 100644 issues/0201-closure-struct-field-call-unresolved.md diff --git a/examples/closures/0315-closures-struct-field-call.sx b/examples/closures/0315-closures-struct-field-call.sx new file mode 100644 index 00000000..5a803b97 --- /dev/null +++ b/examples/closures/0315-closures-struct-field-call.sx @@ -0,0 +1,41 @@ +// Calling a closure / function-pointer value stored in a STRUCT FIELD +// (`box.run(args)`) resolves the call's return type correctly — value returns +// marshal properly, and a failable field (`Closure(..) -> (T, !)`) is `try`/ +// `catch`-able. The call-type resolver mirrors the lowering dispatch: a +// closure/fn-ptr field is called directly (and shadows a same-named method). +// +// Regression (issue 0201): the field-access call path typed such calls as +// `unresolved` — value returns came out as garbage, failable returns rejected +// `catch`/`try` ("operand has type 'unresolved'"). +#import "modules/std.sx"; + +CB :: struct { + add: Closure(i64, i64) -> i64; // closure field, with args + fp: (i64) -> i64; // bare function-pointer field + work: Closure(i64) -> (i64, !); // failable closure field +} + +triple :: (x: i64) -> i64 { return x * 3; } + +// Field call through a `*CB` receiver inside a method, consuming the failable +// field's error channel. +run_work :: (self: *CB, n: i64) -> i64 { + v := self.work(n) catch { return -1; }; + return v; +} + +main :: () -> i64 { + b : CB = ---; + b.add = (x: i64, y: i64) => x + y; + b.fp = triple; + b.work = (n: i64) -> (i64, !) => { + if n < 0 { raise error.Negative; } + n * 10 + }; + + print("{}\n", b.add(3, 4)); // 7 + print("{}\n", b.fp(5)); // 15 + print("{}\n", run_work(@b, 6)); // 60 + print("{}\n", run_work(@b, -1)); // -1 (error path) + return 0; +} diff --git a/examples/closures/expected/0315-closures-struct-field-call.exit b/examples/closures/expected/0315-closures-struct-field-call.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/closures/expected/0315-closures-struct-field-call.exit @@ -0,0 +1 @@ +0 diff --git a/examples/closures/expected/0315-closures-struct-field-call.stderr b/examples/closures/expected/0315-closures-struct-field-call.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/closures/expected/0315-closures-struct-field-call.stderr @@ -0,0 +1 @@ + diff --git a/examples/closures/expected/0315-closures-struct-field-call.stdout b/examples/closures/expected/0315-closures-struct-field-call.stdout new file mode 100644 index 00000000..bf87970b --- /dev/null +++ b/examples/closures/expected/0315-closures-struct-field-call.stdout @@ -0,0 +1,4 @@ +7 +15 +60 +-1 diff --git a/issues/0201-closure-struct-field-call-unresolved.md b/issues/0201-closure-struct-field-call-unresolved.md new file mode 100644 index 00000000..5603727e --- /dev/null +++ b/issues/0201-closure-struct-field-call-unresolved.md @@ -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 211–227) 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. diff --git a/src/ir/calls.zig b/src/ir/calls.zig index 3d61c639..6e956cab 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -270,6 +270,47 @@ pub const CallResolver = struct { } } } + // Struct field holding a CLOSURE value, called directly + // (`box.run(args)` where `run: Closure(..) -> R`). Mirrors the + // lowering dispatch (call.zig closure-field arm) which runs in the + // value-receiver path BEFORE instance-method dispatch — so a + // closure-typed field shadows a same-named method, exactly as + // lowering binds it. Without this the call typed as `.unresolved` + // (issue 0201): value returns marshalled as garbage, failable + // returns couldn't be `try`/`catch`-ed. Lowering owns the dispatch; + // plan only needs the field's `.ret` so typing matches. + { + var fld_recv = recv_ty; + if (!fld_recv.isBuiltin()) { + const ri = self.l.module.types.get(fld_recv); + if (ri == .pointer) fld_recv = ri.pointer.pointee; + } + if (!fld_recv.isBuiltin()) { + const ri = self.l.module.types.get(fld_recv); + if (ri == .@"struct") { + const field_name_id = self.l.module.types.internString(cfa.field); + for (ri.@"struct".fields) |f| { + if (f.name == field_name_id and !f.ty.isBuiltin()) { + const fti = self.l.module.types.get(f.ty); + if (fti == .closure) return .{ + .kind = .closure, + .return_type = fti.closure.ret, + .target = .{ .named = cfa.field }, + }; + // Bare function-pointer field (`fp: (T) -> R`), + // symmetric with the bare-identifier fn-pointer + // path above — call via `call_indirect`. + if (fti == .function) return .{ + .kind = .fn_pointer, + .return_type = fti.function.ret, + .target = .{ .named = cfa.field }, + .prepends_ctx = self.l.implicit_ctx_enabled and fti.function.call_conv != .c, + }; + } + } + } + } + } // Instance method call: obj.method(args) → StructName.method. { var obj_ty = recv_ty; diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index 593b9762..b35588d9 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -990,6 +990,28 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } else self.alloc.dupe(Ref, args.items) catch unreachable; return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret); } + // Bare function-pointer field (`fp: (T) -> R`, no env) — + // load the field value and call it via `call_indirect`, + // mirroring the bare-identifier / global fn-pointer paths + // (ctx prepend gated on the fn-ptr's own ABI). + if (fti == .function) { + var agg = obj; + const oi = self.module.types.get(obj_ty); + if (oi == .pointer) { + agg = self.builder.load(obj, oi.pointer.pointee); + } + const fp_val = self.builder.structGet(agg, @intCast(fi), f.ty); + // Coerce user args to the fn-ptr's param types (issue 0186). + coerceClosureCallArgs(self, args.items, fti.function.params); + var final_args = std.ArrayList(Ref).empty; + defer final_args.deinit(self.alloc); + if (self.fnPtrTypeWantsCtx(f.ty)) { + final_args.append(self.alloc, self.current_ctx_ref) catch unreachable; + } + final_args.appendSlice(self.alloc, args.items) catch unreachable; + const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable; + return self.builder.emit(.{ .call_indirect = .{ .callee = fp_val, .args = owned } }, fti.function.ret); + } } } }