Add loop.changed, loop.previous, loop.next, and loop.cycle variables

This commit is contained in:
Fabien Potencier
2024-07-12 08:24:22 +02:00
parent 9499b2c468
commit 65b4ea4394
9 changed files with 179 additions and 19 deletions
+1
View File
@@ -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
+41 -3
View File
@@ -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 %}
<li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li>
{% 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) %}
<h2>{{ entry.category }}</h2>
{% endif %}
<p>{{ entry.message }}</p>
{% endfor %}
.. note::
When the underlying PHP iterator is not countable, the ``loop.length``,
+1 -1
View File
@@ -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")
;
+35 -1
View File
@@ -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)];
}
}
@@ -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;
}
}
+24
View File
@@ -0,0 +1,24 @@
--TEST--
"for" tag exposes a loop.changed function
--TEMPLATE--
{% for entry in entries %}
{%- if loop.changed(entry.category) -%}
<h2>{{ entry.category }}</h2>
{%~ endif %}
<p>{{ entry.message }}</p>
{% 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--
<h2>cat1</h2>
<p>Cat1 message</p>
<p>Another cat1 message</p>
<h2>cat2</h2>
<p>Cat2 message</p>
<h2>cat3</h2>
<p>Yet another category of messages</p>
+14
View File
@@ -0,0 +1,14 @@
--TEST--
"for" tag exposes a loop.cycle function
--TEMPLATE--
{% for row in [1, 2, 3, 4, 5] %}
<li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li>
{% endfor %}
--DATA--
return []
--EXPECT--
<li class="odd">1</li>
<li class="even">2</li>
<li class="odd">3</li>
<li class="even">4</li>
<li class="odd">5</li>
@@ -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]
+4 -4
View File
@@ -57,7 +57,7 @@ class ForTest extends NodeTestCase
$tests[] = [$node, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->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, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->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, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->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, <<<EOF
// line 1
\$context['_parent'] = \$context;
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->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')};