Repository: twigphp/Twig (php)

Issue:
Precedence of ?? operator
Hi!

This might be a bug:
The precedence of ?? is not the same in twig as it is in php.

> php:
$x = 'a';
var_dump(null ?? $x.'_1'); // a_1
var_dump('notnull' ?? $x.'_1'); // notnull

> twig:
{{ null ?? x ~ '_1' }} // a_1
{{ 'notnull' ?? x ~ '_1' }} // notnull_1 expected: nutnull
{{ 'notnull' ?? (x ~ '_1') }} // notnull


Wrong precedence of NOT operator
In most programming languages (such as C++, Java, or PHP) the boolean unary NOT operator has a higher precedence than e.g. the multiplication, division, remainder, or relational operators. It usually has a precedence same as the unary '+' or unary '-'.

But why does Twig deviate from this practice and puts the unary 'NOT' operator precedence between the ones below:

....
'+' (binary add)
'-' (binary minus)
'~' (binary concat)
**'NOT' (unary not)**
'*' (binary multiply)
'/' (binary division)
....

Not even Django (Python) has such a strange precedence placement.

There are several other bad habits in the Twig expression syntax, but I think above mentioned issue could be fixed, and if need be, add a runtime switch to ensure backward compatibility with older Twig versions, but have future Twig releases use a proper operator precedence. 


Pull request description:
Trigger deprecations when using "??" and "not" without explicit parentheses when precedence will change in 4.0
Closes #3387
Closes #3642



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b3472cf..3de8aa10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,6 +3,8 @@ name: "CI"
 on:
     pull_request:
     push:
+        branches:
+            - '3.x'
 
 env:
     SYMFONY_PHPUNIT_DISABLE_RESULT_CACHE: 1
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 8e6d5011..8fe7c868 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -3,6 +3,9 @@ name: "Documentation"
 on:
     pull_request:
     push:
+        branches:
+            - '2.x'
+            - '3.x'
 
 permissions:
   contents: read
diff --git a/CHANGELOG b/CHANGELOG
index 90427309..c4a17f73 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,9 @@
 # 3.15.0 (2024-XX-XX)
 
- * Deprecate using `~` with `+` or `-` in an expression without using parentheses to clarify precedence
+ * Add support for triggering deprecations for future operator precedence changes
+ * Deprecate using the `not` unary operator in an expression with ``*``, ``/``, ``//``, or ``%`` without using explicit parentheses to clarify precedence
+ * Deprecate using the `??` binary operator without explicit parentheses
+ * Deprecate using the `~` binary operator in an expression with `+` or `-` without using parentheses to clarify precedence
  * Deprecate not passing `AbstractExpression` args to most constructor arguments for classes extending `AbstractExpression`
  * Fix `power` expressions with a negative number in parenthesis (`(-1) ** 2`)
  * Deprecate instantiating `Node` directly. Use `EmptyNode` or `Nodes` instead.
@@ -19,7 +22,6 @@
  * Deprecate the `sandbox` tag
  * Improve the way one can deprecate a Twig callable (use `deprecation_info` instead of the other callable options)
  * Add the `enum` function
- * Add support for logical `xor` operator
 
 # 3.14.0 (2024-09-09)
 
diff --git a/doc/deprecated.rst b/doc/deprecated.rst
index 10cc1214..e1967add 100644
--- a/doc/deprecated.rst
+++ b/doc/deprecated.rst
@@ -314,10 +314,9 @@ Node
 Operators
 ---------
 
-* Using ``~`` with ``+`` or ``-`` in an expression without using parentheses to
-  clarify precedence is deprecated as of Twig 3.15 (in Twig 4.0, parentheses
-  won't be needed anymore as ``+`` / ``-`` will have a higher precedence than
-  ``~``).
+* Using ``~`` in an expression with the ``+`` or ``-`` operators without using
+  parentheses to clarify precedence triggers a deprecation as of Twig 3.15 (in
+  Twig 4.0, ``+`` / ``-`` will have a higher precedence than ``~``).
 
   For example, the following expression will trigger a deprecation in Twig 3.15::
 
