From 1b777dd6ab22dc1ba2a9479a29057957a14bb00d Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 31 May 2026 17:07:49 +0300 Subject: [PATCH] ERR/E0.2: raise / try / catch / onfail + precedence + consumer-aware pipe (parser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ast.zig | 38 +++++ src/lsp/server.zig | 4 + src/parser.zig | 363 +++++++++++++++++++++++++++++++++++++++++++-- src/print.zig | 153 +++++++++++++++++-- src/sema.zig | 36 +++++ src/token.zig | 8 + 6 files changed, 579 insertions(+), 23 deletions(-) diff --git a/src/ast.zig b/src/ast.zig index 6c488c7..a7184fb 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -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, diff --git a/src/lsp/server.zig b/src/lsp/server.zig index 8683999..9f59d46 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -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, diff --git a/src/parser.zig b/src/parser.zig index b378438..45de9a3 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -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 ; + 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 ; + // 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; }"); +} diff --git a/src/print.zig b/src/print.zig index e6bddaa..b205c61 100644 --- a/src/print.zig +++ b/src/print.zig @@ -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, + }; +} diff --git a/src/sema.zig b/src/sema.zig index 2faa5e4..7e575a3 100644 --- a/src/sema.zig +++ b/src/sema.zig @@ -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; diff --git a/src/token.zig b/src/token.zig index 4443712..2ee525a 100644 --- a/src/token.zig +++ b/src/token.zig @@ -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 },