From 65b4ea4394940dee36304d0e7e2a6ee6ba4a9c35 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 12 Jul 2024 08:24:22 +0200 Subject: [PATCH] Add `loop.changed`, `loop.previous`, `loop.next`, and `loop.cycle` variables --- CHANGELOG | 1 + doc/tags/for.rst | 44 +++++++++++++++++-- src/Node/ForNode.php | 2 +- src/Runtime/LoopContext.php | 36 ++++++++++++++- src/Runtime/{Loop.php => LoopIterator.php} | 42 +++++++++++++----- tests/Fixtures/tags/for/loop_changed.test | 24 ++++++++++ tests/Fixtures/tags/for/loop_cycle.test | 14 ++++++ .../Fixtures/tags/for/loop_previous_next.test | 27 ++++++++++++ tests/Node/ForTest.php | 8 ++-- 9 files changed, 179 insertions(+), 19 deletions(-) rename src/Runtime/{Loop.php => LoopIterator.php} (59%) create mode 100644 tests/Fixtures/tags/for/loop_changed.test create mode 100644 tests/Fixtures/tags/for/loop_cycle.test create mode 100644 tests/Fixtures/tags/for/loop_previous_next.test 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 %} +
  • {{ row }}
  • + {% endfor %} + +Use ``loop.previous`` and ``loop.next`` to compare the current value with the +previous or next values: + +.. code-block:: twig + + {% for value in values %} + {% if not loop.first and value > loop.previous %} + The value just increased! + {% endif %} + {{ value }} + {% if not loop.last and loop.next > value %} + The value will increase even more! + {% endif %} + {% endfor %} + +Use ``loop.changed`` to check if the value has changed since the last iteration: + +.. code-block:: html+twig + + {% for entry in entries %} + {% if loop.changed(entry.category) %} +

    {{ entry.category }}

    + {% endif %} +

    {{ 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.category }}

    + {%~ endif %} +

    {{ 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

    +

    Cat1 message

    +

    Another cat1 message

    +

    cat2

    +

    Cat2 message

    +

    cat3

    +

    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] %} +
  • {{ row }}
  • +{% endfor %} +--DATA-- +return [] +--EXPECT-- +
  • 1
  • +
  • 2
  • +
  • 3
  • +
  • 4
  • +
  • 5
  • diff --git a/tests/Fixtures/tags/for/loop_previous_next.test b/tests/Fixtures/tags/for/loop_previous_next.test new file mode 100644 index 000000000..c0e673ae4 --- /dev/null +++ b/tests/Fixtures/tags/for/loop_previous_next.test @@ -0,0 +1,27 @@ +--TEST-- +"for" tag exposes loop.previous and loop.next functions +--TEMPLATE-- +{% for value in [1, 3, 1, 5, 3] %} + + {{~ loop.previous ?? ' ' -}} + {% if not loop.first and value > loop.previous -%} + < + {%- else -%} + {{ loop.first ? ' ' : '>' -}} + {%- endif -%} + [{{~ value }}] + {%- if not loop.last and loop.next > value -%} + < + {%- else -%} + {{ loop.last ? ' ' : '>' -}} + {%- endif -%} + {{ loop.next ?? ' ' -}} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +[1]<3 +1<[3]>1 +3>[1]<5 +1<[5]>3 +5>[3] diff --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index 7c651d5d2..5ce276670 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -57,7 +57,7 @@ class ForTest extends NodeTestCase $tests[] = [$node, <<getVariableGetter('items')}); +\$__internal_compile_0 = new \Twig\Runtime\LoopIterator({$this->getVariableGetter('items')}); foreach (\$__internal_compile_0 as \$context["key"] => \$context["item"]) { yield {$this->getVariableGetter('foo')}; } @@ -78,7 +78,7 @@ EOF $tests[] = [$node, <<getVariableGetter('values')}); +\$__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')}; @@ -100,7 +100,7 @@ EOF $tests[] = [$node, <<getVariableGetter('values')}); +\$__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')}; @@ -122,7 +122,7 @@ EOF $tests[] = [$node, <<getVariableGetter('values')}); +\$__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')};