Files
Twig/tests/BlockChainTest.php
T
Fabien Potencier dea0483027 feature #4910 Reject template wrappers from another environment (fabpot)
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
-------

83e8f7e123 Reject cross-environment template wrappers in block chains
c1fc112047 Reject cross-environment template wrappers
2026-09-14 14:11:48 +02:00

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 [];
}
}