diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0167f0a3c..527ac2c12d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ permissions: jobs: tests: - name: "PHP ${{ matrix.php-version }}" + name: "PHP ${{ matrix.php-version }} (yield: ${{ matrix.use_yield }})" runs-on: 'ubuntu-latest' @@ -31,6 +31,7 @@ jobs: - '8.2' - '8.3' experimental: [false] + use_yield: [true, false] steps: - name: "Checkout code" @@ -48,6 +49,11 @@ jobs: - run: composer install + - name: "Switch use_yield to true" + if: ${{ matrix.use_yield }} + run: | + sed -i -e "s/'use_yield' => false/'use_yield' => true/" src/Environment.php + - name: "Install PHPUnit" run: vendor/bin/simple-phpunit install @@ -55,13 +61,13 @@ jobs: run: vendor/bin/simple-phpunit --version - name: "Run tests" - run: vendor/bin/simple-phpunit + run: SYMFONY_DEPRECATIONS_HELPER=ignoreFile=./tests/ignore-use-yield-deprecations vendor/bin/simple-phpunit extension-tests: needs: - 'tests' - name: "${{ matrix.extension }} with PHP ${{ matrix.php-version }}" + name: "${{ matrix.extension }} PHP ${{ matrix.php-version }} (yield: ${{ matrix.use_yield }})" runs-on: 'ubuntu-latest' @@ -78,15 +84,16 @@ jobs: - '8.2' - '8.3' extension: - - 'extra/cache-extra' - - 'extra/cssinliner-extra' - - 'extra/html-extra' - - 'extra/inky-extra' - - 'extra/intl-extra' - - 'extra/markdown-extra' - - 'extra/string-extra' - - 'extra/twig-extra-bundle' + - 'cache-extra' + - 'cssinliner-extra' + - 'html-extra' + - 'inky-extra' + - 'intl-extra' + - 'markdown-extra' + - 'string-extra' + - 'twig-extra-bundle' experimental: [false] + use_yield: [true, false] steps: - name: "Checkout code" @@ -115,9 +122,17 @@ jobs: working-directory: ${{ matrix.extension}} run: composer install + - name: "Switch use_yield to true" + if: ${{ matrix.use_yield }} + run: | + sed -i -e "s/'use_yield' => false/'use_yield' => true/" extra/${{ matrix.extension }}/vendor/twig/twig/src/Environment.php + - name: "Run tests for ${{ matrix.extension}}" working-directory: ${{ matrix.extension}} - run: ../../vendor/bin/simple-phpunit + + - name: "Run tests" + working-directory: extra/${{ matrix.extension }} + run: SYMFONY_DEPRECATIONS_HELPER=ignoreFile=../../tests/ignore-use-yield-deprecations ../../vendor/bin/simple-phpunit integration-tests: needs: diff --git a/CHANGELOG b/CHANGELOG index 55a307c39b3..d0bc0cae33c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ -# 3.9.0 (2023-XX-XX) +# 3.9.0 (2024-XX-XX) + * Add a new "yield" mode for output generation + The "use_yield" Environment option controls the output strategy: use "false" for "echo", "true" for "yield" + "yield" will be the only strategy supported in the next major version * Add return type for Symfony 7 compatibility * Fix premature loop exit in Security Policy lookup of allowed methods/properties * Deprecate all internal extension functions in favor of methods on the extension classes diff --git a/extra/twig-extra-bundle/Tests/Fixture/Kernel.php b/extra/twig-extra-bundle/Tests/Fixture/Kernel.php index 3df6357fe1f..857ed95d41a 100644 --- a/extra/twig-extra-bundle/Tests/Fixture/Kernel.php +++ b/extra/twig-extra-bundle/Tests/Fixture/Kernel.php @@ -24,11 +24,17 @@ public function registerBundles(): iterable protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void { - $c->loadFromExtension('framework', [ + $config = [ 'secret' => 'S3CRET', 'test' => true, - ]); - + 'router' => ['utf8' => true], + 'http_method_override' => false, + ]; + if (6 === Kernel::MAJOR_VERSION) { + $config['handle_all_throwables'] = true; + $config['php_errors']['log'] = true; + } + $c->loadFromExtension('framework', $config); $c->loadFromExtension('twig', [ 'default_path' => __DIR__.'/views', ]); diff --git a/src/Compiler.php b/src/Compiler.php index eb652c61a4e..543b4e1046d 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -27,10 +27,12 @@ class Compiler private $sourceOffset; private $sourceLine; private $varNameSalt = 0; + private $checkForOutput; public function __construct(Environment $env) { $this->env = $env; + $this->checkForOutput = $env->isDebug(); } public function getEnvironment(): Environment @@ -85,6 +87,16 @@ public function subcompile(Node $node, bool $raw = true) return $this; } + /** + * @return $this + */ + public function checkForOutput(bool $checkForOutput) + { + $this->checkForOutput = $checkForOutput ? $this->env->isDebug() : false; + + return $this; + } + /** * Adds a raw string to the compiled code. * @@ -92,6 +104,9 @@ public function subcompile(Node $node, bool $raw = true) */ public function raw(string $string) { + if ($this->checkForOutput) { + $this->checkStringForOutput(trim($string)); + } $this->source .= $string; return $this; @@ -105,6 +120,10 @@ public function raw(string $string) public function write(...$strings) { foreach ($strings as $string) { + if ($this->checkForOutput) { + $this->checkStringForOutput(trim($string)); + } + $this->source .= str_repeat(' ', $this->indentation * 4).$string; } @@ -220,4 +239,13 @@ public function getVarName(): string { return sprintf('__internal_compile_%d', $this->varNameSalt++); } + + private function checkStringForOutput(string $string): void + { + if (str_starts_with($string, 'echo')) { + trigger_deprecation('twig/twig', '3.9.0', 'Using "echo" in a "Node::compile()" method is deprecated; use a "TextNode" or "PrintNode" instead or use "yield" when "use_yield" is "true" on the environment (triggered by "%s").', $string); + } elseif (str_starts_with($string, 'print')) { + trigger_deprecation('twig/twig', '3.9.0', 'Using "print" in a "Node::compile()" method is deprecated; use a "TextNode" or "PrintNode" instead or use "yield" when "use_yield" is "true" on the environment (triggered by "%s").', $string); + } + } } diff --git a/src/Environment.php b/src/Environment.php index f9e0086c60a..dbe382d4403 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -66,6 +66,8 @@ class Environment private $runtimeLoaders = []; private $runtimes = []; private $optionsHash; + /** @var bool */ + private $useYield; /** * Constructor. @@ -97,6 +99,10 @@ class Environment * * optimizations: A flag that indicates which optimizations to apply * (default to -1 which means that all optimizations are enabled; * set it to 0 to disable). + * + * * use_yield: Enable a new mode where template are using "yield" instead of "echo" + * (default to "false", but switch it to "true" when possible + * as this will be the only supported mode in Twig 4.0) */ public function __construct(LoaderInterface $loader, $options = []) { @@ -110,8 +116,14 @@ public function __construct(LoaderInterface $loader, $options = []) 'cache' => false, 'auto_reload' => null, 'optimizations' => -1, + 'use_yield' => false, ], $options); + $this->useYield = (bool) $options['use_yield']; + if (!$this->useYield) { + trigger_deprecation('twig/twig', '3.9.0', 'Not setting "use_yield" to "true" is deprecated.'); + } + $this->debug = (bool) $options['debug']; $this->setCharset($options['charset'] ?? 'UTF-8'); $this->autoReload = null === $options['auto_reload'] ? $this->debug : (bool) $options['auto_reload']; @@ -124,6 +136,14 @@ public function __construct(LoaderInterface $loader, $options = []) $this->addExtension(new OptimizerExtension($options['optimizations'])); } + /** + * @internal + */ + public function useYield(): bool + { + return $this->useYield; + } + /** * Enables debugging mode. */ @@ -834,6 +854,7 @@ private function updateOptionsHash(): void self::VERSION, (int) $this->debug, (int) $this->strictVariables, + $this->useYield ? '1' : '0', ]); } } diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index 0632ba74754..241dff0b2ec 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -37,6 +37,14 @@ public function compile(Compiler $compiler): void $compiler ->subcompile($this->getNode('body')) + ; + + if (!$this->getNode('body') instanceof NodeOutputInterface && $compiler->getEnvironment()->useYield()) { + // needed when body doesn't yield anything + $compiler->write("yield '';\n"); + } + + $compiler ->outdent() ->write("}\n\n") ; diff --git a/src/Node/BlockReferenceNode.php b/src/Node/BlockReferenceNode.php index cc8af5b5253..8b98c0f02df 100644 --- a/src/Node/BlockReferenceNode.php +++ b/src/Node/BlockReferenceNode.php @@ -28,9 +28,16 @@ public function __construct(string $name, int $lineno, string $tag = null) public function compile(Compiler $compiler): void { - $compiler - ->addDebugInfo($this) - ->write(sprintf("\$this->displayBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) - ; + if ($compiler->getEnvironment()->useYield()) { + $compiler + ->addDebugInfo($this) + ->write(sprintf("yield from \$this->unwrap()->yieldBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) + ; + } else { + $compiler + ->addDebugInfo($this) + ->write(sprintf("\$this->displayBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) + ; + } } } diff --git a/src/Node/CaptureNode.php b/src/Node/CaptureNode.php index 418f0863de5..cdb77e26921 100644 --- a/src/Node/CaptureNode.php +++ b/src/Node/CaptureNode.php @@ -27,6 +27,31 @@ public function __construct(Node $body, int $lineno, string $tag = null) public function compile(Compiler $compiler): void { + if ($compiler->getEnvironment()->useYield()) { + if ($this->getAttribute('raw')) { + $compiler->raw("implode('', iterator_to_array("); + } else { + $compiler->raw("('' === \$tmp = implode('', iterator_to_array("); + } + if ($this->getAttribute('with_blocks')) { + $compiler->raw("(function () use (&\$context, \$macros, \$blocks) {\n"); + } else { + $compiler->raw("(function () use (&\$context, \$macros) {\n"); + } + $compiler + ->indent() + ->subcompile($this->getNode('body')) + ->outdent() + ->write("})() ?? new \EmptyIterator()))") + ; + if (!$this->getAttribute('raw')) { + $compiler->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset())"); + } + $compiler->raw(";"); + + return; + } + if ($this->getAttribute('with_blocks')) { $compiler->raw("(function () use (&\$context, \$macros, \$blocks) {\n"); } else { diff --git a/src/Node/Expression/BlockReferenceExpression.php b/src/Node/Expression/BlockReferenceExpression.php index b1e2a8f7bb6..e63c5b2149d 100644 --- a/src/Node/Expression/BlockReferenceExpression.php +++ b/src/Node/Expression/BlockReferenceExpression.php @@ -40,9 +40,16 @@ public function compile(Compiler $compiler): void if ($this->getAttribute('output')) { $compiler->addDebugInfo($this); - $this - ->compileTemplateCall($compiler, 'displayBlock') - ->raw(";\n"); + if ($compiler->getEnvironment()->useYield()) { + $compiler->write('yield from '); + $this + ->compileTemplateCall($compiler, 'yieldBlock') + ->raw(";\n"); + } else { + $this + ->compileTemplateCall($compiler, 'displayBlock') + ->raw(";\n"); + } } else { $this->compileTemplateCall($compiler, 'renderBlock'); } @@ -65,6 +72,10 @@ private function compileTemplateCall(Compiler $compiler, string $method): Compil ; } + if ($compiler->getEnvironment()->useYield()) { + $compiler->raw('->unwrap()'); + } + $compiler->raw(sprintf('->%s', $method)); return $this->compileBlockArguments($compiler); diff --git a/src/Node/Expression/InlinePrint.php b/src/Node/Expression/InlinePrint.php index 1ad4751e462..725536a869d 100644 --- a/src/Node/Expression/InlinePrint.php +++ b/src/Node/Expression/InlinePrint.php @@ -26,10 +26,19 @@ public function __construct(Node $node, int $lineno) public function compile(Compiler $compiler): void { - $compiler - ->raw('print (') - ->subcompile($this->getNode('node')) - ->raw(')') - ; + if ($compiler->getEnvironment()->useYield()) { + $compiler + ->raw('yield ') + ->subcompile($this->getNode('node')) + ; + } else { + $compiler + ->checkForOutput(false) + ->raw('print(') + ->checkForOutput(true) + ->subcompile($this->getNode('node')) + ->raw(')') + ; + } } } diff --git a/src/Node/Expression/ParentExpression.php b/src/Node/Expression/ParentExpression.php index 25491971841..9dc27ed1a40 100644 --- a/src/Node/Expression/ParentExpression.php +++ b/src/Node/Expression/ParentExpression.php @@ -28,19 +28,36 @@ public function __construct(string $name, int $lineno, string $tag = null) public function compile(Compiler $compiler): void { - if ($this->getAttribute('output')) { - $compiler - ->addDebugInfo($this) - ->write('$this->displayParentBlock(') - ->string($this->getAttribute('name')) - ->raw(", \$context, \$blocks);\n") - ; + if ($compiler->getEnvironment()->useYield()) { + if ($this->getAttribute('output')) { + $compiler + ->addDebugInfo($this) + ->write('yield from $this->yieldParentBlock(') + ->string($this->getAttribute('name')) + ->raw(", \$context, \$blocks);\n") + ; + } else { + $compiler + ->raw('$this->renderParentBlock(') + ->string($this->getAttribute('name')) + ->raw(', $context, $blocks)') + ; + } } else { - $compiler - ->raw('$this->renderParentBlock(') - ->string($this->getAttribute('name')) - ->raw(', $context, $blocks)') - ; + if ($this->getAttribute('output')) { + $compiler + ->addDebugInfo($this) + ->write('$this->displayParentBlock(') + ->string($this->getAttribute('name')) + ->raw(", \$context, \$blocks);\n") + ; + } else { + $compiler + ->raw('$this->renderParentBlock(') + ->string($this->getAttribute('name')) + ->raw(', $context, $blocks)') + ; + } } } } diff --git a/src/Node/IncludeNode.php b/src/Node/IncludeNode.php index be36b26574f..35f5fa31b19 100644 --- a/src/Node/IncludeNode.php +++ b/src/Node/IncludeNode.php @@ -58,8 +58,18 @@ public function compile(Compiler $compiler): void ->write("}\n") ->write(sprintf("if ($%s) {\n", $template)) ->indent() - ->write(sprintf('$%s->display(', $template)) ; + + if ($compiler->getEnvironment()->useYield()) { + $compiler + ->write(sprintf('yield from $%s->unwrap()->yield(', $template)) + ; + } else { + $compiler + ->write(sprintf('$%s->display(', $template)) + ; + } + $this->addTemplateArguments($compiler); $compiler ->raw(");\n") @@ -67,8 +77,20 @@ public function compile(Compiler $compiler): void ->write("}\n") ; } else { + if ($compiler->getEnvironment()->useYield()) { + $compiler + ->write('yield from ') + ; + } + $this->addGetTemplate($compiler); - $compiler->raw('->display('); + + if ($compiler->getEnvironment()->useYield()) { + $compiler->raw('->unwrap()->yield('); + } else { + $compiler->raw('->display('); + } + $this->addTemplateArguments($compiler); $compiler->raw(");\n"); } diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index dce335c63f5..4bc35344ffb 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -151,14 +151,14 @@ protected function compileClassHeader(Compiler $compiler) ->write("use Twig\Sandbox\SecurityNotAllowedFilterError;\n") ->write("use Twig\Sandbox\SecurityNotAllowedFunctionError;\n") ->write("use Twig\Source;\n") - ->write("use Twig\Template;\n\n") + ->write(sprintf("use Twig\%s;\n\n", $compiler->getEnvironment()->useYield() ? 'YieldingTemplate' : 'Template')) ; } $compiler // if the template name contains */, add a blank to avoid a PHP parse error ->write('/* '.str_replace('*/', '* /', $this->getSourceContext()->getName())." */\n") ->write('class '.$compiler->getEnvironment()->getTemplateClass($this->getSourceContext()->getName(), $this->getAttribute('index'))) - ->raw(" extends Template\n") + ->raw(sprintf(" extends %s\n", $compiler->getEnvironment()->useYield() ? 'YieldingTemplate' : 'Template')) ->write("{\n") ->indent() ->write("private \$source;\n") @@ -325,11 +325,26 @@ protected function compileDisplay(Compiler $compiler) ->repr($parent->getTemplateLine()) ->raw(");\n") ; - $compiler->write('$this->parent'); + } + if ($compiler->getEnvironment()->useYield()) { + $compiler->write('yield from '); + } else { + $compiler->write(''); + } + + if ($parent instanceof ConstantExpression) { + $compiler->raw('$this->parent'); + } else { + $compiler->raw('$this->getParent($context)'); + } + if ($compiler->getEnvironment()->useYield()) { + $compiler->raw("->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks));\n"); } else { - $compiler->write('$this->getParent($context)'); + $compiler->raw("->display(\$context, array_merge(\$this->blocks, \$blocks));\n"); } - $compiler->raw("->display(\$context, array_merge(\$this->blocks, \$blocks));\n"); + } elseif ($compiler->getEnvironment()->useYield() && !$this->hasNodeOutputNodes($this->getNode('body'))) { + // ensure at least one yield call even for templates with no output + $compiler->write("yield '';\n"); } $compiler @@ -471,4 +486,19 @@ protected function compileLoadTemplate(Compiler $compiler, $node, $var) throw new \LogicException('Trait templates can only be constant nodes.'); } } + + private function hasNodeOutputNodes(Node $node): bool + { + if ($node instanceof NodeOutputInterface) { + return true; + } + + foreach ($node as $child) { + if ($this->hasNodeOutputNodes($child)) { + return true; + } + } + + return false; + } } diff --git a/src/Node/PrintNode.php b/src/Node/PrintNode.php index 60386d29969..36bcaee6410 100644 --- a/src/Node/PrintNode.php +++ b/src/Node/PrintNode.php @@ -29,9 +29,19 @@ public function __construct(AbstractExpression $expr, int $lineno, string $tag = public function compile(Compiler $compiler): void { + $compiler->addDebugInfo($this); + + if ($compiler->getEnvironment()->useYield()) { + $compiler->write('yield '); + } else { + $compiler + ->checkForOutput(false) + ->write('echo ') + ->checkForOutput(true) + ; + } + $compiler - ->addDebugInfo($this) - ->write('echo ') ->subcompile($this->getNode('expr')) ->raw(";\n") ; diff --git a/src/Node/TextNode.php b/src/Node/TextNode.php index d74ebe630cc..3e417dad60d 100644 --- a/src/Node/TextNode.php +++ b/src/Node/TextNode.php @@ -28,9 +28,19 @@ public function __construct(string $data, int $lineno) public function compile(Compiler $compiler): void { + $compiler->addDebugInfo($this); + + if ($compiler->getEnvironment()->useYield()) { + $compiler->write('yield '); + } else { + $compiler + ->checkForOutput(false) + ->write('echo ') + ->checkForOutput(true) + ; + } + $compiler - ->addDebugInfo($this) - ->write('echo ') ->string($this->getAttribute('data')) ->raw(";\n") ; diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index e94e983ce65..f20a1cf9641 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -65,7 +65,14 @@ public function renderBlock(string $name, array $context = []): string public function displayBlock(string $name, array $context = []) { - $this->template->displayBlock($name, $this->env->mergeGlobals($context)); + $context = $this->env->mergeGlobals($context); + if ($this->template instanceof YieldingTemplate) { + foreach ($this->template->yieldBlock($name, $context) as $data) { + echo $data; + } + } else { + $this->template->displayBlock($name, $context); + } } public function getSourceContext(): Source diff --git a/src/Test/NodeTestCase.php b/src/Test/NodeTestCase.php index 1e4add6792c..8df0e9fee3a 100644 --- a/src/Test/NodeTestCase.php +++ b/src/Test/NodeTestCase.php @@ -19,6 +19,11 @@ abstract class NodeTestCase extends TestCase { + /** + * @var Environment + */ + private $currentEnv; + abstract public function getTests(); /** @@ -48,7 +53,7 @@ protected function getCompiler(Environment $environment = null) protected function getEnvironment() { - return new Environment(new ArrayLoader([])); + return $this->currentEnv = new Environment(new ArrayLoader([])); } protected function getVariableGetter($name, $line = false) @@ -62,4 +67,19 @@ protected function getAttributeGetter() { return 'CoreExtension::getAttribute($this->env, $this->source, '; } + + protected function getEchoOrYield(): string + { + return ($this->currentEnv ?? $this->getEnvironment())->useYield() ? 'yield' : 'echo'; + } + + protected function getDisplayOrYield(string $expr): string + { + return sprintf(($this->currentEnv ?? $this->getEnvironment())->useYield() ? 'yield from %s->unwrap()->yield' : '%s->display', $expr); + } + + protected function getDisplayOrYieldBlock(string $expr): string + { + return sprintf(($this->currentEnv ?? $this->getEnvironment())->useYield() ? 'yield from %s->unwrap()->yieldBlock' : '%s->displayBlock', $expr); + } } diff --git a/src/YieldingTemplate.php b/src/YieldingTemplate.php new file mode 100644 index 00000000000..93ee894d2b8 --- /dev/null +++ b/src/YieldingTemplate.php @@ -0,0 +1,182 @@ + + * + * @internal + */ +abstract class YieldingTemplate extends Template +{ + /** + * @return iterable + */ + public function yield(array $context, array $blocks = []): iterable + { + $context = $this->env->mergeGlobals($context); + $blocks = array_merge($this->blocks, $blocks); + + try { + yield from $this->doDisplay($context, $blocks); + } catch (Error $e) { + if (!$e->getSourceContext()) { + $e->setSourceContext($this->getSourceContext()); + } + + // this is mostly useful for \Twig\Error\LoaderError exceptions + // see \Twig\Error\LoaderError + if (-1 === $e->getTemplateLine()) { + $e->guess(); + } + + throw $e; + } catch (\Throwable $e) { + $e = new RuntimeError(sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $this->getSourceContext(), $e); + $e->guess(); + + throw $e; + } + } + + public function render(array $context): string + { + $content = ''; + foreach ($this->yield($context) as $data) { + $content .= $data; + } + + return $content; + } + + public function display(array $context, array $blocks = []): void + { + foreach ($this->yield($context, $blocks) as $data) { + echo $data; + } + } + + /** + * @return iterable + */ + public function yieldBlock($name, array $context, array $blocks = [], $useBlocks = true, Template $templateContext = null) + { + if ($useBlocks && isset($blocks[$name])) { + $template = $blocks[$name][0]; + $block = $blocks[$name][1]; + } elseif (isset($this->blocks[$name])) { + $template = $this->blocks[$name][0]; + $block = $this->blocks[$name][1]; + } else { + $template = null; + $block = null; + } + + // avoid RCEs when sandbox is enabled + if (null !== $template && !$template instanceof Template) { + throw new \LogicException('A block must be a method on a \Twig\Template instance.'); + } + + if (null !== $template) { + try { + yield from $template->$block($context, $blocks); + } catch (Error $e) { + if (!$e->getSourceContext()) { + $e->setSourceContext($template->getSourceContext()); + } + + // this is mostly useful for \Twig\Error\LoaderError exceptions + // see \Twig\Error\LoaderError + if (-1 === $e->getTemplateLine()) { + $e->guess(); + } + + throw $e; + } catch (\Throwable $e) { + $e = new RuntimeError(sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $template->getSourceContext(), $e); + $e->guess(); + + throw $e; + } + } elseif (false !== $parent = $this->getParent($context)) { + /** @var YieldingTemplate $parent */ + yield from $parent->yieldBlock($name, $context, array_merge($this->blocks, $blocks), false, $templateContext ?? $this); + } elseif (isset($blocks[$name])) { + throw new RuntimeError(sprintf('Block "%s" should not call parent() in "%s" as the block does not exist in the parent template "%s".', $name, $blocks[$name][0]->getTemplateName(), $this->getTemplateName()), -1, $blocks[$name][0]->getSourceContext()); + } else { + throw new RuntimeError(sprintf('Block "%s" on template "%s" does not exist.', $name, $this->getTemplateName()), -1, ($templateContext ?? $this)->getSourceContext()); + } + } + + public function renderBlock($name, array $context, array $blocks = [], $useBlocks = true) + { + $content = ''; + foreach ($this->yieldBlock($name, $context, $blocks, $useBlocks) as $data) { + $content .= $data; + } + + return $content; + } + + /** + * Yields a parent block. + * + * This method is for internal use only and should never be called + * directly. + * + * @param string $name The block name to display from the parent + * @param array $context The context + * @param array $blocks The current set of blocks + * + * @return iterable + */ + public function yieldParentBlock($name, array $context, array $blocks = []) + { + if (isset($this->traits[$name])) { + yield from $this->traits[$name][0]->yieldBlock($name, $context, $blocks, false); + } elseif (false !== $parent = $this->getParent($context)) { + $parent = $parent->unwrap(); + /** @var YieldingTemplate $parent */ + yield from $parent->yieldBlock($name, $context, $blocks, false); + } else { + throw new RuntimeError(sprintf('The template has no parent and no traits defining the "%s" block.', $name), -1, $this->getSourceContext()); + } + } + + public function renderParentBlock($name, array $context, array $blocks = []) + { + $content = ''; + foreach ($this->yieldParentBlock($name, $context, $blocks) as $data) { + $content .= $data; + } + + return $content; + } + + public function displayBlock($name, array $context, array $blocks = [], $useBlocks = true, Template $templateContext = null) + { + throw new RuntimeError(sprintf('Calling "%s" for block "%s" is not supported as "use_yield" is set to "true".', __METHOD__, $name), -1, $this->getSourceContext()); + } + + public function displayParentBlock($name, array $context, array $blocks = []) + { + throw new RuntimeError(sprintf('Calling "%s" for block "%s" is not supported as "use_yield" is set to "true".', __METHOD__, $name), -1, $this->getSourceContext()); + } + + protected function displayWithErrorHandling(array $context, array $blocks = []) + { + throw new RuntimeError(sprintf('Calling "%s" is not supported as "use_yield" is set to "true".', __METHOD__), -1, $this->getSourceContext()); + } +} diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index db6418ed685..ee6a86c49aa 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -304,20 +304,6 @@ public function getErroredTemplates() ], ]; } - - public function testTwigLeakOutputInDebugMode() - { - $output = exec(sprintf('%s %s debug', \PHP_BINARY, escapeshellarg(__DIR__.'/Fixtures/errors/leak-output.php'))); - - $this->assertSame('Hello OOPS', $output); - } - - public function testDoesNotTwigLeakOutput() - { - $output = exec(sprintf('%s %s', \PHP_BINARY, escapeshellarg(__DIR__.'/Fixtures/errors/leak-output.php'))); - - $this->assertSame('', $output); - } } class ErrorTest_Foo diff --git a/tests/Fixtures/errors/leak-output.php b/tests/Fixtures/errors/leak-output.php deleted file mode 100644 index 732383ea6cd..00000000000 --- a/tests/Fixtures/errors/leak-output.php +++ /dev/null @@ -1,33 +0,0 @@ - 'Hello {{ "world"|broken }}', -]); -$twig = new Environment($loader, ['debug' => isset($argv[1])]); -$twig->addExtension(new BrokenExtension()); - -echo $twig->render('index.html.twig'); diff --git a/tests/Fixtures/functions/parent_in_condition.test b/tests/Fixtures/functions/parent_in_condition.test new file mode 100644 index 00000000000..f3d51d2dd41 --- /dev/null +++ b/tests/Fixtures/functions/parent_in_condition.test @@ -0,0 +1,11 @@ +--TEST-- +"block" calling parent() in a conditional expression +--TEMPLATE-- +{% extends "parent.twig" %} +{% block label %}{{ parent() ?: 'foo' }}{% endblock %} +--TEMPLATE(parent.twig)-- +{% block label %}PARENT_LABEL{% endblock %} +--DATA-- +return [] +--EXPECT-- +PARENT_LABEL diff --git a/tests/Node/AutoEscapeTest.php b/tests/Node/AutoEscapeTest.php index d0f641c083c..b2df9b1605c 100644 --- a/tests/Node/AutoEscapeTest.php +++ b/tests/Node/AutoEscapeTest.php @@ -31,9 +31,10 @@ public function getTests() { $body = new Node([new TextNode('foo', 1)]); $node = new AutoEscapeNode(true, $body, 1); + $displayStmt = $this->getEchoOrYield(); return [ - [$node, "// line 1\necho \"foo\";"], + [$node, "// line 1\n$displayStmt \"foo\";"], ]; } } diff --git a/tests/Node/BlockReferenceTest.php b/tests/Node/BlockReferenceTest.php index 63dc0707c78..f291f29f33c 100644 --- a/tests/Node/BlockReferenceTest.php +++ b/tests/Node/BlockReferenceTest.php @@ -28,7 +28,7 @@ public function getTests() return [ [new BlockReferenceNode('foo', 1), <<displayBlock('foo', \$context, \$blocks); +{$this->getDisplayOrYieldBlock('$this')}('foo', \$context, \$blocks); EOF ], ]; diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index 8c0345885d0..cdc2c861cdb 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -11,7 +11,10 @@ * file that was distributed with this source code. */ +use Twig\Environment; +use Twig\Loader\ArrayLoader; use Twig\Node\BlockNode; +use Twig\Node\Node; use Twig\Node\TextNode; use Twig\Test\NodeTestCase; @@ -28,11 +31,10 @@ public function testConstructor() public function getTests() { - $body = new TextNode('foo', 1); - $node = new BlockNode('foo', $body, 1); + $tests = []; - return [ - [$node, <<getEnvironment()->useYield()) { + $tests[] = [new BlockNode('foo', new TextNode('foo', 1), 1), <<macros; + yield "foo"; +} +EOF + , new Environment(new ArrayLoader()) + ]; + + $tests[] = [new BlockNode('foo', new Node(), 1), <<macros; + yield ''; +} +EOF + , new Environment(new ArrayLoader()) + ]; + } + + return $tests; } } diff --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index dbfac3269b2..2abc31909e8 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -53,13 +53,14 @@ public function getTests() $else = null; $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', false); + $displayStmt = $this->getEchoOrYield(); $tests[] = [$node, <<getVariableGetter('items')}); foreach (\$context['_seq'] as \$context["key"] => \$context["item"]) { - echo {$this->getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['_iterated'], \$context['key'], \$context['item'], \$context['_parent'], \$context['loop']); @@ -93,7 +94,7 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; @@ -135,7 +136,7 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; @@ -178,7 +179,7 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; \$context['_iterated'] = true; ++\$context['loop']['index0']; ++\$context['loop']['index']; @@ -190,7 +191,7 @@ public function getTests() } } if (!\$context['_iterated']) { - echo {$this->getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); diff --git a/tests/Node/IfTest.php b/tests/Node/IfTest.php index d5a6eac8ab4..5dda061d0cb 100644 --- a/tests/Node/IfTest.php +++ b/tests/Node/IfTest.php @@ -47,11 +47,12 @@ public function getTests() ], [], 1); $else = null; $node = new IfNode($t, $else, 1); + $displayStmt = $this->getEchoOrYield(); $tests[] = [$node, <<getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; } EOF ]; @@ -68,9 +69,9 @@ public function getTests() $tests[] = [$node, <<getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; } elseif (false) { - echo {$this->getVariableGetter('bar')}; + $displayStmt {$this->getVariableGetter('bar')}; } EOF ]; @@ -85,9 +86,9 @@ public function getTests() $tests[] = [$node, <<getVariableGetter('foo')}; + $displayStmt {$this->getVariableGetter('foo')}; } else { - echo {$this->getVariableGetter('bar')}; + $displayStmt {$this->getVariableGetter('bar')}; } EOF ]; diff --git a/tests/Node/IncludeTest.php b/tests/Node/IncludeTest.php index 6d96373bfd3..ee68339c5d3 100644 --- a/tests/Node/IncludeTest.php +++ b/tests/Node/IncludeTest.php @@ -42,7 +42,7 @@ public function getTests() $node = new IncludeNode($expr, null, false, false, 1); $tests[] = [$node, <<loadTemplate("foo.twig", null, 1)->display(\$context); +{$this->getDisplayOrYield('$this->loadTemplate("foo.twig", null, 1)')}(\$context); EOF ]; @@ -55,7 +55,7 @@ public function getTests() $node = new IncludeNode($expr, null, false, false, 1); $tests[] = [$node, <<loadTemplate(((true) ? ("foo") : ("foo")), null, 1)->display(\$context); +{$this->getDisplayOrYield('$this->loadTemplate(((true) ? ("foo") : ("foo")), null, 1)')}(\$context); EOF ]; @@ -64,14 +64,14 @@ public function getTests() $node = new IncludeNode($expr, $vars, false, false, 1); $tests[] = [$node, <<loadTemplate("foo.twig", null, 1)->display(CoreExtension::arrayMerge(\$context, ["foo" => true])); +{$this->getDisplayOrYield('$this->loadTemplate("foo.twig", null, 1)')}(CoreExtension::arrayMerge(\$context, ["foo" => true])); EOF ]; $node = new IncludeNode($expr, $vars, true, false, 1); $tests[] = [$node, <<loadTemplate("foo.twig", null, 1)->display(CoreExtension::toArray(["foo" => true])); +{$this->getDisplayOrYield('$this->loadTemplate("foo.twig", null, 1)')}(CoreExtension::toArray(["foo" => true])); EOF ]; @@ -85,7 +85,7 @@ public function getTests() // ignore missing template } if (\$__internal_%s) { - \$__internal_%s->display(CoreExtension::toArray(["foo" => true])); + {$this->getDisplayOrYield('$__internal_%s')}(CoreExtension::toArray(["foo" => true])); } EOF , null, true]; diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php index bd7140b8859..948949af66d 100644 --- a/tests/Node/MacroTest.php +++ b/tests/Node/MacroTest.php @@ -11,6 +11,8 @@ * file that was distributed with this source code. */ +use Twig\Environment; +use Twig\Loader\ArrayLoader; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\NameExpression; use Twig\Node\MacroNode; @@ -33,15 +35,42 @@ public function testConstructor() public function getTests() { - $body = new TextNode('foo', 1); + $tests = []; + $arguments = new Node([ 'foo' => new ConstantExpression(null, 1), 'bar' => new ConstantExpression('Foo', 1), ], [], 1); + + $body = new TextNode('foo', 1); $node = new MacroNode('foo', $body, $arguments, 1); - return [ - [$node, <<getEnvironment()->useYield()) { + $text[] = [$node, <<macros; + \$context = \$this->env->mergeGlobals([ + "foo" => \$__foo__, + "bar" => \$__bar__, + "varargs" => \$__varargs__, + ]); + + \$blocks = []; + + return new Markup(implode('', iterator_to_array((function () use (\$context, \$macros, \$blocks) { + yield "foo"; + })() ?? new \EmptyIterator())), \$this->env->getCharset()); +} +EOF + , new Environment(new ArrayLoader()), + ]; + } else { + $body = new TextNode('foo', 1); + $node = new MacroNode('foo', $body, $arguments, 1); + + $tests[] = [$node, <<getEnvironment()->useYield() ? 'YieldingTemplate' : 'Template'; + $displayStmt = $this->getEchoOrYield(); $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); $tests[] = [$node, <<macros; // line 1 - echo "foo"; + $displayStmt "foo"; } /** @@ -126,6 +128,7 @@ public function getSourceContext() $body = new Node([$import]); $extends = new ConstantExpression('layout.twig', 1); + $parentTemplate = $this->getEnvironment()->useYield() ? 'YieldingTemplate' : 'Template'; $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); $tests[] = [$node, <<macros["macro"] = \$this->loadTemplate("foo.twig", "foo.twig", 2)->unwrap(); // line 1 \$this->parent = \$this->loadTemplate("layout.twig", "foo.twig", 1); - \$this->parent->display(\$context, array_merge(\$this->blocks, \$blocks)); + {$this->getDisplayOrYield('$this->parent')}(\$context, array_merge(\$this->blocks, \$blocks)); } /** @@ -216,6 +219,7 @@ public function getSourceContext() new ConstantExpression('foo', 2), 2 ); + $parentTemplate = $this->getEnvironment()->useYield() ? 'YieldingTemplate' : 'Template'; $twig = new Environment($this->createMock(LoaderInterface::class), ['debug' => true]); $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); @@ -233,10 +237,10 @@ public function getSourceContext() use Twig\Sandbox\SecurityNotAllowedFilterError; use Twig\Sandbox\SecurityNotAllowedFunctionError; use Twig\Source; -use Twig\Template; +use Twig\\{$parentTemplate}; /* foo.twig */ -class __TwigTemplate_%x extends Template +class __TwigTemplate_%x extends $parentTemplate { private \$source; private \$macros = []; @@ -263,7 +267,7 @@ protected function doDisplay(array \$context, array \$blocks = []) // line 4 \$context["foo"] = "foo"; // line 2 - \$this->getParent(\$context)->display(\$context, array_merge(\$this->blocks, \$blocks)); + {$this->getDisplayOrYield('$this->getParent($context)')}(\$context, array_merge(\$this->blocks, \$blocks)); } /** diff --git a/tests/Node/PrintTest.php b/tests/Node/PrintTest.php index 49f8eb49840..f951c2e3695 100644 --- a/tests/Node/PrintTest.php +++ b/tests/Node/PrintTest.php @@ -28,7 +28,9 @@ public function testConstructor() public function getTests() { $tests = []; - $tests[] = [new PrintNode(new ConstantExpression('foo', 1), 1), "// line 1\necho \"foo\";"]; + $displayStmt = $this->getEchoOrYield(); + + $tests[] = [new PrintNode(new ConstantExpression('foo', 1), 1), "// line 1\n$displayStmt \"foo\";"]; return $tests; } diff --git a/tests/Node/SandboxTest.php b/tests/Node/SandboxTest.php index 7cbddd75fb9..bf16f1f03c0 100644 --- a/tests/Node/SandboxTest.php +++ b/tests/Node/SandboxTest.php @@ -31,6 +31,7 @@ public function getTests() $body = new TextNode('foo', 1); $node = new SandboxNode($body, 1); + $displayStmt = $this->getEchoOrYield(); $tests[] = [$node, <<sandbox->enableSandbox(); } try { - echo "foo"; + $displayStmt "foo"; } finally { if (!\$alreadySandboxed) { \$this->sandbox->disableSandbox(); diff --git a/tests/Node/SetTest.php b/tests/Node/SetTest.php index c639be6a494..84d8a77c087 100644 --- a/tests/Node/SetTest.php +++ b/tests/Node/SetTest.php @@ -11,6 +11,8 @@ * file that was distributed with this source code. */ +use Twig\Environment; +use Twig\Loader\ArrayLoader; use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\NameExpression; @@ -46,10 +48,22 @@ public function getTests() EOF ]; + $names = new Node([new AssignNameExpression('foo', 1)], [], 1); $values = new Node([new PrintNode(new ConstantExpression('foo', 1), 1)], [], 1); $node = new SetNode(true, $names, $values, 1); - $tests[] = [$node, <<getEnvironment()->useYield()) { + $tests[] = [$node, <<env->getCharset()); +EOF + , new Environment(new ArrayLoader()), + ]; + } else { + $tests[] = [$node, <<getEchoOrYield(); + $tests[] = [new TextNode('foo', 1), "// line 1\n$displayStmt \"foo\";"]; return $tests; } diff --git a/tests/TemplateWrapperTest.php b/tests/TemplateWrapperTest.php index c524ebe3a8f..776ac3fa806 100644 --- a/tests/TemplateWrapperTest.php +++ b/tests/TemplateWrapperTest.php @@ -59,13 +59,18 @@ public function testDisplayBlock() $twig = new Environment(new ArrayLoader([ 'index' => '{% block foo %}{{ foo }}{{ bar }}{% endblock %}', ])); - $twig->addGlobal('bar', 'BAR'); - $wrapper = $twig->load('index'); + if (!$twig->useYield()) { + $twig->addGlobal('bar', 'BAR'); + + $wrapper = $twig->load('index'); - ob_start(); - $wrapper->displayBlock('foo', ['foo' => 'FOO']); + ob_start(); + $wrapper->displayBlock('foo', ['foo' => 'FOO']); - $this->assertEquals('FOOBAR', ob_get_clean()); + $this->assertEquals('FOOBAR', ob_get_clean()); + } else { + $this->markTestSkipped('yield not used.'); + } } } diff --git a/tests/ignore-use-yield-deprecations b/tests/ignore-use-yield-deprecations new file mode 100644 index 00000000000..0f844211547 --- /dev/null +++ b/tests/ignore-use-yield-deprecations @@ -0,0 +1 @@ +%Since twig/twig 3.9.0: Not setting "use_yield" to "true" is deprecated.%