mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-04 22:47:21 +00:00
Adding support for the ...spread operator on arrays and hashes
This commit is contained in:
committed by
Fabien Potencier
parent
3fd3645601
commit
ba4fe3ba34
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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('=>');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user