Files
sx/issues/0186-closure-optional-param-arg-coercion.md
agra 820cd62fa1 docs: file issues 0185-0188
File four issue write-ups discovered alongside the 0179 work:
- 0185: binary-op operand auto-unwrap silently miscompiles a NULL ?T
- 0186: closure VALUE call does not coerce arg to ?T parameter
- 0187: lambda with inferred return type + block body with early returns
  mis-infers its return type
- 0188: closure-VALUE calls skip argument validation (arity + tuple spread)
2026-06-25 13:57:56 +03:00

92 lines
4.6 KiB
Markdown

# 0186 — calling a closure VALUE with a `?T` parameter does not coerce the argument
> **RESOLVED.** Root cause as diagnosed below: the closure-VALUE call path in
> `src/ir/lower/call.zig` lowered args without coercing to the closure's
> declared parameter types. Two-part fix: (1) `resolveCallParamTypes` now
> returns the closure/function value's param types for an identifier callee
> bound to a closure/fn VALUE in scope (so args lower with the right
> `target_type`; precedes function-name resolution since a local value shadows
> a function); (2) new free fn `coerceClosureCallArgs` coerces each
> already-lowered user arg to the closure's param type via `coerceToType`,
> applied at all three `call_closure` emission sites (local-variable callee,
> struct-field callee, force-unwrap/expr callee) AND the local function-pointer
> `call_indirect` path (which had the identical gap — an adversarial review
> flagged that the `.function` branch of (1) typed fn-ptr args but never
> coerced them). Now a concrete arg wraps present, `null` → absent — matching a
> top-level fn call, for both closure values and fn-pointer values. Regression:
> `examples/closures/0312-closure-optional-param-arg-coercion.sx` (local +
> struct-field closure + fn-pointer value, concrete + null args).
>
> **Discovered while verifying (separate, NOT fixed here):** a lambda with an
> INFERRED return type (no `-> T`) and a block body with early `return`s
> mis-infers its return type (LLVM verifier failure) even with no optionals.
> Filed as issue 0187. (The 0312 regression uses an explicit `-> i64` to avoid
> it.)
## Symptom
When a closure value/variable (a `:=`-bound lambda, or any closure passed as a
value) has a parameter of optional type `?T`, the call site does NOT coerce the
argument to `?T`:
- A concrete argument (`pick(7)`) is NOT wrapped to a present `?i64` — inside
the body the param reads as ABSENT (`p == null` is true), so the closure
silently returns the wrong branch.
- A `null` argument (`pick(null)`) lowers `null` as a bare `ptr null` against a
`{i64, i1}` parameter, which fails LLVM verification:
`Call parameter type does not match function signature! ptr null { i64, i1 }`.
The SAME signature/body as a TOP-LEVEL function works correctly (e.g. issue
0900's `guard`), so the bug is specific to the closure-VALUE call path
(`src/ir/lower/call.zig`'s closure/fn-pointer call lowering), not optionals or
flow narrowing. Found during the adversarial review of issues 0179 / 0185.
## Reproduction
```sx
#import "modules/std.sx";
norm :: (p: ?i64) -> i64 { if p == null { return -1; } return 99; }
main :: () {
pick := (p: ?i64) -> i64 => {
if p == null { return -1; }
return 99;
};
print("pick 7: {}\n", pick(7)); // prints -1 (WRONG — should be 99; arg arrives absent)
print("norm 7: {}\n", norm(7)); // prints 99 (top-level fn, correct)
// print("pick null: {}\n", pick(null)); // LLVM verifier failure: ptr null vs {i64,i1}
}
```
Expected: `pick(7)` prints `99` (the `7` wraps to a present `?i64`), and
`pick(null)` compiles (the `null` lowers to an absent `?i64`), matching the
top-level `norm`.
## Root cause (hypothesis)
The closure-value call path in `src/ir/lower/call.zig` lowers each argument and
passes it to the closure's trampoline WITHOUT running the `coerceToType` step
that the normal sx-to-sx call path applies against the callee's declared
parameter types. So a `T → ?T` wrap (and `null → ?T`) never happens for a
closure value's optional param. A top-level fn call coerces args to param types,
which is why `norm` works.
## Investigation prompt
In `src/ir/lower/call.zig`, find the closure-value / fn-pointer call lowering
(where `%cl.fn`/`%cl.env` trampolines are invoked — grep for `cl.fn` / the
closure-call branch). Confirm it lowers args without coercing to the closure
type's parameter types. The closure's parameter types are available from the
`Closure(...)`/closure TypeInfo. Coerce each lowered argument to the
corresponding parameter type via `self.coerceToType(arg, arg_ty, param_ty)`
before building the call — mirroring the sx-to-sx call path. Verify:
1. The repro above: `pick(7)``99`, `pick(null)` compiles and the body sees
absent.
2. No regression in existing closure examples (`examples/closures/`).
3. Add a regression `examples/closures/03xx-closure-optional-param.sx` covering
a concrete arg (wraps present) and a `null` arg (absent) into a closure-value
`?T` param.
Note: this is purely an argument-coercion gap at the closure-value call site;
it is unrelated to the implicit-optional-unwrap family (issues 0179 / 0185),
which is already fixed.