fix: bindingless if/while/and/or over optional reads has_value (issue 0164)

lowerIfExpr emitted optional_has_value only for the binding form; a bare
'if opt' passed the raw {T,i1} aggregate to condBr, where emitCondBr's
catch-all struct arm silently folded it to 'i1 true' (structs always
truthy) — a silent miscompile that took the present-branch for null
optionals. while / and / or shared the same defect.

Reduce bindingless optional conditions to optional_has_value in
lowerIfExpr/lowerWhile and via a new lowerBoolCondition helper for and/or
operands. Replace the silent-true emitCondBr arm with a lowering-time
diagnostic (checkConditionType/isValidConditionType) rejecting conditions
whose type isn't bool/integer/pointer/optional; the backend @panic is now
an unreachable tripwire.

Regressions: examples/optionals/0908..0910 + diagnostics/1194 (negative).
Verified by 3+3 adversarial reviews.

Filed adjacent bugs found during review: 0168 (array-of-optionals element
load), 0169 (optional->bool coercion), 0170 (closure-optional layout).
This commit is contained in:
agra
2026-06-22 21:04:05 +03:00
parent 2637ae98a5
commit 3e8d003e3d
24 changed files with 418 additions and 13 deletions

View File

@@ -2745,16 +2745,76 @@ pub fn refCapturePointee(self: *Lowering, node: *const Node) ?TypeId {
return if (info == .pointer) info.pointer.pointee else null;
}
/// Is `ty` a type that may be used directly as a runtime branch condition?
/// A condBr (and the short-circuit `and`/`or` merges) ultimately tests an
/// i1: lowering must therefore reduce the condition to something the backend
/// can compare against zero/null. The acceptable categories all lower to an
/// LLVM integer or pointer (or, for `.optional`, are reduced to their
/// has_value i1 by the caller):
/// • bool / integers (signed/unsigned/usize/isize)
/// • integer-backed nominals: `enum` (incl. `enum flags`, e.g. `if p & .read`)
/// and `error_set` (a u32 tag) — both reach condBr as a plain integer
/// • pointers: `*T` / `[*]T` / `cstring` (compared against null)
/// • `optional` — the caller emits `optional_has_value`
/// Everything else (float, void, string, any, type_value, struct/union/tuple/
/// array/slice/vector/function/closure/protocol/pack) reaches condBr as a
/// non-comparable aggregate or a value with no truthiness, and previously got
/// silently folded truthy then `@panic`d in the backend (issue 0164). Such a
/// condition is a type error — see `checkConditionType`.
fn isValidConditionType(self: *Lowering, ty: TypeId) bool {
if (ty == .unresolved) return true; // already-diagnosed elsewhere; don't double-report
return switch (self.module.types.get(ty)) {
.bool, .signed, .unsigned, .usize, .isize => true,
.pointer, .many_pointer, .cstring => true,
.@"enum", .error_set => true,
.optional => true,
else => false,
};
}
/// Emit a located type error when `ty` cannot be used as a branch condition
/// (see `isValidConditionType`). Returns `true` if the type is valid (caller
/// proceeds normally), `false` if a diagnostic was emitted (caller should
/// recover with a placeholder bool so lowering doesn't crash before the
/// diagnostic surfaces). This is the lowering-time replacement for the
/// backend `@panic` in `emitCondBr` (issue 0164): the type and span are both
/// available here, so we report a clean compile-time error instead.
pub fn checkConditionType(self: *Lowering, ty: TypeId, span: ast.Span) bool {
if (isValidConditionType(self, ty)) return true;
if (self.diagnostics) |d| d.addFmt(.err, span, "condition must be a bool, integer, pointer, or optional, but has type '{s}'", .{self.formatTypeName(ty)});
return false;
}
/// Lower `node` as a boolean condition. If its type is an optional, reduce
/// it to its has_value flag (presence-as-truth) — same rule as `if opt`/
/// `while opt`. Without this, a bare optional operand reaches a condBr/phi as
/// a `{T,i1}` aggregate and folds truthy (issue 0164). Returns an i1/bool Ref.
/// A non-condition-typed operand (struct/float/...) is rejected with a located
/// type error via `checkConditionType`; on rejection a placeholder `false` is
/// returned so lowering can continue to surface the diagnostic.
pub fn lowerBoolCondition(self: *Lowering, node: *const Node) Ref {
const ty = self.inferExprType(node);
if (!self.checkConditionType(ty, node.span)) {
_ = self.lowerExpr(node); // still lower for side effects / further diagnostics
return self.builder.constBool(false);
}
const v = self.lowerExpr(node);
if (!ty.isBuiltin() and self.module.types.get(ty) == .optional) {
return self.builder.emit(.{ .optional_has_value = .{ .operand = v } }, .bool);
}
return v;
}
pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref {
// Short-circuit: `a and b` → if a then b else false
if (bop.op == .and_op) {
const lhs = self.lowerExpr(bop.lhs);
const lhs = self.lowerBoolCondition(bop.lhs);
const rhs_bb = self.freshBlock("and.rhs");
const merge_bb = self.freshBlockWithParams("and.merge", &.{.bool});
const false_val = self.builder.constBool(false);
self.builder.condBr(lhs, rhs_bb, &.{}, merge_bb, &.{false_val});
self.builder.switchToBlock(rhs_bb);
const rhs = self.lowerExpr(bop.rhs);
const rhs = self.lowerBoolCondition(bop.rhs);
self.builder.br(merge_bb, &.{rhs});
self.builder.switchToBlock(merge_bb);
return self.builder.blockParam(merge_bb, 0, .bool);
@@ -2768,13 +2828,13 @@ pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref {
if (self.orIsFailableChain(bop)) {
return self.lowerFailableOr(bop);
}
const lhs = self.lowerExpr(bop.lhs);
const lhs = self.lowerBoolCondition(bop.lhs);
const rhs_bb = self.freshBlock("or.rhs");
const merge_bb = self.freshBlockWithParams("or.merge", &.{.bool});
const true_val = self.builder.constBool(true);
self.builder.condBr(lhs, merge_bb, &.{true_val}, rhs_bb, &.{});
self.builder.switchToBlock(rhs_bb);
const rhs = self.lowerExpr(bop.rhs);
const rhs = self.lowerBoolCondition(bop.rhs);
self.builder.br(merge_bb, &.{rhs});
self.builder.switchToBlock(merge_bb);
return self.builder.blockParam(merge_bb, 0, .bool);