Skip to content

Commit 5b8eb15

Browse files
Merge pull request #1760 from knoxiboy/1673-feat-expression-parser
feat: Build a Mathematical Expression Parser and Evaluator from Scratch (#1673)
2 parents 4bf095b + 5cfb0b3 commit 5b8eb15

8 files changed

Lines changed: 674 additions & 0 deletions

File tree

expression_parser/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Mathematical Expression Parser and Evaluator
2+
3+
A zero-external-dependency mathematical expression parser, tokenizer, AST builder, and evaluator built from scratch in Python.
4+
5+
## Features
6+
7+
- **Tokenizer**: Converts input text strings into tokens (Numbers, Operators, Identifiers, Functions, Parentheses). Supports implicit multiplication (e.g. `2(3+4)` or `3pi`).
8+
- **AST Parser**: Recursive-descent parser building Abstract Syntax Trees respecting operator precedence and associativity (`^`, `*`, `/`, `%`, `+`, `-`).
9+
- **Evaluator**: Walks AST nodes safely to calculate results with variable binding support and builtin math functions (`sin`, `cos`, `tan`, `sqrt`, `abs`, `log`, `exp`, `min`, `max`, `floor`, `ceil`).
10+
- **Interactive REPL & CLI**: Support for interactive session and CLI expression evaluation.
11+
12+
## Usage
13+
14+
### CLI REPL
15+
16+
```bash
17+
python expression_parser/main.py
18+
```
19+
20+
### Direct Expression Evaluation
21+
22+
```bash
23+
python expression_parser/main.py "2 + 3 * (4 - 1)^2"
24+
# Output: 29.0
25+
```
26+
27+
### Python API
28+
29+
```python
30+
from expression_parser import evaluate_expression
31+
32+
result = evaluate_expression("sin(pi / 2) + cos(0)")
33+
print(result) # 2.0
34+
35+
vars_dict = {"x": 5, "y": 2}
36+
res = evaluate_expression("x^2 + 2*x*y + y^2", vars_dict)
37+
print(res) # 49.0
38+
```

expression_parser/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
__init__.py for expression_parser module.
3+
"""
4+
5+
from expression_parser.tokenizer import Tokenizer, Token, TokenType, TokenizerError
6+
from expression_parser.parser import Parser, ParseError, ASTNode
7+
from expression_parser.evaluator import Evaluator, EvaluationError, evaluate_expression
8+
9+
__all__ = [
10+
"Tokenizer", "Token", "TokenType", "TokenizerError",
11+
"Parser", "ParseError", "ASTNode",
12+
"Evaluator", "EvaluationError", "evaluate_expression"
13+
]

expression_parser/evaluator.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Evaluator for AST nodes generated by Parser.
3+
Evaluates math expressions with variable and function support.
4+
"""
5+
6+
import math
7+
from typing import Dict, Any, Union
8+
from expression_parser.parser import ASTNode, NumberNode, VariableNode, UnaryOpNode, BinaryOpNode, FunctionCallNode
9+
10+
11+
class EvaluationError(Exception):
12+
pass
13+
14+
15+
class Evaluator:
16+
BUILTIN_FUNCTIONS = {
17+
"sin": math.sin,
18+
"cos": math.cos,
19+
"tan": math.tan,
20+
"asin": math.asin,
21+
"acos": math.acos,
22+
"atan": math.atan,
23+
"sqrt": math.sqrt,
24+
"abs": abs,
25+
"log": math.log,
26+
"exp": math.exp,
27+
"floor": math.floor,
28+
"ceil": math.ceil,
29+
"min": min,
30+
"max": max,
31+
}
32+
33+
BUILTIN_CONSTANTS = {
34+
"pi": math.pi,
35+
"e": math.e,
36+
"tau": math.tau,
37+
}
38+
39+
def __init__(self, variables: Dict[str, Union[int, float]] = None):
40+
self.variables = self.BUILTIN_CONSTANTS.copy()
41+
if variables:
42+
self.variables.update(variables)
43+
44+
def evaluate(self, node: ASTNode) -> Union[int, float]:
45+
if isinstance(node, NumberNode):
46+
return node.value
47+
48+
if isinstance(node, VariableNode):
49+
if node.name in self.variables:
50+
return self.variables[node.name]
51+
raise EvaluationError(f"Undefined variable or constant: '{node.name}'")
52+
53+
if isinstance(node, UnaryOpNode):
54+
val = self.evaluate(node.operand)
55+
if node.operator == '+':
56+
return +val
57+
elif node.operator == '-':
58+
return -val
59+
raise EvaluationError(f"Unsupported unary operator: '{node.operator}'")
60+
61+
if isinstance(node, BinaryOpNode):
62+
left_val = self.evaluate(node.left)
63+
right_val = self.evaluate(node.right)
64+
65+
if node.operator == '+':
66+
return left_val + right_val
67+
elif node.operator == '-':
68+
return left_val - right_val
69+
elif node.operator == '*':
70+
return left_val * right_val
71+
elif node.operator == '/':
72+
if right_val == 0:
73+
raise EvaluationError("Division by zero")
74+
return left_val / right_val
75+
elif node.operator == '%':
76+
if right_val == 0:
77+
raise EvaluationError("Modulo by zero")
78+
return left_val % right_val
79+
elif node.operator in ('^', '**'):
80+
try:
81+
res = left_val ** right_val
82+
if isinstance(res, complex):
83+
raise EvaluationError("Complex numbers result not supported in real domain")
84+
return res
85+
except OverflowError:
86+
raise EvaluationError("Numerical overflow in exponentiation")
87+
88+
raise EvaluationError(f"Unsupported binary operator: '{node.operator}'")
89+
90+
if isinstance(node, FunctionCallNode):
91+
fn_name = node.name.lower()
92+
if fn_name not in self.BUILTIN_FUNCTIONS:
93+
raise EvaluationError(f"Unknown or unsupported function: '{node.name}'")
94+
95+
args = [self.evaluate(arg) for arg in node.args]
96+
func = self.BUILTIN_FUNCTIONS[fn_name]
97+
98+
try:
99+
result = func(*args)
100+
return result
101+
except TypeError as e:
102+
raise EvaluationError(f"Invalid arguments for function '{node.name}': {e}")
103+
except ValueError as e:
104+
raise EvaluationError(f"Mathematical domain error in function '{node.name}': {e}")
105+
106+
raise EvaluationError(f"Unknown AST node type: {type(node).__name__}")
107+
108+
109+
def evaluate_expression(expression: str, variables: Dict[str, Union[int, float]] = None) -> Union[int, float]:
110+
"""Helper function to tokenize, parse, and evaluate a math expression string."""
111+
from expression_parser.tokenizer import Tokenizer
112+
from expression_parser.parser import Parser
113+
114+
tokenizer = Tokenizer(expression)
115+
tokens = tokenizer.tokenize()
116+
parser = Parser(tokens)
117+
ast = parser.parse()
118+
evaluator = Evaluator(variables)
119+
return evaluator.evaluate(ast)

expression_parser/main.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Interactive CLI REPL and runner for Mathematical Expression Parser & Evaluator.
3+
"""
4+
5+
import sys
6+
import os
7+
8+
# Ensure project root is in sys.path
9+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
10+
11+
from typing import Dict, Union
12+
from expression_parser import evaluate_expression, TokenizerError, ParseError, EvaluationError
13+
14+
15+
def run_repl():
16+
print("=" * 60)
17+
print(" Mathematical Expression Parser & Evaluator (CLI REPL)")
18+
print("=" * 60)
19+
print("Commands:")
20+
print(" exit / quit : Exit REPL")
21+
print(" vars : Show current active variables")
22+
print(" let x = val : Set variable value (e.g. let x = 10)")
23+
print(" clear : Reset custom variables")
24+
print("-" * 60)
25+
26+
user_vars: Dict[str, Union[int, float]] = {}
27+
28+
while True:
29+
try:
30+
inp = input("math> ").strip()
31+
except (KeyboardInterrupt, EOFError):
32+
print("\nExiting REPL. Goodbye!")
33+
break
34+
35+
if not inp:
36+
continue
37+
38+
if inp.lower() in ("exit", "quit"):
39+
print("Goodbye!")
40+
break
41+
42+
if inp.lower() == "vars":
43+
if not user_vars:
44+
print("No custom variables set. Builtins available: pi, e, tau.")
45+
else:
46+
print("Active variables:", user_vars)
47+
continue
48+
49+
if inp.lower() == "clear":
50+
user_vars.clear()
51+
print("Custom variables cleared.")
52+
continue
53+
54+
if inp.lower().startswith("let "):
55+
parts = inp[4:].split("=", 1)
56+
if len(parts) == 2:
57+
var_name = parts[0].strip()
58+
val_expr = parts[1].strip()
59+
60+
if not var_name.isidentifier():
61+
print(f"Error: Invalid variable name '{var_name}'")
62+
continue
63+
64+
try:
65+
val = evaluate_expression(val_expr, user_vars)
66+
user_vars[var_name] = val
67+
print(f"Set {var_name} = {val}")
68+
except Exception as e:
69+
print(f"Error evaluating variable value: {e}")
70+
continue
71+
else:
72+
print("Usage for assignment: let <var_name> = <expression>")
73+
continue
74+
75+
try:
76+
result = evaluate_expression(inp, user_vars)
77+
print(f"Result: {result}")
78+
except (TokenizerError, ParseError, EvaluationError) as e:
79+
print(f"Error: {e}")
80+
except Exception as e:
81+
print(f"Unexpected error: {e}")
82+
83+
84+
def main():
85+
if len(sys.argv) > 1:
86+
expr = " ".join(sys.argv[1:])
87+
try:
88+
res = evaluate_expression(expr)
89+
print(res)
90+
except Exception as e:
91+
print(f"Error: {e}", file=sys.stderr)
92+
sys.exit(1)
93+
else:
94+
run_repl()
95+
96+
97+
if __name__ == "__main__":
98+
main()

0 commit comments

Comments
 (0)