Last active
August 20, 2025 21:10
-
-
Save ryangoree/0913d16d1bc8fd7366d23aadb42bec1a to your computer and use it in GitHub Desktop.
A quick and dirty math expression evaluator.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import assert from "node:assert"; | |
| import { describe, it } from "node:test"; | |
| import { evaluate } from "./mathEvaluator.ts"; | |
| describe("evaluate", () => { | |
| it("evaluates single numbers", () => { | |
| assert.strictEqual(evaluate("1"), 1); | |
| assert.strictEqual(evaluate("12"), 12); | |
| assert.strictEqual(evaluate("12.34"), 12.34); | |
| assert.strictEqual(evaluate(".12"), 0.12); | |
| assert.strictEqual(evaluate("12."), 12); | |
| assert.strictEqual(evaluate("1_2"), 1_2); | |
| }); | |
| it("evaluates basic expressions", { timeout: 2000 }, () => { | |
| assert.strictEqual(evaluate("1 + 2"), 1 + 2); | |
| assert.strictEqual(evaluate("2 - 3"), 2 - 3); | |
| assert.strictEqual(evaluate("4 * 5"), 4 * 5); | |
| assert.strictEqual(evaluate("8 / 4"), 8 / 4); | |
| assert.strictEqual(evaluate("2 ** 2"), 2 ** 2); | |
| assert.strictEqual(evaluate("10 % 6"), 10 % 6); | |
| }); | |
| it("evaluates multi-step expressions", { timeout: 2000 }, () => { | |
| assert.strictEqual(evaluate("1 + 2 - 3"), 1 + 2 - 3); | |
| assert.strictEqual(evaluate("2 - 3 + 4"), 2 - 3 + 4); | |
| assert.strictEqual(evaluate("4 * 5 / 10"), (4 * 5) / 10); | |
| assert.strictEqual(evaluate("8 / 4 * 6"), (8 / 4) * 6); | |
| assert.strictEqual(evaluate("2 ** 3 ** 2"), 2 ** (3 ** 2)); | |
| }); | |
| it("respects order of operations", { timeout: 2000 }, () => { | |
| assert.strictEqual(evaluate("1 + 2 * 3 - 4"), 1 + 2 * 3 - 4); | |
| assert.strictEqual(evaluate("2 - 3 / 4 + 5"), 2 - 3 / 4 + 5); | |
| }); | |
| it("handles division by zero", { timeout: 2000 }, () => { | |
| assert.strictEqual(evaluate("1 / 0"), 1 / 0); | |
| assert.strictEqual(evaluate("1 / 0 / 2"), 1 / 0 / 2); | |
| }); | |
| it("evaluates unary operators", () => { | |
| assert.strictEqual(evaluate("+12"), +12); | |
| assert.strictEqual(evaluate("+12"), +12); | |
| assert.strictEqual(evaluate("-12"), -12); | |
| assert.strictEqual(evaluate("+-12"), +-12); | |
| assert.strictEqual(evaluate("-+12"), -+12); | |
| assert.strictEqual(evaluate("+-+-+12"), +-+-+12); | |
| assert.strictEqual(evaluate("-+-+-12"), -+-+-12); | |
| }); | |
| it("evaluates parentheses", () => { | |
| assert.strictEqual(evaluate("10 - (5 + 2)"), 10 - (5 + 2)); | |
| assert.strictEqual(evaluate("(1 + 2) * 3"), (1 + 2) * 3); | |
| assert.strictEqual(evaluate("4 / (2 - 1)"), 4 / (2 - 1)); | |
| assert.strictEqual(evaluate("4 / ((2 - 1))"), 4 / (2 - 1)); | |
| }); | |
| it("evaluates scientific expression", () => { | |
| assert.strictEqual(evaluate("1e2"), 1e2); | |
| assert.strictEqual(evaluate("1E2"), 1e2); | |
| assert.strictEqual(evaluate("12e3"), 12e3); | |
| assert.strictEqual(evaluate("12.34e5"), 12.34e5); | |
| assert.strictEqual(evaluate("12.34e-2"), 12.34e-2); | |
| }); | |
| it("evaluates complex expressions", () => { | |
| assert.strictEqual( | |
| evaluate("2e2 * -+3 / 6 + 3 ** (3 - 1) - 1"), | |
| (2e2 * -+3) / 6 + 3 ** (3 - 1) - 1, | |
| ); | |
| }); | |
| it("evaluates bitwise operators", () => { | |
| assert.strictEqual(evaluate("9 | 26"), 9 | 26); | |
| assert.strictEqual(evaluate("9 ^ 26"), 9 ^ 26); | |
| assert.strictEqual(evaluate("9 & 26"), 9 & 26); | |
| assert.strictEqual(evaluate("26 >> 2"), 26 >> 2); | |
| assert.strictEqual(evaluate("26 << 2"), 26 << 2); | |
| }); | |
| it("Returns NaN for invalid expressions", (t) => { | |
| const invalidExpressions = [ | |
| "++12", | |
| "--12", | |
| "*12", | |
| "/12", | |
| "12+", | |
| "12-", | |
| "12*", | |
| "12/", | |
| "12++34", | |
| "12--34", | |
| "12***34", | |
| "12x", | |
| "12e1.2", | |
| "12||34", | |
| "12^^34", | |
| "12&&34", | |
| "12>>>34", | |
| "12<<<34", | |
| "1+(2*3", | |
| ]; | |
| t.mock.method(console, "error").mock.mockImplementation(() => {}); | |
| for (const expression of invalidExpressions) { | |
| const result = evaluate(expression); | |
| assert( | |
| Number.isNaN(result), | |
| `Expected NaN for "${expression}", got: ${result}`, | |
| ); | |
| } | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // A quick and dirty math expression evaluator. A multi-step lexer + parser | |
| // would be more robust, but this was a fun exercise to see how far I could get | |
| // with regex and string manipulation and without arbitrary code execution. | |
| /** | |
| * Concatenates a sequence of strings and regular expressions into a single | |
| * regular expression. | |
| */ | |
| function concatPatterns(patterns: (string | RegExp)[], flags?: string): RegExp { | |
| return new RegExp( | |
| patterns.map((p) => (p instanceof RegExp ? p.source : p)).join(""), | |
| flags, | |
| ); | |
| } | |
| // https://regex101.com/r/VV3B0V/1 | |
| const unaryRegex = /(?<!\d)(?:(?<!\+)\+|(?<!-)-)/; | |
| // https://regex101.com/r/CBRWl6/1 | |
| const unsignedNumRegex = /\d*\.?\d*(?:e[+-]?\d+)?/i; | |
| // https://regex101.com/r/PhjIPJ/1 | |
| const terminalRegex = concatPatterns( | |
| ["^(", unaryRegex, ")*?", unsignedNumRegex, "$"], | |
| "i", | |
| ); | |
| /** | |
| * Evaluates a terminal expression (a single number preceded by optional | |
| * unary operators). | |
| */ | |
| function evaluateTerminal(expression: string) { | |
| if (!expression) return 0; | |
| if (!expression.match(terminalRegex)) { | |
| throw new Error(`Invalid expression: ${expression}`); | |
| } | |
| const mantissa = expression.split("e")[0]; | |
| const negativeSigns = mantissa?.match(/-/g) || []; | |
| const sign = negativeSigns.length % 2 ? "-" : ""; | |
| const abs = expression.replace(/^[+-]+/g, ""); | |
| const num = Number(`${sign}${abs}`); | |
| if (Number.isNaN(num)) { | |
| throw new Error(`Invalid number: ${expression}`); | |
| } | |
| return num; | |
| } | |
| /** | |
| * A list of operators with their evaluation functions, ordered by precedence. | |
| */ | |
| const operatorPrecedence: { | |
| operator: string; | |
| evaluate: (left: number, right: number) => number; | |
| associativity?: "left" | "right"; | |
| }[] = [ | |
| { | |
| operator: "**", | |
| evaluate: (left, right) => left ** right, | |
| associativity: "right", | |
| }, | |
| { | |
| operator: "/", | |
| evaluate: (left, right) => left / right, | |
| }, | |
| { | |
| operator: "*", | |
| evaluate: (left, right) => left * right, | |
| }, | |
| { | |
| operator: "%", | |
| evaluate: (left, right) => left % right, | |
| }, | |
| { | |
| operator: "-", | |
| evaluate: (left, right) => left - right, | |
| }, | |
| { | |
| operator: "+", | |
| evaluate: (left, right) => left + right, | |
| }, | |
| { | |
| operator: "<<", | |
| evaluate: (left, right) => left << right, | |
| }, | |
| { | |
| operator: ">>", | |
| evaluate: (left, right) => left >> right, | |
| }, | |
| { | |
| operator: "&", | |
| evaluate: (left, right) => left & right, | |
| }, | |
| { | |
| operator: "^", | |
| evaluate: (left, right) => left ^ right, | |
| }, | |
| { | |
| operator: "|", | |
| evaluate: (left, right) => left | right, | |
| }, | |
| ]; | |
| /** | |
| * Creates an operator evaluator function that can evaluate expressions with | |
| * the given operator. | |
| */ | |
| function createOperatorEvaluator({ | |
| operator, | |
| evaluateOperand, | |
| evaluate, | |
| associativity = "left", | |
| }: { | |
| operator: string; | |
| evaluateOperand: (expression: string) => number; | |
| evaluate: (left: number, right: number) => number; | |
| associativity?: "left" | "right"; | |
| }) { | |
| // https://regex101.com/r/WrhG2f/1 | |
| const operatorRegex = concatPatterns([ | |
| "(?<=\\d)", | |
| operator.replace(/([+*|^&])/g, "\\$1"), | |
| "(?=", | |
| unaryRegex, | |
| "*[\\d.])", | |
| ]); | |
| return (expression: string): number => { | |
| if (!expression.includes(operator)) { | |
| return evaluateOperand(expression); | |
| } | |
| const operands = expression.split(operatorRegex); | |
| const isLeftAssociative = associativity === "left"; | |
| const initial = isLeftAssociative ? operands.shift() : operands.pop(); | |
| if (!initial) return 0; | |
| const initialValue = evaluateOperand(initial); | |
| if (!operands.length) return initialValue; | |
| return isLeftAssociative | |
| ? operands.reduce( | |
| (result, operand) => evaluate(result, evaluateOperand(operand)), | |
| initialValue, | |
| ) | |
| : operands.reduceRight( | |
| (result, operand) => evaluate(evaluateOperand(operand), result), | |
| initialValue, | |
| ); | |
| }; | |
| } | |
| let evaluateOperand = evaluateTerminal; | |
| for (const { operator, evaluate, associativity } of operatorPrecedence) { | |
| evaluateOperand = createOperatorEvaluator({ | |
| operator, | |
| evaluateOperand, | |
| evaluate, | |
| associativity, | |
| }); | |
| } | |
| const removableCharsRegex = /_|\s+|\/\/.*$/g; | |
| const parenthesesRegex = /\(([^()]+)\)/g; | |
| /** | |
| * Evaluates a mathematical expression string and returns the result. Supports | |
| * basic arithmetic, unary operators, parentheses, scientific notation, and | |
| * operator precedence. | |
| */ | |
| export function evaluate(expression: string): number { | |
| try { | |
| expression = expression.replace(removableCharsRegex, ""); | |
| while (parenthesesRegex.test(expression)) { | |
| expression = expression.replace(parenthesesRegex, (_, inner) => | |
| String(evaluateOperand(inner)), | |
| ); | |
| } | |
| return evaluateOperand(expression); | |
| } catch (error) { | |
| // Wrap in a new error to prevent deep stack traces that include all | |
| // evaluation steps. | |
| const message = error instanceof Error ? error.message : String(error); | |
| console.error(new Error(message)); | |
| return NaN; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment