diff --git a/docs/semantic-contract.md b/docs/semantic-contract.md index f9a30ea..f03213a 100644 --- a/docs/semantic-contract.md +++ b/docs/semantic-contract.md @@ -65,6 +65,19 @@ Comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) promote their operands with the same table and always return `Boolean` semantically and `i1` in LLVM IR. +## Boolean And Comparison Contract + +Boolean behavior is part of the stable semantic boundary: + +- comparisons always return `Boolean` +- `if`, `while`, and `for-count` conditions must be `Boolean` +- `&&`, `||`, and `!` require `Boolean` operands +- implicit truthiness is forbidden for integers, floats, pointers, and other + non-boolean values + +Lowering should branch directly on the analyzed Boolean `i1` value for control +flow instead of inventing zero-comparison truthiness rules during codegen. + ### Canonical Cast Policy Implicit promotions in variable initializers, assignments, call arguments, and diff --git a/src/irx/analysis/handlers/control_flow.py b/src/irx/analysis/handlers/control_flow.py index d7c83db..19aa87e 100644 --- a/src/irx/analysis/handlers/control_flow.py +++ b/src/irx/analysis/handlers/control_flow.py @@ -14,12 +14,34 @@ SemanticAnalyzerCore, SemanticVisitorMixinBase, ) -from irx.analysis.types import is_assignable +from irx.analysis.types import is_assignable, is_boolean_type from irx.typecheck import typechecked @typechecked class ControlFlowVisitorMixin(SemanticVisitorMixinBase): + def _validate_boolean_condition( + self, + condition: astx.AST, + *, + label: str, + ) -> None: + """ + title: Validate a control-flow condition is Boolean. + parameters: + condition: + type: astx.AST + label: + type: str + """ + condition_type = self._expr_type(condition) + if condition_type is None or is_boolean_type(condition_type): + return + self.context.diagnostics.add( + f"{label} condition must be Boolean", + node=condition, + ) + @SemanticAnalyzerCore.visit.dispatch def visit(self, node: astx.FunctionReturn) -> None: """ @@ -54,6 +76,7 @@ def visit(self, node: astx.IfStmt) -> None: type: astx.IfStmt """ self.visit(node.condition) + self._validate_boolean_condition(node.condition, label="if") self.visit(node.then) if node.else_ is not None: self.visit(node.else_) @@ -68,6 +91,7 @@ def visit(self, node: astx.WhileStmt) -> None: type: astx.WhileStmt """ self.visit(node.condition) + self._validate_boolean_condition(node.condition, label="while") with self.context.in_loop(): self.visit(node.body) self._set_type(node, None) @@ -93,6 +117,10 @@ def visit(self, node: astx.ForCountLoopStmt) -> None: ) self._set_symbol(node.initializer, symbol) self.visit(node.condition) + self._validate_boolean_condition( + node.condition, + label="for-count loop", + ) self.visit(node.update) with self.context.in_loop(): self.visit(node.body) diff --git a/src/irx/analysis/handlers/expressions.py b/src/irx/analysis/handlers/expressions.py index c00e143..acef845 100644 --- a/src/irx/analysis/handlers/expressions.py +++ b/src/irx/analysis/handlers/expressions.py @@ -105,6 +105,15 @@ def visit(self, node: astx.UnaryOp) -> None: """ self.visit(node.operand) operand_type = self._expr_type(node.operand) + if ( + node.op_code == "!" + and operand_type is not None + and not is_boolean_type(operand_type) + ): + self.context.diagnostics.add( + "unary operator '!' requires Boolean operand", + node=node, + ) result_type = unary_result_type(node.op_code, operand_type) if node.op_code in {"++", "--"} and isinstance( node.operand, astx.Identifier @@ -200,6 +209,17 @@ def visit(self, node: astx.BinaryOp) -> None: if flags.fma and flags.fma_rhs is not None: self.visit(flags.fma_rhs) + if ( + node.op_code in {"&&", "and", "||", "or"} + and lhs_type is not None + and rhs_type is not None + and not (is_boolean_type(lhs_type) and is_boolean_type(rhs_type)) + ): + self.context.diagnostics.add( + f"logical operator '{node.op_code}' requires Boolean operands", + node=node, + ) + if node.op_code in {"+", "-", "*", "/", "%"} and not ( (is_numeric_type(lhs_type) and is_numeric_type(rhs_type)) or ( diff --git a/src/irx/analysis/typing.py b/src/irx/analysis/typing.py index a694d04..ac2c672 100644 --- a/src/irx/analysis/typing.py +++ b/src/irx/analysis/typing.py @@ -41,9 +41,7 @@ def binary_result_type( if op_code in {"&&", "and", "||", "or"}: if is_boolean_type(lhs_type) and is_boolean_type(rhs_type): return astx.Boolean() - if is_numeric_type(lhs_type) and is_numeric_type(rhs_type): - return common_numeric_type(lhs_type, rhs_type) - return lhs_type if lhs_type == rhs_type else None + return None if op_code == "=": return lhs_type @@ -82,7 +80,7 @@ def unary_result_type( if op_code == "!": if is_boolean_type(operand_type): return astx.Boolean() - return operand_type + return None if op_code in {"++", "--"} and is_numeric_type(operand_type): return operand_type return operand_type diff --git a/src/irx/builder/lowering/binary_ops.py b/src/irx/builder/lowering/binary_ops.py index 7171974..2bfb972 100644 --- a/src/irx/builder/lowering/binary_ops.py +++ b/src/irx/builder/lowering/binary_ops.py @@ -40,7 +40,7 @@ ) from irx.builder.protocols import VisitorMixinBase from irx.builder.runtime import safe_pop -from irx.builder.types import is_fp_type +from irx.builder.types import is_fp_type, is_int_type from irx.builder.vector import emit_add, emit_int_div, is_vector from irx.typecheck import typechecked @@ -153,6 +153,34 @@ def _emit_vector_add( self.set_fast_math(prev_fast_math) return result + def _load_boolean_operands( + self, + node: astx.BinaryOp, + ) -> tuple[ir.Value, ir.Value]: + """ + title: Load Boolean operands for logical operators. + parameters: + node: + type: astx.BinaryOp + returns: + type: tuple[ir.Value, ir.Value] + """ + llvm_lhs, llvm_rhs, _unsigned = self._load_binary_operands( + node, + unify_numeric=False, + ) + if ( + not is_int_type(llvm_lhs.type) + or llvm_lhs.type.width != 1 + or not is_int_type(llvm_rhs.type) + or llvm_rhs.type.width != 1 + ): + raise Exception( + "codegen: logical operator " + f"'{node.op_code}' must lower Boolean operands." + ) + return llvm_lhs, llvm_rhs + def _emit_vector_sub( self, node: SubBinOp, @@ -524,7 +552,7 @@ def visit(self, node: LogicalAndBinOp) -> None: node: type: LogicalAndBinOp """ - llvm_lhs, llvm_rhs, _unsigned = self._load_binary_operands(node) + llvm_lhs, llvm_rhs = self._load_boolean_operands(node) result = self._llvm.ir_builder.and_(llvm_lhs, llvm_rhs, "andtmp") self.result_stack.append(result) @@ -536,7 +564,7 @@ def visit(self, node: LogicalOrBinOp) -> None: node: type: LogicalOrBinOp """ - llvm_lhs, llvm_rhs, _unsigned = self._load_binary_operands(node) + llvm_lhs, llvm_rhs = self._load_boolean_operands(node) result = self._llvm.ir_builder.or_(llvm_lhs, llvm_rhs, "ortmp") self.result_stack.append(result) diff --git a/src/irx/builder/lowering/control_flow.py b/src/irx/builder/lowering/control_flow.py index cac2ce4..e9fb079 100644 --- a/src/irx/builder/lowering/control_flow.py +++ b/src/irx/builder/lowering/control_flow.py @@ -10,13 +10,37 @@ from irx.builder.core import VisitorCore, semantic_symbol_key from irx.builder.protocols import VisitorMixinBase from irx.builder.runtime import safe_pop -from irx.builder.types import is_fp_type +from irx.builder.types import is_fp_type, is_int_type from irx.builder.vector import emit_add from irx.typecheck import typechecked @typechecked class ControlFlowVisitorMixin(VisitorMixinBase): + def _lower_boolean_condition( + self, + value: ir.Value | None, + *, + context: str, + ) -> ir.Value: + """ + title: Require one lowered control-flow condition to be Boolean. + parameters: + value: + type: ir.Value | None + context: + type: str + returns: + type: ir.Value + """ + if value is None: + raise Exception("codegen: Invalid condition expression.") + if not is_int_type(value.type) or value.type.width != 1: + raise Exception( + f"codegen: {context} condition must lower to Boolean." + ) + return value + @VisitorCore.visit.dispatch def visit(self, block: astx.Block) -> None: """ @@ -51,18 +75,10 @@ def visit(self, node: astx.IfStmt) -> None: type: astx.IfStmt """ self.visit_child(node.condition) - cond_v = safe_pop(self.result_stack) - if cond_v is None: - raise Exception("codegen: Invalid condition expression.") - - if is_fp_type(cond_v.type): - cmp_instruction = self._llvm.ir_builder.fcmp_ordered - zero_val = ir.Constant(cond_v.type, 0.0) - else: - cmp_instruction = self._llvm.ir_builder.icmp_signed - zero_val = ir.Constant(cond_v.type, 0) - - cond_v = cmp_instruction("!=", cond_v, zero_val) + cond_v = self._lower_boolean_condition( + safe_pop(self.result_stack), + context="if", + ) then_bb = self._llvm.ir_builder.function.append_basic_block( "bb_if_then" @@ -155,18 +171,10 @@ def visit(self, expr: astx.WhileStmt) -> None: self._llvm.ir_builder.position_at_end(cond_bb) self.visit_child(expr.condition) - cond_val = safe_pop(self.result_stack) - if cond_val is None: - raise Exception("codegen: Invalid condition expression.") - - if is_fp_type(cond_val.type): - cmp_instruction = self._llvm.ir_builder.fcmp_ordered - zero_val = ir.Constant(cond_val.type, 0.0) - else: - cmp_instruction = self._llvm.ir_builder.icmp_signed - zero_val = ir.Constant(cond_val.type, 0) - - cond_val = cmp_instruction("!=", cond_val, zero_val, "whilecond") + cond_val = self._lower_boolean_condition( + safe_pop(self.result_stack), + context="while", + ) self._llvm.ir_builder.cbranch(cond_val, body_bb, after_bb) self.loop_stack.append( { @@ -218,7 +226,10 @@ def visit(self, node: astx.ForCountLoopStmt) -> None: self.named_values[initializer_key] = var_addr self.visit_child(node.condition) - cond_val = safe_pop(self.result_stack) + cond_val = self._lower_boolean_condition( + safe_pop(self.result_stack), + context="for-count loop", + ) loop_body_bb = self._llvm.ir_builder.function.append_basic_block( "loop.body" diff --git a/src/irx/builder/lowering/unary_ops.py b/src/irx/builder/lowering/unary_ops.py index fbcf9f3..4374a3b 100644 --- a/src/irx/builder/lowering/unary_ops.py +++ b/src/irx/builder/lowering/unary_ops.py @@ -10,7 +10,7 @@ from irx.builder.core import VisitorCore, semantic_symbol_key from irx.builder.protocols import VisitorMixinBase from irx.builder.runtime import safe_pop -from irx.builder.types import is_fp_type +from irx.builder.types import is_fp_type, is_int_type from irx.typecheck import typechecked @@ -88,19 +88,17 @@ def visit(self, node: astx.UnaryOp) -> None: val = safe_pop(self.result_stack) if val is None: raise Exception("codegen: Invalid unary operand.") + if not is_int_type(val.type) or val.type.width != 1: + raise Exception( + "codegen: unary operator '!' must lower a Boolean operand." + ) - zero = ir.Constant(val.type, 0) - is_zero = self._llvm.ir_builder.icmp_signed( - "==", val, zero, "iszero" + result = self._llvm.ir_builder.xor( + val, + ir.Constant(self._llvm.BOOLEAN_TYPE, 1), + "nottmp", ) - if isinstance(val.type, ir.IntType) and val.type.width == 1: - result = is_zero - else: - result = self._llvm.ir_builder.zext( - is_zero, val.type, "nottmp" - ) - if isinstance(node.operand, astx.Identifier): operand_key = semantic_symbol_key( node.operand, node.operand.name diff --git a/tests/test_binary_op.py b/tests/test_binary_op.py index 6d0d0d4..00cbcc2 100644 --- a/tests/test_binary_op.py +++ b/tests/test_binary_op.py @@ -189,35 +189,24 @@ def test_binary_op_string_not_equals(builder_class: type[Builder]) -> None: @pytest.mark.parametrize( - "int_type,literal_type,a_val,b_val,expect", - [ - # use 0/1 so bitwise and/or behave like logical - (astx.Int32, astx.LiteralInt32, 1, 0, "1"), - (astx.Int16, astx.LiteralInt16, 1, 1, "1"), - ], + "a_val,b_val,expect", [(True, False, "1"), (False, False, "0")] ) @pytest.mark.parametrize("builder_class", [LLVMBuilder]) def test_binary_op_logical_and_or( builder_class: type[Builder], - int_type: type, - literal_type: type, - a_val: int, - b_val: int, + a_val: bool, + b_val: bool, expect: str, ) -> None: """ - title: Verify '&&' and '||' for integer booleans (0/1). + title: Verify Boolean '&&' and '||' lowering and execution. parameters: builder_class: type: type[Builder] - int_type: - type: type - literal_type: - type: type a_val: - type: int + type: bool b_val: - type: int + type: bool expect: type: str """ @@ -226,31 +215,34 @@ def test_binary_op_logical_and_or( decl_x = astx.VariableDeclaration( name="x", - type_=int_type(), - value=literal_type(a_val), + type_=astx.Boolean(), + value=astx.LiteralBoolean(a_val), mutability=astx.MutabilityKind.mutable, ) decl_y = astx.VariableDeclaration( name="y", - type_=int_type(), - value=literal_type(b_val), + type_=astx.Boolean(), + value=astx.LiteralBoolean(b_val), mutability=astx.MutabilityKind.mutable, ) - expr = (astx.Identifier("x") & astx.Identifier("x")) | astx.Identifier("y") - assign = astx.VariableAssignment(name="x", value=expr) - - print_ok = PrintExpr(astx.LiteralUTF8String(expect)) + expr = astx.BinaryOp( + "||", + astx.BinaryOp( + "&&", + astx.Identifier("x"), + astx.Identifier("x"), + ), + astx.Identifier("y"), + ) main_proto = astx.FunctionPrototype( - name="main", args=astx.Arguments(), return_type=astx.Int32() + name="main", args=astx.Arguments(), return_type=astx.Boolean() ) main_block = astx.Block() main_block.append(decl_x) main_block.append(decl_y) - main_block.append(assign) - main_block.append(print_ok) - main_block.append(astx.FunctionReturn(astx.LiteralInt32(0))) + main_block.append(astx.FunctionReturn(expr)) main_fn = astx.FunctionDef(prototype=main_proto, body=main_block) module.block.append(main_fn) diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 7cc0bcb..7c4ea1e 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -1,140 +1,532 @@ """ -title: Tests for Boolean logic and comparisons. +title: Tests for Boolean semantics, lowering, and comparisons. """ +from __future__ import annotations + import pytest from irx import astx +from irx.analysis import SemanticError, analyze from irx.builder import Builder as LLVMBuilder from irx.builder.base import Builder -from .conftest import check_result +from .conftest import assert_ir_parses, make_main_module -@pytest.mark.parametrize( - "lhs,op,rhs,expected", - [ - (True, "&&", True, "1"), - (True, "||", False, "1"), - ], -) -@pytest.mark.parametrize( - "builder_class", - [ - LLVMBuilder, - ], -) -def test_boolean_operations( +def _boolean_main_module(*nodes: astx.AST) -> astx.Module: + """ + title: Build a module with one Boolean-returning main function. + parameters: + nodes: + type: astx.AST + variadic: positional + returns: + type: astx.Module + """ + return make_main_module(*nodes, return_type=astx.Boolean()) + + +def _int_main_module(*nodes: astx.AST) -> astx.Module: + """ + title: Build a module with one int32-returning main function. + parameters: + nodes: + type: astx.AST + variadic: positional + returns: + type: astx.Module + """ + return make_main_module(*nodes, return_type=astx.Int32()) + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_literal_declaration_and_assignment( builder_class: type[Builder], - lhs: bool, - op: str, - rhs: bool, - expected: str, ) -> None: """ - title: Test literal Boolean AND/OR operations. + title: Boolean locals should round-trip through declaration and assignment. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + module = _boolean_main_module( + astx.VariableDeclaration( + name="flag", + type_=astx.Boolean(), + value=astx.LiteralBoolean(True), + mutability=astx.MutabilityKind.mutable, + ), + astx.VariableAssignment( + "flag", + astx.LiteralBoolean(False), + ), + astx.FunctionReturn(astx.Identifier("flag")), + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "alloca i1" in ir_text + assert "store i1 1" in ir_text + assert "store i1 0" in ir_text + assert "ret i1" in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_function_accepts_boolean_parameter_and_returns_boolean( + builder_class: type[Builder], +) -> None: + """ + title: >- + Boolean params and returns should lower cleanly as first-class values. parameters: builder_class: type: type[Builder] - lhs: - type: bool - op: - type: str - rhs: - type: bool - expected: - type: str """ builder = builder_class() module = builder.module() - # build the ASTx expression: (lhs && rhs) or (lhs || rhs) - left = astx.LiteralBoolean(lhs) - right = astx.LiteralBoolean(rhs) - expr = astx.BinaryOp(op, left, right) + negate_proto = astx.FunctionPrototype( + name="negate", + args=astx.Arguments(astx.Argument("flag", astx.Boolean())), + return_type=astx.Boolean(), + ) + negate_body = astx.Block() + negate_body.append( + astx.FunctionReturn( + astx.UnaryOp(op_code="!", operand=astx.Identifier("flag")) + ) + ) + module.block.append( + astx.FunctionDef(prototype=negate_proto, body=negate_body) + ) - proto = astx.FunctionPrototype( + main_proto = astx.FunctionPrototype( name="main", args=astx.Arguments(), return_type=astx.Boolean(), ) - block = astx.Block() - block.append(astx.FunctionReturn(expr)) - fn = astx.FunctionDef(prototype=proto, body=block) - module.block.append(fn) + main_body = astx.Block() + main_body.append( + astx.FunctionReturn( + astx.FunctionCall("negate", [astx.LiteralBoolean(True)]) + ) + ) + module.block.append(astx.FunctionDef(prototype=main_proto, body=main_body)) - check_result("build", builder, module, expected_output=expected) + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "define i1" in ir_text + assert "call i1" in ir_text + assert "xor i1" in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_comparison_result_assigns_to_boolean_variable( + builder_class: type[Builder], +) -> None: + """ + title: Comparison results should assign into Boolean locals. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + module = _boolean_main_module( + astx.VariableDeclaration( + name="is_less", + type_=astx.Boolean(), + value=astx.BinaryOp( + "<", + astx.LiteralInt32(1), + astx.LiteralInt32(2), + ), + mutability=astx.MutabilityKind.mutable, + ), + astx.FunctionReturn(astx.Identifier("is_less")), + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "icmp " in ir_text + assert "store i1" in ir_text + assert "ret i1" in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_comparison_result_returns_from_function( + builder_class: type[Builder], +) -> None: + """ + title: Comparison results should return directly from Boolean functions. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + module = builder.module() + + compare_proto = astx.FunctionPrototype( + name="less", + args=astx.Arguments( + astx.Argument("lhs", astx.Int32()), + astx.Argument("rhs", astx.Int32()), + ), + return_type=astx.Boolean(), + ) + compare_body = astx.Block() + compare_body.append( + astx.FunctionReturn( + astx.BinaryOp( + "<", + astx.Identifier("lhs"), + astx.Identifier("rhs"), + ) + ) + ) + module.block.append( + astx.FunctionDef(prototype=compare_proto, body=compare_body) + ) + + main_proto = astx.FunctionPrototype( + name="main", + args=astx.Arguments(), + return_type=astx.Boolean(), + ) + main_body = astx.Block() + main_body.append( + astx.FunctionReturn( + astx.FunctionCall( + "less", + [astx.LiteralInt32(1), astx.LiteralInt32(2)], + ) + ) + ) + module.block.append(astx.FunctionDef(prototype=main_proto, body=main_body)) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "define i1" in ir_text + assert "call i1" in ir_text + assert "icmp " in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_condition_in_if(builder_class: type[Builder]) -> None: + """ + title: If statements should accept Boolean conditions only. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + then_block = astx.Block() + then_block.append(astx.FunctionReturn(astx.LiteralInt32(1))) + else_block = astx.Block() + else_block.append(astx.FunctionReturn(astx.LiteralInt32(0))) + + module = _int_main_module( + astx.VariableDeclaration( + name="flag", + type_=astx.Boolean(), + value=astx.LiteralBoolean(True), + mutability=astx.MutabilityKind.mutable, + ), + astx.IfStmt( + condition=astx.Identifier("flag"), + then=then_block, + else_=else_block, + ), + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "br i1" in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_condition_in_while(builder_class: type[Builder]) -> None: + """ + title: While statements should branch on Boolean locals. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + body = astx.Block() + body.append(astx.VariableAssignment("count", astx.LiteralInt32(1))) + body.append(astx.VariableAssignment("flag", astx.LiteralBoolean(False))) + + module = _int_main_module( + astx.VariableDeclaration( + name="flag", + type_=astx.Boolean(), + value=astx.LiteralBoolean(True), + mutability=astx.MutabilityKind.mutable, + ), + astx.VariableDeclaration( + name="count", + type_=astx.Int32(), + value=astx.LiteralInt32(0), + mutability=astx.MutabilityKind.mutable, + ), + astx.WhileStmt(condition=astx.Identifier("flag"), body=body), + astx.FunctionReturn(astx.Identifier("count")), + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "br i1" in ir_text + assert "icmp " not in ir_text + assert "fcmp " not in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_condition_in_for_count_loop( + builder_class: type[Builder], +) -> None: + """ + title: For-count loops should branch on Boolean conditions. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + loop_body = astx.Block() + loop_body.append(astx.VariableAssignment("count", astx.LiteralInt32(1))) + + module = _int_main_module( + astx.VariableDeclaration( + name="count", + type_=astx.Int32(), + value=astx.LiteralInt32(0), + mutability=astx.MutabilityKind.mutable, + ), + astx.ForCountLoopStmt( + initializer=astx.InlineVariableDeclaration( + name="flag", + type_=astx.Boolean(), + value=astx.LiteralBoolean(True), + mutability=astx.MutabilityKind.mutable, + ), + condition=astx.Identifier("flag"), + update=astx.BinaryOp( + "=", + astx.Identifier("flag"), + astx.LiteralBoolean(False), + ), + body=loop_body, + ), + astx.FunctionReturn(astx.Identifier("count")), + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "br i1" in ir_text + assert "icmp " not in ir_text + assert "fcmp " not in ir_text + assert "loop.body" in ir_text @pytest.mark.parametrize( - "num_type,literal_type", - [ - (astx.Int8, astx.LiteralInt8), - (astx.Int16, astx.LiteralInt16), - (astx.Int32, astx.LiteralInt32), - (astx.Int64, astx.LiteralInt64), - (astx.Float32, astx.LiteralFloat32), - (astx.Float64, astx.LiteralFloat64), - ], -) -@pytest.mark.parametrize( - "lhs,op,rhs,expected", - [ - (1, "<", 2, "1"), - (6, ">=", 6, "1"), - (1, "==", 1, "1"), - (1, "!=", 2, "1"), - ], -) -@pytest.mark.parametrize( - "builder_class", + "op", [ - LLVMBuilder, + "&&", + "||", ], ) -def test_boolean_comparison( +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_logical_binary_operations( builder_class: type[Builder], - num_type: type, - literal_type: type, - lhs: int, op: str, - rhs: int, - expected: str, ) -> None: """ - title: Test numeric comparisons for integers and floats. + title: Logical binary operators should accept only Boolean operands. parameters: builder_class: type: type[Builder] - num_type: - type: type - literal_type: - type: type - lhs: - type: int op: type: str - rhs: - type: int - expected: - type: str """ builder = builder_class() - module = builder.module() + module = _boolean_main_module( + astx.FunctionReturn( + astx.BinaryOp( + op, + astx.LiteralBoolean(True), + astx.LiteralBoolean(False), + ) + ) + ) - # build e.g. LiteralInt32(1) < LiteralInt32(2) - left = literal_type(lhs) - right = literal_type(rhs) - expr = astx.BinaryOp(op, left, right) + ir_text = builder.translate(module) - proto = astx.FunctionPrototype( - name="main", - args=astx.Arguments(), - return_type=astx.Boolean(), + assert_ir_parses(ir_text) + assert ("and i1" if op == "&&" else "or i1") in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_logical_not(builder_class: type[Builder]) -> None: + """ + title: Logical not should operate on Boolean values and return Boolean. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + module = _boolean_main_module( + astx.FunctionReturn(astx.UnaryOp("!", astx.LiteralBoolean(True))) + ) + + ir_text = builder.translate(module) + + assert_ir_parses(ir_text) + assert "xor i1" in ir_text + + +@pytest.mark.parametrize("builder_class", [LLVMBuilder]) +def test_boolean_if_condition_branches_without_truthiness_compare( + builder_class: type[Builder], +) -> None: + """ + title: >- + Boolean conditions should branch directly on i1 without zero compares. + parameters: + builder_class: + type: type[Builder] + """ + builder = builder_class() + then_block = astx.Block() + then_block.append(astx.FunctionReturn(astx.LiteralInt32(1))) + else_block = astx.Block() + else_block.append(astx.FunctionReturn(astx.LiteralInt32(0))) + + module = _int_main_module( + astx.VariableDeclaration( + name="flag", + type_=astx.Boolean(), + value=astx.LiteralBoolean(True), + mutability=astx.MutabilityKind.mutable, + ), + astx.IfStmt( + condition=astx.Identifier("flag"), + then=then_block, + else_=else_block, + ), + ) + + ir_text = builder.translate(module) + + assert "br i1" in ir_text + assert "icmp " not in ir_text + assert "fcmp " not in ir_text + + +def test_rejects_if_integer_condition() -> None: + """ + title: If conditions must be Boolean semantically. + """ + module = _int_main_module( + astx.IfStmt(condition=astx.LiteralInt32(1), then=astx.Block()), + astx.FunctionReturn(astx.LiteralInt32(0)), + ) + + with pytest.raises(SemanticError, match="if condition must be Boolean"): + analyze(module) + + +def test_rejects_while_float_condition() -> None: + """ + title: While conditions must be Boolean semantically. + """ + module = _int_main_module( + astx.WhileStmt( + condition=astx.LiteralFloat64(3.14), + body=astx.Block(), + ), + astx.FunctionReturn(astx.LiteralInt32(0)), ) - block = astx.Block() - block.append(astx.FunctionReturn(expr)) - fn = astx.FunctionDef(prototype=proto, body=block) - module.block.append(fn) - check_result("build", builder, module, expected_output=expected) + with pytest.raises(SemanticError, match="while condition must be Boolean"): + analyze(module) + + +def test_rejects_non_boolean_for_count_condition() -> None: + """ + title: For-count loop conditions must be Boolean semantically. + """ + module = _int_main_module( + astx.ForCountLoopStmt( + initializer=astx.InlineVariableDeclaration( + name="i", + type_=astx.Int32(), + value=astx.LiteralInt32(0), + mutability=astx.MutabilityKind.mutable, + ), + condition=astx.LiteralInt32(1), + update=astx.UnaryOp("++", astx.Identifier("i")), + body=astx.Block(), + ), + astx.FunctionReturn(astx.LiteralInt32(0)), + ) + + with pytest.raises( + SemanticError, + match="for-count loop condition must be Boolean", + ): + analyze(module) + + +def test_rejects_integer_logical_and() -> None: + """ + title: Logical and must reject non-Boolean operands. + """ + expr = astx.BinaryOp( + "&&", + astx.LiteralInt32(1), + astx.LiteralInt32(2), + ) + + with pytest.raises( + SemanticError, + match=r"logical operator '&&' requires Boolean operands", + ): + analyze(expr) + + +def test_rejects_integer_logical_or() -> None: + """ + title: Logical or must reject non-Boolean operands. + """ + expr = astx.BinaryOp( + "||", + astx.LiteralInt32(1), + astx.LiteralInt32(2), + ) + + with pytest.raises( + SemanticError, + match=r"logical operator '\|\|' requires Boolean operands", + ): + analyze(expr) + + +def test_rejects_integer_logical_not() -> None: + """ + title: Logical not must reject non-Boolean operands. + """ + expr = astx.UnaryOp("!", astx.LiteralInt32(1)) + + with pytest.raises( + SemanticError, + match=r"unary operator '!' requires Boolean operand", + ): + analyze(expr) diff --git a/tests/test_break_continue.py b/tests/test_break_continue.py index 0718a3a..3693813 100644 --- a/tests/test_break_continue.py +++ b/tests/test_break_continue.py @@ -49,7 +49,7 @@ def test_break_exits_to_after_block(builder_class: type[Builder]) -> None: body.append(astx.BreakStmt()) loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=body, ) @@ -89,7 +89,7 @@ def test_break_skips_remaining_statements( ) loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=body, ) @@ -121,7 +121,7 @@ def test_nested_loop_break_affects_only_inner( inner_body.append(astx.BreakStmt()) inner_loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=inner_body, ) @@ -129,7 +129,7 @@ def test_nested_loop_break_affects_only_inner( outer_body.append(inner_loop) outer_loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=outer_body, ) @@ -161,7 +161,7 @@ def test_continue_branches_to_condition(builder_class: type[Builder]) -> None: body.append(astx.ContinueStmt()) loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=body, ) @@ -201,7 +201,7 @@ def test_continue_skips_remaining_statements( ) loop = astx.WhileStmt( - condition=astx.LiteralInt32(1), + condition=astx.LiteralBoolean(True), body=body, ) diff --git a/tests/test_unary_op.py b/tests/test_unary_op.py index 717e196..d26b84b 100644 --- a/tests/test_unary_op.py +++ b/tests/test_unary_op.py @@ -5,6 +5,7 @@ import pytest from irx import astx +from irx.analysis import SemanticError, analyze from irx.builder import Builder as LLVMBuilder from irx.builder.base import Builder @@ -73,25 +74,13 @@ def test_unary_op_increment_decrement( value=literal_type(10), mutability=astx.MutabilityKind.mutable, ) - decl_c = astx.VariableDeclaration( - name="c", - type_=int_type(), - value=literal_type(0), - mutability=astx.MutabilityKind.mutable, - ) - var_a = astx.Identifier("a") var_b = astx.Identifier("b") - var_c = astx.Identifier("c") incr_a = astx.UnaryOp(op_code="++", operand=var_a) incr_a.type_ = int_type() decr_b = astx.UnaryOp(op_code="--", operand=var_b) decr_b.type_ = int_type() - not_c = astx.UnaryOp(op_code="!", operand=var_c) - not_c.type_ = int_type() - - final_expr = incr_a + decr_b + not_c main_proto = astx.FunctionPrototype( name="main", args=astx.Arguments(), return_type=int_type() @@ -99,8 +88,8 @@ def test_unary_op_increment_decrement( main_block = astx.Block() main_block.append(decl_a) main_block.append(decl_b) - main_block.append(decl_c) - main_block.append(final_expr) + main_block.append(incr_a) + main_block.append(decr_b) main_block.append(astx.FunctionReturn(literal_type(0))) main_fn = astx.FunctionDef(prototype=main_proto, body=main_block) @@ -177,72 +166,40 @@ def test_unary_op_increment_decrement_float( @pytest.mark.parametrize( - "int_type, literal_type, value, expected_output", - [ - (astx.Int32, astx.LiteralInt32, 0, "1"), - (astx.Int32, astx.LiteralInt32, 5, "0"), - (astx.Int32, astx.LiteralInt32, -3, "0"), - (astx.Int16, astx.LiteralInt16, 0, "1"), - (astx.Int16, astx.LiteralInt16, 7, "0"), - (astx.UInt32, astx.LiteralUInt32, 0, "1"), - (astx.UInt32, astx.LiteralUInt32, 10, "0"), - ], -) -@pytest.mark.parametrize( - "builder_class", + "int_type, literal_type, value", [ - LLVMBuilder, + (astx.Int32, astx.LiteralInt32, 0), + (astx.Int32, astx.LiteralInt32, 5), + (astx.Int32, astx.LiteralInt32, -3), + (astx.Int16, astx.LiteralInt16, 0), + (astx.Int16, astx.LiteralInt16, 7), + (astx.UInt32, astx.LiteralUInt32, 0), + (astx.UInt32, astx.LiteralUInt32, 10), ], ) -def test_unary_op_logical_not_int( - builder_class: type[Builder], +def test_unary_op_logical_not_int_rejected( int_type: type, literal_type: type, value: int, - expected_output: str, ) -> None: """ - title: Test logical NOT (!) for integer types. + title: Logical NOT should reject integer operands. parameters: - builder_class: - type: type[Builder] int_type: type: type literal_type: type: type value: type: int - expected_output: - type: str """ - builder = builder_class() - module = builder.module() - - decl_a = astx.VariableDeclaration( - name="a", - type_=int_type(), - value=literal_type(value), - mutability=astx.MutabilityKind.mutable, - ) - - var_a = astx.Identifier("a") - - not_a = astx.UnaryOp(op_code="!", operand=var_a) - not_a.type_ = int_type() - - main_proto = astx.FunctionPrototype( - name="main", args=astx.Arguments(), return_type=int_type() - ) - main_block = astx.Block() - main_block.append(decl_a) - main_block.append(not_a) - main_block.append(astx.FunctionReturn(astx.Identifier("a"))) - - main_fn = astx.FunctionDef(prototype=main_proto, body=main_block) - - module.block.append(main_fn) - - check_result("build", builder, module, expected_output=expected_output) + del int_type + expr = astx.UnaryOp(op_code="!", operand=literal_type(value)) + + with pytest.raises( + SemanticError, + match=r"unary operator '!' requires Boolean operand", + ): + analyze(expr) @pytest.mark.parametrize(