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.

156
current/PLAN-MULTIRET.md Normal file
View File

@@ -0,0 +1,156 @@
# 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.

View File

@@ -0,0 +1,21 @@
// Empty parens `()` as a return type mean `void`: `f :: () -> () { … }` is
// exactly `f :: () -> void { … }`. The zero-parameter FUNCTION type `() -> R`
// (a callable taking no args) is unaffected — only an empty `()` in the
// return-TYPE slot folds to void.
#import "modules/std.sx";
// `-> ()` is void: no value returned.
greet :: () -> () { print("hi\n"); }
// A `-> void` spelling, for contrast — identical behavior.
greet2 :: () -> void { print("bye\n"); }
// Zero-param function-typed parameter still parses as a callable, not void.
run :: (f: () -> i64) -> i64 { return f(); }
main :: () -> i64 {
greet();
greet2();
print("{}\n", run(() => 7));
return 0;
}

View File

@@ -0,0 +1,31 @@
// Multi-return signatures: a function may return MULTIPLE values, written as a
// bare-paren list in the return slot — `-> (i64, bool)` (positional) or
// `-> (sum: i32, bigger: bool)` (named). The returned values use the bare comma
// `return a, b` form (no `.(…)` literal).
//
// The result is consumed either by DESTRUCTURING (`q, r := f()`) or by binding
// it to a single name and reaching the value slots by field (`c := f(); c.sum`).
// A multi-return is return-position-only as a TYPE: a parameter / variable
// annotation `x: (A, B)` is rejected — use `Tuple(…)` for a tuple value.
#import "modules/std.sx";
// Positional multi-return.
divmod :: (a: i64, b: i64) -> (i64, i64) {
return a / b, a % b;
}
// Named multi-return — the slot names ride the signature.
stats :: (a: i32, b: i32) -> (sum: i32, bigger: bool) {
return sum = a + b, bigger = a > b;
}
main :: () -> i64 {
// Destructure the values.
q, r := divmod(17, 5);
print("17 / 5 = {} rem {}\n", q, r);
// …or bind once and reach the named slots by field.
c := stats(40, 2);
print("sum={} bigger={}\n", c.sum, c.bigger);
return 0;
}

View File

@@ -0,0 +1,34 @@
// A multi-return signature may carry an error channel as its LAST slot:
// `-> (i32, bool, !)` returns two values OR fails. This reuses the existing
// value-carrying-failable machinery — the error is always the final slot. A
// single-value failable `-> (T, !)` (one value + error) is exactly `-> T !`,
// NOT a multi-return.
//
// Consume it like any failable: `catch` / `or` / a guarded destructure or
// single bind. The error rides the SEPARATE `!` channel — a bound result holds
// only the VALUE slots, never the error (so `c.doubled` / `c.big`, no `c.err`).
#import "modules/std.sx";
CheckErr :: error { Negative }
// Two values plus an error channel. The success values use the bare comma
// `return` form; `raise` rides the error channel as usual.
classify :: (n: i32) -> (doubled: i32, big: bool, !) {
if n < 0 { raise error.Negative; }
return doubled = n * 2, big = n > 10;
}
main :: () -> i64 {
// Success: destructure the two value slots (the catch diverges on error).
d, b := classify(7) catch { print("unexpected error\n"); return 1; };
print("classify(7): doubled={} big={}\n", d, b);
// Or bind once (error stripped by `catch`) and reach the value slots by
// field — the error is NOT part of the bound value.
c := classify(21) catch { print("unexpected error\n"); return 1; };
print("classify(21): doubled={} big={}\n", c.doubled, c.big);
// Failure: the error path is taken.
classify(-3) catch { print("classify(-3): caught Negative\n"); return 0; };
return 0;
}

View File

@@ -0,0 +1,42 @@
// Named multi-return slots are in-scope assignable LOCALS. Assign each by name
// in the body; with no explicit `return`, the function implicitly returns the
// slot values once they are all set. A named slot that is never assigned (and
// has no default) is a compile error — see the companion negative example.
//
// This is sugar over the multi-return machinery: `b` below is equivalent to
// `return sum = f1 + f2, good = f1 > f2`, but reads as straight-line setup.
#import "modules/std.sx";
// The slot names `sum` / `good` are locals; assigning them IS the return.
combine :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
good = f1 > f2;
sum = f1 + f2;
}
// A slot local can be read after it is set (here `hi` depends on `lo`).
bounds :: (n: i32) -> (lo: i32, hi: i32) {
lo = n;
hi = lo + 10;
}
// Named slots work with an error channel too (the error is the last slot).
Err :: error { Negative }
roots :: (n: i32) -> (val: i32, sq: i32, !) {
if n < 0 { raise error.Negative; }
val = n;
sq = n * n;
}
main :: () -> i64 {
s, g := combine(40, 2);
print("combine: sum={} good={}\n", s, g);
lo, hi := bounds(5);
print("bounds: lo={} hi={}\n", lo, hi);
v, sq := roots(7) catch { print("unexpected\n"); return 1; };
print("roots: val={} sq={}\n", v, sq);
roots(-1) catch { print("roots(-1): caught Negative\n"); return 0; };
return 0;
}

View File

@@ -0,0 +1,16 @@
// Negative: a NAMED multi-return slot that the body never assigns (and that has
// no default) is a compile error. Here `good` is never set, so the implicit
// return cannot be synthesized — the must-set rule rejects it. (Assign every
// slot, give it a default, or end with an explicit `return`.)
#import "modules/std.sx";
combine :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
sum = f1 + f2;
// `good` is never assigned — must-set violation.
}
main :: () -> i64 {
s, g := combine(1, 2);
print("{} {}\n", s, g);
return 0;
}

View File

@@ -0,0 +1,26 @@
// A named multi-return slot may carry a DEFAULT: `-> (sum: i32 = 0, good: bool)`.
// A defaulted slot is EXEMPT from the must-set rule — if the body never assigns
// it, the default seeds it at the implicit return. A non-defaulted slot must
// still be set (see 0206). An explicit assignment overrides the default.
#import "modules/std.sx";
// `sum` defaults to -1; only `good` must be assigned.
classify :: (n: i32) -> (sum: i32 = -1, good: bool) {
good = n > 0;
// `sum` is left unset → the default (-1) is returned.
}
// Both slots default; the body overrides them.
combine :: (a: i32, b: i32) -> (total: i32 = 0, ok: bool = false) {
total = a + b;
ok = true;
}
main :: () -> i64 {
s, g := classify(5);
print("classify(5): sum={} good={}\n", s, g); // default sum -1, good true
t, o := combine(3, 4);
print("combine(3,4): total={} ok={}\n", t, o); // overridden: 7, true
return 0;
}

View File

@@ -0,0 +1,18 @@
// Negative: the named-return must-set rule is PATH-SENSITIVE (definite
// assignment). A slot assigned on only SOME paths is NOT definitely set, so the
// implicit return is rejected at COMPILE time — rather than returning a stale /
// uninitialized value at run time. Here `s` is assigned only inside `if c`, so
// the `c == false` path would leave it unset. (Assign it on every path — e.g.
// add an `else`, give it a default, or use an explicit `return`.)
#import "modules/std.sx";
pick :: (c: bool) -> (s: i64, y: i64) {
if c { s = 10; } // `s` unset on the else path
y = 1;
}
main :: () -> i64 {
a, b := pick(false);
print("{} {}\n", a, b);
return 0;
}

View File

@@ -0,0 +1,8 @@
// Negative: a multi-return's `return` must yield the right number of values.
// Returning a single value where two are required would leave the second slot
// uninitialized, so it is a compile error.
#import "modules/std.sx";
divmod :: (a: i64, b: i64) -> (i64, i64) {
return a / b; // only one value — needs two
}
main :: () -> i64 { q, r := divmod(7, 2); print("{} {}\n", q, r); return 0; }

View File

@@ -0,0 +1,8 @@
// Negative: named return elements must be given in SLOT ORDER. A mismatched
// name would otherwise be matched positionally and silently produce the wrong
// result, so it is rejected. (Here `b` is given where slot `a` is expected.)
#import "modules/std.sx";
pair :: (n: i32) -> (a: i32, b: i32) {
return b = n, a = n + 1; // out of slot order
}
main :: () -> i64 { x, y := pair(5); print("{} {}\n", x, y); return 0; }

View File

