ERR/E2.1b: value-carrying failable consumers (try / catch)

The consumer side of the error-channel tuple ABI. A value-carrying `-> (T, !E)`
failable can now be consumed by `try` and `catch` (not just destructured).
Single-value; multi-value `-> (T1, T2, !)` consumers bail (E2).

- lowerTry: a value-carrying callee returns `{v, err}`. Extract `err`
  (tuple_get field 1), branch; on success the try value is `tuple_get(field 0)`,
  on error propagate via emitErrorReturn (pure caller → `ret(tag)`;
  value-carrying caller → `ret {undef..., tag}`). Widening now runs for
  value-carrying callees too. Retires the two value-carrying bails.
- lowerCatch: a value-carrying LHS merges through a block-param phi — the
  success edge feeds `tuple_get(field 0)`, the handler edge feeds the body's
  value (coerced to the success type). runCatchBody factors the bound-tag body
  lowering (force_block_value for the value case). Pure-failable catch
  unchanged.
- A non-diverging value-carrying catch body that yields no value is now a
  clean diagnostic ("`catch` body must produce a value … or diverge") instead
  of coercing `void` into a bad ref / failing LLVM verification — caught by an
  adversarial review of the lowering.

Tests: examples/229-value-failable-consume.sx (try in value-carrying + pure
callers, catch block/bare/match-body/diverging bodies; exit 32),
examples/230-value-failable-reject.sx (void catch body rejected; exit 1).
Gates: zig build, zig build test, 268/268 examples.
This commit is contained in:
agra
2026-05-31 22:05:44 +03:00
parent 17c19d5d30
commit a049e2940c
7 changed files with 209 additions and 45 deletions

View File

