ERR/E5.3: specs.md §12 Error Handling (fold locked design into spec)

Add a top-level §12 Error Handling distilling the locked error design +
surface syntax: failable signatures (-> (T,!) / -> ! / multi-value),
named `error { }` + inferred `!` sets, raise/try/catch/or/onfail, the
path-marker rule, set widening, error.X as a value, discard rejection +
flow-check, closures-with-!, return traces, and the u32-last-slot ABI.

Renumber Grammar §12→§13 and Open Questions §13→§14 (insert sits after
§10.5, so §3/§10.5 — the only section numbers referenced from CLAUDE.md
— stay valid). Cross-link the `!` channel from the Keywords list,
Operator Precedence, Function Definition, and §11 Program Structure;
extend the §13 grammar with error_decl, raise_stmt, onfail_stmt, a
catch_expr tier, `try` in unary, and failable type productions.

Pure docs; no compiler change. Gates: build, test, run_examples (293/0).
This commit is contained in:
agra
2026-06-01 18:19:26 +03:00
parent 49dc622234
commit e86e41b719

346
specs.md
View File

@@ -45,10 +45,12 @@ GLSL;
```
### Keywords
`if`, `else`, `then`, `while`, `for`, `break`, `continue`, `true`, `false`, `enum`, `struct`, `union`, `case`, `return`, `defer`, `push`, `ufcs`, `in`, `xx`, `and`, `or`
`if`, `else`, `then`, `while`, `for`, `break`, `continue`, `true`, `false`, `enum`, `struct`, `union`, `case`, `return`, `defer`, `push`, `ufcs`, `in`, `xx`, `and`, `or`, `raise`, `try`, `catch`, `onfail`, `error`
> Note: `enum` is used for both payload-less and payload-bearing sum types (tagged unions). `union` is reserved for C-style untagged unions (memory overlays).
> Note: `raise`, `try`, `catch`, `onfail`, and `error` are the error-handling keywords. `or` is reused as the failable-fallback / chain operator. See [§12 Error Handling](#12-error-handling).
### Operators
| Operator | Meaning |
@@ -1227,10 +1229,15 @@ name :: (params) -> return_type {
```
- Parameters: `name: type` separated by commas
- Return type: `-> type` (omit for void)
- Return type: `-> type` (omit for void). A multi-value return is a tuple: `-> (T1, T2)`.
- Body: block of statements; last expression is the implicit return value
- No `return` keyword needed (last expression = return value)
A trailing `!` in the return type marks the function **failable** — it adds a
separate error channel alongside the normal returns (`-> (T, !)`, `-> !`,
`-> (T1, T2, !)`). The `!` is not a wrapper around the value; it is one more
return slot. See [§12 Error Handling](#12-error-handling).
Examples:
```sx
compute :: (x: s32) -> s32 {
@@ -1421,7 +1428,12 @@ Everything in `sx` is expression-oriented where possible.
| 4 | `^` | bitwise XOR |
| 3 | `\|` | bitwise OR |
| 2 | `and` | logical AND (short-circuit) |
| 1 (lowest) | `or` | logical OR (short-circuit) |
| 1 (lowest) | `or` | logical OR (short-circuit) / failable fallback (§12) |
`try` is a unary prefix in the same tier as `xx` / `@` / `-` / `!` / `~`
(tighter than every binary operator, including `or`); `catch` is a postfix
attached to a failable expression. So `try foo() or try boo()` parses as
`(try foo()) or (try boo())`. See [§12 Error Handling](#12-error-handling).
### Arithmetic
Standard infix: `+`, `-`, `*`, `/` with usual precedence (`*`/`/` before `+`/`-`).
@@ -2274,18 +2286,327 @@ main :: () {
}
```
`main` takes no arguments and returns void. The process exit code is 0 unless otherwise specified.
`main` takes no arguments. Its return type may be any of: void (`()`,
`-> ()`, `-> void`, or no annotation), an integer type (POSIX exit code),
`-> !` (pure failable), or `-> (int_type, !)` (value-carrying failable).
The exit code is `0` for void / `-> !` success, the integer return
truncated to `u8` otherwise. An error that escapes a failable `main`
prints the unhandled-error header + return trace to stderr and exits `1`.
See [§12 Error Handling](#12-error-handling).
---
## 12. Grammar (informal)
## 12. Error Handling
sx models recoverable errors as a **separate return channel**, not a wrapped
result type. A trailing `!` in a function's return type adds one extra return
slot — a `u32` error tag — alongside the normal value slots. This keeps sx's
native multi-return ergonomics: `-> (s32, s64, !)` is a function returning two
values *and* an error, with no tuple-in-a-wrapper.
This section is the canonical surface reference. The design rationale,
trade-offs, and implementation breakdown live in `current/PLAN-ERR.md`.
### Failable signatures
```sx
parse_digit :: (s: string) -> (s32, !) { ... } // one value + error
parse :: (s: string) -> (s32, s64, !) { ... } // multi-value + error
must_init :: () -> ! { ... } // pure failable, no value
divide :: (a: s32, b: s32) -> (s32, !MathErr) { ... } // named set
```
The `!` is always the **last** slot. `0` in the error slot means "no error";
non-zero is an interned global tag id.
### Error sets
Two forms of error set:
```sx
// Named set — declared once, referenced by name from signatures.
ParseErr :: error { BadDigit, Overflow, Empty };
// Inferred set — bare `!` collects whatever tags the body raises.
quick :: () -> (s32, !) {
if cond raise error.SomeAdHocTag; // mints into the inferred set
return 0;
}
```
- An `error { ... }` set is an opaque type; tags are referenced as `error.X`.
- A declared empty set `error { }` is **rejected**.
- **Inferred sets are whole-program.** The compiler runs an SCC fix-point pass
over the entire call graph to converge each bare-`!` function's set
(matching sx's whole-program compilation model). Callers see the converged
union, not bare `!`.
- A top-level (non-`main`) function declared `!` that never errors warns
("declared `!` but never errors — drop the `!`"). Closures and
function-type slots with an empty `!` do **not** warn.
**Tag identity is the name, globally (Zig-style).** Two sets that both list
`NotFound` reference the *same* tag id; `if e == error.NotFound` matches every
`NotFound` regardless of which set raised it. Use distinct names
(`FsNotFound` / `HttpNotFound`) when subsystems must be distinguishable.
### `raise`
Statement form. Terminates the immediately enclosing failable function (like
`return`), setting the error slot; value slots are left undefined.
```sx
if bad raise error.BadDigit; // literal tag
v := foo() catch e {
if e == error.Specific return default;
raise e; // variable tag — re-raise
};
```
`raise EXPR` accepts any tag-typed expression. EXPR's set must be ⊆ the
enclosing function's error set (for a named set), or is absorbed into the
inferred set (for bare `!`). `raise` inside an inline expression is rejected
(`v := if cond raise error.X else 0;` — compile error). A closure body is its
own function boundary: `raise` inside a closure terminates the *closure*.
### `try`
Expression form. `try X` requires `X` to be failable; on `X`'s failure it
routes control to the nearest enclosing fallback target:
- inside an `or` chain → the next `or` operand;
- otherwise → the function's error return (propagation, like Zig's `try`).
```sx
v := try parse_digit(s); // propagate on failure
v2, n := try parse(s); // multi-value
try must_init(); // statement form, discard values
v3 := try foo() or try bar(); // chain: foo fails → try bar
return try transform(try parse(s)); // nests in any value position
```
`try` works in any value-producing position (argument, struct/array literal,
`if`-condition); evaluation is left-to-right and short-circuits on the first
failure, so no partial aggregate is ever built. `try`'s body never binds the
tag — use `catch` for that.
### `catch`
Expression form. Handles the error inline. The binding is a **bare name, no
parens** (`catch e`), and is **optional**. Four shapes, disambiguated by the
token after `catch`:
| Form | Binding | Body |
|---|---|---|
| `catch { ... }` | none (tag ignored) | block — braces required |
| `catch e { ... }` | `e` | block |
| `catch e EXPR` | `e` | bare expression (no braces) |
| `catch e == { case ... }` | `e` | match over `e` (sugar for `{ if e == { ... } }`) |
```sx
v := parse_digit(s) catch e {
log.warn("bad input: {}", e);
return default; // noreturn body
};
v := parse_digit(s) catch e compute_fallback(e); // value-producing body
v, n := parse(s) catch e {
log.warn("parse failed: {}", e);
(0, 0) // tuple body for a multi-value failable
};
v := parse(s) catch e == { // match-body form
case .Empty: 0;
case .BadDigit: -1;
else: raise e;
};
v := (try foo() or try boo()) catch e { return 0; }; // catch over an `or` chain
```
**Body type rule.** The body (block-as-expression) must produce the failable's
success tuple type, or be `noreturn` (the `noreturn` arm subsumes `return` /
`raise` / `break` / `continue` / `unreachable` / noreturn calls). For a
multi-value failable the body must produce a tuple of matching arity and
element types. A non-diverging body that produces no value is a compile error.
### `or` (fallback / chain)
Expression form (the same operator as optional-unwrap). LHS must be failable;
the RHS shape decides the result:
- **plain value of the success type** — terminate; the chain becomes
non-failable; on LHS failure the result is the RHS value (LHS tag discarded);
- **`try EXPR`** — chain; on LHS failure, attempt the RHS (its `try` defines
the next fallback target);
- **bare failable** — allowed only when its error path hits a marker
downstream (see the path-marker rule).
`or` is **left-associative**, evaluated left-to-right with short-circuit.
```sx
v := parse_digit(s) or 0; // value terminator → non-failable
v := try foo() or try boo(); // chain, propagate if both fail
v := foo() or boo() or 0; // bare operands, 0 absorbs all
v, n := parse_pair(s) or (0, 0); // tuple terminator (multi-value)
```
A **void** failable (`-> !`) rejects a plain-value RHS (no success type to
fall back to); `must_init() or must_other()` (chain) and `must_init() catch {}`
(absorb) are the legal forms.
### Path-marker rule
A failable expression `X` may appear **bare** (no `try`) iff its error path
passes through at least one explicit marker before reaching the function
boundary. The markers are: a `try` keyword, a `catch` handler, an `or` value
terminator, or a destructure binding (`v, err := X`). Otherwise `try` (or one
of the other markers directly on `X`) is required.
```sx
a := parse(s) or 0; // OK — terminator on the path
a := parse(s) catch e {...}; // OK — catch marks
v, err := failable(); // OK — destructure marks
a := try foo() or try boo(); // OK — each try marks its own exit
a := foo() or boo(); // ERROR — no marker on the way to the function
a := foo(); // ERROR — bare, no marker downstream
```
### Set widening
Widening is checked **only at subexpressions whose failure escapes to the
function** (propagation). For a **named** caller `!CallerErr`, the escape set
must be ⊆ `CallerErr` (no auto-widening). For an **inferred** caller `!`, the
escape set is absorbed into the converged union. Failures absorbed by a
downstream chain operand / `catch` / terminator / destructure don't contribute.
### `error.X` as a value
`error.X` is a first-class value outside `raise`:
```sx
default_err : ParseErr = error.BadDigit; // typed as the named set
tag_id : u32 = error.BadDigit; // untyped context → global tag id
if e == error.Empty { ... } // compare against a literal
```
- Against a **named-set** destination, `error.X` is valid only if `X ∈` the set
(typo-checked). A comparison to a literal not in the set is a compile error
(it could never be true). For **inferred** sets this check is skipped.
- An error-set value compares (`==` / `!=`) only with an `error.X` literal or
another error-set value — **never a raw integer** (`e == 42` is rejected).
Coerce explicitly (`(xx e) == id`) to use the raw id.
- **Interpolation renders the tag name.** `{}` on an error-set value prints the
tag name (`BadDigit`), never the raw id, via a tag-name table that is
**always linked, even in release builds**.
### Discard rejection & flow-check
Dropping the error slot is a compile error:
```sx
v, _ := failable(); // ERROR: the error slot cannot be dropped — handle it
```
Value slots may be discarded (`_, n := parse(s) catch e { return; }`). The
statement form `try foo();` is the explicit "propagate, use no value." On a
value-carrying failable, the value slot is live only where the compiler can
prove the error slot is null (path-sensitive flow-check).
### `onfail` (error-path cleanup)
Statement form. Block-rooted (Zig-aligned): legal in any block inside a
failable function. **Fires when an error propagates out of its enclosing
block**, regardless of whether an outer `catch` / terminator later absorbs it.
On success exit (fall-through, `return`, `break` / `continue` without an error)
it is skipped — only `defer` runs.
```sx
make_handle :: () -> (Handle, !) {
h := try open();
onfail close(h); // close ONLY on a subsequent failure
try configure(h); // fails → onfail runs → close(h)
return h; // success → onfail skipped; caller owns h
}
open :: (path: string) -> (Handle, !) {
h := try sys_open(path);
onfail e { log.warn("init failed for {}: {}", path, e); sys_close(h); }
...
}
```
**Ordering with `defer`.** Both run in reverse declaration order, interleaved.
On block-error exit both kinds run (newest-first); on block-success exit only
`defer`s run.
**Restrictions.** `raise` / `try` / `return` / `break` / `continue` are
rejected inside an `onfail` (and a `defer`) body — a cleanup body has no
control-transfer target. A failable call in cleanup must be absorbed locally
(`close(h) catch {};` or `flush(buf) or 0`). `onfail` outside a failable
function, or at top level, is rejected.
### Closures with `!`
- **Explicit annotation required.** A closure literal's value type is inferred
as today, but if its body raises or `try`-escapes, the `!` channel is **not**
inferred — declare it (`closure((x: s32) -> (s32, !) { ... })`). This keeps
adding a `raise` from silently changing a lambda's type.
- **Program-wide union per shape.** All `Closure(<sig>) -> (T, !)` occurrences
with the same signature share one inferred-set node; the SCC pass unions
every closure flowing into any matching slot.
- **FFI boundary.** A failable closure cannot be assigned to a non-failable
function-type slot — foreign code can't observe the error channel. Wrap and
absorb the error instead.
- **Non-failable → failable widening is allowed** (∅ ⊆ any set). A
non-failable closure assigned to a failable slot contributes ∅; a single
coalesced adapter thunk `(v) → (v, 0)` reconciles the 1-slot vs 2-slot ABI at
the crossing point.
### Return traces
A failable that reaches the function boundary unhandled carries a **return
trace** — the chain of `raise` / `try` sites the error passed through.
- **Storage:** a thread-local fixed-cap ring (32 frames; newest survive on
overflow). `raise` and each failing `try` push a frame; every absorbing site
(`catch`, a succeeding chain attempt, a value terminator, a destructure)
clears the buffer.
- **Resolution is in-process — no DWARF, no OS symbolizer.** A runtime frame is
a pointer to a compile-time-interned `Frame { file, line, col, func, line_text }`
stamped at the push site; the formatter reads it directly (deterministic,
identical across OS/target, works under the JIT and a signed iOS `.app`). A
comptime frame is `(func_id, ir_offset)` resolved via the interpreter's
in-memory IR/source tables.
- **Mode.** On by default in debug; release no-ops the push points
(opt back in with `--release-traces`). **Comptime (`#run`) is always traced.**
- **Formatting** lives in `library/modules/trace.sx` (`trace.print_current()`),
rendering `func at file:line:col` per frame plus the source line and a `^`
caret. DWARF line-info is still emitted (debug, strippable) so `lldb` / `gdb`
can step sx source — that is a debugger artifact, separate from trace
resolution.
### ABI
The error slot is a `u32`, always the last slot of the multi-return tuple, in
both register- and stack-return conventions. `0` = no error; non-zero = an
interned global tag id (pool capacity ~4.3 billion; fixed 32-bit, no dynamic
widening across builds). Errors are a pure value channel — no coupling to the
implicit `context`.
---
## 13. Grammar (informal)
```
program = top_level*
top_level = decl | import_decl
import_decl = '#import' STRING ';'
| IDENT '::' '#import' STRING ';'
decl = const_decl | var_decl | fn_decl | enum_decl | struct_decl
decl = const_decl | var_decl | fn_decl | enum_decl | struct_decl | error_decl
error_decl = IDENT '::' 'error' '{' IDENT (',' IDENT)* ','? '}' ';'
const_decl = IDENT '::' expr ';'
| IDENT ':' type ':' expr ';'
var_decl = IDENT ':=' expr ';'
@@ -2301,10 +2622,12 @@ params = param (',' param)* ','?
param = IDENT ':' type ('=' expr)?
block = '{' stmt* '}'
stmt = decl | assignment ';' | multi_assign ';' | return_stmt | defer_stmt | insert_stmt
| push_stmt | break_stmt | continue_stmt | expr ';'
| push_stmt | break_stmt | continue_stmt | raise_stmt | onfail_stmt | expr ';'
return_stmt = 'return' expr? ';'
break_stmt = 'break' ';'
continue_stmt = 'continue' ';'
raise_stmt = 'raise' expr ';'
onfail_stmt = 'onfail' IDENT? block
defer_stmt = 'defer' expr ';'
insert_stmt = '#insert' expr ';'
push_stmt = 'push' expr block
@@ -2314,8 +2637,9 @@ lvalue = IDENT | postfix '.' IDENT
expr = if_expr | match_expr | while_expr | for_expr | lambda | binary
while_expr = 'while' expr block
for_expr = 'for' expr ':' '(' IDENT [',' IDENT] ')' block
binary = unary (binop unary)*
unary = ('-' | '!' | 'xx' | 'cast' '(' type ')') postfix
binary = catch_expr (binop catch_expr)* // binop includes `or` (fallback / chain)
catch_expr = unary ('catch' IDENT? (block | '==' '{' case_arm* else_arm? '}' | unary))?
unary = ('-' | '!' | 'xx' | 'try' | 'cast' '(' type ')') postfix
| postfix
postfix = primary ('(' args? ')' | '.' IDENT | '.{' field_init_list '}')*
primary = INT | HEX_INT | BIN_INT | FLOAT | STRING | BOOL | IDENT | '---'
@@ -2333,11 +2657,13 @@ lambda = '(' params? ')' ('->' type)? '=>' expr
args = expr (',' expr)* ','?
type = '$' IDENT | 's32' | 'f32' | 'f64' | 'bool' | 'string'
| 'Any' | 'Type' | '..' type | '[' expr ']' type | IDENT
| '(' type (',' type)* ',' '!' IDENT? ')' // value-carrying failable
| '!' IDENT? // pure failable (`!` / `!Named`)
```
---
## 13. Open Questions
## 14. Open Questions
- **Nested functions**: Can functions be defined inside other functions?
- **Operator overloading**: Not shown — presumably no.