feat(lang): block value requires no trailing ; (Rust-style)

A block's value is now its last statement ONLY when that statement is a
trailing expression with no `;`. A trailing `;` discards the value,
leaving the block void. This makes value-vs-statement explicit and lets
the compiler reject "this block was supposed to produce a value".

Compiler:
- Parser records `Block.produces_value` (last stmt is a no-`;` trailing
  expression) + `Block.discarded_semi` (the `;` that discarded a value),
  via `expectSemicolonAfter`. A trailing expression before `}` may now
  omit its `;` (previously a parse error). Match-arm and else-arm bodies
  are built value-producing regardless of the arm `;` (arms are exempt —
  the `;` is an arm terminator).
- Lowering: `lowerBlockValue` / the block-expr path / `inferExprType`
  respect `produces_value`. A value-position block that discards its value
  is a hard error (`lowerValueBody` for function bodies; the value-context
  `.block` path for if/else branches, `catch` bodies, value bindings,
  match arms). Pure-failable `-> !` bodies (value rides the error channel)
  and a value-if whose branches are void are handled without false errors.
- `defer`/`onfail` cleanup bodies lower as statements (void), so a
  trailing `;` there is fine.

Migration (behavior-preserving — output unchanged):
- stdlib + ~210 examples: dropped the trailing `;` on value-position last
  expressions. `format` now ends with an explicit `#insert "return
  result;"` (it relied on `#insert`-as-block-value, which `;` discards).
- Two `main :: () -> s32` examples that relied on the old silent
  default-return got an explicit trailing `0`.
- Rejection snapshots 0412 / 1013 regenerated (their quoted source lines
  lost a `;`); the diagnostics themselves are unchanged.

Docs/tests: specs.md "Block values" section; examples 0040 (rules) + 0041
(rejection); 3 parser unit tests. Filed issue 0066 (pre-existing
match-arm negated-literal phi-width quirk, surfaced not caused here).

Gates: zig build, zig build test, run_examples.sh -> 343 passed,
cross_compile.sh -> 7 passed (also refreshed its stale example names).
This commit is contained in:
agra
2026-06-02 09:23:50 +03:00
parent 634cf9bc7f
commit bdd0e96d78
265 changed files with 1070 additions and 761 deletions

View File

