Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* Report the column number in syntax errors and expose it via `Error::getTemplateColumn()`
* Track the source offset of each token and expose it via `Token::getOffset()`
* Fix nested `block()` calls to resolve against the overriding template when a block rendered through `block(name, template)` calls `parent()`
* Deprecate the possibility to use a `block` tag within a capture node (like `set`)
* Deprecate the possibility to use a `block` tag within a capture node (like `set`) in child templates
* Deprecate using a `macro`, `extends`, or `use` tag outside the root of a template
* Stop reporting a skipped test in `IntegrationTestCase` when there is no legacy test to run
* Fix `markdown_to_html` to strip the indentation shared by all lines instead of mangling content that starts with a blank line
* Make the `IntegrationTestCase` and `NodeTestCase` test helpers compatible with PHPUnit 11
Expand Down
5 changes: 5 additions & 0 deletions doc/deprecated.rst
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ Templates
in ``Environment::resolveTemplate()`` and ``Environment::load()``); pass
instances of ``Twig\TemplateWrapper`` instead.

* Using a ``macro``, ``extends``, or ``use`` tag outside the root of a template
(for instance nested under an ``if`` or inside a ``block`` or ``macro``) is
deprecated as of Twig 3.27 and will throw in Twig 4.0. These tags have a
global effect on the template and must be declared at the root of its body.

Filters
-------

Expand Down
5 changes: 4 additions & 1 deletion src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Node;
use Twig\NodeVisitor\CorrectnessNodeVisitor;
use Twig\Parser;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
Expand Down Expand Up @@ -328,7 +329,9 @@ public function getTests(): array

public function getNodeVisitors(): array
{
return [];
return [
new CorrectnessNodeVisitor(),
];
}

public function getExpressionParsers(): array
Expand Down
30 changes: 30 additions & 0 deletions src/Node/ConfigNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?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\Node;

use Twig\Attribute\YieldReady;