@@ -0,0 +1,8 @@
// Negative: a named-return slot may not share a name with a PARAMETER — the slot
// local would silently shadow the parameter. Rename one.
#import "modules/std.sx";
inc :: (sum: i32) -> (sum: i32, ok: bool) {
ok = true;
sum = sum + 1;
}
main :: () -> i64 { s, o := inc(10); print("{} {}\n", s, o); return 0; }

View File

@@ -0,0 +1,7 @@
// Negative: a SINGLE-value function may not be given a comma list — the extra
// values would be silently dropped. (Did you mean to declare `-> (i64, i64)`?)
#import "modules/std.sx";
one :: () -> i64 {
return 1, 2; // single-value return, two given
}
main :: () -> i64 { print("{}\n", one()); return 0; }

View File

@@ -0,0 +1,6 @@
// Negative: a bare-paren `(A, B)` is a multi-return SIGNATURE — valid only as a
// return type, never as a value type. A tuple-valued field/variable/parameter
// uses `Tuple(…)`.
#import "modules/std.sx";
Point :: struct { coords: (i64, i64); } // field value type — rejected
main :: () -> i64 { return 0; }

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
hi
bye
7

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
17 / 5 = 3 rem 2
sum=42 bigger=true

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,3 @@
classify(7): doubled=14 big=false
classify(21): doubled=42 big=true
classify(-3): caught Negative

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,4 @@
combine: sum=42 good=true
bounds: lo=5 hi=15
roots: val=7 sq=49
roots(-1): caught Negative

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,11 @@
error: named return 'good' may be unset (not assigned on every path) and has no default — assign it on every path, give it a default, or end with an explicit `return`
--> examples/types/0206-types-named-return-must-set.sx:7:57
|
7 | combine :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
| ^
8 | sum = f1 + f2;
| ^^^^^^^^^^^^^^^^^^
9 | // `good` is never assigned — must-set violation.
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 | }
| ^

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,2 @@
classify(5): sum=-1 good=true
combine(3,4): total=7 ok=true

View File

@@ -0,0 +1,11 @@
error: named return 's' may be unset (not assigned on every path) and has no default — assign it on every path, give it a default, or end with an explicit `return`
--> examples/types/0208-types-named-return-conditional-unset.sx:9:39
|
9 | pick :: (c: bool) -> (s: i64, y: i64) {
| ^
10 | if c { s = 10; } // `s` unset on the else path
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11 | y = 1;
| ^^^^^^^^^^
12 | }
| ^

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,5 @@
error: this function returns 2 values — return them as `return a, b`, not a single value
--> examples/types/0209-types-multi-return-arity.sx:6:12
|
6 | return a / b; // only one value — needs two
| ^^^^^

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,11 @@
error: named return element 'b' does not match the slot 'a' at position 0 — name the elements in slot order
--> examples/types/0210-types-multi-return-name-order.sx:6:5
|
6 | return b = n, a = n + 1; // out of slot order
| ^^^^^^^^^^^^^^^^^^^^^^^^
error: named return element 'a' does not match the slot 'b' at position 1 — name the elements in slot order
--> examples/types/0210-types-multi-return-name-order.sx:6:5
|
6 | return b = n, a = n + 1; // out of slot order
| ^^^^^^^^^^^^^^^^^^^^^^^^

View File

