ERR/E0.2: raise / try / catch / onfail + precedence + consumer-aware pipe (parser)

Parser-only second step of the error-handling stream. No sema/codegen.

- token: 4 keywords — `raise`, `try`, `catch`, `onfail`.
- ast: RaiseStmt, TryExpr, CatchExpr {operand, binding?, body, is_match_body},
  OnFailStmt {binding?, body}.
- parser:
  - `try` is a unary prefix (binds tighter than `or`; right-recursive so it
    stacks under `xx`/`@`/etc).
  - `or` is already left-associative (precedence-climbing loop) — no change.
  - `catch` is a postfix with four body shapes (no-binding block / block /
    bare-expr / `== { case }` match-body, the latter reusing parseMatchBody
    with the binding as subject).
  - `raise EXPR;` and `onfail [e] { } | onfail EXPR;` statements; `error`
    parses in expression position so `raise error.X` works; raise rejected
    in expression position and inside an onfail body (in_onfail_body flag).
  - consumer-aware `|>`: pipes the LHS into the head call through a
    try/catch/or wrapper, preserving the wrapper.
- print: printExpr + match-arm printing for round-trips (anyerror!void to
  break the printExpr<->printMatchArms inferred-error-set loop).
- sema/lsp: exhaustive switch arms for the 4 nodes + 4 keyword tokens.
- tests: ~22 inline parser tests (precedence, all catch forms, both
  rejections, pipe cases, round-trip prints incl. match-body).

zig build, zig build test (264), and 254/254 examples green.
This commit is contained in:
agra
2026-05-31 17:07:49 +03:00
parent e88ee66953
commit 1b777dd6ab
6 changed files with 579 additions and 23 deletions

View File

