From 28c2606be7fa240ae884d0b893a875f0ec9c46dc Mon Sep 17 00:00:00 2001 From: Always-Victorious <213483686+Always-Victorious@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:43:42 +0100 Subject: [PATCH 1/3] Update u.rst to clarify truncate method's third argument behavior Current wording "to preserve whole words" suggests setting the argument to ``true`` preserves words. However, the parameter is ``$cut`` (defaults to ``true`` for cutting at exact length), and ``false`` is required to preserve whole words. --- doc/filters/u.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/filters/u.rst b/doc/filters/u.rst index f27cb1812..db5d05cb9 100644 --- a/doc/filters/u.rst +++ b/doc/filters/u.rst @@ -31,7 +31,7 @@ Truncating a string: {{ 'Lorem ipsum'|u.truncate(8, '...') }} Lorem... -The ``truncate`` method also accepts a third argument to preserve whole words: +By default, ``truncate`` cuts text at the exact length. Pass ``false`` as the third argument to preserve whole words: .. code-block:: twig From 263c04fd1f08650f3efc6433793146b704cffba2 Mon Sep 17 00:00:00 2001 From: Felds Liscia Date: Mon, 1 Dec 2025 15:15:39 -0300 Subject: [PATCH 2/3] Add null-safe operator --- .../Tests/Fixtures/script_names.test | 8 +-- .../Infix/DotExpressionParser.php | 10 +++- src/Node/Expression/GetAttrExpression.php | 30 ++++++++-- tests/ExpressionParserTest.php | 56 +++++++++++++++++++ .../expressions/dot_as_concatenation.test | 2 +- tests/Node/Expression/GetAttrTest.php | 5 ++ 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/extra/intl-extra/Tests/Fixtures/script_names.test b/extra/intl-extra/Tests/Fixtures/script_names.test index 798881681..da6c022a1 100644 --- a/extra/intl-extra/Tests/Fixtures/script_names.test +++ b/extra/intl-extra/Tests/Fixtures/script_names.test @@ -2,15 +2,15 @@ "script_names" function --TEMPLATE-- {{ script_names('UNKNOWN')|length }} -{{ script_names() is iterable }} -{{ script_names('fr') is iterable }} +{{ script_names()|length > 200 ? 'more than 200' : 'less than 200' }} +{{ script_names('fr')|length > 200 ? 'more than 200' : 'less than 200' }} {{ script_names()['Marc'] }} {{ script_names('fr')['Marc'] }} --DATA-- return []; --EXPECT-- 0 -1 -1 +more than 200 +more than 200 Marchen Marchen diff --git a/src/ExpressionParser/Infix/DotExpressionParser.php b/src/ExpressionParser/Infix/DotExpressionParser.php index 7d1cf5058..31418b585 100644 --- a/src/ExpressionParser/Infix/DotExpressionParser.php +++ b/src/ExpressionParser/Infix/DotExpressionParser.php @@ -37,6 +37,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi public function parse(Parser $parser, AbstractExpression $expr, Token $token): AbstractExpression { + $nullSafe = '?.' === $token->getValue(); $stream = $parser->getStream(); $token = $stream->getCurrent(); $lineno = $token->getLine(); @@ -55,7 +56,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi ) { $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()); + throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type "%s".', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); } } @@ -74,7 +75,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi 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); + return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe); } public function getName(): string @@ -82,6 +83,11 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi return '.'; } + public function getAliases(): array + { + return ['?.']; + } + public function getDescription(): string { return 'Get an attribute on a variable'; diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 1222d7759..ac8ef1105 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -4,7 +4,6 @@ * 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. @@ -25,7 +24,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest /** * @param ArrayExpression|NameExpression|null $arguments */ - public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno) + public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno, bool $nullSafe = false) { $nodes = ['node' => $node, 'attribute' => $attribute]; if (null !== $arguments) { @@ -36,7 +35,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class)); } - parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => true], $lineno); + parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe], $lineno); } public function enableDefinedTest(): void @@ -49,6 +48,8 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest { $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; + $nullSafe = $this->getAttribute('null_safe'); + $objectVar = null; // optimize array calls if ( @@ -93,14 +94,27 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ; } - $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); - if ($this->getAttribute('ignore_strict_check')) { $this->getNode('node')->setAttribute('ignore_strict_check', true); } + if ($nullSafe) { + $objectVar = '$'.$compiler->getVarName(); + $compiler + ->raw('((null === ('.$objectVar.' = ') + ->subcompile($this->getNode('node')) + ->raw(')) ? null : '); + } + + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + + if ($nullSafe) { + $compiler->raw($objectVar); + } else { + $compiler->subcompile($this->getNode('node')); + } + $compiler - ->subcompile($this->getNode('node')) ->raw(', ') ->subcompile($this->getNode('attribute')) ; @@ -123,6 +137,10 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest if ($arrayAccessSandbox) { $compiler->raw(')'); } + + if ($nullSafe) { + $compiler->raw(')'); + } } private function changeIgnoreStrictCheck(self $node): void diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 4f1685820..5da06b68c 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -288,6 +288,62 @@ class ExpressionParserTest extends TestCase ]; } + /** + * @dataProvider getTestsForNullSafeOperator + */ + public function testNullSafeOperator($template, $data, $expected) + { + $env = new Environment(new ArrayLoader(['template' => $template])); + + $this->assertSame($expected, $env->render('template', $data)); + } + + public static function getTestsForNullSafeOperator() + { + return [ + [ + '{{ foo?.bar }}', + ['foo' => (object) ['bar' => 'baz']], + 'baz', + ], + [ + '{{ foo?.bar }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => (object) ['bar' => (object) ['baz' => 'qux']]], + 'qux', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => (object) ['bar' => null]], + '', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar?.baz ?? "qux" }}', + ['foo' => null], + 'qux', + ], + [ + '{{ foo?.bar ?? "qux" }}', + ['foo' => (object) ['bar' => 0]], + '0', + ], + [ + '{{ foo?.bar ?? "qux" }}', + ['foo' => (object) ['bar' => false]], + '', + ], + ]; + } + public function testMacroDefinitionDoesNotSupportNonNameVariableName() { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); diff --git a/tests/Fixtures/expressions/dot_as_concatenation.test b/tests/Fixtures/expressions/dot_as_concatenation.test index 7aa1403b6..4a24a544f 100644 --- a/tests/Fixtures/expressions/dot_as_concatenation.test +++ b/tests/Fixtures/expressions/dot_as_concatenation.test @@ -5,4 +5,4 @@ Twig does not support using . for concatenation --DATA-- return [] --EXCEPTION-- -Twig\Error\SyntaxError: Expected name or number, got value "b" of type string in "index.twig" at line 2. +Twig\Error\SyntaxError: Expected name or number, got value "b" of type "string" in "index.twig" at line 2. diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 69f3d0a59..6b2d92f61 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -42,6 +42,7 @@ class GetAttrTest extends NodeTestCase $this->assertEquals($attr, $node->getNode('attribute')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals(Template::ARRAY_CALL, $node->getAttribute('type')); + $this->assertFalse($node->getAttribute('null_safe')); } public static function provideTests(): iterable @@ -51,9 +52,13 @@ class GetAttrTest extends NodeTestCase $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 1); $args = new ArrayExpression([], 1); + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1); $tests[] = [$node, \sprintf('%s%s, "bar", [], "any", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1))]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1))', null, true]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); $tests[] = [$node, '(($_v%s = // line 1'."\n". '($context["foo"] ?? null)) && is_array($_v%s) || $_v%s instanceof ArrayAccess ? ($_v%s["bar"] ?? null) : null)', null, true, ]; From 690495416581594204ff9649ae4687268df09cd5 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 17 Jan 2026 15:08:58 +0100 Subject: [PATCH 3/3] Tweak null-safe operator implementation --- CHANGELOG | 1 + bin/generate_operators_precedence.php | 20 ++++++++++++---- doc/operators_precedence.rst | 12 ++++------ doc/templates.rst | 29 ++++++++++++++++++++--- src/Node/Expression/GetAttrExpression.php | 1 + 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fd8013a36..c78ad440d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.23.0 (2026-XX-XX) + * Add `?.` null-safe operator * Add `===` and `!==` operators (equivalent to the `same as` and `not same as` tests) # 3.22.2 (2025-12-14) diff --git a/bin/generate_operators_precedence.php b/bin/generate_operators_precedence.php index 185a0147e..5baa36dbf 100644 --- a/bin/generate_operators_precedence.php +++ b/bin/generate_operators_precedence.php @@ -23,9 +23,13 @@ $output = fopen(dirname(__DIR__).'/doc/operators_precedence.rst', 'w'); $twig = new Environment(new ArrayLoader([])); $descriptionLength = 11; $expressionParsers = []; +$seen = new SplObjectStorage(); foreach ($twig->getExpressionParsers() as $expressionParser) { - $expressionParsers[] = $expressionParser; - $descriptionLength = max($descriptionLength, $expressionParser instanceof ExpressionParserDescriptionInterface ? strlen($expressionParser->getDescription()) : ''); + if (!$seen->offsetExists($expressionParser)) { + $expressionParsers[] = $expressionParser; + $seen->offsetSet($expressionParser, true); + $descriptionLength = max($descriptionLength, $expressionParser instanceof ExpressionParserDescriptionInterface ? strlen($expressionParser->getDescription()) : ''); + } } fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); @@ -46,9 +50,13 @@ foreach ($expressionParsers as $expressionParser) { if ($previousPrecedence !== $precedence) { $previous = null; } + $operatorName = '``'.$expressionParser->getName().'``'; + if ($expressionParser->getAliases()) { + $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; + } fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", (!$previous || $previousPrecedence !== $precedence ? $precedence : '').($expressionParser->getPrecedenceChange() ? ' => '.$expressionParser->getPrecedenceChange()->getNewPrecedence() : ''), - '``'.$expressionParser->getName().'``', + $operatorName, !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', !$previous || $previousAssociativity !== $associativity ? $associativity : '', $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', @@ -83,9 +91,13 @@ foreach ($expressionParsers as $expressionParser) { if ($previousPrecedence !== $precedence) { $previous = null; } + $operatorName = '``'.$expressionParser->getName().'``'; + if ($expressionParser->getAliases()) { + $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; + } fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", !$previous || $previousPrecedence !== $precedence ? $precedence : '', - '``'.$expressionParser->getName().'``', + $operatorName, !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', !$previous || $previousAssociativity !== $associativity ? $associativity : '', $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', diff --git a/doc/operators_precedence.rst b/doc/operators_precedence.rst index c4e9fc514..2d445564e 100644 --- a/doc/operators_precedence.rst +++ b/doc/operators_precedence.rst @@ -8,7 +8,7 @@ +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``(`` | | | Twig function call | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``.`` | | | Get an attribute on a variable | +| | ``.``, ``?.`` | | | Get an attribute on a variable | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``[`` | | | Array access | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ @@ -88,9 +88,7 @@ +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 10 | ``or`` | infix | Left | | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| 5 | ``?:`` | infix | Right | Elvis operator (a ?: b) | -+------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``?:`` | | | Elvis operator (a ?: b) | +| 5 | ``?:``, ``? :`` | infix | Right | Elvis operator (a ?: b) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 0 | ``(`` | prefix | n/a | Explicit group expression (a) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ @@ -110,7 +108,7 @@ Here is the same table for Twig 4.0 with adjusted precedences: +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``(`` | infix | Left | Twig function call | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``.`` | | | Get an attribute on a variable | +| | ``.``, ``?.`` | | | Get an attribute on a variable | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``[`` | | | Array access | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ @@ -192,9 +190,7 @@ Here is the same table for Twig 4.0 with adjusted precedences: +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 5 | ``??`` | infix | Right | Null coalescing operator (a ?? b) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``?:`` | | | Elvis operator (a ?: b) | -+------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``?:`` | | | Elvis operator (a ?: b) | +| | ``?:``, ``? :`` | | | Elvis operator (a ?: b) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 0 | ``(`` | prefix | n/a | Explicit group expression (a) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ diff --git a/doc/templates.rst b/doc/templates.rst index fe0b1825b..d82557a2c 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -865,7 +865,7 @@ The following operators don't fit into any of the other categories: .. _dot_operator: -* ``.``, ``[]``: Gets an attribute of a variable. +* ``.``, ``?.``, ``[]``: Gets an attribute of a variable. The (``.``) operator abstracts getting an attribute of a variable (methods, properties or constants of a PHP object, or items of a PHP array): @@ -874,8 +874,23 @@ The following operators don't fit into any of the other categories: {{ user.name }} - After the ``.``, you can use any expression by wrapping it with parenthesis - ``()``. + The null-safe operator (``?.``) works like the dot operator but returns + ``null`` instead of throwing an exception when the left operand is ``null``: + + .. code-block:: twig + + {{ user?.name }} + {# returns null if user is null, otherwise returns user.name #} + + {{ user?.address?.city }} + {# can be chained for safe navigation through potentially null values #} + + .. versionadded:: 3.23 + + The null-safe operator was added in Twig 3.23. + + After the ``.`` or ``?.``, you can use any expression by wrapping it with + parenthesis ``()``. One use case is when the attribute contains special characters (like ``-`` that would be interpreted as the minus operator): @@ -884,6 +899,7 @@ The following operators don't fit into any of the other categories: {# equivalent to the non-working user.first-name #} {{ user.('first-name') }} + {{ user?.('first-name') }} Another use case is when the attribute is "dynamic" (defined via a variable): @@ -891,6 +907,7 @@ The following operators don't fit into any of the other categories: {{ user.(name) }} {{ user.('get' ~ name) }} + {{ user?.(name) }} Before Twig 3.15, use the :doc:`attribute ` function instead for the two previous use cases. @@ -926,6 +943,12 @@ The following operators don't fit into any of the other categories: * if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, throw an exception. + To resolve ``user?.name`` to a PHP call, Twig checks if ``user`` is + ``null`` first: + + * if ``user`` is ``null``, return ``null``; + * otherwise, use the same algorithm as for ``user.name``. + To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm at runtime: diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index ac8ef1105..f207b092c 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -4,6 +4,7 @@ * 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.