diff --git a/src/ExpressionParser/Infix/OptionalChainExpressionParser.php b/src/ExpressionParser/Infix/OptionalChainExpressionParser.php new file mode 100644 index 00000000000..244833f5c2e --- /dev/null +++ b/src/ExpressionParser/Infix/OptionalChainExpressionParser.php @@ -0,0 +1,103 @@ +getStream(); + $token = $stream->getCurrent(); + $lineno = $token->getLine(); + $arguments = new ArrayExpression([], $lineno); + $type = Template::ANY_CALL; + $isOptionalChain = true; + $isVariable = $expr instanceof NameExpression; + if ($stream->test(Token::OPERATOR_TYPE, '[')) { + $token = $stream->next(); + $attribute = $parser->parseExpression(); + $stream->expect(Token::PUNCTUATION_TYPE, ']'); + $type = Template::ARRAY_CALL; + } elseif ($stream->nextIf(Token::OPERATOR_TYPE, '(')) { + $attribute = $parser->parseExpression(); + $stream->expect(Token::PUNCTUATION_TYPE, ')'); + } else { + $token = $stream->next(); + if ( + $token->test(Token::NAME_TYPE) + || $token->test(Token::NUMBER_TYPE) + || ($token->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) + ) { + $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()); + } + } + + if ($stream->test(Token::OPERATOR_TYPE, '(')) { + $type = Template::METHOD_CALL; + $arguments = $this->parseCallableArguments($parser, $token->getLine()); + } + + if ( + $expr instanceof NameExpression + && ( + null !== $parser->getImportedSymbol('template', $expr->getAttribute('name')) + || '_self' === $expr->getAttribute('name') && $attribute instanceof ConstantExpression + ) + ) { + + return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine()); + } + + + if ($isVariable && $expr instanceof NameExpression) { + $expr->setAttribute('optional_chain', true); + } + + return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $isOptionalChain); + } + + public function getName(): string + { + return '?.'; + } + + public function getDescription(): string + { + return 'Optional chaining to safely access an attribute on a potentially null variable'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index f7e4250ae50..808012dbe13 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -24,6 +24,7 @@ use Twig\ExpressionParser\Infix\FunctionExpressionParser; use Twig\ExpressionParser\Infix\IsExpressionParser; use Twig\ExpressionParser\Infix\IsNotExpressionParser; +use Twig\ExpressionParser\Infix\OptionalChainExpressionParser; use Twig\ExpressionParser\Infix\SquareBracketExpressionParser; use Twig\ExpressionParser\InfixAssociativity; use Twig\ExpressionParser\PrecedenceChange; @@ -391,6 +392,7 @@ public function getExpressionParsers(): array // all literals new LiteralExpressionParser(), + new OptionalChainExpressionParser(), ]; } diff --git a/src/Lexer.php b/src/Lexer.php index 027771accb9..7438e2c3ace 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -61,6 +61,7 @@ class Lexer public const REGEX_INLINE_COMMENT = '/#[^\n]*/A'; public const PUNCTUATION = '()[]{}?:.,|'; + private const SPECIAL_CHARS = [ 'f' => "\f", 'n' => "\n", diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 781c8af3868..efffe400b90 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -25,7 +25,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 $isOptionalChain = false) { $nodes = ['node' => $node, 'attribute' => $attribute]; if (null !== $arguments) { @@ -36,7 +36,7 @@ public function __construct(AbstractExpression $node, AbstractExpression $attrib 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' => true, 'is_optional_chain' => $isOptionalChain], $lineno); } public function enableDefinedTest(): void @@ -50,7 +50,96 @@ public function compile(Compiler $compiler): void $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; - // optimize array calls + if ($this->getAttribute('is_optional_chain')) { + $var = '$'.$compiler->getVarName(); + + $isOptionalName = $this->getNode('node') instanceof NameExpression && + $this->getNode('node')->getAttribute('optional_chain', false); + + if ($isOptionalName) { + $compiler + ->raw('(array_key_exists(') + ->string($this->getNode('node')->getAttribute('name')) + ->raw(', $context) ? '); + + $compiler + ->raw('(null !== (') + ->raw($var) + ->raw(' = $context[') + ->string($this->getNode('node')->getAttribute('name')) + ->raw(']) ? '); + } else { + $compiler + ->raw('(null !== (') + ->raw($var) + ->raw(' = '); + + $this->getNode('node')->setAttribute('ignore_strict_check', true); + $compiler->subcompile($this->getNode('node')); + $compiler->raw(') ? '); + } + + if ($this->getAttribute('type') === Template::METHOD_CALL) { + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + $compiler + ->raw($var) + ->raw(', ') + ->subcompile($this->getNode('attribute')); + + if ($this->hasNode('arguments')) { + $compiler->raw(', ')->subcompile($this->getNode('arguments')); + } else { + $compiler->raw(', []'); + } + + $compiler->raw(', ') + ->repr($this->getAttribute('type')) + ->raw(', ')->repr($this->definedTest ?? false) + ->raw(', ')->repr(true) // ignore_strict_check = true для optional chaining + ->raw(', ')->repr($env->hasExtension(SandboxExtension::class)) + ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) + ->raw(')'); + } elseif ($this->getAttribute('type') === Template::ARRAY_CALL) { + $compiler->raw('(is_array(') + ->raw($var) + ->raw(') || ') + ->raw($var) + ->raw(' instanceof ArrayAccess ? (') + ->raw($var) + ->raw('[') + ->subcompile($this->getNode('attribute')) + ->raw('] ?? null) : null)'); + } else { + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + $compiler + ->raw($var) + ->raw(', ') + ->subcompile($this->getNode('attribute')); + + if ($this->hasNode('arguments')) { + $compiler->raw(', ')->subcompile($this->getNode('arguments')); + } else { + $compiler->raw(', []'); + } + + $compiler->raw(', ') + ->repr($this->getAttribute('type')) + ->raw(', ')->repr($this->definedTest ?? false) + ->raw(', ')->repr(true) // ignore_strict_check = true для optional chaining + ->raw(', ')->repr($env->hasExtension(SandboxExtension::class)) + ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) + ->raw(')'); + } + + if ($isOptionalName) { + $compiler->raw(' : null) : null)'); + } else { + $compiler->raw(' : null)'); + } + + return; + } + if ( $this->getAttribute('optimizable') && (!$env->isStrictVariables() || $this->getAttribute('ignore_strict_check')) @@ -113,7 +202,7 @@ public function compile(Compiler $compiler): void $compiler->raw(', ') ->repr($this->getAttribute('type')) - ->raw(', ')->repr($this->definedTest) + ->raw(', ')->repr($this->definedTest ?? false) ->raw(', ')->repr($this->getAttribute('ignore_strict_check')) ->raw(', ')->repr($env->hasExtension(SandboxExtension::class)) ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) @@ -124,7 +213,6 @@ public function compile(Compiler $compiler): void $compiler->raw(')'); } } - private function changeIgnoreStrictCheck(GetAttrExpression $node): void { $node->setAttribute('optimizable', false); diff --git a/tests/Extension/OptionalChainingTest.php b/tests/Extension/OptionalChainingTest.php new file mode 100644 index 00000000000..f6f14cba5a9 --- /dev/null +++ b/tests/Extension/OptionalChainingTest.php @@ -0,0 +1,14 @@ + ['bar' => 'value', 0 => 'zero'] +] +--EXPECT-- +value +zero \ No newline at end of file diff --git a/tests/Fixtures/extensions/optional_chaining/basic.test b/tests/Fixtures/extensions/optional_chaining/basic.test new file mode 100644 index 00000000000..ce545cde9a5 --- /dev/null +++ b/tests/Fixtures/extensions/optional_chaining/basic.test @@ -0,0 +1,8 @@ +--TEST-- +Basic optional chaining +--TEMPLATE-- +{{ foo?.bar }} +{{ foo?.bar?.baz }} +--DATA-- +return ['foo' => null] +--EXPECT-- \ No newline at end of file diff --git a/tests/Fixtures/extensions/optional_chaining/method_call.test b/tests/Fixtures/extensions/optional_chaining/method_call.test new file mode 100644 index 00000000000..06f5fc71a3f --- /dev/null +++ b/tests/Fixtures/extensions/optional_chaining/method_call.test @@ -0,0 +1,20 @@ +--TEST-- +Optional chaining with method calls +--TEMPLATE-- +{{ foo?.getBar() }} +{{ foo_with_nested?.bar?.getBaz() }} +--DATA-- +class TestObject { + public function getBar() { return 'bar_value'; } +} +class NestedObject { + public function getBaz() { return 'baz_value'; } +} +return [ + 'foo' => new TestObject(), + 'null_foo' => null, + 'foo_with_nested' => ['bar' => new NestedObject()] +] +--EXPECT-- +bar_value +baz_value \ No newline at end of file diff --git a/tests/Fixtures/extensions/optional_chaining/mixed.test b/tests/Fixtures/extensions/optional_chaining/mixed.test new file mode 100644 index 00000000000..fbb01906de1 --- /dev/null +++ b/tests/Fixtures/extensions/optional_chaining/mixed.test @@ -0,0 +1,12 @@ +--TEST-- +Mixed optional and normal chaining +--TEMPLATE-- +{{ foo?.bar.baz }} +{{ foo.bar?.baz }} +--DATA-- +return [ + 'foo' => ['bar' => ['baz' => 'value']], +] +--EXPECT-- +value +value \ No newline at end of file diff --git a/tests/Fixtures/extensions/optional_chaining/undefined.test b/tests/Fixtures/extensions/optional_chaining/undefined.test new file mode 100644 index 00000000000..af7ab0512f2 --- /dev/null +++ b/tests/Fixtures/extensions/optional_chaining/undefined.test @@ -0,0 +1,7 @@ +--TEST-- +Optional chaining with undefined variables +--TEMPLATE-- +{{ undefinedVar?.bar }} +--DATA-- +return [] +--EXPECT-- \ No newline at end of file diff --git a/tests/Fixtures/extensions/optional_chaining/with_values.test b/tests/Fixtures/extensions/optional_chaining/with_values.test new file mode 100644 index 00000000000..90fb0f3e92e --- /dev/null +++ b/tests/Fixtures/extensions/optional_chaining/with_values.test @@ -0,0 +1,9 @@ +--TEST-- +Optional chaining with values +--TEMPLATE-- +{{ foo?.bar }} +{{ foo?.bar?.baz }} +--DATA-- +return ['foo' => ['bar' => 'value', 'baz' => ['qux' => 'nested']]] +--EXPECT-- +value \ No newline at end of file