diff --git a/CHANGELOG b/CHANGELOG index 85043d29f2f..41376155089 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,9 @@ * Fix `html_attr` dropping `style` declarations whose value is `0`, `0.0` or `'0'` * Fix the `default` filter fallback emitting an undefined variable warning when it uses the null-safe operator * Fix the `matches` operator silently treating PCRE execution errors as non-matches + * Fix `streamBlock()` omitting environment globals + * Add the `BlockChain` class to compose blocks from multiple templates without using template internals + * Fix an output buffer leak when a parent block rendered in an expression throws in non-yield mode * Add the `HtmlExtension::htmlAttrValue()` method to resolve a single HTML attribute value the way the `html_attr` function renders it * Fix `html_attr` JSON encoding a `Stringable` value in a `data-*` attribute instead of using its string representation * Add documentation comments to attach metadata to nodes (experimental) diff --git a/doc/api.rst b/doc/api.rst index d6852bc6d5c..ad2d5b47e21 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -68,6 +68,42 @@ If a template defines blocks, they can be rendered individually via the echo $template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']); +Composing Blocks +---------------- + +.. versionadded:: 3.29 + + The ``BlockChain`` class was introduced in Twig 3.29. + +Use ``BlockChain`` when a form, CMS field, data-grid or similar renderer needs +to select blocks from several templates at runtime. Pass templates from the +highest to the lowest precedence:: + + use Twig\BlockChain; + + $blocks = new BlockChain($twig, [ + 'admin_theme.html.twig', + $twig->load('application_theme.html.twig'), + 'base_theme.html.twig', + ]); + + echo $blocks->renderBlock('field_row', ['field' => $field]); + +Twig considers each template's blocks, blocks imported with ``use`` and parents +before moving to the next template. The first definition of each block wins. +``parent()`` follows the local inheritance or ``use`` hierarchy of the block, +while nested ``block()`` calls use the composed block set unless they name +another template. + +``BlockChain`` composes blocks, not template bodies or macros. Because chained +templates share the composed block set, only combine templates that are trusted +to call one another's blocks. As with direct block rendering, module-level +imports are available after the defining template body has run. + +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. + Streaming Templates ------------------- diff --git a/extra/cache-extra/Tests/IntegrationTest.php b/extra/cache-extra/Tests/IntegrationTest.php index ab72d6442da..651c767844c 100644 --- a/extra/cache-extra/Tests/IntegrationTest.php +++ b/extra/cache-extra/Tests/IntegrationTest.php @@ -19,14 +19,14 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new CacheExtension(), ]; } - protected function getRuntimeLoaders() + protected function getRuntimeLoaders(): array { return [ new class implements RuntimeLoaderInterface { diff --git a/extra/cssinliner-extra/Tests/IntegrationTest.php b/extra/cssinliner-extra/Tests/IntegrationTest.php index 7004b5e99ac..f0acd6316a1 100644 --- a/extra/cssinliner-extra/Tests/IntegrationTest.php +++ b/extra/cssinliner-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new CssInlinerExtension(), diff --git a/extra/html-extra/Tests/IntegrationTest.php b/extra/html-extra/Tests/IntegrationTest.php index 8e2f94e38b9..d5cbb145d1f 100644 --- a/extra/html-extra/Tests/IntegrationTest.php +++ b/extra/html-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new HtmlExtension(), diff --git a/extra/inky-extra/Tests/IntegrationTest.php b/extra/inky-extra/Tests/IntegrationTest.php index d9420dd09bf..10180139d8f 100644 --- a/extra/inky-extra/Tests/IntegrationTest.php +++ b/extra/inky-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new InkyExtension(), diff --git a/extra/intl-extra/Tests/IntegrationTest.php b/extra/intl-extra/Tests/IntegrationTest.php index fa22b570801..d1f422f94de 100644 --- a/extra/intl-extra/Tests/IntegrationTest.php +++ b/extra/intl-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new IntlExtension(), diff --git a/extra/markdown-extra/Tests/IntegrationTest.php b/extra/markdown-extra/Tests/IntegrationTest.php index 7db95c9190f..d1b04d273eb 100644 --- a/extra/markdown-extra/Tests/IntegrationTest.php +++ b/extra/markdown-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new MarkdownExtension(), diff --git a/extra/string-extra/Tests/IntegrationTest.php b/extra/string-extra/Tests/IntegrationTest.php index ddf6abfe509..1fda2fcbc0b 100644 --- a/extra/string-extra/Tests/IntegrationTest.php +++ b/extra/string-extra/Tests/IntegrationTest.php @@ -16,7 +16,7 @@ class IntegrationTest extends IntegrationTestCase { - public function getExtensions() + public function getExtensions(): array { return [ new StringExtension(), diff --git a/src/BlockChain.php b/src/BlockChain.php new file mode 100644 index 00000000000..0449796db3a --- /dev/null +++ b/src/BlockChain.php @@ -0,0 +1,107 @@ + */ + private array $blocks; + private Template $template; + + /** + * @param iterable $templates Templates ordered from highest to lowest precedence + */ + public function __construct( + private Environment $env, + iterable $templates, + array $context = [], + ) { + $resolution = new BlockResolutionContext($env, $context + $env->getGlobals()); + $blocks = []; + + foreach ($templates as $template) { + if (\is_string($template)) { + $template = $env->load($template); + } + if (!$template instanceof TemplateWrapper) { + 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.'); + } + + $resolution->assertOwns($block[0]); + $blocks[$name] = $block; + } + } while (false !== $current = $resolution->getParent($current)); + } + + if (!isset($this->template)) { + 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]); + } + + /** + * @return string[] + */ + public function getBlockNames(): array + { + return array_keys($this->blocks); + } + + /** + * @return iterable + */ + 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]; + } + + throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->template->getTemplateName()), -1, $this->template->getSourceContext()); + } +} diff --git a/src/BlockResolutionContext.php b/src/BlockResolutionContext.php new file mode 100644 index 00000000000..ab68eabfcfe --- /dev/null +++ b/src/BlockResolutionContext.php @@ -0,0 +1,86 @@ + */ + private \SplObjectStorage $parents; + + /** @var \SplObjectStorage */ + private \SplObjectStorage $frozen; + + /** @var \SplObjectStorage */ + 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 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); + } +} diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index b4f939cf630..a94f65239cf 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -37,7 +37,7 @@ public function compile(Compiler $compiler): void ->write(" */\n") ->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n") ->indent() - ->write("\$macros = \$this->macros;\n") + ->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n") ; $compiler diff --git a/src/Node/MacroNode.php b/src/Node/MacroNode.php index 255ee851099..a7c0f82c45f 100644 --- a/src/Node/MacroNode.php +++ b/src/Node/MacroNode.php @@ -115,7 +115,7 @@ public function compile(Compiler $compiler): void ->raw("): string|Markup {\n") ->indent() ->addDebugInfo($this) - ->write("\$macros = \$this->macros;\n") + ->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n") ->write("\$context = [\n") ->indent() ; diff --git a/src/Template.php b/src/Template.php index aeca1467782..b972808a192 100644 --- a/src/Template.php +++ b/src/Template.php @@ -39,6 +39,7 @@ abstract class Template protected $traitAliases = []; protected $extensions = []; protected $sandbox; + protected ?self $macroImportSource = null; private $useYield; private ?MacroNamespace $macroNamespace = null; @@ -78,6 +79,10 @@ abstract public function getSourceContext(): Source; public function getParent(array $context): self|TemplateWrapper|false { if (null !== $this->parent) { + if (null !== $this->macroImportSource) { + $this->ensureSecurityChecked(); + } + return $this->parent; } @@ -164,12 +169,21 @@ public function displayBlock($name, array $context, array $blocks = [], $useBloc public function renderParentBlock($name, array $context, array $blocks = []): string { if (!$this->useYield) { + $level = ob_get_level(); if ($this->env->isDebug()) { ob_start(); } else { ob_start(static function () { return ''; }); } - $this->displayParentBlock($name, $context, $blocks); + try { + $this->displayParentBlock($name, $context, $blocks); + } catch (\Throwable $e) { + while (ob_get_level() > $level) { + ob_end_clean(); + } + + throw $e; + } return ob_get_clean(); } @@ -349,6 +363,52 @@ public function unwrap(): self return $this; } + /** + * @internal + */ + public function isOwnedBy(Environment $env): bool + { + return $this->env === $env; + } + + /** + * @internal + */ + public function freezeLineage(BlockResolutionContext $resolution): self + { + $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 = $parent->freezeLineage($resolution); + $resolution->setFrozen($this, $template); + + return $template; + } finally { + $resolution->endFreeze($this); + } + } + /** * Returns all blocks. * @@ -493,6 +553,28 @@ protected function loadDeclaredMacros(): array return []; } + /** + * @param array $macros + * + * @return array + */ + protected function rebindMacroImports(array $macros): array + { + if (null === $this->macroImportSource) { + return $macros; + } + + static $templateProperty; + $templateProperty ??= new \ReflectionProperty(MacroNamespace::class, 'template'); + foreach ($macros as $name => $namespace) { + if ($namespace instanceof MacroNamespace && $templateProperty->getValue($namespace) === $this->macroImportSource) { + $macros[$name] = $this->getMacroNamespace(); + } + } + + return $macros; + } + /** * Runs the sandbox security check against the current sandbox state. * diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index afadc2b539c..812b9a036b9 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -43,7 +43,7 @@ public function stream(array $context = []): iterable */ public function streamBlock(string $name, array $context = []): iterable { - yield from $this->template->yieldBlock($name, $context); + yield from $this->template->yieldBlock($name, $context + $this->env->getGlobals()); } public function render(array $context = []): string diff --git a/tests/BlockChainTest.php b/tests/BlockChainTest.php new file mode 100644 index 00000000000..a14394e0c5c --- /dev/null +++ b/tests/BlockChainTest.php @@ -0,0 +1,636 @@ + '{% 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 testNestedBlocksUseTheEffectiveNamespaceAndParentUsesTheFrozenLineage(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', ['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 testFreezingAChainDoesNotChangeTheLoadedTemplate(): 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', ['parent' => 'parent2'])); + $this->assertSame('two', $template->renderBlock('field', ['parent' => 'parent2'])); + } + + 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']]; + } + }; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A block must be a method on a \Twig\Template instance.'); + + new BlockChain($twig, [new TemplateWrapper($twig, $template)]); + } + + 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()); + + $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' => ''])); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.'); + + new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]); + } + + public function testDynamicParentSecurityIsCheckedDuringConstruction(): void + { + $twig = new Environment(new ArrayLoader([ + 'theme' => '{% extends parent|upper %}', + 'PARENT' => '', + ])); + $twig->addExtension(new SandboxExtension(new SecurityPolicy(['extends']), true)); + + $this->expectException(SecurityNotAllowedFilterError::class); + + new BlockChain($twig, ['theme'], ['parent' => 'parent']); + } + + 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 testSandboxPolicyChangesAreCheckedOnFrozenIntermediateParents(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 testInheritedMacroLookupUsesTheFrozenLineage(): 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', ['parent' => 'parent2'])); + } + + /** + * @dataProvider yieldModes + */ + #[DataProvider('yieldModes')] + public function testSelfMacroImportsInitializedAfterConstructionUseTheFrozenLineage(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', ['parent' => 'parent2'])); + } + + /** + * @dataProvider yieldModes + */ + #[DataProvider('yieldModes')] + public function testPreWarmedSelfMacroImportsUseTheFrozenLineage(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]); + $this->assertSame('two', $twig->render('theme', ['parent' => 'parent2'])); + + $chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']); + + $this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2'])); + } + + /** + * @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 + { + $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) { + } + + $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 testSelfMacroImportsInMacroBodiesUseTheFrozenLineage(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 %}', + ]), ['autoescape' => false, 'use_yield' => $useYield]); + $chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']); + + $this->assertSame('', $twig->render('theme', ['parent' => 'parent2'])); + $this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2'])); + } + + /** + * @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 testCircularInheritanceIsRejectedDuringConstruction(): void + { + $twig = new Environment(new ArrayLoader([ + 'one' => '{% extends "two" %}', + 'two' => '{% extends "one" %}', + ])); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Circular template inheritance detected while building a block chain from "one".'); + + new BlockChain($twig, ['one']); + } + + 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 []; + } +} diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index 8606484ba67..4fa08fc2dc3 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -47,7 +47,7 @@ public static function provideTests(): iterable */ public function block_foo(array \$context, array \$blocks = []): iterable { - \$macros = \$this->macros; + \$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros); yield "foo"; yield from []; } diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php index 29a0b5cd860..f5bd73b3c16 100644 --- a/tests/Node/MacroTest.php +++ b/tests/Node/MacroTest.php @@ -65,7 +65,7 @@ public static function provideTests(): iterable yield 'with use_yield = true' => [self::createNode(), <<macros; + \$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros); \$context = [ "foo" => \$foo, "bar" => \$bar, @@ -86,7 +86,7 @@ public static function provideTests(): iterable yield 'with use_yield = false' => [self::createNode(), <<macros; + \$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros); \$context = [ "foo" => \$foo, "bar" => \$bar, diff --git a/tests/Node/MacrosTest.php b/tests/Node/MacrosTest.php index 7a5687e6970..b2f7b503e96 100644 --- a/tests/Node/MacrosTest.php +++ b/tests/Node/MacrosTest.php @@ -64,7 +64,7 @@ protected function loadDeclaredMacros(): array return [ "foo" => new \\Twig\\TwigMacro("foo", function (\$foo = null, ...\$varargs): string|Markup { // line 1 - \$macros = \$this->macros; + \$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros); \$context = [ "foo" => \$foo, "varargs" => \$varargs, diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index 92a80cec1d8..5bc9520f0d8 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -226,6 +226,33 @@ public function testDisplayBlockWithUndefinedParentBlock(): void $template->displayBlock('foo', [], ['foo' => [new TemplateForTest($twig, 'index.twig'), 'block_foo']], false); } + /** + * @dataProvider debugModes + */ + #[DataProvider('debugModes')] + public function testRenderParentBlockRestoresOutputBuffersOnError(bool $debug): void + { + $twig = new Environment(new ArrayLoader([ + 'parent' => '{% block content %}{{ missing.value }}{% endblock %}', + 'child' => '{% extends "parent" %}', + ]), ['debug' => $debug, 'strict_variables' => true, 'use_yield' => false]); + $template = $twig->load('child')->unwrap(); + $level = ob_get_level(); + + try { + $template->renderParentBlock('content', []); + $this->fail('Rendering the parent block must fail.'); + } catch (RuntimeError) { + $actualLevel = ob_get_level(); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + + $this->assertSame($level, $actualLevel); + } + public function testGetAttributeOnArrayWithConfusableKey(): void { $twig = new Environment(new ArrayLoader()); @@ -462,6 +489,12 @@ public function testSandboxedArrayAccessWithObjectKeyKeepsTheObjectKey(): void $this->assertSame(0, $key->toStringCalls); } + public static function debugModes(): iterable + { + yield 'debug disabled' => [false]; + yield 'debug enabled' => [true]; + } + public static function getStrictVariablesModes(): iterable { yield 'lax' => [false]; diff --git a/tests/TemplateWrapperTest.php b/tests/TemplateWrapperTest.php index 475c22e6850..a880da54688 100644 --- a/tests/TemplateWrapperTest.php +++ b/tests/TemplateWrapperTest.php @@ -63,6 +63,21 @@ public function testRenderBlock(): void $this->assertEquals('FOOBAR', $wrapper->renderBlock('foo', ['foo' => 'FOO'])); } + public function testStreamBlock(): void + { + $twig = new Environment(new ArrayLoader([ + 'index' => '{% block foo %}{{ foo }}{{ bar }}{% endblock %}', + ])); + $twig->addGlobal('bar', 'BAR'); + + $streamed = ''; + foreach ($twig->load('index')->streamBlock('foo', ['foo' => 'FOO']) as $data) { + $streamed .= $data; + } + + $this->assertSame('FOOBAR', $streamed); + } + public function testDisplayBlock(): void { $twig = new Environment(new ArrayLoader([