@@ -0,0 +1,5 @@
error: named return 'sum' collides with a parameter of the same name — rename one
--> examples/types/0211-types-named-return-param-collision.sx:4:22
|
4 | inc :: (sum: i32) -> (sum: i32, ok: bool) {
| ^^^^^^^^^^^^^^^^^^^^

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,5 @@
error: this function returns a single value, but a list of 2 was given
--> examples/types/0212-types-single-return-comma.sx:5:5
|
5 | return 1, 2; // single-value return, two given
| ^^^^^^^^^^^^

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,5 @@
error: a bare-paren `(A, B)` is a multi-return signature, valid only as a return type; a tuple-valued field uses `Tuple(…)`
--> examples/types/0213-types-multi-return-as-value-type.sx:5:27
|
5 | Point :: struct { coords: (i64, i64); } // field value type — rejected
| ^^^^^^^^^^

View File

@@ -0,0 +1,56 @@
# 0197 — annotated assignment with an incompatible type is unchecked (segfaults)
**Symptom** — A variable / constant declared with an explicit type annotation and
an initializer of an INCOMPATIBLE type is accepted with no diagnostic; the value
is passed through unchanged (a `.none` coercion plan), bit-mangling the slot and
segfaulting at run time.
- Observed: `x : i32 = "hi";` compiles, then crashes (`Segmentation fault`).
- Expected: a compile-time diagnostic — `cannot initialize 'x' of type 'i32'
with a value of type 'string'` (or similar), exit code 1, no crash.
This is a GENERAL type-checking gap, not specific to any one feature. It was
surfaced while reviewing the multi-return feature (a named-return slot default
`-> (sum: i32 = "hi", …)` hit the same path; that site now has its own guard, but
the underlying annotated-assignment hole remains).
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i64 {
x : i32 = "hi"; // string initializer for an i32 slot — no diagnostic
print("{}\n", x); // garbage, then SIGSEGV
return 0;
}
```
`./zig-out/bin/sx run repro.sx` → prints garbage then `Segmentation fault`.
`./zig-out/bin/sx ir repro.sx` does NOT crash (it lowers fine) — the bad coercion
blows up only at run time.
## Investigation prompt
The annotated var/const-decl lowering stores the initializer into the slot
WITHOUT checking that the initializer's type can actually reach the annotated
type. The store goes through `coerceToType` → `coerceMode`
(`src/ir/lower/coerce.zig:596,606`), whose classifier
(`coercionResolver().classify`, `src/ir/conversions.zig:54`) returns `.none` for
an incompatible pair — and `coerceMode`'s `.no_op, .none => return val` arm
(coerce.zig ~614) then passes the value through unchanged, so a 16-byte `string`
lands in a 4-byte `i32` slot (and vice-versa), corrupting memory.
The fix likely belongs at the annotated var-decl / const-decl store sites
(`src/ir/lower/stmt.zig` `lowerVarDecl` ~line 450, and the const-decl path) and
anywhere else a value is stored into an explicitly-annotated slot: when
`classify(src_ty, dst_ty) == .none` and `src_ty != dst_ty`, emit a diagnostic
(`self.diagnostics.addFmt(.err, span, "...", ...)`) instead of silently coercing.
(The multi-return default site already does exactly this — see the
`coercionResolver().classify(...) == .none` guard in `bindNamedReturnSlots`,
`src/ir/lower/stmt.zig` — that pattern can be lifted to a shared helper and reused
at the assignment sites.)
Verification: `./zig-out/bin/sx run repro.sx` should print a type-mismatch
diagnostic and exit non-zero, NOT segfault. Add a `examples/diagnostics/` or
`examples/types/` negative example once fixed.

View File

@@ -132,6 +132,59 @@ v : `i2 = ---; // referenced as a type
x : i2 = 3; // bare `i2` in type position is still the int type x : i2 = 3; // bare `i2` in type position is still the int type
``` ```
### Multiple return values
A function can return several values with a bare-paren return signature —
positional `-> (A, B)` or named `-> (x: A, y: B)`. The empty `-> ()` is `void`,
and a trailing `!` is the error channel (always the last slot): `-> (A, B, !)`. A
multi-return is **not** a tuple value — it is a distinct return shape (so a
parameter / field / variable annotation `x: (A, B)` is rejected; use `Tuple(…)`
for an actual tuple value).
```sx
divmod :: (a: i64, b: i64) -> (i64, i64) {
return a / b, a % b; // bare comma return — no `.( … )` literal
}
stats :: (a: i32, b: i32) -> (sum: i32, big: bool) {
return sum = a + b, big = a > b; // named, in slot order
}
```
Consume the result by **destructuring** or by binding it once and reaching the
value slots by **field**:
```sx
q, r := divmod(17, 5); // q = 3, r = 2
c := stats(40, 2); // c.sum = 42, c.big = true
```
For a **failable** multi-return, the error rides the separate `!` channel — a
bound value holds only the value slots, never the error:
```sx
classify :: (n: i32) -> (doubled: i32, big: bool, !) {
if n < 0 { raise error.Bad; }
return doubled = n * 2, big = n > 10;
}
d, b := classify(7) catch (e) { … }; // error stripped by `catch`; d, b are the values
```
**Named returns as locals.** Named slots are in-scope assignable locals; assigning
them *is* the return (no explicit `return` needed). A slot may carry a default,
which exempts it from the must-set rule:
```sx
combine :: (a: i32, b: i32) -> (sum: i32 = 0, good: bool) {
good = a > b;
sum = a + b; // both slots set → implicit return
}
```
A named slot that is **not assigned on every path** and has no default is a
compile error (definite-assignment) — rather than returning an uninitialized
value.
### Structs ### Structs
```sx ```sx

View File

@@ -893,6 +893,39 @@ s := swap(1, 2); // s.0 = 2, s.1 = 1
t := wrap(42); // t.0 = 42 t := wrap(42); // t.0 = 42
``` ```
#### Multiple Return Values (bare-paren return signature)
A function may return **multiple values** with a bare-paren return signature
(≥2 value slots) — positional `-> (A, B)` or named `-> (x: A, y: B)`, with an
optional trailing `!` error channel as the **last** slot (`-> (A, B, !)`). The
empty `-> ()` is `void`. A multi-return is a DISTINCT construct from a `Tuple(…)`
VALUE: it is represented internally by a reused tuple TypeId (same ABI), but it
is valid ONLY in a function/closure return position — a parameter, field, or
variable annotation `x: (A, B)` is rejected (use `Tuple(…)` for a tuple value).
A single-value `-> (T, !)` (one value + error) is NOT a multi-return; it is
exactly the failable `-> T !`.
```sx
divmod :: (a: i64, b: i64) -> (i64, i64) { return a / b, a % b; }
stats :: (a: i32, b: i32) -> (sum: i32, big: bool) { return sum = a + b, big = a > b; }
```
- **`return` forms.** Bare comma list — `return a, b` (positional) or
`return x = a, y = b` (named, **in slot order**); no `.( … )` literal. A single
positional `return v` is the ordinary single-value return. The list arity must
match the value-slot count, and named elements must agree with the slots; a
mismatch is a compile error (never a silent wrong result).
- **Consumption.** Destructure (`q, r := divmod(…)`) or single-bind + field
access (`c := stats(…); c.sum`). For a failable multi-return the error rides
the separate `!` channel — a bound value (`c := f() catch … `) holds only the
value slots, never the error.
- **Named returns as locals.** Named slots are in-scope assignable locals;
assigning them all *is* the implicit return (no explicit `return` needed). A
slot may carry a default (`(sum: i32 = 0, good: bool)`), which seeds the local
and exempts it from the must-set rule. A slot that is not assigned on every
non-diverging path and has no default is a compile error (definite assignment),
and a slot name may not collide with a parameter name.
#### Representation #### Representation
Tuples are represented as anonymous LLVM struct types (same layout as named structs). A tuple `Tuple(i64, i64)` has LLVM type `{ i64, i64 }`. Tuples are represented as anonymous LLVM struct types (same layout as named structs). A tuple `Tuple(i64, i64)` has LLVM type `{ i64, i64 }`.

View File

@@ -86,6 +86,7 @@ pub const Node = struct {
function_type_expr: FunctionTypeExpr, function_type_expr: FunctionTypeExpr,
closure_type_expr: ClosureTypeExpr, closure_type_expr: ClosureTypeExpr,
tuple_type_expr: TupleTypeExpr, tuple_type_expr: TupleTypeExpr,
return_type_expr: ReturnTypeExpr,
tuple_literal: TupleLiteral, tuple_literal: TupleLiteral,
ufcs_alias: UfcsAlias, ufcs_alias: UfcsAlias,
c_import_decl: CImportDecl, c_import_decl: CImportDecl,
@@ -867,6 +868,25 @@ pub const TupleTypeExpr = struct {
field_names: ?[]const []const u8, // null for positional field_names: ?[]const []const u8, // null for positional
}; };
/// A bare-paren MULTI-RETURN signature `(A, B)` / `(x: A, y: B)` / `(A, B, !)`
/// (≥2 value slots, error always the LAST slot). A function with this return
/// returns MULTIPLE VALUES — a DISTINCT thing from one `Tuple(…)` value: it
/// reuses the tuple ABI under the hood (resolves to a `.tuple` TypeId), but is
/// valid ONLY as a function/closure return type (the general type resolver
/// rejects it anywhere else), and its result is consumed only by destructuring
/// (`a, b := f()`), never bound to a single value. Same shape as a tuple type so
/// the resolver can reuse the field-resolution path. The single-value `(T, !)`
/// (one value + error) is NOT this — it is a plain failable, `-> T !`.
pub const ReturnTypeExpr = struct {
field_types: []const *Node,
field_names: ?[]const []const u8, // null for positional
/// Per-slot default value expressions (`(sum: i32 = 0, good: bool)`), 1:1
/// with `field_types`; an entry is null when that slot has no default. null
/// (the whole field) when NO slot has a default. A defaulted named slot is
/// exempt from the must-set rule — the default seeds the slot local.
field_defaults: ?[]const ?*Node = null,
};
pub const TupleLiteral = struct { pub const TupleLiteral = struct {
elements: []const TupleElement, elements: []const TupleElement,
// Explicit tuple type for the `Tuple(...).( ... )` typed-construction form // Explicit tuple type for the `Tuple(...).( ... )` typed-construction form

View File

@@ -312,6 +312,16 @@ pub const Lowering = struct {
/// Cleared per function body (the `Ref` space is per-function). /// Cleared per function body (the `Ref` space is per-function).
narrowed_refs: std.AutoHashMap(Ref, void) = undefined, narrowed_refs: std.AutoHashMap(Ref, void) = undefined,
force_block_value: bool = false, // set by lowerBlockValue to extract if-else values force_block_value: bool = false, // set by lowerBlockValue to extract if-else values
// Set while lowering a NAMED multi-return function body (`-> (x: A, y: B)`):
// the slot names (1:1 with the return tuple's fields; a trailing "!" marks
// the failable error slot). The slots are bound as in-scope assignable locals;
// at end-of-body with no explicit `return`, `lowerValueBody` synthesizes the
// implicit return from them (must-set rule: an unset, undefaulted slot errors).
named_return_names: ?[]const []const u8 = null,
// Per-slot default exprs (1:1 with the return tuple's fields; null where the
// slot has none). A defaulted named-return slot is seeded with its default
// and exempt from the must-set rule.
named_return_defaults: ?[]const ?*const ast.Node = null,
block_terminated: bool = false, // set when constant-folded if emits a return/br into current block block_terminated: bool = false, // set when constant-folded if emits a return/br into current block
in_lambda_body: bool = false, // true while lowering a closure-literal body; sharpens the `raise`-not-failable diagnostic (ERR E5.1: tell the user to annotate `-> (T, !)`) in_lambda_body: bool = false, // true while lowering a closure-literal body; sharpens the `raise`-not-failable diagnostic (ERR E5.1: tell the user to annotate `-> (T, !)`)
defer_stack: std.ArrayList(CleanupEntry) = std.ArrayList(CleanupEntry).empty, // block-scoped defer + onfail cleanup stack defer_stack: std.ArrayList(CleanupEntry) = std.ArrayList(CleanupEntry).empty, // block-scoped defer + onfail cleanup stack
@@ -646,6 +656,12 @@ pub const Lowering = struct {
pub fn resolveReturnType(self: *Lowering, fd: *const ast.FnDecl) TypeId { pub fn resolveReturnType(self: *Lowering, fd: *const ast.FnDecl) TypeId {
if (fd.return_type) |rt| { if (fd.return_type) |rt| {
// A bare-paren multi-return signature `(A, B)` is valid HERE (return
// position); it resolves to its reused tuple TypeId. Misuse as a VALUE
// type (a param / field / var annotation) is rejected at those sites
// (`resolveParamType` et al.), not in the common resolver — return
// types are re-resolved in many places (call-result typing, protocol
// impls) that a central reject would wrongly trip.
return self.resolveTypeWithBindings(rt); return self.resolveTypeWithBindings(rt);
} }
// No explicit annotation — the type is inferred from the body, which // No explicit annotation — the type is inferred from the body, which
@@ -715,6 +731,19 @@ pub const Lowering = struct {
}; };
} }
/// A bare-paren `(A, B)` multi-return SIGNATURE is valid only as a
/// function/closure return type — never as a VALUE type (a parameter /
/// variable / field annotation), where a tuple value uses `Tuple(…)`. Emits a
/// diagnostic and returns true when `node` is a `ReturnTypeExpr`. (`what` names
/// the offending position, e.g. "parameter" / "variable" / "field".)
pub fn rejectMultiReturnValueType(self: *Lowering, node: *const ast.Node, what: []const u8) bool {
if (node.data != .return_type_expr) return false;
if (self.diagnostics) |d| {
d.addFmt(.err, node.span, "a bare-paren `(A, B)` is a multi-return signature, valid only as a return type; a tuple-valued {s} uses `Tuple(…)`", .{what});
}
return true;
}
pub fn resolveParamType(self: *Lowering, p: *const ast.Param) TypeId { pub fn resolveParamType(self: *Lowering, p: *const ast.Param) TypeId {
// A plain value param with no annotation can only be typed from // A plain value param with no annotation can only be typed from
// context (a lambda's target closure signature). When `resolveParamType` // context (a lambda's target closure signature). When `resolveParamType`
@@ -728,6 +757,9 @@ pub const Lowering = struct {
} }
return .unresolved; return .unresolved;
} }
// A bare-paren `(A, B)` is a MULTI-RETURN signature, valid only as a
// return type — not a parameter value type (use `Tuple(…)`).
if (self.rejectMultiReturnValueType(p.type_expr, "parameter")) return .unresolved;
const declared_ty = self.resolveTypeWithBindings(p.type_expr); const declared_ty = self.resolveTypeWithBindings(p.type_expr);
if (p.is_variadic) { if (p.is_variadic) {
// Two surface forms: // Two surface forms:
@@ -1834,6 +1866,9 @@ pub const Lowering = struct {
pub const lowerInlineBranch = lower_stmt.lowerInlineBranch; pub const lowerInlineBranch = lower_stmt.lowerInlineBranch;
pub const lowerBlockValue = lower_stmt.lowerBlockValue; pub const lowerBlockValue = lower_stmt.lowerBlockValue;
pub const lowerValueBody = lower_stmt.lowerValueBody; pub const lowerValueBody = lower_stmt.lowerValueBody;
pub const bindNamedReturnSlots = lower_stmt.bindNamedReturnSlots;
pub const synthesizeNamedReturn = lower_stmt.synthesizeNamedReturn;
pub const validateMultiReturn = lower_stmt.validateMultiReturn;
pub const tryLowerAsExpr = lower_stmt.tryLowerAsExpr; pub const tryLowerAsExpr = lower_stmt.tryLowerAsExpr;
pub const lowerStmt = lower_stmt.lowerStmt; pub const lowerStmt = lower_stmt.lowerStmt;
pub const lowerVarDecl = lower_stmt.lowerVarDecl; pub const lowerVarDecl = lower_stmt.lowerVarDecl;

View File

@@ -2716,6 +2716,18 @@ pub fn lowerFunctionBodyInto(self: *Lowering, fd: *const ast.FnDecl, fid: FuncId
scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
}; };
// Named multi-return (`-> (x: A, y: B)`): bind the slots as in-scope locals
// for the body to assign; `lowerValueBody` synthesizes the implicit return.
const saved_nrn = self.named_return_names;
const saved_nrd = self.named_return_defaults;
self.named_return_names = null;
self.named_return_defaults = null;
defer {
self.named_return_names = saved_nrn;
self.named_return_defaults = saved_nrd;
}
if (fd.abi != .naked) self.bindNamedReturnSlots(fd, ret_ty, &scope);
// Inbound entry points + abi(.c) sx functions: bind current_ctx_ref // Inbound entry points + abi(.c) sx functions: bind current_ctx_ref
// to the static default before any user code runs. // to the static default before any user code runs.
if (!wants_ctx and self.implicit_ctx_enabled) { if (!wants_ctx and self.implicit_ctx_enabled) {
@@ -2873,6 +2885,17 @@ pub fn lowerFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8, i
scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
}; };
// Named multi-return slots as in-scope locals (see the sibling body path).
const saved_nrn_lf = self.named_return_names;
const saved_nrd_lf = self.named_return_defaults;
self.named_return_names = null;
self.named_return_defaults = null;
defer {
self.named_return_names = saved_nrn_lf;
self.named_return_defaults = saved_nrd_lf;
}
if (fd.abi != .naked) self.bindNamedReturnSlots(fd, ret_ty, &scope);
// Inbound entry points + abi(.c) sx functions: bind // Inbound entry points + abi(.c) sx functions: bind
// current_ctx_ref to &__sx_default_context. See companion comment // current_ctx_ref to &__sx_default_context. See companion comment
// in `lowerFunction` for the same case. // in `lowerFunction` for the same case.

View File

@@ -661,6 +661,13 @@ pub fn matchTypeParam(_: *Lowering, type_node: *const Node, tp_name: []const u8)
if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true; if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true;
break :blk false; break :blk false;
}, },
// A failable closure return `Closure() -> $R !E` folds to a `(T, !)`
// tuple_type_expr, so a `$R` in the value slot lives inside the tuple's
// field_types — descend so the param is still seen as generic-bearing.
.tuple_type_expr => |tt| blk: {
for (tt.field_types) |ft| if (matchTypeParamStatic(ft, tp_name)) break :blk true;
break :blk false;
},
.parameterized_type_expr => |pt| blk: { .parameterized_type_expr => |pt| blk: {
for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true; for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true;
break :blk false; break :blk false;
@@ -683,6 +690,12 @@ pub fn matchTypeParamStatic(type_node: *const Node, tp_name: []const u8) bool {
if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true; if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true;
break :blk false; break :blk false;
}, },
// See the `matchTypeParam` tuple arm — a failable closure return folds
// to a `(T, !)` tuple_type_expr; descend into its value field(s).
.tuple_type_expr => |tt| blk: {
for (tt.field_types) |ft| if (matchTypeParamStatic(ft, tp_name)) break :blk true;
break :blk false;
},
.parameterized_type_expr => |pt| blk: { .parameterized_type_expr => |pt| blk: {
for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true; for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true;
break :blk false; break :blk false;
@@ -764,6 +777,31 @@ pub fn extractTypeParam(self: *Lowering, type_node: *const Node, arg_ty: TypeId,
} }
break :blk null; break :blk null;
}, },
.tuple_type_expr => |tt| blk: {
// A failable closure return `Closure() -> $R !E` folds to a `(T, !)`
// tuple_type_expr, so this arm is reached when inferring `$R` from a
// closure ARG's return type. Two arg shapes must both bind:
// - failable arg (`() -> i64 !E`): its closure `ret` is a `.tuple`
// `(i64, errset)` — match field-wise against the param tuple.
// - non-failable arg (`() -> i64`) ∅-widened into the failable slot:
// its `ret` is the bare value type `i64` — match the param's FIRST
// value field against the whole arg type.
if (!arg_ty.isBuiltin()) {
const info = self.module.types.get(arg_ty);
if (info == .tuple) {
const at = info.tuple;
for (tt.field_types, 0..) |ft, i| {
if (i >= at.fields.len) break;
if (self.extractTypeParam(ft, at.fields[i], tp_name)) |ety| break :blk ety;
}
break :blk null;
}
}
// arg is a bare value type (builtin or single non-tuple): bind it to
// the tuple's first (value) field.
if (tt.field_types.len > 0) break :blk self.extractTypeParam(tt.field_types[0], arg_ty, tp_name);
break :blk null;
},
.parameterized_type_expr => |pt| blk: { .parameterized_type_expr => |pt| blk: {
// A generic-struct param head (`Box($T)`, also reached recursively // A generic-struct param head (`Box($T)`, also reached recursively
// for a pointer-wrapped `*Box($T)`): the arg is a monomorphized // for a pointer-wrapped `*Box($T)`): the arg is a monomorphized

View File

@@ -671,6 +671,7 @@ pub fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl, source_fil
using_idx += 1; using_idx += 1;
} }
if (field_idx < total_explicit) { if (field_idx < total_explicit) {
_ = self.rejectMultiReturnValueType(sd.field_types[field_idx], "field");
const field_ty = self.resolveType(sd.field_types[field_idx]); const field_ty = self.resolveType(sd.field_types[field_idx]);
fields.append(self.alloc, .{ fields.append(self.alloc, .{
.name = table.internString(sd.field_names[field_idx]), .name = table.internString(sd.field_names[field_idx]),

View File

@@ -163,6 +163,16 @@ pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
return; return;
} }
} }
// A NAMED multi-return function (`-> (x: A, y: B)`) with no explicit
// `return`: synthesize the implicit return from the named slot LOCALS (which
// the body assigned). The must-set rule is checked here — an unset, undefaulted
// slot is a loud error, not a silent fill. This takes precedence over the
// "produces no value" diagnostic below (the body legitimately produces its
// result by assigning the slots, not via a trailing expression).
if (self.named_return_names) |names| {
self.synthesizeNamedReturn(body, ret_ty, names);
return;
}
// A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS // A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS
// the error channel) carries no success value — a void body is a normal // the error channel) carries no success value — a void body is a normal
// success exit, not a missing value. `ensureTerminator` emits the // success exit, not a missing value. `ensureTerminator` emits the
@@ -196,6 +206,150 @@ pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
self.ensureTerminator(ret_ty); self.ensureTerminator(ret_ty);
} }
/// Definite-assignment check for the named-return must-set rule: true iff every
/// non-diverging path through `node` assigns the bare identifier `name` (or
/// diverges via `return`/`raise` before reaching the implicit return). PATH-
/// SENSITIVE — a slot set in only ONE branch of an `if` (no `else`) is NOT
/// definitely assigned, so it errors instead of returning a stale/garbage value.
/// - `return`/`raise` → vacuously true (that path never reaches the implicit
/// return, so the slot need not be set on it).
/// - block → the FIRST statement that definitely assigns (or diverges) settles
/// it (sequential composition).
/// - `if` → both branches must (an `if` with no `else` cannot).
/// - `push { … }` → always runs its body.
/// - `match` → all arms must AND there is an `else` arm (exhaustiveness).
/// - `while`/`for`/`defer`/`catch` and everything else → not guaranteed.
/// Does not descend into nested function / lambda bodies (their `return`s own).
fn definitelyAssigns(node: *const Node, name: []const u8) bool {
return switch (node.data) {
.assignment => |a| a.target.data == .identifier and std.mem.eql(u8, a.target.data.identifier.name, name),
.multi_assign => |ma| blk: {
for (ma.targets) |t| {
if (t.data == .identifier and std.mem.eql(u8, t.data.identifier.name, name)) break :blk true;
}
break :blk false;
},
// Function-level divergence — this path never reaches the implicit return.
.return_stmt, .raise_stmt => true,
.block => |blk| {
for (blk.stmts) |s| if (definitelyAssigns(s, name)) return true;
return false;
},
.if_expr => |ie| ie.else_branch != null and
definitelyAssigns(ie.then_branch, name) and definitelyAssigns(ie.else_branch.?, name),
.push_stmt => |ps| definitelyAssigns(ps.body, name),
.match_expr => |me| blk: {
var has_else = false;
for (me.arms) |arm| {
if (arm.pattern == null) has_else = true;
if (!definitelyAssigns(arm.body, name)) break :blk false;
}
break :blk has_else;
},
else => false,
};
}
/// Bind a NAMED multi-return signature's value slots (`-> (x: A, y: B)`) as
/// in-scope assignable locals, so the body's `x = …` writes to them. Each slot
/// is a zero-initialized alloca (deterministic value if a path misses it — see
/// `bodyAssignsTo`). Sets `self.named_return_names`; the caller restores it.
/// No-op for a positional multi-return (no names → use an explicit `return`).
pub fn bindNamedReturnSlots(self: *Lowering, fd: *const ast.FnDecl, ret_ty: TypeId, scope: *Scope) void {
const rt = fd.return_type orelse return;
if (rt.data != .return_type_expr) return;
const names = rt.data.return_type_expr.field_names orelse return; // positional → no locals
const defaults = rt.data.return_type_expr.field_defaults;
if (ret_ty.isBuiltin()) return;
const ti = self.module.types.get(ret_ty);
if (ti != .tuple) return;
const fields = ti.tuple.fields;
const value_count = if (self.errorChannelOf(ret_ty) != null) fields.len - 1 else fields.len;
var i: usize = 0;
while (i < value_count and i < names.len) : (i += 1) {
const nm = names[i];
if (nm.len == 0 or std.mem.eql(u8, nm, "!")) continue;
// A named-return slot that shadows a PARAMETER of the same name would
// silently hide the parameter behind a fresh local — reject the collision.
for (fd.params) |p| {
if (std.mem.eql(u8, p.name, nm)) {
if (self.diagnostics) |d| {
d.addFmt(.err, rt.span, "named return '{s}' collides with a parameter of the same name — rename one", .{nm});
}
}
}
const fty = fields[i];
const slot = self.builder.alloca(fty);
// Seed the slot. A slot with a DEFAULT gets it (type-checked, lowered,
// coerced). Otherwise zero/default-init for ANY type (a deterministic
// value if the path-insensitive must-set can't prove a path sets it —
// never raw garbage; covers string / struct / float slots too).
const dflt: ?*const Node = if (defaults) |ds| (if (i < ds.len) ds[i] else null) else null;
if (dflt) |dn| {
const saved_target = self.target_type;
self.target_type = fty;
const dval = self.lowerExpr(dn);
self.target_type = saved_target;
const dval_ty = self.builder.getRefType(dval);
// Reject a default whose type has NO coercion to the slot type (e.g.
// `sum: i32 = "hi"`) — a `.none` plan would pass the value through
// unchanged and bit-mangle / segfault. (The same hole exists for any
// annotated assignment `x: i32 = "hi"` — a broader pre-existing gap.)
if (dval_ty != .unresolved and self.coercionResolver().classify(dval_ty, fty) == .none and dval_ty != fty) {
if (self.diagnostics) |d| {
d.addFmt(.err, dn.span, "named return '{s}' has a default of type '{s}' that does not match its declared type '{s}'", .{ nm, self.formatTypeName(dval_ty), self.formatTypeName(fty) });
}
self.builder.store(slot, self.buildDefaultValue(fty));
} else {
self.builder.store(slot, self.coerceToType(dval, dval_ty, fty));
}
} else {
self.builder.store(slot, self.buildDefaultValue(fty));
}
scope.put(nm, .{ .ref = slot, .ty = fty, .is_alloca = true });
}
self.named_return_names = names;
self.named_return_defaults = defaults;
}
/// Emit the implicit return of a NAMED multi-return body: enforce the must-set
/// rule on each value slot, then synthesize and lower `return n0 = n0, n1 = n1`
/// over the slot locals — reusing the ordinary return path (tuple build +
/// value-carrying-failable assembly), so failable named multi-returns work too.
pub fn synthesizeNamedReturn(self: *Lowering, body: *const Node, ret_ty: TypeId, names: []const []const u8) void {
const ti = self.module.types.get(ret_ty);
if (ti != .tuple) {
self.ensureTerminator(ret_ty);
return;
}
const fields = ti.tuple.fields;
const value_count = if (self.errorChannelOf(ret_ty) != null) fields.len - 1 else fields.len;
var elems = std.ArrayList(ast.TupleElement).empty;
defer elems.deinit(self.alloc);
var i: usize = 0;
while (i < value_count and i < names.len) : (i += 1) {
const nm = names[i];
if (nm.len == 0 or std.mem.eql(u8, nm, "!")) continue;
// Must-set: a slot not DEFINITELY assigned (on every non-diverging path)
// and with no default is an error. A defaulted slot is exempt — its
// default seeds the local in `bindNamedReturnSlots`.
const has_default = if (self.named_return_defaults) |ds| (i < ds.len and ds[i] != null) else false;
if (!has_default and !definitelyAssigns(body, nm)) {
if (self.diagnostics) |d| {
d.addFmt(.err, body.span, "named return '{s}' may be unset (not assigned on every path) and has no default — assign it on every path, give it a default, or end with an explicit `return`", .{nm});
}
}
const id_node = self.alloc.create(Node) catch return;
id_node.* = .{ .span = body.span, .data = .{ .identifier = .{ .name = nm } } };
elems.append(self.alloc, .{ .name = nm, .value = id_node }) catch return;
}
const tl = self.alloc.create(Node) catch return;
tl.* = .{ .span = body.span, .data = .{ .tuple_literal = .{ .elements = elems.toOwnedSlice(self.alloc) catch return } } };
const rs = ast.ReturnStmt{ .value = tl };
self.lowerReturn(&rs);
}
/// Try to lower a node as an expression, returning its value. /// Try to lower a node as an expression, returning its value.
/// Statement nodes are lowered as statements (returning null). /// Statement nodes are lowered as statements (returning null).
pub fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref { pub fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref {
@@ -295,6 +449,7 @@ pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
} }
if (vd.type_annotation) |ta| { if (vd.type_annotation) |ta| {
// Explicit type annotation — resolve type first, then lower value // Explicit type annotation — resolve type first, then lower value
_ = self.rejectMultiReturnValueType(ta, "variable");
const ty = self.resolveType(ta); const ty = self.resolveType(ta);
const slot = self.builder.alloca(ty); const slot = self.builder.alloca(ty);
if (vd.value) |val| { if (vd.value) |val| {
@@ -535,12 +690,80 @@ pub fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void {
} }
} }
/// Validate an explicit `return` value against a multi-VALUE return type (≥2
/// value slots). Emits diagnostics; does not rewrite. Covers: a bare value where
/// multiple are required (`return 5` for `-> (i64, i64)`), wrong arity (too few /
/// too many), and named elements that disagree with the slot at their position
/// (named return elements must currently be IN SLOT ORDER — reordering by name is
/// a future nicety, but a mismatch is an error, never a silent wrong result).
/// A single-value or single-failable return is left to the existing path.
pub fn validateMultiReturn(self: *Lowering, value_node: *const Node, ret_ty: TypeId) void {
const diags = self.diagnostics orelse return;
const ret_is_tuple = !ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple;
// A comma list / multi-element literal returned from a SINGLE-value
// (non-tuple) function would silently drop the extra values — reject it.
if (!ret_is_tuple and value_node.data == .tuple_literal) {
const els = value_node.data.tuple_literal.elements;
if (els.len > 1) {
for (els) |e| if (e.value.data == .spread_expr) return; // can't count a spread
diags.addFmt(.err, value_node.span, "this function returns a single value, but a list of {d} was given", .{els.len});
}
return;
}
if (!ret_is_tuple) return;
const ti = self.module.types.get(ret_ty);
const fields = ti.tuple.fields;
const is_failable = self.errorChannelOf(ret_ty) != null;
const value_count = if (is_failable) fields.len - 1 else fields.len;
if (value_count < 2) return; // single value / single failable — not multi-return
if (value_node.data == .tuple_literal) {
const els = value_node.data.tuple_literal.elements;
// A spread (`..xs`) can expand to any arity — can't check statically.
for (els) |e| if (e.value.data == .spread_expr) return;
// The value-only list (n == value_count) is the bare-comma form; the full
// failable tuple (n == fields, including the error slot) is also allowed.
if (els.len != value_count and els.len != fields.len) {
diags.addFmt(.err, value_node.span, "this function returns {d} values, but {d} {s} given", .{ value_count, els.len, if (els.len == 1) @as([]const u8, "is") else @as([]const u8, "are") });
return;
}
// Named elements must line up with the slots positionally.
if (ti.tuple.names) |slot_names| {
for (els, 0..) |e, idx| {
const en = e.name orelse continue;
if (idx >= slot_names.len) continue;
const sn = self.module.types.getString(slot_names[idx]);
if (sn.len != 0 and !std.mem.eql(u8, en, sn)) {
diags.addFmt(.err, value_node.span, "named return element '{s}' does not match the slot '{s}' at position {d} — name the elements in slot order", .{ en, sn, idx });
}
}
}
} else {
// A bare value (not a comma list) where ≥2 are required is valid only if
// it already PRODUCES the whole multi-value tuple — forwarding another
// multi-return's result, or a multi-output `asm { … }`. Any TUPLE-typed
// value qualifies (names may differ from the slots); a non-tuple scalar
// does not — that is the `return 5` for `-> (i64, i64)` garbage case.
const vty = self.inferExprType(value_node);
const v_is_tuple = vty != .unresolved and !vty.isBuiltin() and self.module.types.get(vty) == .tuple;
if (vty != .unresolved and !v_is_tuple) {
diags.addFmt(.err, value_node.span, "this function returns {d} values — return them as `return a, b`, not a single value", .{value_count});
}
}
}
pub fn lowerReturn(self: *Lowering, rs: *const ast.ReturnStmt) void { pub fn lowerReturn(self: *Lowering, rs: *const ast.ReturnStmt) void {
if (rs.value) |val| { if (rs.value) |val| {
if (val.data == .identifier and self.isPackName(val.data.identifier.name)) { if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
_ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value); _ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value);
return; return;
} }
// Validate a multi-value return against the function's slots: arity, a
// bare value where multiple are required, and named-element/slot
// agreement. Catches silent garbage (`return 5` for `-> (i64, i64)`) and
// silently-wrong named returns (`return b = …, a = …` ignoring names).
if (self.builder.func) |fid| {
self.validateMultiReturn(val, self.module.functions.items[@intFromEnum(fid)].ret);
}
} }
// Set target_type to function return type so null_literal etc. get the right type. // Set target_type to function return type so null_literal etc. get the right type.
// When inlining a comptime body, the *inlined* fn's declared return type wins // When inlining a comptime body, the *inlined* fn's declared return type wins

View File

@@ -402,6 +402,7 @@ pub const UnknownTypeChecker = struct {
.function_type_expr, .function_type_expr,
.closure_type_expr, .closure_type_expr,
.tuple_type_expr, .tuple_type_expr,
.return_type_expr,
=> {}, => {},
} }
} }

