diff --git a/CHANGELOG b/CHANGELOG index c5ebc15ef..7f81ddaec 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.24.0 (2026-XX-XX) + * Deprecate not implementing the `getOperatorTokens()` method in `ExpressionParserInterface` implementations * Deprecate passing a non-`AbstractExpression` node to `ParserTwig\Node\Expression\Binary\MatchesBinary` constructor * Deprecate passing a non-`AbstractExpression` node to `Parser::setParent()` * Add support for renaming variables in object destructuring (`{name: userName} = user`) diff --git a/doc/deprecated.rst b/doc/deprecated.rst index 3950ee553..eb5b51674 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -485,3 +485,13 @@ Operators * The ``Twig\OperatorPrecedenceChange`` class is deprecated as of Twig 3.21, use ``Twig\ExpressionParser\PrecedenceChange`` instead. + +* Not implementing the ``getOperatorTokens()`` method in + ``Twig\ExpressionParser\ExpressionParserInterface`` implementations is + deprecated as of Twig 3.24. This method will be added to the interface in + Twig 4.0. It returns the operator token strings that the expression parser + handles (used by the Lexer and the parser registry). If your custom + expression parser extends ``Twig\ExpressionParser\AbstractExpressionParser``, + the default implementation returns ``[$this->getName(), ...$this->getAliases()]``. + Override it if your parser doesn't handle operator tokens (return ``[]``) or if + the operator tokens differ from the parser name. diff --git a/doc/operators_precedence.rst b/doc/operators_precedence.rst index 8e8ec343f..d67508063 100644 --- a/doc/operators_precedence.rst +++ b/doc/operators_precedence.rst @@ -92,12 +92,12 @@ +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 0 | ``(`` | prefix | n/a | Explicit group expression (a) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``literal`` | | | A literal value (boolean, string, number, sequence, mapping, ...) | -+------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``?`` | infix | Left | Conditional operator (a ? b : c) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``=`` | | Right | Assignment operator | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | prefix | n/a | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ When a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``. @@ -196,9 +196,9 @@ Here is the same table for Twig 4.0 with adjusted precedences: +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 0 | ``(`` | prefix | n/a | Explicit group expression (a) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``literal`` | | | A literal value (boolean, string, number, sequence, mapping, ...) | -+------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``?`` | infix | Left | Conditional operator (a ? b : c) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``=`` | | Right | Assignment operator | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | prefix | n/a | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ diff --git a/src/ExpressionParser/AbstractExpressionParser.php b/src/ExpressionParser/AbstractExpressionParser.php index bc05bfa05..b8b963a1f 100644 --- a/src/ExpressionParser/AbstractExpressionParser.php +++ b/src/ExpressionParser/AbstractExpressionParser.php @@ -27,4 +27,9 @@ abstract class AbstractExpressionParser implements ExpressionParserInterface { return []; } + + public function getOperatorTokens(): array + { + return [$this->getName(), ...$this->getAliases()]; + } } diff --git a/src/ExpressionParser/ExpressionParserInterface.php b/src/ExpressionParser/ExpressionParserInterface.php index 86576aec4..af7b1a5bf 100644 --- a/src/ExpressionParser/ExpressionParserInterface.php +++ b/src/ExpressionParser/ExpressionParserInterface.php @@ -11,6 +11,14 @@ namespace Twig\ExpressionParser; +/** + * @method list getOperatorTokens() Returns the operator token strings that this expression parser handles. + * These are the strings that should be recognized as operator tokens by the Lexer, + * and used to look up the parser in the registry. + * For most parsers, this returns the name and aliases. Parsers that don't handle + * operator tokens (like LiteralExpressionParser) should return an empty array. + * This method will be added to the interface in Twig 4.0. + */ interface ExpressionParserInterface { public function __toString(): string; diff --git a/src/ExpressionParser/ExpressionParsers.php b/src/ExpressionParser/ExpressionParsers.php index fb35a690e..7efaffc14 100644 --- a/src/ExpressionParser/ExpressionParsers.php +++ b/src/ExpressionParser/ExpressionParsers.php @@ -54,10 +54,9 @@ final class ExpressionParsers implements \IteratorAggregate // throw new \InvalidArgumentException(\sprintf('Precedence for "%s" must be between 0 and 512, got %d.', $parser->getName(), $parser->getPrecedence())); } $interface = $parser instanceof PrefixExpressionParserInterface ? PrefixExpressionParserInterface::class : InfixExpressionParserInterface::class; - $this->parsersByName[$interface][$parser->getName()] = $parser; $this->parsersByClass[$parser::class] = $parser; - foreach ($parser->getAliases() as $alias) { - $this->parsersByName[$interface][$alias] = $parser; + foreach (self::getOperatorTokensFor($parser) as $token) { + $this->parsersByName[$interface][$token] = $parser; } } @@ -90,9 +89,22 @@ final class ExpressionParsers implements \IteratorAggregate public function getIterator(): \Traversable { + $seen = []; foreach ($this->parsersByName as $parsers) { - // we don't yield the keys - yield from $parsers; + foreach ($parsers as $parser) { + $id = spl_object_id($parser); + if (!isset($seen[$id])) { + $seen[$id] = true; + yield $parser; + } + } + } + foreach ($this->parsersByClass as $parser) { + $id = spl_object_id($parser); + if (!isset($seen[$id])) { + $seen[$id] = true; + yield $parser; + } } } @@ -124,4 +136,20 @@ final class ExpressionParsers implements \IteratorAggregate return $this->precedenceChanges; } + + /** + * @internal + * + * @return array + */ + public static function getOperatorTokensFor(ExpressionParserInterface $parser): array + { + if (method_exists($parser, 'getOperatorTokens')) { + return $parser->getOperatorTokens(); + } + + trigger_deprecation('twig/twig', '3.24', 'Not implementing the "getOperatorTokens()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $parser::class, ExpressionParserInterface::class); + + return [$parser->getName(), ...$parser->getAliases()]; + } } diff --git a/src/ExpressionParser/Prefix/LiteralExpressionParser.php b/src/ExpressionParser/Prefix/LiteralExpressionParser.php index da3805106..b467d1aa5 100644 --- a/src/ExpressionParser/Prefix/LiteralExpressionParser.php +++ b/src/ExpressionParser/Prefix/LiteralExpressionParser.php @@ -30,8 +30,6 @@ use Twig\Token; */ final class LiteralExpressionParser extends AbstractExpressionParser implements PrefixExpressionParserInterface, ExpressionParserDescriptionInterface { - private string $type = 'literal'; - public function parse(Parser $parser, Token $token): AbstractExpression { $stream = $parser->getStream(); @@ -41,41 +39,30 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements switch ($token->getValue()) { case 'true': case 'TRUE': - $this->type = 'constant'; - return new ConstantExpression(true, $token->getLine()); case 'false': case 'FALSE': - $this->type = 'constant'; - return new ConstantExpression(false, $token->getLine()); case 'none': case 'NONE': case 'null': case 'NULL': - $this->type = 'constant'; - return new ConstantExpression(null, $token->getLine()); default: - $this->type = 'variable'; - return new ContextVariable($token->getValue(), $token->getLine()); } // no break case $token->test(Token::NUMBER_TYPE): $stream->next(); - $this->type = 'constant'; return new ConstantExpression($token->getValue(), $token->getLine()); case $token->test(Token::STRING_TYPE): case $token->test(Token::INTERPOLATION_START_TYPE): - $this->type = 'string'; - return $this->parseStringExpression($parser); case $token->test(Token::PUNCTUATION_TYPE): @@ -96,7 +83,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) { // in this context, string operators are variable names $stream->next(); - $this->type = 'variable'; return new ContextVariable($token->getValue(), $token->getLine()); } @@ -109,7 +95,12 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements public function getName(): string { - return $this->type; + return 'literal'; + } + + public function getOperatorTokens(): array + { + return []; } public function getDescription(): string @@ -153,8 +144,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements private function parseSequenceExpression(Parser $parser) { - $this->type = 'sequence'; - $stream = $parser->getStream(); $stream->expect(Token::OPERATOR_TYPE, '[', 'A sequence element was expected'); @@ -185,8 +174,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements private function parseMappingExpression(Parser $parser) { - $this->type = 'mapping'; - $stream = $parser->getStream(); $stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected'); diff --git a/src/Lexer.php b/src/Lexer.php index 3d4e0b863..e65f5bedc 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -13,6 +13,7 @@ namespace Twig; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParsers; /** * @author Fabien Potencier @@ -527,7 +528,7 @@ class Lexer { $expressionParsers = []; foreach ($this->env->getExpressionParsers() as $expressionParser) { - $expressionParsers = array_merge($expressionParsers, [$expressionParser->getName()], $expressionParser->getAliases()); + $expressionParsers = array_merge($expressionParsers, ExpressionParsers::getOperatorTokensFor($expressionParser)); } $expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers)); diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index b18c4efbd..1bd32ac92 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -27,7 +27,10 @@ use Twig\Compiler; use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\Prefix\LiteralExpressionParser; use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; +use Twig\ExpressionParser\PrefixExpressionParserInterface; use Twig\Extension\AbstractExtension; use Twig\Loader\ArrayLoader; use Twig\Node\Expression\ArrayExpression; @@ -796,6 +799,36 @@ class ExpressionParserTest extends TestCase yield '= stronger than logical' => ['{% do a = false or true %}{{ a }}', '{% do a = (false or true) %}{{ a }}', eval('$a = false || true; return $a;')]; yield '= stronger than ternary' => ['{% do c = 4 ? 0 : -1 %}{{ c }}', '{% do c = (4 ? 0 : -1) %}{{ c }}', eval('return 4 ? 0 : -1;')]; } + + public function testLiteralExpressionParserGetOperatorTokensReturnsEmptyArray() + { + $env = new Environment(new ArrayLoader()); + $parser = $env->getExpressionParsers()->getByClass(LiteralExpressionParser::class); + + $this->assertSame([], $parser->getOperatorTokens()); + $this->assertSame('literal', $parser->getName()); + } + + public function testExpressionParserGetOperatorTokensDefaultBehavior() + { + $env = new Environment(new ArrayLoader()); + + foreach ($env->getExpressionParsers() as $parser) { + if ($parser instanceof LiteralExpressionParser) { + continue; + } + $expected = [$parser->getName(), ...$parser->getAliases()]; + $this->assertSame($expected, $parser->getOperatorTokens(), \sprintf('getOperatorTokens() for %s should return name + aliases.', $parser::class)); + } + } + + public function testLiteralIsNotRegisteredAsOperator() + { + // Ensure "literal" is not in the operator registry + $env = new Environment(new ArrayLoader()); + $this->assertNull($env->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'literal')); + $this->assertNull($env->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'literal')); + } } class NotReadyFunctionExpression extends FunctionExpression diff --git a/tests/LexerTest.php b/tests/LexerTest.php index 7ede4cb0f..04f0770bd 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -454,6 +454,19 @@ class LexerTest extends TestCase // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions + } + + public function testLiteralIsNotAnOperator() + { + // "literal" is the name of the LiteralExpressionParser but should not be treated as an operator token + $template = '{{ literal }}'; + + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::NAME_TYPE, 'literal'); + $stream->expect(Token::VAR_END_TYPE); + $this->addToAssertionCount(1); }