From fc571b52f3e2fa48b33e0817b0f9bc992ddcd289 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 6 Feb 2024 09:26:56 +0100 Subject: [PATCH] [ExpressionLanguage] Add more configurability to the parsing/linting methods --- .../Component/ExpressionLanguage/CHANGELOG.md | 2 + .../ExpressionLanguage/ExpressionLanguage.php | 15 ++++++-- .../Component/ExpressionLanguage/Parser.php | 38 ++++++++++++------- .../Tests/ExpressionLanguageTest.php | 2 +- .../ExpressionLanguage/Tests/ParserTest.php | 38 ++++++++++++++++--- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md index f5c26e6905370..52a856016ae3c 100644 --- a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md +++ b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md @@ -5,6 +5,8 @@ CHANGELOG --- * 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. 7.0 --- diff --git a/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php index bd9dbfcc1944c..c65733b978342 100644 --- a/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php +++ b/src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php @@ -62,7 +62,7 @@ public function evaluate(Expression|string $expression, array $values = []): mix /** * Parses an expression. */ - 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 +78,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); @@ -95,13 +95,20 @@ public function parse(Expression|string $expression, array $names): ParsedExpres * * @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 "self::IGNORE_UNKNOWN_VARIABLES" instead as a third argument.', __METHOD__); + + $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); } /** diff --git a/src/Symfony/Component/ExpressionLanguage/Parser.php b/src/Symfony/Component/ExpressionLanguage/Parser.php index 3198a09323671..18d2da8083a08 100644 --- a/src/Symfony/Component/ExpressionLanguage/Parser.php +++ b/src/Symfony/Component/ExpressionLanguage/Parser.php @@ -26,11 +26,14 @@ 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 bool $lint = false; + private array $names; + private int $flags = 0; public function __construct( private array $functions, @@ -87,34 +90,43 @@ public function __construct( * 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 "self::IGNORE_UNKNOWN_VARIABLES" instead as a third argument.', __METHOD__); + + $flags |= self::IGNORE_UNKNOWN_VARIABLES; + $names = []; + } + + $this->doParse($stream, $names, $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; @@ -224,13 +236,13 @@ public function parsePrimaryExpression(): Node\Node 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)) { throw new SyntaxError(sprintf('Variable "%s" is not valid.', $token->value), $token->cursor, $this->stream->getExpression(), $token->value, $this->names); } diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php index f7f712d8c5d46..5fa231885a2a1 100644 --- a/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php @@ -461,7 +461,7 @@ public function testRegisterAfterCompile($registerCallback) public function testLintDoesntThrowOnValidExpression() { $el = new ExpressionLanguage(); - $el->lint('1 + 1', null); + $el->lint('1 + 1', []); $this->expectNotToPerformAssertions(); } diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php index e3170ba809fde..7b71dbbb7cbb1 100644 --- a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php @@ -295,7 +295,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 +304,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,9 +321,20 @@ 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, + ], + '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,]', @@ -333,11 +344,18 @@ public static function getLintData(): array 'expression' => '{val1: value1, val2: value2, val3: value3,}', 'names' => ['value1', 'value2', 'value3'], ], - 'disallow expression without names' => [ + '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'], @@ -345,18 +363,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]`.', @@ -364,26 +385,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}`.', @@ -391,11 +417,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]`.',