Skip to content

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

<?php
/**
 * math-evaluator — safe arithmetic expression evaluator (no eval).
 *
 * Language: PHP (8.1+, 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 throws (public API returns ?string).
 *   - Functionally equivalent to the Go twin: same inputs -> same outputs.
 *   - Self-contained: stdlib only (no Composer packages — and notably NO
 *     mathjs, which the TS lib uses; the snippet ports the Go twin's
 *     hand-rolled evaluator onto PHP's math built-ins instead).
 *
 * 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. 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 null. Result formatting uses json_encode, which
 * is PHP's JS-compatible shortest round-trip float repr (4.0 -> "4", 4.6 ->
 * "4.6") — with the non-finite values remapped to JS's Infinity/NaN strings.
 */

// COPIED VERBATIM from src/lib/math-evaluator.ts — the code-injection guard.
// PCRE note: PHP's delimiter scan is literal, so the unescaped '/' inside the
// character class would be read as the closing delimiter. We use '#' as the
// delimiter instead (the pattern body contains no '#') — the matched character
// set is byte-identical to the TS allowlist.
const MATHEVAL_SAFE_CHARS = '#^[0-9+\-*/().,\s a-zA-Z%^!<>]+$#';
const MATHEVAL_FORBIDDEN = '/\b(import|require|eval|function|while|for|process|global|this|window|document|constructor)\b/';
const MATHEVAL_MAX_LEN = 200; // mirrors MAX_LEN in src/lib/math-evaluator.ts

/**
 * Recognized single-arg functions. Lowercased name -> callable taking one arg.
 */
function matheval_functions(): array
{
    return [
        'sqrt' => 'sqrt',
        'sin' => 'sin',
        'cos' => 'cos',
        'tan' => 'tan',
        'log' => 'log', // natural log / ln
        'abs' => 'abs',
        'exp' => 'exp',
    ];
}

/** Named constants (looked up case-insensitively). */
function matheval_constants(): array
{
    return ['pi' => M_PI, 'e' => M_E];
}

/**
 * Convert the (already SAFE_CHARS-validated) expression into a token list
 * terminated by ['eof', null]. Returns null on a malformed number / unexpected
 * char (the latter unreachable given the allowlist). Each token is
 * [kind: string, value: float|string|null].
 */
function matheval_tokenize(string $s): ?array
{
    $toks = [];
    $i = 0;
    $n = strlen($s);
    $single = [
        '+' => 'plus', '-' => 'minus', '*' => 'star', '/' => 'slash',
        '%' => 'percent', '^' => 'caret', '!' => 'bang', '<' => 'lt',
        '>' => 'gt', '(' => 'lparen', ')' => 'rparen', ',' => 'comma',
    ];
    while ($i < $n) {
        $c = $s[$i];
        $ocode = ord($c);
        if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $ocode === 0x0b || $ocode === 0x0c) {
            $i++;
            continue;
        }
        $isDigit = $ocode >= 0x30 && $ocode <= 0x39;
        if ($isDigit || $c === '.') {
            $start = $i;
            while ($i < $n) {
                $b = ord($s[$i]);
                if (($b >= 0x30 && $b <= 0x39) || $s[$i] === '.') {
                    $i++;
                } else {
                    break;
                }
            }
            $num = filter_var(substr($s, $start, $i - $start), FILTER_VALIDATE_FLOAT);
            if ($num === false) {
                return null;
            }
            $toks[] = ['num', $num];
            continue;
        }
        $isAlpha = ($ocode >= 0x41 && $ocode <= 0x5a) || ($ocode >= 0x61 && $ocode <= 0x7a);
        if ($isAlpha) {
            $start = $i;
            while ($i < $n) {
                $b = ord($s[$i]);
                if (($b >= 0x41 && $b <= 0x5a) || ($b >= 0x61 && $b <= 0x7a)) {
                    $i++;
                } else {
                    break;
                }
            }
            $toks[] = ['ident', substr($s, $start, $i - $start)];
            continue;
        }
        $kind = $single[$c] ?? null;
        if ($kind === null) {
            return null;
        }
        $toks[] = [$kind, null];
        $i++;
    }
    $toks[] = ['eof', null];
    return $toks;
}

/**
 * n! for a non-negative integer-valued float; null for negative/non-integer.
 * 171!+ overflows float64 -> INF, matching JS.
 */
function matheval_factorial(float $n): ?float
{
    if ($n < 0.0 || $n !== (float) floor($n)) {
        return null;
    }
    if ($n > 170.0) {
        return INF;
    }
    $r = 1.0;
    $i = 2.0;
    while ($i <= $n) {
        $r *= $i;
        $i += 1.0;
    }
    return $r;
}

