-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce a CorrectnessNodeVisitor to validate that templates are semantically correct #4292
base: 3.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 <[email protected]> | ||
*/ | ||
#[YieldReady] | ||
final class ConfigNode extends Node | ||
{ | ||
public function __construct(int $lineno) | ||
{ | ||
parent::__construct([], [], $lineno); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
<?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\BlockReferenceNode; | ||
use Twig\Node\ConfigNode; | ||
use Twig\Node\ModuleNode; | ||
use Twig\Node\Node; | ||
use Twig\Node\NodeCaptureInterface; | ||
use Twig\Node\TextNode; | ||
|
||
/** | ||
* @author Fabien Potencier <[email protected]> | ||
* | ||
* @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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what if there is a single parsed node with children ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we need to check for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we should introduce a |
||
// 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't this check |
||
if ($this->currentTagNode instanceof NodeCaptureInterface || count($this->blockNodes) > 1) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the goal of this |
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks wrong to me, as it will reset the current tag node to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or even just some other nested node inside the |
||
} | ||
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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the filtering of the BOM gone ? |
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens when |
||
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; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not answering on the "why", i have'nt tried this PR locally yet... but i feel the before/after examples do not represent the same usage/need.
For instance (please don't be too harsh on this code 🙏 ) this is a pattern i used from time to time, and i feel this is often how the set/block was used.
https://github.com/symfony/ux/blob/8a66e74d5e7070e5cbf60042cae535fae28d3e7a/ux.symfony.com/templates/components/Code/CodeLine.html.twig
I don't mind we cannot in 4.0 and will find a better way, but the "after" here would not help in this situation, right ?