docs: tuple syntax cutover — Tuple(...) type, .(...) value, channel-outside-Tuple failables

Rewrite specs.md tuple/failable/pack/UFCS/grammar sections to the new
syntax, update readme.md, and refresh stale tuple references in example
header comments. Also fixes two pre-existing doc inaccuracies surfaced in
review: drop the value-discarding `;` in the tuple-return examples, and
correct the §13 function-type grammar production (optional param list +
optional trailing `!` channel). Optional semantics unchanged.

current/CHECKPOINT-LANG.md logs the cutover.
This commit is contained in:
agra
2026-06-25 18:41:22 +03:00
parent 1dfc22794e
commit 40b5fb5f7e
17 changed files with 184 additions and 103 deletions

166
specs.md
View File

@@ -827,35 +827,54 @@ impl Into(MyBuf) for []u8 { ... }
the convert into itself.
### Tuple Types
Anonymous product types with optional field names. Tuples are first-class values — they can be stored in variables, passed to functions, and returned. Tuples also support **spread** (`..tuple` / `(..tuple)`) and **field projection** (`tuple.field` across all elements) — see "Variadic Heterogeneous Type Packs".
Anonymous product types with optional field names. Tuples are first-class values — they can be stored in variables, passed to functions, and returned. A **named tuple** `Tuple(x: A, y: B)` is sx's anonymous *structural* record — it carries field names but has no nominal identity (distinct from a `struct`, which is nominal). Tuples also support **spread** (`..tuple` / `.(..tuple)`) and **field projection** (`tuple.field` across all elements) — see "Variadic Heterogeneous Type Packs".
The tuple TYPE is always written `Tuple(...)`; a tuple VALUE is always written
`.(...)` (mirroring how a struct type `Point` pairs with a value literal
`Point.{...}` / `.{...}`). Bare parentheses `(...)` are **grouping only,
everywhere** — a comma inside bare parens is a hard error with a migration hint.
#### Construction
```sx
pair := (40, 2); // positional tuple: (i64, i64)
named := (x: 10, y: 20); // named tuple: (x: i64, y: i64)
single := (42,); // 1-tuple (trailing comma in value position)
zeroed : (i32, i32) = ---; // zero-initialized tuple
pair := .(40, 2); // positional tuple value: Tuple(i64, i64)
named := .(x = 10, y = 20); // named tuple value: Tuple(x: i64, y: i64)
single := .(42); // 1-tuple value
empty := .(); // empty tuple value
zeroed : Tuple(i32, i32) = ---; // zero-initialized tuple
// Explicitly typed value (like `Point.{...}`):
p := Tuple(i64, i64).(40, 2);
n := Tuple(x: i64, y: i64).(x = 10, y = 20);
```
Note: In value position, `(expr)` without a comma is a grouping expression, not a tuple. Use `(expr,)` for a 1-tuple value.
A named tuple value uses `=` for its fields (`.(x = a, y = b)`); a named tuple
type keeps `:` (`Tuple(x: A, y: B)`).
#### Type Syntax
In type position, parentheses mirror value position: `(T)` (a single element, no
trailing comma) is a **grouping** that resolves to the inner type `T`, while
`(T,)` (trailing comma) is a 1-tuple. `(A, B)` is a 2-tuple. The `->` arrow
disambiguates function types from grouped/tuple types:
The tuple type is `Tuple(...)`:
```sx
(i64) // grouping: resolves to i64 (NOT a tuple)
(i64,) // tuple type with one field
(i64, i64) // tuple type with two fields
Tuple(i64) // 1-tuple type
Tuple(i64, i64) // 2-tuple type
Tuple(x: i64, y: i64) // named tuple type
Tuple() // empty tuple type
Tuple(..F(Ts)) // pack-spread tuple type (see Variadic Heterogeneous Type Packs)
```
`Tuple(...)` is strictly a **type** in every position — including `size_of(Tuple(...))`
and `type_info(...)` arguments. A tuple **value** comes only from `.(...)` (anonymous)
or `Tuple(...).(...)` (explicitly typed); a bare `Tuple(1, 2)` (non-type elements) is
rejected as a tuple type with non-type elements.
Bare parentheses are grouping and never a tuple:
```sx
(i64) // grouping: resolves to i64
(i64) -> i64 // function type: takes i64, returns i64
(i64, i64) -> i64 // function type: takes two i64, returns i64
?(?i64) // grouping → a genuine nested optional
[1](Closure(i64,i64) -> i64) // grouping → array of one closure
```
Grouping lets a closure/optional/function type be parenthesized for readability
without silently becoming a 1-tuple. A named single element `(x: T)` stays a
(named) tuple.
Grouping lets a closure/optional/function type be parenthesized for readability.
Function types `(A, B) -> R`, parameter lists, lambdas, and `match` bindings keep
using bare parens — they are unaffected by the tuple grammar.
#### Field Access
```sx
@@ -867,49 +886,49 @@ named.0; // 10 — numeric index also works on named tuples
#### As Return Type
```sx
swap :: (a: i64, b: i64) -> (i64, i64) { (b, a); }
wrap :: (x: i64) -> (i64,) { (x,); } // 1-tuple return needs the trailing comma
swap :: (a: i64, b: i64) -> Tuple(i64, i64) { .(b, a) }
wrap :: (x: i64) -> Tuple(i64) { .(x) } // 1-tuple return
s := swap(1, 2); // s.0 = 2, s.1 = 1
t := wrap(42); // t.0 = 42
```
#### Representation
Tuples are represented as anonymous LLVM struct types (same layout as named structs). A tuple `(i64, i64)` has LLVM type `{ i64, i64 }`.
Tuples are represented as anonymous LLVM struct types (same layout as named structs). A tuple `Tuple(i64, i64)` has LLVM type `{ i64, i64 }`.
#### Tuple Operators
**Equality and inequality** — element-wise comparison, both sides must have the same field count:
```sx
(1, 2) == (1, 2) // true
(1, 2) != (1, 3) // true
.(1, 2) == .(1, 2) // true
.(1, 2) != .(1, 3) // true
```
**Concatenation** (`+`) — creates a new tuple with fields from both sides:
```sx
c := (1, 2) + (3, 4); // c : (i64, i64, i64, i64)
c.0; // 1
c.3; // 4
c := .(1, 2) + .(3, 4); // c : Tuple(i64, i64, i64, i64)
c.0; // 1
c.3; // 4
```
**Repetition** (`*`) — repeats a tuple N times (N must be a compile-time integer literal):
```sx
r := (1, 2) * 3; // r : (i64, i64, i64, i64, i64, i64)
r.0; // 1
r.5; // 2
r := .(1, 2) * 3; // r : Tuple(i64, i64, i64, i64, i64, i64)
r.0; // 1
r.5; // 2
```
**Lexicographic comparison** (`<`, `<=`, `>`, `>=`) — compares element-by-element left to right:
```sx
(1, 2) < (1, 3) // true (first fields equal, 2 < 3)
(2, 0) > (1, 9) // true (2 > 1, rest ignored)
(1, 2) <= (1, 2) // true (all equal, <= allows tie)
.(1, 2) < .(1, 3) // true (first fields equal, 2 < 3)
.(2, 0) > .(1, 9) // true (2 > 1, rest ignored)
.(1, 2) <= .(1, 2) // true (all equal, <= allows tie)
```
**Membership** (`in`) — checks if a value exists in a tuple:
```sx
3 in (1, 2, 3) // true
5 in (1, 2, 3) // false
3 in .(1, 2, 3) // true
5 in .(1, 2, 3) // false
```
### Array Types
@@ -1470,8 +1489,8 @@ may do, regardless of the concrete arg types at any particular call site.
| Comptime unroll (element + index) | `inline for xs, 0.. (x, i) { ... }` | multi-iterable parity with the runtime `for`: position 0 drives the count, a trailing open range pairs the cursor |
| Projection | `xs.field` | see "Pack projection" |
| Spread → call args | `..xs` / `..xs.field` | expands to N positional args |
| Spread → tuple value | `(..xs)` / `(..xs.field)` | materializes a tuple |
| Spread → tuple type | `(..F(Ts))` / `(..F(Ts.Arg))` | tuple type with per-element type application |
| Spread → tuple value | `.(..xs)` / `.(..xs.field)` | materializes a tuple |
| Spread → tuple type | `Tuple(..F(Ts))` / `Tuple(..F(Ts.Arg))` | tuple type with per-element type application |
| Spread → callable sig | `Closure(..Ts) -> R` / `Closure(..Ts.Arg) -> R` | positional params of the callable |
#### Pack projection
@@ -1484,7 +1503,7 @@ Resolution is **position-driven** (no cross-namespace shadowing):
type-arg `T`, so `..xs.T` is the pack of element value-types.
- In **value** position, `xs.field` looks `field` up in the constraint's
**runtime-field** namespace and yields a *tuple* of the projected values
(e.g. `xs.value` → `(xs[0].value, xs[1].value, ...)`).
(e.g. `xs.value` → `.(xs[0].value, xs[1].value, ...)`).
A protocol that declares a type-arg and a runtime field with the **same name**
compiles, but emits a soft warning at the protocol declaration (the human is
@@ -1499,13 +1518,13 @@ tuple rather than a pack:
- `tuple.field` projects `field` out of every element (when all elements have a
same-named field), returning a tuple of the projected values.
This lets a pack be materialized once (`stored := (..xs)`) and later re-spread
This lets a pack be materialized once (`stored := .(..xs)`) and later re-spread
(`f(..stored)`) or re-projected (`stored.value`).
#### Pack of zero (N = 0)
`xs.len == 0` is valid: `inline for` over an empty range doesn't execute, spreads
are no-ops, and `(..xs)` is the empty tuple. A library built on packs (e.g.
are no-ops, and `.(..xs)` is the empty tuple. A library built on packs (e.g.
`map`) must handle N=0 — typically by producing a constant result that never
changes.
@@ -1515,10 +1534,10 @@ Because a pack has no runtime representation, using the **bare pack name** where
a runtime value is required is a compile error with a context-tailored
suggestion:
- storing/binding it (`x := xs;`, `self.f = xs;`) → materialize a tuple `(..xs)`;
- storing/binding it (`x := xs;`, `self.f = xs;`) → materialize a tuple `.(..xs)`;
- passing it to a runtime call (`f(xs)`) → declare the parameter as a *slice*
variadic `..xs: []P` (a runtime slice) instead of a pack `..xs: P`;
- returning it (`return xs;`) → return a tuple `(..xs)` (and make the return
- returning it (`return xs;`) → return a tuple `.(..xs)` (and make the return
type that tuple);
- iterating it (`for xs (x)`, `xs[runtime_i]`) → `inline for xs (x)` (or
`inline for 0..xs.len (i)` for the index) for a comptime unroll, or take
@@ -1533,8 +1552,8 @@ place.
#### Storage and protocol conformance
To **store** a pack, materialize a tuple: a pack-shaped struct field is
tuple-typed, `sources: (..ValueListenable(Ts))`, assigned `self.sources =
(..sources)`. To **return** a struct as a protocol value, `xx` requires an
tuple-typed, `sources: Tuple(..ValueListenable(Ts))`, assigned `self.sources =
.(..sources)`. To **return** a struct as a protocol value, `xx` requires an
explicit impl (protocol erasure is impl-driven, not structural) — e.g.
`impl ValueListenable($R) for Combined($R, ..$Ts) { ... }`.
@@ -1542,7 +1561,7 @@ explicit impl (protocol erasure is impl-driven, not structural) — e.g.
```sx
Combined :: struct($R: Type, ..$Ts: []Type) {
sources: (..ValueListenable(Ts)); // pack-spread in tuple type position
sources: Tuple(..ValueListenable(Ts)); // pack-spread in tuple type position
mapper: Closure(..Ts) -> $R; // pack-spread in callable sig
value: $R;
own_allocator: Allocator;
@@ -1559,7 +1578,7 @@ map :: (mapper: Closure(..sources.T) -> $R, ..sources: ValueListenable)
c := context.allocator.alloc(Combined($R, ..sources.T));
c.own_allocator = context.allocator;
c.mapper = mapper;
c.sources = (..sources); // pack-to-tuple materialization
c.sources = .(..sources); // pack-to-tuple materialization
inline for 0..sources.len (i) { // comptime unroll over the pack
sources[i].addListener((_) => c.recompute());
}
@@ -1827,15 +1846,16 @@ name :: (params) -> return_type {
```
- Parameters: `name: type` separated by commas
- Return type: `-> type` (omit for void). A multi-value return is a tuple: `-> (T1, T2)`.
- Return type: `-> type` (omit for void). A multi-value return is a tuple: `-> Tuple(T1, T2)`.
- Body: a block whose **value** is its last statement when that statement is a
trailing expression with **no** `;` (see [Block values](#block-values)). That
value is the implicit return; an explicit `return` works too.
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).
separate error channel alongside the normal returns. The `!` sits **outside**
the tuple: `-> T !` (one value), `-> Tuple(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).
Examples:
```sx
@@ -2462,8 +2482,8 @@ When a tuple is used as the receiver of a UFCS call, its elements are unpacked a
num_add :: (a: i64, b: i64) -> i64 { a + b; }
add :: ufcs num_add;
(40, 2).add(); // splats to num_add(40, 2) → 42
(40,).add(2); // partial: num_add(40, 2) → 42
.(40, 2).add(); // splats to num_add(40, 2) → 42
.(40).add(2); // partial: num_add(40, 2) → 42
40.add(2); // normal UFCS: num_add(40, 2) → 42
```
@@ -2472,8 +2492,8 @@ With more arguments:
compute :: (a: i64, b: i64, c: i64, d: i64) -> i64 { a + b * c - d; }
calc :: ufcs compute;
(1, 2, 3, 4).calc(); // full splat → compute(1, 2, 3, 4)
(1, 2).calc(3, 4); // partial splat → compute(1, 2, 3, 4)
.(1, 2, 3, 4).calc(); // full splat → compute(1, 2, 3, 4)
.(1, 2).calc(3, 4); // partial splat → compute(1, 2, 3, 4)
1.calc(2, 3, 4); // normal UFCS → compute(1, 2, 3, 4)
```
@@ -3101,7 +3121,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`.
@@ -3114,8 +3134,9 @@ See [§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: `-> (i32, i64, !)` is a function returning two
values *and* an error, with no tuple-in-a-wrapper.
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`.
This section is the canonical surface reference. The design rationale,
trade-offs, and implementation breakdown live in `current/PLAN-ERR.md`.
@@ -3123,10 +3144,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) -> (i32, i64, !) { ... } // multi-value + error
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
divide :: (a: i32, b: i32) -> i32 !MathErr { ... } // named set
```
The `!` is always the **last** slot. `0` in the error slot means "no error";
@@ -3141,7 +3162,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;
}
@@ -3228,7 +3249,7 @@ 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
.(0, 0) // tuple body for a multi-value failable
};
v := parse(s) catch (e) == { // match-body form
@@ -3264,7 +3285,7 @@ the RHS shape decides the result:
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)
v, n := parse_pair(s) or .(0, 0); // tuple terminator (multi-value)
```
A **void** failable (`-> !`) rejects a plain-value RHS (no success type to
@@ -3339,14 +3360,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); }
...
@@ -3367,9 +3388,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
@@ -3427,8 +3448,9 @@ const_decl = IDENT '::' expr ';'
var_decl = IDENT ':=' expr ';'
| IDENT ':' type '=' expr ';'
| IDENT ':' type ';'
fn_decl = IDENT '::' '(' params? ')' ('->' type)? block
fn_decl = IDENT '::' '(' params? ')' ('->' ret_type)? block
| IDENT '::' block
ret_type = type ('!' IDENT?)? // trailing `!` = failable; channel outside any Tuple
enum_decl = IDENT '::' 'enum' '{' (IDENT ';')* '}'
struct_decl = IDENT '::' 'struct' '{' struct_member* '}'
struct_member = field_group | '#using' IDENT ';'
@@ -3459,12 +3481,16 @@ binary = catch_expr (binop catch_expr)* // binop includes `or` (fall
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 '}')*
postfix = primary ('(' args? ')' | '.' IDENT | '.{' field_init_list '}'
| '.(' tuple_elem_list? ')')*
primary = INT | HEX_INT | BIN_INT | FLOAT | STRING | BOOL | IDENT | '---'
| '.' IDENT | '.' '{' field_init_list '}'
| '(' expr ')' | block | '#run' expr
| '.(' tuple_elem_list? ')' // tuple value (anonymous): .(a, b) / .(x = a) / .() / .(..xs)
| '(' expr ')' | block | '#run' expr // bare parens = grouping ONLY
field_init_list = field_init (',' field_init)* ','?
field_init = IDENT '=' expr | IDENT | expr
tuple_elem_list = tuple_elem (',' tuple_elem)* ','?
tuple_elem = IDENT '=' expr | '..' expr | expr
if_expr = 'if' expr 'then' expr ('else' expr)?
| 'if' expr block ('else' block)?
match_expr = 'if' expr '==' '{' case_arm* else_arm? '}'
@@ -3475,8 +3501,12 @@ lambda = '(' params? ')' ('->' type)? '=>' expr
args = expr (',' expr)* ','?
type = '$' IDENT | 'i32' | 'f32' | 'f64' | 'bool' | 'string'
| 'Any' | 'Type' | '..' type | '[' expr ']' type | IDENT
| '(' type (',' type)* ',' '!' IDENT? ')' // value-carrying failable
| 'Tuple' '(' tuple_type_list? ')' // tuple type: Tuple(A, B) / Tuple(x: A) / Tuple() / Tuple(..F(Ts))
| '(' type ')' // grouping (bare parens never form a tuple)
| '(' (type (',' type)*)? ')' '->' type ('!' IDENT?)? // function type (params optional; optional error channel)
| '!' IDENT? // pure failable (`!` / `!Named`)
tuple_type_list = tuple_type_elem (',' tuple_type_elem)* ','?
tuple_type_elem = IDENT ':' type | '..' type | type
```
---