From e95d6b2f21b47f5702947cc8b388cbab9eb24ad7 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 13 Jul 2024 18:27:23 +0200 Subject: [PATCH] Add support for recursive loops --- CHANGELOG | 1 + doc/tags/for.rst | 21 +++++ src/Extension/CoreExtension.php | 6 +- src/Node/ForNode.php | 16 +++- src/NodeVisitor/ForNodeVisitor.php | 74 +++++++++++++++ src/Runtime/LoopContext.php | 18 ++++ .../functions/include/with_context.test | 8 +- tests/Fixtures/tags/include/only.test | 12 +-- tests/Node/ForTest.php | 92 ++++++++++++------- 9 files changed, 201 insertions(+), 47 deletions(-) create mode 100644 src/NodeVisitor/ForNodeVisitor.php diff --git a/CHANGELOG b/CHANGELOG index e8cce3c32..2a16c4a28 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/doc/tags/for.rst b/doc/tags/for.rst index 487c15f85..9d0e7634c 100644 --- a/doc/tags/for.rst +++ b/doc/tags/for.rst @@ -110,6 +110,24 @@ replacement block by using ``else``: {% endfor %} +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 + + + 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: diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 144b4a343..fd46bcd8b 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -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 diff --git a/src/Node/ForNode.php b/src/Node/ForNode.php index 389d028b2..eee51fc35 100644 --- a/src/Node/ForNode.php +++ b/src/Node/ForNode.php @@ -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") + ; } } diff --git a/src/NodeVisitor/ForNodeVisitor.php b/src/NodeVisitor/ForNodeVisitor.php new file mode 100644 index 000000000..b4a64cbad --- /dev/null +++ b/src/NodeVisitor/ForNodeVisitor.php @@ -0,0 +1,74 @@ + + * + * @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; + } +} diff --git a/src/Runtime/LoopContext.php b/src/Runtime/LoopContext.php index 716169c3a..26b0765b2 100644 --- a/src/Runtime/LoopContext.php +++ b/src/Runtime/LoopContext.php @@ -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; + } } diff --git a/tests/Fixtures/functions/include/with_context.test b/tests/Fixtures/functions/include/with_context.test index 46ac8c79b..d6984fa1b 100644 --- a/tests/Fixtures/functions/include/with_context.test +++ b/tests/Fixtures/functions/include/with_context.test @@ -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, diff --git a/tests/Fixtures/tags/include/only.test b/tests/Fixtures/tags/include/only.test index 8da402f7a..474cbfaa9 100644 --- a/tests/Fixtures/tags/include/only.test +++ b/tests/Fixtures/tags/include/only.test @@ -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, diff --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index 1916dbc6c..4f7dd765f 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -56,14 +56,20 @@ class ForTest extends NodeTestCase $tests[] = [$node, <<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, <<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, <<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, <<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 ];