mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-05 06:56:51 +00:00
Introduce a Loop object
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
# 4.0.0 (2024-XX-XX)
|
||||
|
||||
* Change the compilation of `for` loops to throw an exception when a `loop.*` variable is not defined
|
||||
* Make `Environment::getGlobals()` private
|
||||
* Drop support for PHP < 8.2
|
||||
|
||||
+3
-3
@@ -76,9 +76,9 @@ Variable Description
|
||||
|
||||
.. note::
|
||||
|
||||
The ``loop.length``, ``loop.revindex``, ``loop.revindex0``, and
|
||||
``loop.last`` variables are only available for PHP arrays, or objects that
|
||||
implement the ``Countable`` interface.
|
||||
When the underlying PHP iterator is not countable, the ``loop.length``,
|
||||
``loop.revindex``, ``loop.revindex0``, and ``loop.last`` variables are not
|
||||
available and a ``RuntimeException`` is thrown if you try to use them.
|
||||
|
||||
The ``else`` Clause
|
||||
-------------------
|
||||
|
||||
@@ -1259,18 +1259,6 @@ final class CoreExtension extends AbstractExtension
|
||||
return $template->$method(...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function ensureTraversable($seq)
|
||||
{
|
||||
if (is_iterable($seq)) {
|
||||
return $seq;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Node;
|
||||
|
||||
use Twig\Attribute\YieldReady;
|
||||
use Twig\Compiler;
|
||||
|
||||
/**
|
||||
* Internal node used by the for node.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
#[YieldReady]
|
||||
class ForLoopNode extends Node
|
||||
{
|
||||
public function __construct(int $lineno, ?string $tag = null)
|
||||
{
|
||||
parent::__construct([], ['with_loop' => false, 'ifexpr' => false, 'else' => false], $lineno, $tag);
|
||||
}
|
||||
|
||||
public function compile(Compiler $compiler): void
|
||||
{
|
||||
if ($this->getAttribute('else')) {
|
||||
$compiler->write("\$context['_iterated'] = true;\n");
|
||||
}
|
||||
|
||||
if ($this->getAttribute('with_loop')) {
|
||||
$compiler
|
||||
->write("++\$context['loop']['index0'];\n")
|
||||
->write("++\$context['loop']['index'];\n")
|
||||
->write("\$context['loop']['first'] = false;\n")
|
||||
->write("if (isset(\$context['loop']['length'])) {\n")
|
||||
->indent()
|
||||
->write("--\$context['loop']['revindex0'];\n")
|
||||
->write("--\$context['loop']['revindex'];\n")
|
||||
->write("\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];\n")
|
||||
->outdent()
|
||||
->write("}\n")
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-33
@@ -25,12 +25,8 @@ use Twig\Node\Expression\AssignNameExpression;
|
||||
#[YieldReady]
|
||||
class ForNode extends Node
|
||||
{
|
||||
private ForLoopNode $loop;
|
||||
|
||||
public function __construct(AssignNameExpression $keyTarget, AssignNameExpression $valueTarget, AbstractExpression $seq, ?Node $ifexpr, Node $body, ?Node $else, int $lineno, ?string $tag = null)
|
||||
{
|
||||
$body = new Node([$body, $this->loop = new ForLoopNode($lineno, $tag)]);
|
||||
|
||||
$nodes = ['key_target' => $keyTarget, 'value_target' => $valueTarget, 'seq' => $seq, 'body' => $body];
|
||||
if (null !== $else) {
|
||||
$nodes['else'] = $else;
|
||||
@@ -41,43 +37,22 @@ class ForNode extends Node
|
||||
|
||||
public function compile(Compiler $compiler): void
|
||||
{
|
||||
$loopName = $compiler->getVarName();
|
||||
|
||||
$compiler
|
||||
->addDebugInfo($this)
|
||||
->write("\$context['_parent'] = \$context;\n")
|
||||
->write("\$context['_seq'] = CoreExtension::ensureTraversable(")
|
||||
->write("\$$loopName = new \Twig\Runtime\Loop(")
|
||||
->subcompile($this->getNode('seq'))
|
||||
->raw(");\n")
|
||||
->raw(", \$context['_parent']);\n")
|
||||
;
|
||||
|
||||
if ($this->hasNode('else')) {
|
||||
$compiler->write("\$context['_iterated'] = false;\n");
|
||||
}
|
||||
|
||||
if ($this->getAttribute('with_loop')) {
|
||||
$compiler
|
||||
->write("\$context['loop'] = [\n")
|
||||
->write(" 'parent' => \$context['_parent'],\n")
|
||||
->write(" 'index0' => 0,\n")
|
||||
->write(" 'index' => 1,\n")
|
||||
->write(" 'first' => true,\n")
|
||||
->write("];\n")
|
||||
->write("if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) {\n")
|
||||
->indent()
|
||||
->write("\$length = count(\$context['_seq']);\n")
|
||||
->write("\$context['loop']['revindex0'] = \$length - 1;\n")
|
||||
->write("\$context['loop']['revindex'] = \$length;\n")
|
||||
->write("\$context['loop']['length'] = \$length;\n")
|
||||
->write("\$context['loop']['last'] = 1 === \$length;\n")
|
||||
->outdent()
|
||||
->write("}\n")
|
||||
;
|
||||
$compiler->write("\$context['loop'] = new \Twig\Runtime\LoopContext(\${$loopName});\n");
|
||||
}
|
||||
|
||||
$this->loop->setAttribute('else', $this->hasNode('else'));
|
||||
$this->loop->setAttribute('with_loop', $this->getAttribute('with_loop'));
|
||||
|
||||
$compiler
|
||||
->write("foreach (\$context['_seq'] as ")
|
||||
->write("foreach (\$$loopName as ")
|
||||
->subcompile($this->getNode('key_target'))
|
||||
->raw(' => ')
|
||||
->subcompile($this->getNode('value_target'))
|
||||
@@ -90,7 +65,7 @@ class ForNode extends Node
|
||||
|
||||
if ($this->hasNode('else')) {
|
||||
$compiler
|
||||
->write("if (!\$context['_iterated']) {\n")
|
||||
->write("if (!\${$loopName}->iterated()) {\n")
|
||||
->indent()
|
||||
->subcompile($this->getNode('else'))
|
||||
->outdent()
|
||||
@@ -101,7 +76,7 @@ class ForNode extends Node
|
||||
$compiler->write("\$_parent = \$context['_parent'];\n");
|
||||
|
||||
// remove some "private" loop variables (needed for nested loops)
|
||||
$compiler->write('unset($context[\'_seq\'], $context[\'_iterated\'], $context[\''.$this->getNode('key_target')->getAttribute('name').'\'], $context[\''.$this->getNode('value_target')->getAttribute('name').'\'], $context[\'_parent\'], $context[\'loop\']);'."\n");
|
||||
$compiler->write('unset($context[\''.$this->getNode('key_target')->getAttribute('name').'\'], $context[\''.$this->getNode('value_target')->getAttribute('name').'\'], $context[\'_parent\'], $context[\'loop\']);'."\n");
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Runtime;
|
||||
|
||||
use Twig\Error\RuntimeError;
|
||||
|
||||
/**
|
||||
* Represents a for loop variable.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Loop implements \Iterator
|
||||
{
|
||||
private \Iterator $seq;
|
||||
private bool $iterated;
|
||||
private int $index0;
|
||||
private int $revindex0;
|
||||
private bool $first;
|
||||
private bool $last;
|
||||
private int $length;
|
||||
|
||||
public function __construct($seq, private $parent)
|
||||
{
|
||||
$this->seq = is_iterable($seq) ? (is_array($seq) ? new \ArrayIterator($seq) : $seq) : new \ArrayIterator([]);
|
||||
$this->rewind();
|
||||
}
|
||||
|
||||
public function current(): mixed
|
||||
{
|
||||
return $this->seq->current();
|
||||
}
|
||||
|
||||
public function key(): mixed
|
||||
{
|
||||
return $this->seq->key();
|
||||
}
|
||||
|
||||
public function next(): void
|
||||
{
|
||||
$this->seq->next();
|
||||
|
||||
$this->iterated = true;
|
||||
++$this->index0;
|
||||
$this->first = false;
|
||||
if (isset($this->length)) {
|
||||
--$this->revindex0;
|
||||
$this->last = 0 === $this->revindex0;
|
||||
}
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
$this->seq->rewind();
|
||||
|
||||
$this->iterated = false;
|
||||
$this->index0 = 0;
|
||||
$this->first = true;
|
||||
if ($this->seq instanceof \Countable) {
|
||||
$length = count($this->seq);
|
||||
$this->revindex0 = $length - 1;
|
||||
$this->length = $length;
|
||||
$this->last = 1 === $length;
|
||||
}
|
||||
}
|
||||
|
||||
public function valid(): bool
|
||||
{
|
||||
return $this->seq->valid();
|
||||
}
|
||||
|
||||
public function iterated(): bool
|
||||
{
|
||||
return $this->iterated;
|
||||
}
|
||||
|
||||
public function getParent(): mixed
|
||||
{
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
public function getRevindex0(): int
|
||||
{
|
||||
if (!$this->seq instanceof \Countable) {
|
||||
throw new RuntimeError('The "loop.revindex0" variable is not defined as the loop iterates on a non-countable iterator.');
|
||||
}
|
||||
|
||||
return $this->revindex0;
|
||||
}
|
||||
|
||||
public function getIndex0(): int
|
||||
{
|
||||
return $this->index0;
|
||||
}
|
||||
|
||||
public function getLength(): int
|
||||
{
|
||||
if (!$this->seq instanceof \Countable) {
|
||||
throw new RuntimeError('The "loop.length" variable is not defined as the loop iterates on a non-countable iterator.');
|
||||
}
|
||||
|
||||
return $this->length;
|
||||
}
|
||||
|
||||
public function isFirst(): bool
|
||||
{
|
||||
return $this->first;
|
||||
}
|
||||
|
||||
public function isLast(): bool
|
||||
{
|
||||
if (!$this->seq instanceof \Countable) {
|
||||
throw new RuntimeError('The "loop.last" variable is not defined as the loop iterates on a non-countable iterator.');
|
||||
}
|
||||
|
||||
return $this->last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Runtime;
|
||||
|
||||
/**
|
||||
* Represents a for loop context variable.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class LoopContext
|
||||
{
|
||||
public function __construct(private Loop $loop)
|
||||
{
|
||||
}
|
||||
|
||||
public function getParent(): mixed
|
||||
{
|
||||
return $this->loop->getParent();
|
||||
}
|
||||
|
||||
public function getRevindex0(): int
|
||||
{
|
||||
return $this->loop->getRevindex0();
|
||||
}
|
||||
|
||||
public function getRevindex(): int
|
||||
{
|
||||
return $this->loop->getRevindex0() + 1;
|
||||
}
|
||||
|
||||
public function getIndex0(): int
|
||||
{
|
||||
return $this->loop->getIndex0();
|
||||
}
|
||||
|
||||
public function getIndex(): int
|
||||
{
|
||||
return $this->getIndex0() + 1;
|
||||
}
|
||||
|
||||
public function getLength(): int
|
||||
{
|
||||
return $this->loop->getLength();
|
||||
}
|
||||
|
||||
public function isFirst(): bool
|
||||
{
|
||||
return $this->loop->isFirst();
|
||||
}
|
||||
|
||||
public function isLast(): bool
|
||||
{
|
||||
return $this->loop->isLast();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
--TEST--
|
||||
"for" tag supports Generators
|
||||
--TEMPLATE--
|
||||
{% for item in items %}
|
||||
{{ item }}
|
||||
{% endfor %}
|
||||
--DATA--
|
||||
return ['items' => (function () { yield 'a'; yield 'b'; yield 'c'; })()]
|
||||
--EXPECT--
|
||||
a
|
||||
b
|
||||
c
|
||||
@@ -0,0 +1,19 @@
|
||||
--TEST--
|
||||
"for" tag loop variable throws when last is used on a non-countable
|
||||
--TEMPLATE--
|
||||
{% for item in items %}
|
||||
* {{ loop.last }}
|
||||
{% endfor %}
|
||||
--DATA--
|
||||
class ItemsIteratorNotCountable implements \Iterator
|
||||
{
|
||||
protected $values = ['foo' => 'bar', 'bar' => 'foo'];
|
||||
public function current(): mixed { return current($this->values); }
|
||||
public function key(): mixed { return key($this->values); }
|
||||
public function next(): void { next($this->values); }
|
||||
public function rewind(): void { reset($this->values); }
|
||||
public function valid(): bool { return false !== current($this->values); }
|
||||
}
|
||||
return ['items' => new ItemsIteratorNotCountable()]
|
||||
--EXCEPTION--
|
||||
Twig\Error\RuntimeError: The "loop.last" variable is not defined as the loop iterates on a non-countable iterator in "index.twig" at line 3.
|
||||
+17
-79
@@ -33,7 +33,7 @@ class ForTest extends NodeTestCase
|
||||
$this->assertEquals($keyTarget, $node->getNode('key_target'));
|
||||
$this->assertEquals($valueTarget, $node->getNode('value_target'));
|
||||
$this->assertEquals($seq, $node->getNode('seq'));
|
||||
$this->assertEquals($body, $node->getNode('body')->getNode('0'));
|
||||
$this->assertEquals($body, $node->getNode('body'));
|
||||
$this->assertFalse($node->hasNode('else'));
|
||||
|
||||
$else = new PrintNode(new NameExpression('foo', 1), 1);
|
||||
@@ -57,12 +57,12 @@ class ForTest extends NodeTestCase
|
||||
$tests[] = [$node, <<<EOF
|
||||
// line 1
|
||||
\$context['_parent'] = \$context;
|
||||
\$context['_seq'] = CoreExtension::ensureTraversable({$this->getVariableGetter('items')});
|
||||
foreach (\$context['_seq'] as \$context["key"] => \$context["item"]) {
|
||||
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->getVariableGetter('items')}, \$context['_parent']);
|
||||
foreach (\$__internal_compile_0 as \$context["key"] => \$context["item"]) {
|
||||
yield {$this->getVariableGetter('foo')};
|
||||
}
|
||||
\$_parent = \$context['_parent'];
|
||||
unset(\$context['_seq'], \$context['_iterated'], \$context['key'], \$context['item'], \$context['_parent'], \$context['loop']);
|
||||
unset(\$context['key'], \$context['item'], \$context['_parent'], \$context['loop']);
|
||||
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
|
||||
EOF
|
||||
];
|
||||
@@ -78,33 +78,13 @@ EOF
|
||||
$tests[] = [$node, <<<EOF
|
||||
// line 1
|
||||
\$context['_parent'] = \$context;
|
||||
\$context['_seq'] = CoreExtension::ensureTraversable({$this->getVariableGetter('values')});
|
||||
\$context['loop'] = [
|
||||
'parent' => \$context['_parent'],
|
||||
'index0' => 0,
|
||||
'index' => 1,
|
||||
'first' => true,
|
||||
];
|
||||
if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) {
|
||||
\$length = count(\$context['_seq']);
|
||||
\$context['loop']['revindex0'] = \$length - 1;
|
||||
\$context['loop']['revindex'] = \$length;
|
||||
\$context['loop']['length'] = \$length;
|
||||
\$context['loop']['last'] = 1 === \$length;
|
||||
}
|
||||
foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) {
|
||||
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->getVariableGetter('values')}, \$context['_parent']);
|
||||
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0);
|
||||
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
|
||||
yield {$this->getVariableGetter('foo')};
|
||||
++\$context['loop']['index0'];
|
||||
++\$context['loop']['index'];
|
||||
\$context['loop']['first'] = false;
|
||||
if (isset(\$context['loop']['length'])) {
|
||||
--\$context['loop']['revindex0'];
|
||||
--\$context['loop']['revindex'];
|
||||
\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];
|
||||
}
|
||||
}
|
||||
\$_parent = \$context['_parent'];
|
||||
unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
|
||||
EOF
|
||||
];
|
||||
@@ -120,33 +100,13 @@ EOF
|
||||
$tests[] = [$node, <<<EOF
|
||||
// line 1
|
||||
\$context['_parent'] = \$context;
|
||||
\$context['_seq'] = CoreExtension::ensureTraversable({$this->getVariableGetter('values')});
|
||||
\$context['loop'] = [
|
||||
'parent' => \$context['_parent'],
|
||||
'index0' => 0,
|
||||
'index' => 1,
|
||||
'first' => true,
|
||||
];
|
||||
if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) {
|
||||
\$length = count(\$context['_seq']);
|
||||
\$context['loop']['revindex0'] = \$length - 1;
|
||||
\$context['loop']['revindex'] = \$length;
|
||||
\$context['loop']['length'] = \$length;
|
||||
\$context['loop']['last'] = 1 === \$length;
|
||||
}
|
||||
foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) {
|
||||
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->getVariableGetter('values')}, \$context['_parent']);
|
||||
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0);
|
||||
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
|
||||
yield {$this->getVariableGetter('foo')};
|
||||
++\$context['loop']['index0'];
|
||||
++\$context['loop']['index'];
|
||||
\$context['loop']['first'] = false;
|
||||
if (isset(\$context['loop']['length'])) {
|
||||
--\$context['loop']['revindex0'];
|
||||
--\$context['loop']['revindex'];
|
||||
\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];
|
||||
}
|
||||
}
|
||||
\$_parent = \$context['_parent'];
|
||||
unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
|
||||
EOF
|
||||
];
|
||||
@@ -162,38 +122,16 @@ EOF
|
||||
$tests[] = [$node, <<<EOF
|
||||
// line 1
|
||||
\$context['_parent'] = \$context;
|
||||
\$context['_seq'] = CoreExtension::ensureTraversable({$this->getVariableGetter('values')});
|
||||
\$context['_iterated'] = false;
|
||||
\$context['loop'] = [
|
||||
'parent' => \$context['_parent'],
|
||||
'index0' => 0,
|
||||
'index' => 1,
|
||||
'first' => true,
|
||||
];
|
||||
if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) {
|
||||
\$length = count(\$context['_seq']);
|
||||
\$context['loop']['revindex0'] = \$length - 1;
|
||||
\$context['loop']['revindex'] = \$length;
|
||||
\$context['loop']['length'] = \$length;
|
||||
\$context['loop']['last'] = 1 === \$length;
|
||||
}
|
||||
foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) {
|
||||
\$__internal_compile_0 = new \Twig\Runtime\Loop({$this->getVariableGetter('values')}, \$context['_parent']);
|
||||
\$context['loop'] = new \Twig\Runtime\LoopContext(\$__internal_compile_0);
|
||||
foreach (\$__internal_compile_0 as \$context["k"] => \$context["v"]) {
|
||||
yield {$this->getVariableGetter('foo')};
|
||||
\$context['_iterated'] = true;
|
||||
++\$context['loop']['index0'];
|
||||
++\$context['loop']['index'];
|
||||
\$context['loop']['first'] = false;
|
||||
if (isset(\$context['loop']['length'])) {
|
||||
--\$context['loop']['revindex0'];
|
||||
--\$context['loop']['revindex'];
|
||||
\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];
|
||||
}
|
||||
}
|
||||
if (!\$context['_iterated']) {
|
||||
if (!\$__internal_compile_0->iterated()) {
|
||||
yield {$this->getVariableGetter('foo')};
|
||||
}
|
||||
\$_parent = \$context['_parent'];
|
||||
unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
unset(\$context['k'], \$context['v'], \$context['_parent'], \$context['loop']);
|
||||
\$context = array_intersect_key(\$context, \$_parent) + \$_parent;
|
||||
EOF
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user