Repository: twigphp/Twig (php)

Issue:
Check data types in default CoreExtension
I am currently upgrading a project for PHP 8.1. I get lot of mb_strtolower(): Passing null to parameter #1 ($string) of type string is deprecated in vendor/twig/twig/src/Extension/CoreExtension.php on line 1098

It's pretty hard to find which template/variable is triggern this. Maybe it makes sense to check the type in the CoreExtension and throw an error?

Pull request description:
Allow null when Twig expects a string
To ease the transition to PHP 8.1, Twig now explicitly accepts `null` in addition to strings in filters that expect strings.
 
Closes #3615
Closes #3557
Closes #3610
Closes #3609


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index a5f36d6e..5ebd566f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,6 @@
-# 2.14.9 (2021-XX-XX)
+# 2.14.9 (2022-XX-XX)
 
-* n/a
+* Allow null when Twig expects a string (for better 8.1 support)
 
 # 2.14.8 (2021-11-25)
 
diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index 883ff4d6..0f77c52b 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -543,7 +543,7 @@ function twig_date_converter(Environment $env, $date = null, $timezone = null)
 /**
  * Replaces strings within a string.
  *
- * @param string             $str  String to replace in
+ * @param string|null        $str  String to replace in
  * @param array|\Traversable $from Replace values
  *
  * @return string
@@ -554,15 +554,15 @@ function twig_replace_filter($str, $from)
         throw new RuntimeError(sprintf('The "replace" filter expects an array or "Traversable" as replace values, got "%s".', \is_object($from) ? \get_class($from) : \gettype($from)));
     }
 
-    return strtr($str, twig_to_array($from));
+    return strtr($str ?? '', twig_to_array($from));
 }
 
 /**
  * Rounds a number.
  *
- * @param int|float $value     The value to round
- * @param int|float $precision The rounding precision
- * @param string    $method    The method to use for rounding
+ * @param int|float|string|null $value     The value to round
+ * @param int|float             $precision The rounding precision
+ * @param string                $method    The method to use for rounding
  *
  * @return int|float The rounded number
  */
@@ -576,6 +576,8 @@ function twig_round($value, $precision = 0, $method = 'common')
         throw new RuntimeError('The round filter only supports the "common", "ceil", and "floor" methods.');
     }
 
+    $value = (float) $value;
+
     return $method($value * 10 ** $precision) / 10 ** $precision;
 }
 
@@ -583,7 +585,7 @@ function twig_round($value, $precision = 0, $method = 'common')
  * Number format filter.
  *
  * All of the formatting options can be left null, in that case the defaults will
- * be used.  Supplying any of the parameters will override the defaults set in the
+ * be used. Supplying any of the parameters will override the defaults set in the
  * environment object.
  *
  * @param mixed  $number       A float/int/string of the number to format
@@ -614,7 +616,7 @@ function twig_number_format_filter(Environment $env, $number, $decimal = null, $
 /**
  * URL encodes (RFC 3986) a string as a path segment or an array as a query string.
  *
- * @param string|array $url A URL or an array of query parameters
+ * @param string|array|null $url A URL or an array of query parameters
  *
  * @return string The URL encoded value
  */
@@ -624,7 +626,7 @@ function twig_urlencode_filter($url)
         return http_build_query($url, '', '&', \PHP_QUERY_RFC3986);
     }
 