/**
 * Coerce a value to float. Booleans coerce to 1.0/0.0 (matching mathjs).
 * Throws ValueError for a function reference.
 */
function matheval_to_num($v): float
{
    if (is_bool($v)) {
        return $v ? 1.0 : 0.0;
    }
    if (is_int($v) || is_float($v)) {
        return (float) $v;
    }
    throw new ValueError('cannot use a function as a number');
}

/**
 * Dispatch a call to its implementation. All functions take exactly one arg.
 * @param list<float> $args
 */
function matheval_call_function(string $name, array $args): float
{
    $fns = matheval_functions();
    if (!isset($fns[$name])) {
        throw new ValueError('unknown function');
    }
    if (count($args) !== 1) {
        throw new ValueError($name . ' expects 1 argument');
    }
    return (float) $fns[$name]($args[0]);
}

/**
 * Recursive-descent parser over a token list. Each rule returns a value
 * (float | bool | MATHEVAL_FUNC_REF). Throws ValueError on any parse failure.
 */
final class MathevalParser
{
    /** @var list<array{0:string,1:mixed}> */
    private array $toks;
    private int $pos = 0;

    /** Bare-function sentinel — a unique object, neither float nor bool. */
    public static function funcRef(): object
    {
        static $ref = null;
        if ($ref === null) {
            $ref = new \stdClass();
        }
        return $ref;
    }

    /** @param list<array{0:string,1:mixed}> $toks */
    public function __construct(array $toks)
    {
        $this->toks = $toks;
    }

    /** @return array{0:string,1:mixed} */
    private function peek(): array
    {
        return $this->toks[$this->pos];
    }

    /** True when every token has been consumed (the next token is eof). */
    public function allConsumed(): bool
    {
        return $this->toks[$this->pos][0] === 'eof';
    }

    /** Advance past the current token but never past the trailing eof. */
    private function nextToken(): array
    {
        $t = $this->toks[$this->pos];
        if ($this->pos < count($this->toks) - 1) {
            $this->pos++;
        }
        return $t;
    }

    /** Lowest precedence: handles < and >, yielding a bool. */
    public function comparison()
    {
        $left = $this->additive();
        for (;;) {
            $kind = $this->peek()[0];
            if ($kind !== 'lt' && $kind !== 'gt') {
                break;
            }
            $this->nextToken();
            $right = $this->additive();
            $res = $kind === 'lt'
                ? matheval_to_num($left) < matheval_to_num($right)
                : matheval_to_num($left) > matheval_to_num($right);
            $left = $res;
        }
        return $left;
    }

    /** Handles + and - (left-associative). */
    public function additive()
    {
        $left = $this->multiplicative();
        for (;;) {
            $kind = $this->peek()[0];
            if ($kind !== 'plus' && $kind !== 'minus') {
                break;
            }
            $this->nextToken();
            $right = $this->multiplicative();
            $ln = matheval_to_num($left);
            $rn = matheval_to_num($right);
            $left = $kind === 'plus' ? $ln + $rn : $ln - $rn;
        }
        return $left;
    }

    /** Handles *, /, and % (modulo) — left-associative. */
    public function multiplicative()
    {
        $left = $this->exponent();
        for (;;) {
            $kind = $this->peek()[0];
            if ($kind !== 'star' && $kind !== 'slash' && $kind !== 'percent') {
                break;
            }
            $this->nextToken();
            $right = $this->exponent();
            $ln = matheval_to_num($left);
            $rn = matheval_to_num($right);
            if ($kind === 'star') {
                $left = $ln * $rn;
            } elseif ($kind === 'slash') {
                $left = $ln / $rn;
            } else {
                $left = fmod($ln, $rn); // matches Go's math.Mod
            }
        }
        return $left;
    }

    /** Handles ^ (right-associative, so it recurses on itself). */
    public function exponent()
    {
        $left = $this->unary();
        if ($this->peek()[0] === 'caret') {
            $this->nextToken();
            $right = $this->exponent();
            return pow(matheval_to_num($left), matheval_to_num($right));
        }
        return $left;
    }

    /** Prefix - (negation) and + (no-op). Recurses to handle --5 etc. */
    public function unary()
    {
        $kind = $this->peek()[0];
        if ($kind === 'minus') {
            $this->nextToken();
            return -matheval_to_num($this->unary());
        }
        if ($kind === 'plus') {
            $this->nextToken();
            return $this->unary();
        }
        return $this->postfix();
    }

