-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
This PR adds support for the optional chaining operator (?.) to Twig. #4623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 3.x
Are you sure you want to change the base?
Changes from all commits
8e279ae
1c94999
d01cc7c
f415198
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| <?php | ||
|
|
||
| namespace Twig\ExpressionParser\Infix; | ||
|
|
||
| use Twig\Error\SyntaxError; | ||
| use Twig\ExpressionParser\AbstractExpressionParser; | ||
| use Twig\ExpressionParser\ExpressionParserDescriptionInterface; | ||
| use Twig\ExpressionParser\InfixAssociativity; | ||
| use Twig\ExpressionParser\InfixExpressionParserInterface; | ||
| use Twig\Lexer; | ||
| use Twig\Node\Expression\AbstractExpression; | ||
| use Twig\Node\Expression\ArrayExpression; | ||
| use Twig\Node\Expression\ConstantExpression; | ||
| use Twig\Node\Expression\GetAttrExpression; | ||
| use Twig\Node\Expression\MacroReferenceExpression; | ||
| use Twig\Node\Expression\NameExpression; | ||
| use Twig\Node\Expression\Variable\TemplateVariable; | ||
| use Twig\Parser; | ||
| use Twig\Template; | ||
| use Twig\Token; | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| final class OptionalChainExpressionParser extends AbstractExpressionParser implements InfixExpressionParserInterface, ExpressionParserDescriptionInterface | ||
| { | ||
| use ArgumentsTrait; | ||
|
|
||
|
|
||
| public function parse(Parser $parser, AbstractExpression $expr, Token $token): AbstractExpression | ||
| { | ||
| $stream = $parser->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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should be registered near other |
||
| ]; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,7 @@ class Lexer | |
| public const REGEX_INLINE_COMMENT = '/#[^\n]*/A'; | ||
| public const PUNCTUATION = '()[]{}?:.,|'; | ||
|
|
||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be reverted |
||
| private const SPECIAL_CHARS = [ | ||
| 'f' => "\f", | ||
| 'n' => "\n", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need another attribute for the optional chain ? Couldn't it be a matter of the |
||
| } | ||
|
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why this change ? |
||
| ->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(')'); | ||
| } | ||
| } | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this empty line between methods should not be removed |
||
| private function changeIgnoreStrictCheck(GetAttrExpression $node): void | ||
| { | ||
| $node->setAttribute('optimizable', false); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php | ||
| // tests/Extension/OptionalChainingTest.php | ||
|
|
||
| namespace Twig\Tests\Extension; | ||
|
|
||
| use Twig\Test\IntegrationTestCase; | ||
|
|
||
| class OptionalChainingTest extends IntegrationTestCase | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no |
||
| { | ||
| public function getFixturesDir(): string | ||
| { | ||
| return __DIR__.'/../Fixtures/extensions/optional_chaining'; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --TEST-- | ||
| Optional chaining with array access | ||
| --TEMPLATE-- | ||
| {{ foo?.bar }} | ||
| {{ foo?.[0] }} | ||
| --DATA-- | ||
| return [ | ||
| 'foo' => ['bar' => 'value', 0 => 'zero'] | ||
| ] | ||
| --EXPECT-- | ||
| value | ||
| zero |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --TEST-- | ||
| Basic optional chaining | ||
| --TEMPLATE-- | ||
| {{ foo?.bar }} | ||
| {{ foo?.bar?.baz }} | ||
| --DATA-- | ||
| return ['foo' => null] | ||
| --EXPECT-- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --TEST-- | ||
| Optional chaining with undefined variables | ||
| --TEMPLATE-- | ||
| {{ undefinedVar?.bar }} | ||
| --DATA-- | ||
| return [] | ||
| --EXPECT-- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this seems to ignore the fact that this is an optional chain. Is it expected ?