Reject the spread operator outside sequences, mappings, and call arguments

This commit is contained in:
Fabien Potencier
2026-09-27 19:43:42 +02:00
parent 4241bb73fe
commit a04ae6d636
6 changed files with 98 additions and 8 deletions
+1
View File
@@ -5,6 +5,7 @@
* Fix the non-selected branch of a `guard` tag registering its parent, blocks, and macros
* Speed up reading object attributes backed by getters or class constants
* Fix the sandbox not reporting the line of a rejected `guard` tag
* Fix the spread operator compiling to invalid PHP outside sequences, mappings, and call arguments
# 3.30.0 (2026-09-25)
@@ -52,11 +52,14 @@ trait ArgumentsTrait
}
}
$value = $parser->parseExpression();
if ($value instanceof SpreadUnary) {
if ($token = $stream->nextIf(Token::OPERATOR_TYPE, '...')) {
$value = new SpreadUnary($parser->parseExpression(), $token->getLine());
$hasSpread = true;
} elseif ($hasSpread) {
throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
} else {
$value = $parser->parseExpression();
if ($hasSpread) {
throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
}
$name = null;
@@ -21,6 +21,7 @@ use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Binary\ConcatBinary;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\EmptyExpression;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Parser;
use Twig\Token;
@@ -163,6 +164,8 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
// Check for empty slots (comma with no expression)
if ($stream->test(Token::PUNCTUATION_TYPE, ',')) {
$node->addElement(new EmptyExpression($stream->getCurrent()->getLine()));
} elseif ($token = $stream->nextIf(Token::OPERATOR_TYPE, '...')) {
$node->addElement(new SpreadUnary($parser->parseExpression(), $token->getLine()));
} else {
$node->addElement($parser->parseExpression());
}
@@ -190,8 +193,8 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
}
$first = false;
if ($stream->test(Token::OPERATOR_TYPE, '...')) {
$node->addElement($parser->parseExpression());
if ($token = $stream->nextIf(Token::OPERATOR_TYPE, '...')) {
$node->addElement(new SpreadUnary($parser->parseExpression(), $token->getLine()));
continue;
}
@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\ExpressionParser\Prefix;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser\AbstractExpressionParser;
use Twig\ExpressionParser\ExpressionParserDescriptionInterface;
use Twig\ExpressionParser\PrefixExpressionParserInterface;
use Twig\Node\Expression\AbstractExpression;
use Twig\Parser;
use Twig\Token;
/**
* Sequences, mappings, and call arguments parse the spread operator themselves; it is invalid anywhere else.
*
* @internal
*/
final class SpreadExpressionParser extends AbstractExpressionParser implements PrefixExpressionParserInterface, ExpressionParserDescriptionInterface
{
public function parse(Parser $parser, Token $token): AbstractExpression
{
throw new SyntaxError('The spread operator can only be used on sequence elements, mapping elements, and call arguments.', $token->getLine(), $parser->getStream()->getSourceContext());
}
public function getName(): string
{
return '...';
}
public function getDescription(): string
{
return 'Spread operator';
}
public function getPrecedence(): int
{
return 512;
}
}
+2 -2
View File
@@ -30,6 +30,7 @@ use Twig\ExpressionParser\InfixAssociativity;
use Twig\ExpressionParser\PrecedenceChange;
use Twig\ExpressionParser\Prefix\GroupingExpressionParser;
use Twig\ExpressionParser\Prefix\LiteralExpressionParser;
use Twig\ExpressionParser\Prefix\SpreadExpressionParser;
use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
use Twig\MacroNamespace;
use Twig\Markup;
@@ -84,7 +85,6 @@ use Twig\Node\Expression\Test\TrueTest;
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\NodeVisitor\CorrectnessNodeVisitor;
use Twig\Parser;
@@ -341,7 +341,7 @@ final class CoreExtension extends AbstractExtension
return [
// unary operators
new UnaryOperatorExpressionParser(NotUnary::class, 'not', 50, new PrecedenceChange('twig/twig', '3.15', 70)),
new UnaryOperatorExpressionParser(SpreadUnary::class, '...', 512, description: 'Spread operator', operandPrecedence: 0),
new SpreadExpressionParser(),
new UnaryOperatorExpressionParser(NegUnary::class, '-', 500),
new UnaryOperatorExpressionParser(PosUnary::class, '+', 500),
+35
View File
@@ -126,6 +126,41 @@ class ExpressionParserTest extends TestCase
];
}
/**
* @dataProvider getMisplacedSpreadTests
*/
#[DataProvider('getMisplacedSpreadTests')]
public function testMisplacedSpreadIsASyntaxError(string $template): void
{
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
$parser = new Parser($env);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('The spread operator can only be used on sequence elements, mapping elements, and call arguments in "index" at line 1.');
$parser->parse($env->tokenize(new Source($template, 'index')));
}
public static function getMisplacedSpreadTests()
{
return [
['{{ ...foo }}'],
['{{ (...foo) }}'],
['{{ -...foo }}'],
['{{ 1 + ...foo }}'],
['{% set bar = ...foo %}'],
['{% do ...foo %}'],
['{% with ...foo %}{% endwith %}'],
['{{ (bar => ...foo)(1) }}'],
['{{ [(...foo)] }}'],
['{{ [1, -...foo] }}'],
['{{ {a: ...foo} }}'],
['{{ {(...foo): 1} }}'],
['{{ bar(-...foo) }}'],
['{{ bar(a: ...foo) }}'],
['{{ bar(a = ...foo) }}'],
];
}
public static function getTestsForSequence()
{
return [