mirror of
https://github.com/twigphp/Twig.git
synced 2026-10-01 17:36:53 +00:00
dea0483027
This PR was squashed before being merged into the 3.x branch. Discussion ---------- Reject template wrappers from another environment Twig now rejects `TemplateWrapper` instances created by another `Environment`. This prevents templates from unexpectedly using another environment's loader, extensions, globals, or sandbox policy. Commits -------83e8f7e123Reject cross-environment template wrappers in block chainsc1fc112047Reject cross-environment template wrappers
808 lines
34 KiB
PHP
808 lines
34 KiB
PHP
<?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\Tests;
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Twig\BlockChain;
|
|
use Twig\Environment;
|
|
use Twig\Error\RuntimeError;
|
|
use Twig\Extension\ProfilerExtension;
|
|
use Twig\Extension\SandboxExtension;
|
|
use Twig\Loader\ArrayLoader;
|
|
use Twig\Profiler\Profile;
|
|
use Twig\Sandbox\SecurityNotAllowedFilterError;
|
|
use Twig\Sandbox\SecurityPolicy;
|
|
use Twig\Source;
|
|
use Twig\Template;
|
|
use Twig\TemplateWrapper;
|
|
|
|
class BlockChainTest extends TestCase
|
|
{
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testComposesCompleteTemplateLineagesInPrecedenceOrder(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme1' => '{% extends "parent1" %}{% block first %}theme1{% endblock %}',
|
|
'parent1' => '{% block shared %}parent1{% endblock %}{% block parent1 %}parent1{% endblock %}',
|
|
'theme2' => '{% extends "parent2" %}{% block shared %}theme2{% endblock %}{% block second %}theme2{% endblock %}',
|
|
'parent2' => '{% block parent2 %}parent2{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
|
|
$chain = new BlockChain($twig, ['theme1', $twig->load('theme2')]);
|
|
|
|
$this->assertSame(['first', 'shared', 'parent1', 'second', 'parent2'], $chain->getBlockNames());
|
|
$this->assertTrue($chain->hasBlock('shared'));
|
|
$this->assertFalse($chain->hasBlock('missing'));
|
|
$this->assertSame('parent1', $chain->renderBlock('shared'));
|
|
$this->assertSame('theme2', $chain->renderBlock('second'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testNestedBlocksUseTheComposedSetAndParentUsesTheRenderContextLineage(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}{% block field %}theme/{{ parent() }}/{{ block("suffix") }}{% endblock %}',
|
|
'parent1' => '{% block field %}parent1{% endblock %}',
|
|
'parent2' => '{% block field %}parent2{% endblock %}',
|
|
'suffix' => '{% block suffix %}suffix{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
|
|
$chain = new BlockChain($twig, ['theme', 'suffix'], ['parent' => 'parent1']);
|
|
|
|
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field'));
|
|
$this->assertSame('theme/parent2/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
|
|
}
|
|
|
|
public function testExplicitTemplateBlockCallsResolveOutsideTheChainNamespace(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% block field %}{{ block("suffix", "explicit") }}{% endblock %}',
|
|
'chain' => '{% block suffix %}chain{% endblock %}',
|
|
'explicit' => '{% block suffix %}explicit{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$chain = new BlockChain($twig, ['theme', 'chain']);
|
|
|
|
$this->assertSame('explicit', $chain->renderBlock('field'));
|
|
}
|
|
|
|
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 %}',
|
|
'parent1' => '{% block field %}one{% endblock %}',
|
|
'parent2' => '{% block field %}two{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
$template = $twig->load('theme');
|
|
$chain = new BlockChain($twig, [$template], ['parent' => 'parent1']);
|
|
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
$this->assertSame('two', $template->renderBlock('field', ['parent' => 'parent2']));
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testStructuralContextIncludesEnvironmentGlobals(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends layout %}{% block field %}{{ parent() }}{% endblock %}',
|
|
'parent' => '{% block field %}parent{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
$twig->addGlobal('layout', 'parent');
|
|
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame('parent', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testTraitAliasesKeepTheirLocalParentLineage(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'base_trait' => '{% block field %}base{% endblock %}',
|
|
'trait' => '{% use "base_trait" %}{% block field %}trait/{{ parent() }}{% endblock %}',
|
|
'theme' => '{% use "trait" with field as aliased %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame(['aliased'], $chain->getBlockNames());
|
|
$this->assertSame('trait/base', $chain->renderBlock('aliased'));
|
|
}
|
|
|
|
public function testRenderCapturesLegacyEchoingBlocks(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(), ['use_yield' => false]);
|
|
$chain = new BlockChain($twig, [new TemplateWrapper($twig, new EchoingBlockChainTemplate($twig))]);
|
|
|
|
$this->assertSame('echo/yield', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testRenderRestoresOutputBuffersOnError(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% block field %}{% set captured %}{{ missing.value }}{% endset %}{% endblock %}',
|
|
]), ['strict_variables' => true, 'use_yield' => false]);
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
$level = ob_get_level();
|
|
|
|
try {
|
|
$chain->renderBlock('field');
|
|
$this->fail('Rendering the block must fail.');
|
|
} catch (RuntimeError) {
|
|
$actualLevel = ob_get_level();
|
|
} finally {
|
|
while (ob_get_level() > $level) {
|
|
ob_end_clean();
|
|
}
|
|
}
|
|
|
|
$this->assertSame($level, $actualLevel);
|
|
}
|
|
|
|
public function testRenderingDisplayingAndStreamingAddGlobals(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% block field %}{{ local }}:{{ global|default("none") }}{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
$twig->addGlobal('global', 'GLOBAL');
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame('LOCAL:GLOBAL', $chain->renderBlock('field', ['local' => 'LOCAL']));
|
|
|
|
ob_start();
|
|
$chain->displayBlock('field', ['local' => 'LOCAL']);
|
|
$this->assertSame('LOCAL:GLOBAL', ob_get_clean());
|
|
|
|
$streamed = '';
|
|
foreach ($chain->streamBlock('field', ['local' => 'LOCAL']) as $data) {
|
|
$streamed .= $data;
|
|
}
|
|
$this->assertSame('LOCAL:GLOBAL', $streamed);
|
|
}
|
|
|
|
public function testRejectsInvalidBlockDefinitions(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader());
|
|
$template = new class($twig) extends EchoingBlockChainTemplate {
|
|
public function __construct(Environment $env)
|
|
{
|
|
parent::__construct($env);
|
|
$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.');
|
|
|
|
$chain->renderBlock('field');
|
|
}
|
|
|
|
public function testRejectsTemplatesThatAreNotStringsWrappersOrChains(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
|
|
$this->expectException(\TypeError::class);
|
|
$this->expectExceptionMessage('Block chain templates must be strings, "Twig\TemplateWrapper" or "Twig\BlockChain" instances, "stdClass" given.');
|
|
|
|
new BlockChain($twig, [new \stdClass()]);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testAChainedChainKeepsItsPositionInThePrecedenceOrder(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'override' => '{% block field %}override{% endblock %}',
|
|
'base' => '{% block field %}base{% endblock %}{% block shared %}base{% endblock %}',
|
|
'fallback' => '{% block shared %}fallback{% endblock %}{% block last %}fallback{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
|
|
$base = new BlockChain($twig, ['base']);
|
|
$chain = new BlockChain($twig, ['override', $base, 'fallback']);
|
|
|
|
$this->assertSame(['field', 'shared', 'last'], $chain->getBlockNames());
|
|
$this->assertSame('override', $chain->renderBlock('field'));
|
|
$this->assertSame('base', $chain->renderBlock('shared'));
|
|
$this->assertSame('fallback', $chain->renderBlock('last'));
|
|
|
|
// the chained chain is left untouched
|
|
$this->assertSame(['field', 'shared'], $base->getBlockNames());
|
|
$this->assertSame('base', $base->renderBlock('field'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testNestedBlockCallsSeeTheBlocksOfAChainedChain(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'override' => '{% block field %}override/{{ block("widget") }}{% endblock %}',
|
|
'base' => '{% block field %}base{% endblock %}{% block widget %}base-widget{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
|
|
$chain = new BlockChain($twig, ['override', new BlockChain($twig, ['base'])]);
|
|
|
|
$this->assertSame('override/base-widget', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testAChainedChainIsComposedThroughItsWholeLineage(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'override' => '{% block field %}override{% endblock %}',
|
|
'base' => '{% extends "layout" %}{% block widget %}base-widget{% endblock %}',
|
|
'layout' => '{% block field %}layout{% endblock %}{% block row %}layout-row{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$chain = new BlockChain($twig, ['override', new BlockChain($twig, ['base'])]);
|
|
|
|
$this->assertSame(['field', 'widget', 'row'], $chain->getBlockNames());
|
|
$this->assertSame('override', $chain->renderBlock('field'));
|
|
$this->assertSame('layout-row', $chain->renderBlock('row'));
|
|
}
|
|
|
|
public function testAChainedChainKeepsItsOwnDefaultContext(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}',
|
|
'parent1' => '{% block field %}one{% endblock %}',
|
|
'parent2' => '{% block field %}two{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$base = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
|
$chain = new BlockChain($twig, [$base]);
|
|
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testAChainedChainWithADynamicParentIsResolvedAgainstTheRenderContext(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}',
|
|
'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, [new BlockChain($twig, ['theme'])]);
|
|
|
|
$context = ['parent' => 'parent1'];
|
|
$this->assertSame(['field', 'only1'], $chain->getBlockNames($context));
|
|
$this->assertSame('one', $chain->renderBlock('field', $context));
|
|
|
|
$context = ['parent' => 'parent2'];
|
|
$this->assertSame(['field', 'only2'], $chain->getBlockNames($context));
|
|
$this->assertSame('two', $chain->renderBlock('field', $context));
|
|
}
|
|
|
|
public function testRejectsChainsFromAnotherEnvironment(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
$other = new Environment(new ArrayLoader(['theme' => '']));
|
|
|
|
$this->expectException(\LogicException::class);
|
|
$this->expectExceptionMessage('A block chain cannot contain a block chain from a different Twig environment.');
|
|
|
|
new BlockChain($twig, [new BlockChain($other, ['theme'])]);
|
|
}
|
|
|
|
public function testRejectsWrappersFromAnotherEnvironment(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
$other = new Environment(new ArrayLoader(['theme' => '']));
|
|
|
|
$this->expectException(\LogicException::class);
|
|
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
|
|
|
|
new BlockChain($twig, [$other->load('theme')]);
|
|
}
|
|
|
|
public function testRejectsWrappersThatHideATemplateFromAnotherEnvironment(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
$other = new Environment(new ArrayLoader(['theme' => '']));
|
|
$wrapper = new TemplateWrapper($twig, $other->load('theme')->unwrap($other));
|
|
|
|
$this->expectException(\LogicException::class);
|
|
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
|
|
|
|
new BlockChain($twig, [$wrapper]);
|
|
}
|
|
|
|
public function testRejectsDynamicParentsFromAnotherEnvironment(): void
|
|
{
|
|
$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(RuntimeError::class);
|
|
$this->expectExceptionMessage('A "Twig\TemplateWrapper" can only be used with the "Twig\Environment" that created it in "theme" at line 1.');
|
|
|
|
$chain->getBlockNames();
|
|
}
|
|
|
|
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);
|
|
|
|
$chain->getBlockNames();
|
|
}
|
|
|
|
public function testDefiningTemplateSecurityIsCheckedDuringRendering(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% block field %}{{ value|upper }}{% endblock %}',
|
|
]));
|
|
$twig->addExtension(new SandboxExtension(new SecurityPolicy(['block']), true));
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->expectException(SecurityNotAllowedFilterError::class);
|
|
|
|
$chain->renderBlock('field', ['value' => 'value']);
|
|
}
|
|
|
|
public function testSandboxPolicyChangesAreObservedAfterConstruction(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'policy_theme' => '{% extends "parent" %}{% block field %}{{ value|upper }}{% endblock %}',
|
|
'parent' => '',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'block'], ['upper']), true);
|
|
$twig->addExtension($sandbox);
|
|
$chain = new BlockChain($twig, ['policy_theme']);
|
|
|
|
$this->assertSame('VALUE', $chain->renderBlock('field', ['value' => 'value']));
|
|
|
|
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'block']));
|
|
$this->expectException(SecurityNotAllowedFilterError::class);
|
|
|
|
$chain->renderBlock('field', ['value' => 'value']);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testSandboxPolicyChangesAreCheckedOnIntermediateParents(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "middle" %}{% block field %}{{ parent() }}{% endblock %}',
|
|
'middle' => '{% extends parent|upper %}',
|
|
'GRANDPARENT' => '{% block field %}safe{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'block'], ['upper'], allowedFunctions: ['parent']), true);
|
|
$twig->addExtension($sandbox);
|
|
$chain = new BlockChain($twig, ['theme'], ['parent' => 'grandparent']);
|
|
|
|
$this->assertSame('safe', $chain->renderBlock('field'));
|
|
|
|
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'block'], allowedFunctions: ['parent']));
|
|
$this->expectException(SecurityNotAllowedFilterError::class);
|
|
|
|
$chain->renderBlock('field');
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testImportedMacroNamespacesObserveSandboxPolicyChanges(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "layout" %}{% import "macros" as macros %}{% block field %}{{ macros.label(value) }}{% endblock %}',
|
|
'layout' => '{{ block("field") }}',
|
|
'macros' => '{% macro label(value) %}{{ value|upper }}{% endmacro %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'import', 'block', 'macro'], ['upper'], allowedFunctions: ['block']), true);
|
|
$twig->addExtension($sandbox);
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame('VALUE', $twig->render('theme', ['value' => 'value']));
|
|
$this->assertSame('VALUE', $chain->renderBlock('field', ['value' => 'value']));
|
|
|
|
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'import', 'block', 'macro'], allowedFunctions: ['block']));
|
|
$this->expectException(SecurityNotAllowedFilterError::class);
|
|
|
|
$chain->renderBlock('field', ['value' => 'value']);
|
|
}
|
|
|
|
public function testProfilerKeepsTheDefiningTemplateAndBlockAttribution(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "parent" %}{% block field %}field{% endblock %}',
|
|
'parent' => '',
|
|
]), ['use_yield' => true]);
|
|
$profile = new Profile();
|
|
$twig->addExtension(new ProfilerExtension($profile));
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$chain->renderBlock('field');
|
|
|
|
$profiles = $profile->getProfiles();
|
|
$this->assertCount(1, $profiles);
|
|
$this->assertSame('theme', $profiles[0]->getTemplate());
|
|
$this->assertSame(Profile::BLOCK, $profiles[0]->getType());
|
|
$this->assertSame('field', $profiles[0]->getName());
|
|
}
|
|
|
|
public function testMacrosStayOwnedByTheirDefiningTemplate(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme1' => '{% macro label() %}one{% endmacro %}{% block field %}{{ _self.label() }}{% endblock %}',
|
|
'theme2' => '{% macro label() %}two{% endmacro %}{% block field %}{{ _self.label() }}{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$chain = new BlockChain($twig, ['theme1', 'theme2']);
|
|
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
}
|
|
|
|
public function testInheritedMacroLookupUsesTheRenderContextLineage(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}{% block field %}{{ _self.label() }}{% endblock %}',
|
|
'parent1' => '{% macro label() %}one{% endmacro %}',
|
|
'parent2' => '{% macro label() %}two{% endmacro %}',
|
|
]), ['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']));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testSelfMacroImportsUseTheRenderContextLineage(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") }}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
|
|
|
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testFromSelfImportsResolveThroughTheLineage(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 %}',
|
|
'parent' => '{% macro label() %}one{% endmacro %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$this->assertSame('', $twig->render('theme'));
|
|
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame('one/one', $chain->renderBlock('field'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testImportedMacroNamespacesKeepTheirOwnLineage(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}{% import parent as inherited %}{% block field %}{{ inherited.label() }}{% endblock %}',
|
|
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
|
|
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
|
|
|
|
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
|
|
|
|
$this->assertSame('two', $chain->renderBlock('field'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testModuleImportsFollowTheDefiningTemplateBodyState(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "layout" %}{% import helper as macros %}{% block field %}{{ macros.label() }}{% endblock %}',
|
|
'layout' => '{{ block("field") }}',
|
|
'macros1' => '{% macro label() %}one{% endmacro %}',
|
|
'macros2' => '{% macro label() %}two{% endmacro %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
try {
|
|
$chain->renderBlock('field', ['helper' => 'macros1']);
|
|
$this->fail('Rendering an uninitialized import must fail.');
|
|
} catch (RuntimeError $e) {
|
|
$this->assertStringContainsString('Macros imported in the body of template "theme" are not available because the body was not rendered', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame('one', $twig->render('theme', ['helper' => 'macros1']));
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
$this->assertSame('two', $twig->render('theme', ['helper' => 'macros2']));
|
|
$this->assertSame('two', $chain->renderBlock('field'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
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 %}',
|
|
'parent' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent']);
|
|
|
|
// 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');
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testMacroBodiesObserveImportUpdatesAfterConstruction(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "layout" %}{% import helper as macros %}{% macro wrapped() %}{{ macros.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
|
|
'layout' => '{{ block("field") }}',
|
|
'macros1' => '{% macro label() %}one{% endmacro %}',
|
|
'macros2' => '{% macro label() %}two{% endmacro %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->assertSame('one', $twig->render('theme', ['helper' => 'macros1']));
|
|
$this->assertSame('one', $chain->renderBlock('field'));
|
|
$this->assertSame('two', $twig->render('theme', ['helper' => 'macros2']));
|
|
$this->assertSame('two', $chain->renderBlock('field'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider yieldModes
|
|
*/
|
|
#[DataProvider('yieldModes')]
|
|
public function testPreWarmedExternalMacroImportsAreNotReboundByChainOrder(bool $useYield): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends "layout" %}{% import "macros" as macros %}{% block field %}{{ macros.label() }}{% endblock %}',
|
|
'layout' => '{{ block("field") }}',
|
|
'macros' => '{% extends macro_parent %}',
|
|
'macros1' => '{% macro label() %}one{% endmacro %}',
|
|
'macros2' => '{% macro label() %}two{% endmacro %}',
|
|
]), ['autoescape' => false, 'use_yield' => $useYield]);
|
|
$this->assertSame('two', $twig->render('theme', ['macro_parent' => 'macros2']));
|
|
|
|
$context = ['macro_parent' => 'macros1'];
|
|
$renderContext = ['macro_parent' => 'macros2'];
|
|
|
|
$this->assertSame('two', (new BlockChain($twig, ['macros', 'theme'], $context))->renderBlock('field', $renderContext));
|
|
$this->assertSame('two', (new BlockChain($twig, ['theme', 'macros'], $context))->renderBlock('field', $renderContext));
|
|
}
|
|
|
|
public function testChainsWithDifferentDynamicParentsCanBeStreamedInterleaved(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => '{% extends parent %}{% block field %}before/{{ parent() }}/after{% endblock %}',
|
|
'parent1' => '{% block field %}one{% endblock %}',
|
|
'parent2' => '{% block field %}two{% endblock %}',
|
|
]), ['autoescape' => false, 'use_yield' => true]);
|
|
|
|
$stream1 = (new BlockChain($twig, ['theme'], ['parent' => 'parent1']))->streamBlock('field');
|
|
$stream2 = (new BlockChain($twig, ['theme'], ['parent' => 'parent2']))->streamBlock('field');
|
|
|
|
$output1 = $output2 = '';
|
|
$stream1->rewind();
|
|
$stream2->rewind();
|
|
while ($stream1->valid() || $stream2->valid()) {
|
|
if ($stream1->valid()) {
|
|
$output1 .= $stream1->current();
|
|
$stream1->next();
|
|
}
|
|
if ($stream2->valid()) {
|
|
$output2 .= $stream2->current();
|
|
$stream2->next();
|
|
}
|
|
}
|
|
|
|
$this->assertSame('before/one/after', $output1);
|
|
$this->assertSame('before/two/after', $output2);
|
|
}
|
|
|
|
public function testErrorsKeepTheDefiningSourceAndLine(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader([
|
|
'theme' => "{% block field %}\n{{ missing.value }}\n{% endblock %}",
|
|
]), ['strict_variables' => true, 'use_yield' => true]);
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
try {
|
|
$chain->renderBlock('field');
|
|
$this->fail('Rendering must fail.');
|
|
} catch (RuntimeError $e) {
|
|
$this->assertSame('theme', $e->getSourceContext()->getName());
|
|
$this->assertSame(2, $e->getTemplateLine());
|
|
}
|
|
}
|
|
|
|
public function testUnknownBlockUsesTheFirstTemplateAsErrorContext(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
$chain = new BlockChain($twig, ['theme']);
|
|
|
|
$this->expectException(RuntimeError::class);
|
|
$this->expectExceptionMessage('Block "missing" on template "theme" does not exist in "theme".');
|
|
|
|
$chain->renderBlock('missing');
|
|
}
|
|
|
|
public function testUnknownBlockUsesTheFirstTemplateOfAChainedChainAsErrorContext(): void
|
|
{
|
|
$twig = new Environment(new ArrayLoader(['theme' => '']));
|
|
$chain = new BlockChain($twig, [new BlockChain($twig, ['theme'])]);
|
|
|
|
$this->expectException(RuntimeError::class);
|
|
$this->expectExceptionMessage('Block "missing" on template "theme" does not exist in "theme".');
|
|
|
|
$chain->renderBlock('missing');
|
|
}
|
|
|
|
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".');
|
|
|
|
$chain->getBlockNames();
|
|
}
|
|
|
|
public function testRequiresAtLeastOneTemplate(): void
|
|
{
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('A block chain requires at least one template.');
|
|
|
|
new BlockChain(new Environment(new ArrayLoader()), []);
|
|
}
|
|
|
|
public static function yieldModes(): iterable
|
|
{
|
|
yield 'echo and yield' => [false];
|
|
yield 'yield only' => [true];
|
|
}
|
|
}
|
|
|
|
class EchoingBlockChainTemplate extends Template
|
|
{
|
|
public function __construct(Environment $env)
|
|
{
|
|
parent::__construct($env);
|
|
$this->parent = false;
|
|
$this->blocks = ['field' => [$this, 'block_field']];
|
|
}
|
|
|
|
public function block_field(array $context, array $blocks = []): iterable
|
|
{
|
|
echo 'echo/';
|
|
yield 'yield';
|
|
}
|
|
|
|
public function getTemplateName(): string
|
|
{
|
|
return 'echoing';
|
|
}
|
|
|
|
public function getDebugInfo(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
public function getSourceContext(): Source
|
|
{
|
|
return new Source('', 'echoing');
|
|
}
|
|
|
|
protected function doDisplay(array $context, array $blocks = []): iterable
|
|
{
|
|
yield from [];
|
|
}
|
|
}
|