Add expression-evaluator: DAGs & state machines tutorial project

Educational calculator teaching FSMs (explicit transition table tokenizer)
and DAGs (recursive descent parser with AST evaluation). Includes CLI with
REPL, graphviz visualization, and 61 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dl92
2026-02-08 18:09:42 +00:00
parent 3a8705ece8
commit 01d5532823
11 changed files with 1557 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from tokenizer import tokenize
from parser import Parser
from evaluator import evaluate, evaluate_traced, EvalError
def eval_expr(expr):
"""Helper: tokenize -> parse -> evaluate in one step."""
tokens = tokenize(expr)
ast = Parser(tokens).parse()
return evaluate(ast)
# ---------- Basic arithmetic ----------
def test_addition():
assert eval_expr("3 + 4") == 7.0
def test_subtraction():
assert eval_expr("10 - 3") == 7.0
def test_multiplication():
assert eval_expr("3 * 4") == 12.0
def test_division():
assert eval_expr("10 / 4") == 2.5
def test_power():
assert eval_expr("2 ^ 10") == 1024.0
# ---------- Precedence ----------
def test_standard_precedence():
assert eval_expr("3 + 4 * 2") == 11.0
def test_parentheses():
assert eval_expr("(3 + 4) * 2") == 14.0
def test_power_precedence():
assert eval_expr("2 * 3 ^ 2") == 18.0
def test_right_associative_power():
# 2^(2^3) = 2^8 = 256
assert eval_expr("2 ^ 2 ^ 3") == 256.0
# ---------- Unary minus ----------
def test_negation():
assert eval_expr("-5") == -5.0
def test_double_negation():
assert eval_expr("--5") == 5.0
def test_negation_with_power():
# -(3^2) = -9, not (-3)^2 = 9
assert eval_expr("-3 ^ 2") == -9.0
def test_negation_in_parens():
assert eval_expr("(-3) ^ 2") == 9.0
# ---------- Decimals ----------
def test_decimal_addition():
assert eval_expr("0.1 + 0.2") == pytest.approx(0.3)
def test_leading_dot():
assert eval_expr(".5 + .5") == 1.0
# ---------- Edge cases ----------
def test_nested_parens():
assert eval_expr("((((3))))") == 3.0
def test_complex_expression():
assert eval_expr("(2 + 3) * (7 - 2) / 5 ^ 1") == 5.0
def test_long_chain():
assert eval_expr("1 + 2 + 3 + 4 + 5") == 15.0
def test_mixed_operations():
assert eval_expr("2 + 3 * 4 - 6 / 2") == 11.0
# ---------- Division by zero ----------
def test_division_by_zero():
with pytest.raises(EvalError):
eval_expr("1 / 0")
def test_division_by_zero_in_expression():
with pytest.raises(EvalError):
eval_expr("5 + 3 / (2 - 2)")
# ---------- Traced evaluation ----------
def test_traced_returns_correct_result():
tokens = tokenize("3 + 4 * 2")
ast = Parser(tokens).parse()
result, steps = evaluate_traced(ast)
assert result == 11.0
assert len(steps) > 0
def test_traced_step_count():
"""A simple binary op has 3 evaluation events: left, right, combine."""
tokens = tokenize("3 + 4")
ast = Parser(tokens).parse()
result, steps = evaluate_traced(ast)
assert result == 7.0
# NumberNode(3), NumberNode(4), BinOp(+)
assert len(steps) == 3

View File

