diff --git a/CHANGELOG b/CHANGELOG index 29b8bbcea..e8cce3c32 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 4.0.0 (2024-XX-XX) + * 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 * Make `Environment::getGlobals()` private diff --git a/doc/tags/for.rst b/doc/tags/for.rst index 3ef25fd4f..83b4658ef 100644 --- a/doc/tags/for.rst +++ b/doc/tags/for.rst @@ -55,9 +55,9 @@ The ``loop`` variable Inside of a ``for`` loop block you can access some special variables: -===================== ============================================================= +===================== ======================================================================== Variable Description -===================== ============================================================= +===================== ======================================================================== ``loop.index`` The current iteration of the loop. (1 indexed) ``loop.index0`` The current iteration of the loop. (0 indexed) ``loop.revindex`` The number of iterations from the end of the loop (1 indexed) @@ -66,7 +66,11 @@ Variable Description ``loop.last`` True if last iteration ``loop.length`` The number of items in the sequence ``loop.parent`` The parent context -===================== ============================================================= +``loop.cycle`` Cycle over a sequence of values +``loop.changed`` True if previously called with a different value or if not called yet +``loop.previous`` The value from the previous iteration (``null`` for the first iteration) +``loop.next`` The value from the next iteration (``null`` for the first iteration) +===================== ======================================================================== .. code-block:: twig @@ -74,6 +78,40 @@ Variable Description {{ loop.index }} - {{ user.username }} {% endfor %} +Use ``loop.cycle`` to cycle among a list of values: + +.. code-block:: html+twig + + {% for row in rows %} +
{{ entry.message }}
+ {% endfor %} + .. note:: When the underlying PHP iterator is not countable, the ``loop.length``, diff --git a/src/Node/ForNode.php b/src/Node/ForNode.php index 1eb40b49d..5283fe55a 100644 --- a/src/Node/ForNode.php +++ b/src/Node/ForNode.php @@ -42,7 +42,7 @@ class ForNode extends Node $compiler ->addDebugInfo($this) ->write("\$context['_parent'] = \$context;\n") - ->write("\$$loopName = new \Twig\Runtime\Loop(") + ->write("\$$loopName = new \Twig\Runtime\LoopIterator(") ->subcompile($this->getNode('seq')) ->raw(");\n") ; diff --git a/src/Runtime/LoopContext.php b/src/Runtime/LoopContext.php index 747eecb3b..328adfe7d 100644 --- a/src/Runtime/LoopContext.php +++ b/src/Runtime/LoopContext.php @@ -20,7 +20,9 @@ namespace Twig\Runtime; */ final class LoopContext { - public function __construct(private Loop $loop, private $parent) + private mixed $lastChanged; + + public function __construct(private LoopIterator $loop, private $parent) { } @@ -63,4 +65,36 @@ final class LoopContext { return $this->loop->isLast(); } + + public function hasChanged(mixed $value): bool + { + if (!isset($this->lastChanged) || $value !== $this->lastChanged) { + $this->lastChanged = $value; + + return true; + } + + return false; + } + + public function getPrevious(): mixed + { + $previous = $this->loop->getPrevious(); + + return $previous['valid'] ? $previous['value'] : null; + } + + public function getNext(): mixed + { + $next = $this->loop->getNext(); + + return $next['valid'] ? $next['value'] : null; + } + + public function cycle($value, ...$values): mixed + { + array_unshift($values, $value); + + return $values[$this->getIndex0() % count($values)]; + } } diff --git a/src/Runtime/Loop.php b/src/Runtime/LoopIterator.php similarity index 59% rename from src/Runtime/Loop.php rename to src/Runtime/LoopIterator.php index d49226efe..9093812fa 100644 --- a/src/Runtime/Loop.php +++ b/src/Runtime/LoopIterator.php @@ -20,12 +20,14 @@ use Twig\Error\RuntimeError; * * @internal */ -final class Loop implements \Iterator +final class LoopIterator implements \Iterator { private \Iterator $seq; private int $index0; private int $length; - private bool $peek = false; + private array $previous = []; + private array $current = []; + private array $next = []; public function __construct($seq) { @@ -35,33 +37,38 @@ final class Loop implements \Iterator public function current(): mixed { - return $this->seq->current(); + return $this->current['value']; } public function key(): mixed { - return $this->seq->key(); + return $this->current['key']; } public function next(): void { - if ($this->peek) { - $this->peek = false; + $this->previous = $this->current; + if ($this->next) { + $this->next = []; } else { $this->seq->next(); } + $this->current = ['valid' => $this->seq->valid(), 'key' => $this->seq->key(), 'value' => $this->seq->current()]; ++$this->index0; } public function rewind(): void { $this->seq->rewind(); + $this->previous = ['valid' => false, 'key' => null, 'value' => null]; + $this->current = ['valid' => $this->seq->valid(), 'key' => $this->seq->key(), 'value' => $this->seq->current()]; + $this->next = []; $this->index0 = 0; } public function valid(): bool { - return $this->seq->valid(); + return $this->current['valid']; } public function iterated(): bool @@ -89,11 +96,26 @@ final class Loop implements \Iterator public function isLast(): bool { - if (!$this->peek) { + return !$this->peek()['valid']; + } + + public function getPrevious(): array + { + return $this->previous; + } + + public function getNext(): array + { + return $this->peek(); + } + + public function peek(): array + { + if (!$this->next) { $this->seq->next(); - $this->peek = true; + $this->next = ['valid' => $this->seq->valid(), 'key' => $this->seq->key(), 'value' => $this->seq->current()]; } - return !$this->seq->valid(); + return $this->next; } } diff --git a/tests/Fixtures/tags/for/loop_changed.test b/tests/Fixtures/tags/for/loop_changed.test new file mode 100644 index 000000000..df491e67a --- /dev/null +++ b/tests/Fixtures/tags/for/loop_changed.test @@ -0,0 +1,24 @@ +--TEST-- +"for" tag exposes a loop.changed function +--TEMPLATE-- +{% for entry in entries %} + {%- if loop.changed(entry.category) -%} +{{ entry.message }}
+{% endfor %} +--DATA-- +return ['entries' => [ + [ 'category' => 'cat1', 'message' => 'Cat1 message' ], + [ 'category' => 'cat1', 'message' => 'Another cat1 message' ], + [ 'category' => 'cat2', 'message' => 'Cat2 message' ], + [ 'category' => 'cat3', 'message' => 'Yet another category of messages' ], +]] +--EXPECT-- +Cat1 message
+Another cat1 message
+Cat2 message
+Yet another category of messages
diff --git a/tests/Fixtures/tags/for/loop_cycle.test b/tests/Fixtures/tags/for/loop_cycle.test new file mode 100644 index 000000000..b8a8eab07 --- /dev/null +++ b/tests/Fixtures/tags/for/loop_cycle.test @@ -0,0 +1,14 @@ +--TEST-- +"for" tag exposes a loop.cycle function +--TEMPLATE-- +{% for row in [1, 2, 3, 4, 5] %} +