Skip to content

Instantly share code, notes, and snippets.

@ryangoree
Last active August 20, 2025 21:10
Show Gist options
  • Select an option

  • Save ryangoree/0913d16d1bc8fd7366d23aadb42bec1a to your computer and use it in GitHub Desktop.

Select an option

Save ryangoree/0913d16d1bc8fd7366d23aadb42bec1a to your computer and use it in GitHub Desktop.
A quick and dirty math expression evaluator.
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}`,
);
}
});
});
// 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