From c3bc6acd421ec1b97ea99fac94fb85e8185c17c9 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 23:24:15 +0300 Subject: [PATCH] ERR/E1.7: reject bare failable calls in defer/onfail cleanup bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `defer`/`onfail` body runs while the block is already exiting, so a failable call there has nowhere to propagate its error. The parser already bans `try`/`raise`/`return`/`break`/`continue` in cleanup bodies (f9dd965); this adds the remaining sema rule — a bare (un-absorbed) failable call must be absorbed locally with `catch` or `or `. Implemented in the shared error-flow pass (`checkCleanupBody` / `checkCleanupNode` / `cleanupReject` in ir/lower.zig): when the walk hits a `defer`/`onfail`, it scans the body transitively (through blocks, `if`, loops, match arms, `catch` handlers; stopping at nested closures) and flags any still-failable expression. `catch` / `or value` strip the error channel, so `exprIsFailable` is false for them — only an unhandled failable trips the check. This completes ERR PLAN E0–E5 plus the two deferred E1 follow-ups (E1.7 + E1.8). New regressions: 1048 (catch/or-value absorbed forms compile + run) and 1049 (bare failable in defer and onfail rejected, exit 1). Filed issue 0065: a braced `defer { … }` / value-block body routes through `parseExpr` (not `parseBlock` like `onfail`), so it can't parse a destructure or `catch`-statement inside. Orthogonal to E1.7 — the spec'd cleanup absorbers (`catch` / `or value`) parse fine in a `defer` body. Gates: zig build, zig build test, run_examples.sh -> 340 passed, 0 failed. --- examples/1048-errors-cleanup-absorption.sx | 28 +++++++ .../1049-errors-cleanup-absorption-reject.sx | 23 ++++++ .../1048-errors-cleanup-absorption.exit | 1 + .../1048-errors-cleanup-absorption.stderr | 1 + .../1048-errors-cleanup-absorption.stdout | 6 ++ ...1049-errors-cleanup-absorption-reject.exit | 1 + ...49-errors-cleanup-absorption-reject.stderr | 11 +++ ...49-errors-cleanup-absorption-reject.stdout | 1 + .../0065-block-expr-destructure-decl-parse.md | 81 +++++++++++++++++++ src/ir/lower.zig | 49 ++++++++++- 10 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 examples/1048-errors-cleanup-absorption.sx create mode 100644 examples/1049-errors-cleanup-absorption-reject.sx create mode 100644 examples/expected/1048-errors-cleanup-absorption.exit create mode 100644 examples/expected/1048-errors-cleanup-absorption.stderr create mode 100644 examples/expected/1048-errors-cleanup-absorption.stdout create mode 100644 examples/expected/1049-errors-cleanup-absorption-reject.exit create mode 100644 examples/expected/1049-errors-cleanup-absorption-reject.stderr create mode 100644 examples/expected/1049-errors-cleanup-absorption-reject.stdout create mode 100644 issues/0065-block-expr-destructure-decl-parse.md diff --git a/examples/1048-errors-cleanup-absorption.sx b/examples/1048-errors-cleanup-absorption.sx new file mode 100644 index 0000000..6741792 --- /dev/null +++ b/examples/1048-errors-cleanup-absorption.sx @@ -0,0 +1,28 @@ +// Failable calls in cleanup bodies must be absorbed locally (ERR step E1.7). A +// `defer` / `onfail` body runs while the block is already exiting, so a failable +// it calls has nowhere to propagate — it must be handled in place with `catch` +// or an `or ` terminator. This file shows the accepted forms; the bare +// (un-absorbed) form is rejected in 1049. + +#import "modules/std.sx"; + +E :: error { Bad } + +failing :: () -> !E { raise error.Bad; } +recover :: () -> (s32, !E) { raise error.Bad; } + +work :: (n: s32) -> !E { + defer print("defer: always\n"); // plain cleanup + onfail { failing() catch e print("onfail: caught (catch)\n"); } // catch absorbs + onfail { x := recover() or 7; print("onfail: x={} (or)\n", x); } // or-value absorbs + if n < 0 { raise error.Bad; } + return; +} + +main :: () -> s32 { + print("[error]\n"); + a := work(-1); // raises → onfail bodies fire, then defer (reverse decl order) + print("[ok]\n"); + b := work(2); // success → only defer fires + return 0; +} diff --git a/examples/1049-errors-cleanup-absorption-reject.sx b/examples/1049-errors-cleanup-absorption-reject.sx new file mode 100644 index 0000000..8ab0a0b --- /dev/null +++ b/examples/1049-errors-cleanup-absorption-reject.sx @@ -0,0 +1,23 @@ +// Rejection counterpart to 1048 (ERR step E1.7). A bare (un-absorbed) failable +// call in a `defer` / `onfail` body is a compile error — the block is already +// exiting, so the error has nowhere to propagate. It must be absorbed locally +// with `catch` or `or `. Both a `defer` and an `onfail` bare call are +// flagged; the program never runs (exit 1). + +#import "modules/std.sx"; + +E :: error { Bad } + +failing :: () -> !E { raise error.Bad; } + +work :: (n: s32) -> !E { + defer failing(); // REJECTED: bare failable in a defer body + onfail { failing(); } // REJECTED: bare failable in an onfail body + if n < 0 { raise error.Bad; } + return; +} + +main :: () -> s32 { + a := work(-1); + return 0; +} diff --git a/examples/expected/1048-errors-cleanup-absorption.exit b/examples/expected/1048-errors-cleanup-absorption.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/1048-errors-cleanup-absorption.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/1048-errors-cleanup-absorption.stderr b/examples/expected/1048-errors-cleanup-absorption.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1048-errors-cleanup-absorption.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/1048-errors-cleanup-absorption.stdout b/examples/expected/1048-errors-cleanup-absorption.stdout new file mode 100644 index 0000000..8f4d92f --- /dev/null +++ b/examples/expected/1048-errors-cleanup-absorption.stdout @@ -0,0 +1,6 @@ +[error] +onfail: x=7 (or) +onfail: caught (catch) +defer: always +[ok] +defer: always diff --git a/examples/expected/1049-errors-cleanup-absorption-reject.exit b/examples/expected/1049-errors-cleanup-absorption-reject.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1049-errors-cleanup-absorption-reject.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1049-errors-cleanup-absorption-reject.stderr b/examples/expected/1049-errors-cleanup-absorption-reject.stderr new file mode 100644 index 0000000..bb960df --- /dev/null +++ b/examples/expected/1049-errors-cleanup-absorption-reject.stderr @@ -0,0 +1,11 @@ +error: a bare failable call in a `defer` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or ` + --> /Users/agra/projects/sx/examples/1049-errors-cleanup-absorption-reject.sx:14:12 + | +14 | defer failing(); // REJECTED: bare failable in a defer body + | ^^^^^^^^^ + +error: a bare failable call in a `onfail` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or ` + --> /Users/agra/projects/sx/examples/1049-errors-cleanup-absorption-reject.sx:15:14 + | +15 | onfail { failing(); } // REJECTED: bare failable in an onfail body + | ^^^^^^^^^ diff --git a/examples/expected/1049-errors-cleanup-absorption-reject.stdout b/examples/expected/1049-errors-cleanup-absorption-reject.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1049-errors-cleanup-absorption-reject.stdout @@ -0,0 +1 @@ + diff --git a/issues/0065-block-expr-destructure-decl-parse.md b/issues/0065-block-expr-destructure-decl-parse.md new file mode 100644 index 0000000..6551582 --- /dev/null +++ b/issues/0065-block-expr-destructure-decl-parse.md @@ -0,0 +1,81 @@ +# 0065 — block-expression body does not parse a destructure decl (`v, e := f();`) + +## Symptom + +A destructure declaration (`v, e := f();`) inside a **block used in +expression position** fails to parse with `expected ';'`. Two surfaced +forms: + +- `defer { v, e := f(); ... }` — a `defer` body is parsed via `parseExpr` + (so its `{ ... }` is a block-EXPRESSION), and the block-expression + statement loop doesn't recognize the `name, name :=` destructure form. +- `y := { v, e := f(); v };` — a value-producing block bound to a name. + +Observed: `error: expected ';'` pointing at the statement *after* the +destructure (the parser bails at the `:=` and resyncs). Expected: the +destructure parses exactly as it does in a normal statement block (an +`if` body, a plain `{ }` statement block, or an `onfail { }` body — all of +which use `parseBlock` and handle it fine). + +This is the same family as the pre-existing "value-producing block body +in binding position doesn't parse" note in `current/CHECKPOINT-ERR.md` +(E2.4b log). `onfail { }` is unaffected because it parses its body with +`parseBlock` (src/parser.zig ~2063); `defer` is affected because it uses +`parseExpr` (~2029). + +## Reproduction + +```sx +#import "modules/std.sx"; + +E :: error { Bad } +val :: () -> (s32, !E) { return 5; } + +f :: () -> !E { + defer { + v, e := val(); // ← error: expected ';' + print("v={}\n", v); + } + return; +} + +main :: () -> s32 { return 0; } +``` + +Also reproduces with no `defer`, as a plain value block: + +```sx +y := { + v, e := val(); // ← error: expected ';' + v +}; +``` + +## Investigation prompt + +The block-expression statement loop (the parser path reached from +`parseExpr` when it hits `{` — see `src/parser.zig`, the block-as-value +parsing around the `parsePrimary`/`parseBlockExpr` path, distinct from +`parseBlock` at ~1931) parses each inner statement but does not run the +destructure-decl detection that `parseStmt` does. Find where +`parseStmt`/`parseBlock` recognizes the `ident (, ident)+ :=` lookahead +and make the block-expression statement loop use the same statement +parser (ideally route block-expression bodies through `parseStmt` so +every statement form — destructure, var/const decl, etc. — is handled +uniformly). + +For `defer` specifically: the simplest aligned fix is to parse a +braced `defer` body with `parseBlock` (like `onfail` does) while keeping +the bare-expression form (`defer expr;`) on `parseExpr`. That removes the +defer-body manifestation even if the general block-expression path is +handled separately. + +Verification: run the repro above — expect it to compile and run +(`exit 0`), with the destructure-bound value usable under an `if !e { … }` +guard (ERR E1.8). Add a regression example under `examples/` once fixed. + +## Status + +OPEN. Orthogonal to ERR E1.7/E1.8 — the spec'd cleanup-body absorbers are +`catch` / `or ` (both parse fine in a `defer` body), so this does +not block the error-handling work. Filed while implementing E1.7. diff --git a/src/ir/lower.zig b/src/ir/lower.zig index c28d786..7888b19 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -788,11 +788,52 @@ pub const Lowering = struct { /// a bare failable call has nowhere to send its error. Reject any failable /// expression-statement that isn't absorbed locally by `catch` / `or value` /// / a destructure binding. (Parser already bans `try`/`raise`/`return`/ - /// `break`/`continue` here.) Stub until slice B. + /// `break`/`continue` here, so the only escape route left for a failable is + /// local absorption.) The check is transitive through nested blocks, `if`, + /// loops, match arms, and `catch` handlers, but stops at a nested closure + /// (its own function boundary). fn checkCleanupBody(self: *Lowering, body: *const Node, kind: []const u8) void { - _ = self; - _ = body; - _ = kind; + self.checkCleanupNode(body, kind); + } + + fn checkCleanupNode(self: *Lowering, node: *const Node, kind: []const u8) void { + switch (node.data) { + .block => |b| for (b.stmts) |s| self.checkCleanupNode(s, kind), + .if_expr => |ie| { + self.cleanupReject(ie.condition, kind); + self.checkCleanupNode(ie.then_branch, kind); + if (ie.else_branch) |eb| self.checkCleanupNode(eb, kind); + }, + .while_expr => |we| { + self.cleanupReject(we.condition, kind); + self.checkCleanupNode(we.body, kind); + }, + .for_expr => |fe| self.checkCleanupNode(fe.body, kind), + .match_expr => |me| for (me.arms) |arm| self.checkCleanupNode(arm.body, kind), + .push_stmt => |ps| self.checkCleanupNode(ps.body, kind), + // A destructure binds the error slot → absorbed (explicit ownership). + .destructure_decl => {}, + .var_decl => |vd| if (vd.value) |v| self.cleanupReject(v, kind), + .const_decl => |cd| self.cleanupReject(cd.value, kind), + .assignment => |a| self.cleanupReject(a.value, kind), + // Closures are their own boundary; the parser-banned control-flow + // exits are handled elsewhere; nested cleanup is independent. + .lambda, .return_stmt, .raise_stmt, .break_expr, .continue_expr, .defer_stmt, .onfail_stmt => {}, + else => self.cleanupReject(node, kind), + } + } + + /// Reject `expr` if it is a bare (un-absorbed) failable in cleanup position. + /// `catch` / `or value` strip the error channel (so `exprIsFailable` is + /// false for them); only a still-failable expression has an unhandled error. + fn cleanupReject(self: *Lowering, expr: *const Node, kind: []const u8) void { + if (expr.data == .catch_expr) { + // The operand is absorbed; the handler body still runs in cleanup. + self.checkCleanupNode(expr.data.catch_expr.body, kind); + return; + } + if (!self.exprIsFailable(expr)) return; + if (self.diagnostics) |d| d.addFmt(.err, expr.span, "a bare failable call in a `{s}` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or `", .{kind}); } /// On Android, the OS loads the .so via a Java-side Activity declared