diff --git a/CHANGELOG.md b/CHANGELOG.md index b06620c..a85455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ CHANGELOG ========= +7.2 +--- + + * Add support for null-coalescing unknown variables + * Add support for comments using `/*` & `*/` + * Allow passing any iterable as `$providers` list to `ExpressionLanguage` constructor + * Add support for `<<`, `>>`, and `~` bitwise operators + * Add support for logical `xor` operator + +7.1 +--- + + * Add support for PHP `min` and `max` functions + * Add `Parser::IGNORE_UNKNOWN_VARIABLES` and `Parser::IGNORE_UNKNOWN_FUNCTIONS` flags to control whether + parsing and linting should check for unknown variables and functions. + * Deprecate passing `null` as the allowed variable names to `ExpressionLanguage::lint()` and `Parser::lint()`, + pass the `IGNORE_UNKNOWN_VARIABLES` flag instead to ignore unknown variables during linting + +7.0 +--- + + * The `in` and `not in` operators now use strict comparison + 6.3 --- diff --git a/Compiler.php b/Compiler.php index 419eef8..97f6fb5 100644 --- a/Compiler.php +++ b/Compiler.php @@ -21,17 +21,13 @@ class Compiler implements ResetInterface { private string $source = ''; - private array $functions; - public function __construct(array $functions) - { - $this->functions = $functions; + public function __construct( + private array $functions, + ) { } - /** - * @return array - */ - public function getFunction(string $name) + public function getFunction(string $name): array { return $this->functions[$name]; } @@ -66,10 +62,7 @@ public function compile(Node\Node $node): static return $this; } - /** - * @return string - */ - public function subcompile(Node\Node $node) + public function subcompile(Node\Node $node): string { $current = $this->source; $this->source = ''; diff --git a/Expression.php b/Expression.php index 3cab1df..b101969 100644 --- a/Expression.php +++ b/Expression.php @@ -18,11 +18,9 @@ */ class Expression { - protected $expression; - - public function __construct(string $expression) - { - $this->expression = $expression; + public function __construct( + protected string $expression, + ) { } /** diff --git a/ExpressionFunction.php b/ExpressionFunction.php index 2ed664c..d9a537c 100644 --- a/ExpressionFunction.php +++ b/ExpressionFunction.php @@ -30,7 +30,6 @@ */ class ExpressionFunction { - private string $name; private \Closure $compiler; private \Closure $evaluator; @@ -39,9 +38,11 @@ class ExpressionFunction * @param callable $compiler A callable able to compile the function * @param callable $evaluator A callable able to evaluate the function */ - public function __construct(string $name, callable $compiler, callable $evaluator) - { - $this->name = $name; + public function __construct( + private string $name, + callable $compiler, + callable $evaluator, + ) { $this->compiler = $compiler(...); $this->evaluator = $evaluator(...); } diff --git a/ExpressionFunctionProviderInterface.php b/ExpressionFunctionProviderInterface.php index 479aeef..272954c 100644 --- a/ExpressionFunctionProviderInterface.php +++ b/ExpressionFunctionProviderInterface.php @@ -19,5 +19,5 @@ interface ExpressionFunctionProviderInterface /** * @return ExpressionFunction[] */ - public function getFunctions(); + public function getFunctions(): array; } diff --git a/ExpressionLanguage.php b/ExpressionLanguage.php index 6350cbf..379d386 100644 --- a/ExpressionLanguage.php +++ b/ExpressionLanguage.php @@ -32,9 +32,9 @@ class ExpressionLanguage protected array $functions = []; /** - * @param ExpressionFunctionProviderInterface[] $providers + * @param iterable $providers */ - public function __construct(?CacheItemPoolInterface $cache = null, array $providers = []) + public function __construct(?CacheItemPoolInterface $cache = null, iterable $providers = []) { $this->cache = $cache ?? new ArrayAdapter(); $this->registerFunctions(); @@ -61,8 +61,10 @@ public function evaluate(Expression|string $expression, array $values = []): mix /** * Parses an expression. + * + * @param int-mask-of $flags */ - public function parse(Expression|string $expression, array $names): ParsedExpression + public function parse(Expression|string $expression, array $names, int $flags = 0): ParsedExpression { if ($expression instanceof ParsedExpression) { return $expression; @@ -78,7 +80,7 @@ public function parse(Expression|string $expression, array $names): ParsedExpres $cacheItem = $this->cache->getItem(rawurlencode($expression.'//'.implode('|', $cacheKeyItems))); if (null === $parsedExpression = $cacheItem->get()) { - $nodes = $this->getParser()->parse($this->getLexer()->tokenize((string) $expression), $names); + $nodes = $this->getParser()->parse($this->getLexer()->tokenize((string) $expression), $names, $flags); $parsedExpression = new ParsedExpression((string) $expression, $nodes); $cacheItem->set($parsedExpression); @@ -91,17 +93,25 @@ public function parse(Expression|string $expression, array $names): ParsedExpres /** * Validates the syntax of an expression. * - * @param array|null $names The list of acceptable variable names in the expression, or null to accept any names + * @param array|null $names The list of acceptable variable names in the expression + * @param int-mask-of $flags * * @throws SyntaxError When the passed expression is invalid */ - public function lint(Expression|string $expression, ?array $names): void + public function lint(Expression|string $expression, ?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\Parser::IGNORE_UNKNOWN_VARIABLES" instead as a third argument.', __METHOD__, __NAMESPACE__); + + $flags |= Parser::IGNORE_UNKNOWN_VARIABLES; + $names = []; + } + if ($expression instanceof ParsedExpression) { return; } - $this->getParser()->lint($this->getLexer()->tokenize((string) $expression), $names); + $this->getParser()->lint($this->getLexer()->tokenize((string) $expression), $names, $flags); } /** @@ -110,13 +120,11 @@ public function lint(Expression|string $expression, ?array $names): void * @param callable $compiler A callable able to compile the function * @param callable $evaluator A callable able to evaluate the function * - * @return void - * * @throws \LogicException when registering a function after calling evaluate(), compile() or parse() * * @see ExpressionFunction */ - public function register(string $name, callable $compiler, callable $evaluator) + public function register(string $name, callable $compiler, callable $evaluator): void { if (isset($this->parser)) { throw new \LogicException('Registering functions after calling evaluate(), compile() or parse() is not supported.'); @@ -125,18 +133,12 @@ public function register(string $name, callable $compiler, callable $evaluator) $this->functions[$name] = ['compiler' => $compiler, 'evaluator' => $evaluator]; } - /** - * @return void - */ - public function addFunction(ExpressionFunction $function) + public function addFunction(ExpressionFunction $function): void { $this->register($function->getName(), $function->getCompiler(), $function->getEvaluator()); } - /** - * @return void - */ - public function registerProvider(ExpressionFunctionProviderInterface $provider) + public function registerProvider(ExpressionFunctionProviderInterface $provider): void { foreach ($provider->getFunctions() as $function) { $this->addFunction($function); @@ -148,7 +150,10 @@ public function registerProvider(ExpressionFunctionProviderInterface $provider) */ protected function registerFunctions() { - $this->addFunction(ExpressionFunction::fromPhp('constant')); + $basicPhpFunctions = ['constant', 'min', 'max']; + foreach ($basicPhpFunctions as $function) { + $this->addFunction(ExpressionFunction::fromPhp($function)); + } $this->addFunction(new ExpressionFunction('enum', static fn ($str): string => \sprintf("(\constant(\$v = (%s))) instanceof \UnitEnum ? \constant(\$v) : throw new \TypeError(\sprintf('The string \"%%s\" is not the name of a valid enum case.', \$v))", $str), diff --git a/Lexer.php b/Lexer.php index 17092f5..f7a3c16 100644 --- a/Lexer.php +++ b/Lexer.php @@ -69,7 +69,10 @@ public function tokenize(string $expression): TokenStream // strings $tokens[] = new Token(Token::STRING_TYPE, stripcslashes(substr($match[0], 1, -1)), $cursor + 1); $cursor += \strlen($match[0]); - } elseif (preg_match('/(?<=^|[\s(])starts with(?=[\s(])|(?<=^|[\s(])ends with(?=[\s(])|(?<=^|[\s(])contains(?=[\s(])|(?<=^|[\s(])matches(?=[\s(])|(?<=^|[\s(])not in(?=[\s(])|(?<=^|[\s(])not(?=[\s(])|(?<=^|[\s(])and(?=[\s(])|\=\=\=|\!\=\=|(?<=^|[\s(])or(?=[\s(])|\|\||&&|\=\=|\!\=|\>\=|\<\=|(?<=^|[\s(])in(?=[\s(])|\.\.|\*\*|\!|\||\^|&|\<|\>|\+|\-|~|\*|\/|%/A', $expression, $match, 0, $cursor)) { + } elseif (preg_match('{/\*.*?\*/}A', $expression, $match, 0, $cursor)) { + // comments + $cursor += \strlen($match[0]); + } elseif (preg_match('/(?<=^|[\s(])starts with(?=[\s(])|(?<=^|[\s(])ends with(?=[\s(])|(?<=^|[\s(])contains(?=[\s(])|(?<=^|[\s(])matches(?=[\s(])|(?<=^|[\s(])not in(?=[\s(])|(?<=^|[\s(])not(?=[\s(])|(?<=^|[\s(])xor(?=[\s(])|(?<=^|[\s(])and(?=[\s(])|\=\=\=|\!\=\=|(?<=^|[\s(])or(?=[\s(])|\|\||&&|\=\=|\!\=|\>\=|\<\=|(?<=^|[\s(])in(?=[\s(])|\.\.|\*\*|\<\<|\>\>|\!|\||\^|&|\<|\>|\+|\-|~|\*|\/|%/A', $expression, $match, 0, $cursor)) { // operators $tokens[] = new Token(Token::OPERATOR_TYPE, $match[0], $cursor + 1); $cursor += \strlen($match[0]); diff --git a/Node/BinaryNode.php b/Node/BinaryNode.php index 455523e..a77d065 100644 --- a/Node/BinaryNode.php +++ b/Node/BinaryNode.php @@ -30,8 +30,8 @@ class BinaryNode extends Node private const FUNCTIONS = [ '**' => 'pow', '..' => 'range', - 'in' => '\\'.self::class.'::inArray', - 'not in' => '!\\'.self::class.'::inArray', + 'in' => '\\in_array', + 'not in' => '!\\in_array', 'contains' => 'str_contains', 'starts with' => 'str_starts_with', 'ends with' => 'str_ends_with', @@ -52,6 +52,8 @@ public function compile(Compiler $compiler): void if ('matches' == $operator) { if ($this->nodes['right'] instanceof ConstantNode) { $this->evaluateMatches($this->nodes['right']->evaluate([], []), ''); + } elseif ($this->nodes['right'] instanceof self && '~' !== $this->nodes['right']->attributes['operator']) { + throw new SyntaxError('The regex passed to "matches" must be a string.'); } $compiler @@ -71,9 +73,14 @@ public function compile(Compiler $compiler): void ->compile($this->nodes['left']) ->raw(', ') ->compile($this->nodes['right']) - ->raw(')') ; + if ('in' === $operator || 'not in' === $operator) { + $compiler->raw(', true'); + } + + $compiler->raw(')'); + return; } @@ -100,18 +107,19 @@ public function evaluate(array $functions, array $values): mixed if (isset(self::FUNCTIONS[$operator])) { $right = $this->nodes['right']->evaluate($functions, $values); - if ('not in' === $operator) { - return !self::inArray($left, $right); - } - $f = self::FUNCTIONS[$operator]; - - return $f($left, $right); + return match ($operator) { + 'in' => \in_array($left, $right, true), + 'not in' => !\in_array($left, $right, true), + default => self::FUNCTIONS[$operator]($left, $right), + }; } switch ($operator) { case 'or': case '||': return $left || $this->nodes['right']->evaluate($functions, $values); + case 'xor': + return $left xor $this->nodes['right']->evaluate($functions, $values); case 'and': case '&&': return $left && $this->nodes['right']->evaluate($functions, $values); @@ -126,6 +134,10 @@ public function evaluate(array $functions, array $values): mixed return $left ^ $right; case '&': return $left & $right; + case '<<': + return $left << $right; + case '>>': + return $left >> $right; case '==': return $left == $right; case '===': @@ -142,10 +154,6 @@ public function evaluate(array $functions, array $values): mixed return $left >= $right; case '<=': return $left <= $right; - case 'not in': - return !self::inArray($left, $right); - case 'in': - return self::inArray($left, $right); case '+': return $left + $right; case '-': @@ -169,6 +177,8 @@ public function evaluate(array $functions, array $values): mixed case 'matches': return $this->evaluateMatches($right, $left); } + + throw new \LogicException(\sprintf('"%s" does not support the "%s" operator.', __CLASS__, $operator)); } public function toArray(): array @@ -176,22 +186,6 @@ public function toArray(): array return ['(', $this->nodes['left'], ' '.$this->attributes['operator'].' ', $this->nodes['right'], ')']; } - /** - * @internal to be replaced by an inline strict call to in_array() in version 7.0 - */ - public static function inArray($value, array $array): bool - { - if (false === $key = array_search($value, $array)) { - return false; - } - - if (!\in_array($value, $array, true)) { - trigger_deprecation('symfony/expression-language', '6.3', 'The "in" operator will use strict comparisons in Symfony 7.0. Loose match found with key "%s" for value %s. Normalize the array parameter so it only has the expected types or implement loose matching in your own expression function.', $key, json_encode($value)); - } - - return true; - } - private function evaluateMatches(string $regexp, ?string $str): int { set_error_handler(static fn ($t, $m) => throw new SyntaxError(\sprintf('Regexp "%s" passed to "matches" is not valid', $regexp).substr($m, 12))); diff --git a/Node/ConstantNode.php b/Node/ConstantNode.php index 37beee8..856cd47 100644 --- a/Node/ConstantNode.php +++ b/Node/ConstantNode.php @@ -20,13 +20,11 @@ */ class ConstantNode extends Node { - public readonly bool $isNullSafe; - private bool $isIdentifier; - - public function __construct(mixed $value, bool $isIdentifier = false, bool $isNullSafe = false) - { - $this->isIdentifier = $isIdentifier; - $this->isNullSafe = $isNullSafe; + public function __construct( + mixed $value, + private bool $isIdentifier = false, + public readonly bool $isNullSafe = false, + ) { parent::__construct( [], ['value' => $value] diff --git a/Node/FunctionNode.php b/Node/FunctionNode.php index 33323f3..6266ba8 100644 --- a/Node/FunctionNode.php +++ b/Node/FunctionNode.php @@ -50,10 +50,7 @@ public function evaluate(array $functions, array $values): mixed return $functions[$this->attributes['name']]['evaluator'](...$arguments); } - /** - * @return array - */ - public function toArray() + public function toArray(): array { $array = []; $array[] = $this->attributes['name']; diff --git a/Node/Node.php b/Node/Node.php index 33cdf24..e14ed45 100644 --- a/Node/Node.php +++ b/Node/Node.php @@ -20,8 +20,8 @@ */ class Node { - public $nodes = []; - public $attributes = []; + public array $nodes = []; + public array $attributes = []; /** * @param array $nodes An array of nodes @@ -57,20 +57,14 @@ public function __toString(): string return implode("\n", $repr); } - /** - * @return void - */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { foreach ($this->nodes as $node) { $node->compile($compiler); } } - /** - * @return mixed - */ - public function evaluate(array $functions, array $values) + public function evaluate(array $functions, array $values): mixed { $results = []; foreach ($this->nodes as $node) { @@ -81,19 +75,14 @@ public function evaluate(array $functions, array $values) } /** - * @return array - * * @throws \BadMethodCallException when this node cannot be transformed to an array */ - public function toArray() + public function toArray(): array { throw new \BadMethodCallException(\sprintf('Dumping a "%s" instance is not supported yet.', static::class)); } - /** - * @return string - */ - public function dump() + public function dump(): string { $dump = ''; @@ -104,18 +93,12 @@ public function dump() return $dump; } - /** - * @return string - */ - protected function dumpString(string $value) + protected function dumpString(string $value): string { return \sprintf('"%s"', addcslashes($value, "\0\t\"\\")); } - /** - * @return bool - */ - protected function isHash(array $value) + protected function isHash(array $value): bool { $expectedKey = 0; diff --git a/Node/NullCoalescedNameNode.php b/Node/NullCoalescedNameNode.php new file mode 100644 index 0000000..e4b4f1d --- /dev/null +++ b/Node/NullCoalescedNameNode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Node; + +use Symfony\Component\ExpressionLanguage\Compiler; + +/** + * @author Adam Kiss + * + * @internal + */ +class NullCoalescedNameNode extends Node +{ + public function __construct(string $name) + { + parent::__construct( + [], + ['name' => $name] + ); + } + + public function compile(Compiler $compiler): void + { + $compiler->raw('$'.$this->attributes['name'].' ?? null'); + } + + public function evaluate(array $functions, array $values): null + { + return null; + } + + public function toArray(): array + { + return [$this->attributes['name'].' ?? null']; + } +} diff --git a/Node/UnaryNode.php b/Node/UnaryNode.php index 55e2121..5a78cfa 100644 --- a/Node/UnaryNode.php +++ b/Node/UnaryNode.php @@ -25,6 +25,7 @@ class UnaryNode extends Node 'not' => '!', '+' => '+', '-' => '-', + '~' => '~', ]; public function __construct(string $operator, Node $node) @@ -53,6 +54,7 @@ public function evaluate(array $functions, array $values): mixed 'not', '!' => !$value, '-' => -$value, + '~' => ~$value, default => $value, }; } diff --git a/ParsedExpression.php b/ParsedExpression.php index 239624e..7002b85 100644 --- a/ParsedExpression.php +++ b/ParsedExpression.php @@ -20,19 +20,14 @@ */ class ParsedExpression extends Expression { - private Node $nodes; - - public function __construct(string $expression, Node $nodes) - { + public function __construct( + string $expression, + private Node $nodes, + ) { parent::__construct($expression); - - $this->nodes = $nodes; } - /** - * @return Node - */ - public function getNodes() + public function getNodes(): Node { return $this->nodes; } diff --git a/Parser.php b/Parser.php index 8b5f4ad..32254cd 100644 --- a/Parser.php +++ b/Parser.php @@ -12,7 +12,7 @@ namespace Symfony\Component\ExpressionLanguage; /** - * Parsers a token stream. + * Parses a token stream. * * This parser implements a "Precedence climbing" algorithm. * @@ -26,26 +26,29 @@ 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 $functions; - private ?array $names; - private bool $lint = false; - - public function __construct(array $functions) - { - $this->functions = $functions; + private array $names; + private int $flags = 0; + public function __construct( + private array $functions, + ) { $this->unaryOperators = [ 'not' => ['precedence' => 50], '!' => ['precedence' => 50], '-' => ['precedence' => 500], '+' => ['precedence' => 500], + '~' => ['precedence' => 500], ]; $this->binaryOperators = [ 'or' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], '||' => ['precedence' => 10, 'associativity' => self::OPERATOR_LEFT], + 'xor' => ['precedence' => 12, 'associativity' => self::OPERATOR_LEFT], 'and' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], '&&' => ['precedence' => 15, 'associativity' => self::OPERATOR_LEFT], '|' => ['precedence' => 16, 'associativity' => self::OPERATOR_LEFT], @@ -66,6 +69,8 @@ public function __construct(array $functions) 'ends with' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], 'matches' => ['precedence' => 20, 'associativity' => self::OPERATOR_LEFT], '..' => ['precedence' => 25, 'associativity' => self::OPERATOR_LEFT], + '<<' => ['precedence' => 25, '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], @@ -89,34 +94,45 @@ public function __construct(array $functions) * 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 = []): Node\Node + public function parse(TokenStream $stream, array $names = [], int $flags = 0): Node\Node { - $this->lint = false; - - return $this->doParse($stream, $names); + 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 `null` instead of the array. + * 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 = []): void + public function lint(TokenStream $stream, ?array $names = [], int $flags = 0): void { - $this->lint = true; - $this->doParse($stream, $names); + 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 = []): Node\Node + private function doParse(TokenStream $stream, array $names, int $flags): Node\Node { + $this->flags = $flags; $this->stream = $stream; $this->names = $names; @@ -130,10 +146,7 @@ private function doParse(TokenStream $stream, ?array $names = []): Node\Node return $node; } - /** - * @return Node\Node - */ - public function parseExpression(int $precedence = 0) + public function parseExpression(int $precedence = 0): Node\Node { $expr = $this->getPrimary(); $token = $this->stream->current; @@ -154,10 +167,7 @@ public function parseExpression(int $precedence = 0) return $expr; } - /** - * @return Node\Node - */ - protected function getPrimary() + protected function getPrimary(): Node\Node { $token = $this->stream->current; @@ -180,10 +190,7 @@ protected function getPrimary() return $this->parsePrimaryExpression(); } - /** - * @return Node\Node - */ - protected function parseConditionalExpression(Node\Node $expr) + protected function parseConditionalExpression(Node\Node $expr): Node\Node { while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '??')) { $this->stream->next(); @@ -214,10 +221,7 @@ protected function parseConditionalExpression(Node\Node $expr) return $expr; } - /** - * @return Node\Node - */ - public function parsePrimaryExpression() + public function parsePrimaryExpression(): Node\Node { $token = $this->stream->current; switch ($token->type) { @@ -238,14 +242,18 @@ public function parsePrimaryExpression() default: if ('(' === $this->stream->current->value) { - if (false === isset($this->functions[$token->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->lint || \is_array($this->names)) { + if (!($this->flags & self::IGNORE_UNKNOWN_VARIABLES)) { if (!\in_array($token->value, $this->names, true)) { + if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '??')) { + return new Node\NullCoalescedNameNode($token->value); + } + throw new SyntaxError(\sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); } @@ -282,10 +290,7 @@ public function parsePrimaryExpression() return $this->parsePostfixExpression($node); } - /** - * @return Node\ArrayNode - */ - public function parseArrayExpression() + public function parseArrayExpression(): Node\ArrayNode { $this->stream->expect(Token::PUNCTUATION_TYPE, '[', 'An array element was expected'); @@ -309,10 +314,7 @@ public function parseArrayExpression() return $node; } - /** - * @return Node\ArrayNode - */ - public function parseHashExpression() + public function parseHashExpression(): Node\ArrayNode { $this->stream->expect(Token::PUNCTUATION_TYPE, '{', 'A hash element was expected'); @@ -356,10 +358,7 @@ public function parseHashExpression() return $node; } - /** - * @return Node\GetAttrNode|Node\Node - */ - public function parsePostfixExpression(Node\Node $node) + public function parsePostfixExpression(Node\Node $node): Node\GetAttrNode|Node\Node { $token = $this->stream->current; while (Token::PUNCTUATION_TYPE == $token->type) { @@ -418,10 +417,8 @@ public function parsePostfixExpression(Node\Node $node) /** * Parses arguments. - * - * @return Node\Node */ - public function parseArguments() + public function parseArguments(): Node\Node { $args = []; $this->stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); diff --git a/Resources/bin/generate_operator_regex.php b/Resources/bin/generate_operator_regex.php index 179810e..3803549 100644 --- a/Resources/bin/generate_operator_regex.php +++ b/Resources/bin/generate_operator_regex.php @@ -13,7 +13,7 @@ throw new Exception('This script must be run from the command line.'); } -$operators = ['not', '!', 'or', '||', '&&', 'and', '|', '^', '&', '==', '===', '!=', '!==', '<', '>', '>=', '<=', 'not in', 'in', '..', '+', '-', '~', '*', '/', '%', 'contains', 'starts with', 'ends with', 'matches', '**']; +$operators = ['not', '!', 'or', '||', 'xor', '&&', 'and', '|', '^', '&', '==', '===', '!=', '!==', '<', '>', '>=', '<=', 'not in', 'in', '..', '+', '-', '~', '*', '/', '%', 'contains', 'starts with', 'ends with', 'matches', '**', '<<', '>>']; $operators = array_combine($operators, array_map('strlen', $operators)); arsort($operators); diff --git a/SerializedParsedExpression.php b/SerializedParsedExpression.php index 5691907..ca7d405 100644 --- a/SerializedParsedExpression.php +++ b/SerializedParsedExpression.php @@ -14,28 +14,24 @@ use Symfony\Component\ExpressionLanguage\Node\Node; /** - * Represents an already parsed expression. + * Represents an already serialized parsed expression. * * @author Fabien Potencier */ class SerializedParsedExpression extends ParsedExpression { - private string $nodes; - /** * @param string $expression An expression * @param string $nodes The serialized nodes for the expression */ - public function __construct(string $expression, string $nodes) - { + public function __construct( + string $expression, + private string $nodes, + ) { $this->expression = $expression; - $this->nodes = $nodes; } - /** - * @return Node - */ - public function getNodes() + public function getNodes(): Node { return unserialize($this->nodes); } diff --git a/Tests/ExpressionLanguageTest.php b/Tests/ExpressionLanguageTest.php index df05548..e8ecfc5 100644 --- a/Tests/ExpressionLanguageTest.php +++ b/Tests/ExpressionLanguageTest.php @@ -71,13 +71,23 @@ public function testCachedParse() $this->assertSame($savedParsedExpression, $parsedExpression); } - public function testConstantFunction() + /** + * @dataProvider basicPhpFunctionProvider + */ + public function testBasicPhpFunction($expression, $expected, $compiled) { $expressionLanguage = new ExpressionLanguage(); - $this->assertEquals(\PHP_VERSION, $expressionLanguage->evaluate('constant("PHP_VERSION")')); + $this->assertSame($expected, $expressionLanguage->evaluate($expression)); + $this->assertSame($compiled, $expressionLanguage->compile($expression)); + } - $expressionLanguage = new ExpressionLanguage(); - $this->assertEquals('\constant("PHP_VERSION")', $expressionLanguage->compile('constant("PHP_VERSION")')); + public static function basicPhpFunctionProvider() + { + return [ + ['constant("PHP_VERSION")', \PHP_VERSION, '\constant("PHP_VERSION")'], + ['min(1,2,3)', 1, '\min(1, 2, 3)'], + ['max(1,2,3)', 3, '\max(1, 2, 3)'], + ]; } public function testEnumFunctionWithConstantThrows() @@ -127,17 +137,28 @@ public function testCompiledEnumFunctionWithBackedEnum() $this->assertSame(FooBackedEnum::Bar, $result); } - public function testProviders() - { - $expressionLanguage = new ExpressionLanguage(null, [new TestProvider()]); - $this->assertEquals('foo', $expressionLanguage->evaluate('identity("foo")')); - $this->assertEquals('"foo"', $expressionLanguage->compile('identity("foo")')); - $this->assertEquals('FOO', $expressionLanguage->evaluate('strtoupper("foo")')); - $this->assertEquals('\strtoupper("foo")', $expressionLanguage->compile('strtoupper("foo")')); - $this->assertEquals('foo', $expressionLanguage->evaluate('strtolower("FOO")')); - $this->assertEquals('\strtolower("FOO")', $expressionLanguage->compile('strtolower("FOO")')); + /** + * @dataProvider providerTestCases + */ + public function testProviders(iterable $providers) + { + $expressionLanguage = new ExpressionLanguage(null, $providers); + $this->assertSame('foo', $expressionLanguage->evaluate('identity("foo")')); + $this->assertSame('"foo"', $expressionLanguage->compile('identity("foo")')); + $this->assertSame('FOO', $expressionLanguage->evaluate('strtoupper("foo")')); + $this->assertSame('\strtoupper("foo")', $expressionLanguage->compile('strtoupper("foo")')); + $this->assertSame('foo', $expressionLanguage->evaluate('strtolower("FOO")')); + $this->assertSame('\strtolower("FOO")', $expressionLanguage->compile('strtolower("FOO")')); $this->assertTrue($expressionLanguage->evaluate('fn_namespaced()')); - $this->assertEquals('\Symfony\Component\ExpressionLanguage\Tests\Fixtures\fn_namespaced()', $expressionLanguage->compile('fn_namespaced()')); + $this->assertSame('\Symfony\Component\ExpressionLanguage\Tests\Fixtures\fn_namespaced()', $expressionLanguage->compile('fn_namespaced()')); + } + + public static function providerTestCases(): iterable + { + yield 'array' => [[new TestProvider()]]; + yield 'Traversable' => [(function () { + yield new TestProvider(); + })()]; } /** @@ -146,7 +167,7 @@ public function testProviders() public function testShortCircuitOperatorsEvaluate($expression, array $values, $expected) { $expressionLanguage = new ExpressionLanguage(); - $this->assertEquals($expected, $expressionLanguage->evaluate($expression, $values)); + $this->assertSame($expected, $expressionLanguage->evaluate($expression, $values)); } /** @@ -168,6 +189,14 @@ public function testParseThrowsInsteadOfNotice() $expressionLanguage->parse('node.', ['node']); } + public function testParseReturnsObjectOnAlreadyParsedExpression() + { + $expressionLanguage = new ExpressionLanguage(); + $expression = $expressionLanguage->parse('1 + 1', []); + + $this->assertSame($expression, $expressionLanguage->parse($expression, [])); + } + public static function shortCircuitProviderEvaluate() { $object = new class(static::fail(...)) { @@ -269,7 +298,7 @@ public function testOperatorCollisions() $expressionLanguage = new ExpressionLanguage(); $expression = 'foo.not in [bar]'; $compiled = $expressionLanguage->compile($expression, ['foo', 'bar']); - $this->assertSame('\Symfony\Component\ExpressionLanguage\Node\BinaryNode::inArray($foo->not, [0 => $bar])', $compiled); + $this->assertSame('\in_array($foo->not, [0 => $bar], true)', $compiled); $result = $expressionLanguage->evaluate($expression, ['foo' => (object) ['not' => 'test'], 'bar' => 'test']); $this->assertTrue($result); @@ -415,6 +444,7 @@ public function bar() } }; + yield ['bar ?? "default"', null]; yield ['foo.bar ?? "default"', null]; yield ['foo.bar.baz ?? "default"', (object) ['bar' => null]]; yield ['foo.bar ?? foo.baz ?? "default"', null]; @@ -440,6 +470,68 @@ public function testRegisterAfterCompile($registerCallback) $registerCallback($el); } + public static function validCommentProvider() + { + yield ['1 /* comment */ + 1']; + yield ['1 /* /* comment with spaces */']; + yield ['1 /** extra stars **/ + 1']; + yield ["/* multi\nline */ 'foo'"]; + } + + /** + * @dataProvider validCommentProvider + */ + public function testLintAllowsComments($expression) + { + $el = new ExpressionLanguage(); + $el->lint($expression, []); + + $this->expectNotToPerformAssertions(); + } + + public static function invalidCommentProvider() + { + yield ['1 + no start */']; + yield ['1 /* no closing']; + yield ['1 /* double closing */ */']; + } + + /** + * @dataProvider invalidCommentProvider + */ + public function testLintThrowsOnInvalidComments($expression) + { + $el = new ExpressionLanguage(); + + $this->expectException(SyntaxError::class); + $el->lint($expression, []); + } + + public function testLintDoesntThrowOnValidExpression() + { + $el = new ExpressionLanguage(); + $el->lint('1 + 1', []); + + $this->expectNotToPerformAssertions(); + } + + public function testLintThrowsOnInvalidExpression() + { + $el = new ExpressionLanguage(); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unexpected end of expression around position 6 for expression `node.`.'); + + $el->lint('node.', ['node']); + } + + public function testCommentsIgnored() + { + $expressionLanguage = new ExpressionLanguage(); + $this->assertSame(3, $expressionLanguage->evaluate('1 /* foo */ + 2')); + $this->assertSame('(1 + 2)', $expressionLanguage->compile('1 /* foo */ + 2')); + } + public static function getRegisterCallbacks() { return [ diff --git a/Tests/LexerTest.php b/Tests/LexerTest.php index 6143ad3..2827cf6 100644 --- a/Tests/LexerTest.php +++ b/Tests/LexerTest.php @@ -35,6 +35,32 @@ public function testTokenize($tokens, $expression) $this->assertEquals(new TokenStream($tokens, $expression), $this->lexer->tokenize($expression)); } + public function testTokenizeMultilineComment() + { + $expression = <<assertEquals(new TokenStream($tokens, str_replace("\n", ' ', $expression)), $this->lexer->tokenize($expression)); + } + public function testTokenizeThrowsErrorWithMessage() { $this->expectException(SyntaxError::class); @@ -107,8 +133,11 @@ public static function getTokenizeData() new Token('punctuation', ']', 27), new Token('operator', '-', 29), new Token('number', 1990, 31), + new Token('operator', '+', 39), + new Token('operator', '~', 41), + new Token('name', 'qux', 42), ], - '(3 + 5) ~ foo("bar").baz[4] - 1.99E+3', + '(3 + 5) ~ foo("bar").baz[4] - 1.99E+3 + ~qux', ], [ [new Token('operator', '..', 1)], @@ -161,6 +190,58 @@ public static function getTokenizeData() ], '-.7_189e+10', ], + [ + [ + new Token('number', 65536, 1), + ], + '65536 /* this is 2^16 */', + ], + [ + [ + new Token('number', 2, 1), + new Token('operator', '*', 21), + new Token('number', 4, 23), + ], + '2 /* /* comment1 */ * 4', + ], + [ + [ + new Token('string', '/* this is', 1), + new Token('operator', '~', 14), + new Token('string', 'not a comment */', 16), + ], + '"/* this is" ~ "not a comment */"', + ], + [ + [ + new Token('string', '/* this is not a comment */', 1), + ], + '"/* this is not a comment */"', + ], + [ + [ + new Token('name', 'foo', 1), + new Token('operator', 'xor', 5), + new Token('name', 'bar', 9), + ], + 'foo xor bar', + ], ]; } + + public function testOperatorRegexWasGeneratedWithScript() + { + ob_start(); + try { + require $script = \dirname(__DIR__).'/Resources/bin/generate_operator_regex.php'; + } finally { + $output = ob_get_clean(); + } + + self::assertStringContainsString( + $output, + file_get_contents((new \ReflectionClass(Lexer::class))->getFileName()), + \sprintf('You need to run "%s" to generate the operator regex.', $script), + ); + } } diff --git a/Tests/Node/BinaryNodeTest.php b/Tests/Node/BinaryNodeTest.php index fd6cc53..375d0a1 100644 --- a/Tests/Node/BinaryNodeTest.php +++ b/Tests/Node/BinaryNodeTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\ExpressionLanguage\Tests\Node; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\ExpressionLanguage\Compiler; use Symfony\Component\ExpressionLanguage\Node\ArrayNode; use Symfony\Component\ExpressionLanguage\Node\BinaryNode; @@ -21,8 +20,6 @@ class BinaryNodeTest extends AbstractNodeTestCase { - use ExpectDeprecationTrait; - public static function getEvaluateData(): array { $array = new ArrayNode(); @@ -32,12 +29,15 @@ public static function getEvaluateData(): array return [ [true, new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], [true, new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + [false, new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], [false, new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], [false, new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], [0, new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], [6, new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], [6, new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + [32, new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], + [2, new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], [true, new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], [true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], @@ -87,12 +87,15 @@ public static function getCompileData(): array return [ ['(true || false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + ['(true xor true)', new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], ['(true && false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + ['(2 << 4)', new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], + ['(32 >> 4)', new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], @@ -116,10 +119,10 @@ public static function getCompileData(): array ['pow(5, 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], ['("a" . "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], - ['\Symfony\Component\ExpressionLanguage\Node\BinaryNode::inArray("a", [0 => "a", 1 => "b"])', new BinaryNode('in', new ConstantNode('a'), $array)], - ['\Symfony\Component\ExpressionLanguage\Node\BinaryNode::inArray("c", [0 => "a", 1 => "b"])', new BinaryNode('in', new ConstantNode('c'), $array)], - ['!\Symfony\Component\ExpressionLanguage\Node\BinaryNode::inArray("c", [0 => "a", 1 => "b"])', new BinaryNode('not in', new ConstantNode('c'), $array)], - ['!\Symfony\Component\ExpressionLanguage\Node\BinaryNode::inArray("a", [0 => "a", 1 => "b"])', new BinaryNode('not in', new ConstantNode('a'), $array)], + ['\in_array("a", [0 => "a", 1 => "b"], true)', new BinaryNode('in', new ConstantNode('a'), $array)], + ['\in_array("c", [0 => "a", 1 => "b"], true)', new BinaryNode('in', new ConstantNode('c'), $array)], + ['!\in_array("c", [0 => "a", 1 => "b"], true)', new BinaryNode('not in', new ConstantNode('c'), $array)], + ['!\in_array("a", [0 => "a", 1 => "b"], true)', new BinaryNode('not in', new ConstantNode('a'), $array)], ['range(1, 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], @@ -139,12 +142,15 @@ public static function getDumpData(): array return [ ['(true or false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], + ['(true xor true)', new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], ['(true and false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], + ['(2 << 4)', new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], + ['(32 >> 4)', new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], @@ -218,6 +224,27 @@ public function testCompileMatchesWithInvalidRegexpAsExpression() eval('$regexp = "this is not a regexp"; '.$compiler->getSource().';'); } + public function testCompileMatchesWithBooleanBinaryNode() + { + $binaryNode = new BinaryNode('||', new ConstantNode(true), new ConstantNode(false)); + $node = new BinaryNode('matches', new ConstantNode('abc'), $binaryNode); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('The regex passed to "matches" must be a string'); + $compiler = new Compiler([]); + $node->compile($compiler); + } + + public function testCompileMatchesWithStringBinaryNode() + { + $binaryNode = new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b')); + $node = new BinaryNode('matches', new ConstantNode('abc'), $binaryNode); + + $compiler = new Compiler([]); + $node->compile($compiler); + $this->expectNotToPerformAssertions(); + } + public function testDivisionByZero() { $node = new BinaryNode('/', new ConstantNode(1), new ConstantNode(0)); @@ -239,17 +266,27 @@ public function testModuloByZero() } /** - * @group legacy + * @testWith [1] + * ["true"] */ - public function testInOperatorStrictness() + public function testInOperatorStrictness(mixed $value) { $array = new ArrayNode(); - $array->addElement(new ConstantNode('a')); + $array->addElement(new ConstantNode('1')); $array->addElement(new ConstantNode(true)); - $node = new BinaryNode('in', new ConstantNode('b'), $array); + $node = new BinaryNode('in', new ConstantNode($value), $array); + + $this->assertFalse($node->evaluate([], [])); + } + + public function testEvaluateUnsupportedOperator() + { + $node = new BinaryNode('unsupported', new ConstantNode(1), new ConstantNode(2)); - $this->expectDeprecation('Since symfony/expression-language 6.3: The "in" operator will use strict comparisons in Symfony 7.0. Loose match found with key "1" for value "b". Normalize the array parameter so it only has the expected types or implement loose matching in your own expression function.'); - $this->assertTrue($node->evaluate([], [])); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"Symfony\Component\ExpressionLanguage\Node\BinaryNode" does not support the "unsupported" operator.'); + + $node->evaluate([], []); } } diff --git a/Tests/Node/NullCoalescedNameNodeTest.php b/Tests/Node/NullCoalescedNameNodeTest.php new file mode 100644 index 0000000..c5baef9 --- /dev/null +++ b/Tests/Node/NullCoalescedNameNodeTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ExpressionLanguage\Tests\Node; + +use Symfony\Component\ExpressionLanguage\Node\NullCoalescedNameNode; + +class NullCoalescedNameNodeTest extends AbstractNodeTestCase +{ + public static function getEvaluateData(): array + { + return [ + [null, new NullCoalescedNameNode('foo'), []], + ]; + } + + public static function getCompileData(): array + { + return [ + ['$foo ?? null', new NullCoalescedNameNode('foo')], + ]; + } + + public static function getDumpData(): array + { + return [ + ['foo ?? null', new NullCoalescedNameNode('foo')], + ]; + } +} diff --git a/Tests/Node/UnaryNodeTest.php b/Tests/Node/UnaryNodeTest.php index 7da4be7..ec7fb7f 100644 --- a/Tests/Node/UnaryNodeTest.php +++ b/Tests/Node/UnaryNodeTest.php @@ -23,6 +23,7 @@ public static function getEvaluateData(): array [3, new UnaryNode('+', new ConstantNode(3))], [false, new UnaryNode('!', new ConstantNode(true))], [false, new UnaryNode('not', new ConstantNode(true))], + [-6, new UnaryNode('~', new ConstantNode(5))], ]; } @@ -33,6 +34,7 @@ public static function getCompileData(): array ['(+3)', new UnaryNode('+', new ConstantNode(3))], ['(!true)', new UnaryNode('!', new ConstantNode(true))], ['(!true)', new UnaryNode('not', new ConstantNode(true))], + ['(~5)', new UnaryNode('~', new ConstantNode(5))], ]; } @@ -43,6 +45,7 @@ public static function getDumpData(): array ['(+ 3)', new UnaryNode('+', new ConstantNode(3))], ['(! true)', new UnaryNode('!', new ConstantNode(true))], ['(not true)', new UnaryNode('not', new ConstantNode(true))], + ['(~ 5)', new UnaryNode('~', new ConstantNode(5))], ]; } } diff --git a/Tests/ParserTest.php b/Tests/ParserTest.php index 1429be8..0f1c893 100644 --- a/Tests/ParserTest.php +++ b/Tests/ParserTest.php @@ -48,6 +48,17 @@ public function testParsePrimaryExpressionWithUnknownFunctionThrows() $parser->parse($stream); } + public function testParseUnknownFunction() + { + $parser = new Parser([]); + $tokenized = (new Lexer())->tokenize('foo()'); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('The function "foo" does not exist around position 1 for expression `foo()`.'); + + $parser->parse($tokenized); + } + /** * @dataProvider getParseData */ @@ -233,6 +244,15 @@ public static function getParseData() 'not foo or foo.not', ['foo'], ], + [ + new Node\BinaryNode( + 'xor', + new Node\NameNode('foo'), + new Node\NameNode('bar'), + ), + 'foo xor bar', + ['foo', 'bar'], + ], [ new Node\BinaryNode('..', new Node\ConstantNode(0), new Node\ConstantNode(3)), '0..3', @@ -295,7 +315,7 @@ public function testNameProposal() /** * @dataProvider getLintData */ - public function testLint($expression, $names, ?string $exception = null) + public function testLint($expression, $names, int $checks = 0, ?string $exception = null) { if ($exception) { $this->expectException(SyntaxError::class); @@ -304,7 +324,7 @@ public function testLint($expression, $names, ?string $exception = null) $lexer = new Lexer(); $parser = new Parser([]); - $parser->lint($lexer->tokenize($expression), $names); + $parser->lint($lexer->tokenize($expression), $names, $checks); // Parser does't return anything when the correct expression is passed $this->expectNotToPerformAssertions(); @@ -321,15 +341,41 @@ public static function getLintData(): array 'expression' => 'foo["some_key"]?.callFunction(a ? b)', 'names' => ['foo', 'a', 'b'], ], - 'allow expression without names' => [ + 'allow expression with unknown names' => [ 'expression' => 'foo.bar', - 'names' => null, + 'names' => [], + 'checks' => Parser::IGNORE_UNKNOWN_VARIABLES, + ], + 'allow expression with unknown functions' => [ + 'expression' => 'foo()', + 'names' => [], + 'checks' => Parser::IGNORE_UNKNOWN_FUNCTIONS, ], - 'disallow expression without names' => [ + 'allow expression with unknown functions and names' => [ + 'expression' => 'foo(bar)', + 'names' => [], + 'checks' => Parser::IGNORE_UNKNOWN_FUNCTIONS | Parser::IGNORE_UNKNOWN_VARIABLES, + ], + 'array with trailing comma' => [ + 'expression' => '[value1, value2, value3,]', + 'names' => ['value1', 'value2', 'value3'], + ], + 'hashmap with trailing comma' => [ + 'expression' => '{val1: value1, val2: value2, val3: value3,}', + 'names' => ['value1', 'value2', 'value3'], + ], + 'disallow expression with unknown names by default' => [ 'expression' => 'foo.bar', 'names' => [], + 'checks' => 0, 'exception' => 'Variable "foo" is not valid around position 1 for expression `foo.bar', ], + 'disallow expression with unknown functions by default' => [ + 'expression' => 'foo()', + 'names' => [], + 'checks' => 0, + 'exception' => 'The function "foo" does not exist around position 1 for expression `foo()', + ], 'operator collisions' => [ 'expression' => 'foo.not in [bar]', 'names' => ['foo', 'bar'], @@ -337,18 +383,21 @@ public static function getLintData(): array 'incorrect expression ending' => [ 'expression' => 'foo["a"] foo["b"]', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'Unexpected token "name" of value "foo" '. 'around position 10 for expression `foo["a"] foo["b"]`.', ], 'incorrect operator' => [ 'expression' => 'foo["some_key"] // 2', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'Unexpected token "operator" of value "/" '. 'around position 18 for expression `foo["some_key"] // 2`.', ], 'incorrect array' => [ 'expression' => '[value1, value2 value3]', 'names' => ['value1', 'value2', 'value3'], + 'checks' => 0, 'exception' => 'An array element must be followed by a comma. '. 'Unexpected token "name" of value "value3" ("punctuation" expected with value ",") '. 'around position 17 for expression `[value1, value2 value3]`.', @@ -356,21 +405,31 @@ public static function getLintData(): array 'incorrect array element' => [ 'expression' => 'foo["some_key")', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'Unclosed "[" around position 3 for expression `foo["some_key")`.', ], + 'incorrect hash key' => [ + 'expression' => '{+: value1}', + 'names' => ['value1'], + 'checks' => 0, + 'exception' => 'A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "operator" of value "+" around position 2 for expression `{+: value1}`.', + ], 'missed array key' => [ 'expression' => 'foo[]', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'Unexpected token "punctuation" of value "]" around position 5 for expression `foo[]`.', ], 'missed closing bracket in sub expression' => [ 'expression' => 'foo[(bar ? bar : "default"]', 'names' => ['foo', 'bar'], + 'checks' => 0, 'exception' => 'Unclosed "(" around position 4 for expression `foo[(bar ? bar : "default"]`.', ], 'incorrect hash following' => [ 'expression' => '{key: foo key2: bar}', 'names' => ['foo', 'bar'], + 'checks' => 0, 'exception' => 'A hash value must be followed by a comma. '. 'Unexpected token "name" of value "key2" ("punctuation" expected with value ",") '. 'around position 11 for expression `{key: foo key2: bar}`.', @@ -378,11 +437,13 @@ public static function getLintData(): array 'incorrect hash assign' => [ 'expression' => '{key => foo}', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'Unexpected character "=" around position 5 for expression `{key => foo}`.', ], 'incorrect array as hash using' => [ 'expression' => '[foo: foo]', 'names' => ['foo'], + 'checks' => 0, 'exception' => 'An array element must be followed by a comma. '. 'Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") '. 'around position 5 for expression `[foo: foo]`.', diff --git a/Token.php b/Token.php index 4693904..5fc7341 100644 --- a/Token.php +++ b/Token.php @@ -12,16 +12,12 @@ namespace Symfony\Component\ExpressionLanguage; /** - * Represents a Token. + * Represents a token. * * @author Fabien Potencier */ class Token { - public $value; - public $type; - public $cursor; - public const EOF_TYPE = 'end of expression'; public const NAME_TYPE = 'name'; public const NUMBER_TYPE = 'number'; @@ -33,11 +29,11 @@ class Token * @param self::*_TYPE $type * @param int|null $cursor The cursor position in the source */ - public function __construct(string $type, string|int|float|null $value, ?int $cursor) - { - $this->type = $type; - $this->value = $value; - $this->cursor = $cursor; + public function __construct( + public string $type, + public string|int|float|null $value, + public ?int $cursor, + ) { } /** diff --git a/TokenStream.php b/TokenStream.php index dd72518..1280f44 100644 --- a/TokenStream.php +++ b/TokenStream.php @@ -18,17 +18,15 @@ */ class TokenStream { - public $current; + public Token $current; - private array $tokens; private int $position = 0; - private string $expression; - public function __construct(array $tokens, string $expression = '') - { - $this->tokens = $tokens; + public function __construct( + private array $tokens, + private string $expression = '', + ) { $this->current = $tokens[0]; - $this->expression = $expression; } /** @@ -41,10 +39,8 @@ public function __toString(): string /** * Sets the pointer to the next token and returns the old one. - * - * @return void */ - public function next() + public function next(): void { ++$this->position; @@ -57,10 +53,8 @@ public function next() /** * @param string|null $message The syntax error message - * - * @return void */ - public function expect(string $type, ?string $value = null, ?string $message = null) + public function expect(string $type, ?string $value = null, ?string $message = null): void { $token = $this->current; if (!$token->test($type, $value)) { diff --git a/composer.json b/composer.json index b123a5c..e24a315 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,9 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", + "symfony/cache": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/cache": "^5.4|^6.0|^7.0", "symfony/service-contracts": "^2.5|^3" }, "autoload": { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy