Files
sx/current/CHECKPOINT-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

6.2 KiB
Raw Blame History

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.