Files
sx/current/PLAN-MULTIRET.md
agra 76689a1ea6 feat: multiple return values — bare-paren signatures, named returns, must-set, defaults
A function may return multiple values via a bare-paren return signature:
`-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)` (error always the last slot),
and `-> ()` is `void`. This is DISTINCT from a `Tuple(…)` value — return-position
only (a dedicated `ReturnTypeExpr` AST node resolving to a reused `.tuple`
TypeId); a parameter / field / variable annotation `x: (A, B)` is rejected. A
single-value `-> (T, !)` stays a plain failable (= `-> T !`).

Returns use the bare comma form `return a, b` / `return x = a, y = b` (no `.( … )`
literal). Consume by destructuring (`a, b := f()`) or single-bind + field access
(`c := f(); c.sum`); a failable bound value holds only the value slots (the error
stays on the `!` channel).

Named return slots are in-scope assignable locals; with no explicit `return` the
implicit return is synthesized from them. Path-sensitive definite-assignment
enforces the must-set rule, and a slot may carry a default that exempts it.
Validation rejects arity mismatches, out-of-slot-order named elements, a
slot/parameter name collision, a comma list from a single-value function, and a
multi-return signature used as a value type.

Examples 0202-0213; readme + specs updated. issues/0197 files a pre-existing
annotated-assignment type-check gap (`x: i32 = "hi"` segfaults) surfaced by the
adversarial review.
2026-06-27 12:31:23 +03:00