View File

@@ -190,6 +190,10 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap
// lives in PackResolver. // lives in PackResolver.
.closure_type_expr => |ct| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveClosurePackShape(&ct, table, alias_map, consts), .closure_type_expr => |ct| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveClosurePackShape(&ct, table, alias_map, consts),
.tuple_type_expr => |tt| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveTupleSpreadShape(&tt, table, alias_map, consts), .tuple_type_expr => |tt| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveTupleSpreadShape(&tt, table, alias_map, consts),
// A multi-return signature resolves to its REUSED tuple TypeId — the ABI
// is a tuple; only its meaning ("multiple return values", return-only,
// destructure-only) differs, which the AST node (not the TypeId) carries.
.return_type_expr => type_resolver.TypeResolver.resolveCompound(table, n, si) orelse .unresolved,
.pack_index_type_expr => { .pack_index_type_expr => {
// Pack-index `$args[N]` in a type position must be resolved // Pack-index `$args[N]` in a type position must be resolved
// against an active pack binding — `type_bridge` has no access // against an active pack binding — `type_bridge` has no access
@@ -334,6 +338,7 @@ pub fn isTypeShapedAstNode(node: *const Node, table: *TypeTable) bool {
.function_type_expr, .function_type_expr,
.closure_type_expr, .closure_type_expr,
.tuple_type_expr, .tuple_type_expr,
.return_type_expr,
.parameterized_type_expr, .parameterized_type_expr,
.pack_index_type_expr, .pack_index_type_expr,
.comptime_pack_ref, .comptime_pack_ref,

View File

@@ -248,41 +248,48 @@ pub const TypeResolver = struct {
const ret_ty = if (ct.return_type) |rt| inner.resolveInner(rt) else TypeId.void; const ret_ty = if (ct.return_type) |rt| inner.resolveInner(rt) else TypeId.void;
break :blk table.closureType(param_ids.items, ret_ty); break :blk table.closureType(param_ids.items, ret_ty);
}, },
.tuple_type_expr => |tt| blk: { .tuple_type_expr => |tt| internTupleLike(table, tt.field_types, tt.field_names, inner),
// A spread field `(..xs)` expands to many fields via the pack // A multi-return signature `(A, B)` resolves to the SAME tuple TypeId
// state — defer to PackResolver by returning null. // (the ABI is a tuple); its distinct meaning lives in the AST node.
for (tt.field_types) |ft| if (ft.data == .spread_expr) break :blk null; .return_type_expr => |rt| internTupleLike(table, rt.field_types, rt.field_names, inner),
else => null,
};
}
/// Intern a `.tuple` TypeId from a list of field-type nodes (+ optional
/// names) — the shared body of the `tuple_type_expr` and `return_type_expr`
/// resolution arms. Returns null to defer a spread to the (stateful)
/// PackResolver, `.unresolved` if any field is non-type, else the tuple.
fn internTupleLike(table: *TypeTable, field_types: []const *Node, field_names: ?[]const []const u8, inner: anytype) ?TypeId {
// A spread field `(..xs)` expands to many fields via the pack state —
// defer to PackResolver by returning null.
for (field_types) |ft| if (ft.data == .spread_expr) return null;
var field_ids = std.ArrayList(TypeId).empty; var field_ids = std.ArrayList(TypeId).empty;
defer field_ids.deinit(table.alloc); defer field_ids.deinit(table.alloc);
for (tt.field_types) |ft| { for (field_types) |ft| {
const fid = inner.resolveInner(ft); const fid = inner.resolveInner(ft);
// A non-type tuple element (e.g. the `1` in `Tuple(i32, 1)`) // A non-type element (e.g. the `1` in `Tuple(i32, 1)`) resolves to
// resolves to `.unresolved`; never intern a tuple carrying it // `.unresolved`; never intern a tuple carrying it — that bogus type
// — that bogus type would reach LLVM emission and panic. The // would reach LLVM emission and panic. The user-facing diagnostic is
// user-facing diagnostic is emitted by the literal-rejection // emitted by the literal-rejection arm in `resolveTypeArg`; here we
// arm in `resolveTypeArg` (lower.zig, the `tuple_type_expr` // just refuse to fabricate the type, propagating the sentinel up.
// check); here we just refuse to fabricate the type, if (fid == .unresolved) return .unresolved;
// propagating the sentinel up.
if (fid == .unresolved) break :blk .unresolved;
field_ids.append(table.alloc, fid) catch return .unresolved; field_ids.append(table.alloc, fid) catch return .unresolved;
} }
// Preserve field names for a named tuple `(x: T, y: U)` when the // Preserve field names for a named tuple `(x: T, y: U)` when the name and
// name and field counts agree (so `t.x` resolves). // field counts agree (so `t.x` resolves).
var name_ids: ?[]const StringId = null; var name_ids: ?[]const StringId = null;
if (tt.field_names) |names| { if (field_names) |names| {
if (names.len == field_ids.items.len) { if (names.len == field_ids.items.len) {
var ids = std.ArrayList(StringId).empty; var ids = std.ArrayList(StringId).empty;
for (names) |n| ids.append(table.alloc, table.internString(n)) catch return .unresolved; for (names) |n| ids.append(table.alloc, table.internString(n)) catch return .unresolved;
name_ids = ids.toOwnedSlice(table.alloc) catch null; name_ids = ids.toOwnedSlice(table.alloc) catch null;
} }
} }
break :blk table.intern(.{ .tuple = .{ return table.intern(.{ .tuple = .{
.fields = table.alloc.dupe(TypeId, field_ids.items) catch return .unresolved, .fields = table.alloc.dupe(TypeId, field_ids.items) catch return .unresolved,
.names = name_ids, .names = name_ids,
} }); } });
},
else => null,
};
} }
/// Generic type-param binding lookup (`$T`, or a bare return-type `T`). /// Generic type-param binding lookup (`$T`, or a bare return-type `T`).

