Files
sx/issues/0119-ufcs-generic-free-function-unresolved.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00

4.8 KiB

0119 — UFCS dot-call on a GENERIC free function: "unresolved ''"

RESOLVED (2026-06-11, same session — Agra language ruling + the opt-in implementation). Final model: free-function dot-calls are OPT-IN. name :: ufcs (params) { body } (new declaration form) and name :: ufcs target; (alias) both opt in; a PLAIN fn never dot-dispatches (tailored diagnostic steers to direct / |> / marking it ufcs). Generic ufcs fns dispatch via dot with the receiver participating in $T binding; a protocol-typed receiver dispatches its own methods first and falls through to ufcs fns (context.allocator.create(Session) works). Bonus root-cause fix: plan-side inferGenericReturnType now delegates to the SAME buildTypeBindings the monomorphizer uses, so structured generic params ([]$T) type direct calls correctly too (was a T{} stub through print's Any boxing — pre-existing). The previously-implicit unannotated dot-dispatch was REMOVED (inverted vs the model); in-tree reliance was 6 example files (audited; migrated to marked form), zero in m3te/game. specs.md §UFCS rewritten around the opt-in matrix. Regression: examples/0053-basic-ufcs-opt-in.sx + 1166-diagnostics-ufcs-not-opted-in.sx; mem helpers marked ufcs (0838 pins dot + pipe + direct). Suite 585/585.

Symptom

obj.func(args) where func is a generic free function (any $T in its signature — a []$T/*$T param or a $T: Type value param) fails with unresolved '<name>'. The same call spelled directly — func(obj, args) — compiles and runs correctly. Concrete (non-generic) free functions rewrite through UFCS fine.

Observed (one probe, all three failures):

  • xs.sum_all() (concrete fn, slice receiver) → works
  • xs.first_of() (generic []$T fn, slice receiver) → unresolved 'first_of'
  • p.pick(i32) (generic $T: Type fn, struct receiver) → unresolved 'pick'
  • a.create(Session) (generic fn, protocol-value receiver) → unresolved 'create'

Expected: specs.md §UFCS promises the rewrite unconditionally ("When object.func(args) is encountered and func is not a field of object's type, the compiler rewrites the call to func(object, args)"). A generic free function called via dot must monomorphize and dispatch exactly as the direct spelling does.

Note: issue-0040 (fixed) covered generic STRUCT METHODS via dot — that is the method path, not the free-function UFCS rewrite.

Reproduction

#import "modules/std.sx";

first_of :: (xs: []$T) -> T { xs[0] }

main :: () {
    arr := .[1, 2, 3];
    xs : []i64 = arr;
    print("{}\n", first_of(xs));   // 1 — direct call works
    print("{}\n", xs.first_of());  // error: unresolved 'first_of'
}

Investigation prompt

The UFCS rewrite lives in the call-lowering path (src/ir/calls.zig / src/ir/lower/call.zig — the field-access-callee handling that falls back to "func is not a field → try func(object, args)"). The fallback resolves the bare name against DECLARED functions (resolveFuncByName / the lowered-function registry). A generic free function is never declared (it is a TEMPLATE in fn_ast_map, fd.type_params.len > 0, monomorphized per call shape) — so the lookup misses and the call is reported unresolved instead of routing through the generic machinery.

The fix likely: in the UFCS fallback, when the bare name resolves to a fn_ast_map entry with type_params.len > 0 (the same gate declareFunction uses), rewrite to the direct-call shape and route through the SAME generic-call path a direct func(obj, args) takes (mangleGenericName + binding inference from args + monomorphize). The direct spelling already works, so the machinery exists — the UFCS arm just never reaches it. Mind the resolution order: scope locals and protocol/struct methods must keep winning over a same-named free template (mirror the existing concrete-UFCS precedence), and visibility gating (import-graph) must apply to the template exactly as for concrete fns.

Verification:

  1. The repro above prints 1 twice, exit 0.
  2. Matrix probe: generic-on-struct (p.pick(i32)), generic-on-slice (xs.first_of()), generic-on-protocol-value (a.create(Session) with create :: (a: Allocator, $T: Type) -> *T) all dispatch; concrete UFCS unchanged.
  3. bash tests/run_examples.sh — 582/582 baseline must hold (UFCS-heavy suite: protocols, packs, List methods).
  4. Pin the repro as a regression example per CLAUDE.md.

Context: BLOCKS MEM Phase 2.2 — the plan's memory helpers are "free functions in mem.sx, UFCS-callable" with canonical call sites context.allocator.create(Session) / slice.clone(context.allocator) (plan Appendix A). The helpers themselves work via direct calls; the step is paused rather than shipping a direct-call-only API that the plan would immediately re-churn.