Add support for recursive loops

This commit is contained in:
Fabien Potencier
2024-07-13 18:27:23 +02:00
parent 69f9367573
commit e95d6b2f21
9 changed files with 201 additions and 47 deletions
+1
View File
@@ -1,5 +1,6 @@
# 4.0.0 (2024-XX-XX)
* Add support for recursive loops (via `loop.recurse()`)
* Add `loop.changed`, `loop.previous`, `loop.next`, and `loop.cycle` variables
* Make `loop.last` always available (even for non-countable iterators)
* Change the compilation of `for` loops to throw an exception when a `loop.*` variable is not defined
+21
View File
@@ -110,6 +110,24 @@ replacement block by using ``else``:
{% endfor %}
</ul>
Recursive Loops
---------------
To use loops recursively, pass the iterable you want to recurse to the
``loop()`` function; the following example shows how to use it for a recursive
sitemap:
.. code-block:: html+twig
<ul class="sitemap">
{%- for item in sitemap %}
<li>{{ item.title }}
{%- if item.children -%}
<ul class="submenu">{{ loop(item.children) }}</ul>
{%- endif %}</li>
{%- endfor %}
</ul>
The ``loop`` Object
-------------------
@@ -132,6 +150,8 @@ Variable Description
``loop.parent`` The parent context
``loop.previous`` The value from the previous iteration (``null`` for the first iteration)
``loop.next`` The value from the next iteration (``null`` for the last iteration)
``loop.depth`` Deep level of a recursive loop (1 indexed)
``loop.depth0`` Deep level of a recursive loop (0 indexed)
===================== ========================================================================
.. note::
@@ -175,6 +195,7 @@ Function Description
===================== ========================================================================
``loop.cycle()`` Cycle over a sequence of values
``loop.changed()`` True if previously called with a different value or if not called yet
``loop()`` Allows to iterate over a nested sequence/mapping
===================== ========================================================================
Use ``loop.cycle()`` to cycle among a list of values:
+5 -1
View File
@@ -56,6 +56,7 @@ use Twig\Node\Expression\Test\SameasTest;
use Twig\Node\Expression\Unary\NegUnary;
use Twig\Node\Expression\Unary\NotUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\NodeVisitor\ForNodeVisitor;
use Twig\NodeVisitor\MacroAutoImportNodeVisitor;
use Twig\Source;
use Twig\Template;
@@ -268,7 +269,10 @@ final class CoreExtension extends AbstractExtension
public function getNodeVisitors(): array
{
return [new MacroAutoImportNodeVisitor()];
return [
new MacroAutoImportNodeVisitor(),
new ForNodeVisitor(),
];
}
public function getOperators(): array
+14 -2
View File
@@ -38,17 +38,21 @@ class ForNode extends Node
public function compile(Compiler $compiler): void
{
$iteratorVar = $compiler->getVarName();
$functionVar = $compiler->getVarName();
$compiler
->addDebugInfo($this)
->write("\$context['_parent'] = \$context;\n")
->write("\$$iteratorVar = new \Twig\Runtime\LoopIterator(")
->subcompile($this->getNode('seq'))
->raw(");\n")
->write("\$$functionVar = function (\$$iteratorVar, &\$context, \$blocks, &\$$functionVar, \$depth) {\n")
->indent()
->write("\$macros = \$this->macros;\n")
->write("\$context['_parent'] = \$context;\n")
;
if ($this->getAttribute('with_loop')) {
$compiler->write("\$context['loop'] = new \Twig\Runtime\LoopContext(\$$iteratorVar, \$context['_parent']);\n");
$compiler->write("\$context['loop'] = new \Twig\Runtime\LoopContext(\$$iteratorVar, \$context['_parent'], \$blocks, \$$functionVar, \$depth);\n");
}
$compiler
@@ -80,5 +84,13 @@ class ForNode extends Node
// keep the values set in the inner context for variables defined in the outer context
$compiler->write("\$context = array_intersect_key(\$context, \$_parent) + \$_parent;\n");
$compiler
->write("return; yield;\n")
->outdent()
->write("};\n")
->write("\Closure::bind(\$$functionVar, \$this, self::class);\n")
->write("yield from \$$functionVar(\$$iteratorVar, \$context, \$blocks, \$$functionVar, 0);\n")
;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\NodeVisitor;
use Twig\Environment;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\ForNode;
use Twig\Node\Node;
use Twig\Node\PrintNode;
/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal
*/
final class ForNodeVisitor implements NodeVisitorInterface
{
private int $loops = 0;
public function enterNode(Node $node, Environment $env): Node
{
if ($node instanceof ForNode) {
++$this->loops;
return $node;
} elseif (!$this->loops) {
// we are outside a loop
return $node;
}
if (!$node instanceof PrintNode) {
return $node;
}
// We look for exactly {{ loop.recurse(...) }}
$exprNode = $node->getNode('expr');
if (
$exprNode instanceof GetAttrExpression
&& $exprNode->getNode('node') instanceof NameExpression
&& 'loop' === $exprNode->getNode('node')->getAttribute('name')
&& $exprNode->getNode('attribute') instanceof ConstantExpression
&& 'recurse' === $exprNode->getNode('attribute')->getAttribute('value')
) {
$exprNode->setAttribute('is_generator', true);
}
return $node;
}
public function leaveNode(Node $node, Environment $env): ?Node
{
if ($node instanceof ForNode) {
--$this->loops;
}
return $node;
}
public function getPriority(): int
{
return 0;
}
}
+18
View File
@@ -25,6 +25,9 @@ final class LoopContext
public function __construct(
private LoopIterator $loop,
private $parent,
private $blocks,
private $recurseFunc,
private $depth,
) {
}
@@ -99,4 +102,19 @@ final class LoopContext
return $values[$this->getIndex0() % count($values)];
}
public function recurse($iterator): \Generator
{
yield from ($this->recurseFunc)(new LoopIterator($iterator), $this->parent, $this->blocks, $this->recurseFunc, $this->depth + 1);
}
public function getDepth0(): int
{
return $this->depth;
}
public function getDepth(): int
{
return $this->depth + 1;
}
}
@@ -10,7 +10,7 @@
--DATA--
return ['foo' => 'bar']
--EXPECT--
foo,global,_parent,
global,_parent,
foo,global,foo1,_parent,
foo1,global,_parent,
foo,global,
global,
foo,global,foo1,
foo1,global,
+6 -6
View File
@@ -12,9 +12,9 @@
--DATA--
return ['vars1' => ['foo1' => 'bar'], 'vars2' => new ArrayObject(['foo2' => 'bar'])]
--EXPECT--
vars1,vars2,global,_parent,
global,_parent,
vars1,vars2,global,foo1,_parent,
foo1,global,_parent,
vars1,vars2,global,foo2,_parent,
foo2,global,_parent,
vars1,vars2,global,
global,
vars1,vars2,global,foo1,
foo1,global,
vars1,vars2,global,foo2,
foo2,global,
+58 -34
View File
@@ -56,14 +56,20 @@ class ForTest extends NodeTestCase
$tests[] = [$node, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\LoopIterator({$this->getVariableGetter('items')});
foreach (\$__internal_compile_0 as \$context["key"] => \$context["item"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['key'], \$context['item'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
\$__internal_compile_1 = function (\$__internal_compile_0, &\$context, \$blocks, &\$__internal_compile_1, \$depth) {
\$macros = \$this->macros;
\$context['_parent'] = \$context;
foreach (\$__internal_compile_0 as \$context["key"] => \$context["item"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['key'], \$context['item'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
return; yield;
};
\Closure::bind(\$__internal_compile_1, \$this, self::class);
yield from \$__internal_compile_1(\$__internal_compile_0, \$context, \$blocks, \$__internal_compile_1, 0);
EOF
];
@@ -77,15 +83,21 @@ EOF
$tests[] = [$node, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\LoopIterator({$this->getVariableGetter('values')});
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent']);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
\$__internal_compile_1 = function (\$__internal_compile_0, &\$context, \$blocks, &\$__internal_compile_1, \$depth) {
\$macros = \$this->macros;
\$context['_parent'] = \$context;
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent'], \$blocks, \$__internal_compile_1, \$depth);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
return; yield;
};
\Closure::bind(\$__internal_compile_1, \$this, self::class);
yield from \$__internal_compile_1(\$__internal_compile_0, \$context, \$blocks, \$__internal_compile_1, 0);
EOF
];
@@ -99,15 +111,21 @@ EOF
$tests[] = [$node, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\LoopIterator({$this->getVariableGetter('values')});
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent']);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
\$__internal_compile_1 = function (\$__internal_compile_0, &\$context, \$blocks, &\$__internal_compile_1, \$depth) {
\$macros = \$this->macros;
\$context['_parent'] = \$context;
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent'], \$blocks, \$__internal_compile_1, \$depth);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
return; yield;
};
\Closure::bind(\$__internal_compile_1, \$this, self::class);
yield from \$__internal_compile_1(\$__internal_compile_0, \$context, \$blocks, \$__internal_compile_1, 0);
EOF
];
@@ -121,18 +139,24 @@ EOF
$tests[] = [$node, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\LoopIterator({$this->getVariableGetter('values')});
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent']);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
if (0 === \$__internal_compile_0->getIndex0()) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
\$__internal_compile_1 = function (\$__internal_compile_0, &\$context, \$blocks, &\$__internal_compile_1, \$depth) {
\$macros = \$this->macros;
\$context['_parent'] = \$context;
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0, \$context['_parent'], \$blocks, \$__internal_compile_1, \$depth);
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
yield {$this->getVariableGetter('foo')};
}
if (0 === \$__internal_compile_0->getIndex0()) {
yield {$this->getVariableGetter('foo')};
}
\$_parent = \$context['_parent'];
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
return; yield;
};
\Closure::bind(\$__internal_compile_1, \$this, self::class);
yield from \$__internal_compile_1(\$__internal_compile_0, \$context, \$blocks, \$__internal_compile_1, 0);
EOF
];