Skip to content

Commit

Permalink
[ExpressionLanguage] Add more configurability to the parsing/linting …
Browse files Browse the repository at this point in the history
…methods
  • Loading branch information
fabpot committed Feb 6, 2024
1 parent 749ad6e commit fc571b5
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 23 deletions.
2 changes: 2 additions & 0 deletions src/Symfony/Component/ExpressionLanguage/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
15 changes: 11 additions & 4 deletions src/Symfony/Component/ExpressionLanguage/ExpressionLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
}

/**
Expand Down
38 changes: 25 additions & 13 deletions src/Symfony/Component/ExpressionLanguage/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Parser::IGNORE_*> $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<Parser::IGNORE_*> $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;

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
38 changes: 33 additions & 5 deletions src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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,]',
Expand All @@ -333,69 +344,86 @@ 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'],
],
'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]`.',
],
'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}`.',
],
'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]`.',
Expand Down

0 comments on commit fc571b5

Please sign in to comment.