lang: extend operand-type check to ordering + bitwise/shift (issue 0055 follow-up)

The arithmetic-only check from the previous commit shared a hole with the
comparison and bitwise/shift ops: lowerBinaryOp derives the result type
from the LHS, so `s64 < string` fed mismatched types to `icmp` (LLVM
verifier failure) and `s64 & string` reinterpreted the string's bytes.

Add isOrderingOperand (numeric / enum / pointer / bool / vector) and
isBitwiseOperand (integer / enum / bool / vector), and route `< <= > >=`
and `& | ^ << >>` through them alongside the existing arithmetic check, all
sharing one diagnostic + placeholder-sentinel path. Flags-enum bitwise
(`.read | .write`, `perm & .read`), enum/pointer comparison, and int
literals stay legal (50-smoke unaffected).

Equality `== / !=` is deliberately left unchecked — its path is heavily
special-cased (str_eq, Any unbox, optional == null); folding a check in
without regressing those is a separate change, noted in the issue.

Regression test renamed arith→binop and broadened to cover `+ * < & <<`
against a string operand: examples/214-binop-operand-type-check.sx.
This commit is contained in:
agra
2026-05-30 10:30:57 +03:00
parent 6016b08712
commit ac7f1d10e5
7 changed files with 132 additions and 50 deletions

View File

@@ -1,15 +0,0 @@
// Scalar arithmetic (`+ - * / %`) requires numeric operands. Mixing a
// non-numeric type (here `string`) is rejected at compile time. The
// result type is otherwise taken from the left operand, so without this
// check `n + s` would lower as `add : s64` and reinterpret the string's
// bytes as an integer — silently producing garbage.
#import "modules/std.sx";
main :: () -> s32 {
n : s64 = 40;
s : string = "nope";
x := n + s; // s64 + string — rejected
y := s * n; // string * s64 — rejected (non-numeric LHS too)
0;
}

View File

@@ -0,0 +1,25 @@
// Scalar binary operators check operand-type compatibility. The result
// type is otherwise taken from the left operand, so mixing a non-numeric
// type (here `string`) would lower as `<op> : s64` and either reinterpret
// the string's bytes (arithmetic / bitwise → garbage) or feed mismatched
// types to `icmp` (ordering → LLVM verifier failure). All such mismatches
// are now rejected at compile time:
// - arithmetic `+ - * / %` (numeric / vector / pointer operands)
// - ordering `< <= > >=` (numeric / enum / pointer operands)
// - bitwise `& | ^` (integer / enum operands)
// - shift `<< >>` (integer / enum operands)
// Legitimate mixes (int+float promotion, flags-enum bitwise, enum/pointer
// comparison) are unaffected — see `examples/50-smoke.sx`.
#import "modules/std.sx";
main :: () -> s32 {
n : s64 = 40;
s : string = "nope";
a := n + s; // arithmetic: s64 + string
b := s * n; // arithmetic: non-numeric LHS (string * s64)
c := n < s; // ordering: s64 < string
d := n & s; // bitwise: s64 & string
e := n << s; // shift: s64 << string
0;
}

View File

@@ -1,21 +1,33 @@
# 0055 — binary arithmetic accepts mismatched operand types (`s64 + string`)
**FIXED** (`examples/214-arith-operand-type-check.sx`). `lowerBinaryOp` in
**FIXED** (`examples/214-binop-operand-type-check.sx`). `lowerBinaryOp` in
[src/ir/lower.zig](../src/ir/lower.zig) now checks operand-type
compatibility for the scalar arithmetic ops (`+ - * / %`) before emitting:
if either operand (after the existing optional-unwrap / int×float
promotion) is not a numeric / vector / pointer type via the new
`isArithOperand`, it emits `cannot apply '<op>' to operands of type '<lhs>'
and '<rhs>'` and returns a placeholder sentinel instead of an `add` that
reinterprets the RHS bytes. `.unresolved` operands are passed through so a
type we couldn't infer is never falsely diagnosed. Regression test:
`examples/214` (covers `s64 + string` and non-numeric LHS `string * s64`).
compatibility for every scalar binary-op group before emitting, via three
predicates that pass `.unresolved` through (so a type we couldn't infer is
never falsely diagnosed) but reject a concretely incompatible operand:
Not covered by this fix (same LHS-derived-type shape, separate ops): the
ordering comparisons (`< <= > >=`) and bitwise/shift ops (`& | ^ << >>`)
have the same hole — `s64 < string` / `s64 & string` still lower without an
operand check. Left out to avoid touching enum/flags comparison + bitwise
paths in the same change; flag as a follow-up if it bites.
- **arithmetic** `+ - * / %``isArithOperand` (numeric / vector /
pointer). Without it `s64 + string` lowered as `add : s64` and
reinterpreted the string's bytes — garbage.
- **ordering** `< <= > >=``isOrderingOperand` (numeric / enum / pointer
/ bool / vector). Without it `s64 < string` fed mismatched LLVM types to
`icmp` and tripped the verifier.
- **bitwise / shift** `& | ^ << >>``isBitwiseOperand` (integer / enum /
bool / vector). Without it `s64 & string` reinterpreted the bytes.
On mismatch it emits `cannot apply '<op>' to operands of type '<lhs>' and
'<rhs>'` and returns a placeholder sentinel instead of the corrupting op.
The existing optional-unwrap and int×float promotion are applied before the
check. Legitimate mixes — flags-enum bitwise (`.read | .write`,
`perm & .read`), enum/pointer comparison, int literals — are unaffected
(covered by `examples/50-smoke.sx`). Regression test: `examples/214`
(rejects `+ * < & <<` against a `string` operand).
Still NOT covered (left deliberately): equality `==` / `!=`, whose path is
heavily special-cased (string `str_eq`, `Any` unbox, `optional == null`).
`s64 == string` still slips through. Folding a compatibility check into
that path without regressing the special cases is a separate change — open
a fresh issue if it bites.
## Symptom

