Math Evaluator — JavaScript source
Evaluate math expressions - arithmetic, functions (sqrt, sin, log), comparisons, and constants (pi, e) - safely, in real time. Input is constrained to math-safe characters, fully client-side.
This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* math-evaluator - safe arithmetic expression evaluator (no eval).
*
* Language: JavaScript (ES2020+, runs unmodified in Node 16+ and modern browsers)
* Source: CosmoDev polyglot showcase port of the Math Evaluator tool, ported
* from cli/math-evaluator/math-evaluator.go (the Go CLI twin) which
* itself mirrors src/lib/math-evaluator.ts.
* License: display source - part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws (public API returns string | null).
* - Functionally equivalent to the Go twin: same inputs -> same outputs.
* - Self-contained: stdlib only (no npm dependencies - and notably NO mathjs,
* which the TS lib uses; the snippet ports the Go twin's hand-rolled
* evaluator onto the JS Math built-ins instead).
*
* The input guard (SAFE_CHARS allowlist + FORBIDDEN word blacklist + MAX_LEN
* 200) is COPIED VERBATIM from src/lib/math-evaluator.ts - every port must
* accept and reject exactly the same inputs. Grammar (precedence low -> high):
* comparison (< >) -> additive (+ -) -> multiplicative (* / %) -> exponent
* (^, right-assoc) -> unary (- prefix) -> postfix factorial (!) -> primary
* (number, parens, function call, constants pi/e). Functions: sqrt, sin, cos,
* tan, log (natural log / ln), abs, exp. Constants: pi, e (case-insensitive).
* A bare function name with no call mirrors mathjs returning a function
* object, so evaluate returns null. Result formatting delegates to JS
* String(number) - the exact reference the Go twin's formatNumber mimics.
*/
'use strict';
/** Allowlist of safe math characters. COPIED VERBATIM from src/lib/math-evaluator.ts. */
const SAFE_CHARS = /^[0-9+\-*/().,\s a-zA-Z%^!<>]+$/;
/** Word-level injection blacklist. COPIED VERBATIM from src/lib/math-evaluator.ts. */
const FORBIDDEN = /\b(import|require|eval|function|while|for|process|global|this|window|document|constructor)\b/;
/** Max input length. Mirrors MAX_LEN = 200 in src/lib/math-evaluator.ts. */
const MAX_LEN = 200;
/** Recognized single-arg functions, mapped onto the JS Math built-ins. */
const FUNCTIONS = {
sqrt: Math.sqrt,
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
log: Math.log, // natural log / ln
abs: Math.abs,
exp: Math.exp,
};
/** Named constants (case-insensitive lookup lowercases first). */
const CONSTANTS = { pi: Math.PI, e: Math.E };
/** Sentinel marking a bare function reference (mirrors mathjs function objects). */
const FUNC_REF = Symbol('matheval.func');
/** Thrown internally for any parse/eval failure; the public API swallows it. */
class _ParseError extends Error {}
/** Byte classifier mirroring the Go twin's isDigit. */
function isDigit(c) {
return c >= '0' && c <= '9';
}
function isAlpha(c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
/**
* Compute n! for a non-negative integer. Returns null (-> parse error) for
* negative or non-integer input; 171!+ overflows to Infinity, matching JS.
*/
function factorial(n) {
if (n < 0 || n !== Math.trunc(n)) return null;
if (n > 170) return Infinity;
let r = 1;
for (let i = 2; i <= n; i++) r *= i;
return r;
}
/**
* Convert the (already SAFE_CHARS-validated) expression into a token array
* terminated by { kind: 'eof' }. Returns null on a malformed number/unexpected
* char (the latter unreachable given the allowlist).
* @returns {Array<{kind: string, value?: number|string}> | null}
*/
function tokenize(s) {
const toks = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\v' || c === '\f') {
i++;
continue;
}
if (isDigit(c) || c === '.') {
const start = i;
while (i < s.length && (isDigit(s[i]) || s[i] === '.')) i++;
const num = Number(s.slice(start, i));
if (Number.isNaN(num)) return null;
toks.push({ kind: 'num', value: num });
continue;
}
if (isAlpha(c)) {
const start = i;
while (i < s.length && isAlpha(s[i])) i++;
toks.push({ kind: 'ident', value: s.slice(start, i) });
continue;
}
const single = {
'+': 'plus', '-': 'minus', '*': 'star', '/': 'slash', '%': 'percent',
'^': 'caret', '!': 'bang', '<': 'lt', '>': 'gt',
'(': 'lparen', ')': 'rparen', ',': 'comma',
}[c];
if (!single) return null;
toks.push({ kind: single });
i++;
}
toks.push({ kind: 'eof' });
return toks;
}
/** Coerce a value to a number. Booleans coerce to 1/0 (matching mathjs). */
function toNum(v) {
if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0;
throw new _ParseError('cannot use a function as a number');
}
/** Recursive-descent parser over a token array. Each rule returns a value
* (number | boolean | FUNC_REF). */
class Parser {
constructor(toks) {
this.toks = toks;
this.pos = 0;
}
peek() {
return this.toks[this.pos];
}
/** Advance past the current token but never past the trailing eof. */
next() {
const t = this.toks[this.pos];
if (this.pos < this.toks.length - 1) this.pos++;
return t;
}
/** Lowest precedence: handles < and >, yielding a boolean. */
comparison() {
let left = this.additive();
for (;;) {
const t = this.peek();
if (t.kind !== 'lt' && t.kind !== 'gt') break;
this.next();
const right = this.additive();
left = t.kind === 'lt' ? toNum(left) < toNum(right) : toNum(left) > toNum(right);
}
return left;
}
/** Handles + and - (left-associative). */
additive() {
let left = this.multiplicative();
for (;;) {
const t = this.peek();
if (t.kind !== 'plus' && t.kind !== 'minus') break;
this.next();
const right = this.multiplicative();
left = t.kind === 'plus' ? toNum(left) + toNum(right) : toNum(left) - toNum(right);
}
return left;
}
/** Handles *, /, and % (modulo) - left-associative. */
multiplicative() {
let left = this.exponent();
for (;;) {
const t = this.peek();
if (t.kind !== 'star' && t.kind !== 'slash' && t.kind !== 'percent') break;
this.next();
const right = this.exponent();
const ln = toNum(left);
const rn = toNum(right);
if (t.kind === 'star') left = ln * rn;
else if (t.kind === 'slash') left = ln / rn;
else left = ln % rn; // JS % matches Go's math.Mod for finite operands
}
return left;
}
/** Handles ^ (right-associative, so it recurses on itself). */
exponent() {
const left = this.unary();
if (this.peek().kind === 'caret') {
this.next();
const right = this.exponent();
return Math.pow(toNum(left), toNum(right));
}
return left;
}
/** Prefix - (negation) and + (no-op). Recurses to handle --5 etc. */
unary() {
const t = this.peek();
if (t.kind === 'minus') {
this.next();
return -toNum(this.unary());
}
if (t.kind === 'plus') {
this.next();
return this.unary();
}
return this.postfix();
}
/** Trailing ! (factorial), applied after the primary. */
postfix() {
let v = this.primary();
while (this.peek().kind === 'bang') {
this.next();
const f = factorial(toNum(v));
if (f === null) throw new _ParseError('factorial requires a non-negative integer');
v = f;
}
return v;
}
/** Number, parenthesized expression, function call, or constant. */
primary() {
const t = this.next();
if (t.kind === 'num') return t.value;
if (t.kind === 'lparen') {
const v = this.comparison();
if (this.next().kind !== 'rparen') throw new _ParseError('expected )');
return v;
}
if (t.kind === 'ident') {
const name = t.value.toLowerCase();
if (this.peek().kind === 'lparen') return this.call(name);
if (name === 'pi') return CONSTANTS.pi;
if (name === 'e') return CONSTANTS.e;
if (Object.prototype.hasOwnProperty.call(FUNCTIONS, name)) return FUNC_REF;
throw new _ParseError('undefined symbol');
}
throw new _ParseError('unexpected token');
}
/** Parse a function call "name(arg, arg, ...)" whose LParen was peeked. */
call(name) {
this.next(); // consume (
const args = [];
if (this.peek().kind !== 'rparen') {
for (;;) {
args.push(toNum(this.comparison()));
if (this.peek().kind === 'comma') {
this.next();
continue;
}
break;
}
}
if (this.next().kind !== 'rparen') throw new _ParseError('expected )');
return callFunction(name, args);
}
}
/** Dispatch a call to its implementation. All functions take exactly one arg. */
function callFunction(name, args) {
const fn = FUNCTIONS[name];
if (!fn) throw new _ParseError('unknown function');
if (args.length !== 1) throw new _ParseError(`${name} expects 1 argument`);
return fn(args[0]);
}
/**
* Evaluate a math expression to a display string, or null if unsafe/invalid.
* Mirrors evaluateExpression in src/lib/math-evaluator.ts. Pure; never throws.
* @param {string} expr
* @returns {string | null}
*/
function evaluate(expr) {
const trimmed = String(expr).trim();
if (trimmed === '' || trimmed.length > MAX_LEN) return null;
if (!SAFE_CHARS.test(trimmed) || FORBIDDEN.test(trimmed)) return null;
const toks = tokenize(trimmed);
if (!toks) return null;
const p = new Parser(toks);
let result;
try {
result = p.comparison();
if (p.peek().kind !== 'eof') return null; // leftover tokens (e.g. "1 2")
} catch {
return null;
}
if (result === FUNC_REF) return null;
if (typeof result === 'boolean') return result ? 'true' : 'false';
// number - JS String() is the formatting reference the other ports mimic.
return String(result);
}
// CommonJS export so the file is consumable from Node without a build step,
// while staying dependency-free and framework-agnostic.
module.exports = { evaluate, tokenize, Parser, callFunction, factorial, FUNCTIONS, CONSTANTS };
// ---------- showcase tests (the canonical suite lives in src/lib) ----------
if (require.main === module) {
/** @param {string|null} actual @param {string|null} expected @param {string} label */
function check(actual, expected, label) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
console.error(`FAIL ${label}: expected ${e}, got ${a}`);
process.exit(1);
}
console.log(`ok ${label} -> ${a}`);
}
check(evaluate('1 + 2'), '3', 'basic arithmetic');
check(evaluate('2 * 3 + 4'), '10', 'precedence (* before +)');
check(evaluate('2 ^ 10'), '1024', 'exponent');
check(evaluate('sqrt(16)'), '4', 'function call');
check(evaluate('2 > 1'), 'true', 'comparison -> boolean string');
check(evaluate('1 +'), null, 'parse error -> null');
console.log('all showcase tests passed');
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →