From f9dd965b6919341dd25103b85017fc19f9e21008 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 01:14:24 +0300 Subject: [PATCH] ERR/E1.7: ban return/break/continue/try in defer & onfail bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A defer or onfail body runs while the block/function is already exiting, so it has no target to transfer control to. `raise` was already rejected (E1.3); this adds the rest of the locked set — `return` / `break` / `continue` / `try`. In parseStmt, the return/break/continue/try parse sites now call a new rejectInCleanup() helper, gated on in_onfail_body || in_defer_body (the existing flags, whose doc-comments already scoped this follow-up). The ban is transitive through nested catch bodies and loops, but parseLambda clears both flags for the closure body — a closure is its own function boundary, so a `return` from a closure created inside a cleanup body stays legal. The diagnostic names the cleanup kind ("an `onfail`" / "a `defer`"). examples/237-cleanup-body-restrictions.sx covers the rejected forms (exit 1); six inline parser tests cover each banned exit, the transitive-through-loop case, the closure-boundary exception, and flag-restore after the defer. Note: examples/213-canonical-map.sx is the user's uncommitted heterogeneous- variadic-pack WIP (prints 40 vs expected 42); it fails on the committed parser too, independent of this change, and is left unstaged. Gates: zig build, zig build test (288 pass), bash tests/run_examples.sh (all green except the unrelated 213 WIP). --- examples/237-cleanup-body-restrictions.sx | 26 ++++++ src/parser.zig | 85 +++++++++++++++++++ .../237-cleanup-body-restrictions.exit | 1 + .../237-cleanup-body-restrictions.txt | 5 ++ 4 files changed, 117 insertions(+) create mode 100644 examples/237-cleanup-body-restrictions.sx create mode 100644 tests/expected/237-cleanup-body-restrictions.exit create mode 100644 tests/expected/237-cleanup-body-restrictions.txt diff --git a/examples/237-cleanup-body-restrictions.sx b/examples/237-cleanup-body-restrictions.sx new file mode 100644 index 0000000..b47fc0d --- /dev/null +++ b/examples/237-cleanup-body-restrictions.sx @@ -0,0 +1,26 @@ +// Cleanup-body control-flow restrictions (ERR step E1.7 follow-up). A `defer` +// or `onfail` body runs while the block/function is already exiting, so it has +// no target to transfer control to: `raise` / `try` / `return` / `break` / +// `continue` are all rejected inside one. The ban is transitive through nested +// `catch` bodies and loops, but NOT through a nested closure (its own function +// boundary). `raise` was already banned (E1.3); this adds the other four. +// This file is expected to FAIL compilation (exit 1). +// +// Run: ./zig-out/bin/sx run examples/237-cleanup-body-restrictions.sx + +#import "modules/std.sx"; + +E :: error { Bad } + +g :: () -> !E { return; } + +f :: () -> !E { + defer { return; } // ERROR: return in defer body + onfail { try g(); } // ERROR: try in onfail body + defer { for 0..1: (i) { break; } } // ERROR: break in defer body (transitive through loop) + onfail e { if e == error.Bad { continue; } } // ERROR: continue in onfail body + try g(); + return; +} + +main :: () -> s32 { return 0; } diff --git a/src/parser.zig b/src/parser.zig index f59cec2..501ee57 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -2007,6 +2007,7 @@ pub const Parser = struct { // Return statement: return expr; or return; if (self.current.tag == .kw_return) { + try self.rejectInCleanup("return"); const start = self.current.loc.start; self.advance(); if (self.current.tag == .semicolon) { @@ -2071,6 +2072,7 @@ pub const Parser = struct { // Break statement: break; if (self.current.tag == .kw_break) { + try self.rejectInCleanup("break"); const start = self.current.loc.start; self.advance(); try self.expect(.semicolon); @@ -2079,6 +2081,7 @@ pub const Parser = struct { // Continue statement: continue; if (self.current.tag == .kw_continue) { + try self.rejectInCleanup("continue"); const start = self.current.loc.start; self.advance(); try self.expect(.semicolon); @@ -2329,6 +2332,7 @@ pub const Parser = struct { // 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) { + try self.rejectInCleanup("try"); const start = self.current.loc.start; self.advance(); const operand = try self.parseUnary(); @@ -3325,6 +3329,19 @@ pub const Parser = struct { // Optional calling convention: callconv(.c) const call_conv = try self.parseOptionalCallConv(); + // A closure is its own function boundary: clear the cleanup-body flags + // so control-flow exits inside the closure body (`return` from the + // closure, etc.) are legal even when the closure literal appears inside + // a `defer` / `onfail` body. Restored after the body. + const saved_onfail = self.in_onfail_body; + const saved_defer = self.in_defer_body; + self.in_onfail_body = false; + self.in_defer_body = false; + defer { + self.in_onfail_body = saved_onfail; + self.in_defer_body = saved_defer; + } + // Two body forms: // (params) => expr — expression lambda // (params) { stmts } — block-body lambda @@ -3607,6 +3624,29 @@ pub const Parser = struct { return self.fail(msg); } + /// A cleanup body is a `defer` or `onfail` body. Control-flow exits + /// (`raise` / `try` / `return` / `break` / `continue`) are banned inside one: + /// cleanup runs while the block/function is already exiting, so there is + /// nothing to propagate or transfer to. The ban is transitive through nested + /// `catch` bodies and loops, but NOT through a nested closure body — a + /// closure is its own function boundary (parseLambda clears the flags). + fn inCleanupBody(self: *const Parser) bool { + return self.in_onfail_body or self.in_defer_body; + } + + /// The cleanup-body phrase for diagnostics, with article (`onfail` takes + /// precedence when both are set, e.g. an `onfail` nested in a `defer`). + fn cleanupKind(self: *const Parser) []const u8 { + return if (self.in_onfail_body) "an `onfail`" else "a `defer`"; + } + + /// Reject a control-flow exit `kw` (e.g. "return") inside a cleanup body. + fn rejectInCleanup(self: *Parser, comptime kw: []const u8) error{ParseError}!void { + if (self.inCleanupBody()) { + return self.failFmt("`" ++ kw ++ "` is not allowed inside {s} body — cleanup runs while the function is already exiting, so there is nothing to transfer control to", .{self.cleanupKind()}); + } + } + fn tokenSlice(self: *const Parser, token: Token) []const u8 { return self.source[token.loc.start..token.loc.end]; } @@ -4274,6 +4314,51 @@ test "E1.3 raise rejected inside a defer body" { try std.testing.expectError(error.ParseError, parser.parse()); } +test "E1.7 return 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 { return; } }"); + try std.testing.expectError(error.ParseError, parser.parse()); +} + +test "E1.7 try 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 { try g(); } }"); + try std.testing.expectError(error.ParseError, parser.parse()); +} + +test "E1.7 break rejected inside a defer body (transitive through a loop)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var parser = Parser.init(arena.allocator(), "f :: () { defer { for 0..1: (i) { break; } } }"); + try std.testing.expectError(error.ParseError, parser.parse()); +} + +test "E1.7 continue 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 e { continue; } }"); + try std.testing.expectError(error.ParseError, parser.parse()); +} + +test "E1.7 return inside a closure within a cleanup body is allowed" { + // A closure is its own function boundary: parseLambda clears the cleanup + // flags, so `return` from the closure body is legal even inside `defer`. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var parser = Parser.init(arena.allocator(), "f :: () { defer g((x: s32) -> s32 { return x; }); }"); + _ = try parser.parse(); +} + +test "E1.7 control-flow legal again after the cleanup body (flag restored)" { + // The cleanup-body flag must not leak to statements that follow the defer. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var parser = Parser.init(arena.allocator(), "f :: () { defer cleanup(); return; }"); + _ = try 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/237-cleanup-body-restrictions.exit b/tests/expected/237-cleanup-body-restrictions.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/237-cleanup-body-restrictions.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/237-cleanup-body-restrictions.txt b/tests/expected/237-cleanup-body-restrictions.txt new file mode 100644 index 0000000..5dfdd06 --- /dev/null +++ b/tests/expected/237-cleanup-body-restrictions.txt @@ -0,0 +1,5 @@ +error: `return` is not allowed inside a `defer` body — cleanup runs while the function is already exiting, so there is nothing to transfer control to + --> /Users/agra/projects/sx/examples/237-cleanup-body-restrictions.sx:18:14 + | +18 | defer { return; } // ERROR: return in defer body + | ^^^^^^