Repository: twigphp/Twig (php)

Issue:
Twig 3.15 introduces potential bc break for extensions with operators
The issue is caused by this PR: https://github.com/twigphp/Twig/pull/4367

Unfortunately I'm currently on the road and don't have much time to debug deeper into the issue, but it essentially comes down to the fact that classes extending `\Twig\Extension\AbstractExtension` and also implementing operators via `getOperators()` will have to be updated to `precedence_change`.

We end up running into this issue with one of our extensions in phpBB after updating twig to the latest version:
```
Twig\Error\SyntaxError: An exception has been thrown during the compilation of a template ("Undefined array key "precedence_change"") in "define.html".
```
(see this build: https://github.com/phpbb/phpbb/actions/runs/12332949057/job/34421397543?pr=6761)

As of right now this ends up being a backwards incompatible change and not just a deprecation.

### Sample code
I've created some sample code for reproducing this issue (test project called `twig_test`).

Base file we'll access is `twig_test/test.php`:
```php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use MyVendor\CustomExtension;

// Set up Twig loader and environment
$loader = new FilesystemLoader(__DIR__ . '/templates');
$twig = new Environment($loader);
$twig->addExtension(new CustomExtension());

// Render a template with the extension
echo $twig->render('example.twig', [
	'foo'	=> true,
]);

```

`twig_test/composer.json`:
```json
{
  "require": {
    "twig/twig": "3.15.*"
  },
  "autoload": {
    "psr-4": {
      "MyVendor\\": "src/"
    }
  }
}

```

`twig_test/src/CustomExtension.php`:
```php
<?php

namespace MyVendor;

use Twig\Extension\AbstractExtension;

class CustomExtension extends AbstractExtension
{
	public function getOperators()
	{
		return [
			[
				'!' => ['precedence' => 50, 'class' => '\Twig\Node\Expression\Unary\NotUnary'],
			],
			[]
		];
	}
}

```

Template file `twig_test/templates/example.twig`:
```twig
{% if !foo %}
    not foo
{% else %}
    foo
{% endif %}

```

Expected output when accessing `test.php` (works until 3.14):
`foo`

Actual output with 3.15:
```
Warning: Undefined array key "precedence_change" in /var/www/html/twig_test/vendor/twig/twig/src/ExpressionParser.php on line 152
foo
```

Pull request description:
Fix unary operator precedence change
Closes #4503

Patch:
diff --git a/CHANGELOG b/CHANGELOG
index bc1d00b8..21fb43f1 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,7 +1,8 @@
 # 3.18.0 (2024-XX-XX)
 
+ * Fix unary operator precedence change
  * Ignore `SyntaxError` exceptions from undefined handlers when using the `guard` tag
- * Add a way to stream template rendering (`TemplateWrapper::stream()` and `TemplateWrapper::streamBlock()` )
+ * Add a way to stream template rendering (`TemplateWrapper::stream()` and `TemplateWrapper::streamBlock()`)
 
 # 3.17.1 (2024-12-12)
 
diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php
index eb60a43b..0f1b0ed3 100644
--- a/src/ExpressionParser.php
+++ b/src/ExpressionParser.php
@@ -149,7 +149,6 @@ class ExpressionParser
                 return;
             }
             $target = explode('_', $unaryOp)[1];
-            $change = $this->unaryOperators[$target]['precedence_change'];
             /** @var AbstractExpression $node */
             $node = $expr->getNode('node');
             foreach ($this->precedenceChanges as $operatorName => $changes) {
@@ -157,6 +156,7 @@ class ExpressionParser
                     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()));
                 }
             }


Test patch:
diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php
index 162f8836..9b9d9b50 100644
--- a/tests/ExpressionParserTest.php
+++ b/tests/ExpressionParserTest.php
@@ -14,6 +14,7 @@ namespace Twig\Tests;
 use PHPUnit\Framework\TestCase;
 use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
 use Twig\Attribute\FirstClassTwigCallableReady;
+use Twig\Compiler;
 use Twig\Environment;
 use Twig\Error\SyntaxError;
 use Twig\Extension\AbstractExtension;
@@ -24,6 +25,7 @@ use Twig\Node\Expression\ConstantExpression;
 use Twig\Node\Expression\FilterExpression;
 use Twig\Node\Expression\FunctionExpression;
 use Twig\Node\Expression\TestExpression;
+use Twig\Node\Expression\Unary\AbstractUnary;
 use Twig\Node\Expression\Variable\ContextVariable;
 use Twig\Node\Node;
 use Twig\Parser;
@@ -564,6 +566,28 @@ class ExpressionParserTest extends TestCase
         $this->expectNotToPerformAssertions();
     }
 
+    public function testUnaryPrecedenceChange()
+    {
+        $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
+        $env->addExtension(new class () extends AbstractExtension {
+            public function getOperators()
+            {
+                $class = new class (new ConstantExpression('foo', 1), 1) extends AbstractUnary {
+                    public function operator(Compiler $compiler): Compiler
+                    {
+                        return $compiler->raw('!');
+                    }
+                };
+
+                return [['!' => ['precedence' => 50, 'class' => $class::class]], []];
+            }
+        });
+        $parser = new Parser($env);
+
+        $parser->parse($env->tokenize(new Source('{{ !false ? "OK" : "KO" }}', 'index')));
+        $this->expectNotToPerformAssertions();
+    }
+
     private static function createContextVariable(string $name, array $attributes): ContextVariable
     {
         $expression = new ContextVariable($name, 1);