From ba4fe3ba34981324f6ec5cdc55d767d6534f3cdc Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Fri, 5 May 2023 06:54:25 -0400 Subject: [PATCH] Adding support for the ...spread operator on arrays and hashes --- doc/templates.rst | 8 ++ src/ExpressionParser.php | 17 ++- src/Extension/CoreExtension.php | 28 ++-- src/Lexer.php | 7 +- src/Node/Expression/ArrayExpression.php | 59 ++++++++- src/Token.php | 6 + tests/ExpressionParserTest.php | 121 ++++++++++++------ .../expressions/spread_array_operator.test | 14 ++ .../expressions/spread_hash_operator.test | 37 ++++++ tests/LexerTest.php | 10 ++ tests/Node/Expression/FilterTest.php | 2 +- tests/Node/Expression/FunctionTest.php | 2 +- tests/Node/Expression/GetAttrTest.php | 2 +- tests/Node/Expression/TestTest.php | 2 +- 14 files changed, 253 insertions(+), 62 deletions(-) create mode 100644 tests/Fixtures/expressions/spread_array_operator.test create mode 100644 tests/Fixtures/expressions/spread_hash_operator.test diff --git a/doc/templates.rst b/doc/templates.rst index 5e52326e4..fd65ba1d7 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -779,6 +779,14 @@ The following operators don't fit into any of the other categories: {# returns the value of foo if it is defined and not null, 'no' otherwise #} {{ foo ?? 'no' }} +* ``...``: The spread operator can be used to expand arrays or hashes (it cannot + be used to expand the arguments of a function call): + + .. code-block:: twig + + {% set numbers = [1, 2, ...moreNumbers] %} + {% set ratings = { 'foo': 10, 'bar': 5, ...moreRatings } %} + .. _templates-string-interpolation: String Interpolation diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index 2048c3c54..38347cb39 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -334,7 +334,14 @@ class ExpressionParser } $first = false; - $node->addElement($this->parseExpression()); + if ($stream->test(/* Token::SPREAD_TYPE */ 13)) { + $stream->next(); + $expr = $this->parseExpression(); + $expr->setAttribute('spread', true); + $node->addElement($expr); + } else { + $node->addElement($this->parseExpression()); + } } $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ']', 'An opened array is not properly closed'); @@ -359,6 +366,14 @@ class ExpressionParser } $first = false; + if ($stream->test(/* Token::SPREAD_TYPE */ 13)) { + $stream->next(); + $value = $this->parseExpression(); + $value->setAttribute('spread', true); + $node->addElement($value); + continue; + } + // a hash key can be: // // * a number -- 12 diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index f99adda45..0f1a10216 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -609,32 +609,34 @@ function twig_urlencode_filter($url) } /** - * Merges an array with another one. + * Merges any number of arrays or Traversable objects. * * {% set items = { 'apple': 'fruit', 'orange': 'fruit' } %} * - * {% set items = items|merge({ 'peugeot': 'car' }) %} + * {% set items = items|merge({ 'peugeot': 'car' }, { 'banana': 'fruit' }) %} * - * {# items now contains { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car' } #} + * {# items now contains { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car', 'banana': 'fruit' } #} * - * @param array|\Traversable $arr1 An array - * @param array|\Traversable $arr2 An array + * @param array|\Traversable ...$arrays Any number of arrays or Traversable objects to merge * * @return array The merged array */ -function twig_array_merge($arr1, $arr2) +function twig_array_merge(...$arrays) { - if (!twig_test_iterable($arr1)) { - throw new RuntimeError(sprintf('The merge filter only works with arrays or "Traversable", got "%s" as first argument.', \gettype($arr1))); + $result = []; + + foreach ($arrays as $argNumber => $array) { + if (!twig_test_iterable($array)) { + throw new RuntimeError(sprintf('The merge filter only works with arrays or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); + } + + $result = array_merge($result, twig_to_array($array)); } - if (!twig_test_iterable($arr2)) { - throw new RuntimeError(sprintf('The merge filter only works with arrays or "Traversable", got "%s" as second argument.', \gettype($arr2))); - } - - return array_merge(twig_to_array($arr1), twig_to_array($arr2)); + return $result; } + /** * Slices a variable. * diff --git a/src/Lexer.php b/src/Lexer.php index 975b0b924..6c45efbc9 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -315,8 +315,13 @@ class Lexer } } + // 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('...'); + } // arrow function - if ('=' === $this->code[$this->cursor] && '>' === $this->code[$this->cursor + 1]) { + elseif ('=' === $this->code[$this->cursor] && '>' === $this->code[$this->cursor + 1]) { $this->pushToken(Token::ARROW_TYPE, '=>'); $this->moveCursor('=>'); } diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php index 0e25fe46a..1b29dd19e 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -59,6 +59,9 @@ class ArrayExpression extends AbstractExpression { if (null === $key) { $key = new ConstantExpression(++$this->index, $value->getTemplateLine()); + $key->setAttribute('index_specified', false); + } else { + $key->setAttribute('index_specified', true); } array_push($this->nodes, $key, $value); @@ -66,20 +69,62 @@ class ArrayExpression extends AbstractExpression public function compile(Compiler $compiler): void { + $keyValuePairs = $this->getKeyValuePairs(); + $hasSpreadItem = $this->hasSpreadItem($keyValuePairs); + $needsArrayMergeSpread = \PHP_VERSION_ID < 80100 && $hasSpreadItem; + + if ($needsArrayMergeSpread) { + $compiler->raw('twig_array_merge('); + } $compiler->raw('['); $first = true; - foreach ($this->getKeyValuePairs() as $pair) { + $reopenAfterMergeSpread = false; + 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; + } if (!$first) { $compiler->raw(', '); } $first = false; - $compiler - ->subcompile($pair['key']) - ->raw(' => ') - ->subcompile($pair['value']) - ; + if ($pair['value']->hasAttribute('spread') && !$needsArrayMergeSpread) { + $compiler->raw('...')->subcompile($pair['value']); + } else { + $indexSpecified = false === $pair['key']->hasAttribute('index_specified') || true === $pair['key']->getAttribute('index_specified'); + if ($indexSpecified) { + $compiler + ->subcompile($pair['key']) + ->raw(' => ') + ; + } + $compiler->subcompile($pair['value']); + } } - $compiler->raw(']'); + if (!$reopenAfterMergeSpread) { + $compiler->raw(']'); + } + if ($needsArrayMergeSpread) { + $compiler->raw(')'); + } + } + + private function hasSpreadItem(array $pairs) + { + foreach ($pairs as $pair) { + if ($pair['value']->hasAttribute('spread')) { + return true; + } + } + + return false; } } diff --git a/src/Token.php b/src/Token.php index 53a6cafc3..fd1a89d2a 100644 --- a/src/Token.php +++ b/src/Token.php @@ -35,6 +35,7 @@ final class Token public const INTERPOLATION_START_TYPE = 10; public const INTERPOLATION_END_TYPE = 11; public const ARROW_TYPE = 12; + public const SPREAD_TYPE = 13; public function __construct(int $type, $value, int $lineno) { @@ -133,6 +134,9 @@ final class Token case self::ARROW_TYPE: $name = 'ARROW_TYPE'; break; + case self::SPREAD_TYPE: + $name = 'SPREAD_TYPE'; + break; default: throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); } @@ -171,6 +175,8 @@ final class Token return 'end of string interpolation'; case self::ARROW_TYPE: return 'arrow function'; + case self::SPREAD_TYPE: + return 'spread operator'; default: throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 1b6f385da..c6f34db0e 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -93,80 +93,108 @@ class ExpressionParserTest extends TestCase return [ // simple array ['{{ [1, 2] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + $this->createConstantExpression(0, false), + $this->createConstantExpression(1), - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), + $this->createConstantExpression(1, false), + $this->createConstantExpression(2), ], 1), ], // array with trailing , ['{{ [1, 2, ] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + $this->createConstantExpression(0, false), + $this->createConstantExpression(1), - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), + $this->createConstantExpression(1, false), + $this->createConstantExpression(2), ], 1), ], // simple hash ['{{ {"a": "b", "b": "c"} }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + $this->createConstantExpression('a', true), + $this->createConstantExpression('b'), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), + $this->createConstantExpression('b', true), + $this->createConstantExpression('c'), ], 1), ], // hash with trailing , ['{{ {"a": "b", "b": "c", } }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + $this->createConstantExpression('a', true), + $this->createConstantExpression('b'), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), + $this->createConstantExpression('b', true), + $this->createConstantExpression('c'), ], 1), ], // hash in an array ['{{ [1, {"a": "b", "b": "c"}] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + $this->createConstantExpression(0, false), + $this->createConstantExpression(1), - new ConstantExpression(1, 1), - new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + $this->createConstantExpression(1, false), + new ArrayExpression([ + $this->createConstantExpression('a', true), + $this->createConstantExpression('b'), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), - ], 1), + $this->createConstantExpression('b', true), + $this->createConstantExpression('c'), + ], 1), ], 1), ], // array in a hash ['{{ {"a": [1, 2], "b": "c"} }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + $this->createConstantExpression('a', true), + new ArrayExpression([ + $this->createConstantExpression(0, false), + $this->createConstantExpression(1), - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), - ], 1), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), + $this->createConstantExpression(1, false), + $this->createConstantExpression(2), + ], 1), + + $this->createConstantExpression('b', true), + $this->createConstantExpression('c'), ], 1), ], ['{{ {a, b} }}', new ArrayExpression([ - new ConstantExpression('a', 1), + $this->createConstantExpression('a', true), new NameExpression('a', 1), - new ConstantExpression('b', 1), + + $this->createConstantExpression('b', true), new NameExpression('b', 1), ], 1)], + + // array with spread operator + ['{{ [1, 2, ...foo] }}', + new ArrayExpression([ + $this->createConstantExpression(0, false), + $this->createConstantExpression(1), + + $this->createConstantExpression(1, false), + $this->createConstantExpression(2), + + $this->createConstantExpression(2, false), + $this->createNameExpression('foo', ['spread' => true]), + ], 1)], + + // hash with spread operator + ['{{ {"a": "b", "b": "c", ...otherLetters} }}', + new ArrayExpression([ + $this->createConstantExpression('a', true), + $this->createConstantExpression('b'), + + $this->createConstantExpression('b', true), + $this->createConstantExpression('c'), + + $this->createConstantExpression(0, false), + $this->createNameExpression('otherLetters', ['spread' => true]), + ], 1)], ]; } @@ -387,4 +415,25 @@ class ExpressionParserTest extends TestCase $parser->parse($env->tokenize(new Source('{{ 1 is foobar }}', 'index'))); } + + private function createNameExpression(string $name, array $attributes) + { + $expression = new NameExpression($name, 1); + foreach ($attributes as $key => $value) { + $expression->setAttribute($key, $value); + } + + return $expression; + } + + private function createConstantExpression($value, ?bool $indexSpecified = null) + { + $constant = new ConstantExpression($value, 1); + + if (null !== $indexSpecified) { + $constant->setAttribute('index_specified', $indexSpecified); + } + + return $constant; + } } diff --git a/tests/Fixtures/expressions/spread_array_operator.test b/tests/Fixtures/expressions/spread_array_operator.test new file mode 100644 index 000000000..292488eda --- /dev/null +++ b/tests/Fixtures/expressions/spread_array_operator.test @@ -0,0 +1,14 @@ +--TEST-- +Twig supports the spread operator on arrays +--TEMPLATE-- +{{ [1, 2, ...[3, 4]]|join(',') }} +{{ [1, 2, ...moreNumbers]|join(',') }} +{{ [1, 2, ...iterableNumbers]|join(',') }} +{{ [1, 2, ...iterableNumbers, 0, ...moreNumbers]|join(',') }} +--DATA-- +return ['moreNumbers' => [5, 6, 7, 8], 'iterableNumbers' => new \ArrayObject([6, 7, 8, 9])] +--EXPECT-- +1,2,3,4 +1,2,5,6,7,8 +1,2,6,7,8,9 +1,2,6,7,8,9,0,5,6,7,8 diff --git a/tests/Fixtures/expressions/spread_hash_operator.test b/tests/Fixtures/expressions/spread_hash_operator.test new file mode 100644 index 000000000..c2429f00e --- /dev/null +++ b/tests/Fixtures/expressions/spread_hash_operator.test @@ -0,0 +1,37 @@ +--TEST-- +Twig supports the spread operator on hashes +--TEMPLATE-- +{% for key, value in { firstName: 'Ryan', lastName: 'Weaver', favoriteFood: 'popcorn', ...{favoriteFood: 'pizza', sport: 'running'} } %} + {{ key }}: {{ value }} +{% endfor %} + +{% for key, value in { firstName: 'Ryan', ...morePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} + +{% for key, value in { firstName: 'Ryan', ...iterablePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} + +{# multiple spreads #} +{% for key, value in { firstName: 'Ryan', ...iterablePersonalDetails, lastName: 'Weaver', ...morePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} +--DATA-- +return ['morePersonalDetails' => ['favoriteColor' => 'orange'], 'iterablePersonalDetails' => new \ArrayObject(['favoriteShoes' => 'barefoot'])]; +--EXPECT-- + firstName: Ryan + lastName: Weaver + favoriteFood: pizza + sport: running + + firstName: Ryan + favoriteColor: orange + + firstName: Ryan + favoriteShoes: barefoot + + firstName: Ryan + favoriteShoes: barefoot + lastName: Weaver + favoriteColor: orange diff --git a/tests/LexerTest.php b/tests/LexerTest.php index fdb58c2d5..ad62c22ac 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -51,6 +51,16 @@ class LexerTest extends TestCase $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($this->createMock(LoaderInterface::class))); diff --git a/tests/Node/Expression/FilterTest.php b/tests/Node/Expression/FilterTest.php index 4b30c9cae..b8cc48fab 100644 --- a/tests/Node/Expression/FilterTest.php +++ b/tests/Node/Expression/FilterTest.php @@ -127,7 +127,7 @@ class FilterTest extends NodeTestCase new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", "1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", "1", "2", ["3", "foo" => "bar"])', $environment]; // from extension $node = $this->createFilter($string, 'foo'); diff --git a/tests/Node/Expression/FunctionTest.php b/tests/Node/Expression/FunctionTest.php index 8c9beb370..05c07c029 100644 --- a/tests/Node/Expression/FunctionTest.php +++ b/tests/Node/Expression/FunctionTest.php @@ -94,7 +94,7 @@ class FunctionTest extends NodeTestCase new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar("1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar("1", "2", ["3", "foo" => "bar"])', $environment]; // function as an anonymous function $node = $this->createFunction('anonymous', [new ConstantExpression('foo', 1)]); diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 16c76c609..c76fb3992 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -53,7 +53,7 @@ class GetAttrTest extends NodeTestCase $args->addElement(new NameExpression('foo', 1)); $args->addElement(new ConstantExpression('bar', 1)); $node = new GetAttrExpression($expr, $attr, $args, Template::METHOD_CALL, 1); - $tests[] = [$node, sprintf('%s%s, "bar", [0 => %s, 1 => "bar"], "method", false, false, false, 1)', $this->getAttributeGetter(), $this->getVariableGetter('foo', 1), $this->getVariableGetter('foo'))]; + $tests[] = [$node, sprintf('%s%s, "bar", [%s, "bar"], "method", false, false, false, 1)', $this->getAttributeGetter(), $this->getVariableGetter('foo', 1), $this->getVariableGetter('foo'))]; return $tests; } diff --git a/tests/Node/Expression/TestTest.php b/tests/Node/Expression/TestTest.php index 97955cb62..df7c7202b 100644 --- a/tests/Node/Expression/TestTest.php +++ b/tests/Node/Expression/TestTest.php @@ -67,7 +67,7 @@ class TestTest extends NodeTestCase new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", "1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", "1", "2", ["3", "foo" => "bar"])', $environment]; return $tests; }