diff --git a/specs.md b/specs.md index 4a74148..54944da 100644 --- a/specs.md +++ b/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() -> (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.