Repository: twigphp/Twig (php)

Issue:
The ' ** ' operator does not work on negative numbers as an expected way .
PHP Version : PHP 7.3.3-1+0~20190307202245.32+stretch~1.gbp32ebb2
(Php versions 7.1 and 7.2 I tested it, and behavior is the same.)

Symfony Version : 4.2
Twig Version : symfony/twig-bundle": "4.2.3
Lsb Release : Debian GNU/Linux 9.8 (stretch)

---

in raw php code (as expected):
-2 ** 2 = 4

![image](https://user-images.githubusercontent.com/3167656/54212757-65138d80-44f4-11e9-9563-665cee09cd7e.png)

---

in twig (something is wrong in here);
{{ -2 ** 2 }}
{{ -2 ** 3 }}
...

![image](https://user-images.githubusercontent.com/3167656/54213428-6f825700-44f5-11e9-895f-a2ce8c03e69f.png)

Power function not working as expected
I tried to define random values with arbitrary sign and have come to the conclusion that the power function seems to be implemented in a non-standard fashion. Twig version is 3.4.6 and it is used within the Coderunner extension of Moodle.
For testing purposes, I define Twig powers which should be positive or negative, respectively, if the exponent is even or odd.
```
(-1)^0 = 1: {{ (-1)**0 }}, (-1)^1 = -1: {{(-1)**1}}, (-1)^2 = 1: {{(-1)**2}}
(-2)^0 = 1: {{(-2)**0}} , (-2)^1 = -2: {{(-2)**1}} , (-2)^2 = 4: {{(-2)**2}}
(-1.5)^0 = 1: {{ (-1.5)**0 }} , (-1.5)^1 = -1.5: {{ (-1.5)**1 }} , (-1.5)^2 = 2.25: {{ (-1.5)**2 }}
```
This does not yield the expected result when interpreting powers with natural exponent as successive multiplication with the zeroth power being the "empty" multiplication which yields the neutral element 1.
```
(-1)^0 = 1: -1, (-1)^1 = -1: -1, (-1)^2 = 1: -1
(-2)^0 = 1: -1 , (-2)^1 = -2: -2 , (-2)^2 = 4: -4
(-1.5)^0 = 1: -1 , (-1.5)^1 = -1.5: -1.5 , (-1.5)^2 = 2.25: -2.25
```
The power function seems to set aside any negative sign and put it to the respective power of the absolute value of the base. In a way, this is fine because powers with non-integer exponent are in principle not defined for negative bases because of the definition of general powers as `b**c := exp(c*ln(b))` via the exponential function and the natural logarithm where the latter is only defined for positive values. Twig seems to use the following definition instead which yields a value for any power of any base. It has the advantage that any power is defined.
```
b**c := sign(b)*abs(b)**c = sign(b)*exp(c*ln(abs(b)))
```
This leads, however, to contradictory results when applying it to base zero.
```
0^0 = ?: {{ 0**0 }}, 0^1 = 0: {{ 0**0 }}, 0^2 = 0: {{ 0**0 }}
(-0)^0 = ?: {{ (-0)**0 }}, 0^1 = 0: {{ (-0)**0 }}, 0^2 = 0: {{ (-0)**0 }}
```
Setting aside the fact that 0^0 is mathematically undefined, the results are all wrong and -0 even behaves differently from 0.
```
0^0 = ?: 1, 0^1 = 0: 1, 0^2 = 0: 1
(-0)^0 = ?: -1, (-0)^1 = 0: -1, (-0)^2 = 0: -1
```
So on top of the above, Twig seems to define `ln(0) := 0` which is simply false. This all is quite unexpected and even confusing. I do see good reasons for having a universally defined function even if non-standard. However, it should be clearly pointed out in the [documentation](https://twig.symfony.com/doc/3.x/templates.html#math). This may sound pedantic but I had to spend quite some time analysing the problem in depth before discovering the source of the error in my application.

Pull request description:
Fix ignored parenthesis in expressions
Closes #3705
Closes #2891


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index f2311502..9edf14f5 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,6 @@
 # 3.15.0 (2024-XX-XX)
 
+ * Fix `power` expressions with a negative number in parenthesis (`(-1) ** 2`)
  * Deprecate instantiating `Node` directly. Use `EmptyNode` or `Nodes` instead.
  * Add support for inline comments
  * Add support for accessing class constants with the dot operator
diff --git a/doc/filters/country_name.rst b/doc/filters/country_name.rst
index a30184de..434b0bda 100644
--- a/doc/filters/country_name.rst
+++ b/doc/filters/country_name.rst
@@ -1,7 +1,8 @@
 ``country_name``
 ================
 
-The ``country_name`` filter returns the country name given its ISO-3166 code:
+The ``country_name`` filter returns the country name given its ISO-3166
+two-letter code:
 
 .. code-block:: twig
 
@@ -15,9 +16,6 @@ By default, the filter uses the current locale. You can pass it explicitly:
     {# États-Unis #}
     {{ 'US'|country_name('fr') }}
 
-    {# 美國 #}
-    {{ 'US'|country_name('zh_Hant_HK') }}
-
 .. note::
 
     The ``country_name`` filter is part of the ``IntlExtension`` which is not
@@ -43,6 +41,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/currency_name.rst b/doc/filters/currency_name.rst
index 498dc942..a35c4999 100644
--- a/doc/filters/currency_name.rst
+++ b/doc/filters/currency_name.rst
@@ -1,7 +1,8 @@
 ``currency_name``
 =================
 
-The ``currency_name`` filter returns the currency name given its ISO 4217 code:
+The ``currency_name`` filter returns the currency name given its three-letter
+code:
 
 .. code-block:: twig
 
@@ -43,6 +44,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/currency_symbol.rst b/doc/filters/currency_symbol.rst
index 80843fba..84a048ed 100644
--- a/doc/filters/currency_symbol.rst
+++ b/doc/filters/currency_symbol.rst
@@ -1,7 +1,7 @@
 ``currency_symbol``
 ===================
 
-The ``currency_symbol`` filter returns the currency symbol given its ISO 4217
+The ``currency_symbol`` filter returns the currency symbol given its three-letter
 code:
 
 .. code-block:: twig
@@ -44,6 +44,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/format_currency.rst b/doc/filters/format_currency.rst
index 6f1036d3..c4c364a0 100644
--- a/doc/filters/format_currency.rst
+++ b/doc/filters/format_currency.rst
@@ -74,6 +74,4 @@ Arguments
 
 * ``currency``: The currency
 * ``attrs``: A map of attributes
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/format_date.rst b/doc/filters/format_date.rst
index ab0d609d..cd6beba9 100644
--- a/doc/filters/format_date.rst
+++ b/doc/filters/format_date.rst
@@ -29,10 +29,8 @@ the :doc:`format_datetime<format_datetime>` filter, but without the time.
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
+* ``locale``: The locale
 * ``dateFormat``: The date format
 * ``pattern``: A date time pattern
 * ``timezone``: The date timezone
 * ``calendar``: The calendar ("gregorian" by default)
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
diff --git a/doc/filters/format_datetime.rst b/doc/filters/format_datetime.rst
index 5f3a49b0..a47d4973 100644
--- a/doc/filters/format_datetime.rst
+++ b/doc/filters/format_datetime.rst
@@ -98,7 +98,7 @@ The default timezone can also be set globally by calling ``setTimezone()``::
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
+* ``locale``: The locale
 * ``dateFormat``: The date format
 * ``timeFormat``: The time format
 * ``pattern``: A date time pattern
@@ -106,4 +106,3 @@ Arguments
 * ``calendar``: The calendar ("gregorian" by default)
 
 .. _ICU user guide: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
diff --git a/doc/filters/format_number.rst b/doc/filters/format_number.rst
index e1bedc2f..900fa5ea 100644
--- a/doc/filters/format_number.rst
+++ b/doc/filters/format_number.rst
@@ -112,8 +112,6 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
+* ``locale``: The locale
 * ``attrs``: A map of attributes
 * ``style``: The style of the number output
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
diff --git a/doc/filters/format_time.rst b/doc/filters/format_time.rst
index 8709a6bc..1e213e61 100644
--- a/doc/filters/format_time.rst
+++ b/doc/filters/format_time.rst
@@ -29,10 +29,8 @@ the :doc:`format_datetime<format_datetime>` filter, but without the date.
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
+* ``locale``: The locale
 * ``timeFormat``: The time format
 * ``pattern``: A date time pattern
 * ``timezone``: The date timezone
 * ``calendar``: The calendar ("gregorian" by default)
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
diff --git a/doc/filters/language_name.rst b/doc/filters/language_name.rst
index ccffd20e..55c24392 100644
--- a/doc/filters/language_name.rst
+++ b/doc/filters/language_name.rst
@@ -1,8 +1,8 @@
 ``language_name``
 =================
 
-The ``language_name`` filter returns the language name based on its ISO 639-1
-code, ISO 639-2 code, or other specific localized code:
+The ``language_name`` filter returns the language name given its two-letter
+code:
 
 .. code-block:: twig
 
@@ -44,6 +44,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/locale_name.rst b/doc/filters/locale_name.rst
index 9e0df073..c6d34cbc 100644
--- a/doc/filters/locale_name.rst
+++ b/doc/filters/locale_name.rst
@@ -1,7 +1,8 @@
 ``locale_name``
 ===============
 
-The ``locale_name`` filter returns the locale name given its code:
+The ``locale_name`` filter returns the locale name given its two-letter
+code:
 
 .. code-block:: twig
 
@@ -43,6 +44,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/filters/slug.rst b/doc/filters/slug.rst
index f5b91b2d..773a42fa 100644
--- a/doc/filters/slug.rst
+++ b/doc/filters/slug.rst
@@ -56,6 +56,4 @@ Arguments
 ---------
 
 * ``separator``: The separator that is used to join words (defaults to ``-``)
-* ``locale``: The locale code of the original string as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale of the original string (if none is specified, it will be automatically detected)
diff --git a/doc/filters/timezone_name.rst b/doc/filters/timezone_name.rst
index 26c6f891..dfb22818 100644
--- a/doc/filters/timezone_name.rst
+++ b/doc/filters/timezone_name.rst
@@ -1,7 +1,7 @@
 ``timezone_name``
 =================
 
-The ``timezone_name`` filter returns the timezone name given its ISO 8601 timezone identifier:
+The ``timezone_name`` filter returns the timezone name given a timezone identifier:
 
 .. code-block:: twig
 
@@ -43,6 +43,4 @@ By default, the filter uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/country_names.rst b/doc/functions/country_names.rst
index f65b265e..692137b0 100644
--- a/doc/functions/country_names.rst
+++ b/doc/functions/country_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/country_timezones.rst b/doc/functions/country_timezones.rst
index 370a4bef..ecbbc1c9 100644
--- a/doc/functions/country_timezones.rst
+++ b/doc/functions/country_timezones.rst
@@ -2,15 +2,13 @@
 =====================
 
 The ``country_timezones`` function returns the names of the timezones associated
-with a given country its ISO-3166 code:
+with a given country code:
 
 .. code-block:: twig
 
     {# Europe/Paris #}
     {{ country_timezones('FR')|join(', ') }}
 
-If the specified country were to be unknown, it will return an empty array
-
 .. note::
 
     The ``country_timezones`` function is part of the ``IntlExtension`` which is not
@@ -32,8 +30,3 @@ If the specified country were to be unknown, it will return an empty array
 
         $twig = new \Twig\Environment(...);
         $twig->addExtension(new IntlExtension());
-
-Arguments
----------
-
-* ``country``: The country code
\ No newline at end of file
diff --git a/doc/functions/currency_names.rst b/doc/functions/currency_names.rst
index 9113aa08..dfb446c8 100644
--- a/doc/functions/currency_names.rst
+++ b/doc/functions/currency_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/language_names.rst b/doc/functions/language_names.rst
index 145a4955..f1cce488 100644
--- a/doc/functions/language_names.rst
+++ b/doc/functions/language_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/locale_names.rst b/doc/functions/locale_names.rst
index b8597f98..320ab672 100644
--- a/doc/functions/locale_names.rst
+++ b/doc/functions/locale_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/script_names.rst b/doc/functions/script_names.rst
index cddf9310..79b20c65 100644
--- a/doc/functions/script_names.rst
+++ b/doc/functions/script_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/doc/functions/timezone_names.rst b/doc/functions/timezone_names.rst
index 98c5871e..69db196f 100644
--- a/doc/functions/timezone_names.rst
+++ b/doc/functions/timezone_names.rst
@@ -44,6 +44,4 @@ By default, the function uses the current locale. You can pass it explicitly:
 Arguments
 ---------
 
-* ``locale``: The locale code as defined in `RFC 5646`_
-
-.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+* ``locale``: The locale
diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php
index cd1b1993..97e3ae00 100644
--- a/src/ExpressionParser.php
+++ b/src/ExpressionParser.php
@@ -172,7 +172,12 @@ class ExpressionParser
             $expr = $this->parseExpression();
             $this->parser->getStream()->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed');
 
-            return $this->parsePostfixExpression($expr);
+            $expr = $this->parsePostfixExpression($expr);
+            if ($expr instanceof NegUnary) {
+                $expr->wrapInParentheses();
+            }
+
+            return $expr;
         }
 
         return $this->parsePrimaryExpression();
diff --git a/src/Node/Expression/Unary/AbstractUnary.php b/src/Node/Expression/Unary/AbstractUnary.php
index e31e3f84..b9b44ed4 100644
--- a/src/Node/Expression/Unary/AbstractUnary.php
+++ b/src/Node/Expression/Unary/AbstractUnary.php
@@ -20,14 +20,26 @@ abstract class AbstractUnary extends AbstractExpression
 {
     public function __construct(Node $node, int $lineno)
     {
-        parent::__construct(['node' => $node], [], $lineno);
+        parent::__construct(['node' => $node], ['with_parentheses' => false], $lineno);
+    }
+
+    public function wrapInParentheses(): void
+    {
+        $this->setAttribute('with_parentheses', true);
     }
 
     public function compile(Compiler $compiler): void
     {
-        $compiler->raw(' ');
+        if ($this->getAttribute('with_parentheses')) {
+            $compiler->raw('(');
+        } else {
+            $compiler->raw(' ');
+        }
         $this->operator($compiler);
         $compiler->subcompile($this->getNode('node'));
+        if ($this->getAttribute('with_parentheses')) {
+            $compiler->raw(')');
+        }
     }
 
     abstract public function operator(Compiler $compiler): Compiler;


Test patch:
diff --git a/tests/Fixtures/expressions/power.test b/tests/Fixtures/expressions/power.test
index 84fd2369..5fb3fa4b 100644
--- a/tests/Fixtures/expressions/power.test
+++ b/tests/Fixtures/expressions/power.test
@@ -8,6 +8,10 @@ Twig parses power expressions
 {{ a ** b }}
 {{ b ** a }}
 {{ b ** b }}
+{{ -1**0 }}
+{{ (-1)**0 }}
+{{ -a**0 }}
+{{ (-a)**0 }}
 --DATA--
 return ['a' => 4, 'b' => -2]
 --EXPECT--
@@ -18,3 +22,7 @@ return ['a' => 4, 'b' => -2]
 0.0625
 16
 0.25
+-1
+1
+-1
+1