From 21010e3429892523d2bddaaf22795efd6bdf4aa8 Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Sun, 19 Apr 2015 19:10:55 +0200 Subject: [PATCH 01/12] Initial commit for Math Lexer & Parser. Fixing implementation of the Shunting-yard algorithm. Fixing spelling error. --- src/Math/Calculator.php | 92 +++++ .../IncorrectExpressionException.php | 14 + .../IncorrectParenthesesException.php | 14 + .../Exception/UnknownConstantException.php | 14 + .../Exception/UnknownFunctionException.php | 14 + .../Exception/UnknownOperatorException.php | 14 + src/Math/Exception/UnknownTokenException.php | 14 + src/Math/Lexer.php | 322 ++++++++++++++++++ src/Math/Token/BaseToken.php | 62 ++++ src/Math/Token/CommaToken.php | 15 + src/Math/Token/DivisionToken.php | 39 +++ src/Math/Token/FunctionToken.php | 30 ++ src/Math/Token/FunctionTokenInterface.php | 18 + src/Math/Token/MinusToken.php | 39 +++ src/Math/Token/ModulusToken.php | 39 +++ src/Math/Token/MultiplyToken.php | 39 +++ src/Math/Token/NumberToken.php | 15 + src/Math/Token/OperatorTokenInterface.php | 52 +++ src/Math/Token/ParenthesesCloseToken.php | 15 + src/Math/Token/ParenthesesOpenToken.php | 15 + src/Math/Token/PlusToken.php | 39 +++ src/Math/Token/PowerToken.php | 39 +++ src/Math/Token/TokenInterface.php | 15 + 23 files changed, 969 insertions(+) create mode 100644 src/Math/Calculator.php create mode 100644 src/Math/Exception/IncorrectExpressionException.php create mode 100644 src/Math/Exception/IncorrectParenthesesException.php create mode 100644 src/Math/Exception/UnknownConstantException.php create mode 100644 src/Math/Exception/UnknownFunctionException.php create mode 100644 src/Math/Exception/UnknownOperatorException.php create mode 100644 src/Math/Exception/UnknownTokenException.php create mode 100644 src/Math/Lexer.php create mode 100644 src/Math/Token/BaseToken.php create mode 100644 src/Math/Token/CommaToken.php create mode 100644 src/Math/Token/DivisionToken.php create mode 100644 src/Math/Token/FunctionToken.php create mode 100644 src/Math/Token/FunctionTokenInterface.php create mode 100644 src/Math/Token/MinusToken.php create mode 100644 src/Math/Token/ModulusToken.php create mode 100644 src/Math/Token/MultiplyToken.php create mode 100644 src/Math/Token/NumberToken.php create mode 100644 src/Math/Token/OperatorTokenInterface.php create mode 100644 src/Math/Token/ParenthesesCloseToken.php create mode 100644 src/Math/Token/ParenthesesOpenToken.php create mode 100644 src/Math/Token/PlusToken.php create mode 100644 src/Math/Token/PowerToken.php create mode 100644 src/Math/Token/TokenInterface.php diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php new file mode 100644 index 00000000..e077cae2 --- /dev/null +++ b/src/Math/Calculator.php @@ -0,0 +1,92 @@ +lexer = new Lexer(); + + $this->lexer + ->addOperator('\+', 'Drupal\rules\Math\Token\PlusToken') + ->addOperator('\-', 'Drupal\rules\Math\Token\MinusToken') + ->addOperator('\*', 'Drupal\rules\Math\Token\MultiplyToken') + ->addOperator('\/', 'Drupal\rules\Math\Token\DivisionToken') + ->addOperator('\%', 'Drupal\rules\Math\Token\ModulusToken') + ->addOperator('\^', '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()); + $this->lexer->addConstant('e', exp(1)); + } + + 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 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 $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($regex, $operator) { + if (!in_array('Drupal\rules\Math\Token\OperatorTokenInterface', class_implements($operator))) { + throw new \InvalidArgumentException(); + } + + // Clear the static cache when a new operator is added. + unset($this->compiledRegex); + + $this->operators[md5($regex)] = [$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) { + throw new \LogicException(); + }; + + $types = [ + 'number', 'operator', 'function', 'open', 'close', 'comma', 'constant', + ]; + + 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); + } + } + }, $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\IncorrectParenthesesException + * @throws \Drupal\rules\Math\Exception\IncorrectExpressionException + */ + public function postfix($tokens) { + $output = []; + $stack = []; + + foreach ($tokens as $token) { + if ($token instanceof NumberToken) { + $output[] = $token; + } + elseif ($token instanceof FunctionToken) { + array_push($stack, $token); + } + elseif ($token instanceof ParenthesesOpenToken) { + array_push($stack, $token); + } + elseif ($token instanceof CommaToken) { + while (($current = array_pop($stack)) && (!$current instanceof ParenthesesOpenToken)) { + $output[] = $current; + + if (empty($stack)) { + throw new IncorrectExpressionException(); + } + } + } + elseif ($token instanceof ParenthesesCloseToken) { + while (($current = array_pop($stack)) && !($current instanceof ParenthesesOpenToken)) { + $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 ParenthesesOpenToken || $token instanceof ParenthesesCloseToken) { + throw new IncorrectParenthesesException(); + } + + $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 ParenthesesOpenToken($offset, $value); + + case 'close': + return new ParenthesesCloseToken($offset, $value); + + case 'comma': + return new CommaToken($offset, $value); + + case 'operator': + foreach ($this->operators as $id => $operator) { + if (!empty($match[$id][0])) { + return new $operator[1]($value, $offset); + } + } + 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); + } + + 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<$id>{$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)', $this->getOperatorRegex()), + 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]*'), + sprintf('(?P%s)', '\('), + sprintf('(?P%s)', '\)'), + sprintf('(?P%s)', '\,'), + ]; + + $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() / $b->getValue(); + return new NumberToken($result); + } + +} diff --git a/src/Math/Token/FunctionToken.php b/src/Math/Token/FunctionToken.php new file mode 100644 index 00000000..9e4bd0c9 --- /dev/null +++ b/src/Math/Token/FunctionToken.php @@ -0,0 +1,30 @@ +value; + for ($i = 0; $i < $places; $i++) { + array_push($arguments, array_pop($stack)->getValue()); + } + $result = call_user_func_array($function, $arguments); + return new NumberToken($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($result); + } + +} diff --git a/src/Math/Token/ModulusToken.php b/src/Math/Token/ModulusToken.php new file mode 100644 index 00000000..da68107b --- /dev/null +++ b/src/Math/Token/ModulusToken.php @@ -0,0 +1,39 @@ +getValue() % $a->getValue(); + return new NumberToken($b->getValue, $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 @@ + Date: Sun, 19 Apr 2015 23:14:05 +0200 Subject: [PATCH 02/12] Fixing spelling mistake (parenthesis / parentheses). --- .../IncorrectParenthesesException.php | 14 ----------- .../IncorrectParenthesisException.php | 14 +++++++++++ src/Math/Lexer.php | 24 +++++++++---------- src/Math/Token/ParenthesesCloseToken.php | 15 ------------ src/Math/Token/ParenthesesOpenToken.php | 15 ------------ src/Math/Token/ParenthesisCloseToken.php | 15 ++++++++++++ src/Math/Token/ParenthesisOpenToken.php | 15 ++++++++++++ 7 files changed, 56 insertions(+), 56 deletions(-) delete mode 100644 src/Math/Exception/IncorrectParenthesesException.php create mode 100644 src/Math/Exception/IncorrectParenthesisException.php delete mode 100644 src/Math/Token/ParenthesesCloseToken.php delete mode 100644 src/Math/Token/ParenthesesOpenToken.php create mode 100644 src/Math/Token/ParenthesisCloseToken.php create mode 100644 src/Math/Token/ParenthesisOpenToken.php diff --git a/src/Math/Exception/IncorrectParenthesesException.php b/src/Math/Exception/IncorrectParenthesesException.php deleted file mode 100644 index 8c94e5fb..00000000 --- a/src/Math/Exception/IncorrectParenthesesException.php +++ /dev/null @@ -1,14 +0,0 @@ - Date: Sun, 19 Apr 2015 23:26:39 +0200 Subject: [PATCH 03/12] Fixing token precedence in compiled regex. --- src/Math/Lexer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Math/Lexer.php b/src/Math/Lexer.php index c490faa9..e22430e2 100644 --- a/src/Math/Lexer.php +++ b/src/Math/Lexer.php @@ -258,7 +258,7 @@ protected function createToken($type, $value, $offset, $match) { case 'operator': foreach ($this->operators as $id => $operator) { if (!empty($match[$id][0])) { - return new $operator[1]($value, $offset); + return new $operator[1]($offset, $value); } } throw new UnknownOperatorException($offset, $value); @@ -306,13 +306,13 @@ protected function getCompiledTokenRegex() { } $regex = [ - sprintf('(?P%s)', $this->getOperatorRegex()), 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]*'), sprintf('(?P%s)', '\('), sprintf('(?P%s)', '\)'), sprintf('(?P%s)', '\,'), + sprintf('(?P%s)', $this->getOperatorRegex()), ]; $regex = implode('|', $regex); From 4f89f7c88e57d8b0d1ed582739ccc626d604b0cd Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Sun, 19 Apr 2015 23:27:07 +0200 Subject: [PATCH 04/12] Adding first test case for the tokenizer. --- tests/src/Unit/MathLexerTest.php | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/src/Unit/MathLexerTest.php diff --git a/tests/src/Unit/MathLexerTest.php b/tests/src/Unit/MathLexerTest.php new file mode 100644 index 00000000..23b3bcd0 --- /dev/null +++ b/tests/src/Unit/MathLexerTest.php @@ -0,0 +1,102 @@ +lexer = new Lexer(); + $this->lexer + ->addOperator('\+', 'Drupal\rules\Math\Token\PlusToken') + ->addOperator('\-', 'Drupal\rules\Math\Token\MinusToken') + ->addOperator('\*', 'Drupal\rules\Math\Token\MultiplyToken') + ->addOperator('\/', 'Drupal\rules\Math\Token\DivisionToken') + ->addOperator('\%', 'Drupal\rules\Math\Token\ModulusToken') + ->addOperator('\^', 'Drupal\rules\Math\Token\PowerToken'); + + $this->lexer + ->addFunction('abs', 'abs', 1) + ->addFunction('atan2', 'atan2', 2); + } + + /** + * Tests that mathematical expressions are properly tokenized. + * + * @param string $expression + * A mathematical expression. + * + * @param \Drupal\Rules\TokenInterface[] + * The list of matched tokens. + * + * @covers ::tokenize + * @dataProvider tokenizeProvider + */ + public function testTokenize($expression, $tokens) { + $this->assertArrayEquals($tokens, $this->lexer->tokenize($expression)); + } + + /** + * 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', [ + new NumberToken(0, 3), + new PowerToken(1, '^'), + new NumberToken(2, 5), + new MultiplyToken(4, '*'), + new NumberToken(6, 5)], + ], + ['(3^2) * -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), + ]], + ]; + } + +} From ceecd044dde870ae230b5bd3ed65344e491c9a4a Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Sun, 19 Apr 2015 23:42:50 +0200 Subject: [PATCH 05/12] Adding unique names for operators. --- src/Math/Calculator.php | 12 ++++++------ src/Math/Lexer.php | 10 ++++++---- tests/src/Unit/MathLexerTest.php | 12 ++++++------ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php index e077cae2..bb1f5a67 100644 --- a/src/Math/Calculator.php +++ b/src/Math/Calculator.php @@ -20,12 +20,12 @@ public function __construct() { $this->lexer = new Lexer(); $this->lexer - ->addOperator('\+', 'Drupal\rules\Math\Token\PlusToken') - ->addOperator('\-', 'Drupal\rules\Math\Token\MinusToken') - ->addOperator('\*', 'Drupal\rules\Math\Token\MultiplyToken') - ->addOperator('\/', 'Drupal\rules\Math\Token\DivisionToken') - ->addOperator('\%', 'Drupal\rules\Math\Token\ModulusToken') - ->addOperator('\^', 'Drupal\rules\Math\Token\PowerToken'); + ->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) diff --git a/src/Math/Lexer.php b/src/Math/Lexer.php index e22430e2..f757c630 100644 --- a/src/Math/Lexer.php +++ b/src/Math/Lexer.php @@ -73,6 +73,8 @@ public function addFunction($name, callable $function, $arguments = 1) { /** * 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 @@ -80,7 +82,7 @@ public function addFunction($name, callable $function, $arguments = 1) { * * @return $this */ - public function addOperator($regex, $operator) { + public function addOperator($name, $regex, $operator) { if (!in_array('Drupal\rules\Math\Token\OperatorTokenInterface', class_implements($operator))) { throw new \InvalidArgumentException(); } @@ -88,7 +90,7 @@ public function addOperator($regex, $operator) { // Clear the static cache when a new operator is added. unset($this->compiledRegex); - $this->operators[md5($regex)] = [$regex, $operator]; + $this->operators[$name] = [$regex, $operator]; return $this; } @@ -257,7 +259,7 @@ protected function createToken($type, $value, $offset, $match) { case 'operator': foreach ($this->operators as $id => $operator) { - if (!empty($match[$id][0])) { + if (!empty($match["op_$id"][0])) { return new $operator[1]($offset, $value); } } @@ -289,7 +291,7 @@ protected function createToken($type, $value, $offset, $match) { protected function getOperatorRegex() { $operators = []; foreach ($this->operators as $id => $operator) { - $operators[] = "(?P<$id>{$operator[0]})"; + $operators[] = "(?P{$operator[0]})"; } return implode('|', $operators); } diff --git a/tests/src/Unit/MathLexerTest.php b/tests/src/Unit/MathLexerTest.php index 23b3bcd0..0759ccff 100644 --- a/tests/src/Unit/MathLexerTest.php +++ b/tests/src/Unit/MathLexerTest.php @@ -37,12 +37,12 @@ public function setUp() { $this->lexer = new Lexer(); $this->lexer - ->addOperator('\+', 'Drupal\rules\Math\Token\PlusToken') - ->addOperator('\-', 'Drupal\rules\Math\Token\MinusToken') - ->addOperator('\*', 'Drupal\rules\Math\Token\MultiplyToken') - ->addOperator('\/', 'Drupal\rules\Math\Token\DivisionToken') - ->addOperator('\%', 'Drupal\rules\Math\Token\ModulusToken') - ->addOperator('\^', 'Drupal\rules\Math\Token\PowerToken'); + ->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) From bbc713ff98dd2798eab567f19ebf5a30cdf3529a Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Sun, 19 Apr 2015 23:52:15 +0200 Subject: [PATCH 06/12] Fixing offsets. --- src/Math/Token/DivisionToken.php | 4 ++-- src/Math/Token/FunctionToken.php | 8 +++----- src/Math/Token/MinusToken.php | 2 +- src/Math/Token/ModulusToken.php | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Math/Token/DivisionToken.php b/src/Math/Token/DivisionToken.php index be3e1781..325ed08b 100644 --- a/src/Math/Token/DivisionToken.php +++ b/src/Math/Token/DivisionToken.php @@ -32,8 +32,8 @@ public function getAssociativity() { public function execute(&$stack) { $a = array_pop($stack); $b = array_pop($stack); - $result = $a->getValue() / $b->getValue(); - return new NumberToken($result); + $result = $b->getValue() / $a->getValue(); + return new NumberToken($b->getOffset(), $result); } } diff --git a/src/Math/Token/FunctionToken.php b/src/Math/Token/FunctionToken.php index 9e4bd0c9..6dc50851 100644 --- a/src/Math/Token/FunctionToken.php +++ b/src/Math/Token/FunctionToken.php @@ -12,19 +12,17 @@ */ class FunctionToken extends BaseToken implements FunctionTokenInterface { - use ContainerTokenTrait; - /** * {@inheritdoc} */ public function execute(&$stack) { $arguments = []; - list($places, $function) = $this->value; - for ($i = 0; $i < $places; $i++) { + list($count, $function) = $this->value; + for ($i = 0; $i < $count; $i++) { array_push($arguments, array_pop($stack)->getValue()); } $result = call_user_func_array($function, $arguments); - return new NumberToken($result); + return new NumberToken($this->getOffset(), $result); } } diff --git a/src/Math/Token/MinusToken.php b/src/Math/Token/MinusToken.php index 185ad084..719a6df0 100644 --- a/src/Math/Token/MinusToken.php +++ b/src/Math/Token/MinusToken.php @@ -33,7 +33,7 @@ public function execute(&$stack) { $a = array_pop($stack); $b = array_pop($stack); $result = $b->getValue() - $a->getValue(); - return new NumberToken($result); + return new NumberToken($b->getOffset(), $result); } } diff --git a/src/Math/Token/ModulusToken.php b/src/Math/Token/ModulusToken.php index da68107b..26b74541 100644 --- a/src/Math/Token/ModulusToken.php +++ b/src/Math/Token/ModulusToken.php @@ -33,7 +33,7 @@ public function execute(&$stack) { $a = array_pop($stack); $b = array_pop($stack); $result = $b->getValue() % $a->getValue(); - return new NumberToken($b->getValue, $result); + return new NumberToken($b->getOffset(), $result); } } From d421c67ca9d60be044d1a8574f1c0f061603d0ce Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Sun, 19 Apr 2015 23:54:56 +0200 Subject: [PATCH 07/12] Adding docblocks. --- src/Math/Calculator.php | 8 ++++++++ src/Math/Lexer.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php index bb1f5a67..5eb77403 100644 --- a/src/Math/Calculator.php +++ b/src/Math/Calculator.php @@ -1,5 +1,10 @@ Date: Sun, 19 Apr 2015 23:58:17 +0200 Subject: [PATCH 08/12] Adding more docblocks and fixing some existing ones. --- src/Math/Calculator.php | 17 +++++++++++++++++ src/Math/Lexer.php | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php index 5eb77403..552d6ed3 100644 --- a/src/Math/Calculator.php +++ b/src/Math/Calculator.php @@ -24,6 +24,9 @@ class Calculator { */ protected $tokenCache = []; + /** + * Constructs a new Calculator object. + */ public function __construct() { $this->lexer = new Lexer(); @@ -69,6 +72,20 @@ public function __construct() { $this->lexer->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])) { diff --git a/src/Math/Lexer.php b/src/Math/Lexer.php index 0e389f0d..26d82585 100644 --- a/src/Math/Lexer.php +++ b/src/Math/Lexer.php @@ -42,7 +42,7 @@ class Lexer { /** * The list of registered constants. * - * @var number[] + * @var array */ protected $constants = []; From 39ec9251ba276e9f660b4a4460f048209907da25 Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Mon, 20 Apr 2015 10:33:24 +0200 Subject: [PATCH 09/12] Adding tests for the Shunting-yard algorithm implementation. --- tests/src/Unit/MathLexerTest.php | 116 ++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/tests/src/Unit/MathLexerTest.php b/tests/src/Unit/MathLexerTest.php index 0759ccff..979c98d5 100644 --- a/tests/src/Unit/MathLexerTest.php +++ b/tests/src/Unit/MathLexerTest.php @@ -8,7 +8,10 @@ namespace Drupal\Tests\rules\Unit; use Drupal\rules\Math\Lexer; +use Drupal\rules\Math\Token\CommaToken; use Drupal\rules\Math\Token\DivisionToken; +use Drupal\rules\Math\Token\FunctionToken; +use Drupal\rules\Math\Token\MinusToken; use Drupal\rules\Math\Token\MultiplyToken; use Drupal\rules\Math\Token\NumberToken; use Drupal\rules\Math\Token\ParenthesisCloseToken; @@ -54,8 +57,7 @@ public function setUp() { * * @param string $expression * A mathematical expression. - * - * @param \Drupal\Rules\TokenInterface[] + * @param \Drupal\rules\Math\Token\TokenInterface[] $tokens * The list of matched tokens. * * @covers ::tokenize @@ -65,6 +67,21 @@ 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. */ @@ -96,7 +113,102 @@ public function tokenizeProvider() { new MultiplyToken(6, '*'), new NumberToken(8, -2), ]], + ['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 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, '+'), + ]]]; + } + } From bb8cecc4f36d6c81a67676c50978b1445a1a1a4e Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Mon, 20 Apr 2015 10:34:35 +0200 Subject: [PATCH 10/12] Use is_subclass_of() instead of class_implements(). --- src/Math/Lexer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Math/Lexer.php b/src/Math/Lexer.php index 26d82585..aaa20106 100644 --- a/src/Math/Lexer.php +++ b/src/Math/Lexer.php @@ -83,7 +83,7 @@ public function addFunction($name, callable $function, $arguments = 1) { * @return $this */ public function addOperator($name, $regex, $operator) { - if (!in_array('Drupal\rules\Math\Token\OperatorTokenInterface', class_implements($operator))) { + if (!is_subclass_of($operator, 'Drupal\rules\Math\Token\OperatorTokenInterface')) { throw new \InvalidArgumentException(); } From f372ec9f2d5379e87413726f10810bb89d891121 Mon Sep 17 00:00:00 2001 From: Sebastian Siemssen Date: Mon, 20 Apr 2015 11:49:19 +0200 Subject: [PATCH 11/12] Adding support for variables. --- src/Math/Calculator.php | 14 ++++++++++++-- src/Math/Exception/UnknownVariableException.php | 14 ++++++++++++++ src/Math/Lexer.php | 16 ++++++++++++++-- src/Math/Token/VariableToken.php | 15 +++++++++++++++ tests/src/Unit/MathLexerTest.php | 2 +- 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/Math/Exception/UnknownVariableException.php create mode 100644 src/Math/Token/VariableToken.php diff --git a/src/Math/Calculator.php b/src/Math/Calculator.php index 552d6ed3..eb2cc49d 100644 --- a/src/Math/Calculator.php +++ b/src/Math/Calculator.php @@ -8,9 +8,11 @@ namespace Drupal\rules\Math; use Drupal\rules\Math\Exception\IncorrectExpressionException; +use Drupal\rules\Math\Exception\UnknownVariableException; use Drupal\rules\Math\Token\FunctionToken; use Drupal\rules\Math\Token\NumberToken; use Drupal\rules\Math\Token\OperatorTokenInterface; +use Drupal\rules\Math\Token\VariableToken; /** * Parser for mathematical expressions. @@ -68,8 +70,9 @@ public function __construct() { ->addFunction('tan', 'tan', 1) ->addFunction('tanh', 'tanh', 1); - $this->lexer->addConstant('pi', pi()); - $this->lexer->addConstant('e', exp(1)); + $this->lexer + ->addConstant('pi', pi()) + ->addConstant('e', exp(1)); } /** @@ -101,6 +104,13 @@ public function calculate($expression, $variables) { 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)); } diff --git a/src/Math/Exception/UnknownVariableException.php b/src/Math/Exception/UnknownVariableException.php new file mode 100644 index 00000000..cab5908b --- /dev/null +++ b/src/Math/Exception/UnknownVariableException.php @@ -0,0 +1,14 @@ +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', + '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); } @@ -160,7 +166,7 @@ public function postfix($tokens) { $stack = []; foreach ($tokens as $token) { - if ($token instanceof NumberToken) { + if ($token instanceof NumberToken || $token instanceof VariableToken) { $output[] = $token; } elseif ($token instanceof FunctionToken) { @@ -277,6 +283,10 @@ protected function createToken($type, $value, $offset, $match) { 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); @@ -311,6 +321,8 @@ protected function getCompiledTokenRegex() { 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)', '\,'), diff --git a/src/Math/Token/VariableToken.php b/src/Math/Token/VariableToken.php new file mode 100644 index 00000000..859c6ee5 --- /dev/null +++ b/src/Math/Token/VariableToken.php @@ -0,0 +1,15 @@ + Date: Mon, 20 Apr 2015 11:58:41 +0200 Subject: [PATCH 12/12] Adding test data for variables and constants. --- tests/src/Unit/MathLexerTest.php | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/src/Unit/MathLexerTest.php b/tests/src/Unit/MathLexerTest.php index 122994b6..917b67d1 100644 --- a/tests/src/Unit/MathLexerTest.php +++ b/tests/src/Unit/MathLexerTest.php @@ -18,6 +18,7 @@ use Drupal\rules\Math\Token\ParenthesisOpenToken; use Drupal\rules\Math\Token\PlusToken; use Drupal\rules\Math\Token\PowerToken; +use Drupal\rules\Math\Token\VariableToken; /** * @coversDefaultClass \Drupal\rules\Math\Lexer @@ -50,6 +51,9 @@ public function setUp() { $this->lexer ->addFunction('abs', 'abs', 1) ->addFunction('atan2', 'atan2', 2); + + $this->lexer + ->addConstant('pi', pi()); } /** @@ -90,21 +94,23 @@ public function tokenizeProvider() { ['3 + 2', [ new NumberToken(0, 3), new PlusToken(2, '+'), - new NumberToken(4, 2)], - ], + new NumberToken(4, 2), + ]], ['7/6', [ new NumberToken(0, 7), new DivisionToken(1, '/'), - new NumberToken(2, 6)], - ], - ['3^5 * 5', [ + 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)], - ], - ['(3^2) * -2', [ + 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, '^'), @@ -112,6 +118,8 @@ public function tokenizeProvider() { new ParenthesisCloseToken(4, ')'), new MultiplyToken(6, '*'), new NumberToken(8, -2), + new PlusToken(11, '+'), + new VariableToken(13, 'foo'), ]], ['abs(-5)', [ new FunctionToken(0, [1, 'abs']),