refactor: canonical failable syntax (T, !) — remove the bare -> T ! sugar

The trailing-`!`-after-the-value-type spelling (`-> T !`, `-> Tuple(A,B) !`) was a
redundant second way to write a failable return that the parser folded into the
same AST as the parenthesized `(T, !)` / `(A, B, !)` result list. Remove it so
there is ONE canonical spelling: the error channel always rides as the last slot
of the parenthesized list.

- parser: `parseFnReturnType` no longer folds a trailing `!` after a value type —
  it rejects it with a located diagnostic ("a failable return is written `(T, !)`
  … not `T !`"). This one chokepoint covers fn declarations, lambdas, fn-pointer
  types `(A) -> R`, and closure types `Closure(A) -> R`. The error-ONLY `-> !` /
  `-> !ErrSet` form is unaffected (parsed by parseTypeExpr as an error_type_expr).
- migrated every usage to canonical form across library/ + examples/ + issues/ +
  tests/: `-> T !E` → `-> (T, !E)`; the value-carrying `-> Tuple(A, B) !` (which
  FLATTENED to a multi-value failable) → `-> (A, B, !)`, preserving behavior. A
  genuine single-tuple-value failable stays `-> (Tuple(A,B), !)`.
- parser unit tests: the "bare form folds" tests become "bare form is rejected";
  canonical-form parse tests retained.
- docs: specs.md §12 + scattered refs and readme.md updated to the `(T, !)` form.

Behavior-preserving (the bare form was sugar for the same AST). Adversarial review
confirmed: rejection complete across all positions, every canonical form works on
both success/error paths, error-only `-> !` intact, no crashes. Full suite green
(unit tests + 850 corpus examples).
This commit is contained in:
agra
2026-06-27 18:11:20 +03:00
parent b322dcfe61
commit 213cedf0b5
53 changed files with 184 additions and 232 deletions

View File

@@ -903,7 +903,7 @@ VALUE: it is represented internally by a reused tuple TypeId (same ABI), but it
is valid ONLY in a function/closure return position — a parameter, field, or
variable annotation `x: (A, B)` is rejected (use `Tuple(…)` for a tuple value).
A single-value `-> (T, !)` (one value + error) is NOT a multi-return; it is
exactly the failable `-> T !`.
exactly the failable `-> (T, !)`.
```sx
divmod :: (a: i64, b: i64) -> (i64, i64) { return a / b, a % b; }
@@ -1886,7 +1886,7 @@ name :: (params) -> return_type {
A trailing `!` in the return type marks the function **failable** — it adds a
separate error channel alongside the normal returns. The `!` sits **outside**
the tuple: `-> T !` (one value), `-> Tuple(T1, T2) !` (multi value), `-> !`
the tuple: `-> (T, !)` (one value), `-> (T1, T2, !)` (multi value), `-> !`
(void). The `!` is not a wrapper around the value; it is one more return slot.
See [§12 Error Handling](#12-error-handling).
@@ -3154,7 +3154,7 @@ main :: () {
`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).
`-> !` (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`.
@@ -3165,11 +3165,12 @@ See [§12 Error Handling](#12-error-handling).
## 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: `-> Tuple(i32, i64) !` is a function returning
two values *and* an error, with no tuple-in-a-wrapper. The `!` sits **outside**
the `Tuple`.
result type. A `!` as the **last slot** of the parenthesized result list adds one
extra return slot — a `u32` error tag — alongside the normal value slots. This
keeps sx's native multi-return ergonomics: `-> (i32, i64, !)` is a function
returning two values *and* an error, with no tuple-in-a-wrapper. A single-value
failable is `-> (T, !)`; an error-only failable is `-> !`. (There is no bare
`-> T !` spelling — the error channel always rides inside the `(…, !)` list.)
This section is the canonical surface reference. The design rationale,
trade-offs, and implementation breakdown live in `current/PLAN-ERR.md`.
@@ -3177,10 +3178,10 @@ trade-offs, and implementation breakdown live in `current/PLAN-ERR.md`.
### Failable signatures
```sx
parse_digit :: (s: string) -> i32 ! { ... } // one value + error
parse :: (s: string) -> Tuple(i32, i64) ! { ... } // multi-value + error
must_init :: () -> ! { ... } // pure failable, no value
divide :: (a: i32, b: i32) -> i32 !MathErr { ... } // named set
parse_digit :: (s: string) -> (i32, !) { ... } // one value + error
parse :: (s: string) -> (i32, i64, !) { ... } // multi-value + error
must_init :: () -> ! { ... } // pure failable, no value
divide :: (a: i32, b: i32) -> (i32, !MathErr) { ... } // named set
```
The `!` is always the **last** slot. `0` in the error slot means "no error";
@@ -3195,7 +3196,7 @@ Two forms of error set:
ParseErr :: error { BadDigit, Overflow, Empty };
// Inferred set — bare `!` collects whatever tags the body raises.
quick :: () -> i32 ! {
quick :: () -> (i32, !) {
if cond raise error.SomeAdHocTag; // mints into the inferred set
return 0;
}
@@ -3393,14 +3394,14 @@ On success exit (fall-through, `return`, `break` / `continue` without an error)
it is skipped — only `defer` runs.
```sx
make_handle :: () -> Handle ! {
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 ! {
open :: (path: string) -> (Handle, !) {
h := try sys_open(path);
onfail (e) { log.warn("init failed for {}: {}", path, e); sys_close(h); }
...
@@ -3421,9 +3422,9 @@ function, or at top level, is rejected.
- **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: i32) -> i32 ! { ... })`). This keeps
inferred — declare it (`closure((x: i32) -> (i32, !) { ... })`). This keeps
adding a `raise` from silently changing a lambda's type.
- **Program-wide union per shape.** All `Closure(<sig>) -> T !` occurrences
- **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