@@ -15263,16 +15263,12 @@ pub const Lowering = struct {
return self.builder.constInt(0, .void);
};
// E1.4a scope guards — bail loudly on the shapes E2/E1.4b own.
if (op_ty != callee_set) {
// Value-carrying callee: `try` must bind the value slot(s), which
// needs the error-channel tuple ABI (E2).
return self.bailTry(span, "a value-carrying failable callee (`-> (T..., !)`)");
}
if (caller_ret != caller_set) {
// Propagating from a value-carrying caller needs undef value slots
// alongside the error slot (E2's tuple ABI).
return self.bailTry(span, "a value-carrying failable function");
// A value-carrying callee (`-> (T, !)`) returns a tuple `{v, err}`; a
// pure-failable callee (`-> !`) returns the bare error tag. Multi-value
// callees (`-> (T1, T2, !)`) need multi-slot extraction — deferred.
const callee_value_carrying = op_ty != callee_set;
if (callee_value_carrying and self.module.types.get(op_ty).tuple.fields.len != 2) {
return self.bailTry(span, "a multi-value failable callee (`-> (T1, T2, !)`)");
}
// (3) Widening: the callee's escape set must be ⊆ the caller's named
@@ -15293,28 +15289,64 @@ pub const Lowering = struct {
}
}
// (4) Lower: evaluate the call (→ the error tag, 0 = success), branch.
const err_val = self.lowerExpr(operand);
// (4) Lower: evaluate the operand, then branch on its error tag (which
// is the bare result for a pure callee, or the last tuple slot for
// a value-carrying one).
const result = self.lowerExpr(operand);
const err_val = if (callee_value_carrying)
self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 1, .base_type = op_ty } }, callee_set)
else
result;
const err_ty = self.builder.getRefType(err_val);
const zero = self.builder.constInt(0, err_ty);
const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = zero } }, .bool);
const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);
const prop_bb = self.freshBlock("try.prop");
const ok_bb = self.freshBlock("try.ok");
self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});
// Propagation: run the function's defers, then return the error tag
// (widened to the caller's set — global-flat tag ids carry across).
// Propagation: run the function's defers, then return the caller's
// failure carrying this tag (pure caller → `ret(tag)`; value-carrying
// caller → `ret {undef..., tag}`).
self.builder.switchToBlock(prop_bb);
self.emitBlockDefers(self.func_defer_base);
const widened = if (err_ty != caller_set) self.coerceToType(err_val, err_ty, caller_set) else err_val;
self.builder.ret(widened, caller_set);
self.emitErrorReturn(caller_ret, caller_set, err_val);
// Success: continue. A pure-failable callee has no value slots.
// Success: a value-carrying callee yields its value slot; a
// pure-failable callee has no value (void).
self.builder.switchToBlock(ok_bb);
if (callee_value_carrying) {
const succ_ty = self.module.types.get(op_ty).tuple.fields[0];
return self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 0, .base_type = op_ty } }, succ_ty);
}
return self.builder.constInt(0, .void);
}
/// Return the enclosing function's failure carrying error tag `err`. A
/// pure-failable caller (`-> !`) returns the tag directly; a value-carrying
/// caller (`-> (T..., !)`) returns `{undef value slots..., tag}`. Honors
/// inline-comptime return targets. The caller emits defers first.
fn emitErrorReturn(self: *Lowering, caller_ret: TypeId, caller_set: TypeId, err: Ref) void {
const ety = self.builder.getRefType(err);
const coerced = if (ety != caller_set) self.coerceToType(err, ety, caller_set) else err;
if (caller_ret == caller_set) {
if (self.inline_return_target) |iri| {
self.builder.store(iri.slot, coerced);
self.builder.br(iri.done_bb, &.{});
} else {
self.builder.ret(coerced, caller_set);
}
} else {
const fields = self.module.types.get(caller_ret).tuple.fields;
var undefs = std.ArrayList(Ref).empty;
defer undefs.deinit(self.alloc);
for (fields[0 .. fields.len - 1]) |vty| {
undefs.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
}
const tup = self.buildFailableTuple(caller_ret, undefs.items, coerced);
self.emitTupleRet(caller_ret, tup);
}
}
fn diagTryNotFailable(self: *Lowering, span: ast.Span) void {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`try` is only valid inside a failable function (a return type with `!` or `!Named`)", .{});
@@ -15338,46 +15370,90 @@ pub const Lowering = struct {
}
return self.builder.constInt(0, .void);
};
if (op_ty != err_set) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`catch` on a value-carrying failable (`-> (T..., !)`) is not yet lowered — pending the error-channel tuple ABI (ERR E2)", .{});
}
// Pure-failable LHS (`-> !`): no success value. Run the body on the
// error path; both paths fall through to a value-less merge.
if (op_ty == err_set) {
const err_val = self.lowerExpr(ce.operand);
const err_ty = self.builder.getRefType(err_val);
const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);
const handle_bb = self.freshBlock("catch.handle");
const merge_bb = self.freshBlock("catch.merge");
self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{});
self.builder.switchToBlock(handle_bb);
_ = self.runCatchBody(ce, err_val, err_set, null);
if (!self.currentBlockHasTerminator()) self.builder.br(merge_bb, &.{});
self.builder.switchToBlock(merge_bb);
return self.builder.constInt(0, .void);
}
// Evaluate the operand → the error tag (0 = success), then branch.
const err_val = self.lowerExpr(ce.operand);
const err_ty = self.builder.getRefType(err_val);
const zero = self.builder.constInt(0, err_ty);
const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = zero } }, .bool);
// Value-carrying LHS (`-> (T, !)`): on success the catch yields the
// value slot; on error it yields the handler body's value. The paths
// merge through a block-parameter (phi). Multi-value is deferred.
const fields = self.module.types.get(op_ty).tuple.fields;
if (fields.len != 2) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`catch` on a multi-value failable (`-> (T1, T2, !)`) is not yet lowered — pending the multi-value error-channel ABI (ERR E2)", .{});
}
return self.builder.constInt(0, .void);
}
const succ_ty = fields[0];
const result = self.lowerExpr(ce.operand);
const err_val = self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 1, .base_type = op_ty } }, err_set);
const succ_val = self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 0, .base_type = op_ty } }, succ_ty);
const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_set) } }, .bool);
const handle_bb = self.freshBlock("catch.handle");
const merge_bb = self.freshBlock("catch.merge");
// On success (err == 0) jump straight to the merge; on error, handle.
self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{});
const merge_bb = self.freshBlockWithParams("catch.merge", &.{succ_ty});
// Success → merge with the value slot; error → run the handler.
self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{succ_val});
// Handle: bind the tag (typed as the error set) and run the body in a
// child scope. The body diverges (terminates the block) or falls
// through to the merge.
self.builder.switchToBlock(handle_bb);
const body_val = self.runCatchBody(ce, err_val, err_set, succ_ty);
if (!self.currentBlockHasTerminator()) {
// A non-diverging handler must produce a value of the success type.
// A value-less (void) body is a type error — diagnose and feed an
// undef placeholder so the merge phi stays well-typed (rather than
// coercing `void` into a bad ref).
const bv: Ref = blk: {
if (body_val) |v| {
const vty = self.builder.getRefType(v);
if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty);
}
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)});
}
break :blk self.builder.constUndef(succ_ty);
};
self.builder.br(merge_bb, &.{bv});
}
self.builder.switchToBlock(merge_bb);
return self.builder.blockParam(merge_bb, 0, succ_ty);
}
/// Lower a `catch` body in a child scope that binds the error tag to the
/// catch binding (if any). When `want_ty` is non-null (value-carrying
/// catch), returns the body's value (or null if the body diverged); when
/// null (pure-failable catch), runs the body for effect and returns null.
fn runCatchBody(self: *Lowering, ce: *const ast.CatchExpr, err_val: Ref, err_set: TypeId, want_ty: ?TypeId) ?Ref {
var handle_scope = Scope.init(self.alloc, self.scope);
const saved_scope = self.scope;
self.scope = &handle_scope;
defer {
self.scope = saved_scope;
handle_scope.deinit();
}
if (ce.binding) |name| {
handle_scope.put(name, .{ .ref = err_val, .ty = err_set, .is_alloca = false });
}
if (ce.body.data == .block) {
self.lowerBlock(ce.body);
} else {
_ = self.lowerExpr(ce.body);
if (want_ty == null) {
if (ce.body.data == .block) self.lowerBlock(ce.body) else _ = self.lowerExpr(ce.body);
return null;
}
self.scope = saved_scope;
handle_scope.deinit();
if (!self.currentBlockHasTerminator()) self.builder.br(merge_bb, &.{});
// Merge (success path + non-diverging handle). Pure-failable → void.
self.builder.switchToBlock(merge_bb);
return self.builder.constInt(0, .void);
const saved_fbv = self.force_block_value;
self.force_block_value = true;
defer self.force_block_value = saved_fbv;
return if (ce.body.data == .block) self.lowerBlockValue(ce.body) else self.lowerExpr(ce.body);
}
fn bailTry(self: *Lowering, span: ast.Span, comptime what: []const u8) Ref {