* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\ExpressionLanguage; /** * Parses a token stream. * * This parser implements a "Precedence climbing" algorithm. * * @see http://www.engr.mun.ca/~theo/Misc/exp_parsing.htm * @see http://en.wikipedia.org/wiki/Operator-precedence_parser * * @author Fabien Potencier */ class Parser { public const OPERATOR_LEFT = 1; public const OPERATOR_RIGHT = 2; public const IGNORE_UNKNOWN_VARIABLES = 1; public const IGNORE_UNKNOWN_FUNCTIONS = 2; private TokenStream $stream; private array $unaryOperators; private array $binaryOperators; private array $names; private int $flags = 0; public function __construct( private array $functions, ) { $this->unaryOperators = [ 'not' => ['precedence' => 50], '!' => ['precedence' => 50], '-' => ['precedence' => 500], '+' => ['precedence' => 500], ]; $this->binaryOperators = [ 'or' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], '||' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], 'and' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], '&&' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], '|' => ['precedence' => 16, 'associativity' => self::OPERATOR_LEFT], '^' => ['precedence' => 17, 'associativity' => self::OPERATOR_LEFT], '&' => ['precedence' => 18, 'associativity' => self::OPERATOR_LEFT], '==' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '===' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '!=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '!==' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '<' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '>' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '>=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '<=' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'not in' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'in' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'contains' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'starts with' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'ends with' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'matches' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '..' => ['precedence' => 25, 'associativity' => self::OPERATOR_LEFT], '+' => ['precedence' => 30, 'associativity' => self::OPERATOR_LEFT], '-' => ['precedence' => 30, 'associativity' => self::OPERATOR_LEFT], '~' => ['precedence' => 40, 'associativity' => self::OPERATOR_LEFT], '*' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], '/' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], '%' => ['precedence' => 60, 'associativity' => self::OPERATOR_LEFT], '**' => ['precedence' => 200, 'associativity' => self::OPERATOR_RIGHT], ]; } /** * Converts a token stream to a node tree. * * The valid names is an array where the values * are the names that the user can use in an expression. * * If the variable name in the compiled PHP code must be * different, define it as the key. * * For instance, ['this' => 'container'] means that the * variable 'container' can be used in the expression * but the compiled code will use 'this'. * * @param int-mask-of $flags * * @throws SyntaxError */ public function parse(TokenStream $stream, array $names = [], int $flags = 0): Node\Node { return $this->doParse($stream, $names, $flags); } /** * Validates the syntax of an expression. * * The syntax of the passed expression will be checked, but not parsed. * If you want to skip checking dynamic variable names, pass `Parser::IGNORE_UNKNOWN_VARIABLES` instead of the array. * * @param int-mask-of $flags * * @throws SyntaxError When the passed expression is invalid */ public function lint(TokenStream $stream, ?array $names = [], int $flags = 0): void { if (null === $names) { trigger_deprecation('symfony/expression-language', '7.1', 'Passing "null" as the second argument of "%s()" is deprecated, pass "%s::IGNORE_UNKNOWN_VARIABLES" instead as a third argument.', __METHOD__, __CLASS__); $flags |= self::IGNORE_UNKNOWN_VARIABLES; $names = []; } $this->doParse($stream, $names, $flags); } /** * @param int-mask-of $flags * * @throws SyntaxError */ private function doParse(TokenStream $stream, array $names, int $flags): Node\Node { $this->flags = $flags; $this->stream = $stream; $this->names = $names; $node = $this->parseExpression(); if (!$stream->isEOF()) { throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', $stream->current->type, $stream->current->value), $stream->current->cursor, $stream->getExpression()); } unset($this->stream, $this->names); return $node; } public function parseExpression(int $precedence = 0): Node\Node { $expr = $this->getPrimary(); $token = $this->stream->current; while ($token->test(Token::OPERATOR_TYPE) && isset($this->binaryOperators[$token->value]) && $this->binaryOperators[$token->value]['precedence'] >= $precedence) { $op = $this->binaryOperators[$token->value]; $this->stream->next(); $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); $expr = new Node\BinaryNode($token->value, $expr, $expr1); $token = $this->stream->current; } if (0 === $precedence) { return $this->parseConditionalExpression($expr); } return $expr; } protected function getPrimary(): Node\Node { $token = $this->stream->current; if ($token->test(Token::OPERATOR_TYPE) && isset($this->unaryOperators[$token->value])) { $operator = $this->unaryOperators[$token->value]; $this->stream->next(); $expr = $this->parseExpression($operator['precedence']); return $this->parsePostfixExpression(new Node\UnaryNode($token->value, $expr)); } if ($token->test(Token::PUNCTUATION_TYPE, '(')) { $this->stream->next(); $expr = $this->parseExpression(); $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); return $this->parsePostfixExpression($expr); } return $this->parsePrimaryExpression(); } protected function parseConditionalExpression(Node\Node $expr): Node\Node { while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '??')) { $this->stream->next(); $expr2 = $this->parseExpression(); $expr = new Node\NullCoalesceNode($expr, $expr2); } while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '?')) { $this->stream->next(); if (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { $expr2 = $this->parseExpression(); if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) { $this->stream->next(); $expr3 = $this->parseExpression(); } else { $expr3 = new Node\ConstantNode(null); } } else { $this->stream->next(); $expr2 = $expr; $expr3 = $this->parseExpression(); } $expr = new Node\ConditionalNode($expr, $expr2, $expr3); } return $expr; } public function parsePrimaryExpression(): Node\Node { $token = $this->stream->current; switch ($token->type) { case Token::NAME_TYPE: $this->stream->next(); switch ($token->value) { case 'true': case 'TRUE': return new Node\ConstantNode(true); case 'false': case 'FALSE': return new Node\ConstantNode(false); case 'null': case 'NULL': return new Node\ConstantNode(null); default: if ('(' === $this->stream->current->value) { if (!($this->flags & self::IGNORE_UNKNOWN_FUNCTIONS) && false === isset($this->functions[$token->value])) { throw new SyntaxError(sprintf('The function "%s" does not exist.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, array_keys($this->functions)); } $node = new Node\FunctionNode($token->value, $this->parseArguments()); } else { if (!($this->flags & self::IGNORE_UNKNOWN_VARIABLES)) { if (!\in_array($token->value, $this->names, true)) { throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); } // is the name used in the compiled code different // from the name used in the expression? if (\is_int($name = array_search($token->value, $this->names))) { $name = $token->value; } } else { $name = $token->value; } $node = new Node\NameNode($name); } } break; case Token::NUMBER_TYPE: case Token::STRING_TYPE: $this->stream->next(); return new Node\ConstantNode($token->value); default: if ($token->test(Token::PUNCTUATION_TYPE, '[')) { $node = $this->parseArrayExpression(); } elseif ($token->test(Token::PUNCTUATION_TYPE, '{')) { $node = $this->parseHashExpression(); } else { throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', $token->type, $token->value), $token->cursor, $this->stream->getExpression()); } } return $this->parsePostfixExpression($node); } public function parseArrayExpression(): Node\ArrayNode { $this->stream->expect(Token::PUNCTUATION_TYPE, '[', 'An array element was expected'); $node = new Node\ArrayNode(); $first = true; while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { if (!$first) { $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'An array element must be followed by a comma'); // trailing ,? if ($this->stream->current->test(Token::PUNCTUATION_TYPE, ']')) { break; } } $first = false; $node->addElement($this->parseExpression()); } $this->stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened array is not properly closed'); return $node; } public function parseHashExpression(): Node\ArrayNode { $this->stream->expect(Token::PUNCTUATION_TYPE, '{', 'A hash element was expected'); $node = new Node\ArrayNode(); $first = true; while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { if (!$first) { $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'A hash value must be followed by a comma'); // trailing ,? if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '}')) { break; } } $first = false; // a hash key can be: // // * a number -- 12 // * a string -- 'a' // * a name, which is equivalent to a string -- a // * an expression, which must be enclosed in parentheses -- (1 + 2) if ($this->stream->current->test(Token::STRING_TYPE) || $this->stream->current->test(Token::NAME_TYPE) || $this->stream->current->test(Token::NUMBER_TYPE)) { $key = new Node\ConstantNode($this->stream->current->value); $this->stream->next(); } elseif ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { $key = $this->parseExpression(); } else { $current = $this->stream->current; throw new SyntaxError(sprintf('A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', $current->type, $current->value), $current->cursor, $this->stream->getExpression()); } $this->stream->expect(Token::PUNCTUATION_TYPE, ':', 'A hash key must be followed by a colon (:)'); $value = $this->parseExpression(); $node->addElement($value, $key); } $this->stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened hash is not properly closed'); return $node; } public function parsePostfixExpression(Node\Node $node): Node\GetAttrNode|Node\Node { $token = $this->stream->current; while (Token::PUNCTUATION_TYPE == $token->type) { if ('.' === $token->value || '?.' === $token->value) { $isNullSafe = '?.' === $token->value; $this->stream->next(); $token = $this->stream->current; $this->stream->next(); if ( Token::NAME_TYPE !== $token->type // Operators like "not" and "matches" are valid method or property names, // // In other words, besides NAME_TYPE, OPERATOR_TYPE could also be parsed as a property or method. // This is because operators are processed by the lexer prior to names. So "not" in "foo.not()" or "matches" in "foo.matches" will be recognized as an operator first. // But in fact, "not" and "matches" in such expressions shall be parsed as method or property names. // // And this ONLY works if the operator consists of valid characters for a property or method name. // // Other types, such as STRING_TYPE and NUMBER_TYPE, can't be parsed as property nor method names. // // As a result, if $token is NOT an operator OR $token->value is NOT a valid property or method name, an exception shall be thrown. && (Token::OPERATOR_TYPE !== $token->type || !preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $token->value)) ) { throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression()); } $arg = new Node\ConstantNode($token->value, true, $isNullSafe); $arguments = new Node\ArgumentsNode(); if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { $type = Node\GetAttrNode::METHOD_CALL; foreach ($this->parseArguments()->nodes as $n) { $arguments->addElement($n); } } else { $type = Node\GetAttrNode::PROPERTY_CALL; } $node = new Node\GetAttrNode($node, $arg, $arguments, $type); } elseif ('[' === $token->value) { $this->stream->next(); $arg = $this->parseExpression(); $this->stream->expect(Token::PUNCTUATION_TYPE, ']'); $node = new Node\GetAttrNode($node, $arg, new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL); } else { break; } $token = $this->stream->current; } return $node; } /** * Parses arguments. */ public function parseArguments(): Node\Node { $args = []; $this->stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); while (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ')')) { if ($args) { $this->stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); } $args[] = $this->parseExpression(); } $this->stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); return new Node\Node($args); } }