@@ -0,0 +1,136 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from tokenizer import tokenize, TokenType
from parser import Parser, ParseError, NumberNode, BinOpNode, UnaryOpNode
def parse(expr):
"""Helper: tokenize and parse in one step."""
return Parser(tokenize(expr)).parse()
# ---------- Basic parsing ----------
def test_parse_number():
ast = parse("42")
assert isinstance(ast, NumberNode)
assert ast.value == 42.0
def test_parse_decimal():
ast = parse("3.14")
assert isinstance(ast, NumberNode)
assert ast.value == 3.14
def test_parse_addition():
ast = parse("3 + 4")
assert isinstance(ast, BinOpNode)
assert ast.op == TokenType.PLUS
assert isinstance(ast.left, NumberNode)
assert isinstance(ast.right, NumberNode)
# ---------- Precedence ----------
def test_multiply_before_add():
"""3 + 4 * 2 should parse as 3 + (4 * 2)."""
ast = parse("3 + 4 * 2")
assert ast.op == TokenType.PLUS
assert isinstance(ast.right, BinOpNode)
assert ast.right.op == TokenType.MULTIPLY
def test_power_before_multiply():
"""2 * 3 ^ 4 should parse as 2 * (3 ^ 4)."""
ast = parse("2 * 3 ^ 4")
assert ast.op == TokenType.MULTIPLY
assert isinstance(ast.right, BinOpNode)
assert ast.right.op == TokenType.POWER
def test_parentheses_override_precedence():
"""(3 + 4) * 2 should parse as (3 + 4) * 2."""
ast = parse("(3 + 4) * 2")
assert ast.op == TokenType.MULTIPLY
assert isinstance(ast.left, BinOpNode)
assert ast.left.op == TokenType.PLUS
# ---------- Associativity ----------
def test_left_associative_subtraction():
"""10 - 3 - 2 should parse as (10 - 3) - 2."""
ast = parse("10 - 3 - 2")
assert ast.op == TokenType.MINUS
assert isinstance(ast.left, BinOpNode)
assert ast.left.op == TokenType.MINUS
assert isinstance(ast.right, NumberNode)
def test_power_right_associative():
"""2 ^ 3 ^ 4 should parse as 2 ^ (3 ^ 4)."""
ast = parse("2 ^ 3 ^ 4")
assert ast.op == TokenType.POWER
assert isinstance(ast.left, NumberNode)
assert isinstance(ast.right, BinOpNode)
assert ast.right.op == TokenType.POWER
# ---------- Unary minus ----------
def test_unary_minus():
ast = parse("-3")
assert isinstance(ast, UnaryOpNode)
assert ast.operand.value == 3.0
def test_double_negation():
ast = parse("--3")
assert isinstance(ast, UnaryOpNode)
assert isinstance(ast.operand, UnaryOpNode)
assert ast.operand.operand.value == 3.0
def test_unary_minus_precedence():
"""-3^2 should parse as -(3^2), not (-3)^2."""
ast = parse("-3 ^ 2")
assert isinstance(ast, UnaryOpNode)
assert isinstance(ast.operand, BinOpNode)
assert ast.operand.op == TokenType.POWER
def test_unary_minus_in_expression():
"""2 * -3 should parse as 2 * (-(3))."""
ast = parse("2 * -3")
assert ast.op == TokenType.MULTIPLY
assert isinstance(ast.right, UnaryOpNode)
# ---------- Nested parentheses ----------
def test_nested_parens():
ast = parse("((3))")
assert isinstance(ast, NumberNode)
assert ast.value == 3.0
def test_complex_nesting():
"""((2 + 3) * (7 - 2))"""
ast = parse("((2 + 3) * (7 - 2))")
assert isinstance(ast, BinOpNode)
assert ast.op == TokenType.MULTIPLY
# ---------- Errors ----------
def test_missing_rparen():
with pytest.raises(ParseError):
parse("(3 + 4")
def test_empty_expression():
with pytest.raises(ParseError):
parse("")
def test_trailing_operator():
with pytest.raises(ParseError):
parse("3 +")
def test_empty_parens():
with pytest.raises(ParseError):
parse("()")

View File

