Math Evaluator — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package matheval is the Go twin of CosmoDev's src/lib/math-evaluator.ts
// (dual source: the web lib is TypeScript backed by mathjs, the CLI lib is Go
// backed by a hand-rolled recursive-descent evaluator — Go has no mathjs). Pure
// + deterministic, never panics. The table-driven tests in matheval_test.go
// share vectors with src/lib/math-evaluator.test.ts so the two implementations
// are held to the same contract.
//
// The input guard (SAFE_CHARS allowlist + FORBIDDEN word blacklist + MAX_LEN
// 200) is COPIED VERBATIM from src/lib/math-evaluator.ts — both twins 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 ("", false) — matching the TS lib's
// `typeof result === 'function' → null` path.
package matheval
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
// SAFE_CHARS and FORBIDDEN are COPIED VERBATIM from src/lib/math-evaluator.ts.
// They are the code-injection guard and MUST stay byte-identical to the TS
// patterns so both twins accept/reject the same expressions.
var (
safeChars = regexp.MustCompile(`^[0-9+\-*/().,\s a-zA-Z%^!<>]+$`)
forbidden = regexp.MustCompile(`\b(import|require|eval|function|while|for|process|global|this|window|document|constructor)\b`)
)
// maxLen mirrors MAX_LEN = 200 in src/lib/math-evaluator.ts.
const maxLen = 200
// ─── tokens ──────────────────────────────────────────────────────────────
type tokKind int
const (
tokEOF tokKind = iota
tokNumber
tokIdent
tokPlus
tokMinus
tokStar
tokSlash
tokPercent
tokCaret
tokBang
tokLT
tokGT
tokLParen
tokRParen
tokComma
)
type token struct {
kind tokKind
num float64 // valid for tokNumber
ident string // valid for tokIdent
}
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') }
// tokenize converts the (already SAFE_CHARS-validated) expression into a token
// slice terminated by tokEOF. SAFE_CHARS guarantees every byte is one of the
// allowed ASCII characters, so the default branch is unreachable in practice —
// it exists as a safety net.
func tokenize(s string) ([]token, error) {
toks := make([]token, 0, len(s)/2+1)
i := 0
for i < len(s) {
c := s[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f':
i++
case (c >= '0' && c <= '9') || c == '.':
start := i
for i < len(s) && (isDigit(s[i]) || s[i] == '.') {
i++
}
f, err := strconv.ParseFloat(s[start:i], 64)
if err != nil {
return nil, fmt.Errorf("matheval: invalid number %q", s[start:i])
}
toks = append(toks, token{kind: tokNumber, num: f})
case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'):
start := i
for i < len(s) && isAlpha(s[i]) {
i++
}
toks = append(toks, token{kind: tokIdent, ident: s[start:i]})
default:
var k tokKind
switch c {
case '+':
k = tokPlus
case '-':
k = tokMinus
case '*':
k = tokStar
case '/':
k = tokSlash
case '%':
k = tokPercent
case '^':
k = tokCaret
case '!':
k = tokBang
case '<':
k = tokLT
case '>':
k = tokGT
case '(':
k = tokLParen
case ')':
k = tokRParen
case ',':
k = tokComma
default:
return nil, fmt.Errorf("matheval: unexpected character %q", c)
}
toks = append(toks, token{kind: k})
i++
}
}
return append(toks, token{kind: tokEOF}), nil
}
// ─── values ──────────────────────────────────────────────────────────────
type valueKind int
const (
kindNumber valueKind = iota
kindBool
kindFunc // bare function reference — mirrors mathjs function objects
)
type value struct {
kind valueKind
num float64
truth bool
}
// toNum coerces a value to float64 for arithmetic. Booleans coerce to 1/0
// (matching mathjs). A function value is not a number → error.
func toNum(v value) (float64, error) {
switch v.kind {
case kindNumber:
return v.num, nil
case kindBool:
if v.truth {
return 1, nil
}
return 0, nil
default:
return 0, fmt.Errorf("matheval: cannot use a function as a number")
}
}
// ─── parser ──────────────────────────────────────────────────────────────
type parser struct {
toks []token
pos int
}
func (p *parser) peek() token { return p.toks[p.pos] }
// next advances past the current token but never past the trailing tokEOF, so
// peek always returns a valid token.
func (p *parser) next() token {
t := p.toks[p.pos]
if p.pos < len(p.toks)-1 {
p.pos++
}
return t
}
// parseComparison: lowest precedence. Handles < and >, yielding a boolean.
func (p *parser) parseComparison() (value, error) {
left, err := p.parseAdditive()
if err != nil {
return value{}, err
}
for {
t := p.peek()
if t.kind != tokLT && t.kind != tokGT {
break
}
p.next()
right, err := p.parseAdditive()
if err != nil {
return value{}, err
}
ln, err := toNum(left)
if err != nil {
return value{}, err
}
rn, err := toNum(right)
if err != nil {
return value{}, err
}
res := false
if t.kind == tokLT {
res = ln < rn
} else {
res = ln > rn
}
left = value{kind: kindBool, truth: res}
}
return left, nil
}
// parseAdditive: handles + and - (left-associative).
func (p *parser) parseAdditive() (value, error) {
left, err := p.parseMultiplicative()
if err != nil {
return value{}, err
}
for {
t := p.peek()
if t.kind != tokPlus && t.kind != tokMinus {
break
}
p.next()
right, err := p.parseMultiplicative()
if err != nil {
return value{}, err
}
ln, err := toNum(left)
if err != nil {
return value{}, err
}
rn, err := toNum(right)
if err != nil {
return value{}, err
}
if t.kind == tokPlus {
left = value{kind: kindNumber, num: ln + rn}
} else {
left = value{kind: kindNumber, num: ln - rn}
}
}
return left, nil
}
// parseMultiplicative: handles *, /, and % (modulo) — left-associative.
func (p *parser) parseMultiplicative() (value, error) {
left, err := p.parseExponent()
if err != nil {
return value{}, err
}
for {
t := p.peek()
if t.kind != tokStar && t.kind != tokSlash && t.kind != tokPercent {
break
}
p.next()
right, err := p.parseExponent()
if err != nil {
return value{}, err
}
ln, err := toNum(left)
if err != nil {
return value{}, err
}
rn, err := toNum(right)
if err != nil {
return value{}, err
}
switch t.kind {
case tokStar:
left = value{kind: kindNumber, num: ln * rn}
case tokSlash:
left = value{kind: kindNumber, num: ln / rn}
case tokPercent:
left = value{kind: kindNumber, num: math.Mod(ln, rn)}
}
}
return left, nil
}
// parseExponent: handles ^ (right-associative, so it recurses on itself).
func (p *parser) parseExponent() (value, error) {
left, err := p.parseUnary()
if err != nil {
return value{}, err
}
if p.peek().kind == tokCaret {
p.next()
right, err := p.parseExponent() // right-associative
if err != nil {
return value{}, err
}
ln, err := toNum(left)
if err != nil {
return value{}, err
}
rn, err := toNum(right)
if err != nil {
return value{}, err
}
return value{kind: kindNumber, num: math.Pow(ln, rn)}, nil
}
return left, nil
}
// parseUnary: prefix - (negation) and + (no-op). Recurses to handle --5 etc.
func (p *parser) parseUnary() (value, error) {
switch p.peek().kind {
case tokMinus:
p.next()
v, err := p.parseUnary()
if err != nil {
return value{}, err
}
n, err := toNum(v)
if err != nil {
return value{}, err
}
return value{kind: kindNumber, num: -n}, nil
case tokPlus:
p.next()
return p.parseUnary()
}
return p.parsePostfix()
}
// parsePostfix: trailing ! (factorial), applied after the primary.
func (p *parser) parsePostfix() (value, error) {
v, err := p.parsePrimary()
if err != nil {
return value{}, err
}
for p.peek().kind == tokBang {
p.next()
n, err := toNum(v)
if err != nil {
return value{}, err
}
f, err := factorial(n)
if err != nil {
return value{}, err
}
v = value{kind: kindNumber, num: f}
}
return v, nil
}
// parsePrimary: number, parenthesized expression, function call, or constant.
func (p *parser) parsePrimary() (value, error) {
t := p.next()
switch t.kind {
case tokNumber:
return value{kind: kindNumber, num: t.num}, nil
case tokLParen:
v, err := p.parseComparison()
if err != nil {
return value{}, err
}
if p.next().kind != tokRParen {
return value{}, fmt.Errorf("matheval: expected )")
}
return v, nil
case tokIdent:
name := strings.ToLower(t.ident)
if p.peek().kind == tokLParen {
return p.parseCall(name)
}
switch name {
case "pi":
return value{kind: kindNumber, num: math.Pi}, nil
case "e":
return value{kind: kindNumber, num: math.E}, nil
}
if isFunctionName(name) {
// Bare function reference — mirrors mathjs returning a function
// object; Evaluate maps this to ("", false).
return value{kind: kindFunc}, nil
}
return value{}, fmt.Errorf("matheval: undefined symbol %q", t.ident)
default:
return value{}, fmt.Errorf("matheval: unexpected token")
}
}
// parseCall parses a function call "name(arg, arg, ...)" whose LParen has been
// peeked but not yet consumed.
func (p *parser) parseCall(name string) (value, error) {
p.next() // consume (
args := []float64{}
if p.peek().kind != tokRParen {
for {
av, err := p.parseComparison()
if err != nil {
return value{}, err
}
an, err := toNum(av)
if err != nil {
return value{}, err
}
args = append(args, an)
if p.peek().kind == tokComma {
p.next()
continue
}
break
}
}
if p.next().kind != tokRParen {
return value{}, fmt.Errorf("matheval: expected )")
}
return callFunction(name, args)
}
// ─── functions & constants ───────────────────────────────────────────────
// isFunctionName reports whether name is a recognized function (a bare
// reference to one yields the kindFunc sentinel → Evaluate returns false).
func isFunctionName(name string) bool {
switch name {
case "sqrt", "sin", "cos", "tan", "log", "abs", "exp":
return true
}
return false
}
// callFunction dispatches a call to its implementation. All supported functions
// take exactly one argument (log is natural-log / ln here).
func callFunction(name string, args []float64) (value, error) {
if len(args) != 1 {
return value{}, fmt.Errorf("matheval: %s expects 1 argument, got %d", name, len(args))
}
x := args[0]
switch name {
case "sqrt":
return value{kind: kindNumber, num: math.Sqrt(x)}, nil
case "sin":
return value{kind: kindNumber, num: math.Sin(x)}, nil
case "cos":
return value{kind: kindNumber, num: math.Cos(x)}, nil
case "tan":
return value{kind: kindNumber, num: math.Tan(x)}, nil
case "log":
return value{kind: kindNumber, num: math.Log(x)}, nil
case "abs":
return value{kind: kindNumber, num: math.Abs(x)}, nil
case "exp":
return value{kind: kindNumber, num: math.Exp(x)}, nil
}
return value{}, fmt.Errorf("matheval: unknown function %q", name)
}
// factorial computes n! for a non-negative integer-valued float. Returns an
// error for negative or non-integer input.
func factorial(n float64) (float64, error) {
if n < 0 || n != math.Trunc(n) {
return 0, fmt.Errorf("matheval: factorial requires a non-negative integer")
}
if n > 170 { // 171! overflows float64
return math.Inf(1), nil
}
r := 1.0
for i := 2.0; i <= n; i++ {
r *= i
}
return r, nil
}
// ─── formatting ──────────────────────────────────────────────────────────
// formatNumber formats a float64 to match JS String(number) for the results the
// evaluator produces: integer-valued floats drop the decimal point (3.0 → "3",
// matching String(3)); other floats use the shortest round-trip representation
// (4.6 → "4.6"). Non-finite values mirror JS ("Infinity"/"-Infinity"/"NaN").
func formatNumber(f float64) string {
switch {
case math.IsNaN(f):
return "NaN"
case math.IsInf(f, 1):
return "Infinity"
case math.IsInf(f, -1):
return "-Infinity"
default:
return strconv.FormatFloat(f, 'f', -1, 64)
}
}
// ─── public API ──────────────────────────────────────────────────────────
// Evaluate mirrors evaluateExpression in src/lib/math-evaluator.ts. It returns
// the formatted result and true for a valid math expression, or ("", false) if
// the input is empty, over-long, contains disallowed characters or forbidden
// words, is a bare function reference, or fails to parse/evaluate. It is pure
// and never panics.
func Evaluate(expr string) (string, bool) {
trimmed := strings.TrimSpace(expr)
if trimmed == "" || len(trimmed) > maxLen {
return "", false
}
if !safeChars.MatchString(trimmed) || forbidden.MatchString(trimmed) {
return "", false
}
toks, err := tokenize(trimmed)
if err != nil {
return "", false
}
p := &parser{toks: toks}
result, err := p.parseComparison()
if err != nil {
return "", false
}
// Every token must be consumed — leftover tokens (e.g. "1 2") are an error.
if p.peek().kind != tokEOF {
return "", false
}
switch result.kind {
case kindFunc:
return "", false
case kindBool:
if result.truth {
return "true", true
}
return "false", true
case kindNumber:
return formatNumber(result.num), true
}
return "", false
}
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 →