@@ -37,6 +37,18 @@ pub const Parser = struct {
/// already exiting, so there is nothing to propagate to. E1.7 extends this
/// to the full {try, return, break, continue} set.
in_defer_body: bool = false,
/// Set by `expectSemicolonAfter` for the statement just parsed: true when the
/// statement is a trailing value (an expression / block-form with NO `;`),
/// false when a `;` terminated it (value discarded). `parseBlock` reads it
/// after the last statement to set `Block.produces_value`. Reset at the top
/// of `parseStmt` so non-expression statements (decls, return, …) leave it
/// false.
last_stmt_produces_value: bool = false,
/// Span of the `;` that discarded the just-parsed statement's value, when
/// that statement was an expression terminated by `;` (so the value could
/// have been kept by dropping it). Null when the statement kept its value or
/// wasn't a value expression. Read by `parseBlock` into `Block.discarded_semi`.
last_stmt_semi_loc: ?ast.Span = null,
pub fn init(allocator: std.mem.Allocator, source: [:0]const u8) Parser {
var lexer = Lexer.init(source);
@@ -1401,7 +1413,7 @@ pub const Parser = struct {
try self.expect(.semicolon);
const stmts = try self.allocator.alloc(*Node, 1);
stmts[0] = expr_node;
const block_node = try self.createNode(expr_node.span.start, .{ .block = .{ .stmts = stmts } });
const block_node = try self.createNode(expr_node.span.start, .{ .block = .{ .stmts = stmts, .produces_value = true } });
try members.append(self.allocator, .{ .method = .{
.name = member_name,
.params = &.{},
@@ -1500,7 +1512,7 @@ pub const Parser = struct {
try self.expect(.semicolon);
const stmts = try self.allocator.alloc(*Node, 1);
stmts[0] = expr;
body_node = try self.createNode(expr.span.start, .{ .block = .{ .stmts = stmts } });
body_node = try self.createNode(expr.span.start, .{ .block = .{ .stmts = stmts, .produces_value = true } });
} else {
try self.expect(.semicolon);
}
@@ -1911,7 +1923,7 @@ pub const Parser = struct {
const stmts = try self.allocator.alloc(*Node, 1);
stmts[0] = expr;
const block_start = expr.span.start;
const block = try self.createNode(block_start, .{ .block = .{ .stmts = stmts } });
const block = try self.createNode(block_start, .{ .block = .{ .stmts = stmts, .produces_value = true } });
break :blk block;
} else try self.parseBlock();
@@ -1932,33 +1944,54 @@ pub const Parser = struct {
const start = self.current.loc.start;
try self.expect(.l_brace);
var stmts = std.ArrayList(*Node).empty;
var produces_value = false;
var discarded_semi: ?ast.Span = null;
while (self.current.tag != .r_brace and self.current.tag != .eof) {
const stmt = try self.parseStmt();
try stmts.append(self.allocator, stmt);
// The block's value-ness is its LAST statement's value-ness.
produces_value = self.last_stmt_produces_value;
// A discarding `;` is only meaningful when the block has no value.
discarded_semi = if (produces_value) null else self.last_stmt_semi_loc;
}
try self.expect(.r_brace);
return try self.createNode(start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator) } });
return try self.createNode(start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator), .produces_value = produces_value, .discarded_semi = discarded_semi } });
}
/// Block-form if/match/while/bare blocks don't require trailing semicolon.
/// Consume the terminator after an expression/block-form statement and
/// record whether the statement is a trailing VALUE (no `;`) or a discarded
/// statement (`;`). A trailing `;` always discards; otherwise the statement
/// is the (potential) block value — allowed when it is block-form (where
/// `;` is optional) or when it is the last thing before `}`.
fn expectSemicolonAfter(self: *Parser, expr: *Node) anyerror!void {
const needs_semi = switch (expr.data) {
.if_expr => |ie| ie.is_inline,
.match_expr => false,
.while_expr => false,
.for_expr => false,
.block => false,
.jni_env_block => false,
else => true,
const block_form = switch (expr.data) {
.if_expr => |ie| !ie.is_inline,
.match_expr, .while_expr, .for_expr, .block, .jni_env_block => true,
else => false,
};
if (needs_semi) {
try self.expect(.semicolon);
} else if (self.current.tag == .semicolon) {
self.advance(); // consume optional ;
if (self.current.tag == .semicolon) {
self.last_stmt_semi_loc = .{ .start = self.current.loc.start, .end = self.current.loc.end };
self.advance(); // explicit terminator → value discarded
self.last_stmt_produces_value = false;
} else if (block_form or self.current.tag == .r_brace) {
// Block-form statements never require `;`; a plain expression may
// omit it only as the trailing value before `}`. Either way this
// statement is the block's value (and discards nothing — clear any
// stale semi location from a nested statement).
self.last_stmt_produces_value = true;
self.last_stmt_semi_loc = null;
} else {
try self.expect(.semicolon); // emits "expected ;"
}
}
pub fn parseStmt(self: *Parser) anyerror!*Node {
// Default: a statement discards its value unless `expectSemicolonAfter`
// marks it a trailing value (no `;`). Non-expression statements (decls,
// return/raise, break/continue, defer/onfail) never set it, so they
// correctly leave the enclosing block value-less.
self.last_stmt_produces_value = false;
self.last_stmt_semi_loc = null;
// Check if this is a declaration (IDENT followed by ::, :=, or : type)
if (self.isIdentLike()) {
const saved_lexer = self.lexer;
@@ -3138,7 +3171,10 @@ pub const Parser = struct {
self.advance();
const expr = try self.parseExpr();
try self.expect(.semicolon);
const body = try self.createNode(arm_start, .{ .block = .{ .stmts = try self.allocator.dupe(*Node, &.{expr}) } });
// Arm bodies are value-producing regardless of the arm `;` (the
// `;` is an arm terminator, not a value-discard — match arms are
// exempt from the block trailing-`;` rule).
const body = try self.createNode(arm_start, .{ .block = .{ .stmts = try self.allocator.dupe(*Node, &.{expr}), .produces_value = true } });
try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture });
} else {
const stmts_start = self.current.loc.start;
@@ -3146,7 +3182,10 @@ pub const Parser = struct {
while (self.current.tag != .kw_case and self.current.tag != .kw_else and self.current.tag != .r_brace and self.current.tag != .eof) {
try stmts.append(self.allocator, try self.parseStmt());
}
const body = try self.createNode(stmts_start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator) } });
// Arm exempt from the trailing-`;` rule (see above); the wrapper
// yields its last statement's value — which, for a braced-block
// arm body, still respects that inner block's own flag.
const body = try self.createNode(stmts_start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator), .produces_value = true } });
try arms.append(self.allocator, .{ .pattern = pattern, .body = body, .is_break = false, .capture = capture });
}
}
@@ -3159,7 +3198,7 @@ pub const Parser = struct {
while (self.current.tag != .r_brace and self.current.tag != .eof) {
try stmts.append(self.allocator, try self.parseStmt());
}
const body = try self.createNode(else_start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator) } });
const body = try self.createNode(else_start, .{ .block = .{ .stmts = try stmts.toOwnedSlice(self.allocator), .produces_value = true } });
try arms.append(self.allocator, .{ .pattern = null, .body = body, .is_break = false });
}
try self.expect(.r_brace);
@@ -3692,6 +3731,42 @@ test "parse minimal main" {
try std.testing.expectEqual(@as(i64, 42), body.data.block.stmts[0].data.int_literal.value);
}
test "block value: trailing expr without `;` produces a value" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () -> s32 { 42 }");
const root = try parser.parse();
const body = root.data.root.decls[0].data.fn_decl.body;
try std.testing.expect(body.data.block.produces_value);
try std.testing.expect(body.data.block.discarded_semi == null);
}
test "block value: trailing `;` discards the value" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () -> s32 { 42; }");
const root = try parser.parse();
const body = root.data.root.decls[0].data.fn_decl.body;
try std.testing.expect(!body.data.block.produces_value);
try std.testing.expect(body.data.block.discarded_semi != null);
}
test "block value: match arms are exempt (keep `;`, still produce a value)" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: (n: s32) -> s32 { if n == { case 1: 5; else: 0; } }");
const root = try parser.parse();
const body = root.data.root.decls[0].data.fn_decl.body;
// Function body's trailing match has no `;` → the body is a value.
try std.testing.expect(body.data.block.produces_value);
const match = body.data.block.stmts[0];
try std.testing.expect(match.data == .match_expr);
// Each arm body (built with `;`) is still value-producing (exempt).
for (match.data.match_expr.arms) |arm| {
try std.testing.expect(arm.body.data.block.produces_value);
}
}
test "parse #run const binding" {
const source = "x :: #run compute(5);";
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);