diff --git a/CHANGELOG b/CHANGELOG index 61aa34a14..185a82a6f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/doc/templates.rst b/doc/templates.rst index 9070c14c2..5c0c4a013 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -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 ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 184a1ad71..f77ba881c 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1401,6 +1401,39 @@ final class CoreExtension extends AbstractExtension return $preserveKeys ? $seq : array_values($seq); } + /** + * @param list $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. * diff --git a/src/Node/Expression/Binary/SequenceDestructuringSetBinary.php b/src/Node/Expression/Binary/SequenceDestructuringSetBinary.php index 0667b4645..326af2bc1 100644 --- a/src/Node/Expression/Binary/SequenceDestructuringSetBinary.php +++ b/src/Node/Expression/Binary/SequenceDestructuringSetBinary.php @@ -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,15 +64,11 @@ class SequenceDestructuringSetBinary extends AbstractBinary $compiler->raw('$context[')->repr($name)->raw(']'); } } - $compiler->raw('] = array_pad(('); - $var = '$'.$compiler->getVarName(); $compiler - ->raw($var.' = ') - ->subcompile($this->getNode('right')) - ->raw(') instanceof \Traversable ? iterator_to_array('.$var.') : '.$var) - ->raw(', ') + ->raw('] = array_pad('.$var.', ') ->repr(\count($this->variables)) - ->raw(', null)'); + ->raw(', null)))') + ; } public function operator(Compiler $compiler): Compiler diff --git a/tests/Fixtures/expressions/set.test b/tests/Fixtures/expressions/set.test index cb54d24d4..5c12f8888 100644 --- a/tests/Fixtures/expressions/set.test +++ b/tests/Fixtures/expressions/set.test @@ -23,7 +23,8 @@ Twig supports the "=" operator (assignment) {% do {name} = user_obj %}{{ name }} # Array destructuring from a Traversable -{% do [p, q] = pair_traversable %}{{ p }} {{ q }} +{% 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 }} @@ -38,7 +39,18 @@ return [ public function getName() { return 'Fabien'; } }, 'null_obj' => null, - 'pair_traversable' => new ArrayIterator(['Fabien', 'Potencier']), + '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 @@ -59,7 +71,8 @@ Fabien Potencier Fabien # Array destructuring from a Traversable -Fabien Potencier +Fabien Potencier 2 same +one null # Object destructuring with renaming Fabien fabien@example.com