View File

@@ -384,20 +384,33 @@ test "parser: -> ! stays a bare error_type_expr" {
try std.testing.expect(rt.data == .error_type_expr); try std.testing.expect(rt.data == .error_type_expr);
} }
// Old inline `-> (T, !)` failable form is gone — rejected with the new-form hint. // Bare-paren `-> (T, !)` is a SINGLE-value failable return (= `-> T !`): one
test "parser: old inline -> (T, !) is rejected" { // value slot + a trailing error channel. Parses to a `(T, !)` tuple_type_expr —
// NOT a multi-return signature (only ≥2 value slots are `return_type_expr`).
test "parser: -> (T, !) is a single-value failable, not multi-return" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator); var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit(); defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () -> (i64, !) { 0 }"); var parser = Parser.init(arena.allocator(), "f :: () -> (i64, !) { 0 }");
try std.testing.expectError(error.ParseError, parser.parse()); const root = try parser.parse();
const rt = root.data.root.decls[0].data.fn_decl.return_type.?;
try std.testing.expect(rt.data == .tuple_type_expr);
const fields = rt.data.tuple_type_expr.field_types;
try std.testing.expectEqual(@as(usize, 2), fields.len);
try std.testing.expect(fields[1].data == .error_type_expr);
} }
// Bare-paren tuple TYPE `(A, B)` is gone — rejected (tuple types use `Tuple(...)`). // A bare-paren list with ≥2 VALUE slots is a MULTI-RETURN signature: it PARSES
test "parser: bare-paren tuple type (A, B) is rejected" { // to its OWN `return_type_expr` node (a distinct thing from a `Tuple(…)` value).
// Its rejection OUTSIDE a return position is a RESOLVE-time diagnostic (see the
// corpus), not a parse error.
test "parser: bare-paren (A, B) parses to a return_type_expr" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator); var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit(); defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: (t: (i64, i32)) { }"); var parser = Parser.init(arena.allocator(), "f :: () -> (i64, i32) { 0 }");
try std.testing.expectError(error.ParseError, parser.parse()); const root = try parser.parse();
const rt = root.data.root.decls[0].data.fn_decl.return_type.?;
try std.testing.expect(rt.data == .return_type_expr);
try std.testing.expectEqual(@as(usize, 2), rt.data.return_type_expr.field_types.len);
} }
// Bare-paren tuple VALUE `(a, b)` is gone — rejected (tuple values use `.(...)`). // Bare-paren tuple VALUE `(a, b)` is gone — rejected (tuple values use `.(...)`).

