From 9984fa6b96ce834c0aa8d7fb1f00d662b279eda6 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 31 May 2026 19:09:32 +0300 Subject: [PATCH] ERR/E1.3: raise sema + pure-failable lowering `raise EXPR` now terminates a failable function via the error channel. Scope (Option 2): full raise sema checks + lowering for the pure-failable shape (`-> !` / `-> !Named`); the value-carrying `-> (T..., !)` shape bails loudly, deferred to E2's error-channel tuple ABI. - lowerStmt + tryLowerAsExpr: `.raise_stmt` -> lowerRaise (also routes a raise that is a block's last statement, which previously hit unknown_expr) - lowerRaise: failable-context check (effectiveReturnType + errorChannelOf); literal membership via lowerErrorTagLiteral; variable form subset-checked via checkErrorSetSubset; pure-failable emits ret(tag) - lowerErrorTagLiteral skips membership for the bare-`!` inferred placeholder - plain `return;` in a pure-failable fn emits ret(0) (success / no error) - parser: in_defer_body flag rejects `raise` inside a `defer` body Tests: examples/219-raise.sx (positive, exit 8), examples/220-raise-rejections.sx (3 sema rejections, exit 1), inline parser test for raise-in-defer. Gates: zig build, zig build test, 258/258 examples. --- examples/219-raise.sx | 26 ++++ examples/220-raise-rejections.sx | 32 +++++ src/ir/lower.zig | 156 +++++++++++++++++++++-- src/parser.zig | 18 +++ tests/expected/219-raise.exit | 1 + tests/expected/219-raise.txt | 1 + tests/expected/220-raise-rejections.exit | 1 + tests/expected/220-raise-rejections.txt | 17 +++ 8 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 examples/219-raise.sx create mode 100644 examples/220-raise-rejections.sx create mode 100644 tests/expected/219-raise.exit create mode 100644 tests/expected/219-raise.txt create mode 100644 tests/expected/220-raise-rejections.exit create mode 100644 tests/expected/220-raise-rejections.txt diff --git a/examples/219-raise.sx b/examples/219-raise.sx new file mode 100644 index 0000000..227c29d --- /dev/null +++ b/examples/219-raise.sx @@ -0,0 +1,26 @@ +// First runnable `raise` (ERR step E1.3). A `-> !Named` (pure failable) +// function terminates via the error channel with `raise error.X`; a plain +// `return;` is the success exit (error slot 0). The caller binds the result +// and inspects it with the enum-like `==`. The value-carrying `-> (T, !)` +// shape lands with the error-channel tuple ABI in ERR phase E2. + +#import "modules/std.sx"; + +ParseErr :: error { BadDigit, Overflow, Empty } + +// Pure failable: raises on bad input, otherwise succeeds (error slot 0). +check :: (n: s32) -> !ParseErr { + if n < 0 { raise error.BadDigit; } + return; // success — no error +} + +main :: () -> s32 { + good := check(7); // success path -> no error + bad := check(-1); // raise path -> BadDigit + r : s32 = 0; + if bad == error.BadDigit { r = r + 8; } // true -> +8 + if good == error.BadDigit { r = r + 1; } // false (success = no error) + if bad == error.Overflow { r = r + 2; } // false (raised BadDigit) + print("raise result: {}\n", r); // -> 8 + return r; +} diff --git a/examples/220-raise-rejections.sx b/examples/220-raise-rejections.sx new file mode 100644 index 0000000..8342f70 --- /dev/null +++ b/examples/220-raise-rejections.sx @@ -0,0 +1,32 @@ +// `raise` rejections (ERR step E1.3): +// - `raise` is only valid inside a failable function, +// - a literal `raise error.X` must name a tag in the function's set, +// - a variable `raise e` must carry a set that is a subset of the +// function's set. +// The positive case lives in `examples/219-raise.sx`. Parse-time rejections +// (`raise` in expression position / inside `defer` / `onfail`) are covered by +// the inline parser tests. + +#import "modules/std.sx"; + +ParseErr :: error { BadDigit, Overflow } +OtherErr :: error { Weird } + +// Literal tag not in the declared set. +bad_tag :: () -> !ParseErr { + raise error.NotInSet; // error: NotInSet not in ParseErr +} + +// Variable whose error set is not a subset of the function's set. +makes_other :: () -> !OtherErr { return; } +relay :: () -> !ParseErr { + e := makes_other(); // e : OtherErr + raise e; // error: OtherErr not subset of ParseErr +} + +main :: () -> s32 { + x := bad_tag(); // force bad_tag to lower + y := relay(); // force relay to lower + raise error.BadDigit; // error: main (-> s32) is not failable + return 0; +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 54b7709..e751a82 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1443,7 +1443,7 @@ pub const Lowering = struct { /// Statement nodes are lowered as statements (returning null). fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref { return switch (node.data) { - .var_decl, .const_decl, .fn_decl, .return_stmt, .assignment, .defer_stmt, .push_stmt, .multi_assign, .destructure_decl => { + .var_decl, .const_decl, .fn_decl, .return_stmt, .raise_stmt, .assignment, .defer_stmt, .push_stmt, .multi_assign, .destructure_decl => { self.lowerStmt(node); return null; }, @@ -1457,6 +1457,7 @@ pub const Lowering = struct { .const_decl => |cd| self.lowerConstDecl(&cd), .fn_decl => |fd| self.lowerLocalFnDecl(&fd), .return_stmt => |rs| self.lowerReturn(&rs), + .raise_stmt => |rs| self.lowerRaise(&rs, node.span), .assignment => |asgn| self.lowerAssignment(&asgn), .defer_stmt => |ds| self.lowerDefer(&ds), .push_stmt => |ps| self.lowerPush(&ps), @@ -1734,7 +1735,18 @@ pub const Lowering = struct { self.builder.ret(coerced, ret_ty); } } else { - self.builder.retVoid(); + // A bare `return;` in a pure failable function (`-> !` / `-> !Named`, + // whose return type IS the error set) is the success exit — the + // error slot carries 0 ("no error"). Everything else is a void return. + const ret_ty = if (self.builder.func) |fid| + self.module.functions.items[@intFromEnum(fid)].ret + else + TypeId.void; + if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .error_set) { + self.builder.ret(self.builder.constInt(0, ret_ty), ret_ty); + } else { + self.builder.retVoid(); + } } } @@ -4744,16 +4756,21 @@ pub const Lowering = struct { if (!t.isBuiltin()) { const info = self.module.types.get(t); if (info == .error_set) { - var in_set = false; - for (info.error_set.tags) |member| { - if (member == tag_id) { - in_set = true; - break; + // The bare-`!` inferred placeholder (reserved name "!") accepts + // any tag — its members aren't known until the whole-program SCC + // pass (E1.4) folds in every raised tag. Skip membership for it. + if (!std.mem.eql(u8, self.module.types.getString(info.error_set.name), "!")) { + var in_set = false; + for (info.error_set.tags) |member| { + if (member == tag_id) { + in_set = true; + break; + } } - } - if (!in_set) { - if (self.diagnostics) |diags| { - diags.addFmt(.err, span, "error tag 'error.{s}' is not in error set '{s}'", .{ tag_name, self.module.types.getString(info.error_set.name) }); + if (!in_set) { + if (self.diagnostics) |diags| { + diags.addFmt(.err, span, "error tag 'error.{s}' is not in error set '{s}'", .{ tag_name, self.module.types.getString(info.error_set.name) }); + } } } return self.builder.constInt(@as(i64, @intCast(tag_id)), t); @@ -14934,6 +14951,123 @@ pub const Lowering = struct { self.builder.emit(.{ .cmp_ne = .{ .lhs = lv, .rhs = rv } }, .bool); } + /// The declared return type of the function currently being lowered (the + /// inlined body's type wins while inlining a comptime call), or null when + /// there is no enclosing function. + fn effectiveReturnType(self: *Lowering) ?TypeId { + if (self.inline_return_target) |iri| return iri.ret_ty; + if (self.builder.func) |fid| return self.module.functions.items[@intFromEnum(fid)].ret; + return null; + } + + /// If `ret_ty` belongs to a failable function, the TypeId of its error + /// channel; else null. `-> !Named` / `-> !` resolve the error set directly; + /// `-> (T..., !)` carries it as the last tuple field (the locked ABI). + fn errorChannelOf(self: *Lowering, ret_ty: TypeId) ?TypeId { + if (ret_ty.isBuiltin()) return null; + switch (self.module.types.get(ret_ty)) { + .error_set => return ret_ty, + .tuple => |t| { + if (t.fields.len == 0) return null; + const last = t.fields[t.fields.len - 1]; + if (last.isBuiltin()) return null; + return if (self.module.types.get(last) == .error_set) last else null; + }, + else => return null, + } + } + + /// True for the bare-`!` inferred placeholder error set (reserved name "!"). + fn isInferredErrorSet(self: *Lowering, set: TypeId) bool { + if (set.isBuiltin()) return false; + const info = self.module.types.get(set); + if (info != .error_set) return false; + return std.mem.eql(u8, self.module.types.getString(info.error_set.name), "!"); + } + + /// Diagnose every tag of `src` that is not also a member of `dst` (the + /// enclosing function's named error set). Both must be `.error_set` types. + fn checkErrorSetSubset(self: *Lowering, src: TypeId, dst: TypeId, span: ast.Span) void { + if (src.isBuiltin() or dst.isBuiltin()) return; + const src_info = self.module.types.get(src); + const dst_info = self.module.types.get(dst); + if (src_info != .error_set or dst_info != .error_set) return; + for (src_info.error_set.tags) |tag| { + var found = false; + for (dst_info.error_set.tags) |d| { + if (d == tag) { + found = true; + break; + } + } + if (!found) { + if (self.diagnostics) |diags| { + diags.addFmt(.err, span, "error tag 'error.{s}' is not in caller's error set '{s}'", .{ self.module.types.getTagName(tag), self.module.types.getString(dst_info.error_set.name) }); + } + } + } + } + + /// `raise EXPR;` — terminate the enclosing failable function via the error + /// channel. E1.3 lowers the **pure-failable** shape (`-> !` / `-> !Named`, + /// whose return type IS the error set): emit `ret(EXPR)`. The value-carrying + /// shape (`-> (T..., !)`) needs the value slots set to `undef` alongside the + /// error slot — that tuple ABI lands in E2.1/E2.2, so we bail loudly here + /// rather than ship a half-built return that silently corrupts value slots. + fn lowerRaise(self: *Lowering, rs: *const ast.RaiseStmt, span: ast.Span) void { + // (1) `raise` is legal only inside a failable function. + const ret_ty = self.effectiveReturnType() orelse { + self.diagRaiseNotFailable(span); + return; + }; + const err_set = self.errorChannelOf(ret_ty) orelse { + self.diagRaiseNotFailable(span); + return; + }; + const inferred = self.isInferredErrorSet(err_set); + + // (2) Set check. Lowering EXPR with the function's error set as the + // target type makes a literal `raise error.X` validate `X ∈ set` + // inside lowerErrorTagLiteral (the inferred placeholder accepts any + // tag). The variable form `raise e` is subset-checked below. + const saved_target = self.target_type; + self.target_type = err_set; + const tag_ref = self.lowerExpr(rs.tag); + self.target_type = saved_target; + + if (!inferred and !isErrorTagLiteralNode(rs.tag)) { + if (self.errorSetTypeOf(rs.tag)) |src_set| { + self.checkErrorSetSubset(src_set, err_set, span); + } + } + + // (3) Emit the failure return. Pure-failable: the return type IS the + // error set, so return the tag value directly. + if (ret_ty == err_set) { + self.emitBlockDefers(self.func_defer_base); + const tag_ty = self.builder.getRefType(tag_ref); + const coerced = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref; + if (self.inline_return_target) |iri| { + self.builder.store(iri.slot, coerced); + self.builder.br(iri.done_bb, &.{}); + } else { + self.builder.ret(coerced, err_set); + } + } else { + // Value-carrying `-> (T..., !)`: needs undef value slots + the error + // slot, assembled per the error-channel tuple ABI (ERR E2.1/E2.2). + if (self.diagnostics) |diags| { + diags.addFmt(.err, span, "`raise` in a value-carrying failable function (`-> (T..., !)`) is not yet lowered — pending the error-channel tuple ABI (ERR E2); use a `-> !` / `-> !Named` signature for now", .{}); + } + } + } + + fn diagRaiseNotFailable(self: *Lowering, span: ast.Span) void { + if (self.diagnostics) |diags| { + diags.addFmt(.err, span, "`raise` is only valid inside a failable function (a return type with `!` or `!Named`)", .{}); + } + } + fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 { return switch (op) { .add => "+", diff --git a/src/parser.zig b/src/parser.zig index 1222ccf..caed4b8 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -32,6 +32,11 @@ pub const Parser = struct { /// rejected — an error during cleanup has no propagation target. E1.7 /// extends this to the full {try, return, break, continue} set. in_onfail_body: bool = false, + /// When true (set while parsing a `defer` body), a `raise` statement is + /// rejected — same reason as `onfail`: cleanup runs while the function is + /// already exiting, so there is nothing to propagate to. E1.7 extends this + /// to the full {try, return, break, continue} set. + in_defer_body: bool = false, pub fn init(allocator: std.mem.Allocator, source: [:0]const u8) Parser { var lexer = Lexer.init(source); @@ -2010,6 +2015,9 @@ pub const Parser = struct { if (self.current.tag == .kw_defer) { const start = self.current.loc.start; self.advance(); + const saved_defer = self.in_defer_body; + self.in_defer_body = true; + defer self.in_defer_body = saved_defer; const deferred = try self.parseExpr(); try self.expect(.semicolon); return try self.createNode(start, .{ .defer_stmt = .{ .expr = deferred } }); @@ -2021,6 +2029,9 @@ pub const Parser = struct { if (self.in_onfail_body) { return self.fail("`raise` is not allowed inside an `onfail` body — an error during cleanup has no propagation target"); } + if (self.in_defer_body) { + return self.fail("`raise` is not allowed inside a `defer` body — an error during cleanup has no propagation target"); + } self.advance(); const tag_expr = try self.parseExpr(); try self.expect(.semicolon); @@ -4247,6 +4258,13 @@ test "E0.2 raise rejected inside an onfail body" { try std.testing.expectError(error.ParseError, parser.parse()); } +test "E1.3 raise rejected inside a defer body" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var parser = Parser.init(arena.allocator(), "f :: () { defer { raise error.X; } }"); + try std.testing.expectError(error.ParseError, parser.parse()); +} + test "E0.2 onfail with binding and block body" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/tests/expected/219-raise.exit b/tests/expected/219-raise.exit new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tests/expected/219-raise.exit @@ -0,0 +1 @@ +8 diff --git a/tests/expected/219-raise.txt b/tests/expected/219-raise.txt new file mode 100644 index 0000000..d512d5d --- /dev/null +++ b/tests/expected/219-raise.txt @@ -0,0 +1 @@ +raise result: 8 diff --git a/tests/expected/220-raise-rejections.exit b/tests/expected/220-raise-rejections.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/220-raise-rejections.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/220-raise-rejections.txt b/tests/expected/220-raise-rejections.txt new file mode 100644 index 0000000..c468de3 --- /dev/null +++ b/tests/expected/220-raise-rejections.txt @@ -0,0 +1,17 @@ +error: error tag 'error.NotInSet' is not in error set 'ParseErr' + --> /Users/agra/projects/sx/examples/220-raise-rejections.sx:17:11 + | +17 | raise error.NotInSet; // error: NotInSet not in ParseErr + | ^^^^^^^^^^^^^^ + +error: error tag 'error.Weird' is not in caller's error set 'ParseErr' + --> /Users/agra/projects/sx/examples/220-raise-rejections.sx:24:5 + | +24 | raise e; // error: OtherErr not subset of ParseErr + | ^^^^^^^^ + +error: `raise` is only valid inside a failable function (a return type with `!` or `!Named`) + --> /Users/agra/projects/sx/examples/220-raise-rejections.sx:30:5 + | +30 | raise error.BadDigit; // error: main (-> s32) is not failable + | ^^^^^^^^^^^^^^^^^^^^^