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()));