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

@@ -610,6 +610,11 @@ pub const Parser = struct {
self.advance(); // skip '('
var param_types = std.ArrayList(*Node).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;
// An error channel type (`!` / `!Named`) is only valid as the
// 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 } });
try param_names.append(self.allocator, null);
try param_types.append(self.allocator, spread);
try param_defaults.append(self.allocator, null);
continue;
}
// Check for optional param name: `name: Type`
@@ -656,6 +662,16 @@ pub const Parser = struct {
const elem = try self.parseTypeExpr();
if (elem.data == .error_type_expr) saw_error_type = true;
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);
if (self.current.tag == .arrow) {
@@ -673,6 +689,12 @@ pub const Parser = struct {
.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
// UNNAMED, non-spread element with NO trailing comma resolves to the
// inner type. This lets `(Closure(i64,i64) -> i64)`, `?(?i64)`, etc.
@@ -682,15 +704,49 @@ pub const Parser = struct {
{
return param_types.items[0];
}
// Anything else (a top-level comma, a `(T,)` 1-tuple, names, a
// spread) used to build a bare-paren `tuple_type_expr`. That grammar
// is gone: tuple types are written `Tuple( … )`. If the group ends in
// an error channel `!`, it is the old failable spelling `-> (T, !)`.
// A bare-paren result list classifies by VALUE-slot count (fields
// minus a trailing error channel — the error is ALWAYS the last slot):
// - ≥2 value slots → a MULTI-RETURN signature `(A, B)` /
// `(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
param_types.items[param_types.items.len - 1].data == .error_type_expr;
if (last_is_err) {
return self.fail("failable returns use `-> T !` or `-> Tuple(T1,T2) !`");
const value_count = param_types.items.len - @as(usize, if (last_is_err) 1 else 0);
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)`)");
}
@@ -1997,13 +2053,25 @@ pub const Parser = struct {
.many_pointer_type_expr => |mpte| collectGenericNames(mpte.element_type, list, allocator),
.slice_type_expr => |ste| collectGenericNames(ste.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| {
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| {
for (cte.param_types) |pt| collectGenericNames(pt, 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 => {},
}
}
@@ -2274,9 +2342,40 @@ pub const Parser = struct {
self.advance();
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);
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>;
@@ -4065,6 +4164,13 @@ pub const Parser = struct {
self.current.tag == .percent or self.current.tag == .plus or
self.current.tag == .minus or self.current.tag == .question 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.advance();