diff --git a/CHANGELOG b/CHANGELOG
index eefd094ac1b..f0770c88e77 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,7 @@
# 3.15.0 (2024-XX-XX)
+ * Deprecate the possibility to use a `block` tag within a capture node (like `set`)
+ * Throw an exception when a `macro`, `extends`, or `use` tag is used outside the root of a template
* Improve the way one can deprecate a Twig callable (use `deprecation_info` instead of the other callable options)
# 3.14.0 (2024-09-09)
diff --git a/doc/deprecated.rst b/doc/deprecated.rst
index c280702a5cb..92515d6d033 100644
--- a/doc/deprecated.rst
+++ b/doc/deprecated.rst
@@ -188,6 +188,29 @@ Templates
``Template::loadTemplate()``); pass instances of ``Twig\TemplateWrapper``
instead.
+* Having a "block" definition nested in another node that captures the output
+ (like "set") is deprecated in Twig 3.14 and will throw in Twig 4.0. Such use
+ cases should be avoided as the "block" tag is used to both define the block
+ AND display it in place. Here is how you can decouple both easily:
+
+ Before::
+
+ {% extends "layout.twig" %}
+
+ {% set str %}
+ {% block content %}Some content{% endblock %}
+ {% endset %}
+
+ After::
+
+ {% extends "layout.twig" %}
+
+ {% block content %}Some content{% endblock %}
+
+ {% set str %}
+ {{ block('content') }}
+ {% endset %}
+
Filters
-------
diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index 550dc0f3851..b8c944d5520 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -64,6 +64,7 @@
use Twig\Node\Expression\Unary\NotUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Node;
+use Twig\NodeVisitor\CorrectnessNodeVisitor;
use Twig\NodeVisitor\MacroAutoImportNodeVisitor;
use Twig\Parser;
use Twig\Source;
@@ -285,7 +286,10 @@ public function getTests(): array
public function getNodeVisitors(): array
{
- return [new MacroAutoImportNodeVisitor()];
+ return [
+ new CorrectnessNodeVisitor(),
+ new MacroAutoImportNodeVisitor(),
+ ];
}
public function getOperators(): array
diff --git a/src/Node/ConfigNode.php b/src/Node/ConfigNode.php
new file mode 100644
index 00000000000..758e057054f
--- /dev/null
+++ b/src/Node/ConfigNode.php
@@ -0,0 +1,30 @@
+
+ */
+#[YieldReady]
+final class ConfigNode extends Node
+{
+ public function __construct(int $lineno)
+ {
+ parent::__construct([], [], $lineno);
+ }
+}
diff --git a/src/Node/TextNode.php b/src/Node/TextNode.php
index fae65fb2cb4..86071dab408 100644
--- a/src/Node/TextNode.php
+++ b/src/Node/TextNode.php
@@ -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;
+ }
}
diff --git a/src/NodeVisitor/CorrectnessNodeVisitor.php b/src/NodeVisitor/CorrectnessNodeVisitor.php
new file mode 100644
index 00000000000..b428fa80da9
--- /dev/null
+++ b/src/NodeVisitor/CorrectnessNodeVisitor.php
@@ -0,0 +1,131 @@
+
+ *
+ * @internal
+ */
+final class CorrectnessNodeVisitor implements NodeVisitorInterface
+{
+ private ?\SplObjectStorage $rootNodes = null;
+ // in a tag node that does not support "block" nodes (all of them except "block")
+ private ?Node $currentTagNode = null;
+ private bool $hasParent = false;
+ private ?\SplObjectStorage $blockNodes = null;
+ private int $currentBlockNodeLevel = 0;
+
+ public function enterNode(Node $node, Environment $env): Node
+ {
+ if ($node instanceof ModuleNode) {
+ $this->rootNodes = new \SplObjectStorage();
+ $this->hasParent = $node->hasNode('parent');
+
+ // allows to identify when we enter/leave the block nodes
+ $this->blockNodes = new \SplObjectStorage();
+ foreach ($node->getNode('blocks') as $n) {
+ $this->blockNodes->attach($n);
+ }
+
+ $body = $node->getNode('body')->getNode('0');
+ // see Parser::subparse() which does not wrap the parsed Nodes if there is only one node
+ foreach (count($body) ? $body : new Node([$body]) as $k => $n) {
+ // check that this root node of a child template only contains empty output nodes
+ 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->attach($n);
+ }
+
+ return $node;
+ }
+
+ if ($this->blockNodes->contains($node)) {
+ ++$this->currentBlockNodeLevel;
+ }
+
+ if ($this->hasParent && $node->getNodeTag() && !$node instanceof BlockReferenceNode) {
+ $this->currentTagNode = $node;
+ }
+
+ if ($node instanceof ConfigNode && !$this->rootNodes->contains($node)) {
+ throw new SyntaxError(sprintf('The "%s" tag must always be at the root of the body of a template.', $node->getNodeTag()), $node->getTemplateLine(), $node->getSourceContext());
+ }
+
+ if ($this->currentTagNode && $node instanceof BlockReferenceNode) {
+ if ($this->currentTagNode instanceof NodeCaptureInterface || count($this->blockNodes) > 1) {
+ trigger_deprecation('twig/twig', '3.14', \sprintf('Having a "block" tag under a "%s" tag (line %d) is deprecated in %s at line %d.', $this->currentTagNode->getNodeTag(), $this->currentTagNode->getTemplateLine(), $node->getSourceContext()->getName(), $node->getTemplateLine()));
+ } else {
+ throw new SyntaxError(\sprintf('A "block" tag cannot be under a "%s" tag (line %d).', $this->currentTagNode->getNodeTag(), $this->currentTagNode->getTemplateLine()), $node->getTemplateLine(), $node->getSourceContext());
+ }
+ }
+
+ return $node;
+ }
+
+ public function leaveNode(Node $node, Environment $env): Node
+ {
+ if ($node instanceof ModuleNode) {
+ $this->rootNodes = null;
+ $this->hasParent = false;
+ $this->blockNodes = null;
+ $this->currentBlockNodeLevel = 0;
+ }
+ if ($this->hasParent && $node->getNodeTag() && !$node instanceof BlockReferenceNode) {
+ $this->currentTagNode = null;
+ }
+ if ($this->hasParent && $this->blockNodes->contains($node)) {
+ --$this->currentBlockNodeLevel;
+ }
+
+ return $node;
+ }
+
+ public function getPriority(): int
+ {
+ return -255;
+ }
+
+ /**
+ * 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();
+ }
+
+ foreach ($node as $n) {
+ if (!$this->isEmptyOutputNode($n)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Parser.php b/src/Parser.php
index 40370bb1bf2..b93da56e3e2 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -20,8 +20,6 @@
use Twig\Node\MacroNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
-use Twig\Node\NodeCaptureInterface;
-use Twig\Node\NodeOutputInterface;
use Twig\Node\PrintNode;
use Twig\Node\TextNode;
use Twig\TokenParser\TokenParserInterface;
@@ -81,10 +79,6 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals
try {
$body = $this->subparse($test, $dropNeedle);
-
- if (null !== $this->parent && null === $body = $this->filterBodyNodes($body)) {
- $body = new Node();
- }
} catch (SyntaxError $e) {
if (!$e->getSourceContext()) {
$e->setSourceContext($this->stream->getSourceContext());
@@ -97,6 +91,10 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals
throw $e;
}
+ if ($this->parent) {
+ $this->cleanupBodyForChildTemplates($body);
+ }
+
$node = new ModuleNode(new BodyNode([$body]), $this->parent, new Node($this->blocks), new Node($this->macros), new Node($this->traits), $this->embeddedTemplates, $stream->getSourceContext());
$traverser = new NodeTraverser($this->env, $this->visitors);
@@ -334,50 +332,16 @@ public function getCurrentToken(): Token
return $this->stream->getCurrent();
}
- private function filterBodyNodes(Node $node, bool $nested = false): ?Node
+ private function cleanupBodyForChildTemplates(Node $body): void
{
- // check that the body does not contain non-empty output nodes
- if (
- ($node instanceof TextNode && !ctype_space($node->getAttribute('data')))
- || (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface)
- ) {
- if (str_contains((string) $node, \chr(0xEF).\chr(0xBB).\chr(0xBF))) {
- $t = substr($node->getAttribute('data'), 3);
- if ('' === $t || ctype_space($t)) {
- // bypass empty nodes starting with a BOM
- return null;
- }
- }
-
- 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?', $node->getTemplateLine(), $this->stream->getSourceContext());
- }
-
- // bypass nodes that "capture" the output
- 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 $node;
- }
-
- // "block" tags that are not captured (see above) are only used for defining
- // the content of the block. In such a case, nesting it does not work as
- // expected as the definition is not part of the default template code flow.
- if ($nested && $node instanceof BlockReferenceNode) {
- throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext());
- }
-
- if ($node instanceof NodeOutputInterface) {
- return null;
- }
-
- // here, $nested means "being at the root level of a child template"
- // we need to discard the wrapping "Node" for the "body" node
- $nested = $nested || Node::class !== \get_class($node);
- foreach ($node as $k => $n) {
- if (null !== $n && null === $this->filterBodyNodes($n, $nested)) {
- $node->removeNode($k);
+ foreach ($body as $k => $node) {
+ if ($node instanceof BlockReferenceNode) {
+ // as it has a parent, the block reference won't be used
+ $body->removeNode($k);
+ } elseif ($node instanceof TextNode && $node->isBlank()) {
+ // remove nodes considered as "empty"
+ $body->removeNode($k);
}
}
-
- return $node;
}
}
diff --git a/src/Test/IntegrationTestCase.php b/src/Test/IntegrationTestCase.php
index 8690a809e38..5584c66eac2 100644
--- a/src/Test/IntegrationTestCase.php
+++ b/src/Test/IntegrationTestCase.php
@@ -165,7 +165,7 @@ public function getLegacyTests()
return $this->getTests('testLegacyIntegration', true);
}
- protected function doIntegrationTest($file, $message, $condition, $templates, $exception, $outputs, $deprecation = '')
+ protected function doIntegrationTest($file, $message, $condition, $templateSources, $exception, $outputs, $deprecation = '')
{
if (!$outputs) {
$this->markTestSkipped('no tests to run');
@@ -185,10 +185,10 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e
'strict_variables' => true,
], $match[2] ? eval($match[2].';') : []);
// make sure that template are always compiled even if they are the same (useful when testing with more than one data/expect sections)
- foreach ($templates as $j => $template) {
- $templates[$j] = $template.str_repeat(' ', $i);
+ foreach ($templateSources as $name => $template) {
+ $templateSources[$name] = $template.str_repeat(' ', $i);
}
- $loader = new ArrayLoader($templates);
+ $loader = new ArrayLoader($templateSources);
$twig = new Environment($loader, $config);
$twig->addGlobal('global', 'global');
foreach ($this->getRuntimeLoaders() as $runtimeLoader) {
@@ -212,6 +212,7 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e
}
$deprecations = [];
+ $templates = [];
try {
$prevHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$deprecations, &$prevHandler) {
if (\E_USER_DEPRECATED === $type) {
@@ -223,7 +224,9 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e
return $prevHandler ? $prevHandler($type, $msg, $file, $line, $context) : false;
});
- $template = $twig->load('index.twig');
+ foreach (array_keys($templateSources) as $templateName) {
+ $templates[$templateName] = $twig->load($templateName);
+ }
} catch (\Exception $e) {
if (false !== $exception) {
$message = $e->getMessage();
@@ -239,7 +242,7 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e
restore_error_handler();
}
- $this->assertSame($deprecation, implode("\n", $deprecations));
+ $template = $templates['index.twig'];
try {
$output = trim($template->render(eval($match[1].';')), "\n ");
@@ -266,12 +269,14 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e
if ($expected !== $output) {
printf("Compiled templates that failed on case %d:\n", $i + 1);
- foreach (array_keys($templates) as $name) {
+ foreach (array_keys($templateSources) as $name) {
echo "Template: $name\n";
echo $twig->compile($twig->parse($twig->tokenize($twig->getLoader()->getSourceContext($name))));
}
}
$this->assertEquals($expected, $output, $message.' (in '.$file.')');
+
+ $this->assertSame($deprecation, implode("\n", $deprecations));
}
}
diff --git a/src/TokenParser/ExtendsTokenParser.php b/src/TokenParser/ExtendsTokenParser.php
index 86ddfdfba34..c0339abe3b2 100644
--- a/src/TokenParser/ExtendsTokenParser.php
+++ b/src/TokenParser/ExtendsTokenParser.php
@@ -12,7 +12,7 @@
namespace Twig\TokenParser;
-use Twig\Error\SyntaxError;
+use Twig\Node\ConfigNode;
use Twig\Node\Node;
use Twig\Token;
@@ -27,19 +27,10 @@ final class ExtendsTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
- $stream = $this->parser->getStream();
-
- if ($this->parser->peekBlockStack()) {
- throw new SyntaxError('Cannot use "extend" in a block.', $token->getLine(), $stream->getSourceContext());
- } elseif (!$this->parser->isMainScope()) {
- throw new SyntaxError('Cannot use "extend" in a macro.', $token->getLine(), $stream->getSourceContext());
- }
-
$this->parser->setParent($this->parser->getExpressionParser()->parseExpression());
+ $this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
- $stream->expect(Token::BLOCK_END_TYPE);
-
- return new Node([], [], $token->getLine());
+ return new ConfigNode($token->getLine());
}
public function getTag(): string
diff --git a/src/TokenParser/MacroTokenParser.php b/src/TokenParser/MacroTokenParser.php
index c7762075c56..93af8964c76 100644
--- a/src/TokenParser/MacroTokenParser.php
+++ b/src/TokenParser/MacroTokenParser.php
@@ -13,6 +13,7 @@
use Twig\Error\SyntaxError;
use Twig\Node\BodyNode;
+use Twig\Node\ConfigNode;
use Twig\Node\MacroNode;
use Twig\Node\Node;
use Twig\Token;
@@ -51,7 +52,7 @@ public function parse(Token $token): Node
$this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno));
- return new Node([], [], $lineno);
+ return new ConfigNode($lineno);
}
public function decideBlockEnd(Token $token): bool
diff --git a/src/TokenParser/UseTokenParser.php b/src/TokenParser/UseTokenParser.php
index 1b96b40478e..a8a058659a5 100644
--- a/src/TokenParser/UseTokenParser.php
+++ b/src/TokenParser/UseTokenParser.php
@@ -12,6 +12,7 @@
namespace Twig\TokenParser;
use Twig\Error\SyntaxError;
+use Twig\Node\ConfigNode;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Node;
use Twig\Token;
@@ -63,7 +64,7 @@ public function parse(Token $token): Node
$this->parser->addTrait(new Node(['template' => $template, 'targets' => new Node($targets)]));
- return new Node([], [], $token->getLine());
+ return new ConfigNode($token->getLine());
}
public function getTag(): string
diff --git a/tests/Fixtures/tags/inheritance/capturing_block.legacy.test b/tests/Fixtures/tags/inheritance/capturing_block.legacy.test
new file mode 100644
index 00000000000..d6b595b0ff9
--- /dev/null
+++ b/tests/Fixtures/tags/inheritance/capturing_block.legacy.test
@@ -0,0 +1,19 @@
+--TEST--
+capturing "block" tag with "extends" tag
+--DEPRECATION--
+Since twig/twig 3.14: Having a "block" tag under a "set" tag (line 4) is deprecated in index.twig at line 5.
+--TEMPLATE--
+{% extends "layout.twig" %}
+
+{% set foo %}
+ {%- block content %}FOO{% endblock %}
+{% endset %}
+
+{% block content1 %}BAR{{ foo }}{% endblock %}
+--TEMPLATE(layout.twig)--
+{% block content %}{% endblock %}
+{% block content1 %}{% endblock %}
+--DATA--
+return []
+--EXPECT--
+FOOBARFOO
diff --git a/tests/Fixtures/tags/inheritance/capturing_block.test b/tests/Fixtures/tags/inheritance/capturing_block.test
index 91db2c22f59..d2e9cb35ac5 100644
--- a/tests/Fixtures/tags/inheritance/capturing_block.test
+++ b/tests/Fixtures/tags/inheritance/capturing_block.test
@@ -3,8 +3,10 @@ capturing "block" tag with "extends" tag
--TEMPLATE--
{% extends "layout.twig" %}
-{% set foo %}
- {%- block content %}FOO{% endblock %}
+{% block content %}FOO{% endblock %}
+
+{% set foo -%}
+ {{ block('content') }}
{% endset %}
{% block content1 %}BAR{{ foo }}{% endblock %}
diff --git a/tests/Fixtures/tags/inheritance/conditional_block.test b/tests/Fixtures/tags/inheritance/conditional_block.test
index 0b42212dd1a..d799bfa042e 100644
--- a/tests/Fixtures/tags/inheritance/conditional_block.test
+++ b/tests/Fixtures/tags/inheritance/conditional_block.test
@@ -11,4 +11,4 @@ conditional "block" tag with "extends" tag
--DATA--
return []
--EXCEPTION--
-Twig\Error\SyntaxError: A block definition cannot be nested under non-capturing nodes in "index.twig" at line 5.
+Twig\Error\SyntaxError: A "block" tag cannot be under a "if" tag (line 4) in "index.twig" at line 5.
diff --git a/tests/Fixtures/tags/inheritance/conditional_block_nested.legacy.test b/tests/Fixtures/tags/inheritance/conditional_block_nested.legacy.test
new file mode 100644
index 00000000000..3b6a01b70ce
--- /dev/null
+++ b/tests/Fixtures/tags/inheritance/conditional_block_nested.legacy.test
@@ -0,0 +1,40 @@
+--TEST--
+conditional "block" tag with "extends" tag (nested)
+--DEPRECATION--
+Since twig/twig 3.14: Having a "block" tag under a "if" tag (line 7) is deprecated in layout.twig at line 8.
+--TEMPLATE--
+{% extends "layout.twig" %}
+
+{% block content_base %}
+ {{ parent() -}}
+ index
+{% endblock %}
+
+{% block content_layout -%}
+ {{ parent() -}}
+ nested_index
+{% endblock %}
+--TEMPLATE(layout.twig)--
+{% extends "base.twig" %}
+
+{% block content_base %}
+ {{ parent() -}}
+ layout
+ {% if true -%}
+ {% block content_layout -%}
+ nested_layout
+ {% endblock -%}
+ {% endif %}
+{% endblock %}
+--TEMPLATE(base.twig)--
+{% block content_base %}
+ base
+{% endblock %}
+--DATA--
+return []
+--EXPECT--
+base
+layout
+ nested_layout
+ nested_index
+index
diff --git a/tests/Fixtures/tags/inheritance/conditional_block_nested.test b/tests/Fixtures/tags/inheritance/conditional_block_nested.test
index 1f99dfb6167..63d60f6f94a 100644
--- a/tests/Fixtures/tags/inheritance/conditional_block_nested.test
+++ b/tests/Fixtures/tags/inheritance/conditional_block_nested.test
@@ -15,13 +15,15 @@ conditional "block" tag with "extends" tag (nested)
--TEMPLATE(layout.twig)--
{% extends "base.twig" %}
+{% block content_layout -%}
+ nested_layout
+{% endblock -%}
+
{% block content_base %}
{{ parent() -}}
layout
{% if true -%}
- {% block content_layout -%}
- nested_layout
- {% endblock -%}
+ {{ block('content_layout') -}}
{% endif %}
{% endblock %}
--TEMPLATE(base.twig)--
@@ -34,5 +36,5 @@ return []
base
layout
nested_layout
- nested_index
+nested_index
index
diff --git a/tests/Fixtures/tags/inheritance/extends_in_block.test b/tests/Fixtures/tags/inheritance/extends_in_block.test
index a372ea1c81e..9650057ea0d 100644
--- a/tests/Fixtures/tags/inheritance/extends_in_block.test
+++ b/tests/Fixtures/tags/inheritance/extends_in_block.test
@@ -7,4 +7,4 @@
--DATA--
return []
--EXCEPTION--
-Twig\Error\SyntaxError: Cannot use "extend" in a block in "index.twig" at line 3.
+Twig\Error\SyntaxError: The "extends" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/inheritance/extends_in_condition.test b/tests/Fixtures/tags/inheritance/extends_in_condition.test
new file mode 100644
index 00000000000..f7bfc747b5c
--- /dev/null
+++ b/tests/Fixtures/tags/inheritance/extends_in_condition.test
@@ -0,0 +1,11 @@
+--TEST--
+"extends" tag in a condition
+--TEMPLATE--
+{% if false %}
+ {% extends "base.twig" %}
+{% endif %}
+--TEMPLATE(base.twig)--
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: The "extends" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/inheritance/extends_in_macro.test b/tests/Fixtures/tags/inheritance/extends_in_macro.test
index dc87b2a8c2d..8f6ebef06c6 100644
--- a/tests/Fixtures/tags/inheritance/extends_in_macro.test
+++ b/tests/Fixtures/tags/inheritance/extends_in_macro.test
@@ -4,7 +4,8 @@
{% macro foo() %}
{% extends "foo.twig" %}
{% endmacro %}
+--TEMPLATE(foo.twig)--
--DATA--
return []
--EXCEPTION--
-Twig\Error\SyntaxError: Cannot use "extend" in a macro in "index.twig" at line 3.
+Twig\Error\SyntaxError: The "extends" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/inheritance/multiple.test b/tests/Fixtures/tags/inheritance/multiple.test
index fc25badd34f..e0e3cac5b96 100644
--- a/tests/Fixtures/tags/inheritance/multiple.test
+++ b/tests/Fixtures/tags/inheritance/multiple.test
@@ -1,12 +1,36 @@
--TEST--
"extends" tag
--TEMPLATE--
-{% extends "layout.twig" %}{% block content %}{{ parent() }}index {% endblock %}
+{% extends "layout.twig" %}
+
+{% block content_base %}
+ {{ parent() -}}
+ index
+{% endblock %}
+
+{% block content_layout -%}
+ {{ parent() -}}
+ nested_index
+{% endblock %}
--TEMPLATE(layout.twig)--
-{% extends "base.twig" %}{% block content %}{{ parent() }}layout {% endblock %}
+{% extends "base.twig" %}
+
+{% block content_base %}
+ {{ parent() -}}
+ layout
+ {% block content_layout -%}
+ nested_layout
+ {% endblock %}
+{% endblock %}
--TEMPLATE(base.twig)--
-{% block content %}base {% endblock %}
+{% block content_base %}
+ base
+{% endblock %}
--DATA--
return []
--EXPECT--
-base layout index
+base
+layout
+ nested_layout
+ nested_index
+index
diff --git a/tests/Fixtures/tags/inheritance/use_in_condition.test b/tests/Fixtures/tags/inheritance/use_in_condition.test
new file mode 100644
index 00000000000..52c3faf99d5
--- /dev/null
+++ b/tests/Fixtures/tags/inheritance/use_in_condition.test
@@ -0,0 +1,11 @@
+--TEST--
+"use" tag in a condition
+--TEMPLATE--
+{% if false %}
+ {% use "base.twig" %}
+{% endif %}
+--TEMPLATE(base.twig)--
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: The "use" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/inheritance/use_in_macro.test b/tests/Fixtures/tags/inheritance/use_in_macro.test
new file mode 100644
index 00000000000..f9f9e8ee3c3
--- /dev/null
+++ b/tests/Fixtures/tags/inheritance/use_in_macro.test
@@ -0,0 +1,13 @@
+--TEST--
+"use" tag in a macro
+--TEMPLATE--
+{% macro input(name, value, type, size) %}
+ {% use "base.twig" %}
+
+
+{% endmacro %}
+--TEMPLATE(base.twig)--
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: The "use" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/macro/macro_in_block.test b/tests/Fixtures/tags/macro/macro_in_block.test
new file mode 100644
index 00000000000..1fc96fed02c
--- /dev/null
+++ b/tests/Fixtures/tags/macro/macro_in_block.test
@@ -0,0 +1,12 @@
+--TEST--
+"macro" tag in a block
+--TEMPLATE--
+{% block foo %}
+ {% macro input(name, value, type, size) %}
+
+ {% endmacro %}
+{% endblock %}
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: The "macro" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Fixtures/tags/macro/macro_in_condition.test b/tests/Fixtures/tags/macro/macro_in_condition.test
new file mode 100644
index 00000000000..43ec7278479
--- /dev/null
+++ b/tests/Fixtures/tags/macro/macro_in_condition.test
@@ -0,0 +1,12 @@
+--TEST--
+"macro" tag in a condition
+--TEMPLATE--
+{% if false %}
+ {% macro input(name, value, type, size) %}
+
+ {% endmacro %}
+{% endif %}
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: The "macro" tag must always be at the root of the body of a template in "index.twig" at line 3.
diff --git a/tests/Node/TextTest.php b/tests/Node/TextTest.php
index 2d2fe9151a7..48d2f7a78f8 100644
--- a/tests/Node/TextTest.php
+++ b/tests/Node/TextTest.php
@@ -30,4 +30,23 @@ public static function provideTests(): iterable
return $tests;
}
+
+ /**
+ * @dataProvider getIsBlankData
+ */
+ public function testIsBlank($blank)
+ {
+ $this->isTrue((new TextNode($blank, 1))->isBlank());
+ $this->isTrue((new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$blank, 1))->isBlank());
+ }
+
+ public static function getIsBlankData()
+ {
+ return [
+ [' '],
+ ["\t"],
+ ["\n"],
+ ["\n\t\n "],
+ ];
+ }
}
diff --git a/tests/NodeVisitor/CorrectnessTest.php b/tests/NodeVisitor/CorrectnessTest.php
new file mode 100644
index 00000000000..ba1e2caacd5
--- /dev/null
+++ b/tests/NodeVisitor/CorrectnessTest.php
@@ -0,0 +1,100 @@
+assertEquals($expected, $this->traverse($input, $expected));
+ }
+
+ public static function getFilterBodyNodesData()
+ {
+ return [
+ [
+ $input = new Node([new SetNode(false, new Node(), new Node(), 1)]),
+ $input,
+ ],
+ [
+ $input = new Node([new SetNode(true, new Node(), new Node([new Node([new TextNode('foo', 1)])]), 1)]),
+ $input,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getFilterBodyNodesDataThrowsException
+ */
+ public function testFilterBodyNodesThrowsException($input)
+ {
+ $this->expectException(SyntaxError::class);
+ $this->traverse($input, new Node());
+ }
+
+ public static function getFilterBodyNodesDataThrowsException()
+ {
+ return [
+ [new TextNode('foo', 1)],
+ [new Node([new Node([new TextNode('foo', 1)])])],
+ ];
+ }
+
+ /**
+ * @dataProvider getFilterBodyNodesWithBOMData
+ */
+ public function testFilterBodyNodesWithBOM($emptyText)
+ {
+ $input = new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$emptyText, 1);
+
+ $this->assertCount(0, $this->traverse($input, new Node()));
+ }
+
+ public static function getFilterBodyNodesWithBOMData()
+ {
+ return [
+ [' '],
+ ["\t"],
+ ["\n"],
+ ["\n\t\n "],
+ ];
+ }
+
+ private function traverse(Node $input, Node $expected): Node
+ {
+ $source = new Source('', 'index');
+ $input = new ModuleNode(new BodyNode([$input]), new ConstantExpression('parent', 1), new Node(), new Node(), new Node(), [], $source);
+ $expected->setSourceContext($source);
+
+ $env = new Environment(new ArrayLoader(['index' => '']));
+ $traverser = new NodeTraverser($env, [new CorrectnessNodeVisitor()]);
+
+ return $traverser->traverse($input, $env)->getNode('body')->getNode('0');
+ }
+}
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
index dc45fd66d30..cdd0ef637a7 100644
--- a/tests/ParserTest.php
+++ b/tests/ParserTest.php
@@ -17,7 +17,6 @@
use Twig\Lexer;
use Twig\Loader\ArrayLoader;
use Twig\Node\Node;
-use Twig\Node\SetNode;
use Twig\Node\TextNode;
use Twig\Parser;
use Twig\Source;
@@ -59,80 +58,6 @@ public function testUnknownTagWithoutSuggestions()
$parser->parse($stream);
}
- /**
- * @dataProvider getFilterBodyNodesData
- */
- public function testFilterBodyNodes($input, $expected)
- {
- $parser = $this->getParser();
- $m = new \ReflectionMethod($parser, 'filterBodyNodes');
- $m->setAccessible(true);
-
- $this->assertEquals($expected, $m->invoke($parser, $input));
- }
-
- public static function getFilterBodyNodesData()
- {
- return [
- [
- new Node([new TextNode(' ', 1)]),
- new Node([]),
- ],
- [
- $input = new Node([new SetNode(false, new Node(), new Node(), 1)]),
- $input,
- ],
- [
- $input = new Node([new SetNode(true, new Node(), new Node([new Node([new TextNode('foo', 1)])]), 1)]),
- $input,
- ],
- ];
- }
-
- /**
- * @dataProvider getFilterBodyNodesDataThrowsException
- */
- public function testFilterBodyNodesThrowsException($input)
- {
- $parser = $this->getParser();
-
- $m = new \ReflectionMethod($parser, 'filterBodyNodes');
- $m->setAccessible(true);
-
- $this->expectException(SyntaxError::class);
- $m->invoke($parser, $input);
- }
-
- public static function getFilterBodyNodesDataThrowsException()
- {
- return [
- [new TextNode('foo', 1)],
- [new Node([new Node([new TextNode('foo', 1)])])],
- ];
- }
-
- /**
- * @dataProvider getFilterBodyNodesWithBOMData
- */
- public function testFilterBodyNodesWithBOM($emptyNode)
- {
- $parser = $this->getParser();
-
- $m = new \ReflectionMethod($parser, 'filterBodyNodes');
- $m->setAccessible(true);
- $this->assertNull($m->invoke($parser, new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$emptyNode, 1)));
- }
-
- public static function getFilterBodyNodesWithBOMData()
- {
- return [
- [' '],
- ["\t"],
- ["\n"],
- ["\n\t\n "],
- ];
- }
-
public function testParseIsReentrant()
{
$twig = new Environment(new ArrayLoader(), [
@@ -200,6 +125,57 @@ public function testImplicitMacroArgumentDefaultValues()
$this->assertTrue($argumentNodes->getNode('lo')->getAttribute('value'));
}
+ public function testBodyForChildTemplates()
+ {
+ $twig = new Environment(new ArrayLoader());
+ $node = $twig->parse($twig->tokenize(new Source(<<getNode('body')->getNode('0');
+ $this->assertCount(2, $body);
+ $this->assertSame('extends', $body->getNode('0')->getNodeTag());
+ $this->assertSame('set', $body->getNode('4')->getNodeTag());
+ }
+
+ public function testBodyForParentTemplates()
+ {
+ $twig = new Environment(new ArrayLoader());
+ $node = $twig->parse($twig->tokenize(new Source(<<getNode('body')->getNode('0');
+ $this->assertCount(5, $body);
+ $this->assertSame('block', $body->getNode('0')->getNodeTag());
+ $this->assertInstanceOf(TextNode::class, $body->getNode('1'));
+ $this->assertSame('set', $body->getNode('2')->getNodeTag());
+ $this->assertInstanceOf(TextNode::class, $body->getNode('3'));
+ $this->assertSame('block', $body->getNode('4')->getNodeTag());
+ }
+
protected function getParser()
{
$parser = new Parser(new Environment(new ArrayLoader()));