Skip to content

Commit

Permalink
feature #4580 Add a proper prefix spread operator (fabpot)
Browse files Browse the repository at this point in the history
This PR was merged into the 3.x branch.

Discussion
----------

Add a proper prefix spread operator

Now that 3.x has bumped the PHP min version to 8.1, we can move the spread operator to a real one.

Commits
-------

3964aeb Add a proper prefix spread operator
  • Loading branch information
fabpot committed Feb 14, 2025
2 parents 2f31505 + 3964aeb commit 640b382
Show file tree
Hide file tree
Showing 10 changed files with 50 additions and 110 deletions.
6 changes: 4 additions & 2 deletions doc/operators_precedence.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
=========== ================ ======= ============= ===========
Precedence Operator Type Associativity Description
=========== ================ ======= ============= ===========
512 => 300 ``|`` infix Left Twig filter call
512 ``...`` prefix n/a Spread operator
=> 300 ``|`` infix Left Twig filter call

Check failure on line 6 in doc/operators_precedence.rst

View workflow job for this annotation

GitHub Actions / DOCtor-RST

Please add 4 spaces for every indention.
``(`` Twig function call
``.`` Get an attribute on a variable
``[`` Array access
Expand Down Expand Up @@ -56,7 +57,8 @@ Here is the same table for Twig 4.0 with adjusted precedences:
=========== ================ ======= ============= ===========
Precedence Operator Type Associativity Description
=========== ================ ======= ============= ===========
512 ``(`` infix Left Twig function call
512 ``...`` prefix n/a Spread operator
``(`` infix Left Twig function call
``.`` Get an attribute on a variable
``[`` Array access
500 ``-`` prefix n/a
Expand Down
6 changes: 2 additions & 4 deletions src/ExpressionParser/Infix/ArgumentsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,11 @@ private function parseNamedArguments(Parser $parser, bool $parseOpenParenthesis
}
}

if ($stream->nextIf(Token::SPREAD_TYPE)) {
$value = $parser->parseExpression();
if ($value instanceof SpreadUnary) {
$hasSpread = true;
$value = new SpreadUnary($parser->parseExpression(), $stream->getCurrent()->getLine());
} elseif ($hasSpread) {
throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
} else {
$value = $parser->parseExpression();
}

$name = null;
Expand Down
16 changes: 5 additions & 11 deletions src/ExpressionParser/Prefix/LiteralExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Binary\ConcatBinary;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Parser;
use Twig\Token;
Expand Down Expand Up @@ -174,13 +175,7 @@ private function parseSequenceExpression(Parser $parser)
}
$first = false;

if ($stream->nextIf(Token::SPREAD_TYPE)) {
$expr = $parser->parseExpression();
$expr->setAttribute('spread', true);
$node->addElement($expr);
} else {
$node->addElement($parser->parseExpression());
}
$node->addElement($parser->parseExpression());
}
$stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened sequence is not properly closed');

Expand All @@ -207,10 +202,9 @@ private function parseMappingExpression(Parser $parser)
}
$first = false;

if ($stream->nextIf(Token::SPREAD_TYPE)) {
$value = $parser->parseExpression();
$value->setAttribute('spread', true);
$node->addElement($value);
if ($stream->test(Token::OPERATOR_TYPE, '...')) {
$node->addElement($parser->parseExpression());

continue;
}

Expand Down
2 changes: 2 additions & 0 deletions src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
use Twig\Node\Expression\Unary\NegUnary;
use Twig\Node\Expression\Unary\NotUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Node;
use Twig\Parser;
use Twig\Sandbox\SecurityNotAllowedMethodError;
Expand Down Expand Up @@ -330,6 +331,7 @@ public function getExpressionParsers(): array
return [
// unary operators
new UnaryOperatorExpressionParser(NotUnary::class, 'not', 50, new PrecedenceChange('twig/twig', '3.15', 70)),
new UnaryOperatorExpressionParser(SpreadUnary::class, '...', 512, description: 'Spread operator'),
new UnaryOperatorExpressionParser(NegUnary::class, '-', 500),
new UnaryOperatorExpressionParser(PosUnary::class, '+', 500),

Expand Down
7 changes: 1 addition & 6 deletions src/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,8 @@ private function lexExpression(): void
}
}

// spread operator
if ('.' === $this->code[$this->cursor] && ($this->cursor + 2 < $this->end) && '.' === $this->code[$this->cursor + 1] && '.' === $this->code[$this->cursor + 2]) {
$this->pushToken(Token::SPREAD_TYPE, '...');
$this->moveCursor('...');
}
// operators
elseif (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) {
if (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) {
$operator = preg_replace('/\s+/', ' ', $match[0]);
if (\in_array($operator, $this->openingBrackets)) {
$this->checkBrackets($operator);
Expand Down
80 changes: 21 additions & 59 deletions src/Node/Expression/ArrayExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Twig\Node\Expression;

use Twig\Compiler;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Unary\StringCastUnary;
use Twig\Node\Expression\Variable\ContextVariable;

Expand Down Expand Up @@ -68,76 +69,37 @@ public function addElement(AbstractExpression $value, ?AbstractExpression $key =

public function compile(Compiler $compiler): void
{
$keyValuePairs = $this->getKeyValuePairs();
$needsArrayMergeSpread = \PHP_VERSION_ID < 80100 && $this->hasSpreadItem($keyValuePairs);

if ($needsArrayMergeSpread) {
$compiler->raw('CoreExtension::merge(');
}
$compiler->raw('[');
$first = true;
$reopenAfterMergeSpread = false;
$nextIndex = 0;
foreach ($keyValuePairs as $pair) {
if ($reopenAfterMergeSpread) {
$compiler->raw(', [');
$reopenAfterMergeSpread = false;
}

if ($needsArrayMergeSpread && $pair['value']->hasAttribute('spread')) {
$compiler->raw('], ')->subcompile($pair['value']);
$first = true;
$reopenAfterMergeSpread = true;
continue;
}
foreach ($this->getKeyValuePairs() as $pair) {
if (!$first) {
$compiler->raw(', ');
}
$first = false;

if ($pair['value']->hasAttribute('spread') && !$needsArrayMergeSpread) {
$compiler->raw('...')->subcompile($pair['value']);
++$nextIndex;
} else {
$key = null;
if ($pair['key'] instanceof ContextVariable) {
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
}
if ($pair['key'] instanceof TempNameExpression) {
$key = $pair['key']->getAttribute('name');
$pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine());
}
if ($pair['key'] instanceof ConstantExpression) {
$key = $pair['key']->getAttribute('value');
}

if ($nextIndex !== $key) {
$compiler
->subcompile($pair['key'])
->raw(' => ')
;
}
++$nextIndex;

$compiler->subcompile($pair['value']);
$key = null;
if ($pair['key'] instanceof ContextVariable) {
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
}
if ($pair['key'] instanceof TempNameExpression) {
$key = $pair['key']->getAttribute('name');
$pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine());
}
if ($pair['key'] instanceof ConstantExpression) {
$key = $pair['key']->getAttribute('value');
}
}
if (!$reopenAfterMergeSpread) {
$compiler->raw(']');
}
if ($needsArrayMergeSpread) {
$compiler->raw(')');
}
}

private function hasSpreadItem(array $pairs): bool
{
foreach ($pairs as $pair) {
if ($pair['value']->hasAttribute('spread')) {
return true;
if ($nextIndex !== $key && !$pair['value'] instanceof SpreadUnary) {
$compiler
->subcompile($pair['key'])
->raw(' => ')
;
}
}
++$nextIndex;

return false;
$compiler->subcompile($pair['value']);
}
$compiler->raw(']');
}
}
7 changes: 1 addition & 6 deletions src/NodeVisitor/SandboxNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,7 @@ private function wrapNode(Node $node, string $name): void
{
$expr = $node->getNode($name);
if (($expr instanceof ContextVariable || $expr instanceof GetAttrExpression) && !$expr->isGenerator()) {
// Simplify in 4.0 as the spread attribute has been removed there
$new = new CheckToStringNode($expr);
if ($expr->hasAttribute('spread')) {
$new->setAttribute('spread', $expr->getAttribute('spread'));
}
$node->setNode($name, $new);
$node->setNode($name, new CheckToStringNode($expr));
} elseif ($expr instanceof SpreadUnary) {
$this->wrapNode($expr, 'node');
} elseif ($expr instanceof ArrayExpression) {
Expand Down
11 changes: 11 additions & 0 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ final class Token
* @deprecated since Twig 3.21, "arrow" is now an operator
*/
public const ARROW_TYPE = 12;
/**
* @deprecated since Twig 3.21, "spread" is now an operator
*/
public const SPREAD_TYPE = 13;

public function __construct(
Expand All @@ -44,6 +47,9 @@ public function __construct(
if (self::ARROW_TYPE === $type) {
trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::ARROW_TYPE);
}
if (self::SPREAD_TYPE === $type) {
trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "spread" is now an operator.', self::SPREAD_TYPE);
}
}

public function __toString(): string
Expand Down Expand Up @@ -74,6 +80,11 @@ public function test($type, $values = null): bool

return self::OPERATOR_TYPE === $this->type && '=>' === $this->value;
}
if (self::SPREAD_TYPE === $type) {
trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "spread" is now an operator.', self::typeToEnglish(self::SPREAD_TYPE));

return self::OPERATOR_TYPE === $this->type && '...' === $this->value;
}

$typeMatches = $this->type === $type;
if ($typeMatches && self::PUNCTUATION_TYPE === $type && \in_array($this->value, ['(', '[', '|', '.', '?', '?:']) && $values) {
Expand Down
15 changes: 3 additions & 12 deletions tests/ExpressionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\Expression\TestExpression;
use Twig\Node\Expression\Unary\AbstractUnary;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Node;
use Twig\Parser;
Expand Down Expand Up @@ -193,7 +194,7 @@ public static function getTestsForSequence()
new ConstantExpression(2, 1),

new ConstantExpression(2, 1),
self::createContextVariable('foo', ['spread' => true]),
new SpreadUnary(new ContextVariable('foo', 1), 1),
], 1)],

// mapping with spread operator
Expand All @@ -206,7 +207,7 @@ public static function getTestsForSequence()
new ConstantExpression('c', 1),

new ConstantExpression(0, 1),
self::createContextVariable('otherLetters', ['spread' => true]),
new SpreadUnary(new ContextVariable('otherLetters', 1), 1),
], 1)],
];
}
Expand Down Expand Up @@ -591,16 +592,6 @@ public function operator(Compiler $compiler): Compiler
$this->expectNotToPerformAssertions();
}

private static function createContextVariable(string $name, array $attributes): ContextVariable
{
$expression = new ContextVariable($name, 1);
foreach ($attributes as $key => $value) {
$expression->setAttribute($key, $value);
}

return $expression;
}

/**
* @dataProvider getBindingPowerTests
*/
Expand Down
10 changes: 0 additions & 10 deletions tests/LexerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,6 @@ public function testBracketsNesting()
$this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '}'));
}

public function testSpreadOperator()
{
$template = '{{ { a: "a", ...{ b: "b" } } }}';

$this->assertEquals(1, $this->countToken($template, Token::SPREAD_TYPE, '...'));
// sanity check on lexing after spread
$this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '{'));
$this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '}'));
}

protected function countToken($template, $type, $value = null)
{
$lexer = new Lexer(new Environment(new ArrayLoader()));
Expand Down

0 comments on commit 640b382

Please sign in to comment.