feat: parenthesized type grouping — (T) groups, (T,) is a 1-tuple (issue 0177)

In type position, parentheses now mirror value position: (T) (a single
unnamed element, no trailing comma) is a GROUPING that resolves to the
inner type; (T,) is a 1-tuple; (A, B) a 2-tuple; named (x: T) and spread
(..Ts) stay tuples; (...) -> R stays a function type. This lets a
closure/optional/function type be parenthesized for readability without
silently becoming a 1-tuple:
  [1](Closure(i64,i64) -> i64)   // array of closures (issue 0177) -> 7
  ?(?i64)                        // genuine nested optional (issue 0165 intent)

Parser: src/parser.zig returns the inner node for a single unnamed
non-spread no-trailing-comma parenthesized type. formatTypeName (both
generic.zig diagnostics + types.zig reflection) now render a 1-tuple as
(T,) so the spelling is unambiguous and diagnostics are self-consistent.
The 0165 coerce/stmt note reworded accordingly.

specs.md §Type Syntax updated; basic/0036 wrap return -> (i64,); obsolete
diagnostic 1195 removed (?(?i64) now compiles); regression
examples/types/0201-types-parenthesized-type-grouping.sx added; 0414 .ir
golden regenerated for the (T,) rendering. Resolves 0177; updates
0165/0170. Verified by 3 adversarial reviews; suite 792/0.
This commit is contained in:
agra
2026-06-23 10:43:47 +03:00
parent c41f51aed3
commit 555ccdc024
18 changed files with 120 additions and 40 deletions

View File

@@ -35,7 +35,7 @@ main :: () {
print("{}\n", a); // 2 print("{}\n", a); // 2
print("{}\n", b); // 1 print("{}\n", b); // 1
wrap :: (x: i64) -> (i64) { (x,) } wrap :: (x: i64) -> (i64,) { (x,) } // 1-tuple needs trailing comma; (i64) groups
t := wrap(99); t := wrap(99);
print("{}\n", t.0); // 99 print("{}\n", t.0); // 99
} }

View File

@@ -1,18 +0,0 @@
// `?(?i64)` is `optional` wrapping the SINGLE-FIELD TUPLE `(?i64)` — in type
// position `(T)` is a 1-tuple, not a grouping (specs.md §"Tuple Types"). So
// assigning a bare `?i64` value to a `?(?i64)` slot is a type mismatch: the
// optional's payload is the tuple `(?i64)`, not `?i64`.
//
// Regression (issue 0165): this used to silently lower to a malformed
// `insertvalue { {{i64,i1}}, i1 }` that aborted the LLVM verifier. It now
// produces a clean diagnostic naming the payload type and pointing at the
// parens-are-a-tuple gotcha. (To write a genuine nested optional, alias the
// inner one: `Opt :: ?i64; x : ?Opt = ...` — see
// examples/optionals/0911-nested-optional-via-alias.sx.)
#import "modules/std.sx";
main :: () {
inner : ?i64 = 5;
outer : ?(?i64) = inner;
print("unreachable\n");
}

View File

@@ -1,5 +0,0 @@
error: cannot assign a value of type '?i64' to optional '?(?i64)': its payload type is '(?i64)' (note: in type position '(T)' is a single-field tuple, not a grouping — write the inner optional without parentheses)
--> examples/diagnostics/1195-diagnostics-err-parenthesized-optional-tuple.sx:16:3
|
16 | outer : ?(?i64) = inner;
| ^^^^^^^^^^^^^^^^^^^^^^^^

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,38 @@
// Parenthesized type grouping: in type position `(T)` (single element, no
// trailing comma) is a GROUPING that resolves to the inner type — mirroring
// value position where `(expr)` groups and `(expr,)` is a 1-tuple. A 1-tuple
// type requires the trailing comma `(T,)`; `(A, B)` is a 2-tuple.
//
// This lets a closure/optional type be parenthesized for readability:
// [1](Closure(i64,i64) -> i64) // array of closures (grouped element type)
// ?(?i64) // nested optional
// without the parens silently turning it into a 1-tuple.
#import "modules/std.sx";
add :: (a: i64, b: i64) -> i64 { return a + b; }
main :: () {
// `(i64)` groups to `i64`.
g : (i64) = 7;
print("{}\n", g); // 7
// `((i64))` groups twice.
gg : ((i64)) = 8;
print("{}\n", gg); // 8
// `?(?i64)` is a genuine nested optional (grouping, not a 1-tuple).
no : ?(?i64) = 5;
print("{}\n", no!!); // 5
// Parenthesized closure element type → array of callable closures.
fns : [1](Closure(i64, i64) -> i64) = .[ add ];
print("{}\n", fns[0](3, 4)); // 7
// A 1-tuple type still requires the trailing comma.
one : (i64,) = (9,);
print("{}\n", one.0); // 9
// A 2-tuple is unaffected.
two : (i64, i64) = (40, 2);
print("{}\n", two.0 + two.1); // 42
}

