Adding support for the ...spread operator on arrays and hashes

This commit is contained in:
Ryan Weaver
2023-05-05 06:54:25 -04:00
committed by Fabien Potencier
parent 3fd3645601
commit ba4fe3ba34
14 changed files with 253 additions and 62 deletions
+8
View File
@@ -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
+16 -1
View File
@@ -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
+15 -13
View File
@@ -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.
*
+6 -1
View File
@@ -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('=>');
}
+52 -7
View File
@@ -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;
}
}
+6
View File
@@ -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));
}
+85 -36
View File
@@ -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;
}
}
@@ -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
@@ -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
+10
View File
@@ -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)));
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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)]);
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}