Skip to content

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

# math-evaluator — Ruby port: safe arithmetic expression evaluator (no eval).
# Ruby HAS eval — running it on shared-link input is exactly the attack the
# TS lib's guard prevents, so this ports the Go twin's recursive-descent
# parser instead. Guard first (allowlist + keyword blacklist + MAX_LEN), then
# parse; anything unsafe or invalid yields nil.

# COPIED VERBATIM from src/lib/math-evaluator.ts — the code-injection guard.
# (\A..\z instead of ^..$: Ruby's anchors match line boundaries.)
SAFE_CHARS = /\A[0-9+\-*\/().,\s a-zA-Z%^!<>]+\z/
FORBIDDEN = /\b(import|require|eval|function|while|for|process|global|this|window|document|constructor)\b/
MAX_LEN = 200

# Recognized single-arg functions; log is natural log / ln (as in mathjs).
FN = {
  'sqrt' => ->(x) { Math.sqrt(x) }, 'sin' => ->(x) { Math.sin(x) },
  'cos' => ->(x) { Math.cos(x) },   'tan' => ->(x) { Math.tan(x) },
  'log' => ->(x) { Math.log(x) },   'abs' => ->(x) { x.abs },
  'exp' => ->(x) { Math.exp(x) }
}.freeze

# Recursive-descent parser, precedence low → high: comparison < > → + - →
# * / % → ^ (right-assoc) → unary - → postfix ! → primary (number, parens,
# pi/e, one-arg calls). nil from @s[@i] is the end-of-input sentinel.
class Parser
  attr_reader :is_bool

  def initialize(src)
    @s = src
    @i = 0
    @err = false
    @is_bool = false
  end

  def peek
    @i += 1 while @s[@i] == ' ' || @s[@i] == "\t"
    @s[@i]
  end

  def eat(ch)
    return false unless peek == ch
    @i += 1
    true
  end

  def error!
    @err = true
    0.0
  end

  def factorial(n)
    return error! if n.negative? || n != n.truncate
    r = 1.0
    (2..n.to_i).each { |k| r *= k }
    r
  end

  def primary
    c = peek
    if c == '('
      @i += 1
      v = cmp
      @err = true unless eat(')')
      return v
    end
    if c =~ /[0-9.]/
      text = @s[@i..].match(/\A[0-9.]+/)[0]
      @i += text.length
      # reject malformed numbers ("." / "1.2.3") that to_f would gloss over
      return error! unless text =~ /[0-9]/ && text.count('.') <= 1
      text.to_f
    elsif c =~ /[a-zA-Z]/
      name = +''
      while @s[@i] =~ /[a-zA-Z]/
        name << @s[@i].downcase
        @i += 1
      end
      if (fn = FN[name])
        return error! unless eat('(')
        v = cmp
        return error! unless eat(')')
        fn.call(v)
      elsif name == 'pi'
        Math::PI
      elsif name == 'e'
        Math::E
      else
        error! # bare function reference or unknown symbol → rejected
      end
    else
      error!
    end
  end

  def post
    v = primary
    v = factorial(v) while eat('!')
    v
  end

  def unary
    return -unary if eat('-')
    return unary if eat('+')
    post
  end

  def powr
    l = unary
    eat('^') ? l**powr : l
  end

  def mul
    v = powr
    while eat('*') || eat('/') || eat('%')
      op = @s[@i - 1]
      r = powr
      v = op == '*' ? v * r : op == '/' ? v / r : v % r
    end
    v
  end

  def add
    v = mul
    while eat('+') || eat('-')
      v = @s[@i - 1] == '+' ? v + mul : v - mul
    end
    v
  end

  # Lowest precedence; parens recurse back here. Comparisons yield 1/0,
  # remembered in @is_bool so the caller can print "true"/"false".
  def cmp
    v = add
    while eat('<') || eat('>')
      lt = @s[@i - 1] == '<'
      r = add
      v = lt ? (v < r ? 1.0 : 0.0) : (v > r ? 1.0 : 0.0)
      @is_bool = true
    end
    v
  end

  def parsed_fully?
    peek
    !@err && @i >= @s.length
  end
end

# Mirrors evaluateExpression in src/lib/math-evaluator.ts: formatted result,
# or nil for empty/over-long/unsafe input or any parse failure. Integer-
# valued floats drop the ".0", matching JS String(number).
def evaluate(expr)
  t = expr.strip
  return nil if t.empty? || t.length > MAX_LEN || t !~ SAFE_CHARS || t =~ FORBIDDEN

  parser = Parser.new(t)
  v = parser.cmp
  return nil unless parser.parsed_fully?
  return v != 0 ? 'true' : 'false' if parser.is_bool

  v == v.truncate && v.abs < 1e15 ? v.to_i.to_s : v.to_s
end

if $PROGRAM_NAME == __FILE__
  ['1 + 2 * 3', '2^10', '5! + sqrt(16)', 'sin(pi)', '3 > 2', 'sqrt'].each do |c|
    puts format('%-14s = %s', c, evaluate(c) || '(rejected)')
  end
end

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 →