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

@@ -58,6 +58,10 @@ pub const Node = struct {
many_pointer_type_expr: ManyPointerTypeExpr,
optional_type_expr: OptionalTypeExpr,
error_type_expr: ErrorTypeExpr,
raise_stmt: RaiseStmt,
try_expr: TryExpr,
catch_expr: CatchExpr,
onfail_stmt: OnFailStmt,
pack_index_type_expr: PackIndexTypeExpr,
comptime_pack_ref: ComptimePackRef,
force_unwrap: ForceUnwrap,
@@ -407,6 +411,40 @@ pub const DeferStmt = struct {
expr: *Node,
};
// ── Error handling (ERR stream) ──────────────────────────────────────────
/// `raise EXPR;` — terminates control flow like `return`, populating the
/// error channel. `tag` is a tag-typed expression: `error.X` (a field
/// access on the `error` keyword) or a tag-bound variable (`raise e`).
pub const RaiseStmt = struct {
tag: *Node,
};
/// `try X` — a failable attempt. Unary prefix, binds tighter than any
/// binary operator. Sema (E1.4) rejects a non-failable operand.
pub const TryExpr = struct {
operand: *Node,
};
/// `X catch [e] BODY` — inline failure handler (postfix). The binding is a
/// bare name (no parens) and optional. Body is a block, a bare expression,
/// or — when `is_match_body` — a `match_expr` from the `== { case ... }`
/// sugar (whose subject is the binding).
pub const CatchExpr = struct {
operand: *Node,
binding: ?[]const u8 = null,
body: *Node,
is_match_body: bool = false,
};
/// `onfail [e] BODY` — cleanup run on error-exit of the enclosing block.
/// Binding optional (bare name). Body is a block (`onfail [e] { ... }`) or
/// a bare expression (`onfail EXPR;`).
pub const OnFailStmt = struct {
binding: ?[]const u8 = null,
body: *Node,
};
pub const PushStmt = struct {
context_expr: *Node,
body: *Node,

View File

@@ -1653,6 +1653,10 @@ pub const Server = struct {
.kw_false,
.kw_enum,
.kw_error,
.kw_raise,
.kw_try,
.kw_catch,
.kw_onfail,
.kw_case,
.kw_break,
.kw_continue,

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; }");
}

View File

@@ -1,12 +1,12 @@
//! Focused AST round-trip printer.
//!
//! Reconstructs sx source text from AST nodes. The scope is intentionally
//! narrow: it covers the declaration and type-expression node kinds the
//! error-handling (ERR) stream's parser tests round-trip, and bails loudly
//! (`error.UnsupportedNode`) on anything it does not yet handle — so an
//! unsupported node can never be silently mis-printed (CLAUDE.md REJECTED
//! PATTERNS: no silent arms). Later ERR steps extend it as new surface
//! syntax lands.
//! narrow: it covers the declaration, type-expression, and (since ERR E0.2)
//! the error-handling expression/statement node kinds the ERR stream's parser
//! tests round-trip, and bails loudly (`error.UnsupportedNode`) on anything it
//! does not yet handle — so an unsupported node can never be silently
//! mis-printed (CLAUDE.md REJECTED PATTERNS: no silent arms). Later steps
//! extend it as new surface syntax lands.
const std = @import("std");
const ast = @import("ast.zig");
@@ -14,10 +14,9 @@ const ast = @import("ast.zig");
const Node = ast.Node;
const Writer = *std.Io.Writer;
/// Print a declaration node back to source text. Currently handles
/// `error { ... }` set declarations; falls through to `printType` for
/// type-expression nodes.
pub fn printNode(node: *const Node, writer: Writer) !void {
/// Print a node back to source text. Routes declarations here, expressions /
/// statements to `printExpr`, and type expressions to `printType`.
pub fn printNode(node: *const Node, writer: Writer) anyerror!void {
switch (node.data) {
.error_set_decl => |d| {
try writer.writeAll(d.name);
@@ -29,12 +28,101 @@ pub fn printNode(node: *const Node, writer: Writer) !void {
if (d.tag_names.len > 0) try writer.writeByte(' ');
try writer.writeByte('}');
},
else => try printExpr(node, writer),
}
}
/// Print an expression or statement node back to source text.
pub fn printExpr(node: *const Node, writer: Writer) anyerror!void {
switch (node.data) {
.identifier => |id| try writer.writeAll(id.name),
.enum_literal => |el| {
try writer.writeByte('.');
try writer.writeAll(el.name);
},
.int_literal => |l| try writer.print("{d}", .{l.value}),
.bool_literal => |l| try writer.writeAll(if (l.value) "true" else "false"),
.string_literal => |l| {
try writer.writeByte('"');
try writer.writeAll(l.raw);
try writer.writeByte('"');
},
.call => |c| {
try printExpr(c.callee, writer);
try writer.writeByte('(');
for (c.args, 0..) |arg, i| {
if (i > 0) try writer.writeAll(", ");
try printExpr(arg, writer);
}
try writer.writeByte(')');
},
.field_access => |fa| {
try printExpr(fa.object, writer);
try writer.writeByte('.');
try writer.writeAll(fa.field);
},
.binary_op => |b| {
const op_str = binaryOpStr(b.op) orelse return error.UnsupportedNode;
try printExpr(b.lhs, writer);
try writer.writeByte(' ');
try writer.writeAll(op_str);
try writer.writeByte(' ');
try printExpr(b.rhs, writer);
},
.try_expr => |t| {
try writer.writeAll("try ");
try printExpr(t.operand, writer);
},
.catch_expr => |c| {
try printExpr(c.operand, writer);
try writer.writeAll(" catch");
if (c.binding) |bnd| {
try writer.writeByte(' ');
try writer.writeAll(bnd);
}
if (c.is_match_body) {
try writer.writeAll(" == ");
try printMatchArms(c.body, writer);
} else {
try writer.writeByte(' ');
try printExpr(c.body, writer);
}
},
.raise_stmt => |r| {
try writer.writeAll("raise ");
try printExpr(r.tag, writer);
},
.onfail_stmt => |o| {
try writer.writeAll("onfail");
if (o.binding) |bnd| {
try writer.writeByte(' ');
try writer.writeAll(bnd);
}
try writer.writeByte(' ');
try printExpr(o.body, writer);
},
.return_stmt => |r| {
try writer.writeAll("return");
if (r.value) |v| {
try writer.writeByte(' ');
try printExpr(v, writer);
}
},
.block => |blk| {
try writer.writeByte('{');
for (blk.stmts) |stmt| {
try writer.writeByte(' ');
try printExpr(stmt, writer);
try writer.writeByte(';');
}
try writer.writeAll(" }");
},
else => try printType(node, writer),
}
}
/// Print a type-expression node back to source text.
pub fn printType(node: *const Node, writer: Writer) !void {
pub fn printType(node: *const Node, writer: Writer) anyerror!void {
switch (node.data) {
.type_expr => |t| try writer.writeAll(t.name),
.error_type_expr => |e| {
@@ -68,3 +156,46 @@ pub fn printType(node: *const Node, writer: Writer) !void {
else => return error.UnsupportedNode,
}
}
/// Print the `{ case ... }` arms of a `match_expr` (used for the catch
/// match-body form `catch e == { ... }`). The subject is implicit (the catch
/// binding), so only the arms are emitted. Each arm body must be a one-statement
/// block (the common case); anything else bails loudly.
fn printMatchArms(node: *const Node, writer: Writer) anyerror!void {
if (node.data != .match_expr) return error.UnsupportedNode;
try writer.writeByte('{');
for (node.data.match_expr.arms) |arm| {
try writer.writeByte(' ');
if (arm.pattern) |pat| {
try writer.writeAll("case ");
try printExpr(pat, writer);
try writer.writeAll(": ");
} else {
try writer.writeAll("else: ");
}
if (arm.body.data != .block or arm.body.data.block.stmts.len != 1) {
return error.UnsupportedNode;
}
try printExpr(arm.body.data.block.stmts[0], writer);
try writer.writeByte(';');
}
try writer.writeAll(" }");
}
fn binaryOpStr(op: ast.BinaryOp.Op) ?[]const u8 {
return switch (op) {
.or_op => "or",
.and_op => "and",
.add => "+",
.sub => "-",
.mul => "*",
.div => "/",
.eq => "==",
.neq => "!=",
.lt => "<",
.lte => "<=",
.gt => ">",
.gte => ">=",
else => null,
};
}

View File

@@ -1130,6 +1130,29 @@ pub const Analyzer = struct {
.defer_stmt => |ds| {
try self.analyzeNode(ds.expr);
},
.raise_stmt => |rs| {
try self.analyzeNode(rs.tag);
},
.try_expr => |te| {
try self.analyzeNode(te.operand);
},
.catch_expr => |ce| {
try self.analyzeNode(ce.operand);
try self.pushScope();
if (ce.binding) |bname| {
try self.addSymbol(bname, .variable, null, node.span);
}
try self.analyzeNode(ce.body);
self.popScope();
},
.onfail_stmt => |os| {
try self.pushScope();
if (os.binding) |bname| {
try self.addSymbol(bname, .variable, null, node.span);
}
try self.analyzeNode(os.body);
self.popScope();
},
.push_stmt => |ps| {
try self.analyzeNode(ps.context_expr);
try self.analyzeNode(ps.body);
@@ -1587,6 +1610,19 @@ pub fn findNodeAtOffset(node: *Node, offset: u32) ?*Node {
.defer_stmt => |ds| {
if (findNodeAtOffset(ds.expr, offset)) |found| return found;
},
.raise_stmt => |rs| {
if (findNodeAtOffset(rs.tag, offset)) |found| return found;
},
.try_expr => |te| {
if (findNodeAtOffset(te.operand, offset)) |found| return found;
},
.catch_expr => |ce| {
if (findNodeAtOffset(ce.operand, offset)) |found| return found;
if (findNodeAtOffset(ce.body, offset)) |found| return found;
},
.onfail_stmt => |os| {
if (findNodeAtOffset(os.body, offset)) |found| return found;
},
.push_stmt => |ps| {
if (findNodeAtOffset(ps.context_expr, offset)) |found| return found;
if (findNodeAtOffset(ps.body, offset)) |found| return found;

View File

@@ -14,6 +14,10 @@ pub const Tag = enum {
kw_false,
kw_enum,
kw_error, // error (error-set declaration)
kw_raise, // raise (error propagation statement)
kw_try, // try (failable-attempt prefix)
kw_catch, // catch (failable handler postfix)
kw_onfail, // onfail (error-exit cleanup statement)
kw_case,
kw_break,
kw_continue,
@@ -224,6 +228,10 @@ pub const keywords = std.StaticStringMap(Tag).initComptime(.{
.{ "false", .kw_false },
.{ "enum", .kw_enum },
.{ "error", .kw_error },
.{ "raise", .kw_raise },
.{ "try", .kw_try },
.{ "catch", .kw_catch },
.{ "onfail", .kw_onfail },
.{ "case", .kw_case },
.{ "break", .kw_break },
.{ "continue", .kw_continue },