From a04ae6d636ac34fe55005fc72dd95d1cc8202ae3 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 27 Sep 2026 19:43:42 +0200 Subject: [PATCH] Reject the spread operator outside sequences, mappings, and call arguments --- CHANGELOG | 1 + src/ExpressionParser/Infix/ArgumentsTrait.php | 11 +++-- .../Prefix/LiteralExpressionParser.php | 7 ++- .../Prefix/SpreadExpressionParser.php | 48 +++++++++++++++++++ src/Extension/CoreExtension.php | 4 +- tests/ExpressionParserTest.php | 35 ++++++++++++++ 6 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 src/ExpressionParser/Prefix/SpreadExpressionParser.php diff --git a/CHANGELOG b/CHANGELOG index d40bf0913..fcd1e30cc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/src/ExpressionParser/Infix/ArgumentsTrait.php b/src/ExpressionParser/Infix/ArgumentsTrait.php index fe2013843..cd1f1c6f2 100644 --- a/src/ExpressionParser/Infix/ArgumentsTrait.php +++ b/src/ExpressionParser/Infix/ArgumentsTrait.php @@ -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; diff --git a/src/ExpressionParser/Prefix/LiteralExpressionParser.php b/src/ExpressionParser/Prefix/LiteralExpressionParser.php index b467d1aa5..62c21cd38 100644 --- a/src/ExpressionParser/Prefix/LiteralExpressionParser.php +++ b/src/ExpressionParser/Prefix/LiteralExpressionParser.php @@ -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; } diff --git a/src/ExpressionParser/Prefix/SpreadExpressionParser.php b/src/ExpressionParser/Prefix/SpreadExpressionParser.php new file mode 100644 index 000000000..042fa8610 --- /dev/null +++ b/src/ExpressionParser/Prefix/SpreadExpressionParser.php @@ -0,0 +1,48 @@ +getLine(), $parser->getStream()->getSourceContext()); + } + + public function getName(): string + { + return '...'; + } + + public function getDescription(): string + { + return 'Spread operator'; + } + + public function getPrecedence(): int + { + return 512; + } +} diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 9157bfa78..16a8105e5 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -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), diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 26ee469f2..342a24a2f 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -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 [