View File

@@ -0,0 +1,6 @@
7
8
5
7
9
42

View File

@@ -14,6 +14,11 @@
> Genuine nested optionals via alias (`Opt :: ?i64; ?Opt`) work and round-trip. > Genuine nested optionals via alias (`Opt :: ?i64; ?Opt`) work and round-trip.
> Regressions: `examples/optionals/0911-nested-optional-via-alias.sx`, > Regressions: `examples/optionals/0911-nested-optional-via-alias.sx`,
> `examples/diagnostics/1195-diagnostics-err-parenthesized-optional-tuple.sx`. > `examples/diagnostics/1195-diagnostics-err-parenthesized-optional-tuple.sx`.
> **UPDATE (grouping):** `(T)` in type position is now a GROUPING, not a
> 1-tuple, so `?(?i64)` is a genuine NESTED OPTIONAL and compiles (what this
> issue originally wanted). The coerce guard now only fires for an explicit
> 1-tuple child `?(T,)` mismatch (note reworded). The obsolete diagnostic
> example 1195 was removed; see `examples/types/0201-types-parenthesized-type-grouping.sx`.
> Verified by 3 adversarial reviews. (A review noted `?any` over-rejection; > Verified by 3 adversarial reviews. (A review noted `?any` over-rejection;
> that turned out to be a casing non-bug — lowercase `any` is an undefined name, > that turned out to be a casing non-bug — lowercase `any` is an undefined name,
> the type is `Any`; see 0171, closed NOT-A-BUG.) > the type is `Any`; see 0171, closed NOT-A-BUG.)

View File

@@ -16,6 +16,12 @@
> `examples/closures/0311-closures-optional-closure.sx`. (Adjacent pre-existing > `examples/closures/0311-closures-optional-closure.sx`. (Adjacent pre-existing
> bug found + filed: 0177 — array-element closure direct call `fns[i](args)` > bug found + filed: 0177 — array-element closure direct call `fns[i](args)`
> crashes.) > crashes.)
>
> **UPDATE (grouping):** with parenthesized-type grouping now in place,
> `?(() -> void)` parses as optional-of-(bare function pointer) `() -> void`,
> not a tuple-optional; assigning a closure literal to it correctly diagnoses the
> closure-vs-bare-fnptr mismatch (use `?Closure() -> void`). The `g!()`
> call-through fix here is unchanged and still correct for `?Closure(...)`.
## Symptom ## Symptom

View File

@@ -1,5 +1,19 @@
# 0177 — calling a closure stored in an array element directly (`fns[i](args)`) crashes / miscompiles # 0177 — calling a closure stored in an array element directly (`fns[i](args)`) crashes / miscompiles
> **RESOLVED via parenthesized-type grouping.** The repro
> `[1](Closure(i64,i64) -> i64) = .[ add ]` was not an array of closures — under
> the old rule `(Closure(...) -> R)` was a 1-tuple, so it was an array of
> 1-tuples and `fns[0](...)` tried to call a tuple → LLVM "Called function must
> be a pointer!". Per the user's direction, parentheses in TYPE position are now
> a GROUPING (mirroring value position): `(T)` (no trailing comma) resolves to
> the inner type, `(T,)` is the 1-tuple. So `[1](Closure(...) -> R)` is now an
> array of closures and `fns[0](3,4)` returns `7`. (The canonical non-paren
> `[1]Closure(...) -> R = .[ add ]` already worked.) Implemented in
> `src/parser.zig` (single unnamed non-spread element, no trailing comma →
> return the inner type node). Regression:
> `examples/types/0201-types-parenthesized-type-grouping.sx`. specs.md §Type
> Syntax updated. Verified by 3 adversarial reviews; suite 792/0.
## Symptom ## Symptom
A closure (or `Closure(...)`-typed value) stored in an array, called DIRECTLY via A closure (or `Closure(...)`-typed value) stored in an array, called DIRECTLY via

