diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php new file mode 100644 index 00000000..eb2cc49d --- /dev/null +++ b/src/Math/Calculator.php @@ -0,0 +1,127 @@ +lexer = new Lexer(); + + $this->lexer + ->addOperator('plus', '\+', 'Drupal\rules\Math\Token\PlusToken') + ->addOperator('minus', '\-', 'Drupal\rules\Math\Token\MinusToken') + ->addOperator('multiply', '\*', 'Drupal\rules\Math\Token\MultiplyToken') + ->addOperator('division', '\/', 'Drupal\rules\Math\Token\DivisionToken') + ->addOperator('modulus', '\%', 'Drupal\rules\Math\Token\ModulusToken') + ->addOperator('power', '\^', 'Drupal\rules\Math\Token\PowerToken'); + + $this->lexer + ->addFunction('abs', 'abs', 1) + ->addFunction('acos', 'acos', 1) + ->addFunction('acosh', 'acosh', 1) + ->addFunction('asin', 'asin', 1) + ->addFunction('asinh', 'asinh', 1) + ->addFunction('atan2', 'atan2', 2) + ->addFunction('atan', 'atan', 1) + ->addFunction('atanh', 'atanh', 1) + ->addFunction('ceil', 'ceil', 1) + ->addFunction('cos', 'cos', 1) + ->addFunction('cosh', 'cosh', 1) + ->addFunction('deg2rad', 'deg2rad', 1) + ->addFunction('exp', 'exp', 1) + ->addFunction('floor', 'floor', 1) + ->addFunction('hypot', 'hypot', 2) + ->addFunction('log10', 'log10', 1) + ->addFunction('log', 'log', 2) + ->addFunction('max', 'max', 2) + ->addFunction('min', 'min', 2) + ->addFunction('pow', 'pow', 2) + ->addFunction('rad2deg', 'rad2deg', 1) + ->addFunction('rand', 'rand', 2) + ->addFunction('round', 'round', 1) + ->addFunction('sin', 'sin', 1) + ->addFunction('sinh', 'sinh', 1) + ->addFunction('sqrt', 'sqrt', 1) + ->addFunction('tan', 'tan', 1) + ->addFunction('tanh', 'tanh', 1); + + $this->lexer + ->addConstant('pi', pi()) + ->addConstant('e', exp(1)); + } + + /** + * Calculates the result of a mathematical expression. + * + * @param string $expression + * The mathematical expression. + * @param array $variables + * A list of numerical values keyed by their variable names. + * + * @return mixed + * The result of the mathematical expression. + * + * @throws \Drupal\rules\Math\Exception\IncorrectExpressionException + * @throws \Drupal\rules\Math\Exception\IncorrectParenthesisException + */ + public function calculate($expression, $variables) { + $hash = md5($expression); + if (isset($this->tokenCache[$hash])) { + return $this->tokenCache[$hash]; + } + + $stream = $this->lexer->tokenize($expression); + $tokens = $this->lexer->postfix($stream); + $this->tokenCache[$hash] = $tokens; + + $stack = []; + foreach ($tokens as $token) { + if ($token instanceof NumberToken) { + array_push($stack, $token); + } + elseif ($token instanceof VariableToken) { + $identifier = $token->getValue(); + if (!isset($variables[$identifier])) { + throw new UnknownVariableException($token->getOffset(), $identifier); + } + array_push($stack, new NumberToken($token->getOffset(), $variables[$identifier])); + } + elseif ($token instanceof OperatorTokenInterface || $token instanceof FunctionToken) { + array_push($stack, $token->execute($stack)); + } + } + + $result = array_pop($stack); + if (!empty($stack)) { + throw new IncorrectExpressionException(); + } + + return $result->getValue(); + } + +} diff --git a/src/Math/Exception/IncorrectExpressionException.php b/src/Math/Exception/IncorrectExpressionException.php new file mode 100644 index 00000000..edeb7758 --- /dev/null +++ b/src/Math/Exception/IncorrectExpressionException.php @@ -0,0 +1,14 @@ +functions[$name] = [$arguments, $function]; + return $this; + } + + /** + * Registers an operator with the lexer. + * + * @param string $name + * The name of the operator. + * @param string $regex + * The regular expression of the operator token. + * @param string $operator + * The full qualified class name of the operator token. + * + * @return $this + */ + public function addOperator($name, $regex, $operator) { + if (!is_subclass_of($operator, 'Drupal\rules\Math\Token\OperatorTokenInterface')) { + throw new \InvalidArgumentException(); + } + + // Clear the static cache when a new operator is added. + unset($this->compiledRegex); + + $this->operators[$name] = [$regex, $operator]; + return $this; + } + + /** + * Registers a constant with the lexer. + * + * @param string $name + * The name of the constant. + * @param int $value + * The value of the constant. + * + * @return $this + */ + public function addConstant($name, $value) { + $this->constants[$name] = $value; + return $this; + } + + /** + * Generates a token stream from a mathematical expression. + * + * @param string $input + * The mathematical expression to tokenize. + * + * @return array + * The generated token stream. + */ + public function tokenize($input) { + $matches = []; + $regex = $this->getCompiledTokenRegex(); + + if (preg_match_all($regex, $input, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) === FALSE) { + // There was a failure when evaluating the regular expression. + throw new \LogicException(); + }; + + $types = [ + 'number', 'operator', 'function', 'open', 'close', 'comma', 'constant', 'variable', + ]; + + // Traverse over all matches and create the corresponding tokens. + return array_map(function ($match) use ($types) { + foreach ($types as $type) { + if (!empty($match[$type][0])) { + return $this->createToken($type, $match[$type][0], $match[$type][1], $match); + } + } + + // There was a match outside of one of the token types. + throw new \LogicException(); + }, $matches); + } + + /** + * Reorganizes a list of tokens into reverse polish (postfix) notation. + * + * Uses an implementation of the Shunting-yard algorithm. + * + * http://en.wikipedia.org/wiki/Shunting-yard_algorithm + * + * @param \Drupal\rules\Math\Token\TokenInterface[] $tokens + * The tokens to be reorganized into reverse polish (postfix) notation. + * + * @return \Drupal\rules\Math\Token\TokenInterface[] + * The given tokens in reverse polish (postfix) notation. + * + * @throws \Drupal\rules\Math\Exception\IncorrectParenthesisException + * @throws \Drupal\rules\Math\Exception\IncorrectExpressionException + */ + public function postfix($tokens) { + $output = []; + $stack = []; + + foreach ($tokens as $token) { + if ($token instanceof NumberToken || $token instanceof VariableToken) { + $output[] = $token; + } + elseif ($token instanceof FunctionToken) { + array_push($stack, $token); + } + elseif ($token instanceof ParenthesisOpenToken) { + array_push($stack, $token); + } + elseif ($token instanceof CommaToken) { + while (($current = array_pop($stack)) && (!$current instanceof ParenthesisOpenToken)) { + $output[] = $current; + + if (empty($stack)) { + throw new IncorrectExpressionException(); + } + } + } + elseif ($token instanceof ParenthesisCloseToken) { + while (($current = array_pop($stack)) && !($current instanceof ParenthesisOpenToken)) { + $output[] = $current; + } + + if (!empty($stack) && ($stack[count($stack) - 1] instanceof FunctionToken)) { + $output[] = array_pop($stack); + } + } + elseif ($token instanceof OperatorTokenInterface) { + while (!empty($stack)) { + $last = end($stack); + if (!($last instanceof OperatorTokenInterface)) { + break; + } + + $associativity = $token->getAssociativity(); + $precedence = $token->getPrecedence(); + $last_precedence = $last->getPrecedence(); + if (!( + ($associativity === OperatorTokenInterface::ASSOCIATIVITY_LEFT && $precedence <= $last_precedence) || + ($associativity === OperatorTokenInterface::ASSOCIATIVITY_RIGHT && $precedence < $last_precedence) + )) { + break; + } + + $output[] = array_pop($stack); + } + + array_push($stack, $token); + } + } + + while (!empty($stack)) { + $token = array_pop($stack); + if ($token instanceof ParenthesisOpenToken || $token instanceof ParenthesisCloseToken) { + throw new IncorrectParenthesisException(); + } + + $output[] = $token; + } + + return $output; + } + + /** + * Creates a token object of the given type. + * + * @param string $type + * The type of the token. + * @param string $value + * The matched string. + * @param int $offset + * The offset of the matched string. + * @param $match + * The full match as returned by preg_match_all(). + * + * @return \Drupal\rules\Math\Token\TokenInterface + * The created token object. + * + * @throws \Drupal\rules\Math\Exception\UnknownConstantException + * @throws \Drupal\rules\Math\Exception\UnknownFunctionException + * @throws \Drupal\rules\Math\Exception\UnknownOperatorException + * @throws \Drupal\rules\Math\Exception\UnknownTokenException + */ + protected function createToken($type, $value, $offset, $match) { + switch ($type) { + case 'number': + return new NumberToken($offset, $value); + + case 'open': + return new ParenthesisOpenToken($offset, $value); + + case 'close': + return new ParenthesisCloseToken($offset, $value); + + case 'comma': + return new CommaToken($offset, $value); + + case 'operator': + foreach ($this->operators as $id => $operator) { + if (!empty($match["op_$id"][0])) { + return new $operator[1]($offset, $value); + } + } + throw new UnknownOperatorException($offset, $value); + + case 'function': + if (isset($this->functions[$value])) { + return new FunctionToken($offset, $this->functions[$value]); + } + throw new UnknownFunctionException($offset, $value); + + case 'constant': + $constant = substr($value, 1); + if (isset($this->constants[$constant])) { + return new NumberToken($offset, $this->constants[$constant]); + } + throw new UnknownConstantException($offset, $constant); + + case 'variable': + $variable = substr($value, 1, -1); + return new VariableToken($offset, $variable); + } + + throw new UnknownTokenException($offset, $value); + } + + /** + * Builds a concatenated regular expression for all available operators. + * + * @return string + * The regular expression for matching all available operators. + */ + protected function getOperatorRegex() { + $operators = []; + foreach ($this->operators as $id => $operator) { + $operators[] = "(?P{$operator[0]})"; + } + return implode('|', $operators); + } + + /** + * Compiles the regular expressions of all token types. + * + * @return string + * The compiled regular expression. + */ + protected function getCompiledTokenRegex() { + if (isset($this->compiledRegex)) { + return $this->compiledRegex; + } + + $regex = [ + sprintf('(?P%s)', '\-?\d+\.?\d*(E-?\d+)?'), + sprintf('(?P%s)', '[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*'), + sprintf('(?P%s)', '\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*'), + // @todo What are tokens/placeholders going to look like in D8 Rules? + sprintf('(?P%s)', '\[[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*\]'), + sprintf('(?P%s)', '\('), + sprintf('(?P%s)', '\)'), + sprintf('(?P%s)', '\,'), + sprintf('(?P%s)', $this->getOperatorRegex()), + ]; + + $regex = implode('|', $regex); + return $this->compiledRegex = "/$regex/i"; + } + +} diff --git a/src/Math/Token/BaseToken.php b/src/Math/Token/BaseToken.php new file mode 100644 index 00000000..7b252520 --- /dev/null +++ b/src/Math/Token/BaseToken.php @@ -0,0 +1,62 @@ +offset; + } + + /** + * Returns the value of the token. + * + * @return mixed + * The value of the token. + */ + public function getValue() { + return $this->value; + } + + /** + * Constructs a new TokenBase object. + * + * @param int $offset + * The offset of the token in the string. + * @param mixed $value + * The value of the token. + */ + public function __construct($offset, $value) { + $this->offset = $offset; + $this->value = $value; + } + +} diff --git a/src/Math/Token/CommaToken.php b/src/Math/Token/CommaToken.php new file mode 100644 index 00000000..ce6f8119 --- /dev/null +++ b/src/Math/Token/CommaToken.php @@ -0,0 +1,15 @@ +getValue() / $a->getValue(); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/FunctionToken.php b/src/Math/Token/FunctionToken.php new file mode 100644 index 00000000..6dc50851 --- /dev/null +++ b/src/Math/Token/FunctionToken.php @@ -0,0 +1,28 @@ +value; + for ($i = 0; $i < $count; $i++) { + array_push($arguments, array_pop($stack)->getValue()); + } + $result = call_user_func_array($function, $arguments); + return new NumberToken($this->getOffset(), $result); + } + +} diff --git a/src/Math/Token/FunctionTokenInterface.php b/src/Math/Token/FunctionTokenInterface.php new file mode 100644 index 00000000..5a96a5df --- /dev/null +++ b/src/Math/Token/FunctionTokenInterface.php @@ -0,0 +1,18 @@ +getValue() - $a->getValue(); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/ModulusToken.php b/src/Math/Token/ModulusToken.php new file mode 100644 index 00000000..26b74541 --- /dev/null +++ b/src/Math/Token/ModulusToken.php @@ -0,0 +1,39 @@ +getValue() % $a->getValue(); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/MultiplyToken.php b/src/Math/Token/MultiplyToken.php new file mode 100644 index 00000000..d0920fa1 --- /dev/null +++ b/src/Math/Token/MultiplyToken.php @@ -0,0 +1,39 @@ +getValue() * $a->getValue(); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/NumberToken.php b/src/Math/Token/NumberToken.php new file mode 100644 index 00000000..7199e71e --- /dev/null +++ b/src/Math/Token/NumberToken.php @@ -0,0 +1,15 @@ +getValue() + $a->getValue(); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/PowerToken.php b/src/Math/Token/PowerToken.php new file mode 100644 index 00000000..a9004cf9 --- /dev/null +++ b/src/Math/Token/PowerToken.php @@ -0,0 +1,39 @@ +getValue(), $a->getValue()); + return new NumberToken($b->getOffset(), $result); + } + +} diff --git a/src/Math/Token/TokenInterface.php b/src/Math/Token/TokenInterface.php new file mode 100644 index 00000000..e9c825c9 --- /dev/null +++ b/src/Math/Token/TokenInterface.php @@ -0,0 +1,15 @@ +lexer = new Lexer(); + $this->lexer + ->addOperator('plus', '\+', 'Drupal\rules\Math\Token\PlusToken') + ->addOperator('minus', '\-', 'Drupal\rules\Math\Token\MinusToken') + ->addOperator('multiply', '\*', 'Drupal\rules\Math\Token\MultiplyToken') + ->addOperator('division', '\/', 'Drupal\rules\Math\Token\DivisionToken') + ->addOperator('modulus', '\%', 'Drupal\rules\Math\Token\ModulusToken') + ->addOperator('power', '\^', 'Drupal\rules\Math\Token\PowerToken'); + + $this->lexer + ->addFunction('abs', 'abs', 1) + ->addFunction('atan2', 'atan2', 2); + + $this->lexer + ->addConstant('pi', pi()); + } + + /** + * Tests that mathematical expressions are properly tokenized. + * + * @param string $expression + * A mathematical expression. + * @param \Drupal\rules\Math\Token\TokenInterface[] $tokens + * The list of matched tokens. + * + * @covers ::tokenize + * @dataProvider tokenizeProvider + */ + public function testTokenize($expression, $tokens) { + $this->assertArrayEquals($tokens, $this->lexer->tokenize($expression)); + } + + /** + * Tests that the token stream is properly translated into postfix. + * + * @param \Drupal\rules\Math\Token\TokenInterface[] $tokens + * The list of tokens in infix notation. + * @param \Drupal\rules\Math\Token\TokenInterface[] $postfix + * The list of tokens in postfix notation. + * + * @covers ::postfix + * @dataProvider postfixProvider + */ + public function testPostfix($tokens, $postfix) { + $this->assertArrayEquals($postfix, $this->lexer->postfix($tokens)); + } + + /** + * Data provider for the testTokenize() test case. + */ + public function tokenizeProvider() { + return [ + ['3 + 2', [ + new NumberToken(0, 3), + new PlusToken(2, '+'), + new NumberToken(4, 2), + ]], + ['7/6', [ + new NumberToken(0, 7), + new DivisionToken(1, '/'), + new NumberToken(2, 6), + ]], + ['3^5 * 5 * $pi', [ + new NumberToken(0, 3), + new PowerToken(1, '^'), + new NumberToken(2, 5), + new MultiplyToken(4, '*'), + new NumberToken(6, 5), + new MultiplyToken(8, '*'), + new NumberToken(10, pi()), + ]], + ['(3^2) * -2 + [foo]', [ + new ParenthesisOpenToken(0, '('), + new NumberToken(1, 3), + new PowerToken(2, '^'), + new NumberToken(3, 2), + new ParenthesisCloseToken(4, ')'), + new MultiplyToken(6, '*'), + new NumberToken(8, -2), + new PlusToken(11, '+'), + new VariableToken(13, 'foo'), + ]], + ['abs(-5)', [ + new FunctionToken(0, [1, 'abs']), + new ParenthesisOpenToken(3, '('), + new NumberToken(4, -5), + new ParenthesisCloseToken(6, ')'), + ]], + ['atan2(4, -3)', [ + new FunctionToken(0, [2, 'atan2']), + new ParenthesisOpenToken(5, '('), + new NumberToken(6, 4), + new CommaToken(7, ','), + new NumberToken(9, -3), + new ParenthesisCloseToken(11, ')'), + ]], + // Example expression from Wikipedia. + // http://en.wikipedia.org/wiki/Shunting-yard_algorithm + ['3 + 4 * 2 / ( 1 - 5 ) ^ 2 ^ 3', [ + new NumberToken(0, 3), + new PlusToken(2, '+'), + new NumberToken(4, 4), + new MultiplyToken(6, '*'), + new NumberToken(8, 2), + new DivisionToken(10, '/'), + new ParenthesisOpenToken(12, '('), + new NumberToken(14, 1), + new MinusToken(16, '-'), + new NumberToken(18, 5), + new ParenthesisCloseToken(20, ')'), + new PowerToken(22, '^'), + new NumberToken(24, 2), + new PowerToken(26, '^'), + new NumberToken(28, 3), + ]], + ]; + } + + /** + * Data provider for the testPostfix() test case. + */ + public function postfixProvider() { + return [[[ + new NumberToken(0, 3), + new PlusToken(2, '+'), + new NumberToken(4, 2), + ], [ + new NumberToken(0, 3), + new NumberToken(4, 2), + new PlusToken(2, '+'), + ]], [[ + new ParenthesisOpenToken(0, '('), + new NumberToken(1, 3), + new PowerToken(2, '^'), + new NumberToken(3, 2), + new ParenthesisCloseToken(4, ')'), + new MultiplyToken(6, '*'), + new NumberToken(8, -2), + ], [ + new NumberToken(1, 3), + new NumberToken(3, 2), + new PowerToken(2, '^'), + new NumberToken(8, -2), + new MultiplyToken(6, '*'), + ]], + // Example expression from Wikipedia. + // http://en.wikipedia.org/wiki/Shunting-yard_algorithm + [[ + new NumberToken(0, 3), + new PlusToken(2, '+'), + new NumberToken(4, 4), + new MultiplyToken(6, '*'), + new NumberToken(8, 2), + new DivisionToken(10, '/'), + new ParenthesisOpenToken(12, '('), + new NumberToken(14, 1), + new MinusToken(16, '-'), + new NumberToken(18, 5), + new ParenthesisCloseToken(20, ')'), + new PowerToken(22, '^'), + new NumberToken(24, 2), + new PowerToken(26, '^'), + new NumberToken(28, 3), + ], [ + new NumberToken(0, 3), + new NumberToken(4, 4), + new NumberToken(8, 2), + new MultiplyToken(6, '*'), + new NumberToken(14, 1), + new NumberToken(18, 5), + new MinusToken(16, '-'), + new NumberToken(24, 2), + new NumberToken(28, 3), + new PowerToken(26, '^'), + new PowerToken(22, '^'), + new DivisionToken(10, '/'), + new PlusToken(2, '+'), + ]]]; + } + +}