157 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN-MULTIRET — bare-paren multi-value returns + named returns
## Why
sx already has multi-value returns, but only in a verbose spelling:
`-> Tuple(A, B)` / `-> Tuple(x: A, y: B)` types and `return .(a, b)` /
`return .(x = a, y = b)` tuple-literal returns. Destructuring (`a, b := f()`),
named/positional field access (`r.x` / `r.0`), and value-carrying failables
(`Tuple(A, B) !E`) all work on top of the existing `.tuple` TypeId.
The user wants the ergonomic, canonical surface:
```sx
a :: () -> () { } // () ≡ void
two :: () -> (i32, bool) { return 42, true; } // bare-paren type + bare comma return
b :: (f1: i32, f2: i32) -> (sum: i32, good: bool) { // named returns are in-scope locals
good = true;
sum = f1 + f2; // implicit return: all named slots set
}
b2 :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
return f1 + f2, f2 > 42; // bare comma return still works
}
read :: () -> (i32, bool, !) { ... } // error channel ALWAYS the last slot
```
Rules (from the user):
- **`() -> ()``() -> void`.**
- **A multi-return signature is NOT a tuple — it just REUSES the tuple machinery.**
`-> (i32, bool)` / `-> (x: i32, y: bool)` mean "this function returns multiple
values", a DISTINCT thing from `-> Tuple(i32, bool)` (which returns one tuple
value). The bare-paren form is valid ONLY as a function/closure RETURN
signature — `x: (A, B)` (a variable/param/field annotation) stays REJECTED;
`Tuple(…)` is the spelling for an actual tuple value type.
- **Consumption — destructure OR single-bind (REVISED 2026-06-27).** A
multi-return result may be DESTRUCTURED (`s, g := b2()`) OR bound to a single
name and reached by field (`c := b2(); c.sum` / `c.0`). The earlier
destructure-only rule (single-bind = error) was REVERSED by the user — single
binding is allowed; the bound value behaves like a tuple of the value slots.
- **Failable: the error stays SEPARATE.** For `-> (sum, good, !)`, a bound
value (`c := f() catch …` / `try`) holds ONLY the value slots — the error
rides the `!` channel and is NEVER part of `c` (no `c.err`). This falls out of
the existing failable machinery (catch/try strip the error before binding).
- **Failable: the error channel is always the LAST slot** (`(A, B, !)`).
- **Bare comma return**: `return v1, v2;` maps positionally to the return slots —
no `.(…)` tuple literal needed.
- **Named returns are assignable locals.** With no explicit `return`, an implicit
return at end-of-body synthesizes the result from the named locals. **A named
return that is neither assigned on the path nor given a default is a COMPILE
ERROR.** A named slot may carry a default (`(sum: i32 = 0, good: bool)`); a
defaulted slot needn't be assigned.
## Representation (how "not a tuple, reuse machinery" is realized) — AS BUILT
- A dedicated AST node **`ReturnTypeExpr`** (`field_types` + optional
`field_names`, same shape as a tuple) is produced by the parser for a bare-paren
result list with **≥2 value slots** (`(A, B)`, `(x: A, y: B)`, `(A, B, !)`). A
single-value `(T, !)` stays a `tuple_type_expr` (a plain failable, `= -> T !`).
An EMPTY `()` parses to the `void` type.
- It resolves (type_resolver `internTupleLike`, shared with `tuple_type_expr`) to
a reused `.tuple` TypeId — full ABI / failable / destructure / field-access
machinery reuse. Its distinct MEANING lives in the AST node, not the TypeId.
- Position gating: the node is valid only in a return slot. `resolveParamType`
rejects a `ReturnTypeExpr` parameter annotation ("multi-return is return-only;
use Tuple(…)"). Being a distinct node, its mere appearance in a value-type
position is categorically an error (no flag to check) — exhaustive `switch`es
over `node.data` were forced to add a `.return_type_expr` arm (coverage).
- Consumption: destructure (`a, b := f()`) or single-bind + field access
(`c := f(); c.sum`). No single source of truth needed at call sites — the
result is just a tuple value.
- SCOPE: multi-return on `name :: (...) -> (…) { }` function declarations first.
Multi-return CLOSURE-TYPE values (`cb: Closure() -> (A, B)`) and lambda
literals are a later phase.
## What already exists (re-use, do NOT rebuild)
- `tuple_type_expr``.tuple` TypeId with optional `names` (type_resolver.zig
`resolveCompound`).
- Named + positional tuple field access `r.x` / `r.0` (expr.zig
`lowerFieldAccessOnType`).
- Destructuring `a, b := f()` (`DestructureDecl`, stmt.zig).
- Value-carrying failable assembly `(T1, …, !)` (error.zig
`lowerFailableSuccessReturn` / `emitTupleRet`) — error in the last slot.
- `return .(a, b)` / `return .(x = a, y = b)` tuple-literal returns (stmt.zig
`lowerReturn`).
- Generic inference through a failable/tuple closure return (this session's
parser `collectGenericNames` + generic.zig `extractTypeParam` tuple arms).
## Foundation already landed (uncommitted, suite-green)
- **parser.zig** — `collectGenericNames` descends tuple/optional/function nodes
(so `Closure() -> $R !` binds `$R`); the bare-paren result-list path builds a
failable `tuple_type_expr` when it ends in `!` (`(A, B, !)` parses).
- **generic.zig** — `extractTypeParam` / `matchTypeParam[Static]` handle the
`(value, !)` tuple so `$R` infers from a closure ARG's failable return.
## Phases (each: implement → lock with an example → `zig build test` green)
0. **`() -> ()` = void (parser).** Isolated, unambiguous. An empty `()` in the
paren type path resolves to `void`. Lock: `a :: () -> () { }`.
1. **Multi-return signatures `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)`
(parser + AST + resolution).** Add the `multi_return_type` AST node; the parser
produces it for a bare-paren result list (return position). The return resolver
lowers it to a `.tuple` TypeId and sets `Function.multi_return`; the general
resolver rejects it (return-position only). Returns still use the existing
`return .(…)` literal in this phase (bare comma is Phase 2). Consumption is
destructuring `a, b := f()` (existing machinery). Lock: positional + named +
failable multi-return examples, each destructured.
2. **Destructure-only enforcement + bare comma `return v1, v2` (parser + lowering).**
(a) Reject using a multi-return call as a single value (`r := f()`, an arg, an
operand) — read `Function.multi_return` at the binding/use site; only
destructuring is allowed. (b) Extend the return statement to parse a
comma-separated value list and lower it to the same multi-slot return the
`.(…)` literal produces (error slot stays implicit for failables). Single-value
`return v` unchanged. Lock: `-> (i64, bool) { return 7, true; }`, a failable
variant, and a negative example (`r := f()` → diagnostic).
3. **Named-return locals + must-set rule (sema/lowering).** For a named return
`-> (x: A, y: B)`, bind each name as an in-scope assignable local (alloca). On
a path that reaches end-of-body with NO explicit `return`, synthesize the
implicit return from the named locals. Diagnose loudly if any named slot is
neither assigned on that path nor defaulted (no silent zero-fill). Explicit
`return v1, v2` / `return .(…)` still override. Lock: the
`b :: (...) -> (sum, good) { good = true; sum = ... }` example + a negative
example (unset slot → diagnostic).
4. **Named-return defaults `(sum: i32 = 0, good: bool)`.** A slot with a default
is exempt from the must-set rule; the default fills it at the implicit (or
partial explicit) return. Lock: an example mixing a defaulted + a required
slot.
## Open decisions (Decisions Log)
- **D1 — multi-return is NOT a tuple; return-position-only.** *Chosen* (user
directive). Realized via a distinct **`ReturnTypeExpr` AST node** (the user
preferred a dedicated node over a `TupleTypeExpr.is_multi_return` flag — it
makes "not a tuple" true at the AST level and makes position-gating
categorical) that resolves to a reused `.tuple` TypeId. A new `.tuple`-like
TypeInfo variant was rejected — it would ripple through every exhaustive type
switch for no ABI benefit. **Destructure-only was REVERSED** (see Rules):
single-binding a multi-return result is allowed (field access on the value
slots); the failable error stays on the separate `!` channel.
- **D2 (Phase 3) — storage for named-return locals.** Lean: an alloca per named
slot bound in the function scope under its name; the implicit return reads them
into the result tuple. Revisit if the must-set analysis wants SSA-style
definite-assignment instead of an alloca + per-path check.
- **D3 — multi-return closure-type values / lambda literals.** Deferred past the
function-decl phases (needs a `ClosureInfo.multi_return` flag). Phases 04 cover
named function declarations only.
## Validation (every phase)
- `zig build && zig build test` green (full corpus).
- New `examples/<category>/…` locked with snapshots; review the diff for `.ir`
churn only where expected (the prelude type table is untouched by this stream,
so churn should be minimal/none).
- Adversarial review of each phase before it lands.
## Category for examples
Multi-return is a core type/return feature — use the `types` block (`01xx`),
next free numbers, unless a better fit emerges.