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

@@ -312,6 +312,16 @@ pub const Lowering = struct {
/// Cleared per function body (the `Ref` space is per-function).
narrowed_refs: std.AutoHashMap(Ref, void) = undefined,
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
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
@@ -646,6 +656,12 @@ pub const Lowering = struct {
pub fn resolveReturnType(self: *Lowering, fd: *const ast.FnDecl) TypeId {
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);
}
// 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 {
// A plain value param with no annotation can only be typed from
// context (a lambda's target closure signature). When `resolveParamType`
@@ -728,6 +757,9 @@ pub const Lowering = struct {
}
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);
if (p.is_variadic) {
// Two surface forms:
@@ -1834,6 +1866,9 @@ pub const Lowering = struct {
pub const lowerInlineBranch = lower_stmt.lowerInlineBranch;
pub const lowerBlockValue = lower_stmt.lowerBlockValue;
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 lowerStmt = lower_stmt.lowerStmt;
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 });
};
// 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
// to the static default before any user code runs.
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 });
};
// 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
// current_ctx_ref to &__sx_default_context. See companion comment
// 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;
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: {
for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true;
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;
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: {
for (pt.args) |a| if (matchTypeParamStatic(a, tp_name)) break :blk true;
break :blk false;
@@ -764,6 +777,31 @@ pub fn extractTypeParam(self: *Lowering, type_node: *const Node, arg_ty: TypeId,
}
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: {
// A generic-struct param head (`Box($T)`, also reached recursively
// 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;
}
if (field_idx < total_explicit) {
_ = self.rejectMultiReturnValueType(sd.field_types[field_idx], "field");
const field_ty = self.resolveType(sd.field_types[field_idx]);
fields.append(self.alloc, .{
.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;
}
}
// 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
// the error channel) carries no success value — a void body is a normal
// 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);
}
/// 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.
/// Statement nodes are lowered as statements (returning null).
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| {
// Explicit type annotation — resolve type first, then lower value
_ = self.rejectMultiReturnValueType(ta, "variable");
const ty = self.resolveType(ta);
const slot = self.builder.alloca(ty);
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 {
if (rs.value) |val| {
if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
_ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value);
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.
// 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,
.closure_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.
.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),
// 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 `$args[N]` in a type position must be resolved
// 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,
.closure_type_expr,
.tuple_type_expr,
.return_type_expr,
.parameterized_type_expr,
.pack_index_type_expr,
.comptime_pack_ref,

View File

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