Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/ExpressionParser/Infix/OptionalChainExpressionParser.php
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());
Copy link
Member

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 ?

}


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;
}
}
2 changes: 2 additions & 0 deletions src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -391,6 +392,7 @@ public function getExpressionParsers(): array

// all literals
new LiteralExpressionParser(),
new OptionalChainExpressionParser(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be registered near other get attribute operators, not in the section labelled as all literals

];
}

Expand Down
1 change: 1 addition & 0 deletions src/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class Lexer
public const REGEX_INLINE_COMMENT = '/#[^\n]*/A';
public const PUNCTUATION = '()[]{}?:.,|';


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be reverted

private const SPECIAL_CHARS = [
'f' => "\f",
'n' => "\n",
Expand Down
98 changes: 93 additions & 5 deletions src/Node/Expression/GetAttrExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The 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 ignore_strict_check attribute ?

}

public function enableDefinedTest(): void
Expand All @@ -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'))
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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())
Expand All @@ -124,7 +213,6 @@ public function compile(Compiler $compiler): void
$compiler->raw(')');
}
}

Copy link
Member

Choose a reason for hiding this comment

The 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);
Expand Down
14 changes: 14 additions & 0 deletions tests/Extension/OptionalChainingTest.php
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no OptionalChaining extension. The operator is registered by CoreExtension. This should be tested by the existing integration tests by putting the fixtures in the existing folder.

{
public function getFixturesDir(): string
{
return __DIR__.'/../Fixtures/extensions/optional_chaining';
}
}
12 changes: 12 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/array_access.test
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
8 changes: 8 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/basic.test
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--
20 changes: 20 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/method_call.test
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
12 changes: 12 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/mixed.test
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
7 changes: 7 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/undefined.test
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--
9 changes: 9 additions & 0 deletions tests/Fixtures/extensions/optional_chaining/with_values.test
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