diff --git a/.gitattributes b/.gitattributes index 86b9ef413df..c07b0dfb56e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ /.github/ export-ignore +/bin/ export-ignore /doc/ export-ignore /extra/ export-ignore /tests/ export-ignore diff --git a/CHANGELOG b/CHANGELOG index 30730ffeb84..cb70fd0c780 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,11 @@ # 3.21.0 (2025-XX-XX) - * n/a + * Deprecate using the `|` operator in an expression with `+` or `-` without using parentheses to clarify precedence + * Deprecate operator precedence outside of the [0, 512] range + * Introduce expression parser classes to describe operators and operands provided by extensions + instead of arrays (it comes with many deprecations that are documented in + the ``deprecated`` documentation chapter) + * Deprecate the `Twig\ExpressionParser`, and `Twig\OperatorPrecedenceChange` classes # 3.20.0 (2025-02-13) diff --git a/bin/generate_operators_precedence.php b/bin/generate_operators_precedence.php new file mode 100644 index 00000000000..a95f18f57fb --- /dev/null +++ b/bin/generate_operators_precedence.php @@ -0,0 +1,89 @@ +getExpressionParsers() as $expressionParser) { + $expressionParsers[] = $expressionParser; +} + +fwrite($output, "\n=========== ================ ======= ============= ===========\n"); +fwrite($output, "Precedence Operator Type Associativity Description\n"); +fwrite($output, '=========== ================ ======= ============= ==========='); + +usort($expressionParsers, fn ($a, $b) => $b->getPrecedence() <=> $a->getPrecedence()); + +$previous = null; +foreach ($expressionParsers as $expressionParser) { + $precedence = $expressionParser->getPrecedence(); + $previousPrecedence = $previous ? $previous->getPrecedence() : \PHP_INT_MAX; + $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; + $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; + if ($previousPrecedence !== $precedence) { + $previous = null; + } + fwrite($output, rtrim(\sprintf("\n%-11s %-16s %-7s %-13s %s\n", + (!$previous || $previousPrecedence !== $precedence ? $precedence : '').($expressionParser->getPrecedenceChange() ? ' => '.$expressionParser->getPrecedenceChange()->getNewPrecedence() : ''), + '``'.$expressionParser->getName().'``', + !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', + !$previous || $previousAssociativity !== $associativity ? $associativity : '', + $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', + ))); + $previous = $expressionParser; +} +fwrite($output, "\n=========== ================ ======= ============= ===========\n"); +fwrite($output, "\nWhen a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``.\n"); + +fwrite($output, "\nHere is the same table for Twig 4.0 with adjusted precedences:\n"); + +fwrite($output, "\n=========== ================ ======= ============= ===========\n"); +fwrite($output, "Precedence Operator Type Associativity Description\n"); +fwrite($output, '=========== ================ ======= ============= ==========='); + +usort($expressionParsers, function ($a, $b) { + $aPrecedence = $a->getPrecedenceChange() ? $a->getPrecedenceChange()->getNewPrecedence() : $a->getPrecedence(); + $bPrecedence = $b->getPrecedenceChange() ? $b->getPrecedenceChange()->getNewPrecedence() : $b->getPrecedence(); + + return $bPrecedence - $aPrecedence; +}); + +$previous = null; +foreach ($expressionParsers as $expressionParser) { + $precedence = $expressionParser->getPrecedenceChange() ? $expressionParser->getPrecedenceChange()->getNewPrecedence() : $expressionParser->getPrecedence(); + $previousPrecedence = $previous ? ($previous->getPrecedenceChange() ? $previous->getPrecedenceChange()->getNewPrecedence() : $previous->getPrecedence()) : \PHP_INT_MAX; + $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; + $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; + if ($previousPrecedence !== $precedence) { + $previous = null; + } + fwrite($output, rtrim(\sprintf("\n%-11s %-16s %-7s %-13s %s\n", + !$previous || $previousPrecedence !== $precedence ? $precedence : '', + '``'.$expressionParser->getName().'``', + !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', + !$previous || $previousAssociativity !== $associativity ? $associativity : '', + $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', + ))); + $previous = $expressionParser; +} +fwrite($output, "\n=========== ================ ======= ============= ===========\n"); + +fclose($output); diff --git a/doc/advanced.rst b/doc/advanced.rst index bc7e5d376f4..963aecf86d0 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -775,26 +775,9 @@ responsible for parsing the tag and compiling it to PHP. Operators ~~~~~~~~~ -The ``getOperators()`` methods lets you add new operators. Here is how to add -the ``!``, ``||``, and ``&&`` operators:: - - class CustomTwigExtension extends \Twig\Extension\AbstractExtension - { - public function getOperators() - { - return [ - [ - '!' => ['precedence' => 50, 'class' => \Twig\Node\Expression\Unary\NotUnary::class], - ], - [ - '||' => ['precedence' => 10, 'class' => \Twig\Node\Expression\Binary\OrBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - '&&' => ['precedence' => 15, 'class' => \Twig\Node\Expression\Binary\AndBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - ], - ]; - } - - // ... - } +The ``getOperators()`` methods lets you add new operators. To implement a new +one, have a look at the default operators provided by +``Twig\Extension\CoreExtension``. Tests ~~~~~ diff --git a/doc/deprecated.rst b/doc/deprecated.rst index b3397d8ddd7..6e8212fb0e4 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -210,27 +210,35 @@ Node Visitors Parser ------ -* Passing a second argument to ``ExpressionParser::parseFilterExpressionRaw()`` - is deprecated as of Twig 3.12. - * The following methods from ``Twig\Parser`` are deprecated as of Twig 3.12: ``getBlockStack()``, ``hasBlock()``, ``getBlock()``, ``hasMacro()``, ``hasTraits()``, ``getParent()``. -* The ``Twig\ExpressionParser::parseHashExpression()`` method is deprecated, use - ``Twig\ExpressionParser::parseMappingExpression()`` instead. - -* The ``Twig\ExpressionParser::parseArrayExpression()`` method is deprecated, use - ``Twig\ExpressionParser::parseSequenceExpression()`` instead. - * Passing ``null`` to ``Twig\Parser::setParent()`` is deprecated as of Twig 3.12. -* The ``Twig\ExpressionParser::parseOnlyArguments()`` and - ``Twig\ExpressionParser::parseArguments()`` methods are deprecated, use - ``Twig\ExpressionParser::parseNamedArguments()`` instead. - -Lexer +* The ``Twig\Parser::getExpressionParser()`` method is deprecated as of Twig + 3.21, use ``Twig\Parser::parseExpression()`` instead. + +* The ``Twig\ExpressionParser`` class is deprecated as of Twig 3.21: + + * ``parseExpression()``, use ``Parser::parseExpression()`` + * ``parsePrimaryExpression()``, use ``Parser::parseExpression()`` + * ``parseStringExpression()``, use ``Parser::parseExpression()`` + * ``parseHashExpression()``, use ``Parser::parseExpression()`` + * ``parseMappingExpression()``, use ``Parser::parseExpression()`` + * ``parseArrayExpression()``, use ``Parser::parseExpression()`` + * ``parseSequenceExpression()``, use ``Parser::parseExpression()`` + * ``parsePostfixExpression`` + * ``parseSubscriptExpression`` + * ``parseFilterExpression`` + * ``parseFilterExpressionRaw`` + * ``parseArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` + * ``parseAssignmentExpression``, use ``AbstractTokenParser::parseAssignmentExpression`` + * ``parseMultitargetExpression`` + * ``parseOnlyArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` + +Token ----- * Not passing a ``Source`` instance to ``Twig\TokenStream`` constructor is @@ -239,6 +247,12 @@ Lexer * The ``Token::getType()`` method is deprecated as of Twig 3.19, use ``Token::test()`` instead. +* The ``Token::ARROW_TYPE`` constant is deprecated as of Twig 3.21, the arrow + ``=>`` is now an operator (``Token::OPERATOR_TYPE``). + +* The ``Token::PUNCTUATION_TYPE`` with values ``(``, ``[``, ``|``, ``.``, + ``?``, or ``?:`` are now of the ``Token::OPERATOR_TYPE`` type. + Templates --------- @@ -364,6 +378,8 @@ Node Operators --------- +* An operator precedence must be part of the [0, 512] range as of Twig 3.21. + * The ``.`` operator allows accessing class constants as of Twig 3.15. This can be a BC break if you don't use UPPERCASE constant names. @@ -418,3 +434,45 @@ Operators {# or #} {{ (not 1) * 2 }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* Using the ``|`` operator in an expression with ``+`` or ``-`` without explicit + parentheses to clarify precedence triggers a deprecation as of Twig 3.21 (in + Twig 4.0, ``|`` will have a higher precedence than ``+`` and ``-``). + + For example, the following expression will trigger a deprecation in Twig 3.21:: + + {{ -1|abs }} + + To avoid the deprecation, add parentheses to clarify the precedence:: + + {{ -(1|abs) }} {# this is equivalent to what Twig 3.x does without the parentheses #} + + {# or #} + + {{ (-1)|abs }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* The ``Twig\Extension\ExtensionInterface::getOperators()`` method is deprecated + as of Twig 3.21, use ``Twig\Extension\ExtensionInterface::getExpressionParsers()`` + instead: + + Before: + + public function getOperators(): array { + return [ + 'not' => [ + 'precedence' => 10, + 'class' => NotUnary::class, + ], + ]; + } + + After: + + public function getExpressionParsers(): array { + return [ + new UnaryOperatorExpressionParser(NotUnary::class, 'not', 10), + ]; + } + +* The ``Twig\OperatorPrecedenceChange`` class is deprecated as of Twig 3.21, + use ``Twig\ExpressionParser\PrecedenceChange`` instead. diff --git a/doc/filters/number_format.rst b/doc/filters/number_format.rst index 047249d6718..4f68596fbe1 100644 --- a/doc/filters/number_format.rst +++ b/doc/filters/number_format.rst @@ -15,15 +15,21 @@ separator using the additional arguments: {{ 9800.333|number_format(2, '.', ',') }} -To format negative numbers or math calculation, wrap the previous statement -with parentheses (needed because of Twig's :ref:`precedence of operators -`): +To format negative numbers, wrap the previous statement with parentheses (note +that as of Twig 3.21, not using parentheses is deprecated as the filter +operator will change precedence in Twig 4.0): .. code-block:: twig {{ -9800.333|number_format(2, '.', ',') }} {# outputs : -9 #} {{ (-9800.333)|number_format(2, '.', ',') }} {# outputs : -9,800.33 #} - {{ 1 + 0.2|number_format(2) }} {# outputs : 1.2 #} + +To format math calculation, wrap the previous statement with parentheses +(needed because of Twig's :ref:`precedence of operators -`): + +.. code-block:: twig + + {{ 1 + 0.2|number_format(2) }} {# outputs : 1.2 #} {{ (1 + 0.2)|number_format(2) }} {# outputs : 1.20 #} If no formatting options are provided then Twig will use the default formatting diff --git a/doc/operators_precedence.rst b/doc/operators_precedence.rst new file mode 100644 index 00000000000..f603127f3dd --- /dev/null +++ b/doc/operators_precedence.rst @@ -0,0 +1,104 @@ + +=========== ================ ======= ============= =========== +Precedence Operator Type Associativity Description +=========== ================ ======= ============= =========== +512 => 300 ``|`` infix Left Twig filter call + ``(`` Twig function call + ``.`` Get an attribute on a variable + ``[`` Array access +500 ``-`` prefix n/a + ``+`` +300 => 5 ``??`` infix Right Null coalescing operator (a ?? b) +250 ``=>`` infix Left Arrow function (x => expr) +200 ``**`` infix Right Exponentiation operator +100 ``is`` infix Left Twig tests + ``is not`` Twig tests +60 ``*`` infix Left + ``/`` + ``//`` Floor division + ``%`` +50 => 70 ``not`` prefix n/a +40 => 27 ``~`` infix Left +30 ``+`` infix Left + ``-`` +25 ``..`` infix Left +20 ``==`` infix Left + ``!=`` + ``<=>`` + ``<`` + ``>`` + ``>=`` + ``<=`` + ``not in`` + ``in`` + ``matches`` + ``starts with`` + ``ends with`` + ``has some`` + ``has every`` +18 ``b-and`` infix Left +17 ``b-xor`` infix Left +16 ``b-or`` infix Left +15 ``and`` infix Left +12 ``xor`` infix Left +10 ``or`` infix Left +5 ``?:`` infix Right Elvis operator (a ?: b) + ``?:`` Elvis operator (a ?: b) +0 ``(`` prefix n/a Explicit group expression (a) + ``literal`` A literal value (boolean, string, number, sequence, mapping, ...) + ``?`` infix Left Conditional operator (a ? b : c) +=========== ================ ======= ============= =========== + +When a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``. + +Here is the same table for Twig 4.0 with adjusted precedences: + +=========== ============== ======= ============= =========== +Precedence Operator Type Associativity Description +=========== ============== ======= ============= =========== +512 `(` infix Left Twig function call + `.` Get an attribute on a variable + `[` Array access +500 `-` prefix n/a + `+` +300 `|` infix Left Twig filter call +250 `=>` infix Left Arrow function (x => expr) +200 `**` infix Right Exponentiation operator +100 `is` infix Left Twig tests + `is not` Twig tests +70 `not` prefix n/a +60 `*` infix Left + `/` + `//` Floor division + `%` +30 `+` infix Left + `-` +27 `~` infix Left +25 `..` infix Left +20 `==` infix Left + `!=` + `<=>` + `<` + `>` + `>=` + `<=` + `not in` + `in` + `matches` + `starts with` + `ends with` + `has some` + `has every` +18 `b-and` infix Left +17 `b-xor` infix Left +16 `b-or` infix Left +15 `and` infix Left +12 `xor` infix Left +10 `or` infix Left +5 `??` infix Right Null coalescing operator (a ?? b) + `?:` Elvis operator (a ?: b) + `?:` Elvis operator (a ?: b) +0 `(` prefix n/a Explicit group expression (a) + `literal` A literal value (boolean, string, number, sequence, mapping, ...) + `?` infix Left Conditional operator (a ? b : c) +=========== ============== ======= ============= =========== diff --git a/doc/templates.rst b/doc/templates.rst index 7bf2d15f591..33a32e89e1a 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -186,28 +186,6 @@ filters. {{ ('HELLO' ~ 'FABIEN')|lower }} - A common mistake is to forget using parentheses for filters on negative - numbers as a negative number in Twig is represented by the ``-`` operator - followed by a positive number. As the ``-`` operator has a lower precedence - than the filter operator, it can lead to confusion: - - .. code-block:: twig - - {{ -1|abs }} {# returns -1 #} - {{ -1**0 }} {# returns -1 #} - - {# as it is equivalent to #} - - {{ -(1|abs) }} - {{ -(1**0) }} - - For such cases, use parentheses to force the precedence: - - .. code-block:: twig - - {{ (-1)|abs }} {# returns 1 as expected #} - {{ (-1)**0 }} {# returns 1 as expected #} - Functions --------- @@ -703,14 +681,16 @@ Twig allows you to do math in templates; the following operators are supported: ``4``. * ``//``: Divides two numbers and returns the floored integer result. ``{{ 20 - // 7 }}`` is ``2``, ``{{ -20 // 7 }}`` is ``-3`` (this is just syntactic + // 7 }}`` is ``2``, ``{{ -20 // 7 }}`` is ``-3`` (this is just syntactic sugar for the :doc:`round` filter). * ``*``: Multiplies the left operand with the right one. ``{{ 2 * 2 }}`` would return ``4``. * ``**``: Raises the left operand to the power of the right operand. ``{{ 2 ** - 3 }}`` would return ``8``. + 3 }}`` would return ``8``. Be careful as the ``**`` operator is right + associative, which means that ``{{ -1**0 }}`` is equivalent to ``{{ -(1**0) + }}`` and not ``{{ (-1)**0 }}``. .. _template_logic: @@ -1033,35 +1013,9 @@ Understanding the precedence of these operators is crucial for writing correct and efficient Twig templates. The operator precedence rules are as follows, with the lowest-precedence -operators listed first: - -============================= =================================== ===================================================== -Operator Score of precedence Description -============================= =================================== ===================================================== -``?:`` 0 Ternary operator, conditional statement -``or`` 10 Logical OR operation between two boolean expressions -``xor`` 12 Logical XOR operation between two boolean expressions -``and`` 15 Logical AND operation between two boolean expressions -``b-or`` 16 Bitwise OR operation on integers -``b-xor`` 17 Bitwise XOR operation on integers -``b-and`` 18 Bitwise AND operation on integers -``==``, ``!=``, ``<=>``, 20 Comparison operators -``<``, ``>``, ``>=``, -``<=``, ``not in``, ``in``, -``matches``, ``starts with``, -``ends with``, ``has some``, -``has every`` -``..`` 25 Range of values -``+``, ``-`` 30 Addition and subtraction on numbers -``~`` 40 String concatenation -``not`` 50 Negates a statement -``*``, ``/``, ``//``, ``%`` 60 Arithmetic operations on numbers -``is``, ``is not`` 100 Tests -``**`` 200 Raises a number to the power of another -``??`` 300 Default value when a variable is null -``+``, ``-`` 500 Unary operations on numbers -``|``,``[]``,``.`` - Filters, sequence, mapping, and attribute access -============================= =================================== ===================================================== +operators listed first. + +.. include:: operators_precedence.rst Without using any parentheses, the operator precedence rules are used to determine how to convert the code to PHP: diff --git a/extra/cache-extra/TokenParser/CacheTokenParser.php b/extra/cache-extra/TokenParser/CacheTokenParser.php index dcc2ddd288f..dc33b1f5c5e 100644 --- a/extra/cache-extra/TokenParser/CacheTokenParser.php +++ b/extra/cache-extra/TokenParser/CacheTokenParser.php @@ -24,14 +24,13 @@ class CacheTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $expressionParser = $this->parser->getExpressionParser(); - $key = $expressionParser->parseExpression(); + $key = $this->parser->parseExpression(); $ttl = null; $tags = null; while ($stream->test(Token::NAME_TYPE)) { $k = $stream->getCurrent()->getValue(); - if (!in_array($k, ['ttl', 'tags'])) { + if (!\in_array($k, ['ttl', 'tags'])) { throw new SyntaxError(\sprintf('Unknown "%s" configuration.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); } @@ -41,7 +40,7 @@ public function parse(Token $token): Node if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { throw new SyntaxError(\sprintf('The "%s" modifier takes exactly one argument (0 given).', $k), $line, $stream->getSourceContext()); } - $arg = $expressionParser->parseExpression(); + $arg = $this->parser->parseExpression(); if ($stream->test(Token::PUNCTUATION_TYPE, ',')) { throw new SyntaxError(\sprintf('The "%s" modifier takes exactly one argument (2 given).', $k), $line, $stream->getSourceContext()); } diff --git a/extra/cache-extra/composer.json b/extra/cache-extra/composer.json index 4ae0621cd4d..cd7919eddb0 100644 --- a/extra/cache-extra/composer.json +++ b/extra/cache-extra/composer.json @@ -17,7 +17,7 @@ "require": { "php": ">=8.1.0", "symfony/cache": "^5.4|^6.4|^7.0", - "twig/twig": "^3.19|^4.0" + "twig/twig": "^3.20|^4.0" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0" diff --git a/src/Environment.php b/src/Environment.php index 6e00b3d3b24..46e0f3da972 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -19,6 +19,7 @@ use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParsers; use Twig\Extension\CoreExtension; use Twig\Extension\EscaperExtension; use Twig\Extension\ExtensionInterface; @@ -27,8 +28,6 @@ use Twig\Loader\ArrayLoader; use Twig\Loader\ChainLoader; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\NodeVisitor\NodeVisitorInterface; @@ -925,22 +924,10 @@ public function mergeGlobals(array $context): array /** * @internal - * - * @return array}> - */ - public function getUnaryOperators(): array - { - return $this->extensionSet->getUnaryOperators(); - } - - /** - * @internal - * - * @return array, associativity: ExpressionParser::OPERATOR_*}> */ - public function getBinaryOperators(): array + public function getExpressionParsers(): ExpressionParsers { - return $this->extensionSet->getBinaryOperators(); + return $this->extensionSet->getExpressionParsers(); } private function updateOptionsHash(): void diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index 233139ee4ab..60ebcb66787 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -12,28 +12,17 @@ namespace Twig; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Error\SyntaxError; -use Twig\Node\EmptyNode; -use Twig\Node\Expression\AbstractExpression; +use Twig\ExpressionParser\Infix\DotExpressionParser; +use Twig\ExpressionParser\Infix\FilterExpressionParser; +use Twig\ExpressionParser\Infix\SquareBracketExpressionParser; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\ArrowFunctionExpression; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\MacroReferenceExpression; -use Twig\Node\Expression\Ternary\ConditionalTernary; -use Twig\Node\Expression\TestExpression; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\Expression\Unary\NegUnary; -use Twig\Node\Expression\Unary\NotUnary; use Twig\Node\Expression\Unary\PosUnary; use Twig\Node\Expression\Unary\SpreadUnary; use Twig\Node\Expression\Variable\AssignContextVariable; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Expression\Variable\LocalVariable; -use Twig\Node\Expression\Variable\TemplateVariable; use Twig\Node\Node; use Twig\Node\Nodes; @@ -46,47 +35,25 @@ * @see https://en.wikipedia.org/wiki/Operator-precedence_parser * * @author Fabien Potencier + * + * @deprecated since Twig 3.21 */ class ExpressionParser { + /** + * @deprecated since Twig 3.21 + */ public const OPERATOR_LEFT = 1; + /** + * @deprecated since Twig 3.21 + */ public const OPERATOR_RIGHT = 2; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: self::OPERATOR_*}> */ - private $binaryOperators; - private $readyNodes = []; - private array $precedenceChanges = []; - private bool $deprecationCheck = true; - public function __construct( private Parser $parser, private Environment $env, ) { - $this->unaryOperators = $env->getUnaryOperators(); - $this->binaryOperators = $env->getBinaryOperators(); - - $ops = []; - foreach ($this->unaryOperators as $n => $c) { - $ops[] = $c + ['name' => $n, 'type' => 'unary']; - } - foreach ($this->binaryOperators as $n => $c) { - $ops[] = $c + ['name' => $n, 'type' => 'binary']; - } - foreach ($ops as $config) { - if (!isset($config['precedence_change'])) { - continue; - } - $name = $config['type'].'_'.$config['name']; - $min = min($config['precedence_change']->getNewPrecedence(), $config['precedence']); - $max = max($config['precedence_change']->getNewPrecedence(), $config['precedence']); - foreach ($ops as $c) { - if ($c['precedence'] > $min && $c['precedence'] < $max) { - $this->precedenceChanges[$c['type'].'_'.$c['name']][] = $name; - } - } - } + trigger_deprecation('twig/twig', '3.21', 'Class "%s" is deprecated, use "Parser::parseExpression()" instead.', __CLASS__); } public function parseExpression($precedence = 0) @@ -95,416 +62,78 @@ public function parseExpression($precedence = 0) trigger_deprecation('twig/twig', '3.15', 'Passing a second argument ($allowArrow) to "%s()" is deprecated.', __METHOD__); } - if ($arrow = $this->parseArrow()) { - return $arrow; - } - - $expr = $this->getPrimary(); - $token = $this->parser->getCurrentToken(); - while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) { - $op = $this->binaryOperators[$token->getValue()]; - $this->parser->getStream()->next(); - - if ('is not' === $token->getValue()) { - $expr = $this->parseNotTestExpression($expr); - } elseif ('is' === $token->getValue()) { - $expr = $this->parseTestExpression($expr); - } elseif (isset($op['callable'])) { - $expr = $op['callable']($this->parser, $expr); - } else { - $previous = $this->setDeprecationCheck(true); - try { - $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); - } finally { - $this->setDeprecationCheck($previous); - } - $class = $op['class']; - $expr = new $class($expr, $expr1, $token->getLine()); - } - - $expr->setAttribute('operator', 'binary_'.$token->getValue()); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated, use "Parser::parseExpression()" instead.', __METHOD__); - $this->triggerPrecedenceDeprecations($expr); - - $token = $this->parser->getCurrentToken(); - } - - if (0 === $precedence) { - return $this->parseConditionalExpression($expr); - } - - return $expr; - } - - private function triggerPrecedenceDeprecations(AbstractExpression $expr): void - { - // Check that the all nodes that are between the 2 precedences have explicit parentheses - if (!$expr->hasAttribute('operator') || !isset($this->precedenceChanges[$expr->getAttribute('operator')])) { - return; - } - - if (str_starts_with($unaryOp = $expr->getAttribute('operator'), 'unary')) { - if ($expr->hasExplicitParentheses()) { - return; - } - $target = explode('_', $unaryOp)[1]; - /** @var AbstractExpression $node */ - $node = $expr->getNode('node'); - foreach ($this->precedenceChanges as $operatorName => $changes) { - if (!\in_array($unaryOp, $changes)) { - continue; - } - if ($node->hasAttribute('operator') && $operatorName === $node->getAttribute('operator')) { - $change = $this->unaryOperators[$target]['precedence_change']; - trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" unary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $target, $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); - } - } - } else { - foreach ($this->precedenceChanges[$expr->getAttribute('operator')] as $operatorName) { - foreach ($expr as $node) { - /** @var AbstractExpression $node */ - if ($node->hasAttribute('operator') && $operatorName === $node->getAttribute('operator') && !$node->hasExplicitParentheses()) { - $op = explode('_', $operatorName)[1]; - $change = $this->binaryOperators[$op]['precedence_change']; - trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" binary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $op, $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); - } - } - } - } + return $this->parser->parseExpression((int) $precedence); } /** - * @return ArrowFunctionExpression|null + * @deprecated since Twig 3.21 */ - private function parseArrow() - { - $stream = $this->parser->getStream(); - - // short array syntax (one argument, no parentheses)? - if ($stream->look(1)->test(Token::ARROW_TYPE)) { - $line = $stream->getCurrent()->getLine(); - $token = $stream->expect(Token::NAME_TYPE); - $names = [new AssignContextVariable($token->getValue(), $token->getLine())]; - $stream->expect(Token::ARROW_TYPE); - - return new ArrowFunctionExpression($this->parseExpression(), new Nodes($names), $line); - } - - // first, determine if we are parsing an arrow function by finding => (long form) - $i = 0; - if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, '(')) { - return null; - } - ++$i; - while (true) { - // variable name - ++$i; - if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ',')) { - break; - } - ++$i; - } - if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ')')) { - return null; - } - ++$i; - if (!$stream->look($i)->test(Token::ARROW_TYPE)) { - return null; - } - - // yes, let's parse it properly - $token = $stream->expect(Token::PUNCTUATION_TYPE, '('); - $line = $token->getLine(); - - $names = []; - while (true) { - $token = $stream->expect(Token::NAME_TYPE); - $names[] = new AssignContextVariable($token->getValue(), $token->getLine()); - - if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { - break; - } - } - $stream->expect(Token::PUNCTUATION_TYPE, ')'); - $stream->expect(Token::ARROW_TYPE); - - return new ArrowFunctionExpression($this->parseExpression(), new Nodes($names), $line); - } - - private function getPrimary(): AbstractExpression - { - $token = $this->parser->getCurrentToken(); - - if ($this->isUnary($token)) { - $operator = $this->unaryOperators[$token->getValue()]; - $this->parser->getStream()->next(); - $expr = $this->parseExpression($operator['precedence']); - $class = $operator['class']; - - $expr = new $class($expr, $token->getLine()); - $expr->setAttribute('operator', 'unary_'.$token->getValue()); - - if ($this->deprecationCheck) { - $this->triggerPrecedenceDeprecations($expr); - } - - return $this->parsePostfixExpression($expr); - } elseif ($token->test(Token::PUNCTUATION_TYPE, '(')) { - $this->parser->getStream()->next(); - $previous = $this->setDeprecationCheck(false); - try { - $expr = $this->parseExpression()->setExplicitParentheses(); - } finally { - $this->setDeprecationCheck($previous); - } - $this->parser->getStream()->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); - - return $this->parsePostfixExpression($expr); - } - - return $this->parsePrimaryExpression(); - } - - private function parseConditionalExpression($expr): AbstractExpression - { - while ($this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, '?')) { - $expr2 = $this->parseExpression(); - if ($this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ':')) { - // Ternary operator (expr ? expr2 : expr3) - $expr3 = $this->parseExpression(); - } else { - // Ternary without else (expr ? expr2) - $expr3 = new ConstantExpression('', $this->parser->getCurrentToken()->getLine()); - } - - $expr = new ConditionalTernary($expr, $expr2, $expr3, $this->parser->getCurrentToken()->getLine()); - } - - return $expr; - } - - private function isUnary(Token $token): bool - { - return $token->test(Token::OPERATOR_TYPE) && isset($this->unaryOperators[$token->getValue()]); - } - - private function isBinary(Token $token): bool - { - return $token->test(Token::OPERATOR_TYPE) && isset($this->binaryOperators[$token->getValue()]); - } - public function parsePrimaryExpression() { - $token = $this->parser->getCurrentToken(); - switch (true) { - case $token->test(Token::NAME_TYPE): - $this->parser->getStream()->next(); - switch ($token->getValue()) { - case 'true': - case 'TRUE': - $node = new ConstantExpression(true, $token->getLine()); - break; - - case 'false': - case 'FALSE': - $node = new ConstantExpression(false, $token->getLine()); - break; - - case 'none': - case 'NONE': - case 'null': - case 'NULL': - $node = new ConstantExpression(null, $token->getLine()); - break; - - default: - if ('(' === $this->parser->getCurrentToken()->getValue()) { - $node = $this->getFunctionNode($token->getValue(), $token->getLine()); - } else { - $node = new ContextVariable($token->getValue(), $token->getLine()); - } - } - break; - - case $token->test(Token::NUMBER_TYPE): - $this->parser->getStream()->next(); - $node = new ConstantExpression($token->getValue(), $token->getLine()); - break; - - case $token->test(Token::STRING_TYPE): - case $token->test(Token::INTERPOLATION_START_TYPE): - $node = $this->parseStringExpression(); - break; + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - case $token->test(Token::PUNCTUATION_TYPE): - $node = match ($token->getValue()) { - '[' => $this->parseSequenceExpression(), - '{' => $this->parseMappingExpression(), - default => throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', $token->toEnglish(), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()), - }; - break; - - case $token->test(Token::OPERATOR_TYPE): - if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) { - // in this context, string operators are variable names - $this->parser->getStream()->next(); - $node = new ContextVariable($token->getValue(), $token->getLine()); - break; - } - - if ('=' === $token->getValue() && ('==' === $this->parser->getStream()->look(-1)->getValue() || '!=' === $this->parser->getStream()->look(-1)->getValue())) { - throw new SyntaxError(\sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()); - } - - // no break - default: - throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', $token->toEnglish(), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()); - } - - return $this->parsePostfixExpression($node); + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parseStringExpression() { - $stream = $this->parser->getStream(); - - $nodes = []; - // a string cannot be followed by another string in a single expression - $nextCanBeString = true; - while (true) { - if ($nextCanBeString && $token = $stream->nextIf(Token::STRING_TYPE)) { - $nodes[] = new ConstantExpression($token->getValue(), $token->getLine()); - $nextCanBeString = false; - } elseif ($stream->nextIf(Token::INTERPOLATION_START_TYPE)) { - $nodes[] = $this->parseExpression(); - $stream->expect(Token::INTERPOLATION_END_TYPE); - $nextCanBeString = true; - } else { - break; - } - } - - $expr = array_shift($nodes); - foreach ($nodes as $node) { - $expr = new ConcatBinary($expr, $node, $node->getTemplateLine()); - } + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - return $expr; + return $this->parseExpression(); } /** - * @deprecated since Twig 3.11, use parseSequenceExpression() instead + * @deprecated since Twig 3.11, use parseExpression() instead */ public function parseArrayExpression() { - trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseSequenceExpression()" instead.', __METHOD__); + trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); - return $this->parseSequenceExpression(); + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parseSequenceExpression() { - $stream = $this->parser->getStream(); - $stream->expect(Token::PUNCTUATION_TYPE, '[', 'A sequence element was expected'); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - $node = new ArrayExpression([], $stream->getCurrent()->getLine()); - $first = true; - while (!$stream->test(Token::PUNCTUATION_TYPE, ']')) { - if (!$first) { - $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A sequence element must be followed by a comma'); - - // trailing ,? - if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { - break; - } - } - $first = false; - - if ($stream->nextIf(Token::SPREAD_TYPE)) { - $expr = $this->parseExpression(); - $expr->setAttribute('spread', true); - $node->addElement($expr); - } else { - $node->addElement($this->parseExpression()); - } - } - $stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened sequence is not properly closed'); - - return $node; + return $this->parseExpression(); } /** - * @deprecated since Twig 3.11, use parseMappingExpression() instead + * @deprecated since Twig 3.11, use parseExpression() instead */ public function parseHashExpression() { - trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseMappingExpression()" instead.', __METHOD__); + trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); - return $this->parseMappingExpression(); + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parseMappingExpression() { - $stream = $this->parser->getStream(); - $stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected'); - - $node = new ArrayExpression([], $stream->getCurrent()->getLine()); - $first = true; - while (!$stream->test(Token::PUNCTUATION_TYPE, '}')) { - if (!$first) { - $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A mapping value must be followed by a comma'); - - // trailing ,? - if ($stream->test(Token::PUNCTUATION_TYPE, '}')) { - break; - } - } - $first = false; + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - if ($stream->nextIf(Token::SPREAD_TYPE)) { - $value = $this->parseExpression(); - $value->setAttribute('spread', true); - $node->addElement($value); - continue; - } - - // a mapping key can be: - // - // * a number -- 12 - // * a string -- 'a' - // * a name, which is equivalent to a string -- a - // * an expression, which must be enclosed in parentheses -- (1 + 2) - if ($token = $stream->nextIf(Token::NAME_TYPE)) { - $key = new ConstantExpression($token->getValue(), $token->getLine()); - - // {a} is a shortcut for {a:a} - if ($stream->test(Token::PUNCTUATION_TYPE, [',', '}'])) { - $value = new ContextVariable($key->getAttribute('value'), $key->getTemplateLine()); - $node->addElement($value, $key); - continue; - } - } elseif (($token = $stream->nextIf(Token::STRING_TYPE)) || $token = $stream->nextIf(Token::NUMBER_TYPE)) { - $key = new ConstantExpression($token->getValue(), $token->getLine()); - } elseif ($stream->test(Token::PUNCTUATION_TYPE, '(')) { - $key = $this->parseExpression(); - } else { - $current = $stream->getCurrent(); - - throw new SyntaxError(\sprintf('A mapping key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', $current->toEnglish(), $current->getValue()), $current->getLine(), $stream->getSourceContext()); - } - - $stream->expect(Token::PUNCTUATION_TYPE, ':', 'A mapping key must be followed by a colon (:)'); - $value = $this->parseExpression(); - - $node->addElement($value, $key); - } - $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed'); - - return $node; + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parsePostfixExpression($node) { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + while (true) { $token = $this->parser->getCurrentToken(); if ($token->test(Token::PUNCTUATION_TYPE)) { @@ -523,81 +152,49 @@ public function parsePostfixExpression($node) return $node; } - public function getFunctionNode($name, $line) + /** + * @deprecated since Twig 3.21 + */ + public function parseSubscriptExpression($node) { - if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) { - return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->createArguments($line), $line); - } - - $args = $this->parseNamedArguments(); - $function = $this->getFunction($name, $line); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - if ($function->getParserCallable()) { - $fakeNode = new EmptyNode($line); - $fakeNode->setSourceContext($this->parser->getStream()->getSourceContext()); + $parsers = new \ReflectionProperty($this->parser, 'parsers'); - return ($function->getParserCallable())($this->parser, $fakeNode, $args, $line); - } - - if (!isset($this->readyNodes[$class = $function->getNodeClass()])) { - $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); - } - - if (!$ready = $this->readyNodes[$class]) { - trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFunction" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); - } - - return new $class($ready ? $function : $function->getName(), $args, $line); - } - - public function parseSubscriptExpression($node) - { if ('.' === $this->parser->getStream()->next()->getValue()) { - return $this->parseSubscriptExpressionDot($node); + return $parsers->getValue($this->parser)->getByClass(DotExpressionParser::class)->parse($this->parser, $node, $this->parser->getCurrentToken()); } - return $this->parseSubscriptExpressionArray($node); + return $parsers->getValue($this->parser)->getByClass(SquareBracketExpressionParser::class)->parse($this->parser, $node, $this->parser->getCurrentToken()); } + /** + * @deprecated since Twig 3.21 + */ public function parseFilterExpression($node) { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + $this->parser->getStream()->next(); return $this->parseFilterExpressionRaw($node); } + /** + * @deprecated since Twig 3.21 + */ public function parseFilterExpressionRaw($node) { - if (\func_num_args() > 1) { - trigger_deprecation('twig/twig', '3.12', 'Passing a second argument to "%s()" is deprecated.', __METHOD__); - } - - while (true) { - $token = $this->parser->getStream()->expect(Token::NAME_TYPE); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '(')) { - $arguments = new EmptyNode(); - } else { - $arguments = $this->parseNamedArguments(); - } + $parsers = new \ReflectionProperty($this->parser, 'parsers'); - $filter = $this->getFilter($token->getValue(), $token->getLine()); - - $ready = true; - if (!isset($this->readyNodes[$class = $filter->getNodeClass()])) { - $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); - } - - if (!$ready = $this->readyNodes[$class]) { - trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFilter" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); - } - - $node = new $class($node, $ready ? $filter : new ConstantExpression($filter->getName(), $token->getLine()), $arguments, $token->getLine()); - - if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '|')) { + $op = $parsers->getValue($this->parser)->getByClass(FilterExpressionParser::class); + while (true) { + $node = $op->parse($this->parser, $node, $this->parser->getCurrentToken()); + if (!$this->parser->getStream()->test(Token::OPERATOR_TYPE, '|')) { break; } - $this->parser->getStream()->next(); } @@ -611,11 +208,13 @@ public function parseFilterExpressionRaw($node) * * @throws SyntaxError * - * @deprecated since Twig 3.19 Use parseNamedArguments() instead + * @deprecated since Twig 3.19 Use Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments() instead */ public function parseArguments() { - trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "%s::parseNamedArguments()" instead.', __METHOD__, __CLASS__)); + trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()" instead.', __METHOD__)); + + $parsePrimary = new \ReflectionMethod($this->parser, 'parsePrimary'); $namedArguments = false; $definition = false; @@ -630,7 +229,7 @@ public function parseArguments() $args = []; $stream = $this->parser->getStream(); - $stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); $hasSpread = false; while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { if ($args) { @@ -664,7 +263,7 @@ public function parseArguments() $name = $value->getAttribute('name'); if ($definition) { - $value = $this->getPrimary(); + $value = $parsePrimary->invoke($this->parser); if (!$this->checkConstantExpression($value)) { throw new SyntaxError('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping).', $token->getLine(), $stream->getSourceContext()); @@ -694,8 +293,13 @@ public function parseArguments() return new Nodes($args); } + /** + * @deprecated since Twig 3.21, use "AbstractTokenParser::parseAssignmentExpression()" instead + */ public function parseAssignmentExpression() { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated, use "AbstractTokenParser::parseAssignmentExpression()" instead.', __METHOD__); + $stream = $this->parser->getStream(); $targets = []; while (true) { @@ -716,8 +320,13 @@ public function parseAssignmentExpression() return new Nodes($targets); } + /** + * @deprecated since Twig 3.21 + */ public function parseMultitargetExpression() { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + $targets = []; while (true) { $targets[] = $this->parseExpression(); @@ -729,133 +338,6 @@ public function parseMultitargetExpression() return new Nodes($targets); } - private function parseNotTestExpression(Node $node): NotUnary - { - return new NotUnary($this->parseTestExpression($node), $this->parser->getCurrentToken()->getLine()); - } - - private function parseTestExpression(Node $node): TestExpression - { - $stream = $this->parser->getStream(); - $test = $this->getTest($node->getTemplateLine()); - - $arguments = null; - if ($stream->test(Token::PUNCTUATION_TYPE, '(')) { - $arguments = $this->parseNamedArguments(); - } elseif ($test->hasOneMandatoryArgument()) { - $arguments = new Nodes([0 => $this->getPrimary()]); - } - - if ('defined' === $test->getName() && $node instanceof ContextVariable && null !== $alias = $this->parser->getImportedSymbol('function', $node->getAttribute('name'))) { - $node = new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], new ArrayExpression([], $node->getTemplateLine()), $node->getTemplateLine()); - } - - $ready = $test instanceof TwigTest; - if (!isset($this->readyNodes[$class = $test->getNodeClass()])) { - $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); - } - - if (!$ready = $this->readyNodes[$class]) { - trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigTest" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); - } - - return new $class($node, $ready ? $test : $test->getName(), $arguments, $this->parser->getCurrentToken()->getLine()); - } - - private function getTest(int $line): TwigTest - { - $stream = $this->parser->getStream(); - $name = $stream->expect(Token::NAME_TYPE)->getValue(); - - if ($stream->test(Token::NAME_TYPE)) { - // try 2-words tests - $name = $name.' '.$this->parser->getCurrentToken()->getValue(); - - if ($test = $this->env->getTest($name)) { - $stream->next(); - } - } else { - $test = $this->env->getTest($name); - } - - if (!$test) { - if ($this->parser->shouldIgnoreUnknownTwigCallables()) { - return new TwigTest($name, fn () => ''); - } - $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $stream->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getTests())); - - throw $e; - } - - if ($test->isDeprecated()) { - $stream = $this->parser->getStream(); - $src = $stream->getSourceContext(); - $test->triggerDeprecation($src->getPath() ?: $src->getName(), $stream->getCurrent()->getLine()); - } - - return $test; - } - - private function getFunction(string $name, int $line): TwigFunction - { - try { - $function = $this->env->getFunction($name); - } catch (SyntaxError $e) { - if (!$this->parser->shouldIgnoreUnknownTwigCallables()) { - throw $e; - } - - $function = null; - } - - if (!$function) { - if ($this->parser->shouldIgnoreUnknownTwigCallables()) { - return new TwigFunction($name, fn () => ''); - } - $e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->parser->getStream()->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getFunctions())); - - throw $e; - } - - if ($function->isDeprecated()) { - $src = $this->parser->getStream()->getSourceContext(); - $function->triggerDeprecation($src->getPath() ?: $src->getName(), $line); - } - - return $function; - } - - private function getFilter(string $name, int $line): TwigFilter - { - try { - $filter = $this->env->getFilter($name); - } catch (SyntaxError $e) { - if (!$this->parser->shouldIgnoreUnknownTwigCallables()) { - throw $e; - } - - $filter = null; - } - if (!$filter) { - if ($this->parser->shouldIgnoreUnknownTwigCallables()) { - return new TwigFilter($name, fn () => ''); - } - $e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->parser->getStream()->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getFilters())); - - throw $e; - } - - if ($filter->isDeprecated()) { - $src = $this->parser->getStream()->getSourceContext(); - $filter->triggerDeprecation($src->getPath() ?: $src->getName(), $line); - } - - return $filter; - } - // checks that the node only contains "constant" elements // to be removed in 4.0 private function checkConstantExpression(Node $node): bool @@ -875,159 +357,13 @@ private function checkConstantExpression(Node $node): bool return true; } - private function setDeprecationCheck(bool $deprecationCheck): bool - { - $current = $this->deprecationCheck; - $this->deprecationCheck = $deprecationCheck; - - return $current; - } - - private function createArguments(int $line): ArrayExpression - { - $arguments = new ArrayExpression([], $line); - foreach ($this->parseNamedArguments() as $k => $n) { - $arguments->addElement($n, new LocalVariable($k, $line)); - } - - return $arguments; - } - /** - * @deprecated since Twig 3.19 Use parseNamedArguments() instead + * @deprecated since Twig 3.19 Use Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments() instead */ public function parseOnlyArguments() { - trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "%s::parseNamedArguments()" instead.', __METHOD__, __CLASS__)); - - return $this->parseNamedArguments(); - } - - public function parseNamedArguments(): Nodes - { - $args = []; - $stream = $this->parser->getStream(); - $stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); - $hasSpread = false; - while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { - if ($args) { - $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); - - // if the comma above was a trailing comma, early exit the argument parse loop - if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { - break; - } - } - - if ($stream->nextIf(Token::SPREAD_TYPE)) { - $hasSpread = true; - $value = new SpreadUnary($this->parseExpression(), $stream->getCurrent()->getLine()); - } elseif ($hasSpread) { - throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); - } else { - $value = $this->parseExpression(); - } - - $name = null; - if (($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) || ($token = $stream->nextIf(Token::PUNCTUATION_TYPE, ':'))) { - if (!$value instanceof ContextVariable) { - throw new SyntaxError(\sprintf('A parameter name must be a string, "%s" given.', \get_class($value)), $token->getLine(), $stream->getSourceContext()); - } - $name = $value->getAttribute('name'); - $value = $this->parseExpression(); - } - - if (null === $name) { - $args[] = $value; - } else { - $args[$name] = $value; - } - } - $stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); - - return new Nodes($args); - } - - private function parseSubscriptExpressionDot(Node $node): AbstractExpression - { - $stream = $this->parser->getStream(); - $token = $stream->getCurrent(); - $lineno = $token->getLine(); - $arguments = new ArrayExpression([], $lineno); - $type = Template::ANY_CALL; - - if ($stream->nextIf(Token::PUNCTUATION_TYPE, '(')) { - $attribute = $this->parseExpression(); - $stream->expect(Token::PUNCTUATION_TYPE, ')'); - } else { - $token = $stream->next(); - if ( - $token->test(Token::NAME_TYPE) - || $token->test(Token::NUMBER_TYPE) - || ($token->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) - ) { - $attribute = new ConstantExpression($token->getValue(), $token->getLine()); - } else { - throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); - } - } - - if ($stream->test(Token::PUNCTUATION_TYPE, '(')) { - $type = Template::METHOD_CALL; - $arguments = $this->createArguments($token->getLine()); - } - - if ( - $node instanceof ContextVariable - && ( - null !== $this->parser->getImportedSymbol('template', $node->getAttribute('name')) - || '_self' === $node->getAttribute('name') && $attribute instanceof ConstantExpression - ) - ) { - return new MacroReferenceExpression(new TemplateVariable($node->getAttribute('name'), $node->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $node->getTemplateLine()); - } - - return new GetAttrExpression($node, $attribute, $arguments, $type, $lineno); - } - - private function parseSubscriptExpressionArray(Node $node): AbstractExpression - { - $stream = $this->parser->getStream(); - $token = $stream->getCurrent(); - $lineno = $token->getLine(); - $arguments = new ArrayExpression([], $lineno); - - // slice? - $slice = false; - if ($stream->test(Token::PUNCTUATION_TYPE, ':')) { - $slice = true; - $attribute = new ConstantExpression(0, $token->getLine()); - } else { - $attribute = $this->parseExpression(); - } - - if ($stream->nextIf(Token::PUNCTUATION_TYPE, ':')) { - $slice = true; - } - - if ($slice) { - if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { - $length = new ConstantExpression(null, $token->getLine()); - } else { - $length = $this->parseExpression(); - } - - $filter = $this->getFilter('slice', $token->getLine()); - $arguments = new Nodes([$attribute, $length]); - $filter = new ($filter->getNodeClass())($node, $filter, $arguments, $token->getLine()); - - $stream->expect(Token::PUNCTUATION_TYPE, ']'); - - return $filter; - } - - $stream->expect(Token::PUNCTUATION_TYPE, ']'); + trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()" instead.', __METHOD__)); - return new GetAttrExpression($node, $attribute, $arguments, Template::ARRAY_CALL, $lineno); + return $this->parseArguments(); } } diff --git a/src/ExpressionParser/AbstractExpressionParser.php b/src/ExpressionParser/AbstractExpressionParser.php new file mode 100644 index 00000000000..bc05bfa051e --- /dev/null +++ b/src/ExpressionParser/AbstractExpressionParser.php @@ -0,0 +1,30 @@ +value, $this->getName()); + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return null; + } + + public function getAliases(): array + { + return []; + } +} diff --git a/src/ExpressionParser/ExpressionParserDescriptionInterface.php b/src/ExpressionParser/ExpressionParserDescriptionInterface.php new file mode 100644 index 00000000000..686f8a59f1e --- /dev/null +++ b/src/ExpressionParser/ExpressionParserDescriptionInterface.php @@ -0,0 +1,17 @@ + + */ + public function getAliases(): array; +} diff --git a/src/ExpressionParser/ExpressionParserType.php b/src/ExpressionParser/ExpressionParserType.php new file mode 100644 index 00000000000..8c21a8d7633 --- /dev/null +++ b/src/ExpressionParser/ExpressionParserType.php @@ -0,0 +1,33 @@ + + * + * @internal + */ +final class ExpressionParsers implements \IteratorAggregate +{ + /** + * @var array, array> + */ + private array $parsersByName = []; + + /** + * @var array, ExpressionParserInterface> + */ + private array $parsersByClass = []; + + /** + * @var \WeakMap>|null + */ + private ?\WeakMap $precedenceChanges = null; + + /** + * @param array $parsers + */ + public function __construct(array $parsers = []) + { + $this->add($parsers); + } + + /** + * @param array $parsers + * + * @return $this + */ + public function add(array $parsers): static + { + foreach ($parsers as $parser) { + if ($parser->getPrecedence() > 512 || $parser->getPrecedence() < 0) { + trigger_deprecation('twig/twig', '3.21', 'Precedence for "%s" must be between 0 and 512, got %d.', $parser->getName(), $parser->getPrecedence()); + // 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; + } + } + + return $this; + } + + /** + * @template T of ExpressionParserInterface + * + * @param class-string $class + * + * @return T|null + */ + public function getByClass(string $class): ?ExpressionParserInterface + { + return $this->parsersByClass[$class] ?? null; + } + + /** + * @template T of ExpressionParserInterface + * + * @param class-string $interface + * + * @return T|null + */ + public function getByName(string $interface, string $name): ?ExpressionParserInterface + { + return $this->parsersByName[$interface][$name] ?? null; + } + + public function getIterator(): \Traversable + { + foreach ($this->parsersByName as $parsers) { + // we don't yield the keys + yield from $parsers; + } + } + + /** + * @internal + * + * @return \WeakMap> + */ + public function getPrecedenceChanges(): \WeakMap + { + if (null === $this->precedenceChanges) { + $this->precedenceChanges = new \WeakMap(); + foreach ($this as $ep) { + if (!$ep->getPrecedenceChange()) { + continue; + } + $min = min($ep->getPrecedenceChange()->getNewPrecedence(), $ep->getPrecedence()); + $max = max($ep->getPrecedenceChange()->getNewPrecedence(), $ep->getPrecedence()); + foreach ($this as $e) { + if ($e->getPrecedence() > $min && $e->getPrecedence() < $max) { + if (!isset($this->precedenceChanges[$e])) { + $this->precedenceChanges[$e] = []; + } + $this->precedenceChanges[$e][] = $ep; + } + } + } + } + + return $this->precedenceChanges; + } +} diff --git a/src/ExpressionParser/Infix/ArgumentsTrait.php b/src/ExpressionParser/Infix/ArgumentsTrait.php new file mode 100644 index 00000000000..185ec51a0b5 --- /dev/null +++ b/src/ExpressionParser/Infix/ArgumentsTrait.php @@ -0,0 +1,81 @@ +parseNamedArguments($parser, $parseOpenParenthesis) as $k => $n) { + $arguments->addElement($n, new LocalVariable($k, $line)); + } + + return $arguments; + } + + private function parseNamedArguments(Parser $parser, bool $parseOpenParenthesis = true): Nodes + { + $args = []; + $stream = $parser->getStream(); + if ($parseOpenParenthesis) { + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + } + $hasSpread = false; + while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { + if ($args) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); + + // if the comma above was a trailing comma, early exit the argument parse loop + if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { + break; + } + } + + if ($stream->nextIf(Token::SPREAD_TYPE)) { + $hasSpread = true; + $value = new SpreadUnary($parser->parseExpression(), $stream->getCurrent()->getLine()); + } elseif ($hasSpread) { + throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } else { + $value = $parser->parseExpression(); + } + + $name = null; + if (($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) || ($token = $stream->nextIf(Token::PUNCTUATION_TYPE, ':'))) { + if (!$value instanceof ContextVariable) { + throw new SyntaxError(\sprintf('A parameter name must be a string, "%s" given.', $value::class), $token->getLine(), $stream->getSourceContext()); + } + $name = $value->getAttribute('name'); + $value = $parser->parseExpression(); + } + + if (null === $name) { + $args[] = $value; + } else { + $args[$name] = $value; + } + } + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); + + return new Nodes($args); + } +} diff --git a/src/ExpressionParser/Infix/ArrowExpressionParser.php b/src/ExpressionParser/Infix/ArrowExpressionParser.php new file mode 100644 index 00000000000..c8630da41e7 --- /dev/null +++ b/src/ExpressionParser/Infix/ArrowExpressionParser.php @@ -0,0 +1,53 @@ +parseExpression(), $expr, $token->getLine()); + } + + public function getName(): string + { + return '=>'; + } + + public function getDescription(): string + { + return 'Arrow function (x => expr)'; + } + + public function getPrecedence(): int + { + return 250; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php b/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php new file mode 100644 index 00000000000..4c66da73bc1 --- /dev/null +++ b/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php @@ -0,0 +1,80 @@ + */ + private string $nodeClass, + private string $name, + private int $precedence, + private InfixAssociativity $associativity = InfixAssociativity::Left, + private ?PrecedenceChange $precedenceChange = null, + private ?string $description = null, + private array $aliases = [], + ) { + } + + /** + * @return AbstractBinary + */ + public function parse(Parser $parser, AbstractExpression $left, Token $token): AbstractExpression + { + $right = $parser->parseExpression(InfixAssociativity::Left === $this->getAssociativity() ? $this->getPrecedence() + 1 : $this->getPrecedence()); + + return new ($this->nodeClass)($left, $right, $token->getLine()); + } + + public function getAssociativity(): InfixAssociativity + { + return $this->associativity; + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + public function getPrecedence(): int + { + return $this->precedence; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return $this->precedenceChange; + } + + public function getAliases(): array + { + return $this->aliases; + } +} diff --git a/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php b/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php new file mode 100644 index 00000000000..9707c0a04bd --- /dev/null +++ b/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php @@ -0,0 +1,62 @@ +parseExpression($this->getPrecedence()); + if ($parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ':')) { + // Ternary operator (expr ? expr2 : expr3) + $else = $parser->parseExpression($this->getPrecedence()); + } else { + // Ternary without else (expr ? expr2) + $else = new ConstantExpression('', $token->getLine()); + } + + return new ConditionalTernary($left, $then, $else, $token->getLine()); + } + + public function getName(): string + { + return '?'; + } + + public function getDescription(): string + { + return 'Conditional operator (a ? b : c)'; + } + + public function getPrecedence(): int + { + return 0; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/DotExpressionParser.php b/src/ExpressionParser/Infix/DotExpressionParser.php new file mode 100644 index 00000000000..7d1cf505827 --- /dev/null +++ b/src/ExpressionParser/Infix/DotExpressionParser.php @@ -0,0 +1,99 @@ +getStream(); + $token = $stream->getCurrent(); + $lineno = $token->getLine(); + $arguments = new ArrayExpression([], $lineno); + $type = Template::ANY_CALL; + + if ($stream->nextIf(Token::OPERATOR_TYPE, '(')) { + $attribute = $parser->parseExpression(); + $stream->expect(Token::PUNCTUATION_TYPE, ')'); + } else { + $token = $stream->next(); + if ( + $token->test(Token::NAME_TYPE) + || $token->test(Token::NUMBER_TYPE) + || ($token->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) + ) { + $attribute = new ConstantExpression($token->getValue(), $token->getLine()); + } else { + throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); + } + } + + if ($stream->test(Token::OPERATOR_TYPE, '(')) { + $type = Template::METHOD_CALL; + $arguments = $this->parseCallableArguments($parser, $token->getLine()); + } + + if ( + $expr instanceof NameExpression + && ( + null !== $parser->getImportedSymbol('template', $expr->getAttribute('name')) + || '_self' === $expr->getAttribute('name') && $attribute instanceof ConstantExpression + ) + ) { + return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine()); + } + + return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno); + } + + public function getName(): string + { + return '.'; + } + + public function getDescription(): string + { + return 'Get an attribute on a variable'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/FilterExpressionParser.php b/src/ExpressionParser/Infix/FilterExpressionParser.php new file mode 100644 index 00000000000..0bbe6b40969 --- /dev/null +++ b/src/ExpressionParser/Infix/FilterExpressionParser.php @@ -0,0 +1,85 @@ +getStream(); + $token = $stream->expect(Token::NAME_TYPE); + $line = $token->getLine(); + + if (!$stream->test(Token::OPERATOR_TYPE, '(')) { + $arguments = new EmptyNode(); + } else { + $arguments = $this->parseNamedArguments($parser); + } + + $filter = $parser->getFilter($token->getValue(), $line); + + $ready = true; + if (!isset($this->readyNodes[$class = $filter->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFilter" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); + } + + return new $class($expr, $ready ? $filter : new ConstantExpression($filter->getName(), $line), $arguments, $line); + } + + public function getName(): string + { + return '|'; + } + + public function getDescription(): string + { + return 'Twig filter call'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return new PrecedenceChange('twig/twig', '3.21', 300); + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/FunctionExpressionParser.php b/src/ExpressionParser/Infix/FunctionExpressionParser.php new file mode 100644 index 00000000000..e9cd7751793 --- /dev/null +++ b/src/ExpressionParser/Infix/FunctionExpressionParser.php @@ -0,0 +1,90 @@ +getLine(); + if (!$expr instanceof NameExpression) { + throw new SyntaxError('Function name must be an identifier.', $line, $parser->getStream()->getSourceContext()); + } + + $name = $expr->getAttribute('name'); + + if (null !== $alias = $parser->getImportedSymbol('function', $name)) { + return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false), $line); + } + + $args = $this->parseNamedArguments($parser, false); + + $function = $parser->getFunction($name, $line); + + if ($function->getParserCallable()) { + $fakeNode = new EmptyNode($line); + $fakeNode->setSourceContext($parser->getStream()->getSourceContext()); + + return ($function->getParserCallable())($parser, $fakeNode, $args, $line); + } + + if (!isset($this->readyNodes[$class = $function->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFunction" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); + } + + return new $class($ready ? $function : $function->getName(), $args, $line); + } + + public function getName(): string + { + return '('; + } + + public function getDescription(): string + { + return 'Twig function call'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/IsExpressionParser.php b/src/ExpressionParser/Infix/IsExpressionParser.php new file mode 100644 index 00000000000..88d54f70a7b --- /dev/null +++ b/src/ExpressionParser/Infix/IsExpressionParser.php @@ -0,0 +1,84 @@ +getStream(); + $test = $parser->getTest($token->getLine()); + + $arguments = null; + if ($stream->test(Token::OPERATOR_TYPE, '(')) { + $arguments = $this->parseNamedArguments($parser); + } elseif ($test->hasOneMandatoryArgument()) { + $arguments = new Nodes([0 => $parser->parseExpression($this->getPrecedence())]); + } + + if ('defined' === $test->getName() && $expr instanceof NameExpression && null !== $alias = $parser->getImportedSymbol('function', $expr->getAttribute('name'))) { + $expr = new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], new ArrayExpression([], $expr->getTemplateLine()), $expr->getTemplateLine()); + } + + $ready = $test instanceof TwigTest; + if (!isset($this->readyNodes[$class = $test->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigTest" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class); + } + + return new $class($expr, $ready ? $test : $test->getName(), $arguments, $stream->getCurrent()->getLine()); + } + + public function getPrecedence(): int + { + return 100; + } + + public function getName(): string + { + return 'is'; + } + + public function getDescription(): string + { + return 'Twig tests'; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/IsNotExpressionParser.php b/src/ExpressionParser/Infix/IsNotExpressionParser.php new file mode 100644 index 00000000000..1e1085aa835 --- /dev/null +++ b/src/ExpressionParser/Infix/IsNotExpressionParser.php @@ -0,0 +1,33 @@ +getLine()); + } + + public function getName(): string + { + return 'is not'; + } +} diff --git a/src/ExpressionParser/Infix/SquareBracketExpressionParser.php b/src/ExpressionParser/Infix/SquareBracketExpressionParser.php new file mode 100644 index 00000000000..c47c91dee36 --- /dev/null +++ b/src/ExpressionParser/Infix/SquareBracketExpressionParser.php @@ -0,0 +1,91 @@ +getStream(); + $lineno = $token->getLine(); + $arguments = new ArrayExpression([], $lineno); + + // slice? + $slice = false; + if ($stream->test(Token::PUNCTUATION_TYPE, ':')) { + $slice = true; + $attribute = new ConstantExpression(0, $token->getLine()); + } else { + $attribute = $parser->parseExpression(); + } + + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ':')) { + $slice = true; + } + + if ($slice) { + if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { + $length = new ConstantExpression(null, $token->getLine()); + } else { + $length = $parser->parseExpression(); + } + + $filter = $parser->getFilter('slice', $token->getLine()); + $arguments = new Nodes([$attribute, $length]); + $filter = new ($filter->getNodeClass())($expr, $filter, $arguments, $token->getLine()); + + $stream->expect(Token::PUNCTUATION_TYPE, ']'); + + return $filter; + } + + $stream->expect(Token::PUNCTUATION_TYPE, ']'); + + return new GetAttrExpression($expr, $attribute, $arguments, Template::ARRAY_CALL, $lineno); + } + + public function getName(): string + { + return '['; + } + + public function getDescription(): string + { + return 'Array access'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/InfixAssociativity.php b/src/ExpressionParser/InfixAssociativity.php new file mode 100644 index 00000000000..3aeccce4565 --- /dev/null +++ b/src/ExpressionParser/InfixAssociativity.php @@ -0,0 +1,18 @@ + + */ +class PrecedenceChange +{ + public function __construct( + private string $package, + private string $version, + private int $newPrecedence, + ) { + } + + public function getPackage(): string + { + return $this->package; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getNewPrecedence(): int + { + return $this->newPrecedence; + } +} diff --git a/src/ExpressionParser/Prefix/GroupingExpressionParser.php b/src/ExpressionParser/Prefix/GroupingExpressionParser.php new file mode 100644 index 00000000000..5c6608da401 --- /dev/null +++ b/src/ExpressionParser/Prefix/GroupingExpressionParser.php @@ -0,0 +1,78 @@ +getStream(); + $expr = $parser->parseExpression($this->getPrecedence()); + + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ')')) { + if (!$stream->test(Token::OPERATOR_TYPE, '=>')) { + return $expr->setExplicitParentheses(); + } + + return new ListExpression([$expr], $token->getLine()); + } + + // determine if we are parsing an arrow function arguments + if (!$stream->test(Token::PUNCTUATION_TYPE, ',')) { + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); + } + + $names = [$expr]; + while (true) { + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ')')) { + break; + } + $stream->expect(Token::PUNCTUATION_TYPE, ','); + $token = $stream->expect(Token::NAME_TYPE); + $names[] = new ContextVariable($token->getValue(), $token->getLine()); + } + + if (!$stream->test(Token::OPERATOR_TYPE, '=>')) { + throw new SyntaxError('A list of variables must be followed by an arrow.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + + return new ListExpression($names, $token->getLine()); + } + + public function getName(): string + { + return '('; + } + + public function getDescription(): string + { + return 'Explicit group expression (a)'; + } + + public function getPrecedence(): int + { + return 0; + } +} diff --git a/src/ExpressionParser/Prefix/LiteralExpressionParser.php b/src/ExpressionParser/Prefix/LiteralExpressionParser.php new file mode 100644 index 00000000000..67bae6c3241 --- /dev/null +++ b/src/ExpressionParser/Prefix/LiteralExpressionParser.php @@ -0,0 +1,251 @@ +getStream(); + switch (true) { + case $token->test(Token::NAME_TYPE): + $stream->next(); + 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): + // In 4.0, we should always return the node or throw an error for default + if ($node = match ($token->getValue()) { + '{' => $this->parseMappingExpression($parser), + default => null, + }) { + return $node; + } + + // no break + case $token->test(Token::OPERATOR_TYPE): + if ('[' === $token->getValue()) { + return $this->parseSequenceExpression($parser); + } + + 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()); + } + + if ('=' === $token->getValue() && ('==' === $stream->look(-1)->getValue() || '!=' === $stream->look(-1)->getValue())) { + throw new SyntaxError(\sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $stream->getSourceContext()); + } + + // no break + default: + throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', $token->toEnglish(), $token->getValue()), $token->getLine(), $stream->getSourceContext()); + } + } + + public function getName(): string + { + return $this->type; + } + + public function getDescription(): string + { + return 'A literal value (boolean, string, number, sequence, mapping, ...)'; + } + + public function getPrecedence(): int + { + // not used + return 0; + } + + private function parseStringExpression(Parser $parser) + { + $stream = $parser->getStream(); + + $nodes = []; + // a string cannot be followed by another string in a single expression + $nextCanBeString = true; + while (true) { + if ($nextCanBeString && $token = $stream->nextIf(Token::STRING_TYPE)) { + $nodes[] = new ConstantExpression($token->getValue(), $token->getLine()); + $nextCanBeString = false; + } elseif ($stream->nextIf(Token::INTERPOLATION_START_TYPE)) { + $nodes[] = $parser->parseExpression(); + $stream->expect(Token::INTERPOLATION_END_TYPE); + $nextCanBeString = true; + } else { + break; + } + } + + $expr = array_shift($nodes); + foreach ($nodes as $node) { + $expr = new ConcatBinary($expr, $node, $node->getTemplateLine()); + } + + return $expr; + } + + private function parseSequenceExpression(Parser $parser) + { + $this->type = 'sequence'; + + $stream = $parser->getStream(); + $stream->expect(Token::OPERATOR_TYPE, '[', 'A sequence element was expected'); + + $node = new ArrayExpression([], $stream->getCurrent()->getLine()); + $first = true; + while (!$stream->test(Token::PUNCTUATION_TYPE, ']')) { + if (!$first) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A sequence element must be followed by a comma'); + + // trailing ,? + if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { + break; + } + } + $first = false; + + if ($stream->nextIf(Token::SPREAD_TYPE)) { + $expr = $parser->parseExpression(); + $expr->setAttribute('spread', true); + $node->addElement($expr); + } else { + $node->addElement($parser->parseExpression()); + } + } + $stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened sequence is not properly closed'); + + return $node; + } + + private function parseMappingExpression(Parser $parser) + { + $this->type = 'mapping'; + + $stream = $parser->getStream(); + $stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected'); + + $node = new ArrayExpression([], $stream->getCurrent()->getLine()); + $first = true; + while (!$stream->test(Token::PUNCTUATION_TYPE, '}')) { + if (!$first) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A mapping value must be followed by a comma'); + + // trailing ,? + if ($stream->test(Token::PUNCTUATION_TYPE, '}')) { + break; + } + } + $first = false; + + if ($stream->nextIf(Token::SPREAD_TYPE)) { + $value = $parser->parseExpression(); + $value->setAttribute('spread', true); + $node->addElement($value); + continue; + } + + // a mapping key can be: + // + // * a number -- 12 + // * a string -- 'a' + // * a name, which is equivalent to a string -- a + // * an expression, which must be enclosed in parentheses -- (1 + 2) + if ($token = $stream->nextIf(Token::NAME_TYPE)) { + $key = new ConstantExpression($token->getValue(), $token->getLine()); + + // {a} is a shortcut for {a:a} + if ($stream->test(Token::PUNCTUATION_TYPE, [',', '}'])) { + $value = new ContextVariable($key->getAttribute('value'), $key->getTemplateLine()); + $node->addElement($value, $key); + continue; + } + } elseif (($token = $stream->nextIf(Token::STRING_TYPE)) || $token = $stream->nextIf(Token::NUMBER_TYPE)) { + $key = new ConstantExpression($token->getValue(), $token->getLine()); + } elseif ($stream->test(Token::OPERATOR_TYPE, '(')) { + $key = $parser->parseExpression(); + } else { + $current = $stream->getCurrent(); + + throw new SyntaxError(\sprintf('A mapping key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', $current->toEnglish(), $current->getValue()), $current->getLine(), $stream->getSourceContext()); + } + + $stream->expect(Token::PUNCTUATION_TYPE, ':', 'A mapping key must be followed by a colon (:)'); + $value = $parser->parseExpression(); + + $node->addElement($value, $key); + } + $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed'); + + return $node; + } +} diff --git a/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php b/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php new file mode 100644 index 00000000000..35468940a14 --- /dev/null +++ b/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php @@ -0,0 +1,71 @@ + */ + private string $nodeClass, + private string $name, + private int $precedence, + private ?PrecedenceChange $precedenceChange = null, + private ?string $description = null, + private array $aliases = [], + ) { + } + + /** + * @return AbstractUnary + */ + public function parse(Parser $parser, Token $token): AbstractExpression + { + return new ($this->nodeClass)($parser->parseExpression($this->precedence), $token->getLine()); + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + public function getPrecedence(): int + { + return $this->precedence; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return $this->precedenceChange; + } + + public function getAliases(): array + { + return $this->aliases; + } +} diff --git a/src/ExpressionParser/PrefixExpressionParserInterface.php b/src/ExpressionParser/PrefixExpressionParserInterface.php new file mode 100644 index 00000000000..587997c51a2 --- /dev/null +++ b/src/ExpressionParser/PrefixExpressionParserInterface.php @@ -0,0 +1,21 @@ +getFileName(); diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index a351f570a18..89bc2cf6f0d 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -16,7 +16,20 @@ use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; -use Twig\ExpressionParser; +use Twig\ExpressionParser\Infix\ArrowExpressionParser; +use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser; +use Twig\ExpressionParser\Infix\ConditionalTernaryExpressionParser; +use Twig\ExpressionParser\Infix\DotExpressionParser; +use Twig\ExpressionParser\Infix\FilterExpressionParser; +use Twig\ExpressionParser\Infix\FunctionExpressionParser; +use Twig\ExpressionParser\Infix\IsExpressionParser; +use Twig\ExpressionParser\Infix\IsNotExpressionParser; +use Twig\ExpressionParser\Infix\SquareBracketExpressionParser; +use Twig\ExpressionParser\InfixAssociativity; +use Twig\ExpressionParser\PrecedenceChange; +use Twig\ExpressionParser\Prefix\GroupingExpressionParser; +use Twig\ExpressionParser\Prefix\LiteralExpressionParser; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; use Twig\Markup; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\Binary\AddBinary; @@ -67,7 +80,6 @@ use Twig\Node\Expression\Unary\NotUnary; use Twig\Node\Expression\Unary\PosUnary; use Twig\Node\Node; -use Twig\OperatorPrecedenceChange; use Twig\Parser; use Twig\Sandbox\SecurityNotAllowedMethodError; use Twig\Sandbox\SecurityNotAllowedPropertyError; @@ -313,50 +325,68 @@ public function getNodeVisitors(): array return []; } - public function getOperators(): array + public function getExpressionParsers(): array { return [ - [ - 'not' => ['precedence' => 50, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 70), 'class' => NotUnary::class], - '-' => ['precedence' => 500, 'class' => NegUnary::class], - '+' => ['precedence' => 500, 'class' => PosUnary::class], - ], - [ - '? :' => ['precedence' => 5, 'class' => ElvisBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - '?:' => ['precedence' => 5, 'class' => ElvisBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - '??' => ['precedence' => 300, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 5), 'class' => NullCoalesceBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - 'or' => ['precedence' => 10, 'class' => OrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'xor' => ['precedence' => 12, 'class' => XorBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'and' => ['precedence' => 15, 'class' => AndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-or' => ['precedence' => 16, 'class' => BitwiseOrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-xor' => ['precedence' => 17, 'class' => BitwiseXorBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-and' => ['precedence' => 18, 'class' => BitwiseAndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '==' => ['precedence' => 20, 'class' => EqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '!=' => ['precedence' => 20, 'class' => NotEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=>' => ['precedence' => 20, 'class' => SpaceshipBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<' => ['precedence' => 20, 'class' => LessBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>' => ['precedence' => 20, 'class' => GreaterBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>=' => ['precedence' => 20, 'class' => GreaterEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=' => ['precedence' => 20, 'class' => LessEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'not in' => ['precedence' => 20, 'class' => NotInBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'in' => ['precedence' => 20, 'class' => InBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'matches' => ['precedence' => 20, 'class' => MatchesBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'starts with' => ['precedence' => 20, 'class' => StartsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'ends with' => ['precedence' => 20, 'class' => EndsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'has some' => ['precedence' => 20, 'class' => HasSomeBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'has every' => ['precedence' => 20, 'class' => HasEveryBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '..' => ['precedence' => 25, 'class' => RangeBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '+' => ['precedence' => 30, 'class' => AddBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '-' => ['precedence' => 30, 'class' => SubBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '~' => ['precedence' => 40, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 27), 'class' => ConcatBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '*' => ['precedence' => 60, 'class' => MulBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '/' => ['precedence' => 60, 'class' => DivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '//' => ['precedence' => 60, 'class' => FloorDivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '%' => ['precedence' => 60, 'class' => ModBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is not' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '**' => ['precedence' => 200, 'class' => PowerBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - ], + // unary operators + new UnaryOperatorExpressionParser(NotUnary::class, 'not', 50, new PrecedenceChange('twig/twig', '3.15', 70)), + new UnaryOperatorExpressionParser(NegUnary::class, '-', 500), + new UnaryOperatorExpressionParser(PosUnary::class, '+', 500), + + // binary operators + new BinaryOperatorExpressionParser(ElvisBinary::class, '?:', 5, InfixAssociativity::Right, description: 'Elvis operator (a ?: b)', aliases: ['? :']), + new BinaryOperatorExpressionParser(NullCoalesceBinary::class, '??', 300, InfixAssociativity::Right, new PrecedenceChange('twig/twig', '3.15', 5), description: 'Null coalescing operator (a ?? b)'), + new BinaryOperatorExpressionParser(OrBinary::class, 'or', 10), + new BinaryOperatorExpressionParser(XorBinary::class, 'xor', 12), + new BinaryOperatorExpressionParser(AndBinary::class, 'and', 15), + new BinaryOperatorExpressionParser(BitwiseOrBinary::class, 'b-or', 16), + new BinaryOperatorExpressionParser(BitwiseXorBinary::class, 'b-xor', 17), + new BinaryOperatorExpressionParser(BitwiseAndBinary::class, 'b-and', 18), + new BinaryOperatorExpressionParser(EqualBinary::class, '==', 20), + new BinaryOperatorExpressionParser(NotEqualBinary::class, '!=', 20), + new BinaryOperatorExpressionParser(SpaceshipBinary::class, '<=>', 20), + new BinaryOperatorExpressionParser(LessBinary::class, '<', 20), + new BinaryOperatorExpressionParser(GreaterBinary::class, '>', 20), + new BinaryOperatorExpressionParser(GreaterEqualBinary::class, '>=', 20), + new BinaryOperatorExpressionParser(LessEqualBinary::class, '<=', 20), + new BinaryOperatorExpressionParser(NotInBinary::class, 'not in', 20), + new BinaryOperatorExpressionParser(InBinary::class, 'in', 20), + new BinaryOperatorExpressionParser(MatchesBinary::class, 'matches', 20), + new BinaryOperatorExpressionParser(StartsWithBinary::class, 'starts with', 20), + new BinaryOperatorExpressionParser(EndsWithBinary::class, 'ends with', 20), + new BinaryOperatorExpressionParser(HasSomeBinary::class, 'has some', 20), + new BinaryOperatorExpressionParser(HasEveryBinary::class, 'has every', 20), + new BinaryOperatorExpressionParser(RangeBinary::class, '..', 25), + new BinaryOperatorExpressionParser(AddBinary::class, '+', 30), + new BinaryOperatorExpressionParser(SubBinary::class, '-', 30), + new BinaryOperatorExpressionParser(ConcatBinary::class, '~', 40, precedenceChange: new PrecedenceChange('twig/twig', '3.15', 27)), + new BinaryOperatorExpressionParser(MulBinary::class, '*', 60), + new BinaryOperatorExpressionParser(DivBinary::class, '/', 60), + new BinaryOperatorExpressionParser(FloorDivBinary::class, '//', 60, description: 'Floor division'), + new BinaryOperatorExpressionParser(ModBinary::class, '%', 60), + new BinaryOperatorExpressionParser(PowerBinary::class, '**', 200, InfixAssociativity::Right, description: 'Exponentiation operator'), + + // ternary operator + new ConditionalTernaryExpressionParser(), + + // Twig callables + new IsExpressionParser(), + new IsNotExpressionParser(), + new FilterExpressionParser(), + new FunctionExpressionParser(), + + // get attribute operators + new DotExpressionParser(), + new SquareBracketExpressionParser(), + + // group expression + new GroupingExpressionParser(), + + // arrow function + new ArrowExpressionParser(), + + // all literals + new LiteralExpressionParser(), ]; } diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php index d51cd3ee2ff..44356f62769 100644 --- a/src/Extension/ExtensionInterface.php +++ b/src/Extension/ExtensionInterface.php @@ -11,11 +11,9 @@ namespace Twig\Extension; -use Twig\ExpressionParser; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; +use Twig\ExpressionParser\ExpressionParserInterface; +use Twig\ExpressionParser\PrecedenceChange; use Twig\NodeVisitor\NodeVisitorInterface; -use Twig\OperatorPrecedenceChange; use Twig\TokenParser\TokenParserInterface; use Twig\TwigFilter; use Twig\TwigFunction; @@ -25,6 +23,8 @@ * Interface implemented by extension classes. * * @author Fabien Potencier + * + * @method array getExpressionParsers() */ interface ExtensionInterface { @@ -66,11 +66,11 @@ public function getFunctions(); /** * Returns a list of operators to add to the existing list. * - * @return array First array of unary operators, second array of binary operators + * @return array * * @psalm-return array{ - * array}>, - * array, associativity: ExpressionParser::OPERATOR_*}> + * array}>, + * array, associativity: ExpressionParser::OPERATOR_*}> * } */ public function getOperators(); diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php index b069232b44f..5afa729f9ed 100644 --- a/src/ExtensionSet.php +++ b/src/ExtensionSet.php @@ -12,12 +12,17 @@ namespace Twig; use Twig\Error\RuntimeError; +use Twig\ExpressionParser\ExpressionParsers; +use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser; +use Twig\ExpressionParser\InfixAssociativity; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\PrecedenceChange; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; use Twig\Extension\ExtensionInterface; use Twig\Extension\GlobalsInterface; use Twig\Extension\LastModifiedExtensionInterface; use Twig\Extension\StagingExtension; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; +use Twig\Node\Expression\AbstractExpression; use Twig\NodeVisitor\NodeVisitorInterface; use Twig\TokenParser\TokenParserInterface; @@ -46,10 +51,7 @@ final class ExtensionSet private $functions; /** @var array */ private $dynamicFunctions; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: ExpressionParser::OPERATOR_*}> */ - private $binaryOperators; + private ExpressionParsers $expressionParsers; /** @var array|null */ private $globals; /** @var array */ @@ -406,28 +408,13 @@ public function getTest(string $name): ?TwigTest return null; } - /** - * @return array}> - */ - public function getUnaryOperators(): array + public function getExpressionParsers(): ExpressionParsers { if (!$this->initialized) { $this->initExtensions(); } - return $this->unaryOperators; - } - - /** - * @return array, associativity: ExpressionParser::OPERATOR_*}> - */ - public function getBinaryOperators(): array - { - if (!$this->initialized) { - $this->initExtensions(); - } - - return $this->binaryOperators; + return $this->expressionParsers; } private function initExtensions(): void @@ -440,8 +427,7 @@ private function initExtensions(): void $this->dynamicFunctions = []; $this->dynamicTests = []; $this->visitors = []; - $this->unaryOperators = []; - $this->binaryOperators = []; + $this->expressionParsers = new ExpressionParsers(); foreach ($this->extensions as $extension) { $this->initExtension($extension); @@ -491,18 +477,66 @@ private function initExtension(ExtensionInterface $extension): void $this->visitors[] = $visitor; } - // operators - if ($operators = $extension->getOperators()) { - if (!\is_array($operators)) { - throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array with operators, got "%s".', \get_class($extension), get_debug_type($operators).(\is_resource($operators) ? '' : '#'.$operators))); - } + // expression parsers + if (method_exists($extension, 'getExpressionParsers')) { + $this->expressionParsers->add($extension->getExpressionParsers()); + } + + $operators = $extension->getOperators(); + if (!\is_array($operators)) { + throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array with operators, got "%s".', \get_class($extension), get_debug_type($operators).(\is_resource($operators) ? '' : '#'.$operators))); + } - if (2 !== \count($operators)) { - throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators))); + if (2 !== \count($operators)) { + throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators))); + } + + $expressionParsers = []; + foreach ($operators[0] as $operator => $op) { + $expressionParsers[] = new UnaryOperatorExpressionParser($op['class'], $operator, $op['precedence'], $op['precedence_change'] ?? null, '', $op['aliases'] ?? []); + } + foreach ($operators[1] as $operator => $op) { + $op['associativity'] = match ($op['associativity']) { + 1 => InfixAssociativity::Left, + 2 => InfixAssociativity::Right, + default => throw new \InvalidArgumentException(\sprintf('Invalid associativity "%s" for operator "%s".', $op['associativity'], $operator)), + }; + + if ($op['callable']) { + $expressionParsers[] = $this->convertInfixExpressionParser($op['class'], $operator, $op['precedence'], $op['associativity'], $op['precedence_change'] ?? null, $op['aliases'] ?? [], $op['callable']); + } else { + $expressionParsers[] = new BinaryOperatorExpressionParser($op['class'], $operator, $op['precedence'], $op['associativity'], $op['precedence_change'] ?? null, $op['aliases'] ?? []); } + } + + if (\count($expressionParsers)) { + trigger_deprecation('twig/twig', '3.21', \sprintf('Extension "%s" uses the old signature for "getOperators()", please implement "getExpressionParsers()" instead.', $extension::class)); - $this->unaryOperators = array_merge($this->unaryOperators, $operators[0]); - $this->binaryOperators = array_merge($this->binaryOperators, $operators[1]); + $this->expressionParsers->add($expressionParsers); } } + + private function convertInfixExpressionParser(string $nodeClass, string $operator, int $precedence, InfixAssociativity $associativity, ?PrecedenceChange $precedenceChange, array $aliases, callable $callable): InfixExpressionParserInterface + { + trigger_deprecation('twig/twig', '3.21', \sprintf('Using a non-ExpressionParserInterface object to define the "%s" binary operator is deprecated.', $operator)); + + return new class($nodeClass, $operator, $precedence, $associativity, $precedenceChange, $aliases, $callable) extends BinaryOperatorExpressionParser { + public function __construct( + string $nodeClass, + string $operator, + int $precedence, + InfixAssociativity $associativity = InfixAssociativity::Left, + ?PrecedenceChange $precedenceChange = null, + array $aliases = [], + private $callable = null, + ) { + parent::__construct($nodeClass, $operator, $precedence, $associativity, $precedenceChange, $aliases); + } + + public function parse(Parser $parser, AbstractExpression $expr, Token $token): AbstractExpression + { + return ($this->callable)($parser, $expr); + } + }; + } } diff --git a/src/Lexer.php b/src/Lexer.php index 929673c6082..c9f2f0adc20 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -36,6 +36,8 @@ class Lexer private $position; private $positions; private $currentVarBlockLine; + private array $openingBrackets = ['{', '(', '[']; + private array $closingBrackets = ['}', ')', ']']; public const STATE_DATA = 0; public const STATE_BLOCK = 1; @@ -337,14 +339,13 @@ private function lexExpression(): void $this->pushToken(Token::SPREAD_TYPE, '...'); $this->moveCursor('...'); } - // arrow function - elseif ('=' === $this->code[$this->cursor] && ($this->cursor + 1 < $this->end) && '>' === $this->code[$this->cursor + 1]) { - $this->pushToken(Token::ARROW_TYPE, '=>'); - $this->moveCursor('=>'); - } // operators elseif (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) { - $this->pushToken(Token::OPERATOR_TYPE, preg_replace('/\s+/', ' ', $match[0])); + $operator = preg_replace('/\s+/', ' ', $match[0]); + if (\in_array($operator, $this->openingBrackets)) { + $this->checkBrackets($operator); + } + $this->pushToken(Token::OPERATOR_TYPE, $operator); $this->moveCursor($match[0]); } // names @@ -359,22 +360,7 @@ private function lexExpression(): void } // punctuation elseif (str_contains(self::PUNCTUATION, $this->code[$this->cursor])) { - // opening bracket - if (str_contains('([{', $this->code[$this->cursor])) { - $this->brackets[] = [$this->code[$this->cursor], $this->lineno]; - } - // closing bracket - elseif (str_contains(')]}', $this->code[$this->cursor])) { - if (!$this->brackets) { - throw new SyntaxError(\sprintf('Unexpected "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); - } - - [$expect, $lineno] = array_pop($this->brackets); - if ($this->code[$this->cursor] != strtr($expect, '([{', ')]}')) { - throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); - } - } - + $this->checkBrackets($this->code[$this->cursor]); $this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]); ++$this->cursor; } @@ -544,26 +530,25 @@ private function moveCursor($text): void private function getOperatorRegex(): string { - $operators = array_merge( - ['='], - array_keys($this->env->getUnaryOperators()), - array_keys($this->env->getBinaryOperators()) - ); + $expressionParsers = ['=']; + foreach ($this->env->getExpressionParsers() as $expressionParser) { + $expressionParsers = array_merge($expressionParsers, [$expressionParser->getName()], $expressionParser->getAliases()); + } - $operators = array_combine($operators, array_map('strlen', $operators)); - arsort($operators); + $expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers)); + arsort($expressionParsers); $regex = []; - foreach ($operators as $operator => $length) { + foreach ($expressionParsers as $expressionParser => $length) { // an operator that ends with a character must be followed by // a whitespace, a parenthesis, an opening map [ or sequence { - $r = preg_quote($operator, '/'); - if (ctype_alpha($operator[$length - 1])) { + $r = preg_quote($expressionParser, '/'); + if (ctype_alpha($expressionParser[$length - 1])) { $r .= '(?=[\s()\[{])'; } // an operator that begins with a character must not have a dot or pipe before - if (ctype_alpha($operator[0])) { + if (ctype_alpha($expressionParser[0])) { $r = '(?state = array_pop($this->states); } + + private function checkBrackets(string $code): void + { + // opening bracket + if (\in_array($code, $this->openingBrackets)) { + $this->brackets[] = [$code, $this->lineno]; + } elseif (\in_array($code, $this->closingBrackets)) { + // closing bracket + if (!$this->brackets) { + throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source); + } + + [$expect, $lineno] = array_pop($this->brackets); + if ($code !== str_replace($this->openingBrackets, $this->closingBrackets, $expect)) { + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); + } + } + } } diff --git a/src/Node/Expression/ArrowFunctionExpression.php b/src/Node/Expression/ArrowFunctionExpression.php index 2bae4edd75f..552b8fe9115 100644 --- a/src/Node/Expression/ArrowFunctionExpression.php +++ b/src/Node/Expression/ArrowFunctionExpression.php @@ -12,6 +12,9 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Error\SyntaxError; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; /** @@ -23,6 +26,14 @@ class ArrowFunctionExpression extends AbstractExpression { public function __construct(AbstractExpression $expr, Node $names, $lineno) { + if (!$names instanceof ListExpression && !$names instanceof ContextVariable) { + throw new SyntaxError('The arrow function argument must be a list of variables or a single variable.', $names->getTemplateLine(), $names->getSourceContext()); + } + + if ($names instanceof ContextVariable) { + $names = new ListExpression([new AssignContextVariable($names->getAttribute('name'), $names->getTemplateLine())], $lineno); + } + parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno); } @@ -31,19 +42,7 @@ public function compile(Compiler $compiler): void $compiler ->addDebugInfo($this) ->raw('function (') - ; - foreach ($this->getNode('names') as $i => $name) { - if ($i) { - $compiler->raw(', '); - } - - $compiler - ->raw('$__') - ->raw($name->getAttribute('name')) - ->raw('__') - ; - } - $compiler + ->subcompile($this->getNode('names')) ->raw(') use ($context, $macros) { ') ; foreach ($this->getNode('names') as $name) { diff --git a/src/Node/Expression/ListExpression.php b/src/Node/Expression/ListExpression.php new file mode 100644 index 00000000000..dd7fc1f9cd5 --- /dev/null +++ b/src/Node/Expression/ListExpression.php @@ -0,0 +1,41 @@ + $items + */ + public function __construct(array $items, int $lineno) + { + parent::__construct($items, [], $lineno); + } + + public function compile(Compiler $compiler): void + { + foreach ($this as $i => $name) { + if ($i) { + $compiler->raw(', '); + } + + $compiler + ->raw('$__') + ->raw($name->getAttribute('name')) + ->raw('__') + ; + } + } +} diff --git a/src/OperatorPrecedenceChange.php b/src/OperatorPrecedenceChange.php index 1d9edefd11c..31ebaef48cb 100644 --- a/src/OperatorPrecedenceChange.php +++ b/src/OperatorPrecedenceChange.php @@ -11,32 +11,24 @@ namespace Twig; +use Twig\ExpressionParser\PrecedenceChange; + /** - * Represents a precedence change for an operator. + * Represents a precedence change. * * @author Fabien Potencier + * + * @deprecated since Twig 1.20 Use Twig\ExpressionParser\PrecedenceChange instead */ -class OperatorPrecedenceChange +class OperatorPrecedenceChange extends PrecedenceChange { public function __construct( private string $package, private string $version, private int $newPrecedence, ) { - } - - public function getPackage(): string - { - return $this->package; - } - - public function getVersion(): string - { - return $this->version; - } + trigger_deprecation('twig/twig', '3.21', 'The "%s" class is deprecated since Twig 3.21. Use "%s" instead.', self::class, PrecedenceChange::class); - public function getNewPrecedence(): int - { - return $this->newPrecedence; + parent::__construct($package, $version, $newPrecedence); } } diff --git a/src/Parser.php b/src/Parser.php index ff1772c16bb..5bbeadfd060 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -13,6 +13,12 @@ namespace Twig; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParserInterface; +use Twig\ExpressionParser\ExpressionParsers; +use Twig\ExpressionParser\ExpressionParserType; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\Prefix\LiteralExpressionParser; +use Twig\ExpressionParser\PrefixExpressionParserInterface; use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; use Twig\Node\BodyNode; @@ -49,10 +55,12 @@ class Parser private $embeddedTemplates = []; private $varNameSalt = 0; private $ignoreUnknownTwigCallables = false; + private ExpressionParsers $parsers; public function __construct( private Environment $env, ) { + $this->parsers = $env->getExpressionParsers(); } public function getEnvironment(): Environment @@ -78,10 +86,6 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals $this->visitors = $this->env->getNodeVisitors(); } - if (null === $this->expressionParser) { - $this->expressionParser = new ExpressionParser($this, $this->env); - } - $this->stream = $stream; $this->parent = null; $this->blocks = []; @@ -155,7 +159,7 @@ public function subparse($test, bool $dropNeedle = false): Node case $this->stream->getCurrent()->test(Token::VAR_START_TYPE): $token = $this->stream->next(); - $expr = $this->expressionParser->parseExpression(); + $expr = $this->parseExpression(); $this->stream->expect(Token::VAR_END_TYPE); $rv[] = new PrintNode($expr, $token->getLine()); break; @@ -337,11 +341,42 @@ public function popLocalScope(): void array_shift($this->importedSymbols); } + /** + * @deprecated since Twig 3.21 + */ public function getExpressionParser(): ExpressionParser { + trigger_deprecation('twig/twig', '3.21', 'Method "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); + + if (null === $this->expressionParser) { + $this->expressionParser = new ExpressionParser($this, $this->env); + } + return $this->expressionParser; } + public function parseExpression(int $precedence = 0): AbstractExpression + { + $token = $this->getCurrentToken(); + if ($token->test(Token::OPERATOR_TYPE) && $ep = $this->parsers->getByName(PrefixExpressionParserInterface::class, $token->getValue())) { + $this->getStream()->next(); + $expr = $ep->parse($this, $token); + $this->checkPrecedenceDeprecations($ep, $expr); + } else { + $expr = $this->parsers->getByClass(LiteralExpressionParser::class)->parse($this, $token); + } + + $token = $this->getCurrentToken(); + while ($token->test(Token::OPERATOR_TYPE) && ($ep = $this->parsers->getByName(InfixExpressionParserInterface::class, $token->getValue())) && $ep->getPrecedence() >= $precedence) { + $this->getStream()->next(); + $expr = $ep->parse($this, $expr, $token); + $this->checkPrecedenceDeprecations($ep, $expr); + $token = $this->getCurrentToken(); + } + + return $expr; + } + public function getParent(): ?Node { trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); @@ -380,6 +415,98 @@ public function getCurrentToken(): Token return $this->stream->getCurrent(); } + public function getFunction(string $name, int $line): TwigFunction + { + try { + $function = $this->env->getFunction($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $function = null; + } + + if (!$function) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigFunction($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getFunctions())); + + throw $e; + } + + if ($function->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $function->triggerDeprecation($src->getPath() ?: $src->getName(), $line); + } + + return $function; + } + + public function getFilter(string $name, int $line): TwigFilter + { + try { + $filter = $this->env->getFilter($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $filter = null; + } + if (!$filter) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigFilter($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getFilters())); + + throw $e; + } + + if ($filter->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $filter->triggerDeprecation($src->getPath() ?: $src->getName(), $line); + } + + return $filter; + } + + public function getTest(int $line): TwigTest + { + $name = $this->stream->expect(Token::NAME_TYPE)->getValue(); + + if ($this->stream->test(Token::NAME_TYPE)) { + // try 2-words tests + $name = $name.' '.$this->getCurrentToken()->getValue(); + + if ($test = $this->env->getTest($name)) { + $this->stream->next(); + } + } else { + $test = $this->env->getTest($name); + } + + if (!$test) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigTest($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getTests())); + + throw $e; + } + + if ($test->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $test->triggerDeprecation($src->getPath() ?: $src->getName(), $this->stream->getCurrent()->getLine()); + } + + return $test; + } + private function filterBodyNodes(Node $node, bool $nested = false): ?Node { // check that the body does not contain non-empty output nodes @@ -427,4 +554,43 @@ private function filterBodyNodes(Node $node, bool $nested = false): ?Node return $node; } + + private function checkPrecedenceDeprecations(ExpressionParserInterface $expressionParser, AbstractExpression $expr) + { + $expr->setAttribute('expression_parser', $expressionParser); + $precedenceChanges = $this->parsers->getPrecedenceChanges(); + + // Check that the all nodes that are between the 2 precedences have explicit parentheses + if (!isset($precedenceChanges[$expressionParser])) { + return; + } + + if ($expr->hasExplicitParentheses()) { + return; + } + + if ($expressionParser instanceof PrefixExpressionParserInterface) { + /** @var AbstractExpression $node */ + $node = $expr->getNode('node'); + foreach ($precedenceChanges as $ep => $changes) { + if (!\in_array($expressionParser, $changes, true)) { + continue; + } + if ($node->hasAttribute('expression_parser') && $ep === $node->getAttribute('expression_parser')) { + $change = $expressionParser->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('As the "%s" %s operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "%s" at line %d.', $expressionParser->getName(), ExpressionParserType::getType($expressionParser)->value, $this->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + } + } + } + + foreach ($precedenceChanges[$expressionParser] as $ep) { + foreach ($expr as $node) { + /** @var AbstractExpression $node */ + if ($node->hasAttribute('expression_parser') && $ep === $node->getAttribute('expression_parser') && !$node->hasExplicitParentheses()) { + $change = $ep->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('As the "%s" %s operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "%s" at line %d.', $ep->getName(), ExpressionParserType::getType($ep)->value, $this->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + } + } + } + } } diff --git a/src/Token.php b/src/Token.php index a4da548cbf2..0d8b385ebfa 100644 --- a/src/Token.php +++ b/src/Token.php @@ -30,6 +30,9 @@ final class Token public const PUNCTUATION_TYPE = 9; public const INTERPOLATION_START_TYPE = 10; public const INTERPOLATION_END_TYPE = 11; + /** + * @deprecated since Twig 3.21, "arrow" is now an operator + */ public const ARROW_TYPE = 12; public const SPREAD_TYPE = 13; @@ -38,6 +41,9 @@ public function __construct( private $value, private int $lineno, ) { + if (self::ARROW_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::ARROW_TYPE); + } } public function __toString(): string @@ -63,7 +69,39 @@ public function test($type, $values = null): bool $type = self::NAME_TYPE; } - return ($this->type === $type) && ( + if (self::ARROW_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::typeToEnglish(self::ARROW_TYPE)); + + return self::OPERATOR_TYPE === $this->type && '=>' === $this->value; + } + + $typeMatches = $this->type === $type; + if ($typeMatches && self::PUNCTUATION_TYPE === $type && \in_array($this->value, ['(', '[', '|', '.', '?', '?:']) && $values) { + foreach ((array) $values as $value) { + if (\in_array($value, ['(', '[', '|', '.', '?', '?:'])) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token is now an "%s" token instead of a "%s" one.', $this->value, self::typeToEnglish(self::OPERATOR_TYPE), $this->toEnglish()); + + break; + } + } + } + if (!$typeMatches) { + if (self::OPERATOR_TYPE === $type && self::PUNCTUATION_TYPE === $this->type) { + if ($values) { + foreach ((array) $values as $value) { + if (\in_array($value, ['(', '[', '|', '.', '?', '?:'])) { + $typeMatches = true; + + break; + } + } + } else { + $typeMatches = true; + } + } + } + + return $typeMatches && ( null === $values || (\is_array($values) && \in_array($this->value, $values)) || $this->value == $values diff --git a/src/TokenParser/AbstractTokenParser.php b/src/TokenParser/AbstractTokenParser.php index 720ea676283..8acaa6f56e9 100644 --- a/src/TokenParser/AbstractTokenParser.php +++ b/src/TokenParser/AbstractTokenParser.php @@ -11,7 +11,11 @@ namespace Twig\TokenParser; +use Twig\Lexer; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Nodes; use Twig\Parser; +use Twig\Token; /** * Base class for all token parsers. @@ -29,4 +33,29 @@ public function setParser(Parser $parser): void { $this->parser = $parser; } + + /** + * Parses an assignment expression like "a, b". + */ + protected function parseAssignmentExpression(): Nodes + { + $stream = $this->parser->getStream(); + $targets = []; + while (true) { + $token = $stream->getCurrent(); + if ($stream->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) { + // in this context, string operators are variable names + $stream->next(); + } else { + $stream->expect(Token::NAME_TYPE, null, 'Only variables can be assigned to'); + } + $targets[] = new AssignContextVariable($token->getValue(), $token->getLine()); + + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); + } } diff --git a/src/TokenParser/ApplyTokenParser.php b/src/TokenParser/ApplyTokenParser.php index 0c95074828a..5b560e74916 100644 --- a/src/TokenParser/ApplyTokenParser.php +++ b/src/TokenParser/ApplyTokenParser.php @@ -11,6 +11,7 @@ namespace Twig\TokenParser; +use Twig\ExpressionParser\Infix\FilterExpressionParser; use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; use Twig\Node\Nodes; @@ -33,7 +34,15 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $ref = new LocalVariable(null, $lineno); - $filter = $this->parser->getExpressionParser()->parseFilterExpressionRaw($ref); + $filter = $ref; + $op = $this->parser->getEnvironment()->getExpressionParsers()->getByClass(FilterExpressionParser::class); + while (true) { + $filter = $op->parse($this->parser, $filter, $this->parser->getCurrentToken()); + if (!$this->parser->getStream()->test(Token::OPERATOR_TYPE, '|')) { + break; + } + $this->parser->getStream()->next(); + } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideApplyEnd'], true); diff --git a/src/TokenParser/AutoEscapeTokenParser.php b/src/TokenParser/AutoEscapeTokenParser.php index b50b29e659e..86feb27e621 100644 --- a/src/TokenParser/AutoEscapeTokenParser.php +++ b/src/TokenParser/AutoEscapeTokenParser.php @@ -32,7 +32,7 @@ public function parse(Token $token): Node if ($stream->test(Token::BLOCK_END_TYPE)) { $value = 'html'; } else { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); if (!$expr instanceof ConstantExpression) { throw new SyntaxError('An escaping strategy must be a string or false.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } diff --git a/src/TokenParser/BlockTokenParser.php b/src/TokenParser/BlockTokenParser.php index 3561b99cdd7..452b323e533 100644 --- a/src/TokenParser/BlockTokenParser.php +++ b/src/TokenParser/BlockTokenParser.php @@ -53,7 +53,7 @@ public function parse(Token $token): Node } } else { $body = new Nodes([ - new PrintNode($this->parser->getExpressionParser()->parseExpression(), $lineno), + new PrintNode($this->parser->parseExpression(), $lineno), ]); } $stream->expect(Token::BLOCK_END_TYPE); diff --git a/src/TokenParser/DeprecatedTokenParser.php b/src/TokenParser/DeprecatedTokenParser.php index 164ef26eec3..df1ba381f44 100644 --- a/src/TokenParser/DeprecatedTokenParser.php +++ b/src/TokenParser/DeprecatedTokenParser.php @@ -33,8 +33,7 @@ final class DeprecatedTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $expressionParser = $this->parser->getExpressionParser(); - $expr = $expressionParser->parseExpression(); + $expr = $this->parser->parseExpression(); $node = new DeprecatedNode($expr, $token->getLine()); while ($stream->test(Token::NAME_TYPE)) { @@ -44,10 +43,10 @@ public function parse(Token $token): Node switch ($k) { case 'package': - $node->setNode('package', $expressionParser->parseExpression()); + $node->setNode('package', $this->parser->parseExpression()); break; case 'version': - $node->setNode('version', $expressionParser->parseExpression()); + $node->setNode('version', $this->parser->parseExpression()); break; default: throw new SyntaxError(\sprintf('Unknown "%s" option.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); diff --git a/src/TokenParser/DoTokenParser.php b/src/TokenParser/DoTokenParser.php index 8afd4855937..ca9d03d454f 100644 --- a/src/TokenParser/DoTokenParser.php +++ b/src/TokenParser/DoTokenParser.php @@ -24,7 +24,7 @@ final class DoTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); diff --git a/src/TokenParser/EmbedTokenParser.php b/src/TokenParser/EmbedTokenParser.php index f1acbf1ef00..fa279104614 100644 --- a/src/TokenParser/EmbedTokenParser.php +++ b/src/TokenParser/EmbedTokenParser.php @@ -28,7 +28,7 @@ public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $parent = $this->parser->getExpressionParser()->parseExpression(); + $parent = $this->parser->parseExpression(); [$variables, $only, $ignoreMissing] = $this->parseArguments(); diff --git a/src/TokenParser/ExtendsTokenParser.php b/src/TokenParser/ExtendsTokenParser.php index a93afe8cd59..8f64698187d 100644 --- a/src/TokenParser/ExtendsTokenParser.php +++ b/src/TokenParser/ExtendsTokenParser.php @@ -36,7 +36,7 @@ public function parse(Token $token): Node throw new SyntaxError('Cannot use "extend" in a macro.', $token->getLine(), $stream->getSourceContext()); } - $this->parser->setParent($this->parser->getExpressionParser()->parseExpression()); + $this->parser->setParent($this->parser->parseExpression()); $stream->expect(Token::BLOCK_END_TYPE); diff --git a/src/TokenParser/ForTokenParser.php b/src/TokenParser/ForTokenParser.php index 3e08b22fa8a..21166fc1fab 100644 --- a/src/TokenParser/ForTokenParser.php +++ b/src/TokenParser/ForTokenParser.php @@ -35,9 +35,9 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $targets = $this->parser->getExpressionParser()->parseAssignmentExpression(); + $targets = $this->parseAssignmentExpression(); $stream->expect(Token::OPERATOR_TYPE, 'in'); - $seq = $this->parser->getExpressionParser()->parseExpression(); + $seq = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideForFork']); diff --git a/src/TokenParser/FromTokenParser.php b/src/TokenParser/FromTokenParser.php index c8732df29f6..1c80a171777 100644 --- a/src/TokenParser/FromTokenParser.php +++ b/src/TokenParser/FromTokenParser.php @@ -29,7 +29,7 @@ final class FromTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $macro = $this->parser->getExpressionParser()->parseExpression(); + $macro = $this->parser->parseExpression(); $stream = $this->parser->getStream(); $stream->expect(Token::NAME_TYPE, 'import'); diff --git a/src/TokenParser/IfTokenParser.php b/src/TokenParser/IfTokenParser.php index 6b90105633b..4e3588e5be5 100644 --- a/src/TokenParser/IfTokenParser.php +++ b/src/TokenParser/IfTokenParser.php @@ -36,7 +36,7 @@ final class IfTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $lineno = $token->getLine(); - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $stream = $this->parser->getStream(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); @@ -52,7 +52,7 @@ public function parse(Token $token): Node break; case 'elseif': - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); $tests[] = $expr; diff --git a/src/TokenParser/ImportTokenParser.php b/src/TokenParser/ImportTokenParser.php index f23584a5a42..6dcb7662cbf 100644 --- a/src/TokenParser/ImportTokenParser.php +++ b/src/TokenParser/ImportTokenParser.php @@ -28,7 +28,7 @@ final class ImportTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $macro = $this->parser->getExpressionParser()->parseExpression(); + $macro = $this->parser->parseExpression(); $this->parser->getStream()->expect(Token::NAME_TYPE, 'as'); $name = $this->parser->getStream()->expect(Token::NAME_TYPE)->getValue(); $var = new AssignTemplateVariable(new TemplateVariable($name, $token->getLine()), $this->parser->isMainScope()); diff --git a/src/TokenParser/IncludeTokenParser.php b/src/TokenParser/IncludeTokenParser.php index c5ce180ad2a..55ac1516c4e 100644 --- a/src/TokenParser/IncludeTokenParser.php +++ b/src/TokenParser/IncludeTokenParser.php @@ -30,7 +30,7 @@ class IncludeTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); [$variables, $only, $ignoreMissing] = $this->parseArguments(); @@ -53,7 +53,7 @@ protected function parseArguments() $variables = null; if ($stream->nextIf(Token::NAME_TYPE, 'with')) { - $variables = $this->parser->getExpressionParser()->parseExpression(); + $variables = $this->parser->parseExpression(); } $only = false; diff --git a/src/TokenParser/MacroTokenParser.php b/src/TokenParser/MacroTokenParser.php index 33379be0319..38e66c81073 100644 --- a/src/TokenParser/MacroTokenParser.php +++ b/src/TokenParser/MacroTokenParser.php @@ -73,7 +73,7 @@ private function parseDefinition(): ArrayExpression { $arguments = new ArrayExpression([], $this->parser->getCurrentToken()->getLine()); $stream = $this->parser->getStream(); - $stream->expect(Token::PUNCTUATION_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { if (\count($arguments)) { $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); @@ -87,7 +87,7 @@ private function parseDefinition(): ArrayExpression $token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name'); $name = new LocalVariable($token->getValue(), $this->parser->getCurrentToken()->getLine()); if ($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) { - $default = $this->parser->getExpressionParser()->parseExpression(); + $default = $this->parser->parseExpression(); } else { $default = new ConstantExpression(null, $this->parser->getCurrentToken()->getLine()); $default->setAttribute('is_implicit', true); diff --git a/src/TokenParser/SetTokenParser.php b/src/TokenParser/SetTokenParser.php index bb43907bd24..1aabbf582b1 100644 --- a/src/TokenParser/SetTokenParser.php +++ b/src/TokenParser/SetTokenParser.php @@ -13,6 +13,7 @@ use Twig\Error\SyntaxError; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\SetNode; use Twig\Token; @@ -34,11 +35,11 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $names = $this->parser->getExpressionParser()->parseAssignmentExpression(); + $names = $this->parseAssignmentExpression(); $capture = false; if ($stream->nextIf(Token::OPERATOR_TYPE, '=')) { - $values = $this->parser->getExpressionParser()->parseMultitargetExpression(); + $values = $this->parseMultitargetExpression(); $stream->expect(Token::BLOCK_END_TYPE); @@ -70,4 +71,17 @@ public function getTag(): string { return 'set'; } + + private function parseMultitargetExpression(): Nodes + { + $targets = []; + while (true) { + $targets[] = $this->parser->parseExpression(); + if (!$this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); + } } diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php index a7da0f5ecf4..2c7b77c024b 100644 --- a/src/TokenParser/TypesTokenParser.php +++ b/src/TokenParser/TypesTokenParser.php @@ -63,7 +63,7 @@ private function parseSimpleMappingExpression(TokenStream $stream): array if ($stream->nextIf(Token::OPERATOR_TYPE, '?:')) { $isOptional = true; } else { - $isOptional = null !== $stream->nextIf(Token::PUNCTUATION_TYPE, '?'); + $isOptional = null !== $stream->nextIf(Token::OPERATOR_TYPE, '?'); $stream->expect(Token::PUNCTUATION_TYPE, ':', 'A type name must be followed by a colon (:)'); } diff --git a/src/TokenParser/UseTokenParser.php b/src/TokenParser/UseTokenParser.php index ebd95aa317f..41386c8b479 100644 --- a/src/TokenParser/UseTokenParser.php +++ b/src/TokenParser/UseTokenParser.php @@ -36,7 +36,7 @@ final class UseTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $template = $this->parser->getExpressionParser()->parseExpression(); + $template = $this->parser->parseExpression(); $stream = $this->parser->getStream(); if (!$template instanceof ConstantExpression) { diff --git a/src/TokenParser/WithTokenParser.php b/src/TokenParser/WithTokenParser.php index 8ce4f02b2c5..83470d8651f 100644 --- a/src/TokenParser/WithTokenParser.php +++ b/src/TokenParser/WithTokenParser.php @@ -31,7 +31,7 @@ public function parse(Token $token): Node $variables = null; $only = false; if (!$stream->test(Token::BLOCK_END_TYPE)) { - $variables = $this->parser->getExpressionParser()->parseExpression(); + $variables = $this->parser->parseExpression(); $only = (bool) $stream->nextIf(Token::NAME_TYPE, 'only'); } diff --git a/tests/CustomExtensionTest.php b/tests/CustomExtensionTest.php index 40be3b38293..174ad5e73f4 100644 --- a/tests/CustomExtensionTest.php +++ b/tests/CustomExtensionTest.php @@ -19,6 +19,8 @@ class CustomExtensionTest extends TestCase { /** + * @group legacy + * * @dataProvider provideInvalidExtensions */ public function testGetInvalidOperators(ExtensionInterface $extension, $expectedExceptionMessage) @@ -29,7 +31,7 @@ public function testGetInvalidOperators(ExtensionInterface $extension, $expected $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - $env->getUnaryOperators(); + $env->getExpressionParsers(); } public static function provideInvalidExtensions() diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index 34774db1c5b..19f2fce4677 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -18,6 +18,10 @@ use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; +use Twig\ExpressionParser\PrefixExpressionParserInterface; use Twig\Extension\AbstractExtension; use Twig\Extension\ExtensionInterface; use Twig\Extension\GlobalsInterface; @@ -307,8 +311,8 @@ public function testAddExtension() $this->assertArrayHasKey('foo_filter', $twig->getFilters()); $this->assertArrayHasKey('foo_function', $twig->getFunctions()); $this->assertArrayHasKey('foo_test', $twig->getTests()); - $this->assertArrayHasKey('foo_unary', $twig->getUnaryOperators()); - $this->assertArrayHasKey('foo_binary', $twig->getBinaryOperators()); + $this->assertNotNull($twig->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'foo_unary')); + $this->assertNotNull($twig->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'foo_binary')); $this->assertArrayHasKey('foo_global', $twig->getGlobals()); $visitors = $twig->getNodeVisitors(); $found = false; @@ -594,11 +598,11 @@ public function getFunctions(): array ]; } - public function getOperators(): array + public function getExpressionParsers(): array { return [ - ['foo_unary' => ['precedence' => 0]], - ['foo_binary' => ['precedence' => 0]], + new UnaryOperatorExpressionParser('', 'foo_unary', 0), + new BinaryOperatorExpressionParser('', 'foo_binary', 0), ]; } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index d3887e93880..bed218cc0fb 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -17,6 +17,7 @@ use Twig\Compiler; use Twig\Environment; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; use Twig\Extension\AbstractExtension; use Twig\Loader\ArrayLoader; use Twig\Node\Expression\ArrayExpression; @@ -570,7 +571,7 @@ public function testUnaryPrecedenceChange() { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $env->addExtension(new class extends AbstractExtension { - public function getOperators() + public function getExpressionParsers(): array { $class = new class(new ConstantExpression('foo', 1), 1) extends AbstractUnary { public function operator(Compiler $compiler): Compiler @@ -579,7 +580,9 @@ public function operator(Compiler $compiler): Compiler } }; - return [['!' => ['precedence' => 50, 'class' => $class::class]], []]; + return [ + new UnaryOperatorExpressionParser($class::class, '!', 50), + ]; } }); $parser = new Parser($env); @@ -597,6 +600,86 @@ private static function createContextVariable(string $name, array $attributes): return $expression; } + + /** + * @dataProvider getBindingPowerTests + */ + public function testBindingPower(string $expression, string $expectedExpression, mixed $expectedResult, array $context = []) + { + $env = new Environment(new ArrayLoader([ + 'expression' => $expression, + 'expected' => $expectedExpression, + ])); + + $this->assertSame($env->render('expected', $context), $env->render('expression', $context)); + $this->assertEquals($expectedResult, $env->render('expression', $context)); + } + + public static function getBindingPowerTests(): iterable + { + // * / // % stronger than + - + foreach (['*', '/', '//', '%'] as $op1) { + foreach (['+', '-'] as $op2) { + $e = "12 $op1 6 $op2 3"; + if ('//' === $op1) { + $php = eval("return (int) floor(12 / 6) $op2 3;"); + } else { + $php = eval("return $e;"); + } + yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php]; + + $e = "12 $op2 6 $op1 3"; + if ('//' === $op1) { + $php = eval("return 12 $op2 (int) floor(6 / 3);"); + } else { + $php = eval("return $e;"); + } + yield "$op2 vs $op1" => ["{{ $e }}", "{{ 12 $op2 (6 $op1 3) }}", $php]; + } + } + + // + - * / // % stronger than == != <=> < > >= <= `not in` `in` `matches` `starts with` `ends with` `has some` `has every` + foreach (['+', '-', '*', '/', '//', '%'] as $op1) { + foreach (['==', '!=', '<=>', '<', '>', '>=', '<='] as $op2) { + $e = "12 $op1 6 $op2 3"; + if ('//' === $op1) { + $php = eval("return (int) floor(12 / 6) $op2 3;"); + } else { + $php = eval("return $e;"); + } + yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php]; + } + } + yield '+ vs not in' => ['{{ 1 + 2 not in [3, 4] }}', '{{ (1 + 2) not in [3, 4] }}', eval('return !in_array(1 + 2, [3, 4]);')]; + yield '+ vs in' => ['{{ 1 + 2 in [3, 4] }}', '{{ (1 + 2) in [3, 4] }}', eval('return in_array(1 + 2, [3, 4]);')]; + yield '+ vs matches' => ['{{ 1 + 2 matches "/^3$/" }}', '{{ (1 + 2) matches "/^3$/" }}', eval("return preg_match('/^3$/', 1 + 2);")]; + + // ~ stronger than `starts with` `ends with` + yield '~ vs starts with' => ['{{ "a" ~ "b" starts with "a" }}', '{{ ("a" ~ "b") starts with "a" }}', eval("return str_starts_with('ab', 'a');")]; + yield '~ vs ends with' => ['{{ "a" ~ "b" ends with "b" }}', '{{ ("a" ~ "b") ends with "b" }}', eval("return str_ends_with('ab', 'b');")]; + + // [] . stronger than anything else + $context = ['a' => ['b' => 1, 'c' => ['d' => 2]]]; + yield '[] vs unary -' => ['{{ -a["b"] + 3 }}', '{{ -(a["b"]) + 3 }}', eval("\$a = ['b' => 1]; return -\$a['b'] + 3;"), $context]; + yield '[] vs unary - (multiple levels)' => ['{{ -a["c"]["d"] }}', '{{ -((a["c"])["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '. vs unary -' => ['{{ -a.b }}', '{{ -(a.b) }}', eval("\$a = ['b' => 1]; return -\$a['b'];"), $context]; + yield '. vs unary - (multiple levels)' => ['{{ -a.c.d }}', '{{ -((a.c).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '. [] vs unary -' => ['{{ -a.c["d"] }}', '{{ -((a.c)["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '[] . vs unary -' => ['{{ -a["c"].d }}', '{{ -((a["c"]).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + + // () stronger than anything else + yield '() vs unary -' => ['{{ -random(1, 1) + 3 }}', '{{ -(random(1, 1)) + 3 }}', eval('return -rand(1, 1) + 3;')]; + + // + - stronger than | + yield '+ vs |' => ['{{ 10 + 2|length }}', '{{ 10 + (2|length) }}', eval('return 10 + strlen(2);'), $context]; + + // - unary stronger than | + // To be uncomment in Twig 4.0 + // yield '- vs |' => ['{{ -1|abs }}', '{{ (-1)|abs }}', eval("return abs(-1);"), $context]; + + // ?? stronger than () + // yield '?? vs ()' => ['{{ (1 ?? "a") }}', '{{ ((1 ?? "a")) }}', eval("return 1;")]; + } } class NotReadyFunctionExpression extends FunctionExpression diff --git a/tests/Fixtures/expressions/postfix.test b/tests/Fixtures/expressions/postfix.test index 276cbf197d1..6217a8410a5 100644 --- a/tests/Fixtures/expressions/postfix.test +++ b/tests/Fixtures/expressions/postfix.test @@ -8,7 +8,7 @@ Twig parses postfix expressions {{ 'a' }} {{ 'a'|upper }} {{ ('a')|upper }} -{{ -1|upper }} +{{ (-1)|abs }} {{ macros.foo() }} {{ (macros).foo() }} --DATA-- @@ -17,6 +17,6 @@ return [] a A A --1 +1 foo foo diff --git a/tests/Fixtures/filters/arrow_reserved_names.test b/tests/Fixtures/filters/arrow_reserved_names.test index 3e5d0722b1a..188373feee5 100644 --- a/tests/Fixtures/filters/arrow_reserved_names.test +++ b/tests/Fixtures/filters/arrow_reserved_names.test @@ -5,4 +5,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2. +Twig\Error\SyntaxError: The arrow function argument must be a list of variables or a single variable in "index.twig" at line 2. diff --git a/tests/Fixtures/operators/contat_vs_add_sub.legacy.test b/tests/Fixtures/operators/contat_vs_add_sub.legacy.test index 541e4f7cb8a..a1370d2caed 100644 --- a/tests/Fixtures/operators/contat_vs_add_sub.legacy.test +++ b/tests/Fixtures/operators/contat_vs_add_sub.legacy.test @@ -1,8 +1,8 @@ --TEST-- +/- will have a higher precedence over ~ in Twig 4.0 --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 2. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 3. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 3. --TEMPLATE-- {{ '42' ~ 1 + 41 }} {{ '42' ~ 43 - 1 }} diff --git a/tests/Fixtures/operators/minus_vs_pipe.legacy.test b/tests/Fixtures/operators/minus_vs_pipe.legacy.test new file mode 100644 index 00000000000..ac6cd3278a2 --- /dev/null +++ b/tests/Fixtures/operators/minus_vs_pipe.legacy.test @@ -0,0 +1,10 @@ +--TEST-- +| will have a higher precedence over + and - in Twig 4.0 +--DEPRECATION-- +Since twig/twig 3.21: As the "|" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. +--TEMPLATE-- +{{ -1|abs }} +--DATA-- +return [] +--EXPECT-- +-1 diff --git a/tests/Fixtures/operators/not_precedence.legacy.test b/tests/Fixtures/operators/not_precedence.legacy.test index 5178288e950..3a2f4a7ec3f 100644 --- a/tests/Fixtures/operators/not_precedence.legacy.test +++ b/tests/Fixtures/operators/not_precedence.legacy.test @@ -1,7 +1,7 @@ --TEST-- *, /, //, and % will have a higher precedence over not in Twig 4.0 --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "not" unary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 2. +Since twig/twig 3.15: As the "not" prefix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. --TEMPLATE-- {{ not 1 * 2 }} --DATA-- diff --git a/tests/Fixtures/operators/not_precedence.test b/tests/Fixtures/operators/not_precedence.test index 592b1c33440..f21a0861653 100644 --- a/tests/Fixtures/operators/not_precedence.test +++ b/tests/Fixtures/operators/not_precedence.test @@ -2,7 +2,7 @@ *, /, //, and % will have a higher precedence over not in Twig 4.0 --TEMPLATE-- {{ (not 1) * 2 }} -{{ (not 1 * 2) }} +{{ not (1 * 2) }} --DATA-- return [] --EXPECT-- diff --git a/tests/Fixtures/tests/null_coalesce.legacy.test b/tests/Fixtures/tests/null_coalesce.legacy.test index 2b2036660c9..4aaec83743a 100644 --- a/tests/Fixtures/tests/null_coalesce.legacy.test +++ b/tests/Fixtures/tests/null_coalesce.legacy.test @@ -1,16 +1,16 @@ --TEST-- Twig supports the ?? operator --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 4. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 5. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 6. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 7. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 10. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 9. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 11. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 16. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 15. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 17. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 4. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 5. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 6. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 7. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 10. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 9. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 11. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 16. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 15. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 17. --TEMPLATE-- {{ nope ?? nada ?? 'OK' -}} {# no deprecation as the operators have the same precedence #}