@@ -0,0 +1,139 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from tokenizer import tokenize, TokenType, Token, TokenError
# ---------- Basic tokens ----------
def test_single_integer():
tokens = tokenize("42")
assert tokens[0].type == TokenType.NUMBER
assert tokens[0].value == "42"
def test_decimal_number():
tokens = tokenize("3.14")
assert tokens[0].type == TokenType.NUMBER
assert tokens[0].value == "3.14"
def test_leading_dot():
tokens = tokenize(".5")
assert tokens[0].type == TokenType.NUMBER
assert tokens[0].value == ".5"
def test_all_operators():
"""Operators between numbers are all binary."""
tokens = tokenize("1 + 1 - 1 * 1 / 1 ^ 1")
ops = [t.type for t in tokens if t.type not in (TokenType.NUMBER, TokenType.EOF)]
assert ops == [
TokenType.PLUS, TokenType.MINUS, TokenType.MULTIPLY,
TokenType.DIVIDE, TokenType.POWER,
]
def test_operators_between_numbers():
tokens = tokenize("1 + 2 - 3 * 4 / 5 ^ 6")
ops = [t.type for t in tokens if t.type not in (TokenType.NUMBER, TokenType.EOF)]
assert ops == [
TokenType.PLUS, TokenType.MINUS, TokenType.MULTIPLY,
TokenType.DIVIDE, TokenType.POWER,
]
def test_parentheses():
tokens = tokenize("()")
assert tokens[0].type == TokenType.LPAREN
assert tokens[1].type == TokenType.RPAREN
# ---------- Unary minus ----------
def test_unary_minus_at_start():
tokens = tokenize("-3")
assert tokens[0].type == TokenType.UNARY_MINUS
assert tokens[1].type == TokenType.NUMBER
def test_unary_minus_after_lparen():
tokens = tokenize("(-3)")
assert tokens[1].type == TokenType.UNARY_MINUS
def test_unary_minus_after_operator():
tokens = tokenize("2 * -3")
assert tokens[2].type == TokenType.UNARY_MINUS
def test_binary_minus():
tokens = tokenize("5 - 3")
assert tokens[1].type == TokenType.MINUS
def test_double_unary_minus():
tokens = tokenize("--3")
assert tokens[0].type == TokenType.UNARY_MINUS
assert tokens[1].type == TokenType.UNARY_MINUS
assert tokens[2].type == TokenType.NUMBER
# ---------- Whitespace handling ----------
def test_no_spaces():
tokens = tokenize("3+4")
non_eof = [t for t in tokens if t.type != TokenType.EOF]
assert len(non_eof) == 3
def test_extra_spaces():
tokens = tokenize(" 3 + 4 ")
non_eof = [t for t in tokens if t.type != TokenType.EOF]
assert len(non_eof) == 3
# ---------- Position tracking ----------
def test_positions():
tokens = tokenize("3 + 4")
assert tokens[0].position == 0 # '3'
assert tokens[1].position == 2 # '+'
assert tokens[2].position == 4 # '4'
# ---------- Errors ----------
def test_invalid_character():
with pytest.raises(TokenError):
tokenize("3 & 4")
def test_double_dot():
with pytest.raises(TokenError):
tokenize("3.14.15")
# ---------- EOF token ----------
def test_eof_always_present():
tokens = tokenize("42")
assert tokens[-1].type == TokenType.EOF
def test_empty_input():
tokens = tokenize("")
assert len(tokens) == 1
assert tokens[0].type == TokenType.EOF
# ---------- Complex expressions ----------
def test_complex_expression():
tokens = tokenize("(3 + 4.5) * -2 ^ 3")
types = [t.type for t in tokens if t.type != TokenType.EOF]
assert types == [
TokenType.LPAREN, TokenType.NUMBER, TokenType.PLUS,
TokenType.NUMBER, TokenType.RPAREN, TokenType.MULTIPLY,
TokenType.UNARY_MINUS, TokenType.NUMBER, TokenType.POWER,
TokenType.NUMBER,
]
def test_adjacent_parens():
tokens = tokenize("(3)(4)")
types = [t.type for t in tokens if t.type != TokenType.EOF]
assert types == [
TokenType.LPAREN, TokenType.NUMBER, TokenType.RPAREN,
TokenType.LPAREN, TokenType.NUMBER, TokenType.RPAREN,
]