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

@@ -1676,21 +1676,7 @@ pub const Lowering = struct {
const saved_target = self.target_type;
self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null;
if (ret_ty != .void and ret_ty != .noreturn) {
const body_val = self.lowerBlockValue(fd.body);
if (!self.currentBlockHasTerminator()) {
if (body_val) |val| {
// Check if the body value is void (e.g., last stmt is a void call)
const val_ty = self.builder.getRefType(val);
if (val_ty == .void) {
self.ensureTerminator(ret_ty);
} else {
const coerced = self.coerceToType(val, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
}
} else {
self.ensureTerminator(ret_ty);
}
}
self.lowerValueBody(fd.body, ret_ty);
} else {
// void / noreturn: no value to return — lower as statements and
// let `ensureTerminator` close the block (ret void / unreachable).
@@ -1834,21 +1820,7 @@ pub const Lowering = struct {
const saved_target = self.target_type;
self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null;
if (ret_ty != .void and ret_ty != .noreturn) {
const body_val = self.lowerBlockValue(fd.body);
if (!self.currentBlockHasTerminator()) {
if (body_val) |val| {
// Check if body value is void (e.g., last stmt is a void call)
const val_ty = self.builder.getRefType(val);
if (val_ty == .void) {
self.ensureTerminator(ret_ty);
} else {
const coerced = self.coerceToType(val, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
}
} else {
self.ensureTerminator(ret_ty);
}
}
self.lowerValueBody(fd.body, ret_ty);
} else {
// void / noreturn: no value to return — lower as statements and
// let `ensureTerminator` close the block (ret void / unreachable).
@@ -1929,6 +1901,18 @@ pub const Lowering = struct {
self.scope = saved_scope;
block_scope.deinit();
}
// A block whose last statement is `;`-terminated (or not an
// expression) discards its value: lower every statement as a
// statement and yield nothing.
if (!blk.produces_value) {
self.force_block_value = false;
for (blk.stmts) |stmt| {
if (self.block_terminated) return null;
self.lowerStmt(stmt);
if (self.currentBlockHasTerminator()) return null;
}
return null;
}
// Lower all statements except the last normally
self.force_block_value = false; // don't force for non-last statements
for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
@@ -1940,7 +1924,7 @@ pub const Lowering = struct {
if (self.currentBlockHasTerminator()) return null;
}
if (self.block_terminated) return null;
// Last statement: if it's an expression, return its value
// Last statement (no trailing `;`): its value is the block's.
self.force_block_value = true;
const last = blk.stmts[blk.stmts.len - 1];
return self.tryLowerAsExpr(last);
@@ -1952,6 +1936,48 @@ pub const Lowering = struct {
}
}
/// Lower a value-returning function body and emit the implicit return.
/// Emits a hard error when the body yields no value — its last statement is
/// `;`-terminated (value discarded) or void — and the body doesn't already
/// terminate via `return`/`raise`. Replaces the old silent default-return.
fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
const body_val = self.lowerBlockValue(body);
if (self.currentBlockHasTerminator()) return;
if (body_val) |val| {
const val_ty = self.builder.getRefType(val);
if (val_ty != .void) {
const coerced = self.coerceToType(val, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
return;
}
}
// A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS
// the error channel) carries no success value — a void body is a normal
// success exit, not a missing value. `ensureTerminator` emits the
// error-slot-zero success return.
if (self.errorChannelOf(ret_ty)) |chan| {
if (chan == ret_ty) {
self.ensureTerminator(ret_ty);
return;
}
}
if (self.diagnostics) |diags| {
if (body.data == .block and body.data.block.discarded_semi != null) {
diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
} else {
const span = blk: {
if (body.data == .block) {
const stmts = body.data.block.stmts;
if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
}
break :blk body.span;
};
diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
}
}
self.ensureTerminator(ret_ty);
}
/// Try to lower a node as an expression, returning its value.
/// Statement nodes are lowered as statements (returning null).
fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref {
@@ -3050,8 +3076,19 @@ pub const Lowering = struct {
self.scope = saved_scope;
block_scope.deinit();
}
if (self.force_block_value and blk.stmts.len > 0) {
// Extract last expression value (for if-else branch blocks)
// This block sits in value position (lowerExpr is reached only
// for value contexts — statement blocks go through lowerBlock).
// If its last expression's value is discarded by a `;`, the
// surrounding expression has no value to use: report it.
if (!blk.produces_value and blk.discarded_semi != null) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, blk.discarded_semi.?, "this block is used as a value but its last expression's value is discarded by this `;` — drop the `;`", .{});
}
}
// A block in expression position yields its last statement's
// value only when it produces one (no trailing `;`); otherwise
// it runs as statements and evaluates to void.
if (blk.produces_value and blk.stmts.len > 0) {
for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
self.lowerStmt(stmt);
}
@@ -3577,12 +3614,12 @@ pub const Lowering = struct {
break :blk self.builder.emit(.{ .optional_has_value = .{ .operand = opt_val } }, .bool);
} else opt_val;
const has_else = ie.else_branch != null;
// If-else produces a value when inline OR when then-branch has a non-void type
const is_value = (ie.is_inline or self.force_block_value) and has_else;
// If-else produces a value when inline OR when in value position (force_block_value)
var is_value = (ie.is_inline or self.force_block_value) and has_else;
// Infer result type from then branch for value if-exprs
// If then_branch is null/void, try else_branch (e.g., `if cond then null else val`)
const result_type: TypeId = if (is_value) blk: {
var result_type: TypeId = if (is_value) blk: {
var t = self.inferExprType(ie.then_branch);
if ((t == .void or t == .unresolved) and ie.else_branch != null) {
t = self.inferExprType(ie.else_branch.?);
@@ -3595,6 +3632,14 @@ pub const Lowering = struct {
break :blk t;
} else .void;
// A value-position if/else whose branches yield no value (both are
// `;`-terminated / void blocks) is really a statement-if — lowering it
// as a value would build a `phi void`. Demote it.
if (is_value and result_type == .void) {
is_value = false;
result_type = .void;
}
const then_bb = self.freshBlock("if.then");
const else_bb: ?BlockId = if (has_else) self.freshBlock("if.else") else null;
const merge_params: []const TypeId = if (is_value) &.{result_type} else &.{};
@@ -8813,11 +8858,18 @@ pub const Lowering = struct {
var i = stack.len;
while (i > saved_len) {
i -= 1;
if (!stack[i].is_onfail) _ = self.lowerExpr(stack[i].body);
if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
}
self.defer_stack.shrinkRetainingCapacity(saved_len);
}
/// Run a `defer`/`onfail` cleanup body for its side effects (void context).
/// A braced body lowers as statements (NOT as a value) so a trailing-`;`
/// last expression is fine here — cleanup bodies never yield a value.
fn lowerCleanupBody(self: *Lowering, body: *const Node) void {
if (body.data == .block) self.lowerBlock(body) else _ = self.lowerExpr(body);
}
/// Emit cleanups from `base`..current in reverse order on an ERROR exit
/// (raise / try-propagation): BOTH `defer` and `onfail` entries run,
/// interleaved in reverse declaration order. `err_tag` is the in-flight
@@ -8838,14 +8890,14 @@ pub const Lowering = struct {
const saved = self.scope;
self.scope = &ofscope;
ofscope.put(name, .{ .ref = err_tag, .ty = tag_ty, .is_alloca = false });
_ = self.lowerExpr(entry.body);
self.lowerCleanupBody(entry.body);
self.scope = saved;
ofscope.deinit();
} else {
_ = self.lowerExpr(entry.body);
self.lowerCleanupBody(entry.body);
}
} else {
_ = self.lowerExpr(entry.body);
self.lowerCleanupBody(entry.body);
}
}
}
@@ -14579,8 +14631,9 @@ pub const Lowering = struct {
// success type (ERR E1.4c / E1.5).
.return_stmt, .raise_stmt, .break_expr, .continue_expr => .noreturn,
.block => |blk| {
// Block type is the type of the last expression / statement.
if (blk.stmts.len > 0) {
// A block's type is its last expression's type only when it
// produces a value (no trailing `;`); otherwise it is void.
if (blk.produces_value and blk.stmts.len > 0) {
return self.inferExprType(blk.stmts[blk.stmts.len - 1]);
}
return .void;