ERR/E1.7: reject bare failable calls in defer/onfail cleanup bodies
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 <value>`.
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.
This commit is contained in:
28
examples/1048-errors-cleanup-absorption.sx
Normal file
28
examples/1048-errors-cleanup-absorption.sx
Normal file
@@ -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 <value>` 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;
|
||||||
|
}
|
||||||
23
examples/1049-errors-cleanup-absorption-reject.sx
Normal file
23
examples/1049-errors-cleanup-absorption-reject.sx
Normal file
@@ -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 <value>`. 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;
|
||||||
|
}
|
||||||
1
examples/expected/1048-errors-cleanup-absorption.exit
Normal file
1
examples/expected/1048-errors-cleanup-absorption.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
1
examples/expected/1048-errors-cleanup-absorption.stderr
Normal file
1
examples/expected/1048-errors-cleanup-absorption.stderr
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
6
examples/expected/1048-errors-cleanup-absorption.stdout
Normal file
6
examples/expected/1048-errors-cleanup-absorption.stdout
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[error]
|
||||||
|
onfail: x=7 (or)
|
||||||
|
onfail: caught (catch)
|
||||||
|
defer: always
|
||||||
|
[ok]
|
||||||
|
defer: always
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -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 <value>`
|
||||||
|
--> /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 <value>`
|
||||||
|
--> /Users/agra/projects/sx/examples/1049-errors-cleanup-absorption-reject.sx:15:14
|
||||||
|
|
|
||||||
|
15 | onfail { failing(); } // REJECTED: bare failable in an onfail body
|
||||||
|
| ^^^^^^^^^
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
81
issues/0065-block-expr-destructure-decl-parse.md
Normal file
81
issues/0065-block-expr-destructure-decl-parse.md
Normal file
@@ -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 <value>` (both parse fine in a `defer` body), so this does
|
||||||
|
not block the error-handling work. Filed while implementing E1.7.
|
||||||
@@ -788,11 +788,52 @@ pub const Lowering = struct {
|
|||||||
/// a bare failable call has nowhere to send its error. Reject any failable
|
/// a bare failable call has nowhere to send its error. Reject any failable
|
||||||
/// expression-statement that isn't absorbed locally by `catch` / `or value`
|
/// expression-statement that isn't absorbed locally by `catch` / `or value`
|
||||||
/// / a destructure binding. (Parser already bans `try`/`raise`/`return`/
|
/// / 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 {
|
fn checkCleanupBody(self: *Lowering, body: *const Node, kind: []const u8) void {
|
||||||
_ = self;
|
self.checkCleanupNode(body, kind);
|
||||||
_ = 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 <value>`", .{kind});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On Android, the OS loads the .so via a Java-side Activity declared
|
/// On Android, the OS loads the .so via a Java-side Activity declared
|
||||||
|
|||||||
Reference in New Issue
Block a user