Skip to content

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 →