Skip to content

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 →