diff --git a/bin/generate_operators_precedence.php b/bin/generate_operators_precedence.php index f3d878d3e..fed05eede 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() : '', 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 diff --git a/doc/operators_precedence.rst b/doc/operators_precedence.rst index 460027b19..a0b73d545 100644 --- a/doc/operators_precedence.rst +++ b/doc/operators_precedence.rst @@ -6,7 +6,7 @@ +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``(`` | infix | Left | Twig function call | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``.`` | | | Get an attribute on a variable | +| | ``.``, ``?.`` | | | Get an attribute on a variable | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``[`` | | | Array access | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ @@ -86,9 +86,7 @@ +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | 10 | ``or`` | infix | Left | | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| 5 | ``?:`` | infix | Right | Elvis operator (a ?: b) | -+------------+------------------+---------+---------------+-------------------------------------------------------------------+ -| | ``?:`` | | | Elvis operator (a ?: b) | +| 5 | ``?:``, ``? :`` | infix | Right | Elvis operator (a ?: b) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ | | ``??`` | | | Null coalescing operator (a ?? b) | +------------+------------------+---------+---------------+-------------------------------------------------------------------+ diff --git a/doc/templates.rst b/doc/templates.rst index 3872513b5..2b7d9159e 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -851,7 +851,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): @@ -860,8 +860,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): @@ -870,6 +885,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): @@ -877,6 +893,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. @@ -912,6 +929,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/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 104b0c1f4..b75d3178b 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 ea8964fbf..9b2c90e5c 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -21,14 +21,14 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest { use SupportDefinedTestTrait; - public function __construct(AbstractExpression $node, AbstractExpression $attribute, ArrayExpression|ContextVariable|null $arguments, string $type, int $lineno) + public function __construct(AbstractExpression $node, AbstractExpression $attribute, ArrayExpression|ContextVariable|null $arguments, string $type, int $lineno, bool $nullSafe = false) { $nodes = ['node' => $node, 'attribute' => $attribute]; if (null !== $arguments) { $nodes['arguments'] = $arguments; } - 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 @@ -41,6 +41,8 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest { $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; + $nullSafe = $this->getAttribute('null_safe'); + $objectVar = null; // optimize array calls if ( @@ -85,14 +87,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')) ; @@ -125,6 +140,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 aacf8b8e7..29e445185 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -273,6 +273,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 c0e2dcc99..ee7934528 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", arguments: [], lineno: 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, ];