View File

@@ -840,13 +840,22 @@ zeroed : (i32, i32) = ---; // zero-initialized tuple
Note: In value position, `(expr)` without a comma is a grouping expression, not a tuple. Use `(expr,)` for a 1-tuple value. Note: In value position, `(expr)` without a comma is a grouping expression, not a tuple. Use `(expr,)` for a 1-tuple value.
#### Type Syntax #### Type Syntax
In type position, `(T)` is always a tuple type — no trailing comma needed. The `->` arrow disambiguates function types from tuple types: 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:
```sx ```sx
(i64) // tuple type with one field (i64) // grouping: resolves to i64 (NOT a tuple)
(i64,) // tuple type with one field
(i64, i64) // tuple type with two fields (i64, i64) // tuple type with two fields
(i64) -> i64 // function type: takes i64, returns i64 (i64) -> i64 // function type: takes i64, returns i64
(i64, i64) -> i64 // function type: takes two 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.
#### Field Access #### Field Access
```sx ```sx
@@ -859,7 +868,7 @@ named.0; // 10 — numeric index also works on named tuples
#### As Return Type #### As Return Type
```sx ```sx
swap :: (a: i64, b: i64) -> (i64, i64) { (b, a); } swap :: (a: i64, b: i64) -> (i64, i64) { (b, a); }
wrap :: (x: i64) -> (i64) { (x,); } wrap :: (x: i64) -> (i64,) { (x,); } // 1-tuple return needs the trailing comma
s := swap(1, 2); // s.0 = 2, s.1 = 1 s := swap(1, 2); // s.0 = 2, s.1 = 1
t := wrap(42); // t.0 = 42 t := wrap(42); // t.0 = 42

View File

@@ -703,7 +703,7 @@ pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mod
// actually IS a tuple (the `?(?T)` typo); for any other // actually IS a tuple (the `?(?T)` typo); for any other
// mismatch the parens note would be misleading. // mismatch the parens note would be misleading.
const note: []const u8 = if (self.module.types.get(child_ty) == .tuple) const note: []const u8 = if (self.module.types.get(child_ty) == .tuple)
" (note: in type position '(T)' is a single-field tuple, not a grouping — write the inner optional without parentheses)" " (note: '(T,)' with a trailing comma is a 1-tuple; '(T)' without a comma groups to the inner type)"
else else
""; "";
d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot wrap a value of type '{s}' into optional '{s}': its payload type is '{s}'{s}", .{ self.formatTypeName(src_ty), self.formatTypeName(dst_ty), self.formatTypeName(child_ty), note }); d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot wrap a value of type '{s}' into optional '{s}': its payload type is '{s}'{s}", .{ self.formatTypeName(src_ty), self.formatTypeName(dst_ty), self.formatTypeName(child_ty), note });

View File

@@ -570,6 +570,10 @@ pub fn formatTypeName(self: *Lowering, ty: TypeId) []const u8 {
} }
buf.appendSlice(self.alloc, self.formatTypeName(f)) catch break :blk "tuple"; buf.appendSlice(self.alloc, self.formatTypeName(f)) catch break :blk "tuple";
} }
// A 1-tuple renders with the trailing comma `(T,)` — `(T)` now means
// a grouping (the inner type), so the comma is required to spell a
// 1-tuple unambiguously (and keeps diagnostics self-consistent).
if (t.fields.len == 1) buf.append(self.alloc, ',') catch break :blk "tuple";
buf.append(self.alloc, ')') catch break :blk "tuple"; buf.append(self.alloc, ')') catch break :blk "tuple";
break :blk buf.toOwnedSlice(self.alloc) catch "tuple"; break :blk buf.toOwnedSlice(self.alloc) catch "tuple";
}, },

View File