    /** Trailing ! (factorial), applied after the primary. */
    public function postfix()
    {
        $v = $this->primary();
        while ($this->peek()[0] === 'bang') {
            $this->nextToken();
            $f = matheval_factorial(matheval_to_num($v));
            if ($f === null) {
                throw new ValueError('factorial requires a non-negative integer');
            }
            $v = $f;
        }
        return $v;
    }

    /** Number, parenthesized expression, function call, or constant. */
    public function primary()
    {
        $tok = $this->nextToken();
        [$kind, $value] = $tok;
        if ($kind === 'num') {
            return $value;
        }
        if ($kind === 'lparen') {
            $v = $this->comparison();
            if ($this->nextToken()[0] !== 'rparen') {
                throw new ValueError('expected )');
            }
            return $v;
        }
        if ($kind === 'ident') {
            $name = strtolower((string) $value);
            if ($this->peek()[0] === 'lparen') {
                return $this->callName($name);
            }
            $constants = matheval_constants();
            if ($name === 'pi') {
                return $constants['pi'];
            }
            if ($name === 'e') {
                return $constants['e'];
            }
            if (isset(matheval_functions()[$name])) {
                return self::funcRef();
            }
            throw new ValueError('undefined symbol');
        }
        throw new ValueError('unexpected token');
    }

    /** Parse a function call "name(arg, arg, ...)" whose LParen was peeked. */
    private function callName(string $name)
    {
        $this->nextToken(); // consume (
        $args = [];
        if ($this->peek()[0] !== 'rparen') {
            for (;;) {
                $args[] = matheval_to_num($this->comparison());
                if ($this->peek()[0] === 'comma') {
                    $this->nextToken();
                    continue;
                }
                break;
            }
        }
        if ($this->nextToken()[0] !== 'rparen') {
            throw new ValueError('expected )');
        }
        return matheval_call_function($name, $args);
    }
}

/**
 * Format a float to match JS String(number): json_encode is PHP's
 * JS-compatible shortest round-trip float repr (4.0 -> "4", 4.6 -> "4.6",
 * 0.1+0.2 -> "0.30000000000000004"); non-finite values are remapped to JS's
 * "Infinity"/"-Infinity"/"NaN" (json_encode emits false for INF/NaN).
 */
function matheval_format_number(float $x): string
{
    if (is_nan($x)) {
        return 'NaN';
    }
    if (is_infinite($x)) {
        return $x > 0 ? 'Infinity' : '-Infinity';
    }
    $s = json_encode($x);
    return is_string($s) ? $s : (string) $x;
}

/**
 * Evaluate a math expression to a display string, or null if unsafe/invalid.
 * Mirrors evaluateExpression in src/lib/math-evaluator.ts. Pure; never throws.
 */
function matheval_evaluate(string $expr): ?string
{
    $trimmed = trim($expr);
    if ($trimmed === '' || strlen($trimmed) > MATHEVAL_MAX_LEN) {
        return null;
    }
    if (!preg_match(MATHEVAL_SAFE_CHARS, $trimmed) || preg_match(MATHEVAL_FORBIDDEN, $trimmed)) {
        return null;
    }
    $toks = matheval_tokenize($trimmed);
    if ($toks === null) {
        return null;
    }
    $parser = new MathevalParser($toks);
    try {
        $result = $parser->comparison();
        if (!$parser->allConsumed()) { // leftover tokens (e.g. "1 2")
            return null;
        }
    } catch (\Throwable $_) {
        return null;
    }
    if ($result === MathevalParser::funcRef()) {
        return null;
    }
    if (is_bool($result)) { // must precede the number branch (true is int-ish)
        return $result ? 'true' : 'false';
    }
    return matheval_format_number((float) $result);
}

// ---------- showcase tests (the canonical suite lives in src/lib) ----------
// Run: `php php.php`
$isMainRun = PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__;
if ($isMainRun) {
    $check = function ($actual, $expected, $label) {
        if ($actual !== $expected) {
            fwrite(STDERR, "FAIL {$label}: expected " . var_export($expected, true) . ", got " . var_export($actual, true) . "\n");
            exit(1);
        }
        echo "ok   {$label} -> " . var_export($actual, true) . "\n";
    };
    $check(matheval_evaluate('1 + 2'), '3', 'basic arithmetic');
    $check(matheval_evaluate('2 * 3 + 4'), '10', 'precedence (* before +)');
    $check(matheval_evaluate('2 ^ 10'), '1024', 'exponent');
    $check(matheval_evaluate('sqrt(16)'), '4', 'function call');
    $check(matheval_evaluate('2 > 1'), 'true', 'comparison -> boolean string');
    $check(matheval_evaluate('1 +'), null, 'parse error -> null');
    echo "all showcase tests passed\n";
}

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 →