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 →