feature #4893 Fix array destructuring from a Traversable (iliaal, fabpot)

This PR was merged into the 3.x branch.

Discussion
----------

Fix array destructuring from a Traversable

Sequence destructuring compiled its right-hand side straight into `array_pad()`, which only accepts arrays. Destructuring from any `Traversable` crashed with an uncaught `TypeError` at render time:

```twig
{% do [a, b] = items %}
```

with `items` being e.g. an `ArrayIterator` or a generator:

```
array_pad(): Argument #1 ($array) must be of type array, ArrayIterator given
```

This change compiles the right-hand side through an `iterator_to_array()` coercion when it is a `Traversable`, matching how spread (`[...traversable]`), `merge`, and `slice` already accept Traversables:

```php
[$context["a"], $context["b"]] = array_pad(($_v0 = ($context["items"] ?? null)) instanceof \Traversable ? iterator_to_array($_v0) : $_v0, 2, null);
```

Behavior for arrays and other types is unchanged; scalars still fail as before (now via the same `array_pad` TypeError path).

Commits
-------

e2014eb92a Fix Traversable sequence destructuring semantics
9de1b3db98 Fix array destructuring from a Traversable
This commit is contained in:
Fabien Potencier
2026-08-23 09:46:31 +02:00
5 changed files with 77 additions and 3 deletions
+1
View File
@@ -1,6 +1,7 @@
# 3.29.0 (2026-XX-XX)
* Add documentation comments to attach metadata to nodes (experimental)
* Fix sequence destructuring of iterators throwing a `TypeError`
* Add `TempestMarkdown` to use `tempest/markdown` as the `markdown_to_html` converter
* Fix imported macros not resolving their own template-level macro imports
* Add the `include_only` function to render a template without giving it access to the current context
+9
View File
@@ -1210,6 +1210,15 @@ You can skip values by leaving a slot empty:
{# only assign the second value #}
{% do [, last] = ['Fabien', 'Potencier'] %}
.. versionadded:: 3.29
Support for destructuring iterators was introduced in Twig 3.29.
Sequence destructuring also works with iterators (any ``Traversable``
value). Values are extracted in iteration order and keys are ignored. The
iterator is consumed lazily: only as many values as there are variables are
fetched, and the expression returns the iterator itself.
Object Destructuring
~~~~~~~~~~~~~~~~~~~~
+33
View File
@@ -1401,6 +1401,39 @@ final class CoreExtension extends AbstractExtension
return $preserveKeys ? $seq : array_values($seq);
}
/**
* @param list<string|null> $names
*
* @internal
*/
public static function destructureSequence(array &$context, array $names, \Traversable $sequence): \Traversable
{
$count = \count($names);
if (0 === $count) {
return $sequence;
}
$i = 0;
foreach ($sequence as $value) {
$name = $names[$i];
if (null !== $name) {
$context[$name] = $value;
}
if (++$i === $count) {
return $sequence;
}
}
for (; $i < $count; ++$i) {
$name = $names[$i];
if (null !== $name) {
$context[$name] = null;
}
}
return $sequence;
}
/**
* Checks if a variable is empty.
*
@@ -48,7 +48,14 @@ class SequenceDestructuringSetBinary extends AbstractBinary
public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);
$compiler->raw('[');
$var = '$'.$compiler->getVarName();
$compiler
->raw('(('.$var.' = ')
->subcompile($this->getNode('right'))
->raw(') instanceof \Traversable ? CoreExtension::destructureSequence($context, ')
->repr($this->variables)
->raw(', '.$var.') : ([')
;
foreach ($this->variables as $i => $name) {
if ($i) {
$compiler->raw(', ');
@@ -57,7 +64,11 @@ class SequenceDestructuringSetBinary extends AbstractBinary
$compiler->raw('$context[')->repr($name)->raw(']');
}
}
$compiler->raw('] = array_pad(')->subcompile($this->getNode('right'))->raw(', ')->repr(\count($this->variables))->raw(', null)');
$compiler
->raw('] = array_pad('.$var.', ')
->repr(\count($this->variables))
->raw(', null)))')
;
}
public function operator(Compiler $compiler): Compiler
+21 -1
View File
@@ -22,6 +22,10 @@ Twig supports the "=" operator (assignment)
{% do {first_name, last_name} = user_map %}{{ first_name }} {{ last_name }}
{% do {name} = user_obj %}{{ name }}
# Array destructuring from a Traversable
{% do destructured = ([p, q] = pair_traversable) %}{{ p }} {{ q }} {{ pair_traversable.yielded }} {{ destructured is same as(pair_traversable) ? 'same' : 'different' }}
{% do [r, , t] = short_traversable %}{{ r }} {{ t is same as(null) ? 'null' : t }}
# Object destructuring with renaming
{% do {name: userName, email: userEmail} = user %}{{ userName }} {{ userEmail }}
{% do {first_name: first, last_name: last} = user_map %}{{ first }} {{ last }}
@@ -34,7 +38,19 @@ return [
'user_obj' => new class {
public function getName() { return 'Fabien'; }
},
'null_obj' => null
'null_obj' => null,
'pair_traversable' => new class implements IteratorAggregate {
public int $yielded = 0;
public function getIterator(): Traversable
{
foreach ([5 => 'Fabien', 8 => 'Potencier', 13 => 'Ignored'] as $key => $value) {
++$this->yielded;
yield $key => $value;
}
}
},
'short_traversable' => new ArrayIterator(['one']),
]
--EXPECT--
4
@@ -54,6 +70,10 @@ Fabien fabien@example.com
Fabien Potencier
Fabien
# Array destructuring from a Traversable
Fabien Potencier 2 same
one null
# Object destructuring with renaming
Fabien fabien@example.com
Fabien Potencier