@@ -28,6 +28,10 @@ pub const Parser = struct {
/// while parsing a `for` range bound so `for 0..N (i)` reads `N` as the
/// end and leaves `(i)` for the cursor rather than parsing `N(i)`.
suppress_call: bool = false,
/// When true (set while parsing an `onfail` body), a `raise` statement is
/// 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,
pub fn init(allocator: std.mem.Allocator, source: [:0]const u8) Parser {
var lexer = Lexer.init(source);
@@ -2011,6 +2015,42 @@ pub const Parser = struct {
return try self.createNode(start, .{ .defer_stmt = .{ .expr = deferred } });
}
// Raise statement: raise <expr>;
if (self.current.tag == .kw_raise) {
const start = self.current.loc.start;
if (self.in_onfail_body) {
return self.fail("`raise` is not allowed inside an `onfail` body — an error during cleanup has no propagation target");
}
self.advance();
const tag_expr = try self.parseExpr();
try self.expect(.semicolon);
return try self.createNode(start, .{ .raise_stmt = .{ .tag = tag_expr } });
}
// Onfail statement: onfail { body } | onfail e { body } | onfail <expr>;
// A binding is present only when an identifier is immediately followed
// by `{`; otherwise the text after `onfail` is the (no-binding) body.
if (self.current.tag == .kw_onfail) {
const start = self.current.loc.start;
self.advance();
var binding: ?[]const u8 = null;
if (self.current.tag == .identifier and self.peekNext() == .l_brace) {
binding = self.tokenSlice(self.current);
self.advance();
}
const saved_onfail = self.in_onfail_body;
self.in_onfail_body = true;
defer self.in_onfail_body = saved_onfail;
const body: *Node = if (self.current.tag == .l_brace)
try self.parseBlock()
else blk: {
const e = try self.parseExpr();
try self.expect(.semicolon);
break :blk e;
};
return try self.createNode(start, .{ .onfail_stmt = .{ .binding = binding, .body = body } });
}
// Break statement: break;
if (self.current.tag == .kw_break) {
const start = self.current.loc.start;
@@ -2146,23 +2186,37 @@ pub const Parser = struct {
var lhs = initial_lhs;
while (true) {
// Pipe operator: desugar a |> f(args) → f(a, args), a |> f → f(a)
// Pipe operator: desugar a |> f(args) → f(a, args), a |> f → f(a).
// Consumer-aware: the piped LHS goes into the RHS's HEAD call,
// looking THROUGH a `try` prefix / `catch` postfix / `or` fallback
// and leaving the wrapper intact:
// a |> try f(x) → try f(a, x)
// a |> f(x) catch e {...} → f(a, x) catch e {...}
// a |> f(x) or default → f(a, x) or default (only f gets a)
if (self.current.tag == .pipe_arrow and Prec.pipe >= min_prec) {
self.advance();
// Parse the RHS as a full call expression (higher precedence)
const rhs = try self.parseBinary(Prec.pipe + 1);
// Desugar based on RHS shape
if (rhs.data == .call) {
// a |> f(args) → f(a, args...)
// Walk through error-handling wrappers to the head call node.
var head = rhs;
const head_call: ?*Node = while (true) {
switch (head.data) {
.call => break head,
.try_expr => head = head.data.try_expr.operand,
.catch_expr => head = head.data.catch_expr.operand,
.binary_op => |bo| {
if (bo.op == .or_op) head = bo.lhs else break null;
},
else => break null,
}
};
if (head_call) |cn| {
// a |> ...f(args)... → ...f(a, args)... (wrapper preserved;
// mutating the head call in place updates the wrapper).
var new_args = std.ArrayList(*Node).empty;
try new_args.append(self.allocator, lhs);
for (rhs.data.call.args) |arg| {
try new_args.append(self.allocator, arg);
}
lhs = try self.createNode(lhs.span.start, .{ .call = .{
.callee = rhs.data.call.callee,
.args = try new_args.toOwnedSlice(self.allocator),
} });
for (cn.data.call.args) |arg| try new_args.append(self.allocator, arg);
cn.data.call.args = try new_args.toOwnedSlice(self.allocator);
lhs = rhs;
} else {
// a |> f → f(a)
const args = try self.allocator.alloc(*Node, 1);
@@ -2252,6 +2306,16 @@ pub const Parser = struct {
const operand = try self.parseUnary();
return try self.createNode(start, .{ .unary_op = .{ .op = .address_of, .operand = operand } });
}
// `try X` — failable-attempt prefix. Joins the unary tier (binds
// tighter than any binary op incl. `or`); right-recursive so prefixes
// stack by adjacency (`xx try foo()` = `xx (try foo())`). Failability
// of the operand is a sema check (E1.4), not a parse-time restriction.
if (self.current.tag == .kw_try) {
const start = self.current.loc.start;
self.advance();
const operand = try self.parseUnary();
return try self.createNode(start, .{ .try_expr = .{ .operand = operand } });
}
// cast(Type) expr — prefix operator with type parameter
if (self.current.tag == .identifier and std.mem.eql(u8, self.tokenSlice(self.current), "cast")) {
const saved_lexer = self.lexer;
@@ -2403,6 +2467,38 @@ pub const Parser = struct {
// Only if it's not != (bang_equal would have been lexed as a single token)
self.advance();
expr = try self.createNode(expr.span.start, .{ .force_unwrap = .{ .operand = expr } });
} else if (self.current.tag == .kw_catch) {
// `X catch [binding] BODY` — postfix failure handler.
// Four shapes, disambiguated by peeking after `catch`:
// catch { block } — no binding (braces required)
// catch e { block } — binding + block body
// catch e == { case ... } — binding + match body (sugar)
// catch e EXPR — binding + bare-expression body
self.advance(); // consume 'catch'
var binding: ?[]const u8 = null;
if (self.current.tag == .identifier) {
binding = self.tokenSlice(self.current);
self.advance();
}
var is_match_body = false;
const body: *Node = if (self.current.tag == .l_brace)
try self.parseBlock()
else if (binding != null and self.current.tag == .equal_equal) blk: {
const m_start = self.current.loc.start;
self.advance(); // consume '=='
is_match_body = true;
const subject = try self.createNode(m_start, .{ .identifier = .{ .name = binding.? } });
break :blk try self.parseMatchBody(subject, m_start);
} else if (binding != null)
try self.parseExpr()
else
return self.fail("`catch` without a binding requires a braced body: `catch { ... }`");
expr = try self.createNode(expr.span.start, .{ .catch_expr = .{
.operand = expr,
.binding = binding,
.body = body,
.is_match_body = is_match_body,
} });
} else {
break;
}
@@ -2661,6 +2757,14 @@ pub const Parser = struct {
.hash_jni_env => {
return try self.parseJniEnvBlock();
},
// `error` in expression position is the head of a tag literal
// `error.X` (parsed as a field access); sema (E1) gives it meaning.
.kw_error => {
self.advance();
return try self.createNode(start, .{ .identifier = .{ .name = "error" } });
},
.kw_raise => return self.fail("`raise` is a statement and cannot appear in expression position"),
.kw_onfail => return self.fail("`onfail` is a statement and cannot appear in expression position"),
else => {
return self.fail("unexpected token in expression");
},
@@ -3997,3 +4101,238 @@ test "round-trip print: bare inferred and named error types" {
try std.testing.expectEqualStrings("!ParseErr", aw.writer.toArrayList().items);
}
}
// ── ERR step E0.2 — raise / try / catch / onfail + precedence + pipe ──
/// Parse `src` (a single `f :: () { ... }` decl) and return its body's first
/// statement node.
fn e02FirstStmt(alloc: std.mem.Allocator, src: [:0]const u8) anyerror!*Node {
var parser = Parser.init(alloc, src);
const root = try parser.parse();
return root.data.root.decls[0].data.fn_decl.body.data.block.stmts[0];
}
/// Parse `src` (a `f :: () { v := EXPR; }` decl) and return the EXPR node.
fn e02FirstValue(alloc: std.mem.Allocator, src: [:0]const u8) anyerror!*Node {
const stmt = try e02FirstStmt(alloc, src);
return stmt.data.var_decl.value.?;
}
fn e02ExpectPrints(alloc: std.mem.Allocator, node: *const Node, expected: []const u8) !void {
var aw = std.Io.Writer.Allocating.init(alloc);
try print.printNode(node, &aw.writer);
try std.testing.expectEqualStrings(expected, aw.writer.toArrayList().items);
}
test "E0.2 try binds tighter than or: try foo() or try boo()" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := try foo() or try boo(); }");
try std.testing.expect(v.data == .binary_op);
try std.testing.expect(v.data.binary_op.op == .or_op);
try std.testing.expect(v.data.binary_op.lhs.data == .try_expr);
try std.testing.expect(v.data.binary_op.rhs.data == .try_expr);
}
test "E0.2 or is left-associative: a or b or c => (a or b) or c" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := try a() or try b() or try c(); }");
try std.testing.expect(v.data == .binary_op and v.data.binary_op.op == .or_op);
// LHS is the nested (a or b); RHS is the final operand.
try std.testing.expect(v.data.binary_op.lhs.data == .binary_op);
try std.testing.expect(v.data.binary_op.lhs.data.binary_op.op == .or_op);
try std.testing.expect(v.data.binary_op.rhs.data == .try_expr);
}
test "E0.2 try prefix stacks under xx: xx try foo()" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := xx try foo(); }");
try std.testing.expect(v.data == .unary_op and v.data.unary_op.op == .xx);
try std.testing.expect(v.data.unary_op.operand.data == .try_expr);
}
test "E0.2 catch no binding, braced body" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := foo() catch { }; }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expect(v.data.catch_expr.binding == null);
try std.testing.expect(v.data.catch_expr.is_match_body == false);
try std.testing.expect(v.data.catch_expr.body.data == .block);
}
test "E0.2 catch with binding, block body" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := foo() catch e { bar(); }; }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expectEqualStrings("e", v.data.catch_expr.binding.?);
try std.testing.expect(v.data.catch_expr.body.data == .block);
}
test "E0.2 catch with binding, bare-expression body" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := foo() catch e bar(); }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expectEqualStrings("e", v.data.catch_expr.binding.?);
try std.testing.expect(v.data.catch_expr.is_match_body == false);
try std.testing.expect(v.data.catch_expr.body.data == .call);
}
test "E0.2 catch match-body desugars to match_expr over the binding" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := foo() catch e == { case .Empty: 0; else: 1; }; }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expect(v.data.catch_expr.is_match_body);
try std.testing.expect(v.data.catch_expr.body.data == .match_expr);
try std.testing.expectEqual(@as(usize, 2), v.data.catch_expr.body.data.match_expr.arms.len);
// subject is the binding identifier
try std.testing.expect(v.data.catch_expr.body.data.match_expr.subject.data == .identifier);
try std.testing.expectEqualStrings("e", v.data.catch_expr.body.data.match_expr.subject.data.identifier.name);
}
test "E0.2 catch over a parenthesized or-chain" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := (try foo() or try boo()) catch e { }; }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expect(v.data.catch_expr.operand.data == .binary_op);
try std.testing.expect(v.data.catch_expr.operand.data.binary_op.op == .or_op);
}
test "E0.2 catch without binding and unbraced body is rejected" {
// No binding (the token after `catch` is not an identifier) and no braces:
// the no-binding form requires a braced body.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () { v := foo() catch 42; }");
try std.testing.expectError(error.ParseError, parser.parse());
}
test "E0.2 raise error.X parses as raise_stmt over a field access" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const s = try e02FirstStmt(arena.allocator(), "f :: () { raise error.BadDigit; }");
try std.testing.expect(s.data == .raise_stmt);
try std.testing.expect(s.data.raise_stmt.tag.data == .field_access);
try std.testing.expectEqualStrings("BadDigit", s.data.raise_stmt.tag.data.field_access.field);
const obj = s.data.raise_stmt.tag.data.field_access.object;
try std.testing.expect(obj.data == .identifier);
try std.testing.expectEqualStrings("error", obj.data.identifier.name);
}
test "E0.2 raise variable form" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const s = try e02FirstStmt(arena.allocator(), "f :: () { raise e; }");
try std.testing.expect(s.data == .raise_stmt);
try std.testing.expect(s.data.raise_stmt.tag.data == .identifier);
}
test "E0.2 raise rejected in expression position" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () { x := 1 + raise error.X; }");
try std.testing.expectError(error.ParseError, parser.parse());
}
test "E0.2 raise rejected inside an onfail body" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () { onfail { 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();
const s = try e02FirstStmt(arena.allocator(), "f :: () { onfail e { close(h); } }");
try std.testing.expect(s.data == .onfail_stmt);
try std.testing.expectEqualStrings("e", s.data.onfail_stmt.binding.?);
try std.testing.expect(s.data.onfail_stmt.body.data == .block);
}
test "E0.2 onfail no-binding block vs bare-expression body" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const block_body = try e02FirstStmt(arena.allocator(), "f :: () { onfail { close(h); } }");
try std.testing.expect(block_body.data == .onfail_stmt);
try std.testing.expect(block_body.data.onfail_stmt.binding == null);
try std.testing.expect(block_body.data.onfail_stmt.body.data == .block);
const expr_body = try e02FirstStmt(arena.allocator(), "f :: () { onfail close(h); }");
try std.testing.expect(expr_body.data == .onfail_stmt);
try std.testing.expect(expr_body.data.onfail_stmt.binding == null);
try std.testing.expect(expr_body.data.onfail_stmt.body.data == .call);
}
test "E0.2 consumer-aware pipe: x |> try f() inserts x into the head call" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := x |> try f(); }");
try std.testing.expect(v.data == .try_expr);
const call = v.data.try_expr.operand;
try std.testing.expect(call.data == .call);
try std.testing.expectEqual(@as(usize, 1), call.data.call.args.len);
try std.testing.expect(call.data.call.args[0].data == .identifier);
try std.testing.expectEqualStrings("x", call.data.call.args[0].data.identifier.name);
}
test "E0.2 consumer-aware pipe: x |> f() catch e { } preserves the catch" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := x |> g() catch e { }; }");
try std.testing.expect(v.data == .catch_expr);
try std.testing.expectEqualStrings("e", v.data.catch_expr.binding.?);
try std.testing.expect(v.data.catch_expr.operand.data == .call);
try std.testing.expectEqual(@as(usize, 1), v.data.catch_expr.operand.data.call.args.len);
}
test "E0.2 consumer-aware pipe: x |> f() or d feeds only the head call" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := x |> g() or d; }");
try std.testing.expect(v.data == .binary_op and v.data.binary_op.op == .or_op);
// LHS (head call g) receives x; RHS fallback `d` is untouched.
try std.testing.expect(v.data.binary_op.lhs.data == .call);
try std.testing.expectEqual(@as(usize, 1), v.data.binary_op.lhs.data.call.args.len);
try std.testing.expect(v.data.binary_op.rhs.data == .identifier);
try std.testing.expectEqualStrings("d", v.data.binary_op.rhs.data.identifier.name);
}
test "E0.2 plain pipe still works: x |> f(a) => f(x, a)" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const v = try e02FirstValue(arena.allocator(), "f :: () { v := x |> g(a); }");
try std.testing.expect(v.data == .call);
try std.testing.expectEqual(@as(usize, 2), v.data.call.args.len);
try std.testing.expectEqualStrings("x", v.data.call.args[0].data.identifier.name);
try std.testing.expectEqualStrings("a", v.data.call.args[1].data.identifier.name);
}
test "E0.2 round-trip print: try / or precedence / raise / catch / onfail" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
try e02ExpectPrints(a, try e02FirstValue(a, "f :: () { v := try foo(); }"), "try foo()");
try e02ExpectPrints(a, try e02FirstValue(a, "f :: () { v := try foo() or try boo(); }"), "try foo() or try boo()");
try e02ExpectPrints(a, try e02FirstStmt(a, "f :: () { raise error.BadDigit; }"), "raise error.BadDigit");
try e02ExpectPrints(a, try e02FirstStmt(a, "f :: () { raise e; }"), "raise e");
try e02ExpectPrints(a, try e02FirstValue(a, "f :: () { v := foo() catch e bar(); }"), "foo() catch e bar()");
try e02ExpectPrints(a, try e02FirstValue(a, "f :: () { v := foo() catch e { bar(); }; }"), "foo() catch e { bar(); }");
try e02ExpectPrints(a, try e02FirstValue(a, "f :: () { v := foo() catch { bar(); }; }"), "foo() catch { bar(); }");
try e02ExpectPrints(a, try e02FirstStmt(a, "f :: () { onfail close(h); }"), "onfail close(h)");
try e02ExpectPrints(a, try e02FirstStmt(a, "f :: () { onfail e { close(h); } }"), "onfail e { close(h); }");
}
test "E0.2 round-trip print: catch match-body form" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const v = try e02FirstValue(a, "f :: () { v := foo() catch e == { case .Empty: 0; else: 1; }; }");
try e02ExpectPrints(a, v, "foo() catch e == { case .Empty: 0; else: 1; }");
}