From 0c90971a987b8ebf44021f073837e85fdcf534fb Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 26 Sep 2026 10:54:52 +0200 Subject: [PATCH] Fix the loop variable of a for tag being undefined in the sequence of a nested loop --- CHANGELOG | 1 + src/NodeVisitor/OptimizerNodeVisitor.php | 21 +++++++++++++++++-- .../tags/for/loop_in_nested_sequence.test | 10 +++++++++ tests/NodeVisitor/OptimizerTest.php | 4 ++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/tags/for/loop_in_nested_sequence.test diff --git a/CHANGELOG b/CHANGELOG index d40bf0913..5c5c32b4b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.30.1 (2026-XX-XX) + * Fix the `loop` variable being undefined when used in the sequence of a nested `for` tag * Reject a deprecated `Template` instance created by another environment in `Environment::resolveTemplate()` * Fix a compilation error inside an `autoescape` tag leaking its escaping strategy into the next compiled template * Fix the non-selected branch of a `guard` tag registering its parent, blocks, and macros diff --git a/src/NodeVisitor/OptimizerNodeVisitor.php b/src/NodeVisitor/OptimizerNodeVisitor.php index b778ba40e..dc33f0443 100644 --- a/src/NodeVisitor/OptimizerNodeVisitor.php +++ b/src/NodeVisitor/OptimizerNodeVisitor.php @@ -47,6 +47,8 @@ final class OptimizerNodeVisitor implements NodeVisitorInterface private $loops = []; private $loopsTargets = []; + /** @var list */ + private array $suspendedLoops = []; /** * @param int $optimizers The optimizer mode @@ -129,7 +131,16 @@ final class OptimizerNodeVisitor implements NodeVisitorInterface array_unshift($this->loops, $node); array_unshift($this->loopsTargets, $node->getNode('value_target')->getAttribute('name')); array_unshift($this->loopsTargets, $node->getNode('key_target')->getAttribute('name')); - } elseif (!$this->loops) { + + return; + } + + // the sequence of a loop is evaluated in the scope enclosing the loop + if ($this->loops && $node === $this->loops[0]->getNode('seq')) { + array_unshift($this->suspendedLoops, [$node, array_shift($this->loops), array_shift($this->loopsTargets), array_shift($this->loopsTargets)]); + } + + if (!$this->loops) { // we are outside a loop return; } @@ -137,7 +148,7 @@ final class OptimizerNodeVisitor implements NodeVisitorInterface // when do we need to add the loop variable back? // the loop variable is referenced for the current loop - elseif ($node instanceof ContextVariable && 'loop' === $node->getAttribute('name')) { + if ($node instanceof ContextVariable && 'loop' === $node->getAttribute('name')) { $node->setAttribute('always_defined', true); $this->addLoopToCurrent(); } @@ -187,6 +198,12 @@ final class OptimizerNodeVisitor implements NodeVisitorInterface */ private function leaveOptimizeFor(Node $node): void { + if ($this->suspendedLoops && $node === $this->suspendedLoops[0][0]) { + [, $loop, $keyTarget, $valueTarget] = array_shift($this->suspendedLoops); + array_unshift($this->loops, $loop); + array_unshift($this->loopsTargets, $keyTarget, $valueTarget); + } + if ($node instanceof ForNode) { array_shift($this->loops); array_shift($this->loopsTargets); diff --git a/tests/Fixtures/tags/for/loop_in_nested_sequence.test b/tests/Fixtures/tags/for/loop_in_nested_sequence.test new file mode 100644 index 000000000..c3ab985d3 --- /dev/null +++ b/tests/Fixtures/tags/for/loop_in_nested_sequence.test @@ -0,0 +1,10 @@ +--TEST-- +"for" tag gives access to the loop variable of the enclosing loop in the sequence of a nested loop +--TEMPLATE-- +{% for i in ['a', 'b'] %}{% for j in [loop.index] %}{{ i }}{{ j }}{% endfor %}{% endfor %} +--DATA-- +return [] +--CONFIG-- +return ['strict_variables' => true] +--EXPECT-- +a1b2 diff --git a/tests/NodeVisitor/OptimizerTest.php b/tests/NodeVisitor/OptimizerTest.php index 5a7799049..4596aaea2 100644 --- a/tests/NodeVisitor/OptimizerTest.php +++ b/tests/NodeVisitor/OptimizerTest.php @@ -124,6 +124,10 @@ class OptimizerTest extends TestCase ['{% for i in foo %}{% for j in foo %}{{ loop.parent.loop.index }}{% endfor %}{% endfor %}', ['i' => true, 'j' => true]], + ['{% for i in foo %}{% for j in [loop.index] %}{% endfor %}{% endfor %}', ['i' => true, 'j' => false]], + + ['{% for i in foo %}{% for j in loop %}{% endfor %}{% endfor %}', ['i' => true, 'j' => false]], + ['{% for i in foo %}{% set l = loop %}{% for j in foo %}{{ l.index }}{% endfor %}{% endfor %}', ['i' => true, 'j' => false]], ['{% for i in foo %}{% for j in foo %}{{ foo.parent.loop.index }}{% endfor %}{% endfor %}', ['i' => false, 'j' => false]],