View File

@@ -610,6 +610,11 @@ pub const Parser = struct {
self.advance(); // skip '(' self.advance(); // skip '('
var param_types = std.ArrayList(*Node).empty; var param_types = std.ArrayList(*Node).empty;
var param_names = std.ArrayList(?[]const u8).empty; var param_names = std.ArrayList(?[]const u8).empty;
// Per-element default value (`(sum: i32 = 0, …)`), 1:1 with
// `param_types`; meaningful only for a multi-return signature
// (`return_type_expr`) — ignored for grouping / function / tuple forms.
var param_defaults = std.ArrayList(?*Node).empty;
var any_default = false;
var has_names = false; var has_names = false;
// An error channel type (`!` / `!Named`) is only valid as the // An error channel type (`!` / `!Named`) is only valid as the
// trailing element of a result list. Reject any element after it. // trailing element of a result list. Reject any element after it.
@@ -640,6 +645,7 @@ pub const Parser = struct {
const spread = try self.createNode(spread_start, .{ .spread_expr = .{ .operand = operand } }); const spread = try self.createNode(spread_start, .{ .spread_expr = .{ .operand = operand } });
try param_names.append(self.allocator, null); try param_names.append(self.allocator, null);
try param_types.append(self.allocator, spread); try param_types.append(self.allocator, spread);
try param_defaults.append(self.allocator, null);
continue; continue;
} }
// Check for optional param name: `name: Type` // Check for optional param name: `name: Type`
@@ -656,6 +662,16 @@ pub const Parser = struct {
const elem = try self.parseTypeExpr(); const elem = try self.parseTypeExpr();
if (elem.data == .error_type_expr) saw_error_type = true; if (elem.data == .error_type_expr) saw_error_type = true;
try param_types.append(self.allocator, elem); try param_types.append(self.allocator, elem);
// Optional default value `name: Type = <expr>` — a multi-return
// slot default. Parse it for every element (1:1 with types) and
// attach only to a `return_type_expr` below.
var elem_default: ?*Node = null;
if (self.current.tag == .equal) {
self.advance(); // skip '='
elem_default = try self.parseExpr();
any_default = true;
}
try param_defaults.append(self.allocator, elem_default);
} }
try self.expect(.r_paren); try self.expect(.r_paren);
if (self.current.tag == .arrow) { if (self.current.tag == .arrow) {
@@ -673,6 +689,12 @@ pub const Parser = struct {
.abi = abi, .abi = abi,
} }); } });
} }
// Empty parens `()` with no `->` is the void/unit type:
// `a :: () -> () { }` is equivalent to `-> void`. (`() -> R` was the
// zero-param function type handled by the arrow branch above.)
if (param_types.items.len == 0) {
return try self.createNode(start, .{ .type_expr = .{ .name = "void" } });
}
// No '->': bare `(...)` in type position is GROUPING ONLY. A single // No '->': bare `(...)` in type position is GROUPING ONLY. A single
// UNNAMED, non-spread element with NO trailing comma resolves to the // UNNAMED, non-spread element with NO trailing comma resolves to the
// inner type. This lets `(Closure(i64,i64) -> i64)`, `?(?i64)`, etc. // inner type. This lets `(Closure(i64,i64) -> i64)`, `?(?i64)`, etc.
@@ -682,15 +704,49 @@ pub const Parser = struct {
{ {
return param_types.items[0]; return param_types.items[0];
} }
// Anything else (a top-level comma, a `(T,)` 1-tuple, names, a // A bare-paren result list classifies by VALUE-slot count (fields
// spread) used to build a bare-paren `tuple_type_expr`. That grammar // minus a trailing error channel — the error is ALWAYS the last slot):
// is gone: tuple types are written `Tuple( … )`. If the group ends in // - ≥2 value slots → a MULTI-RETURN signature `(A, B)` /
// an error channel `!`, it is the old failable spelling `-> (T, !)`. // `(x: A, y: B)` / `(A, B, !)`: its OWN node (`return_type_expr`),
// a DISTINCT thing from a `Tuple(…)` value (not a tuple,
// return-only, destructure-only).
// - 1 value slot + error `(T, !)` → a SINGLE-value failable, exactly
// `-> T !` (NOT multi-return): the failable `tuple_type_expr`.
// - anything else (a `(T,)` 1-tuple, a stray spread) → rejected;
// a real tuple VALUE type uses `Tuple(…)`.
const last_is_err = param_types.items.len > 0 and const last_is_err = param_types.items.len > 0 and
param_types.items[param_types.items.len - 1].data == .error_type_expr; param_types.items[param_types.items.len - 1].data == .error_type_expr;
if (last_is_err) { const value_count = param_types.items.len - @as(usize, if (last_is_err) 1 else 0);
return self.fail("failable returns use `-> T !` or `-> Tuple(T1,T2) !`"); if (value_count >= 2 or (last_is_err and value_count == 1)) {
var fnames: ?[]const []const u8 = null;
if (has_names) {
// field_names is non-optional and must stay 1:1 with
// field_types; map an unnamed value slot to "" and a trailing
// error slot to the "!" placeholder (identified by position,
// never by this name — see errorChannelOf).
const nm = try self.allocator.alloc([]const u8, param_names.items.len);
for (param_names.items, 0..) |pn, i| {
nm[i] = pn orelse (if (last_is_err and i == param_names.items.len - 1) "!" else "");
} }
fnames = nm;
}
const field_types = try param_types.toOwnedSlice(self.allocator);
// ≥2 value slots → multi-return signature; a lone `(T, !)` is just
// a single-value failable (= `-> T !`), a plain failable tuple.
if (value_count >= 2) {
return try self.createNode(start, .{ .return_type_expr = .{
.field_types = field_types,
.field_names = fnames,
.field_defaults = if (any_default) try param_defaults.toOwnedSlice(self.allocator) else null,
} });
}
return try self.createNode(start, .{ .tuple_type_expr = .{
.field_types = field_types,
.field_names = fnames,
} });
}
// Anything else (a `(T,)` 1-tuple, a spread): the bare-paren tuple
// grammar is gone — tuple VALUE types are written `Tuple( … )`.
return self.fail("tuple types use `Tuple( … )` (e.g. `Tuple(A, B)`)"); return self.fail("tuple types use `Tuple( … )` (e.g. `Tuple(A, B)`)");
} }
@@ -1997,13 +2053,25 @@ pub const Parser = struct {
.many_pointer_type_expr => |mpte| collectGenericNames(mpte.element_type, list, allocator), .many_pointer_type_expr => |mpte| collectGenericNames(mpte.element_type, list, allocator),
.slice_type_expr => |ste| collectGenericNames(ste.element_type, list, allocator), .slice_type_expr => |ste| collectGenericNames(ste.element_type, list, allocator),
.array_type_expr => |ate| collectGenericNames(ate.element_type, list, allocator), .array_type_expr => |ate| collectGenericNames(ate.element_type, list, allocator),
.optional_type_expr => |ote| collectGenericNames(ote.inner_type, list, allocator),
.parameterized_type_expr => |pte| { .parameterized_type_expr => |pte| {
for (pte.args) |arg| collectGenericNames(arg, list, allocator); for (pte.args) |arg| collectGenericNames(arg, list, allocator);
}, },
.tuple_type_expr => |tte| {
// A failable closure return `Closure() -> $R !E` folds to a
// `(T, !)` tuple_type_expr (parseFnReturnType), so the `$R`
// binding site lives inside the tuple's field_types — descend so
// the value type's generic is still inferred from the call site.
for (tte.field_types) |ft| collectGenericNames(ft, list, allocator);
},
.closure_type_expr => |cte| { .closure_type_expr => |cte| {
for (cte.param_types) |pt| collectGenericNames(pt, list, allocator); for (cte.param_types) |pt| collectGenericNames(pt, list, allocator);
if (cte.return_type) |rt| collectGenericNames(rt, list, allocator); if (cte.return_type) |rt| collectGenericNames(rt, list, allocator);
}, },
.function_type_expr => |fte| {
for (fte.param_types) |pt| collectGenericNames(pt, list, allocator);
if (fte.return_type) |rt| collectGenericNames(rt, list, allocator);
},
else => {}, else => {},
} }
} }
@@ -2274,9 +2342,40 @@ pub const Parser = struct {
self.advance(); self.advance();
return try self.createNode(start, .{ .return_stmt = .{ .value = null } }); return try self.createNode(start, .{ .return_stmt = .{ .value = null } });
} }
const value = try self.parseExpr(); // Comma-separated return list — the bare multi-value `return` form:
// `return a, b` (positional) / `return x = a, y = b` (named), no `.(…)`
// tuple literal needed. Each element is `name = expr` (named, like the
// `.(x = v)` form) or a bare `expr` (positional). A SINGLE positional
// element is an ordinary single-value return (unchanged); a comma list
// — or any named element — is a multi-value return, synthesized as the
// same `tuple_literal` the `.(…)` form produces so the return lowering
// maps it onto the function's multi-return slots.
var ret_elems = std.ArrayList(ast.TupleElement).empty;
var ret_any_named = false;
while (true) {
if (self.isIdentLike() and self.peekNext() == .equal) {
const fname = self.tokenSlice(self.current);
self.advance(); // skip name
self.advance(); // skip '='
const v = try self.parseExpr();
try ret_elems.append(self.allocator, .{ .name = fname, .value = v });
ret_any_named = true;
} else {
const v = try self.parseExpr();
try ret_elems.append(self.allocator, .{ .name = null, .value = v });
}
if (self.current.tag == .comma) {
self.advance();
continue;
}
break;
}
try self.expect(.semicolon); try self.expect(.semicolon);
return try self.createNode(start, .{ .return_stmt = .{ .value = value } }); const ret_value: *Node = if (ret_elems.items.len == 1 and !ret_any_named)
ret_elems.items[0].value
else
try self.createNode(start, .{ .tuple_literal = .{ .elements = try ret_elems.toOwnedSlice(self.allocator) } });
return try self.createNode(start, .{ .return_stmt = .{ .value = ret_value } });
} }
// Defer statement: defer { body } | defer <expr>; // Defer statement: defer { body } | defer <expr>;
@@ -4065,6 +4164,13 @@ pub const Parser = struct {
self.current.tag == .percent or self.current.tag == .plus or self.current.tag == .percent or self.current.tag == .plus or
self.current.tag == .minus or self.current.tag == .question or self.current.tag == .minus or self.current.tag == .question or
self.current.tag == .bang or self.current.tag == .bang or
// A named multi-return slot DEFAULT (`-> (sum: i32 = 0, …)`):
// skip the `=` and the value expression's literal tokens so the
// scan keeps going to the body `{`, instead of misreading the
// decl as a bodyless function-type alias.
self.current.tag == .equal or self.current.tag == .float_literal or
self.current.tag == .string_literal or
self.current.tag == .kw_true or self.current.tag == .kw_false or
self.current.tag == .colon or self.current.tag == .arrow) self.current.tag == .colon or self.current.tag == .arrow)
{ {
self.advance(); self.advance();

View File

@@ -1314,6 +1314,7 @@ pub const Analyzer = struct {
.index_expr, .index_expr,
.slice_expr, .slice_expr,
.tuple_type_expr, .tuple_type_expr,
.return_type_expr,
=> {}, => {},
.protocol_decl => |pd| { .protocol_decl => |pd| {
try self.addSymbol(pd.name, .protocol_type, null, node.span); try self.addSymbol(pd.name, .protocol_type, null, node.span);
@@ -1788,6 +1789,7 @@ pub fn findNodeAtOffset(node: *Node, offset: u32) ?*Node {
.index_expr, .index_expr,
.slice_expr, .slice_expr,
.tuple_type_expr, .tuple_type_expr,
.return_type_expr,
.ufcs_alias, .ufcs_alias,
.closure_type_expr, .closure_type_expr,
.runtime_class_decl, .runtime_class_decl,