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:
346
specs.md
346
specs.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user