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 →