/**
* Represents a node that has global side effects but does not generate template code.
*
* Such nodes must be at the root level of the body of a template.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class ConfigNode extends Node
{
public function __construct(int $lineno)
{
parent::__construct([], [], $lineno);
}
}
16 changes: 16 additions & 0 deletions src/Node/TextNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,20 @@ public function compile(Compiler $compiler): void
->raw(";\n")
;
}

public function isBlank(): bool
{
if (ctype_space($this->getAttribute('data'))) {
return true;
}

if (str_contains((string) $this, \chr(0xEF).\chr(0xBB).\chr(0xBF))) {
$t = substr($this->getAttribute('data'), 3);
if ('' === $t || ctype_space($t)) {
return true;
}
}

return false;
}
}
223 changes: 223 additions & 0 deletions src/NodeVisitor/CorrectnessNodeVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?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\NodeVisitor;

use Twig\Environment;
use Twig\Error\SyntaxError;
use Twig\Node\BlockNode;
use Twig\Node\BlockReferenceNode;
use Twig\Node\ConfigNode;
use Twig\Node\MacroNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\NodeCaptureInterface;
use Twig\Node\NodeOutputInterface;
use Twig\Node\Nodes;
use Twig\Node\TextNode;

/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal
*/
final class CorrectnessNodeVisitor implements NodeVisitorInterface
{
private ?\WeakMap $rootNodes = null;
/**
* Stack of the output-wrapping tags ("if", "for", "set", ...) currently open;
* the top one is the nearest tag a "block" definition would be nested under.
*
* @var list<Node>
*/
private array $tagStack = [];
private bool $hasParent = false;
private int $blockDepth = 0;
private int $macroDepth = 0;
private int $capturingNodeDepth = 0;
private bool $hasExtends = false;

public function enterNode(Node $node, Environment $env): Node
{
if ($node instanceof ModuleNode) {
$this->enterModule($node);

return $node;
}

$this->enterScope($node);

if ($node instanceof ConfigNode) {
$this->checkConfigTag($node);
}

if ($node instanceof BlockReferenceNode) {
$this->checkBlockDefinitionNesting($node);
}

return $node;
}

public function leaveNode(Node $node, Environment $env): Node
{
if ($node instanceof ModuleNode) {
$this->resetState();

return $node;
}

$this->leaveScope($node);

return $node;
}

public function getPriority(): int
{
return -255;
}

private function enterModule(ModuleNode $node): void
{
$this->resetState();
$this->rootNodes = new \WeakMap();
$this->hasParent = $node->hasNode('parent');

foreach ($this->getRootNodes($node) as $n) {
if ($this->hasParent && !$this->isEmptyOutputNode($n)) {
throw new SyntaxError('A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?', $n->getTemplateLine(), $n->getSourceContext());
}
$this->rootNodes[$n] = true;
}
}

private function resetState(): void
{
$this->rootNodes = null;
$this->tagStack = [];
$this->hasParent = false;
$this->blockDepth = 0;
$this->macroDepth = 0;
$this->capturingNodeDepth = 0;
$this->hasExtends = false;
}

/**
* @return iterable<Node>
*/
private function getRootNodes(ModuleNode $node): iterable
{
$body = $node->getNode('body')->getNode('0');

// Parser::subparse() does not wrap the parsed nodes when there is only one,
// so $body can be a single "real" node instead of a Nodes container; in that
// case it is the only root node and must not be iterated over its children.
return $body instanceof Nodes || Node::class === $body::class ? $body : [$body];
}

private function enterScope(Node $node): void
{
if ($node instanceof NodeCaptureInterface) {
++$this->capturingNodeDepth;
}

if ($node instanceof BlockNode) {
++$this->blockDepth;
} elseif ($node instanceof MacroNode) {
++$this->macroDepth;
} elseif ($node->getNodeTag() && !$node instanceof BlockReferenceNode) {
$this->tagStack[] = $node;
}
}

private function leaveScope(Node $node): void
{
if ($node instanceof NodeCaptureInterface) {
--$this->capturingNodeDepth;
}

if ($node instanceof BlockNode) {
--$this->blockDepth;
} elseif ($node instanceof MacroNode) {
--$this->macroDepth;
} elseif ($node->getNodeTag() && !$node instanceof BlockReferenceNode) {
array_pop($this->tagStack);
}
}

private function checkConfigTag(ConfigNode $node): void
{
if ('extends' === $node->getNodeTag()) {
$this->checkExtendsTag($node);
}

if (!isset($this->rootNodes[$node])) {
trigger_deprecation('twig/twig', '3.27', 'Using the "%s" tag outside the root of a template is deprecated in %s at line %d.', $node->getNodeTag(), $node->getSourceContext()->getName(), $node->getTemplateLine());
}
}

private function checkExtendsTag(ConfigNode $node): void
{
// "extends" inside a "block" or a "macro" has always been a hard error; keep it
if ($this->blockDepth) {
throw new SyntaxError('Cannot use "extend" in a block.', $node->getTemplateLine(), $node->getSourceContext());
}
if ($this->macroDepth) {
throw new SyntaxError('Cannot use "extend" in a macro.', $node->getTemplateLine(), $node->getSourceContext());
}
if ($this->hasExtends) {
throw new SyntaxError('Multiple extends tags are forbidden.', $node->getTemplateLine(), $node->getSourceContext());
}

$this->hasExtends = true;
}

private function checkBlockDefinitionNesting(BlockReferenceNode $node): void
{
// A "block" definition nested under an output-wrapping tag is registered globally
// regardless of that tag, so the nesting is misleading. This only matters at the root
// of a child template's body: once inside a block, a macro, an output capture, or in
// a standalone template, the block is rendered in place and behaves like any other.
if (!$this->hasParent || $this->blockDepth || $this->macroDepth || $this->capturingNodeDepth || !$this->tagStack) {
return;
}

$tag = $this->tagStack[array_key_last($this->tagStack)];
throw new SyntaxError(\sprintf('A "block" tag cannot be under a "%s" tag (line %d).', $tag->getNodeTag(), $tag->getTemplateLine()), $node->getTemplateLine(), $node->getSourceContext());
}

/**
* Returns true if the node never outputs anything or if the output is empty.
*/
private function isEmptyOutputNode(Node $node): bool
{
if ($node instanceof NodeCaptureInterface) {
// a "block" tag in such a node will serve as a block definition AND be displayed in place as well
return true;
}

// Can the text be considered "empty" (only whitespace)?
if ($node instanceof TextNode) {
return $node->isBlank();
}

if (!$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface) {
return false;
}

foreach ($node as $n) {
if (!$this->isEmptyOutputNode($n)) {
return false;
}
}

return true;
}
}
Loading
Loading