mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-12 10:26:32 +00:00
feature #4925 Resolve block chains against the render context (fabpot)
This PR was merged into the 3.x branch.
Discussion
----------
Resolve block chains against the render context
While working on the Symfony PR for #4917, I realize that the performance was worse with the Twig's way. #4924 fixes part of the performance "regression". This one closes the gap.
`BlockChain` currently freezes each template's lineage by cloning it, so the block map and `parent()` both resolve `{% extends %}` against the constructor context.
This PR changes that to resolve the chain against the render context instead. `hasBlock()` and `getBlockNames()` take a context, like `TemplateWrapper` already does, and the third constructor argument becomes a default the render context can override. A lineage whose templates all have a fixed parent (none, or constant) is resolved once and cached, so the common case costs nothing; a dynamic `{% extends %}` re-resolves per call (not use by Symfony anyway).
This fixes also an inconsistency: a chained template with a dynamic parent now behaves exactly as it does when rendered directly. It also removes all changes in the "core" logic of Template.
```
construct render 1 chain + 40 renders
Symfony's engine today 13.44us 0.78us 50.3us
#4917 ~70us 0.96us ~117us
this PR 0.61us 0.84us 53.0us
```
Commits
-------
897717d78f Resolve block chains against the render context instead of freezing lineages
This commit is contained in:
+17
-3
@@ -108,9 +108,23 @@ templates share the composed block set, only combine templates that are trusted
|
||||
to call one another's blocks. Chained blocks have the same top-level macro
|
||||
import limitation as blocks rendered directly.
|
||||
|
||||
The optional third constructor argument provides variables used to resolve
|
||||
dynamic parent expressions. Parent hierarchies are fixed when the chain is
|
||||
created; create another chain when these variables change.
|
||||
Every method takes the render context, and the whole chain is resolved against
|
||||
it, so a template using a dynamic ``{% extends %}`` behaves exactly as it would
|
||||
when rendered directly. The optional third constructor argument provides
|
||||
default variables for that resolution, which the render context can override::
|
||||
|
||||
$blocks = new BlockChain($twig, ['application_theme.html.twig'], [
|
||||
'layout' => 'wide_layout.html.twig',
|
||||
]);
|
||||
|
||||
// resolved against wide_layout.html.twig
|
||||
$blocks->getBlockNames();
|
||||
|
||||
// resolved against narrow_layout.html.twig
|
||||
$blocks->getBlockNames(['layout' => 'narrow_layout.html.twig']);
|
||||
|
||||
Pass the same context to ``hasBlock()``, ``getBlockNames()`` and
|
||||
``renderBlock()`` to keep them consistent.
|
||||
|
||||
Streaming Templates
|
||||
-------------------
|
||||
|
||||
+132
-48
@@ -18,21 +18,29 @@ use Twig\Error\RuntimeError;
|
||||
*/
|
||||
final class BlockChain
|
||||
{
|
||||
/** @var list<Template> */
|
||||
private array $templates = [];
|
||||
|
||||
/** @var list<Template> */
|
||||
private array $lineage = [];
|
||||
|
||||
/** @var array<string, array{Template, string}> */
|
||||
private array $blocks;
|
||||
private Template $template;
|
||||
private array $blocks = [];
|
||||
|
||||
/**
|
||||
* Whether the lineage can no longer move, making every further resolution pointless.
|
||||
*/
|
||||
private bool $fixed = false;
|
||||
|
||||
/**
|
||||
* @param iterable<string|TemplateWrapper> $templates Templates ordered from highest to lowest precedence
|
||||
* @param array<string, mixed> $context Default variables used to resolve dynamic parent expressions
|
||||
*/
|
||||
public function __construct(
|
||||
private Environment $env,
|
||||
iterable $templates,
|
||||
array $context = [],
|
||||
private array $context = [],
|
||||
) {
|
||||
$resolution = new BlockResolutionContext($env, $context + $env->getGlobals());
|
||||
$blocks = [];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
if (\is_string($template)) {
|
||||
$template = $env->load($template);
|
||||
@@ -41,67 +49,143 @@ final class BlockChain
|
||||
throw new \TypeError(\sprintf('Block chain templates must be strings or "%s" instances, "%s" given.', TemplateWrapper::class, get_debug_type($template)));
|
||||
}
|
||||
|
||||
$current = $template->unwrap()->freezeLineage($resolution);
|
||||
$this->template ??= $current;
|
||||
do {
|
||||
foreach ($current->getBlocks() as $name => $block) {
|
||||
if (isset($blocks[$name])) {
|
||||
continue;
|
||||
}
|
||||
if (!\is_array($block) || !isset($block[0], $block[1]) || !$block[0] instanceof Template || !\is_string($block[1])) {
|
||||
throw new \LogicException('A block must be a method on a \Twig\Template instance.');
|
||||
}
|
||||
$template = $template->unwrap();
|
||||
if (!$template->isOwnedBy($env)) {
|
||||
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
|
||||
}
|
||||
|
||||
$resolution->assertOwns($block[0]);
|
||||
$blocks[$name] = $block;
|
||||
}
|
||||
} while (false !== $current = $resolution->getParent($current));
|
||||
$this->templates[] = $template;
|
||||
}
|
||||
|
||||
if (!isset($this->template)) {
|
||||
if (!$this->templates) {
|
||||
throw new \InvalidArgumentException('A block chain requires at least one template.');
|
||||
}
|
||||
|
||||
$this->blocks = $blocks;
|
||||
}
|
||||
|
||||
public function hasBlock(string $name): bool
|
||||
{
|
||||
return isset($this->blocks[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function hasBlock(string $name, array $context = []): bool
|
||||
{
|
||||
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context + $this->context + $this->env->getGlobals());
|
||||
|
||||
return isset($blocks[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getBlockNames(): array
|
||||
public function getBlockNames(array $context = []): array
|
||||
{
|
||||
return array_keys($this->blocks);
|
||||
return array_keys($this->fixed ? $this->blocks : $this->resolveBlocks($context + $this->context + $this->env->getGlobals()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return iterable<scalar|\Stringable|null>
|
||||
*/
|
||||
public function streamBlock(string $name, array $context = []): iterable
|
||||
{
|
||||
yield from $this->getBlock($name)->yieldBlock($name, $context + $this->env->getGlobals(), $this->blocks);
|
||||
}
|
||||
|
||||
public function renderBlock(string $name, array $context = []): string
|
||||
{
|
||||
return $this->getBlock($name)->renderBlock($name, $context + $this->env->getGlobals(), $this->blocks);
|
||||
}
|
||||
|
||||
public function displayBlock(string $name, array $context = []): void
|
||||
{
|
||||
$this->getBlock($name)->displayBlock($name, $context + $this->env->getGlobals(), $this->blocks);
|
||||
}
|
||||
|
||||
private function getBlock(string $name): Template
|
||||
{
|
||||
if (isset($this->blocks[$name])) {
|
||||
return $this->blocks[$name][0];
|
||||
$context += $this->context + $this->env->getGlobals();
|
||||
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
|
||||
if (!isset($blocks[$name])) {
|
||||
$this->throwUnknownBlock($name);
|
||||
}
|
||||
|
||||
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->template->getTemplateName()), -1, $this->template->getSourceContext());
|
||||
yield from $this->templates[0]->yieldBlock($name, $context, $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function renderBlock(string $name, array $context = []): string
|
||||
{
|
||||
$context += $this->context + $this->env->getGlobals();
|
||||
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
|
||||
if (!isset($blocks[$name])) {
|
||||
$this->throwUnknownBlock($name);
|
||||
}
|
||||
|
||||
return $this->templates[0]->renderBlock($name, $context, $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function displayBlock(string $name, array $context = []): void
|
||||
{
|
||||
$context += $this->context + $this->env->getGlobals();
|
||||
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
|
||||
if (!isset($blocks[$name])) {
|
||||
$this->throwUnknownBlock($name);
|
||||
}
|
||||
|
||||
$this->templates[0]->displayBlock($name, $context, $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return array<string, array{Template, string}>
|
||||
*/
|
||||
private function resolveBlocks(array $context): array
|
||||
{
|
||||
[$lineage, $this->fixed] = $this->resolveLineage($context);
|
||||
|
||||
if ($lineage === $this->lineage) {
|
||||
return $this->blocks;
|
||||
}
|
||||
|
||||
$blocks = [];
|
||||
foreach ($lineage as $template) {
|
||||
foreach ($template->getBlocks() as $name => $block) {
|
||||
$blocks[$name] ??= $block;
|
||||
}
|
||||
}
|
||||
|
||||
$this->lineage = $lineage;
|
||||
|
||||
return $this->blocks = $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return array{list<Template>, bool}
|
||||
*/
|
||||
private function resolveLineage(array $context): array
|
||||
{
|
||||
$lineage = [];
|
||||
$fixed = true;
|
||||
|
||||
foreach ($this->templates as $template) {
|
||||
$seen = [];
|
||||
do {
|
||||
if (isset($seen[$id = spl_object_id($template)])) {
|
||||
throw new \LogicException(\sprintf('Circular template inheritance detected while building a block chain from "%s".', $template->getTemplateName()));
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$lineage[] = $template;
|
||||
|
||||
$parent = $template->getParent($context);
|
||||
$fixed = $fixed && $template->hasFixedParent();
|
||||
|
||||
// a dynamic parent expression can evaluate to a template from another environment
|
||||
$template = $parent instanceof TemplateWrapper ? $parent->unwrap() : $parent;
|
||||
if (false !== $template && !$template->isOwnedBy($this->env)) {
|
||||
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
|
||||
}
|
||||
} while (false !== $template);
|
||||
}
|
||||
|
||||
return [$lineage, $fixed];
|
||||
}
|
||||
|
||||
private function throwUnknownBlock(string $name): never
|
||||
{
|
||||
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->templates[0]->getTemplateName()), -1, $this->templates[0]->getSourceContext());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +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;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BlockResolutionContext
|
||||
{
|
||||
/** @var \SplObjectStorage<Template, Template|false> */
|
||||
private \SplObjectStorage $parents;
|
||||
|
||||
/** @var \SplObjectStorage<Template, Template> */
|
||||
private \SplObjectStorage $frozen;
|
||||
|
||||
/** @var \SplObjectStorage<Template, true> */
|
||||
private \SplObjectStorage $freezing;
|
||||
|
||||
public function __construct(
|
||||
private Environment $env,
|
||||
private array $context,
|
||||
) {
|
||||
$this->parents = new \SplObjectStorage();
|
||||
$this->frozen = new \SplObjectStorage();
|
||||
$this->freezing = new \SplObjectStorage();
|
||||
}
|
||||
|
||||
public function getParent(Template $template): Template|false
|
||||
{
|
||||
if ($this->parents->offsetExists($template)) {
|
||||
return $this->parents[$template];
|
||||
}
|
||||
|
||||
$parent = $template->getParent($this->context);
|
||||
if ($parent instanceof TemplateWrapper) {
|
||||
$parent = $parent->unwrap();
|
||||
}
|
||||
|
||||
return $this->parents[$template] = $parent;
|
||||
}
|
||||
|
||||
public function setParent(Template $template, Template|false $parent): void
|
||||
{
|
||||
$this->parents[$template] = $parent;
|
||||
}
|
||||
|
||||
public function assertOwns(Template $template): void
|
||||
{
|
||||
if (!$template->isOwnedBy($this->env)) {
|
||||
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isFrozen(Template $template): bool
|
||||
{
|
||||
return $this->frozen->offsetExists($template);
|
||||
}
|
||||
|
||||
public function getFrozen(Template $template): Template
|
||||
{
|
||||
return $this->frozen[$template];
|
||||
}
|
||||
|
||||
public function setFrozen(Template $template, Template $frozen): void
|
||||
{
|
||||
$this->frozen[$template] = $frozen;
|
||||
}
|
||||
|
||||
public function beginFreeze(Template $template): void
|
||||
{
|
||||
if ($this->freezing->offsetExists($template)) {
|
||||
throw new \LogicException(\sprintf('Circular template inheritance detected while building a block chain from "%s".', $template->getTemplateName()));
|
||||
}
|
||||
|
||||
$this->freezing[$template] = true;
|
||||
}
|
||||
|
||||
public function endFreeze(Template $template): void
|
||||
{
|
||||
$this->freezing->offsetUnset($template);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class BlockNode extends Node
|
||||
->write(" */\n")
|
||||
->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n")
|
||||
->indent()
|
||||
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
|
||||
->write("\$macros = \$this->macros;\n")
|
||||
;
|
||||
|
||||
$compiler
|
||||
|
||||
@@ -115,7 +115,7 @@ class MacroNode extends Node
|
||||
->raw("): string|Markup {\n")
|
||||
->indent()
|
||||
->addDebugInfo($this)
|
||||
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
|
||||
->write("\$macros = \$this->macros;\n")
|
||||
->write("\$context = [\n")
|
||||
->indent()
|
||||
;
|
||||
|
||||
+4
-61
@@ -39,7 +39,6 @@ abstract class Template
|
||||
protected $traitAliases = [];
|
||||
protected $extensions = [];
|
||||
protected $sandbox;
|
||||
protected ?self $macroImportSource = null;
|
||||
|
||||
private $useYield;
|
||||
private ?MacroNamespace $macroNamespace = null;
|
||||
@@ -79,11 +78,6 @@ abstract class Template
|
||||
public function getParent(array $context): self|TemplateWrapper|false
|
||||
{
|
||||
if (null !== $this->parent) {
|
||||
// only block chain clones set macroImportSource; they never run yield(), where the sandbox check normally happens
|
||||
if (null !== $this->macroImportSource) {
|
||||
$this->ensureSecurityChecked();
|
||||
}
|
||||
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
@@ -379,42 +373,12 @@ abstract class Template
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns whether getParent() has stopped depending on the context, which
|
||||
* only ever happens for a template with no parent or with a constant one.
|
||||
*/
|
||||
public function freezeLineage(BlockResolutionContext $resolution): self
|
||||
public function hasFixedParent(): bool
|
||||
{
|
||||
$resolution->assertOwns($this);
|
||||
if ($resolution->isFrozen($this)) {
|
||||
return $resolution->getFrozen($this);
|
||||
}
|
||||
|
||||
$resolution->beginFreeze($this);
|
||||
try {
|
||||
if (false === $parent = $resolution->getParent($this)) {
|
||||
$resolution->setFrozen($this, $this);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$template = clone $this;
|
||||
foreach ($template->blocks as &$block) {
|
||||
if ($block[0] === $this) {
|
||||
$block[0] = $template;
|
||||
}
|
||||
}
|
||||
unset($block);
|
||||
|
||||
$template->macroNamespace = null;
|
||||
// Keep module-level imports live while rebinding self imports to the clone.
|
||||
$template->macroImportSource = $this;
|
||||
$template->parent = $frozenParent = $parent->freezeLineage($resolution);
|
||||
$resolution->setParent($template, $frozenParent);
|
||||
$resolution->setFrozen($this, $template);
|
||||
|
||||
return $template;
|
||||
} finally {
|
||||
$resolution->endFreeze($this);
|
||||
}
|
||||
return null !== $this->parent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,27 +525,6 @@ abstract class Template
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MacroNamespace> $macros
|
||||
*
|
||||
* @return array<string, MacroNamespace>
|
||||
*/
|
||||
protected function rebindMacroImports(array $macros): array
|
||||
{
|
||||
if (null === $this->macroImportSource) {
|
||||
return $macros;
|
||||
}
|
||||
|
||||
$imported = $this->macroImportSource->getMacroNamespace();
|
||||
foreach ($macros as $name => $namespace) {
|
||||
if ($namespace === $imported) {
|
||||
$macros[$name] = $this->getMacroNamespace();
|
||||
}
|
||||
}
|
||||
|
||||
return $macros;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the sandbox security check against the current sandbox state.
|
||||
*
|
||||
|
||||
+94
-47
@@ -54,7 +54,7 @@ class BlockChainTest extends TestCase
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testNestedBlocksUseTheEffectiveNamespaceAndParentUsesTheFrozenLineage(bool $useYield): void
|
||||
public function testNestedBlocksUseTheComposedSetAndParentUsesTheRenderContextLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% block field %}theme/{{ parent() }}/{{ block("suffix") }}{% endblock %}',
|
||||
@@ -65,7 +65,8 @@ class BlockChainTest extends TestCase
|
||||
|
||||
$chain = new BlockChain($twig, ['theme', 'suffix'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field'));
|
||||
$this->assertSame('theme/parent2/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
}
|
||||
|
||||
public function testExplicitTemplateBlockCallsResolveOutsideTheChainNamespace(): void
|
||||
@@ -81,7 +82,41 @@ class BlockChainTest extends TestCase
|
||||
$this->assertSame('explicit', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
public function testFreezingAChainDoesNotChangeTheLoadedTemplate(): void
|
||||
public function testTheConstructorContextIsADefaultTheRenderContextCanOverride(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
|
||||
'parent1' => '{% block field %}one{% endblock %}',
|
||||
'parent2' => '{% block field %}two{% endblock %}',
|
||||
]), ['autoescape' => false, 'use_yield' => true]);
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
public function testIntrospectionAndRenderingAgreeOnTheSameContext(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
|
||||
'parent1' => '{% block field %}one{% endblock %}{% block only1 %}{% endblock %}',
|
||||
'parent2' => '{% block field %}two{% endblock %}{% block only2 %}{% endblock %}',
|
||||
]), ['autoescape' => false, 'use_yield' => true]);
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame(['field', 'only1'], $chain->getBlockNames());
|
||||
$this->assertTrue($chain->hasBlock('only1'));
|
||||
$this->assertFalse($chain->hasBlock('only2'));
|
||||
|
||||
$context = ['parent' => 'parent2'];
|
||||
$this->assertSame(['field', 'only2'], $chain->getBlockNames($context));
|
||||
$this->assertFalse($chain->hasBlock('only1', $context));
|
||||
$this->assertTrue($chain->hasBlock('only2', $context));
|
||||
$this->assertSame('two', $chain->renderBlock('field', $context));
|
||||
}
|
||||
|
||||
public function testRenderingThroughTheChainDoesNotChangeTheLoadedTemplate(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
|
||||
@@ -91,8 +126,9 @@ class BlockChainTest extends TestCase
|
||||
$template = $twig->load('theme');
|
||||
$chain = new BlockChain($twig, [$template], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
$this->assertSame('two', $template->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
public function testStructuralContextIncludesEnvironmentGlobals(): void
|
||||
@@ -183,11 +219,13 @@ class BlockChainTest extends TestCase
|
||||
$this->blocks = ['field' => [new \stdClass(), 'block_field']];
|
||||
}
|
||||
};
|
||||
$chain = new BlockChain($twig, [new TemplateWrapper($twig, $template)]);
|
||||
$this->assertSame(['field'], $chain->getBlockNames());
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('A block must be a method on a \Twig\Template instance.');
|
||||
|
||||
new BlockChain($twig, [new TemplateWrapper($twig, $template)]);
|
||||
$chain->renderBlock('field');
|
||||
}
|
||||
|
||||
public function testRejectsTemplatesThatAreNotStringsOrWrappers(): void
|
||||
@@ -227,24 +265,26 @@ class BlockChainTest extends TestCase
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader(['theme' => '{% extends parent %}']));
|
||||
$other = new Environment(new ArrayLoader(['parent' => '']));
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
|
||||
|
||||
new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
|
||||
$chain->getBlockNames();
|
||||
}
|
||||
|
||||
public function testDynamicParentSecurityIsCheckedDuringConstruction(): void
|
||||
public function testDynamicParentSecurityIsCheckedWhenTheLineageIsResolved(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent|upper %}',
|
||||
'PARENT' => '',
|
||||
]));
|
||||
$twig->addExtension(new SandboxExtension(new SecurityPolicy(['extends']), true));
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent']);
|
||||
|
||||
$this->expectException(SecurityNotAllowedFilterError::class);
|
||||
|
||||
new BlockChain($twig, ['theme'], ['parent' => 'parent']);
|
||||
$chain->getBlockNames();
|
||||
}
|
||||
|
||||
public function testDefiningTemplateSecurityIsCheckedDuringRendering(): void
|
||||
@@ -282,7 +322,7 @@ class BlockChainTest extends TestCase
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testSandboxPolicyChangesAreCheckedOnFrozenIntermediateParents(bool $useYield): void
|
||||
public function testSandboxPolicyChangesAreCheckedOnIntermediateParents(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends "middle" %}{% block field %}{{ parent() }}{% endblock %}',
|
||||
@@ -356,7 +396,7 @@ class BlockChainTest extends TestCase
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
public function testInheritedMacroLookupUsesTheFrozenLineage(): void
|
||||
public function testInheritedMacroLookupUsesTheRenderContextLineage(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% block field %}{{ _self.label() }}{% endblock %}',
|
||||
@@ -366,14 +406,15 @@ class BlockChainTest extends TestCase
|
||||
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testSelfMacroImportsInitializedAfterConstructionUseTheFrozenLineage(bool $useYield): void
|
||||
public function testSelfMacroImportsUseTheRenderContextLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
|
||||
@@ -383,50 +424,32 @@ class BlockChainTest extends TestCase
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testPreWarmedSelfMacroImportsUseTheFrozenLineage(bool $useYield): void
|
||||
public function testFromSelfImportsResolveThroughTheLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
|
||||
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
|
||||
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
|
||||
'theme' => '{% extends "parent" %}{% from _self import label %}{% macro wrapped() %}{{ label() }}{% endmacro %}{% block field %}{{ label() }}/{{ _self.wrapped() }}{% endblock %}',
|
||||
'parent' => '{% macro label() %}one{% endmacro %}',
|
||||
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
||||
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
|
||||
$this->assertSame('', $twig->render('theme'));
|
||||
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
$chain = new BlockChain($twig, ['theme']);
|
||||
|
||||
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
$this->assertSame('one/one', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testFromSelfImportsUseTheFrozenLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% from _self import label %}{% macro wrapped() %}{{ label() }}{% endmacro %}{% block field %}{{ label() }}/{{ _self.wrapped() }}{% endblock %}',
|
||||
'parent1' => '{% macro label() %}one{% endmacro %}',
|
||||
'parent2' => '{% macro label() %}two{% endmacro %}',
|
||||
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
||||
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
|
||||
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
|
||||
$this->assertSame('one/one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testImportedMacroNamespacesStayOutsideTheFrozenLineage(bool $useYield): void
|
||||
public function testImportedMacroNamespacesKeepTheirOwnLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% import parent as inherited %}{% block field %}{{ inherited.label() }}{% endblock %}',
|
||||
@@ -470,17 +493,40 @@ class BlockChainTest extends TestCase
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testSelfMacroImportsInMacroBodiesUseTheFrozenLineage(bool $useYield): void
|
||||
public function testSelfMacroImportsInMacroBodiesResolveThroughTheLineage(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends "parent" %}{% import _self as own %}{% macro wrapped() %}{{ own.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
|
||||
'parent' => '{% macro label() %}one{% endmacro %}',
|
||||
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
||||
$chain = new BlockChain($twig, ['theme']);
|
||||
|
||||
$this->assertSame('', $twig->render('theme'));
|
||||
$this->assertSame('one', $chain->renderBlock('field'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider yieldModes
|
||||
*/
|
||||
#[DataProvider('yieldModes')]
|
||||
public function testMacroBodiesCannotResolveADynamicParentJustLikeADirectRender(bool $useYield): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'theme' => '{% extends parent %}{% import _self as own %}{% macro wrapped() %}{{ own.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
|
||||
'parent1' => '{% macro label() %}one{% endmacro %}',
|
||||
'parent2' => '{% macro label() %}two{% endmacro %}',
|
||||
'parent' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
|
||||
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
||||
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent']);
|
||||
|
||||
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
|
||||
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
|
||||
// a macro body only receives the macro arguments, so the "parent" variable is out of reach either way
|
||||
$this->expectException(RuntimeError::class);
|
||||
|
||||
try {
|
||||
$twig->render('theme', ['parent' => 'parent']);
|
||||
$this->fail('Rendering the template directly must fail.');
|
||||
} catch (RuntimeError) {
|
||||
}
|
||||
|
||||
$chain->renderBlock('field');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,17 +627,18 @@ class BlockChainTest extends TestCase
|
||||
$chain->renderBlock('missing');
|
||||
}
|
||||
|
||||
public function testCircularInheritanceIsRejectedDuringConstruction(): void
|
||||
public function testCircularInheritanceIsRejected(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'one' => '{% extends "two" %}',
|
||||
'two' => '{% extends "one" %}',
|
||||
]));
|
||||
$chain = new BlockChain($twig, ['one']);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('Circular template inheritance detected while building a block chain from "one".');
|
||||
|
||||
new BlockChain($twig, ['one']);
|
||||
$chain->getBlockNames();
|
||||
}
|
||||
|
||||
public function testRequiresAtLeastOneTemplate(): void
|
||||
|
||||
@@ -47,7 +47,7 @@ class BlockTest extends NodeTestCase
|
||||
*/
|
||||
public function block_foo(array \$context, array \$blocks = []): iterable
|
||||
{
|
||||
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
|
||||
\$macros = \$this->macros;
|
||||
yield "foo";
|
||||
yield from [];
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class MacroTest extends NodeTestCase
|
||||
yield 'with use_yield = true' => [self::createNode(), <<<EOF
|
||||
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
|
||||
// line 1
|
||||
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
|
||||
\$macros = \$this->macros;
|
||||
\$context = [
|
||||
"foo" => \$foo,
|
||||
"bar" => \$bar,
|
||||
@@ -86,7 +86,7 @@ EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
|
||||
yield 'with use_yield = false' => [self::createNode(), <<<EOF
|
||||
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
|
||||
// line 1
|
||||
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
|
||||
\$macros = \$this->macros;
|
||||
\$context = [
|
||||
"foo" => \$foo,
|
||||
"bar" => \$bar,
|
||||
|
||||
@@ -64,7 +64,7 @@ protected function loadDeclaredMacros(): array
|
||||
return [
|
||||
"foo" => new \\Twig\\TwigMacro("foo", function (\$foo = null, ...\$varargs): string|Markup {
|
||||
// line 1
|
||||
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
|
||||
\$macros = \$this->macros;
|
||||
\$context = [
|
||||
"foo" => \$foo,
|
||||
"varargs" => \$varargs,
|
||||
|
||||
@@ -253,6 +253,27 @@ class TemplateTest extends TestCase
|
||||
$this->assertSame($level, $actualLevel);
|
||||
}
|
||||
|
||||
public function testHasFixedParentOnlyReportsLineagesThatCanNoLongerMove(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader([
|
||||
'no_parent' => '',
|
||||
'constant_parent' => '{% extends "no_parent" %}',
|
||||
'dynamic_parent' => '{% extends parent %}',
|
||||
]));
|
||||
|
||||
$this->assertTrue($twig->load('no_parent')->unwrap()->hasFixedParent());
|
||||
|
||||
$constant = $twig->load('constant_parent')->unwrap();
|
||||
$this->assertFalse($constant->hasFixedParent());
|
||||
$constant->getParent([]);
|
||||
$this->assertTrue($constant->hasFixedParent());
|
||||
|
||||
$dynamic = $twig->load('dynamic_parent')->unwrap();
|
||||
$this->assertFalse($dynamic->hasFixedParent());
|
||||
$dynamic->getParent(['parent' => 'no_parent']);
|
||||
$this->assertFalse($dynamic->hasFixedParent());
|
||||
}
|
||||
|
||||
public function testGetAttributeOnArrayWithConfusableKey(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader());
|
||||
|
||||
Reference in New Issue
Block a user