diff --git a/src/kida/compiler/partial_eval.py b/src/kida/compiler/partial_eval.py index b7c0560..394ec6d 100644 --- a/src/kida/compiler/partial_eval.py +++ b/src/kida/compiler/partial_eval.py @@ -29,6 +29,7 @@ from dataclasses import dataclass, replace from typing import Any, final +from kida.compiler import partial_eval_constants as _constants from kida.nodes import ( BinOp, Block, @@ -73,8 +74,10 @@ With, ) -# Sentinel for "evaluation failed" — distinct from None (which is a valid result) -_UNRESOLVED = object() +_UNRESOLVED = _constants.UNRESOLVED +_PARTIAL_EVAL_EXCEPTIONS = _constants.PARTIAL_EVAL_EXCEPTIONS +_compare_op = _constants.compare_op +_try_eval_const_only = _constants.try_eval_const_only # Maximum number of iterations to unroll in a static for-loop _MAX_UNROLL = 200 @@ -143,121 +146,11 @@ class _LoopProperties: revindex0: int -# Expected errors when constant folding fails — fall back to runtime evaluation. -# Narrowing avoids silently swallowing KeyboardInterrupt, SystemExit, etc. -_PARTIAL_EVAL_EXCEPTIONS: tuple[type[BaseException], ...] = ( - TypeError, - KeyError, - IndexError, - AttributeError, - ValueError, - OverflowError, - ZeroDivisionError, -) - - # --------------------------------------------------------------------------- # Dead code elimination (const-only, no static_context) # --------------------------------------------------------------------------- -def _try_eval_const_only(expr: Expr) -> Any: - """Evaluate expression using only literals and constant expressions. - - Resolves Const, BinOp, UnaryOp, Compare, BoolOp. No Name/Getattr/Getitem - (those require static_context). Used for dead code elimination. - """ - match expr: - case Const(): - return expr.value - - case BinOp(): - left = _try_eval_const_only(expr.left) - right = _try_eval_const_only(expr.right) - if left is _UNRESOLVED or right is _UNRESOLVED: - return _UNRESOLVED - try: - if expr.op == "+": - return left + right - if expr.op == "-": - return left - right - if expr.op == "*": - return left * right - if expr.op == "/": - return left / right - if expr.op == "//": - return left // right - if expr.op == "%": - return left % right - if expr.op == "**": - return left**right - if expr.op == "~": - # Compile-time only: operands are constants, never Markup. - # Runtime ~ uses _markup_concat for Markup preservation. - return str(left) + str(right) - except _PARTIAL_EVAL_EXCEPTIONS: - return _UNRESOLVED - return _UNRESOLVED - - case UnaryOp(): - operand = _try_eval_const_only(expr.operand) - if operand is _UNRESOLVED: - return _UNRESOLVED - try: - if expr.op == "-": - return -operand - if expr.op == "+": - return +operand - if expr.op == "not": - return not operand - except _PARTIAL_EVAL_EXCEPTIONS: - return _UNRESOLVED - return _UNRESOLVED - - case Compare(): - left = _try_eval_const_only(expr.left) - if left is _UNRESOLVED: - return _UNRESOLVED - for op, comp_node in zip(expr.ops, expr.comparators, strict=True): - right = _try_eval_const_only(comp_node) - if right is _UNRESOLVED: - return _UNRESOLVED - try: - result = _compare_op(op, left, right) - except _PARTIAL_EVAL_EXCEPTIONS: - return _UNRESOLVED - if not result: - return False - left = right - return True - - case BoolOp(): - if expr.op == "and": - for val_node in expr.values: - val = _try_eval_const_only(val_node) - if val is _UNRESOLVED: - return _UNRESOLVED - if not val: - return val - return val - for val_node in expr.values: - val = _try_eval_const_only(val_node) - if val is _UNRESOLVED: - return _UNRESOLVED - if val: - return val - return val - - case CondExpr(): - test = _try_eval_const_only(expr.test) - if test is _UNRESOLVED: - return _UNRESOLVED - return _try_eval_const_only(expr.if_true if test else expr.if_false) - - case _: - return _UNRESOLVED - - def _body_has_scoping_nodes(nodes: Sequence[Node]) -> bool: """True if body contains Set, Let, Capture, or Export (block-scoped).""" for n in nodes: @@ -2157,32 +2050,6 @@ class _InlinedBody(Node): nodes: Sequence[Node] = () -def _compare_op(op: str, left: Any, right: Any) -> bool: - """Evaluate a comparison operator.""" - if op == "==": - return left == right - if op == "!=": - return left != right - if op == "<": - return left < right - if op == "<=": - return left <= right - if op == ">": - return left > right - if op == ">=": - return left >= right - if op == "in": - return left in right - if op == "not in": - return left not in right - if op == "is": - return left is right - if op == "is not": - return left is not right - msg = f"Unknown comparison operator: {op}" - raise ValueError(msg) - - def partial_evaluate( template: Template, static_context: dict[str, Any], diff --git a/src/kida/compiler/partial_eval_constants.py b/src/kida/compiler/partial_eval_constants.py new file mode 100644 index 0000000..3545baf --- /dev/null +++ b/src/kida/compiler/partial_eval_constants.py @@ -0,0 +1,151 @@ +"""Constant-expression primitives shared by partial-evaluation phases. + +This module owns the literal-only evaluator used by dead-code elimination and +the sentinel/error policy used by the static-context evaluator. Keeping these +primitives separate makes the phase boundary explicit without changing the +public ``partial_evaluate()`` or ``eliminate_dead_code()`` entrypoints. +""" + +from __future__ import annotations + +from typing import Any + +from kida.nodes import BinOp, BoolOp, Compare, CondExpr, Const, Expr, UnaryOp + +# Distinct from None, which is a valid compile-time result. +UNRESOLVED = object() + +# Expected failures fall back to runtime evaluation. Process-control exceptions +# such as KeyboardInterrupt and SystemExit must continue to propagate. +PARTIAL_EVAL_EXCEPTIONS: tuple[type[BaseException], ...] = ( + TypeError, + KeyError, + IndexError, + AttributeError, + ValueError, + OverflowError, + ZeroDivisionError, +) + + +def compare_op(op: str, left: Any, right: Any) -> bool: + """Evaluate a comparison operator.""" + if op == "==": + return left == right + if op == "!=": + return left != right + if op == "<": + return left < right + if op == "<=": + return left <= right + if op == ">": + return left > right + if op == ">=": + return left >= right + if op == "in": + return left in right + if op == "not in": + return left not in right + if op == "is": + return left is right + if op == "is not": + return left is not right + msg = f"Unknown comparison operator: {op}" + raise ValueError(msg) + + +def try_eval_const_only(expr: Expr) -> Any: + """Evaluate an expression using only literals and constant operators. + + Name, attribute, and item lookups intentionally remain unresolved because + they require a static context. Dead-code elimination uses this narrower + evaluator even when no static context is configured. + """ + match expr: + case Const(): + return expr.value + + case BinOp(): + left = try_eval_const_only(expr.left) + right = try_eval_const_only(expr.right) + if left is UNRESOLVED or right is UNRESOLVED: + return UNRESOLVED + try: + if expr.op == "+": + return left + right + if expr.op == "-": + return left - right + if expr.op == "*": + return left * right + if expr.op == "/": + return left / right + if expr.op == "//": + return left // right + if expr.op == "%": + return left % right + if expr.op == "**": + return left**right + if expr.op == "~": + # Compile-time operands are constants, never Markup. + return str(left) + str(right) + except PARTIAL_EVAL_EXCEPTIONS: + return UNRESOLVED + return UNRESOLVED + + case UnaryOp(): + operand = try_eval_const_only(expr.operand) + if operand is UNRESOLVED: + return UNRESOLVED + try: + if expr.op == "-": + return -operand + if expr.op == "+": + return +operand + if expr.op == "not": + return not operand + except PARTIAL_EVAL_EXCEPTIONS: + return UNRESOLVED + return UNRESOLVED + + case Compare(): + left = try_eval_const_only(expr.left) + if left is UNRESOLVED: + return UNRESOLVED + for op, comp_node in zip(expr.ops, expr.comparators, strict=True): + right = try_eval_const_only(comp_node) + if right is UNRESOLVED: + return UNRESOLVED + try: + result = compare_op(op, left, right) + except PARTIAL_EVAL_EXCEPTIONS: + return UNRESOLVED + if not result: + return False + left = right + return True + + case BoolOp(): + if expr.op == "and": + for val_node in expr.values: + val = try_eval_const_only(val_node) + if val is UNRESOLVED: + return UNRESOLVED + if not val: + return val + return val + for val_node in expr.values: + val = try_eval_const_only(val_node) + if val is UNRESOLVED: + return UNRESOLVED + if val: + return val + return val + + case CondExpr(): + test = try_eval_const_only(expr.test) + if test is UNRESOLVED: + return UNRESOLVED + return try_eval_const_only(expr.if_true if test else expr.if_false) + + case _: + return UNRESOLVED diff --git a/tests/test_partial_eval_constants.py b/tests/test_partial_eval_constants.py new file mode 100644 index 0000000..fe0e414 --- /dev/null +++ b/tests/test_partial_eval_constants.py @@ -0,0 +1,93 @@ +"""Focused contracts for the constant-expression partial-eval phase.""" + +from __future__ import annotations + +import pytest + +from kida.compiler.partial_eval_constants import UNRESOLVED, compare_op, try_eval_const_only +from kida.nodes import BinOp, BoolOp, Compare, CondExpr, Const, Name, UnaryOp + + +def _const(value: object) -> Const: + return Const(lineno=1, col_offset=0, value=value) + + +def test_nested_constant_expression_is_evaluated() -> None: + expression = Compare( + lineno=1, + col_offset=0, + left=BinOp( + lineno=1, + col_offset=0, + op="+", + left=_const(1), + right=_const(1), + ), + ops=("==",), + comparators=(_const(2),), + ) + + assert try_eval_const_only(expression) is True + + +def test_dynamic_name_remains_unresolved() -> None: + expression = Name(lineno=1, col_offset=0, name="request_value") + + assert try_eval_const_only(expression) is UNRESOLVED + + +def test_expected_operator_failure_remains_unresolved() -> None: + expression = BinOp( + lineno=1, + col_offset=0, + op="/", + left=_const(1), + right=_const(0), + ) + + assert try_eval_const_only(expression) is UNRESOLVED + + +def test_boolean_short_circuit_does_not_touch_dynamic_operand() -> None: + expression = BoolOp( + lineno=1, + col_offset=0, + op="and", + values=(_const(False), Name(lineno=1, col_offset=0, name="dynamic")), + ) + + assert try_eval_const_only(expression) is False + + +def test_conditional_expression_uses_constant_winner() -> None: + expression = CondExpr( + lineno=1, + col_offset=0, + test=UnaryOp(lineno=1, col_offset=0, op="not", operand=_const(False)), + if_true=_const("yes"), + if_false=_const("no"), + ) + + assert try_eval_const_only(expression) == "yes" + + +def test_unknown_comparison_operator_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown comparison operator"): + compare_op("contains", 1, 2) + + +def test_process_control_exceptions_are_not_swallowed() -> None: + class InterruptingValue: + def __add__(self, other: object) -> object: + raise KeyboardInterrupt + + expression = BinOp( + lineno=1, + col_offset=0, + op="+", + left=_const(InterruptingValue()), + right=_const(1), + ) + + with pytest.raises(KeyboardInterrupt): + try_eval_const_only(expression)