@@ -331,3 +330,38 @@ Operators
     {# or #}
 
     {{ '42' ~ (1 + 41) }} {# this is equivalent to what Twig 4.x will do without the parentheses #}
+
+* Using ``??`` without explicit parentheses to clarify precedence triggers a
+  deprecation as of Twig 3.15 (in Twig 4.0, ``??`` will have the lowest
+  precedence).
+
+  For example, the following expression will trigger a deprecation in Twig 3.15::
+
+    {{ 'notnull' ?? 'foo' ~ '_bar' }}
+
+  To avoid the deprecation, wrap the ``??`` expression in parentheses to clarify
+  the precedence::
+
+    {{ ('notnull' ?? 'foo') ~ '_bar' }} {# this is equivalent to what Twig 3.x does without the parentheses #}
+
+    {# or #}
+
+    {{ 'notnull' ?? ('foo' ~ '_bar') }} {# this is equivalent to what Twig 4.x will do without the parentheses #}
+
+* Using the ``not`` unary operator in an expression with ``*``, ``/``, ``//``,
+  or ``%`` operators without explicit parentheses to clarify precedence
+  triggers a deprecation as of Twig 3.15 (in Twig 4.0, ``not`` will have a
+  higher precedence than ``*``, ``/``, ``//``, and ``%``).
+
+  For example, the following expression will trigger a deprecation in Twig 3.15::
+
+    {{ not 1 * 2 }}
+
+  To avoid the deprecation, wrap the concatenation in parentheses to clarify
+  the precedence::
+
+    {{ (not 1 * 2) }} {# this is equivalent to what Twig 3.x does without the parentheses #}
+
+    {# or #}
+
+    {{ (not 1) * 2 }} {# this is equivalent to what Twig 4.x will do without the parentheses #}
diff --git a/doc/templates.rst b/doc/templates.rst
index bfe2a1c2..6626d4ee 100644
--- a/doc/templates.rst
+++ b/doc/templates.rst
@@ -76,24 +76,8 @@ Twig templates have access to variables provided by the PHP application and
 variables created in templates via the :doc:`set <tags/set>` tag. These
 variables can be manipulated and displayed in the template.
 
-Twig tries to abstract PHP types as much as possible and works with a few basic
-types, supported by ``filters``, ``functions``, and ``tests`` among others:
-
-===================  ===============================
-Twig Type            PHP Type
-===================  ===============================
-string               A string or a Stringable object
-number               An integer or a float
-boolean              ``true`` or ``false``
-null                 ``null``
-iterable (mapping)   An array
-iterable (sequence)  An array
-iterable (object)    An iterable object
-object               An object
-===================  ===============================
-
-The ``iterable`` and ``object`` types expose attributes you can access via the
-dot (``.``) operator:
+Use a dot (``.``) to access attributes of a variable (methods, properties
+or constants of a PHP object, or items of a PHP array):
 
 .. code-block:: twig
 
@@ -708,8 +692,6 @@ You can combine multiple expressions with the following operators:
 
 * ``and``: Returns true if the left and the right operands are both true.
 
-* ``xor``: Returns true if **either** the left or the right operand is true, but not both.
-
 * ``or``: Returns true if the left or the right operand is true.
 
 * ``not``: Negates a statement.
@@ -960,7 +942,6 @@ 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
diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php
index ed61dcc2..01cb9b93 100644
--- a/src/ExpressionParser.php
+++ b/src/ExpressionParser.php
@@ -20,9 +20,7 @@ use Twig\Node\Expression\ArrayExpression;
 use Twig\Node\Expression\ArrowFunctionExpression;
 use Twig\Node\Expression\AssignNameExpression;
 use Twig\Node\Expression\Binary\AbstractBinary;
-use Twig\Node\Expression\Binary\AddBinary;
 use Twig\Node\Expression\Binary\ConcatBinary;
-use Twig\Node\Expression\Binary\SubBinary;
 use Twig\Node\Expression\ConditionalExpression;
 use Twig\Node\Expression\ConstantExpression;
 use Twig\Node\Expression\GetAttrExpression;
@@ -57,6 +55,7 @@ class ExpressionParser
     /** @var array<string, array{precedence: int, class: class-string<AbstractBinary>, associativity: self::OPERATOR_*}> */
     private $binaryOperators;
     private $readyNodes = [];
+    private array $precedenceChanges = [];
 
     public function __construct(
         private Parser $parser,
@@ -64,16 +63,37 @@ class ExpressionParser
     ) {
         $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;
+                }
+            }
+        }
     }
 
-    public function parseExpression($precedence = 0, $allowArrow = false)
+    public function parseExpression($precedence = 0, $allowArrow = false, bool $deprecationCheck = true)
     {
         if ($allowArrow && $arrow = $this->parseArrow()) {
             return $arrow;
         }
 
         $expr = $this->getPrimary();
-        $token = $this->parser->getCurrentToken();
+        $previousToken = $token = $this->parser->getCurrentToken();
         while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) {
             $op = $this->binaryOperators[$token->getValue()];
             $this->parser->getStream()->next();
@@ -90,10 +110,15 @@ class ExpressionParser
                 $expr = new $class($expr, $expr1, $token->getLine());
             }
 
+            $expr->setAttribute('operator', 'binary_'.$token->getValue());
+
+            $previousToken = $token;
             $token = $this->parser->getCurrentToken();
         }
 
-        $this->triggerPrecedenceDeprecations($expr, $token);
+        if ($deprecationCheck) {
+            $this->triggerPrecedenceDeprecations($expr, $previousToken);
+        }
 
         if (0 === $precedence) {
             return $this->parseConditionalExpression($expr);
@@ -104,18 +129,37 @@ class ExpressionParser
 
     private function triggerPrecedenceDeprecations(AbstractExpression $expr, Token $token): void
     {
-        // Precedence of the ~ operator will be lower than + and - in Twig 4.0
-        if ($expr instanceof AddBinary || $expr instanceof SubBinary) {
-            /** @var AbstractExpression $left */
-            $left = $expr->getNode('left');
-            /** @var AbstractExpression $right */
-            $right = $expr->getNode('right');
-            if (
-                ($left instanceof ConcatBinary && !$left->hasExplicitParentheses())
-                ||
-                ($right instanceof ConcatBinary && !$right->hasExplicitParentheses())
-            ) {
-                trigger_deprecation('twig/twig', '3.15', \sprintf('As "+" / "-" will have a higher precedence than "~" in Twig 4.0, please add parentheses to keep the current behavior in "%s" at line %d.', $this->parser->getStream()->getSourceContext()->getName(), $token->getLine()));
+        // 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];
+            $change = $this->unaryOperators[$target]['precedence_change'];
+            /** @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')) {
+                    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(), $token->getLine()));
+                }
+            }
+        } 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(), $token->getLine()));
+                    }
+                }
             }
         }
     }
@@ -188,10 +232,13 @@ class ExpressionParser
             $expr = $this->parseExpression($operator['precedence']);
             $class = $operator['class'];
 
-            return $this->parsePostfixExpression(new $class($expr, $token->getLine()));
+            $expr = new $class($expr, $token->getLine());
+            $expr->setAttribute('operator', 'unary_'.$token->getValue());
+
+            return $this->parsePostfixExpression($expr);
         } elseif ($token->test(Token::PUNCTUATION_TYPE, '(')) {
             $this->parser->getStream()->next();
-            $expr = $this->parseExpression()->setExplicitParentheses();
+            $expr = $this->parseExpression(deprecationCheck: false)->setExplicitParentheses();
             $this->parser->getStream()->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed');
 
             return $this->parsePostfixExpression($expr);
diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index 43211d3e..6f27e9ac 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -47,7 +47,6 @@ use Twig\Node\Expression\Binary\RangeBinary;
 use Twig\Node\Expression\Binary\SpaceshipBinary;
 use Twig\Node\Expression\Binary\StartsWithBinary;
 use Twig\Node\Expression\Binary\SubBinary;
-use Twig\Node\Expression\Binary\XorBinary;
 use Twig\Node\Expression\BlockReferenceExpression;
 use Twig\Node\Expression\Filter\DefaultFilter;
 use Twig\Node\Expression\FunctionNode\EnumCasesFunction;
@@ -67,6 +66,7 @@ use Twig\Node\Expression\Unary\NotUnary;
 use Twig\Node\Expression\Unary\PosUnary;
 use Twig\Node\Node;
 use Twig\NodeVisitor\MacroAutoImportNodeVisitor;
+use Twig\OperatorPrecedenceChange;
 use Twig\Parser;
 use Twig\Source;
 use Twig\Template;
@@ -297,13 +297,12 @@ final class CoreExtension extends AbstractExtension
     {
         return [
             [
-                'not' => ['precedence' => 50, 'class' => NotUnary::class],
+                '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],
             ],
             [
                 '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],
@@ -325,8 +324,7 @@ final class CoreExtension extends AbstractExtension
                 '..' => ['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 of the ~ operator will change to 27 in Twig 4.0
-                '~' => ['precedence' => 40, 'class' => ConcatBinary::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],
@@ -334,7 +332,7 @@ final class CoreExtension extends AbstractExtension
                 'is' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT],
                 'is not' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT],
                 '**' => ['precedence' => 200, 'class' => PowerBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT],
-                '??' => ['precedence' => 300, 'class' => NullCoalesceExpression::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT],
+                '??' => ['precedence' => 300, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 5), 'class' => NullCoalesceExpression::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT],
             ],
         ];
     }
diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php
index 10a42b6b..1b7be44c 100644
--- a/src/Extension/ExtensionInterface.php
+++ b/src/Extension/ExtensionInterface.php
@@ -67,8 +67,8 @@ interface ExtensionInterface
      * @return array<array> First array of unary operators, second array of binary operators
      *
      * @psalm-return array{
-     *     array<string, array{precedence: int, class: class-string<AbstractExpression>}>,
-     *     array<string, array{precedence: int, class?: class-string<AbstractExpression>, associativity: ExpressionParser::OPERATOR_*}>
+     *     array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractExpression>}>,
+     *     array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class?: class-string<AbstractExpression>, associativity: ExpressionParser::OPERATOR_*}>
      * }
      */
     public function getOperators();
diff --git a/src/Node/Expression/Binary/XorBinary.php b/src/Node/Expression/Binary/XorBinary.php
deleted file mode 100644
index d8ccd785..00000000
--- a/src/Node/Expression/Binary/XorBinary.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/*
- * This file is part of Twig.
- *
- * (c) Fabien Potencier
- * (c) Armin Ronacher
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Twig\Node\Expression\Binary;
-
-use Twig\Compiler;
-
-class XorBinary extends AbstractBinary
-{
-    public function operator(Compiler $compiler): Compiler
-    {
-        return $compiler->raw('xor');
-    }
-}
diff --git a/src/OperatorPrecedenceChange.php b/src/OperatorPrecedenceChange.php
new file mode 100644
index 00000000..12fd98c8
--- /dev/null
+++ b/src/OperatorPrecedenceChange.php
@@ -0,0 +1,42 @@
+<?php
+
+/*
+ * This file is part of Twig.
+ *
+ * (c) Fabien Potencier
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Twig;
+
+/**
+ * Represents a precedence change for an operator.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class OperatorPrecedenceChange
+{
+    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(): string
+    {
+        return $this->newPrecedence;
+    }
+}


Test patch:
diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php
index 7bbc61ce..802f9dd3 100644
--- a/tests/EnvironmentTest.php
+++ b/tests/EnvironmentTest.php
@@ -597,8 +597,8 @@ class EnvironmentTest_Extension extends AbstractExtension implements GlobalsInte
     public function getOperators(): array
     {
         return [
-            ['foo_unary' => []],
-            ['foo_binary' => []],
+            ['foo_unary' => ['precedence' => 0]],
+            ['foo_binary' => ['precedence' => 0]],
         ];
     }
 
diff --git a/tests/Fixtures/expressions/binary.test b/tests/Fixtures/expressions/binary.test
index f7252e95..b4e8be58 100644
--- a/tests/Fixtures/expressions/binary.test
+++ b/tests/Fixtures/expressions/binary.test
@@ -1,5 +1,5 @@
 --TEST--
-Twig supports binary operations (+, -, *, /, ~, %, and, xor, or)
+Twig supports binary operations (+, -, *, /, ~, %, and, or)
 --TEMPLATE--
 {{ 1 + 1 }}
 {{ 2 - 1 }}
@@ -16,12 +16,6 @@ Twig supports binary operations (+, -, *, /, ~, %, and, xor, or)
 {{ 0 or 0 }}
 {{ 0 or 1 and 0 }}
 {{ 1 or 0 and 1 }}
-{{ 1 xor 1 }}
-{{ 1 xor 0 }}
-{{ 0 xor 1 }}
-{{ 0 xor 0 }}
-{{ 0 and 1 or 1 xor 1 }}
-{{ 0 and 1 or 0 xor 1 }}
 {{ "foo" ~ "bar" }}
 {{ foo ~ "bar" }}
 {{ "foo" ~ bar }}
@@ -44,12 +38,6 @@ return ['foo' => 'bar', 'bar' => 'foo']
 1
 
 
-1
-
-1
-1
-
-
 1
 foobar
 barbar
diff --git a/tests/Fixtures/operators/contat_vs_add_sub.legacy.test b/tests/Fixtures/operators/contat_vs_add_sub.legacy.test
index dc4a1ef5..541e4f7c 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: As "+" / "-" will have a higher precedence than "~" in Twig 4.0, please add parentheses to keep the current behavior in "index.twig" at line 2.
-Since twig/twig 3.15: As "+" / "-" will have a higher precedence than "~" in Twig 4.0, please add parentheses to keep the current behavior in "index.twig" at line 3.
+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.
 --TEMPLATE--
 {{ '42' ~ 1 + 41 }}
 {{ '42' ~ 43 - 1 }}
diff --git a/tests/Fixtures/operators/not_precedence.legacy.test b/tests/Fixtures/operators/not_precedence.legacy.test
new file mode 100644
index 00000000..5178288e
--- /dev/null
+++ b/tests/Fixtures/operators/not_precedence.legacy.test
@@ -0,0 +1,9 @@
+--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.
+--TEMPLATE--
+{{ not 1 * 2 }}
+--DATA--
+return []
+--EXPECT--
diff --git a/tests/Fixtures/operators/not_precedence.test b/tests/Fixtures/operators/not_precedence.test
new file mode 100644
index 00000000..592b1c33
--- /dev/null
+++ b/tests/Fixtures/operators/not_precedence.test
@@ -0,0 +1,9 @@
+--TEST--
+*, /, //, and % will have a higher precedence over not in Twig 4.0
+--TEMPLATE--
+{{ (not 1) * 2 }}
+{{ (not 1 * 2) }}
+--DATA--
+return []
+--EXPECT--
+0
diff --git a/tests/Fixtures/tests/null_coalesce.legacy.test b/tests/Fixtures/tests/null_coalesce.legacy.test
new file mode 100644
index 00000000..3d33d0a7
--- /dev/null
+++ b/tests/Fixtures/tests/null_coalesce.legacy.test
@@ -0,0 +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.
+--TEMPLATE--
+{{ nope ?? nada ?? 'OK' -}} {# no deprecation as the operators have the same precedence #}
+
+{{ 1 + nope ?? nada ?? 2 }}
+{{ 1 + nope ?? 3 + nada ?? 2 }}
+--DATA--
+return []
+--EXPECT--
+OK
+3
+6
diff --git a/tests/Fixtures/tests/null_coalesce.test b/tests/Fixtures/tests/null_coalesce.test
index 7af3255d..f80b9071 100644
--- a/tests/Fixtures/tests/null_coalesce.test
+++ b/tests/Fixtures/tests/null_coalesce.test
@@ -10,9 +10,9 @@ Twig supports the ?? operator
 {{ foo.bar.baz.missing ?? 'OK' }}
 {{ foo['bar'] ?? 'KO' }}
 {{ foo['missing'] ?? 'OK' }}
-{{ nope ?? nada ?? 'OK' }}
-{{ 1 + nope ?? nada ?? 2 }}
-{{ 1 + nope ?? 3 + nada ?? 2 }}
+{{ nope ?? (nada ?? 'OK') }}
+{{ 1 + (nope ?? (nada ?? 2)) }}
+{{ 1 + (nope ?? 3) + (nada ?? 2) }}
 --DATA--
 return ['bar' => 'OK', 'foo' => ['bar' => 'OK']]
 --EXPECT--