From 66740fa95b9f16b853eeba998a5889b0d1fb1a06 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 00:40:05 +0300 Subject: [PATCH] ERR/E1.8: reject dropping a failable's error slot on destructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error slot of a value-carrying failable can no longer be silently dropped on a bare destructure. In lowerDestructureDecl, when the RHS is failable (errorChannelOf(ty) != null), the error slot (always the last tuple field) must be bound to a non-`_` name. Reject when it is omitted entirely (fewer names than slots — e.g. `a, c := inc(5)` for `inc: -> (s32,s32,!E)`) or bound to `_` (`v, _ := parse(5)`). The `try` / `catch` / `or value` consumer forms all strip the error channel (their result type is non-failable), so the check never fires on them — only a bare failable destructure is rejected. Value-slot `_` discards stay legal (`a, _, ae := pair()` binds the error). This is the discard-rejection slice of E1.8; the path-sensitive flow-check (value live only where err==null is provable) is a separate follow-up. examples/236-failable-discard-reject.sx covers both rejected shapes (exit 1). Gates: zig build, zig build test, 274/274 examples. --- examples/236-failable-discard-reject.sx | 30 +++++++++++++++++++ src/ir/lower.zig | 17 +++++++++++ .../expected/236-failable-discard-reject.exit | 1 + .../expected/236-failable-discard-reject.txt | 11 +++++++ 4 files changed, 59 insertions(+) create mode 100644 examples/236-failable-discard-reject.sx create mode 100644 tests/expected/236-failable-discard-reject.exit create mode 100644 tests/expected/236-failable-discard-reject.txt diff --git a/examples/236-failable-discard-reject.sx b/examples/236-failable-discard-reject.sx new file mode 100644 index 0000000..c48fe9c --- /dev/null +++ b/examples/236-failable-discard-reject.sx @@ -0,0 +1,30 @@ +// Failable error-slot discard rejection (ERR step E1.8 — discard slice). The +// error slot of a value-carrying failable cannot be dropped on a bare +// destructure: it must be bound (`v, err := …`) and handled, or the failure +// routed through `try` / `catch` / `or value` (all of which strip the error +// channel, so they don't reach this check). Two rejected shapes here: +// (1) omitting the error slot entirely (fewer names than slots), and +// (2) binding it to `_`. +// This file is expected to FAIL compilation (exit 1). +// +// Run: ./zig-out/bin/sx run examples/236-failable-discard-reject.sx + +#import "modules/std.sx"; + +E :: error { Bad, Empty } + +pair :: (n: s32) -> (s32, s32, !E) { + if n < 0 { raise error.Bad; } + return (n, n + 1); +} + +parse :: (n: s32) -> (s32, !E) { + if n < 0 { raise error.Bad; } + return n * 2; +} + +main :: () -> s32 { + a, b := pair(5); // ERROR: error slot omitted (3 slots, 2 names) + v, _ := parse(5); // ERROR: error slot discarded with `_` + return a + b + v; +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 0e8855f..0842d43 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -8313,6 +8313,23 @@ pub const Lowering = struct { const tuple = ti.tuple; if (dd.names.len > tuple.fields.len) return; + // E1.8 (discard rejection): when the RHS is a value-carrying failable, + // the error slot (always the LAST tuple field) cannot be dropped. It is + // dropped when the destructure omits it (fewer names than fields, so the + // trailing error slot is never reached) or binds it to `_`. The `try` / + // `catch` / `or value` consumer forms all strip the error channel (their + // result type is non-failable), so this fires only on a BARE failable + // destructure — exactly the case that would let an error vanish silently. + if (self.errorChannelOf(ty) != null) { + const err_dropped = dd.names.len < tuple.fields.len or + std.mem.eql(u8, dd.names[dd.names.len - 1], "_"); + if (err_dropped) { + if (self.diagnostics) |diags| { + diags.addFmt(.err, dd.value.span, "the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch`", .{}); + } + } + } + // Extract each field and bind to a new variable for (dd.names, 0..) |name, i| { if (std.mem.eql(u8, name, "_")) continue; // discard diff --git a/tests/expected/236-failable-discard-reject.exit b/tests/expected/236-failable-discard-reject.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/236-failable-discard-reject.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/236-failable-discard-reject.txt b/tests/expected/236-failable-discard-reject.txt new file mode 100644 index 0000000..f365ed3 --- /dev/null +++ b/tests/expected/236-failable-discard-reject.txt @@ -0,0 +1,11 @@ +error: the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch` + --> /Users/agra/projects/sx/examples/236-failable-discard-reject.sx:27:13 + | +27 | a, b := pair(5); // ERROR: error slot omitted (3 slots, 2 names) + | ^^^^^^^ + +error: the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch` + --> /Users/agra/projects/sx/examples/236-failable-discard-reject.sx:28:13 + | +28 | v, _ := parse(5); // ERROR: error slot discarded with `_` + | ^^^^^^^^