View File

@@ -2732,12 +2732,20 @@ pub const Lowering = struct {
}
}
// Reject scalar arithmetic on incompatible operand types (e.g.
// `s64 + string`). The result type `ty` is derived from the LHS,
// so without this the op lowers as `add : <lhs>` and reinterprets
// the RHS bytes as the LHS type, silently producing garbage.
switch (bop.op) {
.add, .sub, .mul, .div, .mod => {
// Reject scalar ops on incompatible operand types (e.g.
// `s64 + string`, `s64 < string`, `s64 & string`). The result type
// `ty` is derived from the LHS, so without this the op lowers as
// `<op> : <lhs>` and either reinterprets the RHS bytes (arithmetic
// / bitwise → garbage) or feeds mismatched LLVM types to `icmp`
// (ordering → verifier failure).
{
const group: enum { none, arith, ordering, bitwise } = switch (bop.op) {
.add, .sub, .mul, .div, .mod => .arith,
.lt, .lte, .gt, .gte => .ordering,
.bit_and, .bit_or, .bit_xor, .shl, .shr => .bitwise,
else => .none,
};
if (group != .none) {
const eff_rhs_ty = blk: {
if (rhs_ty == .unresolved) break :blk self.builder.getRefType(rhs);
if (!rhs_ty.isBuiltin()) {
@@ -2746,16 +2754,21 @@ pub const Lowering = struct {
}
break :blk rhs_ty;
};
if (!self.isArithOperand(ty) or !self.isArithOperand(eff_rhs_ty)) {
const ok = switch (group) {
.arith => self.isArithOperand(ty) and self.isArithOperand(eff_rhs_ty),
.ordering => self.isOrderingOperand(ty) and self.isOrderingOperand(eff_rhs_ty),
.bitwise => self.isBitwiseOperand(ty) and self.isBitwiseOperand(eff_rhs_ty),
.none => true,
};
if (!ok) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, bop.lhs.span, "cannot apply '{s}' to operands of type '{s}' and '{s}'", .{
binOpSymbol(bop.op), self.formatTypeName(ty), self.formatTypeName(eff_rhs_ty),
});
}
return self.emitPlaceholder("arith-type-mismatch");
return self.emitPlaceholder("operand-type-mismatch");
}
},
else => {},
}
}
return switch (bop.op) {
@@ -14660,6 +14673,35 @@ pub const Lowering = struct {
};
}
/// Operands valid for ordering comparisons (`< <= > >=`): numbers
/// (incl. custom int widths), enums (ordinal), pointers (address
/// order), bool, and SIMD vectors. NOT strings (no lexicographic `<`
/// lowering exists) or any other aggregate. `.unresolved` passes so an
/// un-inferable operand is never falsely diagnosed.
fn isOrderingOperand(self: *Lowering, ty: TypeId) bool {
if (ty == .unresolved) return true;
if (isInt(ty) or isFloat(ty) or ty == .bool) return true;
if (ty.isBuiltin()) return false;
return switch (self.module.types.get(ty)) {
.signed, .unsigned, .@"enum", .pointer, .many_pointer, .vector => true,
else => false,
};
}
/// Operands valid for bitwise/shift ops (`& | ^ << >>`): integers
/// (incl. custom widths), enums (flags are int-backed), bool, and SIMD
/// vectors. NOT floats, strings, pointers, or aggregates. `.unresolved`
/// passes (see `isOrderingOperand`).
fn isBitwiseOperand(self: *Lowering, ty: TypeId) bool {
if (ty == .unresolved) return true;
if (isInt(ty) or ty == .bool) return true;
if (ty.isBuiltin()) return false;
return switch (self.module.types.get(ty)) {
.signed, .unsigned, .@"enum", .vector => true,
else => false,
};
}
fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) {
.add => "+",

View File

@@ -1,11 +0,0 @@
error: cannot apply '+' to operands of type 's64' and 'string'
--> /Users/agra/projects/sx/examples/214-arith-operand-type-check.sx:12:10
|
12 | x := n + s; // s64 + string — rejected
| ^
error: cannot apply '*' to operands of type 'string' and 's64'
--> /Users/agra/projects/sx/examples/214-arith-operand-type-check.sx:13:10
|
13 | y := s * n; // string * s64 — rejected (non-numeric LHS too)
| ^

View File

@@ -0,0 +1,29 @@
error: cannot apply '+' to operands of type 's64' and 'string'
--> /Users/agra/projects/sx/examples/214-binop-operand-type-check.sx:19:10
|
19 | a := n + s; // arithmetic: s64 + string
| ^
error: cannot apply '*' to operands of type 'string' and 's64'
--> /Users/agra/projects/sx/examples/214-binop-operand-type-check.sx:20:10
|
20 | b := s * n; // arithmetic: non-numeric LHS (string * s64)
| ^
error: cannot apply '<' to operands of type 's64' and 'string'
--> /Users/agra/projects/sx/examples/214-binop-operand-type-check.sx:21:10
|
21 | c := n < s; // ordering: s64 < string
| ^
error: cannot apply '&' to operands of type 's64' and 'string'
--> /Users/agra/projects/sx/examples/214-binop-operand-type-check.sx:22:10
|
22 | d := n & s; // bitwise: s64 & string
| ^
error: cannot apply '<<' to operands of type 's64' and 'string'
--> /Users/agra/projects/sx/examples/214-binop-operand-type-check.sx:23:10
|
23 | e := n << s; // shift: s64 << string
| ^