From a47ea1416e3120d70bd0c4111506178b17871241 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 11 Jun 2026 17:04:51 +0300 Subject: [PATCH] =?UTF-8?q?lang:=20opt-in=20UFCS=20=E2=80=94=20ufcs-marked?= =?UTF-8?q?=20fns=20+=20alias=20dot-dispatch,=20generic=20binding=20via=20?= =?UTF-8?q?receiver;=20one=20binding=20builder=20for=20plan-side=20generic?= =?UTF-8?q?=20returns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...039-basic-free-fn-ufcs-pointer-receiver.sx | 4 +- examples/0053-basic-ufcs-opt-in.sx | 35 +++++ .../0135-types-self-streaming-nonreserved.sx | 2 +- .../0732-modules-flat-same-name-ufcs/a.sx | 2 +- .../0732-modules-flat-same-name-ufcs/b.sx | 2 +- .../a.sx | 2 +- .../b.sx | 2 +- .../a.sx | 2 +- .../b.sx | 2 +- examples/0838-memory-helpers.sx | 19 ++- .../mod.sx | 2 +- .../1166-diagnostics-ufcs-not-opted-in.sx | 11 ++ examples/expected/0053-basic-ufcs-opt-in.exit | 1 + .../expected/0053-basic-ufcs-opt-in.stderr | 1 + .../expected/0053-basic-ufcs-opt-in.stdout | 8 ++ examples/expected/0838-memory-helpers.stdout | 1 + .../1166-diagnostics-ufcs-not-opted-in.exit | 1 + .../1166-diagnostics-ufcs-not-opted-in.stderr | 10 ++ .../1166-diagnostics-ufcs-not-opted-in.stdout | 1 + ...9-ufcs-generic-free-function-unresolved.md | 34 +++-- library/modules/std/mem.sx | 26 ++-- specs.md | 46 ++++-- src/ast.zig | 5 + src/ir/calls.zig | 38 ++++- src/ir/generics.zig | 53 ++----- src/ir/lower/call.zig | 132 ++++++++++++++---- src/parser.zig | 11 +- 27 files changed, 316 insertions(+), 137 deletions(-) create mode 100644 examples/0053-basic-ufcs-opt-in.sx create mode 100644 examples/1166-diagnostics-ufcs-not-opted-in.sx create mode 100644 examples/expected/0053-basic-ufcs-opt-in.exit create mode 100644 examples/expected/0053-basic-ufcs-opt-in.stderr create mode 100644 examples/expected/0053-basic-ufcs-opt-in.stdout create mode 100644 examples/expected/1166-diagnostics-ufcs-not-opted-in.exit create mode 100644 examples/expected/1166-diagnostics-ufcs-not-opted-in.stderr create mode 100644 examples/expected/1166-diagnostics-ufcs-not-opted-in.stdout diff --git a/examples/0039-basic-free-fn-ufcs-pointer-receiver.sx b/examples/0039-basic-free-fn-ufcs-pointer-receiver.sx index ecc5811..2ae61f3 100644 --- a/examples/0039-basic-free-fn-ufcs-pointer-receiver.sx +++ b/examples/0039-basic-free-fn-ufcs-pointer-receiver.sx @@ -10,9 +10,9 @@ Counter :: struct { n: s32; } // FREE functions (defined outside the struct), pointer first param. -bump :: (c: *Counter) -> s32 { c.n += 1; return c.n; } +bump :: ufcs (c: *Counter) -> s32 { c.n += 1; return c.n; } // reached ONLY via UFCS — must still be emitted. -reset :: (c: *Counter) { c.n = 0; } +reset :: ufcs (c: *Counter) { c.n = 0; } main :: () -> s32 { c := Counter.{ n = 10 }; diff --git a/examples/0053-basic-ufcs-opt-in.sx b/examples/0053-basic-ufcs-opt-in.sx new file mode 100644 index 0000000..7fd852e --- /dev/null +++ b/examples/0053-basic-ufcs-opt-in.sx @@ -0,0 +1,35 @@ +// Free-function dot-calls are OPT-IN. Two opt-in spellings: +// name :: ufcs (params) { body } — the fn itself is dot-callable +// name :: ufcs target; — dot-callable (renaming) alias +// A plain fn is callable directly or via `|>` only (see 1166 for the +// rejection). Generic ufcs fns dispatch through normal monomorphization, +// and the plan-side return type binds from the receiver (structured +// params like `[]$T` included). + +#import "modules/std.sx"; + +bump :: (x: s64) -> s64 { x + 1 } +bump2 :: ufcs (x: s64) -> s64 { x + 2 } +bump3 :: ufcs bump; + +Counter :: struct { n: s64; } +inc :: ufcs (c: *Counter, by: s64) -> s64 { c.n += by; c.n } + +gfirst :: ufcs (xs: []$T) -> T { xs[0] } + +main :: () { + f : s64 = 40; + print("marked: {}\n", f.bump2()); // 42 + print("alias: {}\n", f.bump3()); // 41 + print("direct: {}\n", bump(f)); // 41 — plain fn, direct + print("pipe: {}\n", f |> bump()); // 41 — plain fn, pipe + print("marked-direct: {}\n", bump2(f)); // 42 — marked fn callable directly + + c := Counter.{ n = 10 }; + print("ptr-recv: {}\n", c.inc(5)); // 15 — auto address-of receiver + + arr := .[7, 8, 9]; + xs : []s64 = arr; + print("generic-dot: {}\n", xs.gfirst()); // 7 + print("generic-direct: {}\n", gfirst(xs)); // 7 — plan types it s64, not a T stub +} diff --git a/examples/0135-types-self-streaming-nonreserved.sx b/examples/0135-types-self-streaming-nonreserved.sx index 3337b08..9c7dafd 100644 --- a/examples/0135-types-self-streaming-nonreserved.sx +++ b/examples/0135-types-self-streaming-nonreserved.sx @@ -9,7 +9,7 @@ Hasher :: struct { total: s64 = 0; count: s64 = 0; } -update :: (self: *Hasher, n: s64) { +update :: ufcs (self: *Hasher, n: s64) { self.total += n; self.count += 1; } diff --git a/examples/0732-modules-flat-same-name-ufcs/a.sx b/examples/0732-modules-flat-same-name-ufcs/a.sx index b96726c..0300f8c 100644 --- a/examples/0732-modules-flat-same-name-ufcs/a.sx +++ b/examples/0732-modules-flat-same-name-ufcs/a.sx @@ -1,5 +1,5 @@ // a.sx authors `bump` adding 1. Imported first → first-wins winner. `from_a`'s // `v.bump()` resolves a.sx's own author (own == winner → existing UFCS path, // byte-for-byte unchanged). -bump :: (x: s64) -> s64 { return x + 1; } +bump :: ufcs (x: s64) -> s64 { return x + 1; } from_a_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0732-modules-flat-same-name-ufcs/b.sx b/examples/0732-modules-flat-same-name-ufcs/b.sx index f7e928b..37f1d78 100644 --- a/examples/0732-modules-flat-same-name-ufcs/b.sx +++ b/examples/0732-modules-flat-same-name-ufcs/b.sx @@ -1,4 +1,4 @@ // b.sx authors its OWN `bump` adding 100. `from_b`'s `v.bump()` must dispatch // b.sx's author (+100 → 110), not the first-wins winner from a.sx (+1). -bump :: (x: s64) -> s64 { return x + 100; } +bump :: ufcs (x: s64) -> s64 { return x + 100; } from_b_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx index 1381062..1ba8402 100644 --- a/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx @@ -1,2 +1,2 @@ // a.sx authors `dup` (+1). One of two distinct flat authors of `dup`. -dup :: (x: s64) -> s64 { return x + 1; } +dup :: ufcs (x: s64) -> s64 { return x + 1; } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx index eb8120b..211c152 100644 --- a/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx @@ -1,3 +1,3 @@ // b.sx authors its OWN `dup` (+2) — the second distinct flat author. Main // imports both and authors neither, so `v.dup()` from main is ambiguous. -dup :: (x: s64) -> s64 { return x + 2; } +dup :: ufcs (x: s64) -> s64 { return x + 2; } diff --git a/examples/0740-modules-flat-same-name-ufcs-typing/a.sx b/examples/0740-modules-flat-same-name-ufcs-typing/a.sx index bdde05a..45db425 100644 --- a/examples/0740-modules-flat-same-name-ufcs-typing/a.sx +++ b/examples/0740-modules-flat-same-name-ufcs-typing/a.sx @@ -2,5 +2,5 @@ // a.sx authors `tag` returning a string; imported first → first-wins winner. // `show_a`'s `v.tag()` is the caller's OWN author (own == winner → existing UFCS // path, byte-for-byte unchanged): typed AND dispatched as a.tag (string). -tag :: (x: s64) -> string { return "a-string"; } +tag :: ufcs (x: s64) -> string { return "a-string"; } show_a :: () { v : s64 = 10; print("a: v.tag() = {}\n", v.tag()); } diff --git a/examples/0740-modules-flat-same-name-ufcs-typing/b.sx b/examples/0740-modules-flat-same-name-ufcs-typing/b.sx index 3e415f0..423098e 100644 --- a/examples/0740-modules-flat-same-name-ufcs-typing/b.sx +++ b/examples/0740-modules-flat-same-name-ufcs-typing/b.sx @@ -3,5 +3,5 @@ // dispatched AND typed as b.tag (s64 = 110), not the first-wins winner from a.sx // (string). `print` types each arg from the call plan, so a mistype here boxes // the s64 as a string pointer → segfault before the fix. -tag :: (x: s64) -> s64 { return x + 100; } +tag :: ufcs (x: s64) -> s64 { return x + 100; } show_b :: () { v : s64 = 10; print("b: v.tag() = {}\n", v.tag()); } diff --git a/examples/0838-memory-helpers.sx b/examples/0838-memory-helpers.sx index 89015ec..d5ef697 100644 --- a/examples/0838-memory-helpers.sx +++ b/examples/0838-memory-helpers.sx @@ -1,8 +1,9 @@ // Typed allocation helpers over the Allocator protocol (std/mem.sx): // create/destroy (one T), alloc/free (slices), clone, resize, and the -// bytes-level mem_realloc. Free functions — direct calls and the -// fluent pipe spelling (`context.allocator |> create(Session)`) hit -// the same generic machinery. Contents are UNINITIALISED by design +// bytes-level mem_realloc. Declared `ufcs` — the dot spelling +// (`context.allocator.create(Session)`), the pipe spelling, and the +// direct call all hit the same generic machinery. Contents are +// UNINITIALISED by design // (Zig-aligned): assign before reading. TrackingAllocator balances to // zero across every pair. @@ -28,16 +29,22 @@ main :: () { print("pipe-create: {}\n", p.id); a |> destroy(p); + // create — canonical dot spelling on context.allocator + q := context.allocator.create(Session); + q.id = 2; + print("dot-create: {}\n", q.id); + context.allocator.destroy(q); + // alloc / free — typed slice xs := a |> alloc(s64, 4); xs[0] = 10; xs[1] = 20; xs[2] = 30; xs[3] = 40; print("alloc: {} {} len={}\n", xs[0], xs[3], xs.len); - // clone — independent copy - ys := xs |> clone(a); + // clone — independent copy (canonical dot spelling) + ys := xs.clone(a); xs[0] = 99; print("clone: {} (orig {})\n", ys[0], xs[0]); - a |> free(ys); + a.free(ys); // resize — grow (copies, old backing freed) zs := xs |> resize(a, 6); diff --git a/examples/1120-diagnostics-imported-reserved-type-name/mod.sx b/examples/1120-diagnostics-imported-reserved-type-name/mod.sx index 0c9d670..c255725 100644 --- a/examples/1120-diagnostics-imported-reserved-type-name/mod.sx +++ b/examples/1120-diagnostics-imported-reserved-type-name/mod.sx @@ -2,7 +2,7 @@ Box :: struct { total: s64 = 0; count: s64 = 0; } -update :: (self: *Box, n: s64) { +update :: ufcs (self: *Box, n: s64) { self.total += n; self.count += 1; } diff --git a/examples/1166-diagnostics-ufcs-not-opted-in.sx b/examples/1166-diagnostics-ufcs-not-opted-in.sx new file mode 100644 index 0000000..a13c1bf --- /dev/null +++ b/examples/1166-diagnostics-ufcs-not-opted-in.sx @@ -0,0 +1,11 @@ +// A dot-call on a PLAIN free function (no `ufcs` marker, no alias) is +// rejected with a tailored help: direct call, pipe, or declare it ufcs. + +#import "modules/std.sx"; + +bump :: (x: s64) -> s64 { x + 1 } + +main :: () { + f : s64 = 40; + print("{}\n", f.bump()); +} diff --git a/examples/expected/0053-basic-ufcs-opt-in.exit b/examples/expected/0053-basic-ufcs-opt-in.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0053-basic-ufcs-opt-in.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0053-basic-ufcs-opt-in.stderr b/examples/expected/0053-basic-ufcs-opt-in.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0053-basic-ufcs-opt-in.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0053-basic-ufcs-opt-in.stdout b/examples/expected/0053-basic-ufcs-opt-in.stdout new file mode 100644 index 0000000..464ee08 --- /dev/null +++ b/examples/expected/0053-basic-ufcs-opt-in.stdout @@ -0,0 +1,8 @@ +marked: 42 +alias: 41 +direct: 41 +pipe: 41 +marked-direct: 42 +ptr-recv: 15 +generic-dot: 7 +generic-direct: 7 diff --git a/examples/expected/0838-memory-helpers.stdout b/examples/expected/0838-memory-helpers.stdout index be7e278..af743e4 100644 --- a/examples/expected/0838-memory-helpers.stdout +++ b/examples/expected/0838-memory-helpers.stdout @@ -1,5 +1,6 @@ create: 7 42 pipe-create: 1 +dot-create: 2 alloc: 10 40 len=4 clone: 10 (orig 99) resize: 20 60 len=6 diff --git a/examples/expected/1166-diagnostics-ufcs-not-opted-in.exit b/examples/expected/1166-diagnostics-ufcs-not-opted-in.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1166-diagnostics-ufcs-not-opted-in.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1166-diagnostics-ufcs-not-opted-in.stderr b/examples/expected/1166-diagnostics-ufcs-not-opted-in.stderr new file mode 100644 index 0000000..d7d68a7 --- /dev/null +++ b/examples/expected/1166-diagnostics-ufcs-not-opted-in.stderr @@ -0,0 +1,10 @@ +error: 'bump' is not a ufcs function — a plain function does not dispatch via dot-call + --> examples/1166-diagnostics-ufcs-not-opted-in.sx:10:19 + | +10 | print("{}\n", f.bump()); + | ^^^^^^ + +help: call it directly (`bump(receiver, ...)`), pipe it (`receiver |> bump(...)`), or declare it `bump :: ufcs (...) { ... }` + | +10 | print("{}\n", f.bump()); + | ^^^^^^ diff --git a/examples/expected/1166-diagnostics-ufcs-not-opted-in.stdout b/examples/expected/1166-diagnostics-ufcs-not-opted-in.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1166-diagnostics-ufcs-not-opted-in.stdout @@ -0,0 +1 @@ + diff --git a/issues/0119-ufcs-generic-free-function-unresolved.md b/issues/0119-ufcs-generic-free-function-unresolved.md index 58173e4..b71de38 100644 --- a/issues/0119-ufcs-generic-free-function-unresolved.md +++ b/issues/0119-ufcs-generic-free-function-unresolved.md @@ -1,20 +1,24 @@ # 0119 — UFCS dot-call on a GENERIC free function: "unresolved ''" -> **RESOLVED — not a bug** (2026-06-11, Agra language ruling, same -> session). Dot-form UFCS on generic free functions is NOT the language -> contract: UFCS free-function dot-dispatch is the annotated mechanism -> (`name :: ufcs target;`, concrete targets), and the FLUENT spelling -> for free functions is the pipe operator — `xs |> first_of()` desugars -> at parse time to `first_of(xs)`, which dispatches generics through -> the normal monomorphization machinery (verified: -> `context.allocator |> create(Session)` works). specs.md §UFCS -> corrected (it overstated "works with ... generic functions" for the -> dot form). No compiler change. MEM Phase 2.2 unblocked — helpers -> shipped as free functions with the direct + `|>` contract -> (examples/0838-memory-helpers.sx pins both spellings). -> Residual corner (not contracted, unfiled): a `ufcs` ALIAS naming a -> generic target also doesn't dot-dispatch; file separately if ever -> wanted. +> **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 diff --git a/library/modules/std/mem.sx b/library/modules/std/mem.sx index 5dfbe30..a34e142 100644 --- a/library/modules/std/mem.sx +++ b/library/modules/std/mem.sx @@ -4,16 +4,16 @@ // // The user-facing allocation surface over the Allocator protocol's // bytes-level primitives (`alloc_bytes` / `dealloc_bytes`). Free -// functions — call directly or fluently via the pipe operator: +// functions declared `ufcs` — dot-call, pipe, or call directly: // -// s := context.allocator |> create(Session); +// s := context.allocator.create(Session); // s.* = Session.{}; // no zero-init (Zig-aligned) -// defer context.allocator |> destroy(s); +// defer context.allocator.destroy(s); // -// moves := context.allocator |> alloc(Move, 64); -// defer context.allocator |> free(moves); +// moves := context.allocator.alloc(Move, 64); +// defer context.allocator.free(moves); // -// copied := bytes |> clone(context.allocator); +// copied := bytes.clone(context.allocator); // // Bodies are complete for the 2-method protocol era: `mem_realloc` is // alloc+copy+dealloc (the only shape without resize/remap primitives), @@ -21,17 +21,17 @@ // allocation lands with the protocol expansion. // Allocate one T. Contents are UNINITIALISED — assign before reading. -create :: (a: Allocator, $T: Type) -> *T { +create :: ufcs (a: Allocator, $T: Type) -> *T { xx a.alloc_bytes(size_of(T)) } // Free a *T obtained from `create`. -destroy :: (a: Allocator, ptr: *$T) { +destroy :: ufcs (a: Allocator, ptr: *$T) { a.dealloc_bytes(xx ptr); } // Allocate a []T of `count` elements. Contents are UNINITIALISED. -alloc :: (a: Allocator, $T: Type, count: s64) -> []T { +alloc :: ufcs (a: Allocator, $T: Type, count: s64) -> []T { raw := a.alloc_bytes(count * size_of(T)); s : []T = ---; s.ptr = xx raw; @@ -40,12 +40,12 @@ alloc :: (a: Allocator, $T: Type, count: s64) -> []T { } // Free a []T obtained from `alloc` / `clone` / `resize`. -free :: (a: Allocator, slice: []$T) { +free :: ufcs (a: Allocator, slice: []$T) { a.dealloc_bytes(xx slice.ptr); } // Copy a slice into fresh storage owned by `a`. -clone :: (src: []$T, a: Allocator) -> []T { +clone :: ufcs (src: []$T, a: Allocator) -> []T { raw := a.alloc_bytes(src.len * size_of(T)); memcpy(raw, xx src.ptr, src.len * size_of(T)); s : []T = ---; @@ -57,7 +57,7 @@ clone :: (src: []$T, a: Allocator) -> []T { // Reallocate a slice to `new_count` elements: fresh storage, contents // copied up to min(len, new_count), old backing freed. The returned // slice replaces the operand — the old slice is dangling after this. -resize :: (slice: []$T, a: Allocator, new_count: s64) -> []T { +resize :: ufcs (slice: []$T, a: Allocator, new_count: s64) -> []T { raw := a.alloc_bytes(new_count * size_of(T)); n := if slice.len < new_count then slice.len else new_count; memcpy(raw, xx slice.ptr, n * size_of(T)); @@ -72,7 +72,7 @@ resize :: (slice: []$T, a: Allocator, new_count: s64) -> []T { // dealloc — there is no in-place grow primitive to try yet, and // `align` beyond the heap's natural 8 is not honored until the // protocol carries alignment. -mem_realloc :: (a: Allocator, ptr: *void, old: s64, new: s64, align: s64) -> *void { +mem_realloc :: ufcs (a: Allocator, ptr: *void, old: s64, new: s64, align: s64) -> *void { raw := a.alloc_bytes(new); n := if old < new then old else new; memcpy(raw, ptr, n); diff --git a/specs.md b/specs.md index d0663dd..1634dfe 100644 --- a/specs.md +++ b/specs.md @@ -2279,34 +2279,50 @@ print("hello") ### UFCS (Uniform Function Call Syntax) ```sx -object.func(args) // equivalent to func(object, args) +object.func(args) // equivalent to func(object, args) — for OPT-IN functions ``` -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)`. This enables method-like syntax without dedicated method declarations. +Free-function dot-calls are **opt-in**: a plain function never dispatches +via dot. The `ufcs` keyword opts a function in, with two spellings — +marking the function itself, or declaring a (renaming) alias: ```sx -Point :: struct { x: s32; y: s32; } -point_sum :: (p: Point) -> s32 { p.x + p.y; } +create :: (x: s32) -> void {} // plain — NOT dot-callable +create2 :: ufcs (x: s32) -> void {} // ufcs-marked — dot-callable +create3 :: ufcs create; // ufcs alias — dot-callable -p := Point.{3, 4}; -print("{}\n", p.point_sum()); // calls point_sum(p) → 7 +f : s32 = 4; +f.create(); // error: 'create' is not a ufcs function (help: call it + // directly, pipe it, or declare it `create :: ufcs (...)`) +f.create2(); // works — calls create2(f) +f.create3(); // works — calls create(f) through the alias +create2(f); // a ufcs fn is still an ordinary fn: direct calls work +f |> create(); // the pipe works on ANY fn (parse-time desugar, no opt-in) ``` -UFCS works with pointer receivers (auto-deref applies). Generic struct -*methods* dispatch via dot; a generic **free function** (any `$T` in its -signature) is NOT dot-rewritten — call it directly or fluently via the -pipe operator, which desugars at parse time to the direct call: +When `object.func(args)` names an opted-in function and `func` is not a +field or method of `object`'s type, the compiler rewrites the call to +`func(object, args)`. Fields and methods take priority over ufcs +functions; a protocol-typed receiver dispatches its own methods first and +falls through to ufcs functions for non-members +(`context.allocator.create(Session)` — `create` is a ufcs fn taking the +protocol value as its first param). + +UFCS works with pointer receivers (auto-deref, and auto address-of when +the first param is `*T` and the receiver is a value) and with **generic** +functions — the receiver participates in `$T` binding and the call +monomorphizes exactly like the direct spelling: ```sx -first_of :: (xs: []$T) -> T { xs[0] } +first_of :: ufcs (xs: []$T) -> T { xs[0] } +xs.first_of(); // dot — binds $T from the receiver first_of(xs); // direct -xs |> first_of(); // fluent — desugars to first_of(xs) +xs |> first_of(); // pipe — desugars to first_of(xs) ``` -If the field name exists as both a struct field and a free function, the struct field takes priority. - #### UFCS Aliases -The `ufcs` keyword creates a name alias for a function, decoupling the method name from the function name: +The alias form decouples the method name from the function name — +useful when the bare name reads poorly in dot position: ```sx arena_alloc :: (arena: *Arena, size: s64) -> *void { ... } alloc :: ufcs arena_alloc; diff --git a/src/ast.zig b/src/ast.zig index 1bd6af3..c767849 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -142,6 +142,11 @@ pub const FnDecl = struct { /// is a REQUIRED parameter, so a parser site cannot drop it; the default /// here serves only post-check synthesized decls (which are never raw). is_raw: bool = false, + /// `name :: ufcs (params) { body }` — the fn opted into dot-call + /// dispatch (`recv.name(args)`). Dot-calls on free functions are + /// OPT-IN: only `is_ufcs` fns and `ufcs` aliases dispatch; a plain + /// fn is callable directly or via `|>` only. + is_ufcs: bool = false, }; pub const Param = struct { diff --git a/src/ir/calls.zig b/src/ir/calls.zig index 36e0f1d..71c2d6c 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -315,6 +315,36 @@ pub const CallResolver = struct { // the plan carries `prepends_receiver`, distinct from a true // namespace call (`pkg.fn()`), which must NOT prepend. if (self.objectIsValue(cfa.object)) { + // Free-fn dot-dispatch is OPT-IN (mirror lowerCall's gate so + // plan and dispatch agree): only a `ufcs` alias or a fn + // declared `name :: ufcs (...)` classifies as free_fn_ufcs. + // A plain fn falls through (lowering emits the tailored + // not-a-ufcs-function diagnostic). + const alias_target = self.l.program_index.ufcs_alias_map.get(cfa.field); + const eff_field = alias_target orelse cfa.field; + const ufcs_fd = self.l.program_index.fn_ast_map.get(eff_field); + const opted_in = alias_target != null or (ufcs_fd != null and ufcs_fd.?.is_ufcs); + if (!opted_in) return .{ .kind = .unresolved, .return_type = .unresolved }; + // Generic ufcs target: infer the return type with the + // RECEIVER prepended so binding positions align with + // fd.params[0] (mirrors the lowering side's eff_args). + if (ufcs_fd) |fd| { + if (fd.type_params.len > 0) { + const eff_call_args = self.l.alloc.alloc(*ast.Node, c.args.len + 1) catch + return .{ .kind = .unresolved, .return_type = .unresolved }; + eff_call_args[0] = cfa.object; + @memcpy(eff_call_args[1..], c.args); + var c2 = c.*; + c2.args = eff_call_args; + return .{ + .kind = .free_fn_ufcs, + .return_type = self.l.genericResolver().inferGenericReturnType(fd, &c2), + .target = .{ .named = eff_field }, + .prepends_receiver = true, + .expands_defaults = defaultsFor(fd, c.args.len + 1), + }; + } + } // Value-receiver free-fn UFCS (`recv.fn(args)` → `fn(recv, args)`) // routes through the SAME author producer `selectedFreeAuthor` as a // bare call, so the planned target / return type IS the author @@ -335,7 +365,7 @@ pub const CallResolver = struct { }, .ambiguous, .none => {}, } - if (self.l.resolveFuncByName(cfa.field)) |fid| { + if (self.l.resolveFuncByName(eff_field)) |fid| { const func = &self.l.module.functions.items[@intFromEnum(fid)]; return .{ .kind = .free_fn_ufcs, @@ -343,14 +373,14 @@ pub const CallResolver = struct { .target = .{ .func = fid }, .prepends_receiver = true, .prepends_ctx = func.has_implicit_ctx, - .expands_defaults = if (self.l.program_index.fn_ast_map.get(cfa.field)) |fd| defaultsFor(fd, c.args.len + 1) else false, + .expands_defaults = if (ufcs_fd) |fd| defaultsFor(fd, c.args.len + 1) else false, }; } - if (self.l.program_index.fn_ast_map.get(cfa.field)) |bfd| { + if (ufcs_fd) |bfd| { return .{ .kind = .free_fn_ufcs, .return_type = if (bfd.return_type) |rt| self.l.resolveType(rt) else .void, - .target = .{ .named = cfa.field }, + .target = .{ .named = eff_field }, .prepends_receiver = true, .expands_defaults = defaultsFor(bfd, c.args.len + 1), }; diff --git a/src/ir/generics.zig b/src/ir/generics.zig index feae1ea..14db86f 100644 --- a/src/ir/generics.zig +++ b/src/ir/generics.zig @@ -263,53 +263,16 @@ pub const GenericResolver = struct { pub fn inferGenericReturnType(self: GenericResolver, fd: *const ast.FnDecl, c: *const ast.Call) TypeId { if (fd.return_type == null) return .void; - // Build ALL type bindings from call args before resolving return type - var tmp_bindings = std.StringHashMap(TypeId).init(self.l.alloc); + // ONE binding builder: the same `buildTypeBindings` the lowering / + // monomorphization path uses, so plan-side return typing can't + // disagree with the instance actually dispatched. (The previous + // local strategies only bound BARE `$T` value params — a structured + // param (`[]$T`, `*$T`) never bound, so the planned return type of + // e.g. `gfirst(xs: []$T) -> T` was the `T` stub and print's Any + // boxing mis-tagged the value.) + var tmp_bindings = self.buildTypeBindings(fd, c.args); defer tmp_bindings.deinit(); - for (fd.type_params) |tp| { - // Strategy 1: direct type param decl ($T: Type) — param.name == tp.name. - // Only fires when the caller actually supplied a type expression at - // that position; otherwise fall through to value-based inference. - var found = false; - for (fd.params, 0..) |param, pi| { - if (std.mem.eql(u8, param.name, tp.name)) { - if (pi < c.args.len and type_bridge.isTypeShapedAstNode(c.args[pi], &self.l.module.types)) { - const ty = self.l.resolveTypeArg(c.args[pi]); - tmp_bindings.put(tp.name, ty) catch {}; - found = true; - } - break; - } - } - if (found) continue; - - // Strategy 2: inferred from usage (a: $T, b: T) — check ALL matching params, pick widest - var inferred_ty: ?TypeId = null; - for (fd.params, 0..) |param, pi| { - if (param.type_expr.data == .type_expr) { - const te = param.type_expr.data.type_expr; - if (std.mem.eql(u8, te.name, tp.name)) { - if (pi < c.args.len) { - const arg_ty = self.l.inferExprType(c.args[pi]); - if (inferred_ty) |prev| { - if (arg_ty == .f64 and prev != .f64) { - inferred_ty = arg_ty; - } else if (arg_ty == .f32 and prev != .f64 and prev != .f32) { - inferred_ty = arg_ty; - } - } else { - inferred_ty = arg_ty; - } - } - } - } - } - if (inferred_ty) |ty| { - tmp_bindings.put(tp.name, ty) catch {}; - } - } - // Resolve return type with whatever bindings we built. Even an // empty `tmp_bindings` is a valid input — non-generic literal // return types (e.g. `walk(..$args) -> string`) still need to diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index 679041d..65ca495 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -843,9 +843,15 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } } - // Check if receiver is a protocol type → dispatch through vtable/fn_ptrs + // Check if receiver is a protocol type → dispatch through + // vtable/fn_ptrs — but only for the protocol's OWN methods. A + // non-member field falls through to the free-fn ufcs machinery + // (`context.allocator.create(Session)` — a ufcs fn taking the + // protocol value as its first param). if (self.getProtocolInfo(obj_ty)) |proto_info| { - return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, obj_ty); + if (protocolHasMethod(proto_info, fa.field)) { + return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, obj_ty); + } } // Check if receiver is `?Protocol` — for sentinel-shaped @@ -860,7 +866,9 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { if (opt_info == .optional) { const pay_ty = opt_info.optional.child; if (self.getProtocolInfo(pay_ty)) |proto_info| { - return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, pay_ty); + if (protocolHasMethod(proto_info, fa.field)) { + return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, pay_ty); + } } } } @@ -993,10 +1001,11 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { } } - // Try to resolve as bare function name (free-function UFCS: - // `recv.fn(args)` → `fn(recv, args)`). Lazily lower the body — - // a function reached ONLY via UFCS would otherwise be declared - // but never emitted (undefined symbol at link). + // Free-function dot-call (`recv.fn(args)` → `fn(recv, args)`) + // is OPT-IN: only a fn declared `name :: ufcs (...) {...}` or a + // `name :: ufcs target;` alias dispatches. A plain fn is + // callable directly or via `|>` only — a dot-call on one gets a + // tailored diagnostic rather than silently becoming a method. // // R5 §C: a free-function UFCS target with a // genuine flat same-name collision dispatches to the author the @@ -1008,34 +1017,95 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { // (`sel_author` / `cplan.ambiguous_collision`, computed once above) // rather than re-resolving the field name. `.ambiguous` → loud // diagnostic; otherwise the existing first-wins lazy path. - const ufcs_fid: ?FuncId = blk_uf: { + const alias_target = self.program_index.ufcs_alias_map.get(fa.field); + const eff_field = alias_target orelse fa.field; + const ufcs_fd = self.program_index.fn_ast_map.get(eff_field); + const ufcs_opted_in = alias_target != null or (ufcs_fd != null and ufcs_fd.?.is_ufcs); + + if (ufcs_opted_in) { if (author_ambiguous) { if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field}); return Ref.none; } - if (sel_author) |sf| { - break :blk_uf self.selectedFuncId(sf, fa.field); - } - if (self.program_index.fn_ast_map.get(fa.field)) |_| { - if (!self.lowered_functions.contains(fa.field)) { - self.lazyLowerFunction(fa.field); + // 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) { + 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; + var gbindings = self.genericResolver().buildTypeBindings(fd, eff_args.items); + defer gbindings.deinit(); + const gmangled = self.genericResolver().mangleGenericName(eff_field, fd, &gbindings); + if (!self.lowered_functions.contains(gmangled)) { + self.monomorphizeFunction(fd, gmangled, &gbindings); + } + if (self.resolveFuncByName(gmangled)) |gfid| { + const gfunc = &self.module.functions.items[@intFromEnum(gfid)]; + const gret_ty = gfunc.ret; + const gparams = gfunc.params; + // Strip type-decl slots. method_args[0] is the + // receiver (a VALUE — a type-expr receiver + // classifies as a namespace call, never here), + // so fd.params[0] is a value param. + var gvalue_args = std.ArrayList(Ref).empty; + defer gvalue_args.deinit(self.alloc); + gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable; + const types_explicit = method_args.items.len == fd.params.len; + var arg_idx: usize = 1; + for (fd.params[1..]) |p| { + if (isTypeParamDecl(&p, fd.type_params)) { + if (types_explicit) arg_idx += 1; + continue; + } + if (arg_idx < method_args.items.len) { + gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable; + } + arg_idx += 1; + } + self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty); + const final_args = self.prependCtxIfNeeded(gfunc, gvalue_args.items); + self.coerceCallArgs(final_args, gparams); + return self.builder.call(gfid, final_args, gret_ty); + } + return self.emitError(eff_field, c.callee.span); } } - break :blk_uf self.resolveFuncByName(fa.field); - }; - if (ufcs_fid) |fid| { - const func = &self.module.functions.items[@intFromEnum(fid)]; - const ret_ty = func.ret; - const params = func.params; - // Same implicit address-of as a struct-defined method: if the - // free function's first param is `*T` and the receiver is a - // value `T`, pass its address instead of a by-value copy + const ufcs_fid: ?FuncId = blk_uf: { + if (sel_author) |sf| { + break :blk_uf self.selectedFuncId(sf, eff_field); + } + if (ufcs_fd != null) { + if (!self.lowered_functions.contains(eff_field)) { + self.lazyLowerFunction(eff_field); + } + } + break :blk_uf self.resolveFuncByName(eff_field); + }; + if (ufcs_fid) |fid| { + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + // Same implicit address-of as a struct-defined method: if the + // free function's first param is `*T` and the receiver is a + // value `T`, pass its address instead of a by-value copy + self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty); + const final_args = self.prependCtxIfNeeded(func, method_args.items); + self.coerceCallArgs(final_args, params); + return self.builder.call(fid, final_args, ret_ty); + } + return self.emitError(eff_field, c.callee.span); + } - self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty); - const final_args = self.prependCtxIfNeeded(func, method_args.items); - self.coerceCallArgs(final_args, params); - return self.builder.call(fid, final_args, ret_ty); + // A fn by this name exists but is not dot-callable: tailored help. + if (ufcs_fd != null or self.resolveFuncByName(fa.field) != null) { + if (self.diagnostics) |d| { + const id = d.addFmtId(.err, c.callee.span, "'{s}' is not a ufcs function — a plain function does not dispatch via dot-call", .{fa.field}); + d.addHelpFmt(id, c.callee.span, null, "call it directly (`{s}(receiver, ...)`), pipe it (`receiver |> {s}(...)`), or declare it `{s} :: ufcs (...) {{ ... }}`", .{ fa.field, fa.field, fa.field }); + } + return Ref.none; } return self.emitError(fa.field, c.callee.span); }, @@ -1188,6 +1258,14 @@ pub fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) return new_args; } + +fn protocolHasMethod(proto_info: anytype, name: []const u8) bool { + for (proto_info.methods) |m| { + if (std.mem.eql(u8, m.name, name)) return true; + } + return false; +} + pub fn resolveFuncByName(self: *Lowering, name: []const u8) ?FuncId { // Check foreign name map first (e.g., "c_abs" → "abs") const effective_name = self.foreign_name_map.get(name) orelse name; diff --git a/src/parser.zig b/src/parser.zig index 58c1db5..e50903f 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -266,11 +266,18 @@ pub const Parser = struct { return self.parseUnionDecl(name, start_pos, name_is_raw); } - // UFCS alias: name :: ufcs target; + // UFCS forms: + // name :: ufcs (params) -> ret { body } — fn declared dot-callable + // name :: ufcs target; — dot-callable alias if (self.current.tag == .kw_ufcs) { self.advance(); + if (self.current.tag == .l_paren) { + const node = try self.parseFnDecl(name, name_span, name_is_raw, start_pos); + node.data.fn_decl.is_ufcs = true; + return node; + } if (self.current.tag != .identifier) { - return self.fail("expected function name after 'ufcs'"); + return self.fail("expected '(' (a ufcs function declaration) or a function name (a ufcs alias) after 'ufcs'"); } const target = self.tokenSlice(self.current); self.advance();