-    return rawurlencode($url);
+    return rawurlencode($url ?? '');
 }
 
 /**
@@ -686,9 +688,7 @@ function twig_slice(Environment $env, $item, $start, $length = null, $preserveKe
         return \array_slice($item, $start, $length, $preserveKeys);
     }
 
-    $item = (string) $item;
-
-    return (string) mb_substr($item, $start, $length, $env->getCharset());
+    return (string) mb_substr((string) $item, $start, $length, $env->getCharset());
 }
 
 /**
@@ -777,14 +777,16 @@ function twig_join_filter($value, $glue = '', $and = null)
  *  {{ "aabbcc"|split('', 2) }}
  *  {# returns [aa, bb, cc] #}
  *
- * @param string $value     A string
- * @param string $delimiter The delimiter
- * @param int    $limit     The limit
+ * @param string|null $value     A string
+ * @param string      $delimiter The delimiter
+ * @param int         $limit     The limit
  *
  * @return array The split string as an array
  */
 function twig_split_filter(Environment $env, $value, $delimiter, $limit = null)
 {
+    $value = $value ?? '';
+
     if (\strlen($delimiter) > 0) {
         return null === $limit ? explode($delimiter, $value) : explode($delimiter, $value, $limit);
     }
@@ -870,8 +872,8 @@ function twig_get_array_keys_filter($array)
 /**
  * Reverses a variable.
  *
- * @param array|\Traversable|string $item         An array, a \Traversable instance, or a string
- * @param bool                      $preserveKeys Whether to preserve key or not
+ * @param array|\Traversable|string|null $item         An array, a \Traversable instance, or a string
+ * @param bool                           $preserveKeys Whether to preserve key or not
  *
  * @return mixed The reversed input
  */
@@ -890,10 +892,10 @@ function twig_reverse_filter(Environment $env, $item, $preserveKeys = false)
     $charset = $env->getCharset();
 
     if ('UTF-8' !== $charset) {
-        $item = twig_convert_encoding($string, 'UTF-8', $charset);
+        $string = twig_convert_encoding($string, 'UTF-8', $charset);
     }
 
-    preg_match_all('/./us', $item, $matches);
+    preg_match_all('/./us', $string, $matches);
 
     $string = implode('', array_reverse($matches[0]));
 
@@ -968,6 +970,10 @@ function twig_in_filter($value, $compare)
 /**
  * Returns a trimmed string.
  *
+ * @param string|null $string
+ * @param string|null $characterMask
+ * @param string      $side
+ *
  * @return string
  *
  * @throws RuntimeError When an invalid trimming side is used (not a string or not 'left', 'right', or 'both')
@@ -980,11 +986,11 @@ function twig_trim_filter($string, $characterMask = null, $side = 'both')
 
     switch ($side) {
         case 'both':
-            return trim($string, $characterMask);
+            return trim($string ?? '', $characterMask);
         case 'left':
-            return ltrim($string, $characterMask);
+            return ltrim($string ?? '', $characterMask);
         case 'right':
-            return rtrim($string, $characterMask);
+            return rtrim($string ?? '', $characterMask);
         default:
             throw new RuntimeError('Trimming side must be "left", "right" or "both".');
     }
@@ -993,20 +999,29 @@ function twig_trim_filter($string, $characterMask = null, $side = 'both')
 /**
  * Removes whitespaces between HTML tags.
  *
+ * @param string|null $string
+ *
  * @return string
  */
 function twig_spaceless($content)
 {
-    return trim(preg_replace('/>\s+</', '><', $content));
+    return trim(preg_replace('/>\s+</', '><', $content ?? ''));
 }
 
+/**
+ * @param string|null $string
+ * @param string      $to
+ * @param string      $from
+ *
+ * @return string
+ */
 function twig_convert_encoding($string, $to, $from)
 {
     if (!\function_exists('iconv')) {
         throw new RuntimeError('Unable to convert encoding: required function iconv() does not exist. You should install ext-iconv or symfony/polyfill-iconv.');
     }
 
-    return iconv($from, $to, $string);
+    return iconv($from, $to, $string ?? '');
 }
 
 /**
@@ -1044,47 +1059,47 @@ function twig_length_filter(Environment $env, $thing)
 /**
  * Converts a string to uppercase.
  *
- * @param string $string A string
+ * @param string|null $string A string
  *
  * @return string The uppercased string
  */
 function twig_upper_filter(Environment $env, $string)
 {
-    return mb_strtoupper($string, $env->getCharset());
+    return mb_strtoupper($string ?? '', $env->getCharset());
 }
 
 /**
  * Converts a string to lowercase.
  *
- * @param string $string A string
+ * @param string|null $string A string
  *
  * @return string The lowercased string
  */
 function twig_lower_filter(Environment $env, $string)
 {
-    return mb_strtolower($string, $env->getCharset());
+    return mb_strtolower($string ?? '', $env->getCharset());
 }
 
 /**
  * Returns a titlecased string.
  *
- * @param string $string A string
+ * @param string|null $string A string
  *
  * @return string The titlecased string
  */
 function twig_title_string_filter(Environment $env, $string)
 {
     if (null !== $charset = $env->getCharset()) {
-        return mb_convert_case($string, \MB_CASE_TITLE, $charset);
+        return mb_convert_case($string ?? '', \MB_CASE_TITLE, $charset);
     }
 
-    return ucwords(strtolower($string));
+    return ucwords(strtolower($string ?? ''));
 }
 
 /**
  * Returns a capitalized string.
  *
- * @param string $string A string
+ * @param string|null $string A string
  *
  * @return string The capitalized string
  */
@@ -1092,7 +1107,7 @@ function twig_capitalize_string_filter(Environment $env, $string)
 {
     $charset = $env->getCharset();
 
-    return mb_strtoupper(mb_substr($string, 0, 1, $charset), $charset).mb_strtolower(mb_substr($string, 1, null, $charset), $charset);
+    return mb_strtoupper(mb_substr($string ?? '', 0, 1, $charset), $charset).mb_strtolower(mb_substr($string ?? '', 1, null, $charset), $charset);
 }
 
 /**


Test patch:
diff --git a/tests/Fixtures/filters/capitalize.test b/tests/Fixtures/filters/capitalize.test
new file mode 100644
index 00000000..bea82ba7
--- /dev/null
+++ b/tests/Fixtures/filters/capitalize.test
@@ -0,0 +1,14 @@
+--TEST--
+"capitalize" filter
+--TEMPLATE--
+{{ "super helpful"|capitalize }}
+{{ "a"|capitalize }}
+*{{ ""|capitalize }}*
+*{{ null|capitalize }}*
+--DATA--
+return []
+--EXPECT--
+Super helpful
+A
+**
+**
diff --git a/tests/Fixtures/filters/convert_encoding.test b/tests/Fixtures/filters/convert_encoding.test
index 6f0091f3..a2a32485 100644
--- a/tests/Fixtures/filters/convert_encoding.test
+++ b/tests/Fixtures/filters/convert_encoding.test
@@ -2,7 +2,11 @@
 "convert_encoding" filter
 --TEMPLATE--
 {{ "愛していますか？"|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }}
+*{{ ""|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }}*
+*{{ null|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }}*
 --DATA--
 return []
 --EXPECT--
 愛していますか？
+**
+**
diff --git a/tests/Fixtures/filters/escape.test b/tests/Fixtures/filters/escape.test
index 131f5b4e..b5b3f90f 100644
--- a/tests/Fixtures/filters/escape.test
+++ b/tests/Fixtures/filters/escape.test
@@ -2,7 +2,11 @@
 "escape" filter
 --TEMPLATE--
 {{ "foo <br />"|e }}
+*{{ ""|e }}*
+*{{ null|e }}*
 --DATA--
 return []
 --EXPECT--
 foo &lt;br /&gt;
+**
+**
diff --git a/tests/Fixtures/filters/first.test b/tests/Fixtures/filters/first.test
index b19f2eed..6ffc1891 100644
--- a/tests/Fixtures/filters/first.test
+++ b/tests/Fixtures/filters/first.test
@@ -6,7 +6,8 @@
 {{ '1234'|first }}
 {{ arr|first }}
 {{ 'Ä€é'|first }}
-{{ ''|first }}
+*{{ ''|first }}*
+*{{ null|first }}*
 --DATA--
 return ['arr' => new \ArrayObject([1, 2, 3, 4])]
 --EXPECT--
@@ -15,3 +16,5 @@ return ['arr' => new \ArrayObject([1, 2, 3, 4])]
 1
 1
 Ä
+**
+**
diff --git a/tests/Fixtures/filters/last.test b/tests/Fixtures/filters/last.test
index f71896c7..2fdc63b3 100644
--- a/tests/Fixtures/filters/last.test
+++ b/tests/Fixtures/filters/last.test
@@ -6,7 +6,8 @@
 {{ '1234'|last }}
 {{ arr|last }}
 {{ 'Ä€é'|last }}
-{{ ''|last }}
+*{{ ''|last }}*
+*{{ null|last }}*
 --DATA--
 return ['arr' => new \ArrayObject([1, 2, 3, 4])]
 --EXPECT--
@@ -15,3 +16,5 @@ return ['arr' => new \ArrayObject([1, 2, 3, 4])]
 4
 4
 é
+**
+**
diff --git a/tests/Fixtures/filters/length.test b/tests/Fixtures/filters/length.test
index eb3f0233..6e0cd992 100644
--- a/tests/Fixtures/filters/length.test
+++ b/tests/Fixtures/filters/length.test
@@ -7,6 +7,7 @@
 {{ to_string_able|length }}
 {{ countable|length }}
 {{ iterator_aggregate|length }}
+{{ ""|length }}
 {{ null|length }}
 {{ magic|length }}
 {{ non_countable|length }}
@@ -34,6 +35,7 @@ return [
 42
 3
 0
+0
 1
 1
 2
diff --git a/tests/Fixtures/filters/lower.test b/tests/Fixtures/filters/lower.test
new file mode 100644
index 00000000..ec501e7c
--- /dev/null
+++ b/tests/Fixtures/filters/lower.test
@@ -0,0 +1,12 @@
+--TEST--
+"lower" filter
+--TEMPLATE--
+{{ "I like Twig."|lower }}
+*{{ ""|lower }}*
+*{{ null|lower }}*
+--DATA--
+return []
+--EXPECT--
+i like twig.
+**
+**
diff --git a/tests/Fixtures/filters/number_format.test b/tests/Fixtures/filters/number_format.test
index 7f1e2e16..3d12a789 100644
--- a/tests/Fixtures/filters/number_format.test
+++ b/tests/Fixtures/filters/number_format.test
@@ -7,6 +7,9 @@
 {{ 20.25|number_format(2, ',') }}
 {{ 1020.25|number_format(2, ',') }}
 {{ 1020.25|number_format(2, ',', '.') }}
+{{ '1020.25'|number_format(2, ',', '.') }}
+{{ ''|number_format(2, ',', '.') }}
+{{ null|number_format(2, ',', '.') }}
 --DATA--
 return []
 --EXPECT--
@@ -16,3 +19,6 @@ return []
 20,25
 1,020,25
 1.020,25
+1.020,25
+0,00
+0,00
diff --git a/tests/Fixtures/filters/replace.test b/tests/Fixtures/filters/replace.test
index 1b9670a1..3b430cd5 100644
--- a/tests/Fixtures/filters/replace.test
+++ b/tests/Fixtures/filters/replace.test
@@ -4,9 +4,13 @@
 {{ "I liké %this% and %that%."|replace({'%this%': "foo", '%that%': "bar"}) }}
 {{ 'I like single replace operation only %that%'|replace({'%that%' : '%that%1'}) }}
 {{ 'I like %this% and %that%.'|replace(traversable) }}
+*{{ ''|replace(traversable) }}*
+*{{ null|replace(traversable) }}*
 --DATA--
 return ['traversable' => new \ArrayObject(['%this%' => 'foo', '%that%' => 'bar'])]
 --EXPECT--
 I liké foo and bar.
 I like single replace operation only %that%1
 I like foo and bar.
+**
+**
diff --git a/tests/Fixtures/filters/reverse.test b/tests/Fixtures/filters/reverse.test
index 904e5839..42b4dcc6 100644
--- a/tests/Fixtures/filters/reverse.test
+++ b/tests/Fixtures/filters/reverse.test
@@ -7,6 +7,8 @@
 {{ {'a': 'c', 'b': 'a'}|reverse()|join(',') }}
 {{ {'a': 'c', 'b': 'a'}|reverse(preserveKeys=true)|join(glue=',') }}
 {{ {'a': 'c', 'b': 'a'}|reverse(preserve_keys=true)|join(glue=',') }}
+*{{ ''|reverse }}*
+*{{ null|reverse }}*
 --DATA--
 return ['arr' => new \ArrayObject([1, 2, 3, 4])]
 --EXPECT--
@@ -16,3 +18,5 @@ tnemenèvé4321
 a,c
 a,c
 a,c
+**
+**
diff --git a/tests/Fixtures/filters/round.test b/tests/Fixtures/filters/round.test
index 70923754..bb6afcf4 100644
--- a/tests/Fixtures/filters/round.test
+++ b/tests/Fixtures/filters/round.test
@@ -9,6 +9,10 @@
 {{ 21.3|round(-1)}}
 {{ 21.3|round(-1, 'ceil')}}
 {{ 21.3|round(-1, 'floor')}}
+{{ '21.3'|round(-1, 'floor')}}
+
+{{ ''|round(-1, 'floor')}}
+{{ null|round(-1, 'floor')}}
 --DATA--
 return []
 --EXPECT--
@@ -20,3 +24,7 @@ return []
 20
 30
 20
+20
+
+0
+0
diff --git a/tests/Fixtures/filters/spaceless.test b/tests/Fixtures/filters/spaceless.test
index eadc1d49..166a7ea0 100644
--- a/tests/Fixtures/filters/spaceless.test
+++ b/tests/Fixtures/filters/spaceless.test
@@ -2,7 +2,11 @@
 "spaceless" filter
 --TEMPLATE--
 {{ "    <div>   <div>   foo   </div>   </div>"|spaceless }}
+*{{ ""|spaceless }}*
+*{{ null|spaceless }}*
 --DATA--
 return []
 --EXPECT--
 <div><div>   foo   </div></div>
+**
+**
diff --git a/tests/Fixtures/filters/split.test b/tests/Fixtures/filters/split.test
index 2bd46afa..8b2a94d9 100644
--- a/tests/Fixtures/filters/split.test
+++ b/tests/Fixtures/filters/split.test
@@ -9,6 +9,8 @@
 {{ baz|split('', 2)|join('-') }}
 {{ foo|split(',', -2)|join('-') }}
 {{ "hello0world"|split('0')|join('-') }}
+*{{ ""|split(',')|join('-') }}*
+*{{ null|split(',')|join('-') }}*
 --DATA--
 return ['foo' => "one,two,three,four,five", 'baz' => '12345',]
 --EXPECT--
@@ -19,4 +21,6 @@ one-two-three,four,five
 1-2-3-4-5
 12-34-5
 one-two-three
-hello-world
\ No newline at end of file
+hello-world
+**
+**
diff --git a/tests/Fixtures/filters/title.test b/tests/Fixtures/filters/title.test
new file mode 100644
index 00000000..6f1c0c3e
--- /dev/null
+++ b/tests/Fixtures/filters/title.test
@@ -0,0 +1,12 @@
+--TEST--
+"title" filter
+--TEMPLATE--
+{{ "I like Twig."|title }}
+*{{ ""|title }}*
+*{{ null|title }}*
+--DATA--
+return []
+--EXPECT--
+I Like Twig.
+**
+**
diff --git a/tests/Fixtures/filters/trim.test b/tests/Fixtures/filters/trim.test
index 432989ff..141f8635 100644
--- a/tests/Fixtures/filters/trim.test
+++ b/tests/Fixtures/filters/trim.test
@@ -10,6 +10,12 @@
 {{ "/  foo/"|trim("/", "left") }}
 {{ "/  foo/"|trim(character_mask="/", side="left") }}
 {{ "  do nothing.  "|trim("", "right") }}
+*{{ ""|trim }}*
+*{{ ""|trim("", "left") }}*
+*{{ ""|trim("", "right") }}*
+*{{ null|trim }}*
+*{{ null|trim("", "left") }}*
+*{{ null|trim("", "right") }}*
 --DATA--
 return ['text' => "  If you have some <strong>HTML</strong> it will be escaped.  "]
 --EXPECT--
@@ -22,3 +28,9 @@ xxxI like Twig.
   foo/
   foo/
   do nothing.  
+**
+**
+**
+**
+**
+**
diff --git a/tests/Fixtures/filters/upper.test b/tests/Fixtures/filters/upper.test
new file mode 100644
index 00000000..df415075
--- /dev/null
+++ b/tests/Fixtures/filters/upper.test
@@ -0,0 +1,12 @@
+--TEST--
+"upper" filter
+--TEMPLATE--
+{{ "I like Twig."|upper }}
+*{{ ""|upper }}*
+*{{ null|upper }}*
+--DATA--
+return []
+--EXPECT--
+I LIKE TWIG.
+**
+**
diff --git a/tests/Fixtures/filters/urlencode.test b/tests/Fixtures/filters/urlencode.test
index ac236990..5db2e2de 100644
--- a/tests/Fixtures/filters/urlencode.test
+++ b/tests/Fixtures/filters/urlencode.test
@@ -5,6 +5,8 @@
 {{ {foo: "bar", number: 3, "spéßi%l": "e%c0d@d", "spa ce": ""}|url_encode|raw }}
 {{ {}|url_encode|default("default") }}
 {{ 'spéßi%le%c0d@dspa ce'|url_encode }}
+*{{ ''|url_encode }}*
+*{{ null|url_encode }}*
 --DATA--
 return []
 --EXPECT--
@@ -12,3 +14,5 @@ foo=bar&amp;number=3&amp;sp%C3%A9%C3%9Fi%25l=e%25c0d%40d&amp;spa%20ce=
 foo=bar&number=3&sp%C3%A9%C3%9Fi%25l=e%25c0d%40d&spa%20ce=
 default
 sp%C3%A9%C3%9Fi%25le%25c0d%40dspa%20ce
+**
+**