Skip to content

Commit

Permalink
feature #3839 Adding support for the ...spread operator on arrays and…
Browse files Browse the repository at this point in the history
… hashes (weaverryan)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

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

Hi!

Long time user, first time contributor of a real future. I hope I didn't miss anything - but this seemed quite straightforward in the end.

Notes:
* In PHP 7.3 or lower, using the spread operator will result in a Twig syntax error.
* In 8.0 or lower, using the spread operator on a hash will result in a Twig syntax error.

fabbot failures are unrelated, so I'm not touching them.

Cheers!

Commits
-------

ba4fe3b Adding support for the ...spread operator on arrays and hashes
  • Loading branch information
fabpot committed Jul 20, 2023
2 parents e145d36 + ba4fe3b commit 09177a7
Show file tree
Hide file tree
Showing 14 changed files with 253 additions and 62 deletions.
8 changes: 8 additions & 0 deletions doc/templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,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
Expand Down
17 changes: 16 additions & 1 deletion src/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,14 @@ public function parseArrayExpression()
}
$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');

Expand All @@ -359,6 +366,14 @@ public function parseHashExpression()
}
$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
Expand Down
26 changes: 14 additions & 12 deletions src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

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)));
$result = array_merge($result, twig_to_array($array));
}

return array_merge(twig_to_array($arr1), twig_to_array($arr2));
return $result;
}


/**
* Slices a variable.
*
Expand Down
7 changes: 6 additions & 1 deletion src/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,13 @@ private function lexExpression(): void
}
}

// 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('=>');
}
Expand Down
59 changes: 52 additions & 7 deletions src/Node/Expression/ArrayExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,72 @@ public function addElement(AbstractExpression $value, AbstractExpression $key =
{
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);
}

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']);
}
}
if (!$reopenAfterMergeSpread) {
$compiler->raw(']');
}
if ($needsArrayMergeSpread) {
$compiler->raw(')');
}
$compiler->raw(']');
}

private function hasSpreadItem(array $pairs)
{
foreach ($pairs as $pair) {
if ($pair['value']->hasAttribute('spread')) {
return true;
}
}

return false;
}
}
6 changes: 6 additions & 0 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -133,6 +134,9 @@ public static function typeToString(int $type, bool $short = false): string
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));
}
Expand Down Expand Up @@ -171,6 +175,8 @@ public static function typeToEnglish(int $type): string
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));
}
Expand Down
123 changes: 86 additions & 37 deletions tests/ExpressionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,80 +93,108 @@ public function getTestsForArray()
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),

new ConstantExpression(1, 1),
new ConstantExpression(2, 1),
], 1),
new ConstantExpression('b', 1),
new ConstantExpression('c', 1),
$this->createConstantExpression('a', true),
new ArrayExpression([
$this->createConstantExpression(0, false),
$this->createConstantExpression(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)],
];
}

Expand Down Expand Up @@ -387,4 +415,25 @@ public function testUnknownTestWithoutSuggestions()

$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;
}
}
Loading

0 comments on commit 09177a7

Please sign in to comment.