@@ -336,7 +336,7 @@ pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
// Only mention the `(T)`-is-a-1-tuple gotcha when the // Only mention the `(T)`-is-a-1-tuple gotcha when the
// payload actually IS a tuple (the `?(?T)` typo). // payload actually IS a tuple (the `?(?T)` typo).
const note: []const u8 = if (self.module.types.get(child) == .tuple) const note: []const u8 = if (self.module.types.get(child) == .tuple)
" (note: in type position '(T)' is a single-field tuple, not a grouping — write the inner optional without parentheses)" " (note: '(T,)' with a trailing comma is a 1-tuple; '(T)' without a comma groups to the inner type)"
else else
""; "";
d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot assign a value of type '{s}' to optional '{s}': its payload type is '{s}'{s}", .{ self.formatTypeName(post_rt), self.formatTypeName(ty), self.formatTypeName(child), note }); d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot assign a value of type '{s}' to optional '{s}': its payload type is '{s}'{s}", .{ self.formatTypeName(post_rt), self.formatTypeName(ty), self.formatTypeName(child), note });

View File

@@ -1117,6 +1117,8 @@ pub const TypeTable = struct {
if (i > 0) buf.appendSlice(alloc, ", ") catch break :blk "(?)"; if (i > 0) buf.appendSlice(alloc, ", ") catch break :blk "(?)";
buf.appendSlice(alloc, self.formatTypeName(alloc, f)) catch break :blk "(?)"; buf.appendSlice(alloc, self.formatTypeName(alloc, f)) catch break :blk "(?)";
} }
// 1-tuple renders `(T,)` — `(T)` now spells a grouping.
if (tu.fields.len == 1) buf.append(alloc, ',') catch break :blk "(?)";
buf.append(alloc, ')') catch break :blk "(?)"; buf.append(alloc, ')') catch break :blk "(?)";
break :blk buf.toOwnedSlice(alloc) catch "(?)"; break :blk buf.toOwnedSlice(alloc) catch "(?)";
}, },

View File

@@ -552,10 +552,17 @@ pub const Parser = struct {
// An error channel type (`!` / `!Named`) is only valid as the // An error channel type (`!` / `!Named`) is only valid as the
// trailing element of a result list. Reject any element after it. // trailing element of a result list. Reject any element after it.
var saw_error_type = false; var saw_error_type = false;
// Track an explicit trailing comma so a single-element `(T,)` stays a
// 1-tuple while `(T)` (no comma) is a GROUPING — see the grouping
// return below.
var had_trailing_comma = false;
while (self.current.tag != .r_paren and self.current.tag != .eof) { while (self.current.tag != .r_paren and self.current.tag != .eof) {
if (param_types.items.len > 0) { if (param_types.items.len > 0) {
try self.expect(.comma); try self.expect(.comma);
if (self.current.tag == .r_paren) break; // trailing comma ok if (self.current.tag == .r_paren) {
had_trailing_comma = true;
break; // trailing comma ok
}
} }
if (saw_error_type) { if (saw_error_type) {
return self.fail("error type '!' must be the last element of a result list"); return self.fail("error type '!' must be the last element of a result list");
@@ -601,9 +608,21 @@ pub const Parser = struct {
.abi = abi, .abi = abi,
} }); } });
} }
// No '->': tuple type (even for single element). Keep field names // No '->': GROUPING vs tuple. Mirror value position (`(expr)` groups,
// for a named tuple `(x: T, y: U)` so `t.x` resolves. `field_names` // `(expr,)` is a 1-tuple): a single UNNAMED, non-spread element with
// is non-optional per slot, so synthesize `_<i>` for any unnamed one. // NO trailing comma is a grouping — resolve to the inner type. This
// lets `(Closure(i64,i64) -> i64)`, `?(?i64)`, etc. parenthesize a
// type for grouping/readability. A 1-tuple type now requires the
// trailing comma `(T,)`; named `(x: T)` and spread `(..Ts)` stay
// tuples.
if (param_types.items.len == 1 and !had_trailing_comma and !has_names and
param_types.items[0].data != .spread_expr)
{
return param_types.items[0];
}
// Tuple type. Keep field names for a named tuple `(x: T, y: U)` so
// `t.x` resolves. `field_names` is non-optional per slot, so
// synthesize `_<i>` for any unnamed one.
var field_names: ?[]const []const u8 = null; var field_names: ?[]const []const u8 = null;
if (has_names) { if (has_names) {
var fns = std.ArrayList([]const u8).empty; var fns = std.ArrayList([]const u8).empty;