ERR/E1.4a: standalone try sema + pure-failable propagation + named widening

`try f()` (standalone form) now propagates a failable callee's error to the
enclosing failable function. E1.4 was split: E1.4a = standalone try (failure
target = function-propagation); E1.4b = fallback-target routing +
failable-`or` + whole-program SCC for inferred sets + empty-inferred warning.

- lowerExpr: `.try_expr` -> lowerTry
- lowerTry: (1) try legal only inside a failable fn; (2) the sole
  failable-operand check (errorChannelOf(inferExprType(operand))); (3)
  named-caller widening (checkErrorSetSubset at the propagation site); (4)
  pure-failable lowering — condBr on tag != 0: propagate (run defers + ret
  the widened tag) vs continue on success
- inferExprType: `.try_expr` arm (success type: void for pure-failable)
- lowerBinaryOp .or_op: bail loudly on a failable LHS (exprIsFailable);
  the optional-`or` path is unchanged for non-failable LHS
- value-carrying callee/caller `try` bail loudly (pending E2's tuple ABI)

Tests: examples/221-try.sx (positive propagation, exit 5),
examples/222-try-rejections.sx (3 stable rejections: outside-failable,
non-failable operand, named-widening miss; exit 1). Gates: zig build,
zig build test, 260/260 examples.
This commit is contained in:
agra
2026-05-31 19:47:19 +03:00
parent 9984fa6b96
commit aa1aa63bb3
7 changed files with 209 additions and 0 deletions

View File

@@ -2558,6 +2558,7 @@ pub const Lowering = struct {
break :blk self.emitError(te.name, node.span);
},
.try_expr => |te| self.lowerTry(te.operand, node.span),
else => self.emitError("unknown_expr", node.span),
};
}
@@ -2589,6 +2590,17 @@ pub const Lowering = struct {
}
// Short-circuit: `a or b` → if a then true else b
if (bop.op == .or_op) {
// Failable `or` (chain / value-terminator) routes per the fallback
// target and unions error sets — that, plus the whole-program SCC,
// lands in E1.4b/E2.4. Detect a failable LHS (a `try`, or any
// expression carrying an error channel) and bail loudly rather than
// mis-lower it through the optional-unwrap path below.
if (self.exprIsFailable(bop.lhs)) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, bop.lhs.span, "failable `or` (chain / value terminator) is not yet lowered — pending ERR E1.4b; for now use a single `try` or a `catch`", .{});
}
return self.builder.constInt(0, .void);
}
const lhs = self.lowerExpr(bop.lhs);
const rhs_bb = self.freshBlock("or.rhs");
const merge_bb = self.freshBlockWithParams("or.merge", &.{.bool});
@@ -13529,6 +13541,18 @@ pub const Lowering = struct {
},
else => .unresolved,
},
// `try X` evaluates to X's success type (the value part). A
// pure-failable operand (`-> !` / `-> !Named`, whose type IS the
// error set) has no value → `void`; a value-carrying `-> (T, !)`
// operand yields its value part (single field, or the tuple).
.try_expr => |te| blk: {
const op_ty = self.inferExprType(te.operand);
const channel = self.errorChannelOf(op_ty) orelse break :blk .unresolved;
if (op_ty == channel) break :blk .void;
const info = self.module.types.get(op_ty);
if (info == .tuple and info.tuple.fields.len == 2) break :blk info.tuple.fields[0];
break :blk op_ty;
},
.if_expr => |ie| {
// If-else: infer from then branch
if (ie.else_branch != null) {
@@ -15068,6 +15092,98 @@ pub const Lowering = struct {
}
}
/// True if `node`'s value is failable — a `try` (the result is its
/// operand's success value, but the expression itself routes an error) or
/// any expression whose type carries an error channel (a bare failable
/// call). Used to detect failable `or` chains (deferred to E1.4b).
fn exprIsFailable(self: *Lowering, node: *const Node) bool {
if (node.data == .try_expr) return true;
return self.errorChannelOf(self.inferExprType(node)) != null;
}
/// `try X` — a fallible attempt (ERR step E1.4a: the STANDALONE form, whose
/// failure target is function-propagation). Evaluates X; on failure, runs
/// the function's defers and returns the error to the caller; on success,
/// continues with X's value. E1.4a lowers the pure-failable shape (callee
/// `-> !` / `-> !Named`, caller likewise pure-failable). Value-carrying
/// callees, propagation from a value-carrying caller, and `try` inside an
/// `or` chain need the error-channel tuple ABI / fallback routing — those
/// land in E1.4b/E2, so we bail loudly here.
fn lowerTry(self: *Lowering, operand: *const Node, span: ast.Span) Ref {
// (1) `try` is legal only inside a failable function.
const caller_ret = self.effectiveReturnType() orelse {
self.diagTryNotFailable(span);
return self.builder.constInt(0, .void);
};
const caller_set = self.errorChannelOf(caller_ret) orelse {
self.diagTryNotFailable(span);
return self.builder.constInt(0, .void);
};
// (2) The operand must be failable. This is the sole failable-operand
// check (the parser imposes none — see E0.2).
const op_ty = self.inferExprType(operand);
const callee_set = self.errorChannelOf(op_ty) orelse {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`try` requires a failable expression; operand has type '{s}'", .{self.formatTypeName(op_ty)});
}
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");
}
// (3) Widening: the callee's escape set must be ⊆ the caller's named
// set. For an inferred caller (`!`) the absorption happens in the
// whole-program SCC (E1.4b) — no check here.
if (!self.isInferredErrorSet(caller_set)) {
self.checkErrorSetSubset(callee_set, caller_set, span);
}
// (4) Lower: evaluate the call (→ the error tag, 0 = success), branch.
const err_val = self.lowerExpr(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);
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).
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);
// Success: continue. A pure-failable callee has no value slots.
self.builder.switchToBlock(ok_bb);
return self.builder.constInt(0, .void);
}
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`)", .{});
}
}
fn bailTry(self: *Lowering, span: ast.Span, comptime what: []const u8) Ref {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`try` with " ++ what ++ " is not yet lowered — pending the error-channel tuple ABI / fallback routing (ERR E1.4b/E2)", .{});
}
return self.builder.constInt(0, .void);
}
fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) {
.add => "+",