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

32
examples/221-try.sx Normal file
View File

@@ -0,0 +1,32 @@
// First runnable `try` (ERR step E1.4a). The STANDALONE form: a failable
// expression whose failure propagates to the enclosing function's error
// return (Zig-style). `outer` calls `try inner(n)` — on `inner`'s failure
// `outer` returns that error; on success it continues. Both are pure
// failable (`-> !E`). The error-channel tuple ABI for value-carrying
// `-> (T, !)` and `try` in an `or` chain land in ERR E1.4b/E2.
#import "modules/std.sx";
E :: error { Bad, Worse }
inner :: (n: s32) -> !E {
if n < 0 { raise error.Bad; }
return; // success — no error
}
// Propagates inner's error (standalone `try`, target = function return).
outer :: (n: s32) -> !E {
try inner(n);
return;
}
main :: () -> s32 {
bad := outer(-1); // inner raises Bad -> outer propagates
good := outer(7); // inner succeeds -> outer succeeds
r : s32 = 0;
if bad == error.Bad { r = r + 5; } // true -> +5
if good == error.Bad { r = r + 1; } // false (success = no error)
if bad == error.Worse { r = r + 2; } // false (propagated Bad)
print("try result: {}\n", r); // -> 5
return r;
}

View File

@@ -0,0 +1,41 @@
// `try` rejections (ERR step E1.4a):
// - `try` is only valid inside a failable function,
// - the operand must be failable (the sole failable-operand check —
// the parser imposes none),
// - propagating a `try` whose callee's error set is not a subset of the
// caller's named set is rejected (widening at a function-propagation site).
// The positive case lives in `examples/221-try.sx`.
#import "modules/std.sx";
A :: error { Xa }
B :: error { Yb }
ga :: () -> !A { return; }
gb :: () -> !B { return; }
plain :: () -> s32 { return 0; }
// `try` in a non-failable function.
bad_ctx :: () -> s32 {
try ga(); // error: `try` outside a failable function
return 0;
}
// `try` on a non-failable operand.
bad_operand :: () -> !A {
try plain(); // error: operand has type s32 (not failable)
return;
}
// Callee's set (B = {Yb}) is not a subset of the caller's set (A = {Xa}).
widen :: () -> !A {
try gb(); // error: Yb not in caller's error set A
return;
}
main :: () -> s32 {
a := bad_ctx(); // force bad_ctx to lower
b := bad_operand(); // force bad_operand to lower
c := widen(); // force widen to lower
return 0;
}

View File

@@ -2558,6 +2558,7 @@ pub const Lowering = struct {
break :blk self.emitError(te.name, node.span); break :blk self.emitError(te.name, node.span);
}, },
.try_expr => |te| self.lowerTry(te.operand, node.span),
else => self.emitError("unknown_expr", 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 // Short-circuit: `a or b` → if a then true else b
if (bop.op == .or_op) { 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 lhs = self.lowerExpr(bop.lhs);
const rhs_bb = self.freshBlock("or.rhs"); const rhs_bb = self.freshBlock("or.rhs");
const merge_bb = self.freshBlockWithParams("or.merge", &.{.bool}); const merge_bb = self.freshBlockWithParams("or.merge", &.{.bool});
@@ -13529,6 +13541,18 @@ pub const Lowering = struct {
}, },
else => .unresolved, 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_expr => |ie| {
// If-else: infer from then branch // If-else: infer from then branch
if (ie.else_branch != null) { 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 { fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) { return switch (op) {
.add => "+", .add => "+",

View File

@@ -0,0 +1 @@
5

View File

@@ -0,0 +1 @@
try result: 5

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,17 @@
error: `try` is only valid inside a failable function (a return type with `!` or `!Named`)
--> /Users/agra/projects/sx/examples/222-try-rejections.sx:20:5
|
20 | try ga(); // error: `try` outside a failable function
| ^^^^^^^^
error: `try` requires a failable expression; operand has type 's32'
--> /Users/agra/projects/sx/examples/222-try-rejections.sx:26:5
|
26 | try plain(); // error: operand has type s32 (not failable)
| ^^^^^^^^^^^
error: error tag 'error.Yb' is not in caller's error set 'A'
--> /Users/agra/projects/sx/examples/222-try-rejections.sx:32:5
|
32 | try gb(); // error: Yb not in caller's error set A
| ^^^^^^^^