From a6e8d252b1517b822f778c00effe588b0ebf137a Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Sun, 13 Feb 2022 15:31:49 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + README.md | 74 ++++++++++++ bin/calc | 6 + composer.json | 26 ++++ src/Calculator.php | 294 +++++++++++++++++++++++++++++++++++++++++++++ src/TokenList.php | 42 +++++++ 6 files changed, 445 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/calc create mode 100644 composer.json create mode 100644 src/Calculator.php create mode 100644 src/TokenList.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd8b615 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/*.phar +/vendor +/composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cf2e47 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# PHP Calc + +The CLI calculator you never knew you needed. Also usable as a library! + +## Usage + +### CLI + +Call with an expression to evaluate: + + $ calc "42*atan(pi/2)" + 42.163162517863 + +Or call without arguments to go interactive: + + $ calc + ~ 42*atan(pi/2) + 42.163162517863 + +### Library + +As simple as falling off a rock: + + use NoccyLabs\PhpCalc\Calculator; + + $calc = new Calculator(); + $result = $calc->evaluate("42 * atan(pi/2)"); + +## What it does + +Supported operations: + +* `+` addition +* `-` subtraction +* `*` multiplication +* `/` division +* `^` exponent/raise to power + +Supported functions: + +* abs +* acos +* acosh +* asin +* asin +* atan +* atanh +* ceil +* cos +* cosh +* floor +* sin +* sinh +* log +* round +* sqrt +* tan +* tanh + +Constants: + +* `pi` + +## Useful tips + +* You can set the precision using the `_p` variable. If the expression is passed on the + command line, prepend the variable followed by a semicolon, such as `_p=2;pi` for the + result *3.14*. + +## Bugs and known issues + +* Negative numbers and subtraction should be surrounded by whitespace to parse properly. + Passing `2-1` will result in `2` and `-1`, returning *2* and a warning. Passing + `2 - 1` will return `1` as expected, as will `2- 1`. diff --git a/bin/calc b/bin/calc new file mode 100755 index 0000000..ce5e4b6 --- /dev/null +++ b/bin/calc @@ -0,0 +1,6 @@ +#!/usr/bin/env php +consts['pi'] = pi(); + } + + public function run() + { + while (true) { + $in = readline("\001\e[92m\002~\001\e[0m\002 "); + if (trim($in) === '') continue; + + readline_add_history(trim($in)); + + try { + $ret = $this->evaluate($in); + $this->printResult($ret, true); + } catch (\Throwable $t) { + printf("\e[33;1m %s\e[0m\n", $t->getMessage()); + } + } + } + + public function printResult($ret, bool $color=false) + { + if ($ret !== null) { + $_p = $this->vars['_p']??null; + if (ctype_digit($_p)) { + $out = sprintf("%.{$_p}f", $ret); + } else { + $out = $ret; + } + echo $color ? " \e[92;1m{$out}\e[0m\n" : "{$out}\n"; + } else { + echo $color ? " \e[90m#null\e[0m\n" : "#null\n"; + } + } + + /** + * Evaluate a string and return the result. + * + * @param string The expression + * @return int|float|null The result + */ + public function evaluate(string $expr) + { + $toks = $this->parse($expr); + if (!count($toks)) return; + + $tokens = new TokenList($toks); + $ret = $this->evalTokens($tokens); + + return $ret; + } + + /** + * Call a built-in math function + * + * @param string The function name + * @param int|float Parameter value + */ + private function evalFunc(string $func, $val) + { + if (in_array($func, [ + 'abs', 'acos', 'acosh', 'asin', 'asinh', + 'atan', 'atanh', 'ceil', 'cos', 'cosh', + 'floor', 'sin', 'sinh', 'log', 'round', + 'sqrt', 'tan', 'tanh' + ])) { + return call_user_func($func, $val); + } + throw new \RuntimeException("Bad function {$func}"); + } + + /** + * Recursively evaluate parsed tokens. + * + * Functions and paranthesized statements will be replaced by recursive calls t + * evalTokens or evalFunc. + * + * @param TokenList The tokens to evaluate + * @return int|float|null The result + */ + private function evalTokens(TokenList $toks) + { + $buf = []; + $assign = null; + + while (($tok = $toks->read()) !== null) { + if (str_ends_with($tok, '(')) { + $val = $this->evalTokens($toks); + $fn = rtrim($tok, '('); + if ($fn) { + //printf("feval : %s(%f)\n", $fn, $val); + $val = $this->evalFunc($fn, $val); + } + array_push($buf, $val); + } elseif ($tok == ')') { + break; + } elseif ($tok == ';') { + if (!$assign) { + //fprintf(STDERR, "warning: Use of semicolon without assignment\n"); + } + $val = $this->flatten($buf); + if ($assign) { + //printf("store : %s←%f\n", $assign, $val); + $this->vars[$assign] = $val; + $assign = null; + } + $buf = []; + } elseif ($tok == '=') { + $assign = array_pop($buf); + // printf("assign : %s (buf: %s)\n", $assign, join(" ", $buf)); + } else { + if (array_key_exists($tok, $this->consts)) { + $tok = $this->consts[$tok]; + } elseif (array_key_exists($tok, $this->vars)) { + //printf("subst : %s→%f\n", $tok, $this->vars[$tok]); + $tok = $this->vars[$tok]; + } + array_push($buf, $tok); + } + } + + $ret = $this->flatten($buf); + + if ($assign) { + //printf("%s=%f", $assign, $ret); + $this->vars[$assign] = $ret; + } + + return $ret; + + } + + /** + * Takes a buffer and performs the requested operations on it until a single value remains. + * + * @param array The buffer of tokens to evaluate + * @return int|float|null + */ + private function flatten(array $buf) + { + $sta = $buf; + $out = []; + + if (count($buf) == 1) { + return array_shift($buf); + } + + //printf("flatten: %s\n", join(' ', $sta)); + + for ($p = 0; $p < count($sta); $p++) { + $t = $sta[$p]; + switch ($t) { + case '^': + $a = array_pop($out); $b = $sta[++$p]; + $t = pow($a, $b); + break; + } + array_push($out, $t); + } + //if ($sta != $buf) printf(" : %s\n", join(' ', $sta)); + $sta = $out; + $out = []; + if (count($sta) == 1) { + return array_shift($sta); + } + + for ($p = 0; $p < count($sta); $p++) { + $t = $sta[$p]; + switch ($t) { + case '*': + $a = array_pop($out); $b = $sta[++$p]; + $t = $a * $b; + break; + case '/': + $a = array_pop($out); $b = $sta[++$p]; + $t = $a / $b; + break; + } + array_push($out, $t); + } + //if ($sta != $buf) printf(" : %s\n", join(' ', $sta)); + $sta = $out; + $out = []; + if (count($sta) == 1) { + return array_shift($sta); + } + + for ($p = 0; $p < count($sta); $p++) { + $t = $sta[$p]; + switch ($t) { + case '+': + $a = array_pop($out); $b = $sta[++$p]; + $t = $a + $b; + break; + case '-': + $a = array_pop($out); $b = $sta[++$p]; + $t = $a - $b; + break; + } + array_push($out, $t); + } + //if ($sta != $buf) printf(" : %s\n", join(' ', $sta)); + $sta = $out; + $out = []; + + if (count($sta) > 1) { + fprintf(STDERR, "warning: Buffer not empty after flattening, check your expression (contents: %s)\n", join(" ", $sta)); + } + + return array_shift($sta); + } + + /** + * Parse an input expression using regular expressions. + * + * Each supported token has its own regex patten. + * + * @param string The expression to parse + * @return array The parsed tokens + */ + public function parse(string $expr): array + { + $expr = trim($expr); + $src = $expr; + $toks = []; + while (preg_match('/^('. + '[a-z]+\('. // sin( + '|[a-z_]+'. // my_var + '|\('. // ( + '|\)'. // ) + '|(-)?\d+\.\d+'. // 3.14 + '|(-)?\d+'. // 42 + '|='. // = + '|[\+\-\*\/\^]'. // + - * / ^ + '|\;'. // ; + ')/', $expr, $m)) { + $toks[] = $m[0]; + $expr = trim(substr($expr, strlen($m[0]))); + } + if (strlen($expr) > 0) { + printf("err: %s\n %s\n", $src, str_repeat(" ",strlen($src)-strlen($expr))."^"); + return []; + } + return $toks; + } + + public static function main() + { + // call on run() or evaluate() + + $calc = new Calculator(); + + $args = array_slice($GLOBALS['argv'], 1); + if (count($args) > 0) { + try { + $ret = $calc->evaluate($args[0]); + $calc->printResult($ret); + } catch (\Throwable $t) { + fprintf(STDERR, "error: %s\n", $t->getMessage()); + } + } else { + $calc->run(); + } + + } + +} diff --git a/src/TokenList.php b/src/TokenList.php new file mode 100644 index 0000000..32aee6a --- /dev/null +++ b/src/TokenList.php @@ -0,0 +1,42 @@ +toks = $toks; + } + + public function current(): ?string + { + return ($this->ptr >= count($this->toks) ? null : $this->toks[$this->ptr]); + } + + public function read(): ?string + { + $v = $this->current(); + $this->next(); + return $v; + } + + public function next(): ?string + { + if ($this->ptr < count($this->toks)) { + $this->ptr++; + return ($this->ptr < count($this->toks) ? $this->toks[$this->ptr] : null); + } + return null; + } +}