Design: Closures & First-Class Functions¶
Status: Function values (v1, PR #91) and Tier 1 closures (T1.0-T1.5, PR #122) are complete.
Tier 1's residuals — List@(T)/Own@(T) move-capture and closure-aliasing soundness — landed in
PR #122 as well. Generic higher-order functions (Gaps A/C) and List@(T) extensibility (Gap D)
landed in #125/#126, and this release ships their payoff: the collections/iter combinator
module (map/filter/fold/compose), Call.callee widened to any expression (T2.4), and
generic-function references under an explicit expected type (T2.3). What remains is documented in
Part II: the UFCS method form xs.map(f) (Gap B), owned-element combinators, and the rest of
Tier 2 (peek/poke capture, bound-method values, indirect-path parity for owning/variadic
params, C callbacks).
This document is organized in two parts: Part I describes what is implemented and shippable today; Part II describes what is deferred, why, and the options for closing each gap.
Summary¶
Sushi has function types (fn(i32) -> i32), function values, and capturing closures.
A function type names an arity/parameter/return/error-type signature; a function value is
callable data of that type — a top-level function reference, or a lambda literal, optionally
capturing state from its defining scope. Both forms share one representation: a four-word fat
pointer {fn_ptr, env_ptr, drop_ptr, clone_ptr} (widened from three words, 2026-08-14, when
.clone() became total over every type — see below). A non-capturing value (a bare fn reference,
or a lambda that reads nothing from its enclosing scope) carries null env_ptr/drop_ptr/
clone_ptr and costs nothing beyond the wider pointer; a capturing lambda heap-allocates an
environment record that the value owns, frees via drop_ptr, and duplicates via clone_ptr when
.clone()d.
fn make_adder(i32 n) fn(i32) -> i32:
return Result.Ok(|i32 x| x + n) # captures n by value; escapes upward (returned)
fn main() i32:
let add5 = make_adder(5)??
println(add5(10)??) # 15
let i32 scale = 3
let i32[] out = from([1, 2, 3]).map(|x| x * scale)?? # captures scale
return Result.Ok(0)
Part I — Implemented¶
1. The v1 floor: function types and values (non-capturing)¶
A top-level function can be referenced by name, stored in a variable / struct field / List,
passed as an argument, and called through:
fn add_one(i32 x) i32:
return Result.Ok(x + 1)
fn apply(fn(i32) -> i32 f, i32 v) i32:
return Result.Ok(f(v)??) # call through the parameter
fn main() i32:
let fn(i32) -> i32 g = add_one # reference a named function
let i32 r = apply(g, 41)?? # pass it, call through it -> 42
println(r)
return Result.Ok(0)
A function type mirrors the function-declaration return/error syntax:
fn(i32) -> i32— return typei32, error type implicitlyStdError.fn(i32) -> i32 | MathError— explicit custom error type.fn() -> ~— no parameters, blank return.
Collections of functions use the generic form: List@(fn(i32) -> i32) (a raw array of function
pointers is not expressible — the [] in fn() -> T[] binds to the return type).
Result-transparent call. A Sushi fn lowers to Result@(T, E)(params). Calling through a
function value therefore yields the same Result@(T, E) a direct call would, so ??, if
(result), and pattern matching all work unchanged.
Only plain top-level fns are referenceable in v1. Extension methods, perk methods, and FFI
externals have incompatible ABIs (bare-value, self-bound, raw-C) and live in separate tables, so
a bare reference to one is never recognized as a function value — it fails as an undeclared
identifier (CE1001), not CE2093. A generic function reference is recognized-but-deferred
territory; see §8 for the T2.3 exception now allowed, and Part II §4 for what still stays CE2093.
2. Lambda syntax¶
Two body forms, both alternatives in the atom grammar production:
# expression body: |params| expr (desugars to a fn returning Result.Ok(expr))
let f = |i32 x| x + n
# block body: |params|: <indented block> (a full fn body; uses return Result.Ok(...))
let g = |i32 x|:
let i32 y = x * 2
return Result.Ok(y + n)
# optional return / error annotation after the closing pipe
let h = |i32 x| -> i32 | MathError: ...
# param types inferred from an expected function type (call arg, annotated binding)
map(list, |x| x * 2) # x : i32 inferred from an expected fn(i32) -> ... type
# zero-param form: |~| ..., NOT || (the lexer reads || as `or`)
let inc = |~| n + 1
- Params use Sushi's
type nameform (|i32 x, string s|). Bare-name params (|x|) are allowed only where an expectedFunctionTypesupplies the types (a call argument to a fn-typed parameter, or a binding with afn(...)annotation); otherwise it is a "lambda parameter needs a type" diagnostic. Return/error types are inferred from the body / expected type, or annotated with-> T [| E]after the closing pipe. - Result semantics are identical to
fn. An expression-body lambda|x| edesugars to a fn whose body isreturn Result.Ok(e); a block-body lambda is a literal fn body. Calling through a closure yieldsResult@(T, E)exactly like any call, sof(x)??,if (f(x)), and matching are unchanged. - Corollary: because the expression body is auto-wrapped in
Ok, a fallible call in the body must be unwrapped with??at its point of use — a bareResultleft in body position is wrapped again (Result@(Result@(T, E), E)) and fails to typecheck. This is whycompose's body isf(g(x)??)??, notf(g(x)??). The rule generalizes: a lambda body can never let an innerResultpass through unchanged; every fallible call needs its own??. - Block-body lambdas are a
let-RHS-only form. The grammar does not reachlambda_blockfrom generalexpr, since it ends in a dedent with no trailing token, so|x|: <block>used directly as a call argument is a parse error — bind it to aletfirst.
The | disambiguation¶
| is already the bitwise-or operator and the error-type separator in fn types/decls. The lambda
| is disambiguated by position: a | appearing where an operand/atom is expected (prefix
position — start of an expression, call argument, RHS of =) opens a lambda parameter list; a |
in infix position (between two operands) is bitwise-or. Inside a lambda's expression body, a
subsequent | is infix bitwise-or as usual (|x| x | 2 = lambda with body x | 2). Sushi's
parser is LALR (sushi_lang/internals/parser.py:54), so this disambiguation is resolved by the
grammar's shift/reduce tables alone — validated through the parser generator with no new conflicts
(the T1.1 acceptance gate).
Function types (shared, capture-agnostic)¶
fn(P...) -> T [| E]; capture is not part of the type (see §3), so fn(i32) -> i32 names
both a plain fn and any closure of that arity/ok/err.
3. Semantics: ABI, calling convention, capture, RAII¶
Representation — the four-word fat pointer¶
A function value lowers to { i8* fn_ptr, i8* env_ptr, i8* drop_ptr, i8* clone_ptr } (32 bytes,
widened from three words / 24 bytes on 2026-08-14 — see below):
| Field | Non-capturing value | Capturing closure |
|---|---|---|
fn_ptr |
address of a thunk f__thunk(env, ...) wrapping the bare fn |
address of the lifted __lambda_N(env, ...) |
env_ptr |
null |
heap Own@(__closure_env_N)* holding captured values |
drop_ptr |
null |
address of a type-erased env destructor |
clone_ptr |
null |
address of a type-erased env cloner: allocates a fresh environment record and deep-copies each captured field into it |
clone_ptr was added when .clone() became total over every type in the language
(docs/design/move-semantics.md §4) — a function value was the one remaining type without one.
build_closure_value (backend/runtime/closures.py) assembles all four fields; a non-capturing
value's clone_ptr is null, so .clone()-ing a bare fn reference is a runtime-guarded no-op
that hands back an equivalent fat value, exactly like drop_ptr's guarded free.
This mirrors the existing string fat pointer ({i8*, i32, i8}, see
docs/design/string-representation.md), applying the same insert_value/extract_value/store/load
idioms.
Calling convention — adapter-thunk split¶
- Direct calls stay bare.
f(x)wherefnames a top-level fn lowers to the exact v1 instruction; no signature or call site changes. FFI externs andmainare untouched. - Indirect calls are uniformly env-passing. Calling through a function value extracts
fn_ptr/env_ptrand callsfn_ptr(env_ptr, args...)—env_ptrprepended as a hidden leading argument. - A bare fn used as a value is bridged by a thunk. Materializing a top-level fn as a value
synthesizes (once, cached)
f__thunk(i8* env, <params>) { return f(<params>) }and stores{f__thunk, null, null, null}. The thunk ignoresenv, so the indirect ABI is uniform without touching any real function body.
Capture policy¶
Capture is a ConsumingUse.CAPTURE — an ownership sink like any other in
docs/design/ownership-conventions.md — so what happens is the shared classify() table's answer
for the captured variable's provenance and type class, not a closures-specific rule:
- Types that own no heap (primitives, and — since Phase 9 — a
stringbound directly from a literal, which owns nothing at that binding) are captured by value-copy into the environment record; the outer binding stays usable. - Types that own heap (dynamic array,
List@(T),Own@(T),HashMap@(K, V), and — since Phase 9 — anystringnot bound from a literal, e.g. one built by interpolation, returned from a call, or arriving as a parameter) are captured by move into the environment — the outer binding is consumed (borrow-checker enforced; later use is CE2405), and the env's recursive destructor frees them. A struct or fixed array composed only of non-owning fields is captured by copy, exactly like a bare primitive; one with an owning field (including, now, a plainstringfield) is captured by move, exactly like a bare owning value. - A captured closure value (a
fn(...)local that is itself a capturing closure) is also move-captured, same as any other owning type — this is what makescomposeand capture-and-call bodies work (§7). - Borrow capture (
poke/peek) is rejected with CE2094 — deferred to Tier 2 (Part II §3). - Reading a captured field back out of the environment is a BORROW, exactly like reading a
struct field (
docs/design/ownership-conventions.md§4.2): a lambda body that reads a captured owning value (e.g.|~| greetingreturning a capturedstring) sees a borrow of the environment's copy, so returning or otherwise consuming it from inside the body needs its own.clone()— CE2411 otherwise. Reading it without consuming it (e.g.println(greeting)inside the body) is free.
Environment ownership, escape, and RAII¶
- The environment is heap-allocated and owned by the closure value (
Own@(__closure_env_N)), so a closure may escape its creating scope — be returned, or stored in a struct/List. - Freeing is type-erased through
drop_ptr: at any RAII cleanup point, a function value is freed byif (drop_ptr != null) drop_ptr(env_ptr). Non-capturing values carrydrop_ptr = null, so their free is a guarded no-op. This is why the drop slot exists — afn(i32)->i32value cannot tell statically whether it owns an env (capture erasure), so ownership is resolved at runtime by the presence of a drop function. - Capture-taint drives ownership analysis. A bare fn ref / non-capturing lambda is free (copyable, non-owning — preserves v1 ergonomics). A capturing lambda is owning (move semantics
- RAII). A value of erased provenance (arriving through a
fnparameter, or read out of a container) is conservatively treated as owning-with-runtime-drop; the drop is runtime-guarded, so conservative frees are always sound. - Closure aliasing is sound. A plain rebind
let g = fmoves the env (source consumed, CE2405 on later use); a container get-out (let g = fns.get(0)??) and a struct-field read (let g = s.handler) are non-owning borrows (the container/struct stays the sole owner, mirroringOwn@(T).get()); a closure stored in a struct field is freed by the struct's cleanup. No leak, no double-free (validated withleaks --atExit). - Compatibility stays invariant and capture-agnostic.
fn(i32)->i32matches a plain fn and a closure alike (the capture descriptor is metadata, excluded from type identity). Mismatch is CE2002 on assignment and CE2092 on call-through (§9).
Lambda lowering (desugaring)¶
- Synthesize an environment struct
__closure_env_N { <captured fields> }, registered in the struct table (so the recursive destructor and struct lowering handle it for free). - Synthesize the lifted function
__lambda_N(env: poke __closure_env_N, <lambda params>)with the lambda body, rewriting each captured-name read to an env-field access. This reuses the monomorphizer's "synthesize aFuncDef, register aFuncSig, append toprogram.functions" machinery, so the backend emits it with zero special-casing.
The env parameter is poke, and the mode is load-bearing (2026-08-15). A move-captured
List@(T) / Own@(T) / dynamic array is MUTABLE inside the body by design (§3, T1.5), and
after the rewrite every such write goes through this parameter: xs.push(x) is
__closure_env.xs.push(x). The environment is the closure's OWN storage — the closure value
owns it and frees it through drop_ptr — so the lifted function writing to it is not a write
to a caller's value.
It was declared peek until the peek write rule became total: nothing
enforced read-only before that, so the untruthful mode had no consequence. Once it was
enforced, it made two legal shapes a CE2408 — a mutating method on a capture
(tests/closures/test_closure_list_mutate.sushi) and a poke borrow of a capture
(tests/closures/test_closure_env_poke_borrow.sushi). The declaration was corrected rather
than the rule carved out.
The mode is a SEMANTIC declaration only: no backend code reads ReferenceType.mutability, so
codegen is identical either way (the env is a pointer in both cases). Four semantics sites read
it, and for this parameter three are inert — the rebind check (captures are field accesses, the
env is never rebound), the poke→peek
coercion (the env is never a checked argument), and _poke_param_indices for the #168
destroy-effect analysis (round 1 needs a bare-Name receiver, round 2 needs a by-name call
site, and a lifted function is only ever dispatched indirectly).
Open structural note. This is a COMPILER-SYNTHESIZED borrow riding on the same
ReferenceType machinery as a user borrow, so every new reference rule has to be checked
against it by hand. If a second rule needs an accommodation for it, that is the signal to give
it its own kind (a synthesized flag on Param, or a distinct param kind) so a rule can ask
"is this a user borrow?" instead of matching the name __closure_env. One accommodation is a
coincidence; two is a missing concept.
3. At the lambda site, heap-allocate the env, populate captured fields (copy or move), and build
{@__lambda_N, env_ptr, @__closure_env_N_drop}.
4. Tier 1 delivery (T1.0-T1.5)¶
Landed, in dependency order:
- T1.0 — fat-pointer ABI + sizing (
FunctionType.captures, 24-byte lowering). - T1.1 — lambda grammar/AST (
lambda_expr,lambda_block,Lambdanode). - T1.2 — capture analysis (free-name recording in the scope pass).
- T1.3 — type-checking + capture legality, including CE2094 for borrow capture.
- T1.4 — lambda-lifting pass (env struct + lifted function synthesis).
- T1.6 — backend materialization (
emit_lambda, env heap-alloc, fat-value construction). - T1.7 — indirect-call env threading; CE2094 additionally rejects owning/variadic fn-value parameter types (dodging the indirect path's missing deep-copy — a latent double-free; closed by T2.5, Part II §3).
- T1.5 — environment RAII + move-capture (§3), plus closure-aliasing soundness (item 2): the get-out/rebind double-free and struct-field leak, both closed by treating a rebind as a move and a get-out/field-read as a non-owning borrow.
Test coverage: tests/closures/ — positive (test_closure_capture_primitive,
test_closure_bare_param_and_multi_capture, test_closure_escaping, test_closure_owned_move_capture,
test_closure_list_owns, test_closure_list_capture, test_closure_list_mutate,
test_closure_own_capture, test_closure_qq_early_exit, test_closure_rebind_move,
test_closure_get_out, test_closure_in_struct_field, test_closure_capture_closure,
test_fn_fat_layout, test_fn_thunk_name_collision); negative (test_err_closure_borrow_capture,
test_err_closure_owning_param, test_err_closure_use_after_move_capture,
test_err_closure_use_after_move_rebind).
5. Generic higher-order functions¶
Generic functions that take and call a function-typed parameter (fn(T) -> U) infer, monomorphize,
and run. Two gaps were closed to make this possible:
- Gap C — infer type params through function-typed arguments. Generic call-site inference walks
each declared parameter type against the argument type to bind type params; a
FunctionTypebranch was added to both twin unification routines (instantiatecollection andtypecheckvalidation) so afn(T) -> Uparameter recurses into its parameter types and return type, reaching the existing binding logic for the nestedT/U. The instantiate pass additionally learned to present aFunctionTypefor a typed-param lambda (|i32 x| x * k, params from the annotation,ok_typefrom the-> Tannotation or best-effort body inference) and for a bare function reference (inc, built from itsFuncSig). - Limitation: a bare-param lambda argument to a generic (
map(xs, |x| x * k)) is not inferable — its param types come from expected-type propagation, which is not available at Pass 1.5 collection, and is circular anyway (the lambda's type depends on the type params being inferred from it). Use a typed-param lambda (|i32 x| ...) or a function reference. This is a graceful CE2060, not a crash. - Gap A — substitute
FunctionTypeduring monomorphization. The three recursive type-substitution routines (rewriting type params to concrete types) gained aFunctionTypebranch that rebuildsparam_types/ok_type/err_typerecursively, carryingcapturesthrough unchanged (excluded from type identity but drives ownership). - Gap D —
List@(T)is user-extensible. A first-class generic struct now: both concrete (extend List@(i32) sum_all()) and generic (extend List@(T) first_or(T)) extends compile and run. A user List method cannot shadow a builtin List method name (providers are checked first at dispatch); the by-value-self-vs-by-pointer receiver ABI mismatch is reconciled at the dispatch site. - Inline capturing-closure argument leak — fixed, then superseded by the by-value-parameter ruling
(2026-08-14). A capturing closure passed inline as a call argument (
map(xs, |x| x * k)) used to heap-allocate an environment that was never freed, because it was not bound to a local and so not RAII-tracked. The first fix (T1.8) registered it in a per-scope caller-side temporary registry, freed via the runtime-guarded drop on every exit path. That registry is now used for only ONE shape: an inline closure passed to an extension method (compiled withfn_def=None, which registers no parameter cleanup — the caller must still own the argument, which is what keepsreturn selfsafe there). Every other call shape — direct, indirect, variadic — instead transfers ownership of the by-value argument to the callee through the seam (docs/design/ownership-conventions.md), and the callee's own registeredFunctionTypeparameter frees the environment at its own scope exit, exactly like any other owningnomparameter; a caller-side registration there would double-free. Binding to a local is still not required either way.
Validated as free generic functions (tests/generics/test_ho_*): map@(T, U)(List@(T), fn(T) ->
U) with a capturing closure and with U genuinely differing from T (i32 -> bool);
filter@(T)(List@(T), fn(T) -> bool) with a capturing predicate; fold@(T, U)(List@(T), U, fn(U, T) ->
U) with two independently-inferred type params; apply@(T)(fn(T) -> T, T) with a bare fn reference.
6. collections/iter — the bundled Sushi-source stdlib module¶
use <collections/iter> ships map, filter, fold, and compose as ordinary generic free
functions — the delivery of the T1.8/Gap-A/C payoff. Source:
sushi_lang/sushi_stdlib/src_sushi/collections/iter.sushi; docs: docs/stdlib/collections/iter.md.
use <collections/iter>
fn main() i32:
let i32 factor = 10
let List@(i32) xs = List.new()
xs.push(1)
xs.push(2)
xs.push(3)
let List@(i32) ys = map(xs, |i32 x| x * factor).realise(List.new())
println(ys.get(2).realise(-1)) # 30
return Result.Ok(0)
This is the first bundled-Sushi-source stdlib module — a real pattern, not a one-off:
- A
.sushifile lives undersushi_lang/sushi_stdlib/src_sushi/and is registered inSOURCE_STDLIB_MODULES(semantics/stdlib_registry.py), mapping theuse <...>path to the bundled source file. - The compiler pipeline injects it as a compilation unit (
compiler/pipeline.py) before symbol-table build, exactly like a user unit — no bitcode, no platform-specific.bc. The module joins the virtual-unit set (no.bcresolution) and incremental codegen skips it (nothing to cache; it is only ever monomorphized). - Why bundled
.sushisource, not Python-synthesis: every other stdlib module (List, string methods,HashMap) is a Python IR emitter (sushi_lang/sushi_stdlib/src/), hand-lowering LLVM IR. Combinators over generics have no fixed concrete signature to emit ahead of time — they need the existing generic monomorphization pipeline. Shipping them as ordinary Sushi source lets them ride that pipeline for free: nothing is emitted unless a program actually instantiates a combinator, and adding a new combinator is just adding a function to the.sushifile. - Why opt-in
use, not an auto-prelude: consistent with every other stdlib module (collections/hashmap,io/fs,time, ...) — Sushi has no implicit prelude, and combinators are unremarkable generic functions, not language primitives.
Constraints (documented in the module and its doc page):
- Copy/primitive element types only.
filterre-pushes each kept element andmapreads each one; owned-element combinators are deferred (Part II §2). - Free-function call syntax only:
map(xs, f), notxs.map(f)— the UFCS method form needs method-level type parameters (Gap B, Part II §1). - Function argument must be a typed-param lambda or a function reference — a bare-param lambda
(
|x| ...) cannot be inferred against a generic parameter (§5's Gap-C limitation).
compose — the capture-and-call payoff¶
fn compose@(T, U, V)(fn(T) -> U g, fn(U) -> V f) fn(T) -> V:
return Result.Ok(|x| f(g(x)??)??)
compose's returned lambda captures f and g (both function values, one of them possibly a
closure) and calls them in its body — the capture-and-call case that was CE2094-blocked before
T2.4 (§7). compose's lambda parameter is a bare |x|, not a type-param-annotated |T x| —
see Part II §5 for why a |T x| lambda parameter is not yet substituted during monomorphization,
which is why the bare form is used here.
use <collections/iter>
fn inc(i32 x) i32:
return Result.Ok(x + 1)
fn dbl(i32 x) i32:
return Result.Ok(x * 2)
fn main() i32:
let fn(i32) -> i32 incthendouble = compose(nom inc, nom dbl).realise(dbl)
println(incthendouble(10).realise(-1)) # dbl(inc(10)) = 22
return Result.Ok(0)
Test coverage: tests/stdlib/test_iter_module_map.sushi, test_iter_module_filter.sushi,
test_iter_module_fold.sushi, test_iter_module_fnref.sushi, test_iter_compose.sushi,
test_err_iter_unknown_module.sushi.
7. Call-through arbitrary expressions (T2.4)¶
Call.callee is widened from Name to any Expr. Calling through an arbitrary expression that
evaluates to a function value now works, reusing the fat-pointer indirect-call path unchanged:
- A captured closure read back in a lifted lambda body —
env.f(x)— is exactly what makescomposeand any capture-and-call closure body compile (§6, §3). - A fn-typed struct field, called directly:
obj.handler(). ADotCallroutes to an indirect field-call when the receiver struct has a fn-typed field of that name and no method of that name — a same-named method always wins. Nolet f = obj.handlerworkaround needed:
struct Handler:
fn(i32) -> i32 op
fn run(Handler h, i32 v) i32:
return Result.Ok(h.op(v)??) # calls the field directly
- A
Listget-out or a parenthesized expression, called immediately:arr[0](),(e)(),fns.get(0)??(x),(fns.get(0)??)(x).
Mechanically: the AST builder now emits a general Call for a non-Name, non-MemberAccess call
base; the type checker infers the non-Name callee and, when it resolves to a FunctionType,
dispatches to the same indirect-call validator used for a named local, annotating the node for the
backend. ?? was also taught to unwrap a Result and a Maybe Some payload, so a
function-value call inside a lambda body can infer its return type through a Maybe-returning
chain, not just a Result-returning one.
This lifts the CE2094 "capturing and calling a closure value" clause. Capturing another closure
and calling it in the body — previously deferred because the call env.f(x) was a non-Name
callee — now compiles:
fn run() i32:
let i32 n = 10
let fn(i32) -> i32 g = |i32 x| x + n
let fn(i32) -> i32 h = |i32 y|:
return Result.Ok(g(y)?? + 1)
return Result.Ok(h(5)??) # g(5) = 15, h(5) = 16
Two former backend cast failures on this path are now precise front-end CE2002 diagnostics instead.
Test coverage: tests/closures/test_closure_capture_closure.sushi,
tests/functions/test_call_index_result.sushi, tests/functions/test_fn_value_field_call.sushi,
tests/functions/test_fn_value_in_struct.sushi.
8. Generic-function references — the T2.3 annotated slice¶
Referencing a generic function as a value is now allowed when an explicit expected function type is present:
fn identity@(T)(T x) T:
return Result.Ok(x)
fn run() i32:
let fn(i32) -> i32 g = identity # the annotation drives the instantiation identity@(i32)
return Result.Ok(g(41)?? + 1) # 42
This is a minimal, expected-type-driven slice, not a general lift of CE2093: the instantiate pass collects
the instantiation from a let whose declared type is a FunctionType and whose value is a
generic-fn name (unifying the signature against the expected type); the type pass then solves the
type args, rewrites the Name to the mangled concrete name, and infers the concrete FunctionType.
The backend is unchanged — the mangled monomorphized function materializes as an ordinary fn value.
A generic-fn reference into a higher-order function works the same way, via a typed local binding first (not directly as a bare argument):
use <collections/iter>
fn identity@(T)(T x) T:
return Result.Ok(x)
fn run() i32:
let fn(i32) -> i32 id = identity # fixes the instantiation
let List@(i32) xs = List.new()
xs.push(5)
xs.push(7)
let List@(i32) ys = map(xs, id)??
return Result.Ok(ys.get(1).realise(-1)) # 7
What still stays CE2093 is covered once, in Part II §4.
Test coverage: tests/generics/test_generic_fn_ref.sushi,
tests/generics/test_generic_fn_ref_higher_order.sushi,
tests/generics/test_err_generic_fn_ref_no_type.sushi.
9. Diagnostics (live)¶
- CE2002 — a function value assigned to a variable or parameter of an incompatible function type (a plain assignment mismatch, not a call-through).
- CE2092 — function-value type mismatch (arity / parameter / return / error type) when calling through a function value. Function types are invariant.
- CE2093 — illegal function reference: a bare reference to a generic function with no expected function type in context (Part II §4 has the exact remaining boundary). Extension methods, perk methods, and FFI externals are not bare-referenceable at all — they surface as an undeclared identifier (CE1001), not CE2093.
- CE2094 — illegal closure capture: a
peek/pokeborrow (Tier 2, Part II §3); or an owning /variadic fn-value parameter type (before T2.5, Part II §3). Dynamic-array,List@(T),Own@(T), and now closure-value captures are all allowed (move-capture). The former "capturing and calling a closure value" clause is lifted by T2.4 (§7) — that call now compiles instead of erroring.
10. Implementation map (verified anchors)¶
| Concern | File:line |
|---|---|
FunctionType + capture descriptor |
semantics/typesys.py:254-291 |
| Fat-pointer LLVM lowering | backend/types/core/mapping.py:172-178 |
| Sizing 8->24 | backend/types/core/sizing.py:105-107, 231-232 |
Lambda grammar / atom |
grammar.lark:98, 156-173, 236 |
Lambda node / FuncDef shape |
semantics/ast.py:82, 346 |
Call.callee widened to Expr |
semantics/ast.py; semantics/ast_builder/expressions/chains.py |
| Capture analysis | semantics/passes/scope.py |
| Lambda type-check, CE2094, bare-param inference | semantics/passes/types/visitor.py |
| Expected-type propagation to bare-param lambdas | semantics/passes/types/propagation.py |
The lift pass |
semantics/passes/lift.py |
| Shared fn-synthesis wiring | semantics/generics/synthesis.py:register_synthesized_function |
| Ownership predicate (single source of truth) | semantics/typesys.py:owns_resource (Phase 9 merged is_owning_type into it — see docs/design/ownership-conventions.md §6) |
| Ownership seam (consume/bind/copy_out) | semantics/ownership.py (the classify() table), backend/ownership.py (the seam functions) |
| Env heap alloc / recursive env destructor / cloner | backend/generics/own.py:emit_own_alloc, backend/destructors.py:emit_value_destructor, backend/runtime/closures.py (env clone, clone_ptr) |
Runtime API (thunk, build value, indirect call, emit_lambda) |
backend/runtime/closures.py |
Backend expr dispatch -> emit_lambda |
backend/expressions/__init__.py (case Lambda()) |
Indirect call, non-Name callee routing |
backend/expressions/calls/dispatcher.py, backend/expressions/calls/utils.py |
Generic higher-order unification (typecheck / instantiate) |
semantics/passes/types/calls/generics.py:_unify_types_for_inference; semantics/generics/instantiate/types.py:unify_types |
FunctionType substitution (monomorphization) |
semantics/generics/monomorphize/transformer.py; semantics/generics/types.py; semantics/generics/extensions.py |
Gap D (List@(T) extensibility) |
semantics/passes/collect/__init__.py:373 (List as generic struct); backend/expressions/calls/dispatcher.py:268,308,355 (provider-first dispatch + receiver reconcile) |
| T2.3 generic-fn-ref-under-annotation | semantics/generics/instantiate/expressions.py; semantics/generics/instantiate/functions.py; semantics/passes/types/calls/generics.py |
collections/iter source module |
sushi_lang/sushi_stdlib/src_sushi/collections/iter.sushi |
| Source-stdlib-module registry + pipeline injection | semantics/stdlib_registry.py:SOURCE_STDLIB_MODULES; compiler/pipeline.py |
| Diagnostics | internals/errors/types.py (CE2002, CE2092, CE2093, CE2094) |
| Fat-pointer precedent (strings) | backend/runtime/strings.py |
Where the passes actually run (worth knowing before touching any of the above): the live semantic
pipeline is semantics/semantic_analyzer.py. (The old semantics/pipeline.py scaffold and the
_check_single_file path were both deleted in Tier 3 — a single-file compile is a one-unit
multi-file compile.) The lift pass (passes/lift.py) is inserted in
_check_multi_file, per unit, after type validation and before the borrow checker.
Part II — Deferred¶
1. UFCS method form xs.map(f) — Gap B — SUPERSEDED¶
Gap B SHIPPED with the UFCS epic: extend List@(T) map@(U)(fn(T) -> U f) List@(U) is
expressible, inferred at the call site, and monomorphized per (receiver, method,
margs). The decision record is ufcs-combinators.md; the
analysis that follows is kept for history and no longer describes the tree.
The original Gap B analysis (historical)
`extend List@(T) map@(U)(fn(T)->U f) List@(U)` cannot be expressed today. A *same-type* combinator (`extend List@(T) map(fn(T)->T f) List@(T)`) already works (Gap D closed this half); only a *type-changing* method — one that needs its own method-level type parameter `@(U)` — is blocked. Four pieces are missing: 1. **Grammar** (`grammar.lark:36`): `extend_def` is `NAME "(" [parameters] ")" type ...` — no `[type_params]` slot after the method name (contrast `function_def` at `:45`, which has one). `xs.map(f)` (inference-only call, no explicit `@(U)` — Sushi has no method type-arg syntax) already *parses*; only the **definition** needs the slot. `@(` after a method NAME in `extend_suffix` is unambiguous, so this is low-risk, but still needs the LALR acceptance-gate (run the grammar through the parser generator, as with the lambda `|`). 2. **AST/collect**: `ExtendDef` (`ast.py:135`) has no `type_params` field; collect (`semantics/passes/collect/functions.py:748`) derives extension type params from the *receiver's* `target_type.type_args` **only** (stored on `GenericExtensionMethod.type_params`). Needs a method-param field distinct from the receiver params, unioned for body type-resolution. 3. **Call-site inference**: method calls (`semantics/passes/types/calls/methods.py:234-255`) are receiver-driven — concrete type args come entirely from the receiver type; there is **no** argument unification. The free-function unifier (`semantics/passes/types/calls/generics.py:_unify_types_for_inference`, extended for `fn(T)->U` in §5's Gap C) would need to be reused for method calls to solve `U` from the `f` argument. 4. **Monomorphization**: `monomorphize_all_extension_methods` (`semantics/generics/extensions.py`) is eager/receiver-driven, keyed on `struct_instantiations` with a strict `zip` of `generic_method.type_params` against the receiver's `type_args` (CE0096 on count mismatch). Method params need a call-site-driven instantiation dimension combining receiver args **and** independently-inferred method args. **Options:** - **(A) Do nothing — free-function form (current default).** `map(xs, f)` works today, including type-changing (`i32 -> bool`) and capturing closures. The method form is pure UFCS sugar. Zero cost; this is what `collections/iter` documents and ships. - **(B) Same-type-only method combinators.** Ship `extend List@(T)` methods whose result type is `T` (in-place-style map, filter, fold-to-`T`). Works **today** on the back of Gap D, no Gap B needed. Real subset; type-changing map/fold still fall back to free functions. - **(C) Implement Gap B.** Medium-large. Reuses the higher-order inference (Gap C) and substitution (Gap A) machinery from §5; the crux is bridging the eager receiver-driven extension monomorphizer to a call-site-driven one for method params. Unlocks the full ergonomic `xs.map(f)`. Recommendation unchanged: pursue (A)/(B) for the parity payoff (already done); defer (C) until a concrete consumer wants the fluent method form. **Constraints on List extension methods worth knowing (from the Gap D fix):** - **Builtin names cannot be shadowed.** The backend dispatcher checks List provider methods (`push`/`get`/`iter`/…) *before* the user-extension fallback, so a user `extend List@(T) push()` is unreachable. Only non-builtin names route to the extension path. This is not specific to `List` -- it is the general precedence rule, now enforced as **CE2097** across every built-in family. See [method-resolution.md](method-resolution.md) for the full chain and the perk override route. - **Receiver ABI reconciliation.** A List-backed receiver shares the dynamic-array `{i32, i32, T*}` layout and is passed by pointer, but `self` is declared by value; the dispatch site loads the header to reconcile (safe because extension bodies never register `self` for cleanup, so the shared buffer is not double-freed).2. Owned-element combinators — partly closed¶
The METHOD-form filter is fully general since the UFCS epic: it clones each kept
element, so an owning element type works. The free functions, and the method-form
map/fold, stay copy/primitive-element: reading an element into f, and threading
an owning accumulator, still need move-aware handling the bodies do not do. See
ufcs-combinators.md for the v1 scope.
3. Remaining Tier 2¶
- T2.1 —
peek/pokeborrow capture. Lift CE2094 for borrows; track the borrow's lifetime through the closure value under the exclusivity rules. Why deferred: this is the genuinely hard problem the whole closures feature was scoped around — a borrow captured into an escaping, heap-allocated environment can outlive the stack frame that issued it, which the current scope-based borrow checker has no model for. Move-capture (Tier 1) sidesteps it entirely by never letting a reference cross into an environment. - T2.2 — Bound method values.
obj.methodas a callable via a self-binding adapter (env = boxedself); reuses the Tier 1 heap-env + drop machinery. Lifts the lastobj.handler()-shaped papercut that isn't already covered by T2.4's field-call routing (§7) — specifically, a bound method reference, not a fn-typed field read. Why deferred: no concrete consumer yet; mechanically straightforward once wanted. - T2.5 — Indirect-path parity for owning/variadic fn-value params. Implement deep-copy +
variadic-collapse in the indirect-call emitter, driven by
FunctionType.param_types; lifts the T1.7 restriction that fn-value parameter types must be non-owning, non-variadic. Why deferred: the direct-call path already does this; the indirect path's asymmetry is a latent double-free that T1.7 dodges by restricting param types rather than fixing the emitter — closing it is scoped, low-risk cleanup with no capability payoff until an owning fn-value parameter is actually needed. - T2.6 — First-class externals / C callbacks. A fat value with
drop_ptr = nullandenv_ptrserving the Cvoid* userdataconvention; reuses the adapter-thunk ABI directly. Why deferred: no FFI callback consumer yet; independent of the rest of Tier 2.
4. What still stays CE2093¶
A generic-function reference is CE2093 except the T2.3 annotated slice (§8): an explicit
expected function type (an fn-typed let annotation) must be present at the reference site. A bare
reference with no expected fn type — e.g. passing a generic function directly as an argument
without first binding it to a typed local — is still CE2093:
fn identity@(T)(T x) T:
return Result.Ok(x)
fn take(fn(i32) -> i32 f) i32:
return Result.Ok(f(1)??)
fn main() i32:
let i32 r = take(identity)?? # CE2093 -- no expected type at this reference
println(r)
return Result.Ok(0)
Bind it to a typed local first (let fn(i32) -> i32 id = identity; take(id)) to get the T2.3 path.
Extension methods, perk methods, and FFI externals remain outside CE2093 entirely — they are not in
the function table at all, so a bare reference to one is CE1001 (undeclared identifier), a distinct
diagnostic for a distinct reason (incompatible ABI, not deferred capability).
5. The |T x| lambda-parameter monomorphization gap¶
A lambda parameter annotated with a type parameter from the enclosing generic (|T x| ... inside a
fn foo@(T)(...)) is not substituted during monomorphization — the lambda-lifting machinery lifts
the lambda before the enclosing function's type-param substitution reaches its params. The
workaround is a bare parameter (|x| ...), letting expected-type propagation supply the
concrete type at each call site instead of relying on substitution. This is why compose (§6) is
written as |x| f(g(x)??)?? rather than |T x| ..., even though compose is itself generic over
T. No diagnostic currently flags a |T x| misuse distinctly from any other unresolved-type case;
treat this as a known authoring gotcha rather than a validated error path.
6. Risks / open problems¶
- Capture erasure at the type boundary.
fn(i32)->i32erases capture-ness. Resolved by the runtimedrop_ptr: ownership/free is data-driven (if drop_ptr: drop_ptr(env)), not type-driven. This is why the 3-word layout was mandatory from T1 and could not be retrofitted. - Direct-vs-indirect ABI reconciliation. "null env keeps v1 valid" and "indirect calls pass a
leading env" are only jointly consistent via the adapter-thunk split — direct calls bare,
indirect uniform, bare fns bridged by a thunk. A uniform "every fn gets a leading env param" ABI
was rejected as needlessly invasive (rewrites every signature, FFI,
main). - Indirect-path asymmetry (T1.7/T2.5). See Part II §3 — a latent double-free dodged by restricting fn-value param types rather than fixed at the emitter; T2.5 is the eventual close.
- Grammar
|collision — resolved. Position-based disambiguation (prefix|= lambda, infix|= bitwise-or), validated through the parser generator with no new conflicts. - Ownership vs. the move/borrow tracker. Only capturing values are owning (capture-taint);
non-capturing values stay copyable to preserve v1 ergonomics; the runtime-guarded drop makes
conservative (erased-provenance) frees sound. This is answered by
owns_resource(semantics/typesys.py), the single ownership predicate every type asks — seedocs/design/ownership-conventions.md§6. - Pass-ordering. Lambda-lifting needs resolved capture types (post-
types) but its synthesized functions must be borrow-checked (last pass), so it sits between them. If passes are reordered later, this insertion point moves withtypes.
7. Fast path to re-enter¶
- Read Part I (above) for current capability, then this Part II for what's left and why.
git log --oneline 430b5bd..HEAD -- sushi_lang docs/design/closures.md— the closures + T1.8 + T2.3/T2.4 feature commits are the phase history; everything is onmain.- Reproduce the working baseline: compile+run
tests/closures/test_closure_escaping.sushi(prints 15),tests/closures/test_closure_owned_move_capture.sushi(13),tests/closures/test_closure_capture_closure.sushi(16),tests/stdlib/test_iter_compose.sushi(22),tests/generics/test_generic_fn_ref.sushi(42). - Pick the remaining item by leverage:
- Gap B (§1) — the method form
xs.map(f); start with the grammar acceptance-gate, reuse the higher-order unifier for method-call inference, then bridge the extension monomorphizer to a call-site-driven path. - Owned-element combinators (§2) — needs move-aware
map/filterbodies; scope it against a concrete consumer (e.g. aList@(List@(T))transform) before generalizing. - T2.1-T2.6 (§3) — pick by consumer need; T2.2/T2.5/T2.6 are mechanically straightforward, T2.1 is the hard one and should stay last.
- Keep the enhanced suite green after each step (
python tests/run_tests.py --enhanced); leak-check runtime cases with macOSleaks --atExit(baseline noise: ~16 bytes inuser_main, present even in a trivial no-closure program).
Test strategy (repo conventions)¶
tests/run_tests.py: test_* -> exit 0, test_err_* -> exit 2, test_warn_* -> exit 1; runtime
validated via --enhanced. Ground truth lives in tests/closures/, tests/generics/test_ho_*,
tests/generics/test_generic_fn_ref*, and tests/stdlib/test_iter_* — see the test-coverage lines
under each Part I section above for the full file list.