diff --git a/expression_parser/README.md b/expression_parser/README.md new file mode 100644 index 00000000..5623cb85 --- /dev/null +++ b/expression_parser/README.md @@ -0,0 +1,38 @@ +# Mathematical Expression Parser and Evaluator + +A zero-external-dependency mathematical expression parser, tokenizer, AST builder, and evaluator built from scratch in Python. + +## Features + +- **Tokenizer**: Converts input text strings into tokens (Numbers, Operators, Identifiers, Functions, Parentheses). Supports implicit multiplication (e.g. `2(3+4)` or `3pi`). +- **AST Parser**: Recursive-descent parser building Abstract Syntax Trees respecting operator precedence and associativity (`^`, `*`, `/`, `%`, `+`, `-`). +- **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`). +- **Interactive REPL & CLI**: Support for interactive session and CLI expression evaluation. + +## Usage + +### CLI REPL + +```bash +python expression_parser/main.py +``` + +### Direct Expression Evaluation + +```bash +python expression_parser/main.py "2 + 3 * (4 - 1)^2" +# Output: 29.0 +``` + +### Python API + +```python +from expression_parser import evaluate_expression + +result = evaluate_expression("sin(pi / 2) + cos(0)") +print(result) # 2.0 + +vars_dict = {"x": 5, "y": 2} +res = evaluate_expression("x^2 + 2*x*y + y^2", vars_dict) +print(res) # 49.0 +``` diff --git a/expression_parser/__init__.py b/expression_parser/__init__.py new file mode 100644 index 00000000..0b8f414a --- /dev/null +++ b/expression_parser/__init__.py @@ -0,0 +1,13 @@ +""" +__init__.py for expression_parser module. +""" + +from expression_parser.tokenizer import Tokenizer, Token, TokenType, TokenizerError +from expression_parser.parser import Parser, ParseError, ASTNode +from expression_parser.evaluator import Evaluator, EvaluationError, evaluate_expression + +__all__ = [ + "Tokenizer", "Token", "TokenType", "TokenizerError", + "Parser", "ParseError", "ASTNode", + "Evaluator", "EvaluationError", "evaluate_expression" +] diff --git a/expression_parser/evaluator.py b/expression_parser/evaluator.py new file mode 100644 index 00000000..9155013e --- /dev/null +++ b/expression_parser/evaluator.py @@ -0,0 +1,119 @@ +""" +Evaluator for AST nodes generated by Parser. +Evaluates math expressions with variable and function support. +""" + +import math +from typing import Dict, Any, Union +from expression_parser.parser import ASTNode, NumberNode, VariableNode, UnaryOpNode, BinaryOpNode, FunctionCallNode + + +class EvaluationError(Exception): + pass + + +class Evaluator: + BUILTIN_FUNCTIONS = { + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "sqrt": math.sqrt, + "abs": abs, + "log": math.log, + "exp": math.exp, + "floor": math.floor, + "ceil": math.ceil, + "min": min, + "max": max, + } + + BUILTIN_CONSTANTS = { + "pi": math.pi, + "e": math.e, + "tau": math.tau, + } + + def __init__(self, variables: Dict[str, Union[int, float]] = None): + self.variables = self.BUILTIN_CONSTANTS.copy() + if variables: + self.variables.update(variables) + + def evaluate(self, node: ASTNode) -> Union[int, float]: + if isinstance(node, NumberNode): + return node.value + + if isinstance(node, VariableNode): + if node.name in self.variables: + return self.variables[node.name] + raise EvaluationError(f"Undefined variable or constant: '{node.name}'") + + if isinstance(node, UnaryOpNode): + val = self.evaluate(node.operand) + if node.operator == '+': + return +val + elif node.operator == '-': + return -val + raise EvaluationError(f"Unsupported unary operator: '{node.operator}'") + + if isinstance(node, BinaryOpNode): + left_val = self.evaluate(node.left) + right_val = self.evaluate(node.right) + + if node.operator == '+': + return left_val + right_val + elif node.operator == '-': + return left_val - right_val + elif node.operator == '*': + return left_val * right_val + elif node.operator == '/': + if right_val == 0: + raise EvaluationError("Division by zero") + return left_val / right_val + elif node.operator == '%': + if right_val == 0: + raise EvaluationError("Modulo by zero") + return left_val % right_val + elif node.operator in ('^', '**'): + try: + res = left_val ** right_val + if isinstance(res, complex): + raise EvaluationError("Complex numbers result not supported in real domain") + return res + except OverflowError: + raise EvaluationError("Numerical overflow in exponentiation") + + raise EvaluationError(f"Unsupported binary operator: '{node.operator}'") + + if isinstance(node, FunctionCallNode): + fn_name = node.name.lower() + if fn_name not in self.BUILTIN_FUNCTIONS: + raise EvaluationError(f"Unknown or unsupported function: '{node.name}'") + + args = [self.evaluate(arg) for arg in node.args] + func = self.BUILTIN_FUNCTIONS[fn_name] + + try: + result = func(*args) + return result + except TypeError as e: + raise EvaluationError(f"Invalid arguments for function '{node.name}': {e}") + except ValueError as e: + raise EvaluationError(f"Mathematical domain error in function '{node.name}': {e}") + + raise EvaluationError(f"Unknown AST node type: {type(node).__name__}") + + +def evaluate_expression(expression: str, variables: Dict[str, Union[int, float]] = None) -> Union[int, float]: + """Helper function to tokenize, parse, and evaluate a math expression string.""" + from expression_parser.tokenizer import Tokenizer + from expression_parser.parser import Parser + + tokenizer = Tokenizer(expression) + tokens = tokenizer.tokenize() + parser = Parser(tokens) + ast = parser.parse() + evaluator = Evaluator(variables) + return evaluator.evaluate(ast) diff --git a/expression_parser/main.py b/expression_parser/main.py new file mode 100644 index 00000000..61fc13c1 --- /dev/null +++ b/expression_parser/main.py @@ -0,0 +1,98 @@ +""" +Interactive CLI REPL and runner for Mathematical Expression Parser & Evaluator. +""" + +import sys +import os + +# Ensure project root is in sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from typing import Dict, Union +from expression_parser import evaluate_expression, TokenizerError, ParseError, EvaluationError + + +def run_repl(): + print("=" * 60) + print(" Mathematical Expression Parser & Evaluator (CLI REPL)") + print("=" * 60) + print("Commands:") + print(" exit / quit : Exit REPL") + print(" vars : Show current active variables") + print(" let x = val : Set variable value (e.g. let x = 10)") + print(" clear : Reset custom variables") + print("-" * 60) + + user_vars: Dict[str, Union[int, float]] = {} + + while True: + try: + inp = input("math> ").strip() + except (KeyboardInterrupt, EOFError): + print("\nExiting REPL. Goodbye!") + break + + if not inp: + continue + + if inp.lower() in ("exit", "quit"): + print("Goodbye!") + break + + if inp.lower() == "vars": + if not user_vars: + print("No custom variables set. Builtins available: pi, e, tau.") + else: + print("Active variables:", user_vars) + continue + + if inp.lower() == "clear": + user_vars.clear() + print("Custom variables cleared.") + continue + + if inp.lower().startswith("let "): + parts = inp[4:].split("=", 1) + if len(parts) == 2: + var_name = parts[0].strip() + val_expr = parts[1].strip() + + if not var_name.isidentifier(): + print(f"Error: Invalid variable name '{var_name}'") + continue + + try: + val = evaluate_expression(val_expr, user_vars) + user_vars[var_name] = val + print(f"Set {var_name} = {val}") + except Exception as e: + print(f"Error evaluating variable value: {e}") + continue + else: + print("Usage for assignment: let = ") + continue + + try: + result = evaluate_expression(inp, user_vars) + print(f"Result: {result}") + except (TokenizerError, ParseError, EvaluationError) as e: + print(f"Error: {e}") + except Exception as e: + print(f"Unexpected error: {e}") + + +def main(): + if len(sys.argv) > 1: + expr = " ".join(sys.argv[1:]) + try: + res = evaluate_expression(expr) + print(res) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + else: + run_repl() + + +if __name__ == "__main__": + main() diff --git a/expression_parser/parser.py b/expression_parser/parser.py new file mode 100644 index 00000000..effbe4eb --- /dev/null +++ b/expression_parser/parser.py @@ -0,0 +1,164 @@ +""" +AST Node definitions and Parser for mathematical expressions. +Implements a Recursive Descent parser. +""" + +from dataclasses import dataclass +from typing import List, Union +from expression_parser.tokenizer import Token, TokenType, TokenizerError, Tokenizer + + +class ParseError(Exception): + def __init__(self, message: str, position: int): + super().__init__(f"Parse error at position {position}: {message}") + self.position = position + + +# AST Node Definitions +@dataclass +class ASTNode: + pass + + +@dataclass +class NumberNode(ASTNode): + value: Union[int, float] + + +@dataclass +class VariableNode(ASTNode): + name: str + + +@dataclass +class UnaryOpNode(ASTNode): + operator: str + operand: ASTNode + + +@dataclass +class BinaryOpNode(ASTNode): + left: ASTNode + operator: str + right: ASTNode + + +@dataclass +class FunctionCallNode(ASTNode): + name: str + args: List[ASTNode] + + +class Parser: + def __init__(self, tokens: List[Token]): + self.tokens = tokens + self.pos = 0 + + def _current_token(self) -> Token: + if self.pos < len(self.tokens): + return self.tokens[self.pos] + return self.tokens[-1] + + def _consume(self, expected_type: TokenType) -> Token: + tok = self._current_token() + if tok.type != expected_type: + raise ParseError( + f"Expected token type '{expected_type.name}', found '{tok.type.name}' ('{tok.value}')", + tok.position + ) + self.pos += 1 + return tok + + def parse(self) -> ASTNode: + if self._current_token().type == TokenType.EOF: + raise ParseError("Empty expression", 0) + + node = self._expr() + + if self._current_token().type != TokenType.EOF: + tok = self._current_token() + raise ParseError(f"Unexpected token '{tok.value}' after full parse", tok.position) + + return node + + def _expr(self) -> ASTNode: + """expr -> term ((PLUS | MINUS) term)*""" + node = self._term() + + while self._current_token().type in (TokenType.PLUS, TokenType.MINUS): + tok = self._current_token() + self.pos += 1 + right = self._term() + node = BinaryOpNode(left=node, operator=tok.value, right=right) + + return node + + def _term(self) -> ASTNode: + """term -> factor ((MULTIPLY | DIVIDE | MODULO) factor)*""" + node = self._factor() + + while self._current_token().type in (TokenType.MULTIPLY, TokenType.DIVIDE, TokenType.MODULO): + tok = self._current_token() + self.pos += 1 + right = self._factor() + node = BinaryOpNode(left=node, operator=tok.value, right=right) + + return node + + def _factor(self) -> ASTNode: + """factor -> unary ((POWER) factor)?""" + node = self._unary() + + if self._current_token().type == TokenType.POWER: + tok = self._current_token() + self.pos += 1 + right = self._factor() # Right associative + node = BinaryOpNode(left=node, operator=tok.value, right=right) + + return node + + def _unary(self) -> ASTNode: + """unary -> (PLUS | MINUS) unary | primary""" + tok = self._current_token() + + if tok.type in (TokenType.PLUS, TokenType.MINUS): + self.pos += 1 + operand = self._unary() + return UnaryOpNode(operator=tok.value, operand=operand) + + return self._primary() + + def _primary(self) -> ASTNode: + """primary -> NUMBER | IDENTIFIER ( '(' args ')' )? | '(' expr ')' """ + tok = self._current_token() + + if tok.type == TokenType.NUMBER: + self.pos += 1 + return NumberNode(value=tok.value) + + if tok.type == TokenType.IDENTIFIER: + name = tok.value + self.pos += 1 + + if self._current_token().type == TokenType.LPAREN: + self.pos += 1 + args = [] + + if self._current_token().type != TokenType.RPAREN: + args.append(self._expr()) + while self._current_token().type == TokenType.COMMA: + self.pos += 1 + args.append(self._expr()) + + self._consume(TokenType.RPAREN) + return FunctionCallNode(name=name, args=args) + + return VariableNode(name=name) + + if tok.type == TokenType.LPAREN: + self.pos += 1 + node = self._expr() + self._consume(TokenType.RPAREN) + return node + + raise ParseError(f"Unexpected token '{tok.value}'", tok.position) diff --git a/expression_parser/tokenizer.py b/expression_parser/tokenizer.py new file mode 100644 index 00000000..a823b91a --- /dev/null +++ b/expression_parser/tokenizer.py @@ -0,0 +1,151 @@ +""" +Tokenizer for mathematical expression parser. +Converts input string into a sequence of Tokens. +""" + +from enum import Enum, auto +from typing import List, Optional, Any +from dataclasses import dataclass + + +class TokenType(Enum): + NUMBER = auto() + PLUS = auto() + MINUS = auto() + MULTIPLY = auto() + DIVIDE = auto() + MODULO = auto() + POWER = auto() + LPAREN = auto() + RPAREN = auto() + COMMA = auto() + IDENTIFIER = auto() + EOF = auto() + + +@dataclass +class Token: + type: TokenType + value: Any + position: int + + def __repr__(self) -> str: + return f"Token({self.type.name}, {repr(self.value)}, pos={self.position})" + + +class TokenizerError(Exception): + def __init__(self, message: str, position: int): + super().__init__(f"Tokenizer error at position {position}: {message}") + self.position = position + + +class Tokenizer: + FUNCTIONS = {"sin", "cos", "tan", "sqrt", "abs", "log", "exp", "floor", "ceil", "min", "max", "asin", "acos", "atan"} + CONSTANTS = {"pi", "e"} + + def __init__(self, expression: str): + self.expression = expression + self.pos = 0 + self.length = len(expression) + + def _peek(self, offset: int = 0) -> Optional[str]: + idx = self.pos + offset + if idx < self.length: + return self.expression[idx] + return None + + def tokenize(self) -> List[Token]: + tokens: List[Token] = [] + + while self.pos < self.length: + ch = self.expression[self.pos] + + if ch.isspace(): + self.pos += 1 + continue + + start_pos = self.pos + + # Numbers (integers & floats) + if ch.isdigit() or (ch == '.' and self._peek(1) and self._peek(1).isdigit()): + num_str = "" + has_decimal = False + + while self.pos < self.length: + curr = self.expression[self.pos] + if curr.isdigit(): + num_str += curr + self.pos += 1 + elif curr == '.' and not has_decimal: + has_decimal = True + num_str += curr + self.pos += 1 + else: + break + + val = float(num_str) if has_decimal else int(num_str) + tokens.append(Token(TokenType.NUMBER, val, start_pos)) + continue + + # Identifiers (Functions, Constants, Variables) + if ch.isalpha() or ch == '_': + ident = "" + while self.pos < self.length and (self.expression[self.pos].isalnum() or self.expression[self.pos] == '_'): + ident += self.expression[self.pos] + self.pos += 1 + + tokens.append(Token(TokenType.IDENTIFIER, ident, start_pos)) + continue + + # Single & Multi-character operators + if ch == '+': + tokens.append(Token(TokenType.PLUS, '+', start_pos)) + self.pos += 1 + elif ch == '-': + tokens.append(Token(TokenType.MINUS, '-', start_pos)) + self.pos += 1 + elif ch == '*': + if self._peek(1) == '*': + tokens.append(Token(TokenType.POWER, '**', start_pos)) + self.pos += 2 + else: + tokens.append(Token(TokenType.MULTIPLY, '*', start_pos)) + self.pos += 1 + elif ch == '/': + tokens.append(Token(TokenType.DIVIDE, '/', start_pos)) + self.pos += 1 + elif ch == '%': + tokens.append(Token(TokenType.MODULO, '%', start_pos)) + self.pos += 1 + elif ch == '^': + tokens.append(Token(TokenType.POWER, '^', start_pos)) + self.pos += 1 + elif ch == '(': + tokens.append(Token(TokenType.LPAREN, '(', start_pos)) + self.pos += 1 + elif ch == ')': + tokens.append(Token(TokenType.RPAREN, ')', start_pos)) + self.pos += 1 + elif ch == ',': + tokens.append(Token(TokenType.COMMA, ',', start_pos)) + self.pos += 1 + else: + raise TokenizerError(f"Unexpected character '{ch}'", start_pos) + + # Insert implicit multiplication tokens where appropriate (e.g. 2(3), 2x, (a)(b)) + processed_tokens: List[Token] = [] + for i, tok in enumerate(tokens): + processed_tokens.append(tok) + if i + 1 < len(tokens): + next_tok = tokens[i + 1] + + # Number followed by LPAREN, IDENTIFIER + if tok.type == TokenType.NUMBER and next_tok.type in (TokenType.LPAREN, TokenType.IDENTIFIER): + processed_tokens.append(Token(TokenType.MULTIPLY, '*', next_tok.position)) + + # RPAREN followed by LPAREN, NUMBER, IDENTIFIER + elif tok.type == TokenType.RPAREN and next_tok.type in (TokenType.LPAREN, TokenType.NUMBER, TokenType.IDENTIFIER): + processed_tokens.append(Token(TokenType.MULTIPLY, '*', next_tok.position)) + + processed_tokens.append(Token(TokenType.EOF, "", self.length)) + return processed_tokens diff --git a/projects_registry.json b/projects_registry.json index 3d6be63e..8dd3001f 100644 --- a/projects_registry.json +++ b/projects_registry.json @@ -890,5 +890,23 @@ "algorithm" ], "path": "utilities/N-Queens/N-Queens.py" + }, + { + "name": "Mathematical Expression Parser", + "emoji": "🧮", + "category": "math", + "difficulty": "advanced", + "description": "Parse, tokenize, and evaluate mathematical expressions from scratch.", + "keywords": [ + "math", + "parser", + "evaluator", + "ast", + "tokenizer", + "calculator" + ], + "path": "expression_parser/main.py" } ] + + diff --git a/tests/test_expression_parser.py b/tests/test_expression_parser.py new file mode 100644 index 00000000..e34349cf --- /dev/null +++ b/tests/test_expression_parser.py @@ -0,0 +1,73 @@ +import pytest +import math +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from expression_parser import ( + evaluate_expression, Tokenizer, Parser, Evaluator, + TokenType, TokenizerError, ParseError, EvaluationError +) + + +def test_tokenizer_basic(): + tok = Tokenizer("2 + 3 * 4") + tokens = tok.tokenize() + types = [t.type for t in tokens] + assert types == [ + TokenType.NUMBER, TokenType.PLUS, TokenType.NUMBER, + TokenType.MULTIPLY, TokenType.NUMBER, TokenType.EOF + ] + + +def test_implicit_multiplication(): + assert evaluate_expression("2(3 + 4)") == 14.0 + assert evaluate_expression("3pi") == pytest.approx(3 * math.pi) + + +def test_arithmetic_operations(): + assert evaluate_expression("1 + 2 + 3") == 6 + assert evaluate_expression("10 - 4 - 2") == 4 + assert evaluate_expression("2 + 3 * 4") == 14 + assert evaluate_expression("(2 + 3) * 4") == 20 + assert evaluate_expression("2 ^ 3 ^ 2") == 512 # Right associative + assert evaluate_expression("10 % 4") == 2 + + +def test_unary_operators(): + assert evaluate_expression("-5 + 3") == -2 + assert evaluate_expression("-(3 + 4)") == -7 + assert evaluate_expression("+5") == 5 + + +def test_functions_and_constants(): + assert evaluate_expression("sin(pi / 2)") == pytest.approx(1.0) + assert evaluate_expression("cos(0)") == pytest.approx(1.0) + assert evaluate_expression("sqrt(16)") == 4.0 + assert evaluate_expression("abs(-42)") == 42 + assert evaluate_expression("max(1, 5, 2)") == 5 + assert evaluate_expression("min(1, 5, 2)") == 1 + + +def test_variables(): + vars_dict = {"x": 10, "y": 3} + assert evaluate_expression("x + y * 2", vars_dict) == 16 + assert evaluate_expression("x^2 - y", vars_dict) == 97 + + +def test_error_handling(): + with pytest.raises(TokenizerError): + evaluate_expression("2 @ 3") + + with pytest.raises(ParseError): + evaluate_expression("2 +") + + with pytest.raises(ParseError): + evaluate_expression("(2 + 3") + + with pytest.raises(EvaluationError, match="Division by zero"): + evaluate_expression("10 / 0") + + with pytest.raises(EvaluationError, match="Undefined variable"): + evaluate_expression("unknown_var + 1")