From e2e56b70ba33fe1c974f76b50315e2f9b418c75c Mon Sep 17 00:00:00 2001 From: Jaskirat-s7 Date: Fri, 28 Aug 2026 23:45:56 +0530 Subject: [PATCH] feat(arxjit): lower comparisons and boolean operators to astx Adds the comparison and logical half of the expression layer, plus the type inference both need. A comparison lowers to an astx.BinaryOp carrying the comparison's op code, not to astx.CompareOp: IRx's visitor for CompareOp is _not_implemented, so a CompareOp would pass this stage and fail codegen, while the six comparison op codes are exactly the ones IRx resolves to Boolean. A chain becomes the conjunction Python defines it as, and an n-ary and/or folds left into astx's binary node. Operands cannot be lowered at the expected type the way an arithmetic operand is: at a condition the expected type is the bool the comparison yields, so "a < 3" would ask for 3 as a bool. Inference over a scope of the function's parameters supplies an operand type instead, widening to the type that can represent both sides. Also, from the #109 review: - A negated bool literal is now refused. bool is a subclass of int and negating one in Python yields an int, so -True folded to Int64 -1, putting an integer literal where the user wrote a bool. - Every binary node is built through one guard that refuses an operand astx cannot combine. astx requires a DataType on both operands and gives its UnaryOp the generic ExprType, so "not a" in either position raised a bare Exception out of astx with no location on it. The arithmetic form of this, "a + (not b)", failed the same way before this change; both are now located diagnostics. The real fix belongs upstream in astx. Two unreachable branches found while covering this are removed rather than left in: the boolean operator lookup has no failing case, since Python defines exactly two and both are mapped, and a rank lookup cannot fail because a signature naming an unmapped type is refused while the prototype is built. 285 tests, lowering.py at 100% line and branch coverage, verified on CPython 3.10, 3.11 and 3.14. --- packages/arxjit/src/arxjit/lowering.py | 388 ++++++++++++++++++++- packages/arxjit/tests/test_lowering.py | 461 ++++++++++++++++++++++++- 2 files changed, 830 insertions(+), 19 deletions(-) diff --git a/packages/arxjit/src/arxjit/lowering.py b/packages/arxjit/src/arxjit/lowering.py index 3941441..af7de7e 100644 --- a/packages/arxjit/src/arxjit/lowering.py +++ b/packages/arxjit/src/arxjit/lowering.py @@ -7,12 +7,14 @@ IRx compiles. Dispatch is by node type via plum, matching the visitor convention used across the Arx packages. What is lowered so far is the function shell — the prototype and its typed arguments — and straight-line - expressions: literals, parameter reads, and the arithmetic, unary and - comparison-free operators astx shares with IRx. Local assignments and control - flow follow in later stages, and until then any other construct fails closed - with LoweringError. The decorator does not call this stage yet, so nothing - here changes what @jit does; that wiring lands once the lowerer covers the - whole validated subset. + expressions: literals, parameter reads, and the arithmetic, unary, comparison + and logical operators astx shares with IRx. Where an expression is not + lowered at a type its context declares, the type is inferred from the + expression itself, over a scope holding the parameters. Local assignments and + control flow follow in later stages, and until then any other construct fails + closed with LoweringError. The decorator does not call this stage yet, so + nothing here changes what @jit does; that wiring lands once the lowerer + covers the whole validated subset. """ from __future__ import annotations @@ -37,7 +39,7 @@ from arxjit.errors import LoweringError from arxjit.locations import diagnostic from arxjit.source import ExtractedSource -from arxjit.types import Signature, SigType +from arxjit.types import Signature, SigType, bool_, f64, i64 class _Scalar(NamedTuple): @@ -120,6 +122,40 @@ class _Scalar(NamedTuple): ast.Not: "!", } +# A comparison lowers to a BinaryOp carrying the comparison's op_code, not to +# astx.CompareOp: IRx's visitor for that node is _not_implemented, so a +# CompareOp would pass this stage and fail codegen, while these six op codes +# are exactly the ones irx.analysis.typing resolves to Boolean. +# test_the_comparison_tables_agree_with_irx pins the two together. +_COMPARE_OPS: dict[type[ast.cmpop], str] = { + ast.Eq: "==", + ast.NotEq: "!=", + ast.Lt: "<", + ast.LtE: "<=", + ast.Gt: ">", + ast.GtE: ">=", +} + +# IRx accepts both spellings of each logical operator; the symbolic ones are +# used here because that is what its BinaryOp handler special-cases first. +_BOOL_OPS: dict[type[ast.boolop], str] = { + ast.And: "&&", + ast.Or: "||", +} + +# How a type is chosen for operands of differing types: the wider one wins, so +# a comparison happens at a type that can represent both sides rather than one +# that silently narrows either. Float outranks every integer because mixing the +# two compares as float, which is the promotion Python itself performs, and +# bool ranks lowest because it is the only type every other one can hold. +_TYPE_RANK: dict[str, int] = { + "Boolean": 0, + "Int32": 1, + "Int64": 2, + "Float32": 3, + "Float64": 4, +} + # IRx reserves "main" as the program entry point and requires it to take no # parameters and return Int32, so a decorated Python function of that name # cannot be emitted under its own name. Kept as a literal rather than imported @@ -258,6 +294,9 @@ class Lowerer: description: The extracted source being lowered. signature: description: The signature reconciliation settled on. + scope: + type: dict[str, SigType] + description: The type of every name a lowered expression may read. """ def __init__( @@ -267,6 +306,13 @@ def __init__( ) -> None: """ title: Initialize the lowerer for one function. + summary: >- + The scope is seeded with the parameters, which are the only names in + it until local assignment is lowered. It is built by zipping rather + than after checking the two agree, so that constructing a lowerer + raises nothing: a signature that declares the wrong number of types + is arguments' to report, and it says so in terms of the whole + function rather than of whichever parameter ran out first. parameters: extracted: type: ExtractedSource @@ -275,6 +321,13 @@ def __init__( """ self.extracted = extracted self.signature = signature + args = extracted.node.args + self.scope: dict[str, SigType] = { + parameter.arg: sig_type + for parameter, sig_type in zip( + [*args.posonlyargs, *args.args], signature.arg_types + ) + } @private def reject(self, node: ast.AST, message: str) -> LoweringError: @@ -494,11 +547,11 @@ def expression(self, node: ast.BinOp, expected: SigType) -> astx.DataType: f"cannot lower the {name} operator: astx has no binary" " operator for it", ) - return astx.BinaryOp( + return self._binary( + node, op_code, self.expression(node.left, expected), self.expression(node.right, expected), - loc=location(self.extracted, node), ) @dispatch @@ -528,10 +581,22 @@ def expression( return self.expression(node.operand, expected) if isinstance(node.op, ast.USub): operand = node.operand - if isinstance(operand, ast.Constant) and isinstance( - operand.value, (int, float) - ): - return self.literal(node, -operand.value, expected) + if isinstance(operand, ast.Constant): + value = operand.value + # bool before int, as everywhere a literal's kind is read: + # bool is a subclass of int, but negating one in Python + # produces an integer, so folding -True would put an int + # literal where the user wrote a bool and lose the rejection + # _literal_value would otherwise make. + if isinstance(value, bool): + raise self.reject( + node, + "cannot lower a negated bool literal: negation makes" + " it an integer, changing the type of a value the" + " subset admits only as a bool", + ) + if isinstance(value, (int, float)): + return self.literal(node, -value, expected) raise self.reject( node, "cannot lower a negation of anything but a literal: IRx" @@ -551,6 +616,303 @@ def expression( loc=location(self.extracted, node), ) + @dispatch + def expression( + self, node: ast.Compare, expected: SigType + ) -> astx.DataType: + """ + title: Lower a comparison, chained or not. + summary: >- + The operands are lowered at one type wide enough for all of them + rather than at the expected type, which is the type of the comparison + itself and never the type being compared: at a condition the expected + type is bool, and lowering ``a < 3`` there would ask for 3 as a bool. + A chain becomes the conjunction Python defines it as, which repeats + every operand but the outermost. That is safe only because the subset + admits no expression with an effect to repeat, so an operand + evaluated twice yields what it yielded the first time. + parameters: + node: + type: ast.Compare + expected: + type: SigType + description: Unused; a comparison is a bool whatever its context. + returns: + type: astx.DataType + raises: + LoweringError: If a comparison operator has no IRx equivalent. + """ + del expected + operands = [node.left, *node.comparators] + operand_type = self.infer(operands[0]) + for operand in operands[1:]: + operand_type = self._wider(operand_type, self.infer(operand)) + links = [] + for index, operator in enumerate(node.ops): + op_code = _COMPARE_OPS.get(type(operator)) + if op_code is None: + name = type(operator).__name__ + raise self.reject( + node, + f"cannot lower the {name} comparison: IRx has no" + " comparison operator for it", + ) + links.append( + self._binary( + node, + op_code, + self.expression(operands[index], operand_type), + self.expression(operands[index + 1], operand_type), + ) + ) + return self._fold(node, links, "&&") + + @dispatch + def expression(self, node: ast.BoolOp, expected: SigType) -> astx.DataType: + """ + title: Lower an and/or expression. + summary: >- + Each operand is lowered at its own type rather than at the expected + one, for the same reason a comparison's are. Python's and/or evaluate + to one of their operands rather than to a bool; lowered they are the + logical operators IRx implements, which is the same answer only where + the operands are already bools, and IRx is what rejects the operands + where they are not. An n-ary chain folds left into the binary node + IRx has, which preserves the order the operands were written in. The + operator is looked up without a guard, unlike every other table here: + Python defines exactly two boolean operators and both are mapped, so + there is no third one a lookup could fail to find. + parameters: + node: + type: ast.BoolOp + expected: + type: SigType + description: Unused; the result is logical whatever its context. + returns: + type: astx.DataType + """ + del expected + op_code = _BOOL_OPS[type(node.op)] + values = [ + self.expression(value, self.infer(value)) for value in node.values + ] + return self._fold(node, values, op_code) + + @private + def _binary( + self, + node: ast.expr, + op_code: str, + lhs: astx.DataType, + rhs: astx.DataType, + ) -> astx.BinaryOp: + """ + title: Build a binary node, refusing operands astx cannot combine. + summary: >- + astx requires both operands of a binary operator to carry a DataType, + and its UnaryOp carries the generic ExprType instead, so ``not a`` in + either position raises a bare Exception out of astx's constructor + with no source location on it. Checked here so the user gets a + located diagnostic naming the construct, the way every other + unlowerable expression is reported. The real fix belongs upstream in + astx, where giving UnaryOp the type of its operand would make these + compose. + parameters: + node: + type: ast.expr + op_code: + type: str + lhs: + type: astx.DataType + rhs: + type: astx.DataType + returns: + type: astx.BinaryOp + raises: + LoweringError: If either operand carries no astx DataType. + """ + for operand in (lhs, rhs): + if not isinstance(operand.type_, astx.DataType): + raise self.reject( + node, + "cannot lower a unary operation as the operand of a" + " binary one: astx gives it no data type to combine", + ) + return astx.BinaryOp( + op_code, lhs, rhs, loc=location(self.extracted, node) + ) + + @private + def _fold( + self, node: ast.expr, operands: list[astx.DataType], op_code: str + ) -> astx.DataType: + """ + title: Combine two or more operands with one left-associative operator. + summary: >- + astx's binary node takes exactly two operands, so anything wider has + to be folded; left is the association Python gives both the operators + this serves. + parameters: + node: + type: ast.expr + operands: + type: list[astx.DataType] + op_code: + type: str + returns: + type: astx.DataType + """ + folded = operands[0] + for operand in operands[1:]: + folded = self._binary(node, op_code, folded, operand) + return folded + + @private + def _wider(self, left: SigType, right: SigType) -> SigType: + """ + title: Pick the type that can represent both of two operand types. + summary: >- + Every type reaching here is ranked: a signature naming one this stage + has no astx class for is refused while the prototype is built, before + any of the body is lowered, and test_every_sig_type_is_ranked pins + the rank table to the scalar table so the two cannot drift apart. + parameters: + left: + type: SigType + right: + type: SigType + returns: + type: SigType + """ + return max((left, right), key=lambda t: _TYPE_RANK[t.astx_name]) + + @dispatch + def infer(self, node: ast.AST) -> SigType: + """ + title: Refuse to infer a type for a node with no overload. + summary: >- + Fails closed for the same reason the lowering dispatch does: this + runs on a validated function, so a node reaching here means inference + and the subset disagree, which must surface rather than resolve to + some default type the expression does not have. + parameters: + node: + type: ast.AST + returns: + type: SigType + raises: + LoweringError: Always. + """ + kind = type(node).__name__ + raise self.reject( + node, f"cannot infer the type of a {kind} expression" + ) + + @dispatch + def infer(self, node: ast.Constant) -> SigType: + """ + title: Infer the type of a literal from its Python kind. + summary: >- + The widest type of each kind, because a literal standing on its own + has only Python's own notion of its type to go on: Python integers + are unbounded and its floats are doubles. A narrower type is still + reached wherever the context declares one, which is what the expected + type passed to lowering is for. + parameters: + node: + type: ast.Constant + returns: + type: SigType + raises: + LoweringError: If the literal is of no kind the subset admits. + """ + value = node.value + if isinstance(value, bool): + return bool_ + if isinstance(value, int): + return i64 + if isinstance(value, float): + return f64 + name = type(value).__name__ + raise self.reject(node, f"cannot infer the type of a {name} literal") + + @dispatch + def infer(self, node: ast.Name) -> SigType: + """ + title: Infer the type of a name from the scope it was declared in. + parameters: + node: + type: ast.Name + returns: + type: SigType + raises: + LoweringError: If the name is not in scope. + """ + sig_type = self.scope.get(node.id) + if sig_type is None: + raise self.reject( + node, + f"cannot infer the type of {node.id!r}: it is not a parameter" + " of this function", + ) + return sig_type + + @dispatch + def infer(self, node: ast.BinOp) -> SigType: + """ + title: Infer the type of an arithmetic operation from its operands. + parameters: + node: + type: ast.BinOp + returns: + type: SigType + """ + return self._wider(self.infer(node.left), self.infer(node.right)) + + @dispatch + def infer(self, node: ast.UnaryOp) -> SigType: + """ + title: Infer the type of a unary operation. + summary: >- + Only ``not`` changes the type of what it is applied to; the other + operators the subset admits leave it as it was. + parameters: + node: + type: ast.UnaryOp + returns: + type: SigType + """ + if isinstance(node.op, ast.Not): + return bool_ + return self.infer(node.operand) + + @dispatch + def infer(self, node: ast.Compare) -> SigType: + """ + title: Infer the type of a comparison. + parameters: + node: + type: ast.Compare + returns: + type: SigType + """ + return bool_ + + @dispatch + def infer(self, node: ast.BoolOp) -> SigType: + """ + title: Infer the type of an and/or expression. + summary: >- + Logical once lowered, whatever its operands were, which is what makes + it usable as a condition. + parameters: + node: + type: ast.BoolOp + returns: + type: SigType + """ + return bool_ + def _literal_value( self, node: ast.expr, diff --git a/packages/arxjit/tests/test_lowering.py b/packages/arxjit/tests/test_lowering.py index de71ce1..c14cbd7 100644 --- a/packages/arxjit/tests/test_lowering.py +++ b/packages/arxjit/tests/test_lowering.py @@ -25,6 +25,7 @@ from astx.binary_op import _BINARY_OP_TYPES from irx.analysis.api import analyze from irx.analysis.registry import MAIN_FUNCTION_NAME +from irx.analysis.typing import binary_result_type PyFunc = Callable[..., Any] @@ -71,6 +72,44 @@ def _from_source(source: str, signature: Signature) -> astx.FunctionDef: return definition +def _module_from_source(source: str, signature: Signature) -> astx.Module: + """ + title: Lower hand-built source and keep the module (test helper). + summary: >- + analyze takes the whole module rather than the definition alone, so a + test that checks an emitted node is one IRx accepts needs both. + parameters: + source: + type: str + signature: + type: Signature + returns: + type: astx.Module + """ + node = ast.parse(source).body[0] + assert isinstance(node, ast.FunctionDef) + extracted = ExtractedSource( + filename="", source=source, lineno=1, node=node + ) + return lower(extracted, signature) + + +def _returned(module: astx.Module) -> astx.DataType: + """ + title: Return the value of a single-statement function's return. + parameters: + module: + type: astx.Module + returns: + type: astx.DataType + """ + definition = module.block[0] + assert isinstance(definition, astx.FunctionDef) + (returned,) = definition.body.nodes + assert isinstance(returned, astx.FunctionReturn) + return returned.value + + def test_literal_return_lowers_to_a_single_function_module() -> None: """ title: A constant-returning function becomes a one-function astx module. @@ -572,6 +611,416 @@ def test_a_unary_operator_astx_lacks_is_rejected() -> None: assert "cannot lower the Invert operator" in str(excinfo.value) +@pytest.mark.parametrize( + ("operator", "op_code"), + [ + ("==", "=="), + ("!=", "!="), + ("<", "<"), + ("<=", "<="), + (">", ">"), + (">=", ">="), + ], +) +def test_a_comparison_lowers_to_a_binary_op( + operator: str, op_code: str +) -> None: + """ + title: Each comparison lowers to the binary node IRx implements. + summary: >- + astx.CompareOp is what a comparison looks like it should become, but + IRx's visitor for it is not implemented, so it would pass this stage and + fail codegen. analyze proves the emitted form is one IRx accepts. + parameters: + operator: + type: str + op_code: + type: str + """ + source = f"def sample(a, b):\n return a {operator} b\n" + module = _module_from_source(source, bool_(i64, i64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == op_code + analyze(module) + + +def test_a_chained_comparison_folds_into_a_conjunction() -> None: + """ + title: A chain becomes the conjunction Python defines it as. + summary: >- + a < b < c means a < b and b < c, so it lowers to two comparisons joined + by &&. b is evaluated twice, which the subset makes safe: it admits no + expression with an effect that a second evaluation could repeat. + """ + source = "def sample(a, b, c):\n return a < b < c\n" + module = _module_from_source(source, bool_(i64, i64, i64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == "&&" + assert isinstance(returned.lhs, astx.BinaryOp) + assert returned.lhs.op_code == "<" + assert isinstance(returned.rhs, astx.BinaryOp) + assert returned.rhs.op_code == "<" + analyze(module) + + +def test_a_comparison_lowers_its_operands_at_their_own_type() -> None: + """ + title: An operand is not lowered at the type of the comparison. + summary: >- + The expected type at a comparison is the bool the comparison yields, and + lowering the literal in ``a < 3`` against it would ask for 3 as a bool + and refuse a correct program. The operands' own type is used instead. + """ + source = "def sample(a):\n return a < 3\n" + module = _module_from_source(source, bool_(i64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.rhs, astx.LiteralInt64) + analyze(module) + + +def test_a_comparison_lowers_its_operands_at_the_wider_type() -> None: + """ + title: Mixed operands compare at a type that can hold both. + summary: >- + An integer literal compared against a float parameter is lowered as a + float, which is the promotion Python performs, rather than narrowing the + parameter to meet the literal. + """ + source = "def sample(a):\n return a < 3\n" + module = _module_from_source(source, bool_(f64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.rhs, astx.LiteralFloat64) + + +@pytest.mark.parametrize( + ("literal", "sig_type", "expected"), + [ + ("3", i32, astx.LiteralInt64), + ("1.5", f32, astx.LiteralFloat64), + ], +) +def test_a_narrow_parameter_compares_against_a_wide_literal( + literal: str, sig_type: SigType, expected: type[astx.Literal] +) -> None: + """ + title: A narrow parameter is widened to meet its literal, not the reverse. + summary: >- + Inference gives a bare literal the widest type of its kind, so an i32 or + f32 parameter is compared against an Int64 or Float64. That is the one + place a literal is deliberately not built at the parameter's width: a + comparison widens both sides rather than assigning to either, and IRx + inserts exactly this widening, which analyze proves it accepts. + parameters: + literal: + type: str + sig_type: + type: SigType + expected: + type: type[astx.Literal] + """ + source = f"def sample(a):\n return a < {literal}\n" + module = _module_from_source(source, bool_(sig_type)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.rhs, expected) + analyze(module) + + +@pytest.mark.parametrize( + ("operator", "op_code"), [("and", "&&"), ("or", "||")] +) +def test_a_boolean_operator_lowers_to_a_binary_op( + operator: str, op_code: str +) -> None: + """ + title: and and or lower to the logical operators IRx implements. + parameters: + operator: + type: str + op_code: + type: str + """ + source = f"def sample(a, b):\n return a {operator} b\n" + module = _module_from_source(source, bool_(bool_, bool_)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == op_code + analyze(module) + + +def test_an_n_ary_boolean_expression_folds_left() -> None: + """ + title: Three operands become two binary nodes, associating left. + summary: >- + ast holds and/or as one node over all its operands while astx's is + strictly binary, so the operands are folded in the order they were + written. + """ + source = "def sample(a, b, c):\n return a and b and c\n" + module = _module_from_source(source, bool_(bool_, bool_, bool_)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == "&&" + assert isinstance(returned.lhs, astx.BinaryOp) + assert returned.lhs.op_code == "&&" + assert isinstance(returned.rhs, astx.Variable) + assert returned.rhs.name == "c" + + +@pytest.mark.parametrize( + ("operator", "name"), + [("is", "Is"), ("is not", "IsNot"), ("in", "In"), ("not in", "NotIn")], +) +def test_a_comparison_irx_lacks_is_rejected(operator: str, name: str) -> None: + """ + title: A comparison with no IRx op_code is refused, not emitted. + summary: >- + Validation rejects all four before lowering runs, so these are reached + only through the public entry point; none has an operator to lower onto. + parameters: + operator: + type: str + name: + type: str + """ + source = f"def sample(a, b):\n return a {operator} b\n" + with pytest.raises(LoweringError) as excinfo: + _from_source(source, bool_(i64, i64)) + assert f"cannot lower the {name} comparison" in str(excinfo.value) + + +def test_a_negated_bool_literal_is_rejected() -> None: + """ + title: -True is refused rather than folded to an integer. + summary: >- + bool is a subclass of int and negating one in Python yields an int, so + folding -True would put an Int64 literal where the user wrote a bool and + slip past the bool-before-int check every other literal path makes. + """ + with pytest.raises(LoweringError) as excinfo: + _from_source("def sample():\n return -True\n", i64()) + assert "cannot lower a negated bool literal" in str(excinfo.value) + + +def test_a_name_that_is_not_a_parameter_cannot_be_typed() -> None: + """ + title: Inference refuses a name it has no type for. + summary: >- + Validation rejects a free variable before lowering runs, so this is + reached only through the public entry point; inferring some default type + for it would compile a program against a type the name does not have. + """ + source = "def sample(a):\n return a < missing\n" + with pytest.raises(LoweringError) as excinfo: + _from_source(source, bool_(i64)) + assert "it is not a parameter" in str(excinfo.value) + + +def test_an_expression_with_no_inference_overload_fails_closed() -> None: + """ + title: Inference refuses a node it has no rule for. + summary: >- + Inference and lowering cover the same expressions, so a node reaching + inference without a rule means the two have drifted apart. + """ + source = "def sample(a):\n return a < (1 if a else 2)\n" + with pytest.raises(LoweringError) as excinfo: + _from_source(source, bool_(i64)) + assert "cannot infer the type of a IfExp expression" in str(excinfo.value) + + +def test_a_literal_of_no_supported_kind_cannot_be_typed() -> None: + """ + title: Inference refuses a literal kind the subset does not admit. + summary: >- + Validation rejects a string before lowering runs; inference must not + assign it a numeric type on the way past. + """ + source = "def sample(a):\n return a < 'x'\n" + with pytest.raises(LoweringError) as excinfo: + _from_source(source, bool_(i64)) + assert "cannot infer the type of a str literal" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ("def sample(a):\n return a == True\n", astx.LiteralBoolean), + ("def sample(a):\n return a == 1.5\n", astx.LiteralFloat64), + ], +) +def test_a_literal_operand_is_typed_by_its_python_kind( + source: str, expected: type[astx.Literal] +) -> None: + """ + title: A literal standing alone is inferred at the widest type of its kind. + summary: >- + Nothing in the comparison declares a type for it, so Python's own notion + of the literal's type is all there is to go on: its floats are doubles, + and a bool is a bool rather than the integer it can stand in for. + parameters: + source: + type: str + expected: + type: type[astx.Literal] + """ + module = _module_from_source(source, bool_(bool_)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.rhs, expected) + + +def test_an_arithmetic_operand_is_typed_from_its_own_operands() -> None: + """ + title: A comparison against an arithmetic expression infers through it. + summary: >- + The float parameter inside the sum makes the whole sum a float, so the + integer literal it is compared against is lowered as one too. + """ + source = "def sample(a, b):\n return a + 1 < b\n" + module = _module_from_source(source, bool_(f64, f64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.lhs, astx.BinaryOp) + assert isinstance(returned.lhs.rhs, astx.LiteralFloat64) + + +def test_a_negated_operand_keeps_the_type_it_negates() -> None: + """ + title: Unary minus does not change the type inference sees. + """ + source = "def sample(a):\n return -1 < a\n" + module = _module_from_source(source, bool_(f64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert isinstance(returned.lhs, astx.LiteralFloat64) + + +@pytest.mark.parametrize( + ("source", "signature"), + [ + ("def sample(a):\n return (not a) == a\n", bool_(bool_)), + ("def sample(a, b):\n return a + (not b)\n", i64(i64, bool_)), + ], +) +def test_a_unary_operand_of_a_binary_operator_is_rejected( + source: str, signature: Signature +) -> None: + """ + title: not in either operand position is refused with a location. + summary: >- + astx requires a DataType on both operands of a binary operator and gives + its UnaryOp the generic ExprType, so building one raises a bare Exception + out of astx with nothing pointing at the user's code. Refusing here + reports it the way every other unlowerable expression is reported. The + arithmetic form reaches the same wall without this stage lowering + comparisons at all, so the refusal covers a path that predates them. + parameters: + source: + type: str + signature: + type: Signature + """ + with pytest.raises(LoweringError) as excinfo: + _from_source(source, signature) + assert "cannot lower a unary operation" in str(excinfo.value) + + +def test_a_not_expression_is_inferred_as_a_bool() -> None: + """ + title: not makes its operand's type irrelevant to what it yields. + summary: >- + Checked on the inference rule directly, because astx cannot yet hold a + unary operation as the operand of a binary one, so there is no expression + this stage can build that would show the inferred type instead. + """ + source = "def sample(a):\n return not a\n" + node = ast.parse(source).body[0] + assert isinstance(node, ast.FunctionDef) + extracted = ExtractedSource( + filename="", source=source, lineno=1, node=node + ) + lowerer = lowering.Lowerer(extracted, bool_(i64)) + returned = node.body[0] + assert isinstance(returned, ast.Return) + assert returned.value is not None + assert lowerer.infer(returned.value) == bool_ + + +def test_a_comparison_operand_is_typed_as_a_bool() -> None: + """ + title: A comparison nested in an and/or is inferred as the bool it is. + """ + source = "def sample(a, b):\n return a < b and b < a\n" + module = _module_from_source(source, bool_(i64, i64)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == "&&" + analyze(module) + + +def test_a_nested_boolean_operand_is_typed_as_a_bool() -> None: + """ + title: An and/or nested in another is inferred as the bool it is. + """ + source = "def sample(a, b, c):\n return a and (b or c)\n" + module = _module_from_source(source, bool_(bool_, bool_, bool_)) + returned = _returned(module) + assert isinstance(returned, astx.BinaryOp) + assert returned.op_code == "&&" + assert isinstance(returned.rhs, astx.BinaryOp) + assert returned.rhs.op_code == "||" + analyze(module) + + +def test_negating_a_literal_of_no_supported_kind_is_rejected() -> None: + """ + title: Only a numeric literal can be folded into a negative one. + summary: >- + Validation rejects a string before lowering runs, so this is reached only + through the public entry point; it must not be folded into a value + negation does not define. + """ + with pytest.raises(LoweringError) as excinfo: + _from_source("def sample():\n return -'x'\n", i64()) + assert "IRx implements no unary minus" in str(excinfo.value) + + +def test_the_comparison_tables_agree_with_irx() -> None: + """ + title: Every comparison and logical op_code is one IRx resolves. + summary: >- + IRx returns a type for exactly the op codes it implements and None for + anything else, so an entry added here without one there would lower + quietly and fail semantic analysis. Read from IRx directly so the check + cannot go stale. + """ + for op_code in lowering._COMPARE_OPS.values(): + assert ( + binary_result_type(op_code, astx.Int64(), astx.Int64()) is not None + ) + for op_code in lowering._BOOL_OPS.values(): + assert ( + binary_result_type(op_code, astx.Boolean(), astx.Boolean()) + is not None + ) + + +def test_every_sig_type_is_ranked() -> None: + """ + title: Every type this stage can lower can also be ranked against another. + summary: >- + The two tables are keyed the same way, so a scalar added to one without + the other would lower on its own and fail the moment it met a different + type. + """ + assert set(lowering._TYPE_RANK) == set(_SCALARS) + + def test_the_operator_tables_agree_with_astx() -> None: """ title: Every operator this stage emits is one astx specializes. @@ -680,14 +1129,14 @@ def test_an_unlowerable_expression_fails_closed() -> None: """ title: An expression with no overload is reported, not skipped. summary: >- - Validation admits comparisons, so reaching one here means the subset and - the lowerer disagree. They lower with the conditionals that give them a - purpose, not before. + Validation rejects a conditional expression before lowering runs, so this + is reached only through the public entry point; an expression this stage + cannot map must be refused rather than dropped from the body. """ - source = "def sample(x):\n return x < 1\n" + source = "def sample(x):\n return 1 if x else 2\n" with pytest.raises(LoweringError) as excinfo: - _from_source(source, bool_(i64)) - assert "cannot lower a Compare expression" in str(excinfo.value) + _from_source(source, i64(bool_)) + assert "cannot lower a IfExp expression" in str(excinfo.value) def test_a_standalone_expression_statement_is_rejected() -> None: