Skip to content

Math Evaluator — Rust 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 Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! math-evaluator — safe arithmetic expression evaluator (no eval).
//!
//! Language: Rust (edition 2021, standard library only)
//! 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 panics (public API returns Option<String>).
//!   - Functionally equivalent to the Go twin: same inputs -> same outputs.
//!   - Self-contained: std only (no crates.io dependencies). Like Go, Rust has
//!     no mathjs (the TS lib's engine), so this is a hand-rolled evaluator.
//!
//! 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. The evaluator 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 None.
//!
//! Regex note: Rust's std has no regex crate, so the two TS regexes are
//! reproduced as byte-level scans (is_safe_chars / is_forbidden). The behavior
//! matches `\b(word)\b` for the FORBIDDEN list over the ASCII input the
//! allowlist permits.

use std::f64::consts::{E, PI};

/// Max input length. Mirrors MAX_LEN = 200 in src/lib/math-evaluator.ts.
const MAX_LEN: usize = 200;

/// Words blocked even when the allowlist passes — a second line of defense
/// against code-injection vectors. COPIED VERBATIM from the TS FORBIDDEN list.
const FORBIDDEN_WORDS: &[&str] = &[
    "import", "require", "eval", "function", "while", "for", "process", "global",
    "this", "window", "document", "constructor",
];

// ─── tokens ──────────────────────────────────────────────────────────────

#[derive(Debug, Clone, PartialEq)]
enum Tok {
    Num(f64),
    Ident(String),
    Plus,
    Minus,
    Star,
    Slash,
    Percent,
    Caret,
    Bang,
    Lt,
    Gt,
    LParen,
    RParen,
    Comma,
    Eof,
}

fn is_digit(b: u8) -> bool {
    b.is_ascii_digit()
}
fn is_alpha(b: u8) -> bool {
    b.is_ascii_alphabetic()
}

/// Reports whether `b` is a JS "word" char `[A-Za-z0-9_]` — used to reproduce
/// `\b` word boundaries in the FORBIDDEN scan.
fn is_word_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

/// Convert the (already SAFE_CHARS-validated) expression into a token slice
/// terminated by Eof. Returns None on a malformed number or unexpected byte —
/// the allowlist makes the latter unreachable in practice.
fn tokenize(s: &str) -> Option<Vec<Tok>> {
    let bytes = s.as_bytes();
    let mut toks: Vec<Tok> = Vec::with_capacity(bytes.len() / 2 + 1);
    let mut i = 0;
    while i < bytes.len() {
        let c = bytes[i];
        match c {
            b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1,
            b'0'..=b'9' | b'.' => {
                let start = i;
                while i < bytes.len() && (is_digit(bytes[i]) || bytes[i] == b'.') {
                    i += 1;
                }
                let slice = std::str::from_utf8(&bytes[start..i]).ok()?;
                let f: f64 = slice.parse().ok()?;
                toks.push(Tok::Num(f));
            }
            _ if is_alpha(c) => {
                let start = i;
                while i < bytes.len() && is_alpha(bytes[i]) {
                    i += 1;
                }
                let ident = std::str::from_utf8(&bytes[start..i]).ok()?.to_string();
                toks.push(Tok::Ident(ident));
            }
            _ => {
                let t = match c {
                    b'+' => Tok::Plus,
                    b'-' => Tok::Minus,
                    b'*' => Tok::Star,
                    b'/' => Tok::Slash,
                    b'%' => Tok::Percent,
                    b'^' => Tok::Caret,
                    b'!' => Tok::Bang,
                    b'<' => Tok::Lt,
                    b'>' => Tok::Gt,
                    b'(' => Tok::LParen,
                    b')' => Tok::RParen,
                    b',' => Tok::Comma,
                    _ => return None,
                };
                toks.push(t);
                i += 1;
            }
        }
    }
    toks.push(Tok::Eof);
    Some(toks)
}

// ─── values ──────────────────────────────────────────────────────────────

/// A fully-evaluated value. Booleans arise from comparisons; the Func sentinel
/// represents a bare function reference (mirrors mathjs function objects).
#[derive(Clone, Copy)]
enum Val {
    Num(f64),
    Bool(bool),
    Func,
}

/// Coerce a value to f64 for arithmetic. Booleans coerce to 1.0/0.0 (matching
/// mathjs); a function value is not a number -> error.
fn to_num(v: Val) -> Result<f64, ()> {
    match v {
        Val::Num(n) => Ok(n),
        Val::Bool(b) => Ok(if b { 1.0 } else { 0.0 }),
        Val::Func => Err(()),
    }
}

// ─── parser ──────────────────────────────────────────────────────────────

struct Parser {
    toks: Vec<Tok>,
    pos: usize,
}

impl Parser {
    fn peek(&self) -> &Tok {
        &self.toks[self.pos]
    }

    /// Advance past the current token but never past the trailing Eof, so peek
    /// always returns a valid token.
    fn next(&mut self) -> Tok {
        let t = self.toks[self.pos].clone();
        if self.pos < self.toks.len() - 1 {
            self.pos += 1;
        }
        t
    }

    /// Lowest precedence: handles < and >, yielding a boolean.
    fn comparison(&mut self) -> Result<Val, ()> {
        let mut left = self.additive()?;
        loop {
            let lt = matches!(self.peek(), Tok::Lt);
            let gt = matches!(self.peek(), Tok::Gt);
            if !lt && !gt {
                break;
            }
            let op = self.next();
            let right = self.additive()?;
            let ln = to_num(left)?;
            let rn = to_num(right)?;
            let res = if matches!(op, Tok::Lt) { ln < rn } else { ln > rn };
            left = Val::Bool(res);
        }
        Ok(left)
    }

    /// Handles + and - (left-associative).
    fn additive(&mut self) -> Result<Val, ()> {
        let mut left = self.multiplicative()?;
        loop {
            let plus = matches!(self.peek(), Tok::Plus);
            let minus = matches!(self.peek(), Tok::Minus);
            if !plus && !minus {
                break;
            }
            let op = self.next();
            let right = self.multiplicative()?;
            let ln = to_num(left)?;
            let rn = to_num(right)?;
            left = Val::Num(if matches!(op, Tok::Plus) { ln + rn } else { ln - rn });
        }
        Ok(left)
    }

    /// Handles *, /, and % (modulo) — left-associative.
    fn multiplicative(&mut self) -> Result<Val, ()> {
        let mut left = self.exponent()?;
        loop {
            let op = match self.peek() {
                Tok::Star => Tok::Star,
                Tok::Slash => Tok::Slash,
                Tok::Percent => Tok::Percent,
                _ => break,
            };
            self.next();
            let right = self.exponent()?;
            let ln = to_num(left)?;
            let rn = to_num(right)?;
            left = Val::Num(match op {
                Tok::Star => ln * rn,
                Tok::Slash => ln / rn,
                Tok::Percent => ln % rn,
                _ => unreachable!(),
            });
        }
        Ok(left)
    }

    /// Handles ^ (right-associative, so it recurses on itself).
    fn exponent(&mut self) -> Result<Val, ()> {
        let left = self.unary()?;
        if matches!(self.peek(), Tok::Caret) {
            self.next();
            let right = self.exponent()?; // right-associative
            let ln = to_num(left)?;
            let rn = to_num(right)?;
            return Ok(Val::Num(ln.powf(rn)));
        }
        Ok(left)
    }

    /// Prefix - (negation) and + (no-op). Recurses to handle --5 etc.
    fn unary(&mut self) -> Result<Val, ()> {
        match self.peek() {
            Tok::Minus => {
                self.next();
                let v = self.unary()?;
                Ok(Val::Num(-to_num(v)?))
            }
            Tok::Plus => {
                self.next();
                self.unary()
            }
            _ => self.postfix(),
        }
    }

    /// Trailing ! (factorial), applied after the primary.
    fn postfix(&mut self) -> Result<Val, ()> {
        let mut v = self.primary()?;
        while matches!(self.peek(), Tok::Bang) {
            self.next();
            v = Val::Num(factorial(to_num(v)?)?);
        }
        Ok(v)
    }

    /// Number, parenthesized expression, function call, or constant.
    fn primary(&mut self) -> Result<Val, ()> {
        let t = self.next();
        match t {
            Tok::Num(n) => Ok(Val::Num(n)),
            Tok::LParen => {
                let v = self.comparison()?;
                if !matches!(self.next(), Tok::RParen) {
                    return Err(());
                }
                Ok(v)
            }
            Tok::Ident(ref ident) => {
                let name = ident.to_ascii_lowercase();
                if matches!(self.peek(), Tok::LParen) {
                    return self.call(&name);
                }
                match name.as_str() {
                    "pi" => Ok(Val::Num(PI)),
                    "e" => Ok(Val::Num(E)),
                    _ if is_function_name(&name) => Ok(Val::Func),
                    _ => Err(()),
                }
            }
            _ => Err(()),
        }
    }

    /// Parse a function call "name(arg, arg, ...)" whose LParen has been peeked
    /// but not yet consumed.
    fn call(&mut self, name: &str) -> Result<Val, ()> {
        self.next(); // consume (
        let mut args: Vec<f64> = Vec::new();
        if !matches!(self.peek(), Tok::RParen) {
            loop {
                let av = self.comparison()?;
                args.push(to_num(av)?);
                if matches!(self.peek(), Tok::Comma) {
                    self.next();
                    continue;
                }
                break;
            }
        }
        if !matches!(self.next(), Tok::RParen) {
            return Err(());
        }
        match call_function(name, &args) {
            Some(n) => Ok(Val::Num(n)),
            None => Err(()),
        }
    }
}

// ─── functions & constants ───────────────────────────────────────────────

/// Reports whether `name` is a recognized function (a bare reference to one
/// yields the Func sentinel -> evaluate returns None).
fn is_function_name(name: &str) -> bool {
    matches!(name, "sqrt" | "sin" | "cos" | "tan" | "log" | "abs" | "exp")
}

