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.
This commit is contained in:
agra
2026-06-27 12:31:23 +03:00
parent c94f878e7e
commit 76689a1ea6
65 changed files with 1236 additions and 48 deletions

View File

@@ -0,0 +1,100 @@
# CHECKPOINT-MULTIRET — bare-paren multi-value + named returns
Plan: `current/PLAN-MULTIRET.md`. Branch: `feat/multi-return`.
## Last completed step
**Phases 03 implemented** (final suite + snapshot capture in progress). Examples
renumbered to the free `types` block 02020206 (0130/0131 already had duplicate
existing owners).
- **Phase 0** — empty `()` in the type path → `void`. (0202)
- **Phase 1** — multi-return SIGNATURES `(A, B)` / `(x: A, y: B)` / `(A, B, !)`
(≥2 value slots) parse to a `tuple_type_expr` tagged `is_multi_return`; a
single-value `(T, !)` is a plain failable (= `-> T !`). Return resolver yields
the reused tuple TypeId; `resolveParamType` rejects a multi-return tuple
(return-position-only). Consumed by destructuring. (0203, 0204)
- **Phase 2** — bare comma `return v1, v2` (positional) / `return x = v, y = w`
(named): the return parser builds the same `tuple_literal` the `.(…)` form
produces. Single positional `return v` unchanged. (used throughout 02030205)
- **Phase 3** — NAMED-return slots are in-scope assignable LOCALS: bound as
zero-init allocas (`bindNamedReturnSlots`), the implicit return is synthesized
from them (`synthesizeNamedReturn` → reuses `lowerReturn`), and the MUST-SET
rule errors on an unset/undefaulted slot (`bodyAssignsTo`, path-insensitive
MVP). Works with the failable error channel too. (0205 positive, 0206 negative)
Earlier foundation: parser `collectGenericNames` descends tuple/optional/function
nodes; generic.zig `extractTypeParam` handle the `(value, !)` tuple.
## Current state (works, verified by probes)
- `() -> ()` ≡ void; `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)` multi-return.
- `return a, b` / `return x = a, y = b` bare comma; named-return locals + implicit
return + must-set diagnostic; failable named multi-return.
- `(A, B)` in a PARAM slot → loud diagnostic.
- Representation: `TupleTypeExpr.is_multi_return` flag + `Lowering.named_return_names`
state (reuses tuple ABI; no new TypeInfo variant; multi-return-ness derivable
from the FnDecl AST).
## Post-Phase-3 changes (this session)
- **Representation refactored to a dedicated `ReturnTypeExpr` AST node** (user
preferred it over the `TupleTypeExpr.is_multi_return` flag). Resolves to a
reused `.tuple` TypeId via the shared `internTupleLike` helper. Forced
`.return_type_expr` arms onto the exhaustive `node.data` switches (sema,
semantic_diagnostics) — the coverage benefit. Param-position reject + the
named-return-locals / must-set sites now key off `.return_type_expr`.
- **Destructure-only enforcement REVERSED** (user): single-binding a multi-return
is ALLOWED. `c := f(); c.sum` works (the result is a tuple of the value slots).
For a failable multi-return, `c := f() catch …` binds only the value slots —
the error stays on the `!` channel (verified). The `callIsMultiReturn` reject
was removed. Examples 0203/0204 updated to show single-bind + field access
(output byte-identical, snapshots unchanged).
## Phase 4 — named-return DEFAULTS (done, suite pending)
`-> (sum: i32 = 0, good: bool)`: the parser parses `= <expr>` per slot into
`ReturnTypeExpr.field_defaults`; `bindNamedReturnSlots` seeds a defaulted slot
with its (lowered+coerced) default; a defaulted slot is EXEMPT from the must-set
rule. Also fixed `hasFnBodyAfterArrow` (the fn-def-vs-type-const lookahead) to
skip `=` + literal tokens in the return-type scan — otherwise a `=` made the decl
misread as a bodyless type-const ("expected ';'" at the body `{`). Lock: 0207.
## Adversarial-review fixes (this session)
An adversarial review found 8 issues; fixed the soundness + silent-wrong ones:
- **#1 (segfault on a conditionally-assigned non-scalar slot)** → must-set is now
PATH-SENSITIVE definite-assignment (`definitelyAssigns`, stmt.zig): a slot not
assigned on every non-diverging path (and undefaulted) is a COMPILE ERROR, not
a runtime garbage read. `return`/`raise` count as divergence; `if` needs both
branches; `push` bodies count; `match` needs an else arm + all arms.
- **#2 (wrong-type default → segfault)** → `bindNamedReturnSlots` type-checks a
default via the coercion classifier (`.none` ⇒ diagnostic). (NOTE: the same
silent bitcast/segfault exists for ANY annotated assignment `x: i32 = "hi"` — a
broader PRE-EXISTING type-checking gap, not multi-return-specific.)
- **#3 / #8 (return arity garbage)** + **#4 (named elements ignored)** →
`validateMultiReturn` (stmt.zig, called from `lowerReturn`): rejects a bare
value where ≥2 are required, wrong arity, a comma list from a single-value fn,
and named elements out of slot order. (Reordering-by-name is a future nicety;
for now a mismatch is a loud error, never silent-wrong.)
- **#5 (slot shadows param)** → collision diagnostic in `bindNamedReturnSlots`.
- **#7 (push/defer false must-set error)** → subsumed by the DA rewrite (push
bodies count; defer correctly does NOT, as it runs after the implicit return).
## Known limitations / next
- **#6 (design gap, NOT UB)**: a `ReturnTypeExpr` is still silently accepted in
struct-field / var-annotation / generic-arg / closure-RETURN positions (resolves
to a coherent tuple). Only the PARAM position is rejected. Rejecting the rest
needs checks at several value-resolution sites; deferred (no soundness impact).
- **Reordering named return elements by name** (vs requiring slot order) — future.
- **PRE-EXISTING**: annotated-assignment type mismatch (`x: i32 = "hi"`) segfaults
— a general type-checking gap surfaced by the review; may warrant an issue.
- Multi-return CLOSURE-TYPE values / lambda literals deferred (D3).
- Docs: readme.md / specs.md not yet updated for multi-return (docs-track rule).
## Known issues
- (none yet)
## Log
- Pivoted here from the Io-unification Phase 3 (true cancellation), which is
PAUSED at its blocker: capturing a failable closure into a nested closure loses
its failability (`worker() catch` → operand type 'unresolved'; repro
`.sx-tmp/pD.sx`/`pE.sx`). That capture-typing gap is unrelated to multi-return
and waits for a later session. The Io-Phase-3 stdlib edits (core/sched/io +
example 1825) were REVERTED to keep the tree green; the multi-return-relevant
compiler changes were kept.
- Foundation landed + suite green; plan + checkpoint written.