Add getOperatorTokens() to ExpressionParserInterface to separate operator token registration from parser identity

This commit is contained in:
Fabien Potencier
2026-02-23 23:12:24 +01:00
parent 2ec5479d4c
commit e5eb95d0d7
10 changed files with 115 additions and 29 deletions
+1
View File
@@ -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`)
+10
View File
@@ -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.
+4 -4
View File
@@ -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, ...) |
+------------+------------------+---------+---------------+-------------------------------------------------------------------+
@@ -27,4 +27,9 @@ abstract class AbstractExpressionParser implements ExpressionParserInterface
{
return [];
}
public function getOperatorTokens(): array
{
return [$this->getName(), ...$this->getAliases()];
}
}
@@ -11,6 +11,14 @@
namespace Twig\ExpressionParser;
/**
* @method list<string> 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;
+33 -5
View File
@@ -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<string>
*/
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()];
}
}
@@ -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');
+2 -1
View File
@@ -13,6 +13,7 @@
namespace Twig;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser\ExpressionParsers;
/**
* @author Fabien Potencier <fabien@symfony.com>
@@ -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));
+33
View File
@@ -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
+13
View File
@@ -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);
}