/// Dispatch a call to its implementation. All supported functions take exactly
/// one argument (log is natural-log / ln here).
fn call_function(name: &str, args: &[f64]) -> Option<f64> {
    if args.len() != 1 {
        return None;
    }
    let x = args[0];
    Some(match name {
        "sqrt" => x.sqrt(),
        "sin" => x.sin(),
        "cos" => x.cos(),
        "tan" => x.tan(),
        "log" => x.ln(),
        "abs" => x.abs(),
        "exp" => x.exp(),
        _ => return None,
    })
}

/// Compute n! for a non-negative integer-valued float. Returns Err for negative
/// or non-integer input; 171!+ overflows f64 -> infinity.
fn factorial(n: f64) -> Result<f64, ()> {
    if n < 0.0 || n != n.trunc() {
        return Err(());
    }
    if n > 170.0 {
        return Ok(f64::INFINITY);
    }
    let mut r = 1.0;
    let mut i = 2.0;
    while i <= n {
        r *= i;
        i += 1.0;
    }
    Ok(r)
}

// ─── input guard (reproduces the TS SAFE_CHARS / FORBIDDEN regexes) ───────

/// Mirrors the TS SAFE_CHARS allowlist `^[0-9+\-*/().,\s a-zA-Z%^!<>]+$` as a
/// byte scan (std has no regex). `\s` covers space, tab, CR, LF, VT, FF.
fn is_safe_chars(s: &str) -> bool {
    s.bytes().all(|b| {
        matches!(b,
            b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z'
            | b'+' | b'-' | b'*' | b'/' | b'(' | b')' | b'.' | b','
            | b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c
            | b'%' | b'^' | b'!' | b'<' | b'>'
        )
    })
}

/// Mirrors the TS FORBIDDEN blacklist `\b(word|...)\b` as a word-boundary scan
/// over the fixed word list.
fn is_forbidden(s: &str) -> bool {
    let bytes = s.as_bytes();
    for w in FORBIDDEN_WORDS {
        let mut from = 0;
        while let Some(rel) = s[from..].find(w) {
            let idx = from + rel;
            let end = idx + w.len();
            let before_ok = idx == 0 || !is_word_byte(bytes[idx - 1]);
            let after_ok = end == bytes.len() || !is_word_byte(bytes[end]);
            if before_ok && after_ok {
                return true;
            }
            from = idx + w.len();
        }
    }
    false
}

// ─── formatting ──────────────────────────────────────────────────────────

/// Format an f64 to match JS String(number): integer-valued floats drop the
/// decimal point (3.0 -> "3"), other floats use Rust's shortest round-trip
/// Display (4.6 -> "4.6"), and non-finite values mirror JS
/// ("Infinity"/"-Infinity"/"NaN"). Rust's f64 Display already matches JS for
/// the finite range the evaluator produces; the integer branch keeps the two
/// twins byte-identical with the Go formatNumber.
fn format_number(f: f64) -> String {
    if f.is_nan() {
        return "NaN".to_string();
    }
    if f.is_infinite() {
        return if f > 0.0 { "Infinity".to_string() } else { "-Infinity".to_string() };
    }
    if f == f.trunc() && f.abs() < 1e16 {
        return format!("{}", f as i64);
    }
    format!("{}", f)
}

// ─── public API ──────────────────────────────────────────────────────────

/// Evaluate a math expression to a display string, or None if the input is
/// empty, over-long, contains disallowed characters or forbidden words, is a
/// bare function reference, or fails to parse/evaluate. Pure and never panics.
pub fn evaluate(expr: &str) -> Option<String> {
    let trimmed = expr.trim();
    if trimmed.is_empty() || trimmed.len() > MAX_LEN {
        return None;
    }
    if !is_safe_chars(trimmed) || is_forbidden(trimmed) {
        return None;
    }
    let toks = tokenize(trimmed)?;
    let mut p = Parser { toks, pos: 0 };
    let result = p.comparison().ok()?;
    // Every token must be consumed — leftover tokens (e.g. "1 2") are an error.
    if !matches!(p.peek(), Tok::Eof) {
        return None;
    }
    match result {
        Val::Func => None,
        Val::Bool(b) => Some(if b { "true".to_string() } else { "false".to_string() }),
        Val::Num(n) => Some(format_number(n)),
    }
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_arithmetic() {
        assert_eq!(evaluate("1 + 2"), Some("3".into()));
        assert_eq!(evaluate("2 * 3 + 4"), Some("10".into())); // * before +
        assert_eq!(evaluate("2 ^ 10"), Some("1024".into())); // exponent
    }

    #[test]
    fn functions_and_constants() {
        assert_eq!(evaluate("sqrt(16)"), Some("4".into()));
        assert_eq!(evaluate("sin(0)"), Some("0".into()));
    }

    #[test]
    fn comparison_yields_boolean_string() {
        assert_eq!(evaluate("2 > 1"), Some("true".into()));
        assert_eq!(evaluate("1 > 2"), Some("false".into()));
    }

    #[test]
    fn bare_function_reference_is_none() {
        assert_eq!(evaluate("sin"), None);
    }

    #[test]
    fn parse_error_is_none() {
        assert_eq!(evaluate("1 +"), None);
    }
}

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 →