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

@@ -876,7 +876,7 @@ pub const TupleTypeExpr = struct {
/// rejects it anywhere else), and its result is consumed only by destructuring
/// (`a, b := f()`), never bound to a single value. Same shape as a tuple type so
/// the resolver can reuse the field-resolution path. The single-value `(T, !)`
/// (one value + error) is NOT this — it is a plain failable, `-> T !`.
/// (one value + error) is NOT this — it is a plain failable.
pub const ReturnTypeExpr = struct {
field_types: []const *Node,
field_names: ?[]const []const u8, // null for positional

View File

@@ -341,36 +341,21 @@ test "parser: .(x) is a 1-tuple, .() is empty" {
try std.testing.expectEqual(@as(usize, 0), v2.data.tuple_literal.elements.len);
}
// `-> T !` folds to the same `(T, !)` representation: tuple_type_expr whose
// last field is an error_type_expr.
test "parser: -> T ! folds to (T, !) tuple_type_expr" {
// The legacy bare trailing-`!` spelling `-> T !` was removed — the canonical
// failable result list is `-> (T, !)`. The bare form is now a parse error.
test "parser: legacy bare `-> T !` is rejected" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () -> i64 ! { 0 }");
const root = try parser.parse();
const rt = root.data.root.decls[0].data.fn_decl.return_type.?;
try std.testing.expect(rt.data == .tuple_type_expr);
const fields = rt.data.tuple_type_expr.field_types;
try std.testing.expectEqual(@as(usize, 2), fields.len);
try std.testing.expect(fields[0].data == .type_expr);
try std.testing.expect(fields[1].data == .error_type_expr);
try std.testing.expect(fields[1].data.error_type_expr.name == null);
try std.testing.expectError(error.ParseError, parser.parse());
}
// `-> Tuple(T1, T2) !` flattens to (T1, T2, !).
test "parser: -> Tuple(A, B) ! flattens to (A, B, !)" {
// Likewise the legacy `-> Tuple(A, B) !` spelling — write `-> (A, B, !)`.
test "parser: legacy bare `-> Tuple(A, B) !` is rejected" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () -> Tuple(i64, i32) !ParseErr { 0 }");
const root = try parser.parse();
const rt = root.data.root.decls[0].data.fn_decl.return_type.?;
try std.testing.expect(rt.data == .tuple_type_expr);
const fields = rt.data.tuple_type_expr.field_types;
try std.testing.expectEqual(@as(usize, 3), fields.len);
try std.testing.expect(fields[0].data == .type_expr);
try std.testing.expect(fields[1].data == .type_expr);
try std.testing.expect(fields[2].data == .error_type_expr);
try std.testing.expectEqualStrings("ParseErr", fields[2].data.error_type_expr.name.?);
try std.testing.expectError(error.ParseError, parser.parse());
}
// `-> !` (void + error) stays a bare error_type_expr — the trailing-`!` fold

View File

@@ -431,66 +431,27 @@ pub const Parser = struct {
return self.fail("expected ':', '=', ';', or 'extern' after type annotation");
}
/// Parse a function/method/lambda return type, folding a trailing `!`
/// error channel that sits OUTSIDE the value type into the same
/// representation the inline `(T, !)` form produces.
/// Parse a function/method/lambda/closure/fn-pointer return type.
///
/// `-> T !` ⇒ tuple_type_expr { [T, error_type_expr] } (== `(T, !)`)
/// `-> Tuple(A, B) !` ⇒ tuple_type_expr { [A, B, error_type_expr] } (== `(A, B, !)`)
/// `-> !` ⇒ error_type_expr (bare; handled by parseTypeExpr directly)
/// The canonical failable / multi-return spelling wraps the result list in
/// parens: `-> (T, !)`, `-> (A, B, !)`, `-> (x: A, y: B, !)` — the error
/// channel is the last slot. A bare `-> !` (error-only, no value) is parsed
/// by `parseTypeExpr` as an `error_type_expr` and is unaffected.
///
/// The old inline `(T, !)` / `(T1, T2, !)` forms keep working unchanged —
/// this only ADDS the trailing-`!`-after-the-type spelling.
/// The legacy trailing-`!`-after-the-value-type spelling (`-> T !`,
/// `-> Tuple(A, B) !`) is REJECTED — it was a redundant second spelling of
/// `(T, !)` / `(A, B, !)` and is no longer accepted.
fn parseFnReturnType(self: *Parser) anyerror!*Node {
const start = self.current.loc.start;
const ty = try self.parseTypeExpr();
// A trailing `!` (optionally `!Named`) after the return TYPE denotes the
// error channel sitting OUTSIDE the value type. A bare `-> !` is already
// an error_type_expr (no value), so a `!` after one would be a doubled
// error channel — leave it for the normal "unexpected token" path.
if (self.current.tag != .bang or ty.data == .error_type_expr) return ty;
self.advance(); // skip '!'
var set_name: ?[]const u8 = null;
if (self.current.tag == .identifier) {
set_name = self.tokenSlice(self.current);
self.advance();
// A trailing `!` after a VALUE return type is the removed legacy
// spelling. (`-> !` already parsed to an error_type_expr above, so a
// `!` after one would be a doubled channel — leave that to the normal
// "unexpected token" path.)
if (self.current.tag == .bang and ty.data != .error_type_expr) {
return self.fail("a failable return is written `(T, !)` — or `(A, B, !)` for multiple values — not `T !`");
}
const err_node = try self.createNode(start, .{ .error_type_expr = .{ .name = set_name } });
// Build the value+error result list. If the value type is itself a
// tuple — `Tuple(A, B)` (positional) or `Tuple(x: A, y: B)` (named) —
// flatten its fields and append the error channel, so `-> Tuple(A,B) !`
// is identical to `(A, B, !)` and `-> Tuple(x: A, y: B) !` keeps the
// `x`/`y` names on the flattened value fields. The value-return lowering
// inserts the value slots FLAT into the result tuple, so the result type
// must list the value fields flat too — wrapping a named tuple as
// `{ {A,B}, err }` would miscompile the flat 2-tuple insert. Otherwise
// wrap the single value as `(T, !)`.
var fields = std.ArrayList(*Node).empty;
var names = std.ArrayList([]const u8).empty;
var has_names = false;
if (ty.data == .tuple_type_expr) {
const tt = ty.data.tuple_type_expr;
for (tt.field_types) |f| try fields.append(self.allocator, f);
if (tt.field_names) |fn_names| {
has_names = true;
for (fn_names) |nm| try names.append(self.allocator, nm);
// The trailing error channel needs a placeholder name so the
// names slice stays 1:1 with field_types. It is identified by
// position (last field), never by this name (see
// lower/error.zig errorChannelOf).
try names.append(self.allocator, "!");
}
} else {
try fields.append(self.allocator, ty);
}
try fields.append(self.allocator, err_node);
return try self.createNode(start, .{ .tuple_type_expr = .{
.field_types = try fields.toOwnedSlice(self.allocator),
.field_names = if (has_names) try names.toOwnedSlice(self.allocator) else null,
} });
return ty;
}
fn parseTypeExpr(self: *Parser) anyerror!*Node {
@@ -675,10 +636,9 @@ pub const Parser = struct {
}
try self.expect(.r_paren);
if (self.current.tag == .arrow) {
// '->' present: function type. Accept a trailing `!`/`!Named`
// error channel after the return type (`(i64) -> i64 !E`), folded
// to the SAME `(T, !)` / `(A, B, !)` representation the inline form
// produces — the old `-> (T, !)` spelling keeps working too.
// '->' present: function type. A failable return is the canonical
// parenthesized list `(i64) -> (i64, !E)` (parseFnReturnType
// rejects the bare `-> i64 !E` spelling).
self.advance(); // skip '->'
const return_type = try self.parseFnReturnType();
const abi = try self.parseOptionalAbi();
@@ -848,10 +808,9 @@ pub const Parser = struct {
var return_type: ?*Node = null;
if (self.current.tag == .arrow) {
self.advance();
// Accept a trailing `!`/`!Named` error channel after the
// closure return type (`Closure(i64) -> i64 !E`, `... -> T !`),
// folded to the same `(T, !)` / `(A, B, !)` representation; the
// old `-> (T, !)` form keeps working.
// A failable closure return is the canonical parenthesized
// list `Closure(i64) -> (i64, !E)` (parseFnReturnType rejects
// the bare `Closure(i64) -> i64 !E` spelling).
return_type = try self.parseFnReturnType();
}
return try self.createNode(start, .{ .closure_type_expr = .{
@@ -5020,8 +4979,8 @@ test "parse bare failable return: named `!Foo`" {
try std.testing.expectEqualStrings("ParseErr", rt.data.error_type_expr.name.?);
}
test "parse failable with inferred `!` (new `-> T !` form)" {
const source = "f :: () -> i32 ! { 0; }";
test "parse single-value failable `-> (T, !)`" {
const source = "f :: () -> (i32, !) { 0; }";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), source);
@@ -5036,20 +4995,28 @@ test "parse failable with inferred `!` (new `-> T !` form)" {
try std.testing.expect(fields[1].data.error_type_expr.name == null);
}
test "parse failable with named `!Foo` (new `-> Tuple(...) !` form)" {
const source = "f :: () -> Tuple(i32, i64) !ParseErr { 0; }";
test "parse multi-value named failable `-> (A, B, !Foo)`" {
const source = "f :: () -> (i32, i64, !ParseErr) { 0; }";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), source);
const root = try parser.parse();
const rt = root.data.root.decls[0].data.fn_decl.return_type.?;
try std.testing.expect(rt.data == .tuple_type_expr);
const fields = rt.data.tuple_type_expr.field_types;
try std.testing.expect(rt.data == .return_type_expr or rt.data == .tuple_type_expr);
const fields = if (rt.data == .return_type_expr) rt.data.return_type_expr.field_types else rt.data.tuple_type_expr.field_types;
try std.testing.expectEqual(@as(usize, 3), fields.len);
try std.testing.expect(fields[2].data == .error_type_expr);
try std.testing.expectEqualStrings("ParseErr", fields[2].data.error_type_expr.name.?);
}
test "parse legacy bare failable `-> T !` is rejected" {
const source = "f :: () -> i32 ! { 0; }";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), source);
try std.testing.expectError(error.ParseError, parser.parse());
}
test "parse old bare-paren failable `-> (!, i32)` is rejected" {
const source = "f :: () -> (!, i32) { 0; }";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
@@ -5077,11 +5044,10 @@ test "round-trip print: error-set decl" {
try std.testing.expectEqualStrings(source, aw.writer.toArrayList().items);
}
test "print: failable result list with pointer + named error folds to tuple repr" {
// New `-> T !` form: a single value + named error channel folds to the SAME
// internal `tuple_type_expr` the old `(*Handle, !IoErr)` spelling produced,
// so printType still renders the canonical tuple representation.
const source = "open :: () -> *Handle !IoErr { 0; }";
test "print: failable result list with pointer + named error renders canonically" {
// A single value + named error channel `(*Handle, !IoErr)` renders back as
// the canonical parenthesized result list.
const source = "open :: () -> (*Handle, !IoErr) { 0; }";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), source);
@@ -5431,7 +5397,7 @@ test "E0.3 or value-terminator: parse(s) or 0" {
test "E0.3 full failable function parses end-to-end (all E0 forms)" {
const source =
\\parse :: (s: string) -> i32 !ParseErr {
\\parse :: (s: string) -> (i32, !ParseErr) {
\\ onfail (e) { cleanup(s); }
\\ v := try inner(s) or 0;
\\ w := other(s) catch (e2) { return 0; };