diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd4fcb..a85455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ 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 --- diff --git a/Compiler.php b/Compiler.php index 9f470eb..97f6fb5 100644 --- a/Compiler.php +++ b/Compiler.php @@ -94,7 +94,7 @@ public function raw(string $string): static */ public function string(string $value): static { - $this->source .= sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); + $this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); return $this; } diff --git a/ExpressionFunction.php b/ExpressionFunction.php index 8950e21..d9a537c 100644 --- a/ExpressionFunction.php +++ b/ExpressionFunction.php @@ -75,15 +75,15 @@ public static function fromPhp(string $phpFunctionName, ?string $expressionFunct { $phpFunctionName = ltrim($phpFunctionName, '\\'); if (!\function_exists($phpFunctionName)) { - throw new \InvalidArgumentException(sprintf('PHP function "%s" does not exist.', $phpFunctionName)); + throw new \InvalidArgumentException(\sprintf('PHP function "%s" does not exist.', $phpFunctionName)); } $parts = explode('\\', $phpFunctionName); if (!$expressionFunctionName && \count($parts) > 1) { - throw new \InvalidArgumentException(sprintf('An expression function name must be defined when PHP function "%s" is namespaced.', $phpFunctionName)); + throw new \InvalidArgumentException(\sprintf('An expression function name must be defined when PHP function "%s" is namespaced.', $phpFunctionName)); } - $compiler = fn (...$args) => sprintf('\%s(%s)', $phpFunctionName, implode(', ', $args)); + $compiler = fn (...$args) => \sprintf('\%s(%s)', $phpFunctionName, implode(', ', $args)); $evaluator = fn ($p, ...$args) => $phpFunctionName(...$args); diff --git a/ExpressionLanguage.php b/ExpressionLanguage.php index 22112e9..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(); @@ -156,12 +156,12 @@ protected function registerFunctions() } $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), + 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), static function ($arguments, $str): \UnitEnum { $value = \constant($str); if (!$value instanceof \UnitEnum) { - throw new \TypeError(sprintf('The string "%s" is not the name of a valid enum case.', $str)); + throw new \TypeError(\sprintf('The string "%s" is not the name of a valid enum case.', $str)); } return $value; diff --git a/Lexer.php b/Lexer.php index ab32342..f7a3c16 100644 --- a/Lexer.php +++ b/Lexer.php @@ -55,12 +55,12 @@ public function tokenize(string $expression): TokenStream } elseif (str_contains(')]}', $expression[$cursor])) { // closing bracket if (!$brackets) { - throw new SyntaxError(sprintf('Unexpected "%s".', $expression[$cursor]), $cursor, $expression); + throw new SyntaxError(\sprintf('Unexpected "%s".', $expression[$cursor]), $cursor, $expression); } [$expect, $cur] = array_pop($brackets); if ($expression[$cursor] != strtr($expect, '([{', ')]}')) { - throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $cur, $expression); + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $cur, $expression); } $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); @@ -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]); @@ -91,7 +94,7 @@ public function tokenize(string $expression): TokenStream $cursor += \strlen($match[0]); } else { // unlexable - throw new SyntaxError(sprintf('Unexpected character "%s".', $expression[$cursor]), $cursor, $expression); + throw new SyntaxError(\sprintf('Unexpected character "%s".', $expression[$cursor]), $cursor, $expression); } } @@ -99,7 +102,7 @@ public function tokenize(string $expression): TokenStream if ($brackets) { [$expect, $cur] = array_pop($brackets); - throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $cur, $expression); + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $cur, $expression); } return new TokenStream($tokens, $expression); diff --git a/Node/BinaryNode.php b/Node/BinaryNode.php index 5b365c2..a77d065 100644 --- a/Node/BinaryNode.php +++ b/Node/BinaryNode.php @@ -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 @@ -67,7 +69,7 @@ public function compile(Compiler $compiler): void if (isset(self::FUNCTIONS[$operator])) { $compiler - ->raw(sprintf('%s(', self::FUNCTIONS[$operator])) + ->raw(\sprintf('%s(', self::FUNCTIONS[$operator])) ->compile($this->nodes['left']) ->raw(', ') ->compile($this->nodes['right']) @@ -116,6 +118,8 @@ public function evaluate(array $functions, array $values): mixed 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); @@ -130,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 '===': @@ -146,10 +154,6 @@ public function evaluate(array $functions, array $values): mixed return $left >= $right; case '<=': return $left <= $right; - case 'not in': - return !\in_array($left, $right, true); - case 'in': - return \in_array($left, $right, true); case '+': return $left + $right; case '-': @@ -173,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 @@ -182,7 +188,7 @@ public function toArray(): array 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))); + set_error_handler(static fn ($t, $m) => throw new SyntaxError(\sprintf('Regexp "%s" passed to "matches" is not valid', $regexp).substr($m, 12))); try { return preg_match($regexp, (string) $str); } finally { 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/GetAttrNode.php b/Node/GetAttrNode.php index 984247e..416c094 100644 --- a/Node/GetAttrNode.php +++ b/Node/GetAttrNode.php @@ -83,7 +83,7 @@ public function evaluate(array $functions, array $values): mixed } if (!\is_object($obj)) { - throw new \RuntimeException(sprintf('Unable to get property "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); + throw new \RuntimeException(\sprintf('Unable to get property "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); } $property = $this->nodes['attribute']->attributes['value']; @@ -107,10 +107,10 @@ public function evaluate(array $functions, array $values): mixed } if (!\is_object($obj)) { - throw new \RuntimeException(sprintf('Unable to call method "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); + throw new \RuntimeException(\sprintf('Unable to call method "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); } if (!\is_callable($toCall = [$obj, $this->nodes['attribute']->attributes['value']])) { - throw new \RuntimeException(sprintf('Unable to call method "%s" of object "%s".', $this->nodes['attribute']->attributes['value'], get_debug_type($obj))); + throw new \RuntimeException(\sprintf('Unable to call method "%s" of object "%s".', $this->nodes['attribute']->attributes['value'], get_debug_type($obj))); } return $toCall(...array_values($this->nodes['arguments']->evaluate($functions, $values))); @@ -123,7 +123,7 @@ public function evaluate(array $functions, array $values): mixed } if (!\is_array($array) && !$array instanceof \ArrayAccess && !(null === $array && $this->attributes['is_null_coalesce'])) { - throw new \RuntimeException(sprintf('Unable to get an item of non-array "%s".', $this->nodes['node']->dump())); + throw new \RuntimeException(\sprintf('Unable to get an item of non-array "%s".', $this->nodes['node']->dump())); } if ($this->attributes['is_null_coalesce']) { @@ -141,12 +141,13 @@ private function isShortCircuited(): bool public function toArray(): array { + $nullSafe = $this->nodes['attribute'] instanceof ConstantNode && $this->nodes['attribute']->isNullSafe; switch ($this->attributes['type']) { case self::PROPERTY_CALL: - return [$this->nodes['node'], '.', $this->nodes['attribute']]; + return [$this->nodes['node'], $nullSafe ? '?.' : '.', $this->nodes['attribute']]; case self::METHOD_CALL: - return [$this->nodes['node'], '.', $this->nodes['attribute'], '(', $this->nodes['arguments'], ')']; + return [$this->nodes['node'], $nullSafe ? '?.' : '.', $this->nodes['attribute'], '(', $this->nodes['arguments'], ')']; case self::ARRAY_CALL: return [$this->nodes['node'], '[', $this->nodes['attribute'], ']']; diff --git a/Node/Node.php b/Node/Node.php index 4ba5e5c..e14ed45 100644 --- a/Node/Node.php +++ b/Node/Node.php @@ -37,7 +37,7 @@ public function __toString(): string { $attributes = []; foreach ($this->attributes as $name => $value) { - $attributes[] = sprintf('%s: %s', $name, str_replace("\n", '', var_export($value, true))); + $attributes[] = \sprintf('%s: %s', $name, str_replace("\n", '', var_export($value, true))); } $repr = [str_replace('Symfony\Component\ExpressionLanguage\Node\\', '', static::class).'('.implode(', ', $attributes)]; @@ -79,7 +79,7 @@ public function evaluate(array $functions, array $values): mixed */ public function toArray(): array { - throw new \BadMethodCallException(sprintf('Dumping a "%s" instance is not supported yet.', static::class)); + throw new \BadMethodCallException(\sprintf('Dumping a "%s" instance is not supported yet.', static::class)); } public function dump(): string @@ -95,7 +95,7 @@ public function dump(): string protected function dumpString(string $value): string { - return sprintf('"%s"', addcslashes($value, "\0\t\"\\")); + return \sprintf('"%s"', addcslashes($value, "\0\t\"\\")); } protected function isHash(array $value): bool 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/Parser.php b/Parser.php index d34e742..32254cd 100644 --- a/Parser.php +++ b/Parser.php @@ -43,10 +43,12 @@ public function __construct( '!' => ['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], @@ -67,6 +69,8 @@ public function __construct( '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], @@ -134,7 +138,7 @@ private function doParse(TokenStream $stream, array $names, int $flags): Node\No $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()); + 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); @@ -239,14 +243,18 @@ public function parsePrimaryExpression(): Node\Node 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)); + 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); + 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); } // is the name used in the compiled code different @@ -275,7 +283,7 @@ public function parsePrimaryExpression(): Node\Node } 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()); + throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', $token->type, $token->value), $token->cursor, $this->stream->getExpression()); } } @@ -337,7 +345,7 @@ public function parseHashExpression(): Node\ArrayNode } 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()); + 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 (:)'); 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/SyntaxError.php b/SyntaxError.php index e165dc2..8ecb341 100644 --- a/SyntaxError.php +++ b/SyntaxError.php @@ -15,9 +15,9 @@ class SyntaxError extends \LogicException { public function __construct(string $message, int $cursor = 0, string $expression = '', ?string $subject = null, ?array $proposals = null) { - $message = sprintf('%s around position %d', rtrim($message, '.'), $cursor); + $message = \sprintf('%s around position %d', rtrim($message, '.'), $cursor); if ($expression) { - $message = sprintf('%s for expression `%s`', $message, $expression); + $message = \sprintf('%s for expression `%s`', $message, $expression); } $message .= '.'; @@ -32,7 +32,7 @@ public function __construct(string $message, int $cursor = 0, string $expression } if (isset($guess) && $minScore < 3) { - $message .= sprintf(' Did you mean "%s"?', $guess); + $message .= \sprintf(' Did you mean "%s"?', $guess); } } diff --git a/Tests/ExpressionLanguageTest.php b/Tests/ExpressionLanguageTest.php index 272aa1b..e8ecfc5 100644 --- a/Tests/ExpressionLanguageTest.php +++ b/Tests/ExpressionLanguageTest.php @@ -77,8 +77,8 @@ public function testCachedParse() public function testBasicPhpFunction($expression, $expected, $compiled) { $expressionLanguage = new ExpressionLanguage(); - $this->assertEquals($expected, $expressionLanguage->evaluate($expression)); - $this->assertEquals($compiled, $expressionLanguage->compile($expression)); + $this->assertSame($expected, $expressionLanguage->evaluate($expression)); + $this->assertSame($compiled, $expressionLanguage->compile($expression)); } public static function basicPhpFunctionProvider() @@ -116,7 +116,7 @@ public function testCompiledEnumFunction() { $result = null; $expressionLanguage = new ExpressionLanguage(); - eval(sprintf('$result = %s;', $expressionLanguage->compile('enum("Symfony\\\\Component\\\\ExpressionLanguage\\\\Tests\\\\Fixtures\\\\FooEnum::Foo")'))); + eval(\sprintf('$result = %s;', $expressionLanguage->compile('enum("Symfony\\\\Component\\\\ExpressionLanguage\\\\Tests\\\\Fixtures\\\\FooEnum::Foo")'))); $this->assertSame(FooEnum::Foo, $result); } @@ -132,22 +132,33 @@ public function testCompiledEnumFunctionWithBackedEnum() { $result = null; $expressionLanguage = new ExpressionLanguage(); - eval(sprintf('$result = %s;', $expressionLanguage->compile('enum("Symfony\\\\Component\\\\ExpressionLanguage\\\\Tests\\\\Fixtures\\\\FooBackedEnum::Bar")'))); + eval(\sprintf('$result = %s;', $expressionLanguage->compile('enum("Symfony\\\\Component\\\\ExpressionLanguage\\\\Tests\\\\Fixtures\\\\FooBackedEnum::Bar")'))); $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(); + })()]; } /** @@ -156,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)); } /** @@ -166,7 +177,7 @@ public function testShortCircuitOperatorsCompile($expression, array $names, $exp { $result = null; $expressionLanguage = new ExpressionLanguage(); - eval(sprintf('$result = %s;', $expressionLanguage->compile($expression, $names))); + eval(\sprintf('$result = %s;', $expressionLanguage->compile($expression, $names))); $this->assertSame($expected, $result); } @@ -330,12 +341,12 @@ public function testNullSafeEvaluate($expression, $foo) public function testNullSafeCompile($expression, $foo) { $expressionLanguage = new ExpressionLanguage(); - $this->assertNull(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo'])))); + $this->assertNull(eval(\sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo'])))); } public static function provideNullSafe() { - $foo = new class() extends \stdClass { + $foo = new class extends \stdClass { public function bar() { return null; @@ -393,7 +404,7 @@ public function testNullSafeCompileFails($expression, $foo) }); try { - eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))); + eval(\sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))); } finally { restore_error_handler(); } @@ -401,9 +412,9 @@ public function testNullSafeCompileFails($expression, $foo) public static function provideInvalidNullSafe() { - yield ['foo?.bar.baz', (object) ['bar' => null], 'Unable to get property "baz" of non-object "foo.bar".']; - yield ['foo?.bar["baz"]', (object) ['bar' => null], 'Unable to get an item of non-array "foo.bar".']; - yield ['foo?.bar["baz"].qux.quux', (object) ['bar' => ['baz' => null]], 'Unable to get property "qux" of non-object "foo.bar["baz"]".']; + yield ['foo?.bar.baz', (object) ['bar' => null], 'Unable to get property "baz" of non-object "foo?.bar".']; + yield ['foo?.bar["baz"]', (object) ['bar' => null], 'Unable to get an item of non-array "foo?.bar".']; + yield ['foo?.bar["baz"].qux.quux', (object) ['bar' => ['baz' => null]], 'Unable to get property "qux" of non-object "foo?.bar["baz"]".']; } /** @@ -421,18 +432,19 @@ public function testNullCoalescingEvaluate($expression, $foo) public function testNullCoalescingCompile($expression, $foo) { $expressionLanguage = new ExpressionLanguage(); - $this->assertSame(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))), 'default'); + $this->assertSame(eval(\sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))), 'default'); } public static function provideNullCoalescing() { - $foo = new class() extends \stdClass { + $foo = new class extends \stdClass { public function bar() { return null; } }; + yield ['bar ?? "default"', null]; yield ['foo.bar ?? "default"', null]; yield ['foo.bar.baz ?? "default"', (object) ['bar' => null]]; yield ['foo.bar ?? foo.baz ?? "default"', null]; @@ -458,6 +470,43 @@ 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(); @@ -476,6 +525,13 @@ public function testLintThrowsOnInvalidExpression() $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 fd06587..375d0a1 100644 --- a/Tests/Node/BinaryNodeTest.php +++ b/Tests/Node/BinaryNodeTest.php @@ -29,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))], @@ -84,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))], @@ -136,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))], @@ -215,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)); @@ -249,4 +279,14 @@ public function testInOperatorStrictness(mixed $value) $this->assertFalse($node->evaluate([], [])); } + + public function testEvaluateUnsupportedOperator() + { + $node = new BinaryNode('unsupported', new ConstantNode(1), new ConstantNode(2)); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"Symfony\Component\ExpressionLanguage\Node\BinaryNode" does not support the "unsupported" operator.'); + + $node->evaluate([], []); + } } diff --git a/Tests/Node/FunctionNodeTest.php b/Tests/Node/FunctionNodeTest.php index aa667f7..9999482 100644 --- a/Tests/Node/FunctionNodeTest.php +++ b/Tests/Node/FunctionNodeTest.php @@ -34,14 +34,14 @@ public static function getCompileData(): array public static function getDumpData(): array { return [ - ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')])), ['foo' => static::getCallables()]], + ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')]))], ]; } protected static function getCallables(): array { return [ - 'compiler' => fn ($arg) => sprintf('foo(%s)', $arg), + 'compiler' => fn ($arg) => \sprintf('foo(%s)', $arg), 'evaluator' => fn ($variables, $arg) => $arg, ]; } diff --git a/Tests/Node/GetAttrNodeTest.php b/Tests/Node/GetAttrNodeTest.php index 6d81a2b..d939afc 100644 --- a/Tests/Node/GetAttrNodeTest.php +++ b/Tests/Node/GetAttrNodeTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\ExpressionLanguage\Tests\Node; +use Symfony\Component\ExpressionLanguage\Node\ArgumentsNode; use Symfony\Component\ExpressionLanguage\Node\ArrayNode; use Symfony\Component\ExpressionLanguage\Node\ConstantNode; use Symfony\Component\ExpressionLanguage\Node\GetAttrNode; @@ -50,10 +51,12 @@ public static function getDumpData(): array ['foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), self::getArrayNode(), GetAttrNode::ARRAY_CALL)], ['foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), self::getArrayNode(), GetAttrNode::ARRAY_CALL)], - ['foo.foo', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), self::getArrayNode(), GetAttrNode::PROPERTY_CALL), ['foo' => new Obj()]], + ['foo.foo', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), self::getArrayNode(), GetAttrNode::PROPERTY_CALL)], - ['foo.foo({"b": "a", 0: "b"})', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), self::getArrayNode(), GetAttrNode::METHOD_CALL), ['foo' => new Obj()]], + ['foo.foo({"b": "a", 0: "b"})', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), self::getArrayNode(), GetAttrNode::METHOD_CALL)], ['foo[index]', new GetAttrNode(new NameNode('foo'), new NameNode('index'), self::getArrayNode(), GetAttrNode::ARRAY_CALL)], + + ['foo?.foo()', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo', true, true), new ArgumentsNode(), GetAttrNode::METHOD_CALL)], ]; } 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 31a8658..0f1c893 100644 --- a/Tests/ParserTest.php +++ b/Tests/ParserTest.php @@ -244,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', diff --git a/Token.php b/Token.php index c1a7c10..5fc7341 100644 --- a/Token.php +++ b/Token.php @@ -41,7 +41,7 @@ public function __construct( */ public function __toString(): string { - return sprintf('%3d %-11s %s', $this->cursor, strtoupper($this->type), $this->value); + return \sprintf('%3d %-11s %s', $this->cursor, strtoupper($this->type), $this->value); } /** diff --git a/TokenStream.php b/TokenStream.php index a7f6735..1280f44 100644 --- a/TokenStream.php +++ b/TokenStream.php @@ -20,15 +20,13 @@ class TokenStream { 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; } /** @@ -60,7 +58,7 @@ public function expect(string $type, ?string $value = null, ?string $message = n { $token = $this->current; if (!$token->test($type, $value)) { - throw new SyntaxError(sprintf('%sUnexpected token "%s" of value "%s" ("%s" expected%s).', $message ? $message.'. ' : '', $token->type, $token->value, $type, $value ? sprintf(' with value "%s"', $value) : ''), $token->cursor, $this->expression); + throw new SyntaxError(\sprintf('%sUnexpected token "%s" of value "%s" ("%s" expected%s).', $message ? $message.'. ' : '', $token->type, $token->value, $type, $value ? \sprintf(' with value "%s"', $value) : ''), $token->cursor, $this->expression); } $this->next(); } 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