Fetch the escaper runtime once in the constructor of templates that escape

This commit is contained in:
Fabien Potencier
2026-09-25 08:18:29 +02:00
parent 9e198d7637
commit 3417f483bf
7 changed files with 206 additions and 4 deletions
+1
View File
@@ -2,6 +2,7 @@
* Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string
* Speed up splitting a string into characters in `split`, `random`, and `shuffle`
* Speed up escaping by fetching the escaper runtime once per template
* Speed up adding extensions to an environment
* Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment
* Speed up loading a template that the environment has already loaded
+3 -2
View File
@@ -14,6 +14,7 @@ namespace Twig\Extension;
use Twig\Environment;
use Twig\FileExtensionEscapingStrategy;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\Filter\EscapeFilter;
use Twig\Node\Expression\Filter\RawFilter;
use Twig\Node\Node;
use Twig\NodeVisitor\EscaperNodeVisitor;
@@ -51,8 +52,8 @@ final class EscaperExtension extends AbstractExtension
public function getFilters(): array
{
return [
new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]),
new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]),
new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]),
new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]),
new TwigFilter('raw', null, ['is_safe' => ['all'], 'node_class' => RawFilter::class]),
];
}
@@ -0,0 +1,51 @@
<?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\Expression\Filter;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Node;
use Twig\TwigFilter;
/**
* Uses the escaper runtime fetched by the template constructor when the escaper node visitor flagged it.
*
* @internal
*/
final class EscapeFilter extends FilterExpression
{
/**
* @param AbstractExpression $node
*/
#[FirstClassTwigCallableReady]
public function __construct(Node $node, TwigFilter|ConstantExpression $filter, Node $arguments, int $lineno)
{
parent::__construct($node, $filter, $arguments, $lineno);
$this->setAttribute('template_escaper', false);
}
protected function compileCallable(Compiler $compiler): void
{
if (!$this->getAttribute('template_escaper')) {
parent::compileCallable($compiler);
return;
}
$compiler->raw('$this->escaper->escape');
$this->compileArguments($compiler);
}
}
+12 -1
View File
@@ -74,6 +74,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface
'index' => null,
'embedded_templates' => $embeddedTemplates,
'strategy' => false,
'escaper' => false,
], 1);
// populate the template name of all node children
@@ -201,8 +202,14 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface
->write("/**\n")
->write(" * @var array<string, MacroNamespace>\n")
->write(" */\n")
->write("private array \$macros = [];\n\n")
->write("private array \$macros = [];\n")
;
if ($this->getAttribute('escaper')) {
$compiler->write("private \\Twig\\Runtime\\EscaperRuntime \$escaper;\n");
}
$compiler->raw("\n");
}
protected function compileConstructor(Compiler $compiler): void
@@ -215,6 +222,10 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface
->write("\$this->source = \$this->getSourceContext();\n\n")
;
if ($this->getAttribute('escaper')) {
$compiler->write("\$this->escaper = \$env->getRuntime('Twig\\Runtime\\EscaperRuntime');\n\n");
}
// parent
if (!$this->hasNode('parent')) {
$compiler->write("\$this->parent = false;\n\n");
+21 -1
View File
@@ -18,6 +18,7 @@ use Twig\Node\BlockNode;
use Twig\Node\BlockReferenceNode;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\Filter\EscapeFilter;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Expression\OperatorEscapeInterface;
use Twig\Node\ImportNode;
@@ -40,6 +41,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface
private $traverser;
private $defaultStrategy = false;
private $safeVars = [];
private bool $usesEscaper = false;
public function __construct()
{
@@ -55,6 +57,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface
$node->setAttribute('strategy', \is_string($this->defaultStrategy) ? $this->defaultStrategy : false);
$this->safeVars = [];
$this->blocks = [];
$this->usesEscaper = false;
} elseif ($node instanceof AutoEscapeNode) {
$this->statusStack[] = $node->getAttribute('value');
} elseif ($node instanceof BlockNode) {
@@ -69,10 +72,15 @@ final class EscaperNodeVisitor implements NodeVisitorInterface
public function leaveNode(Node $node, Environment $env): ?Node
{
if ($node instanceof ModuleNode) {
$node->setAttribute('escaper', $this->usesEscaper);
$this->defaultStrategy = false;
$this->safeVars = [];
$this->blocks = [];
} elseif ($node instanceof FilterExpression) {
if ($node instanceof EscapeFilter) {
$this->useTemplateEscaper($node);
}
return $this->preEscapeFilterNode($node, $env);
} elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping()) {
$expression = $node->getNode('expr');
@@ -175,8 +183,20 @@ final class EscaperNodeVisitor implements NodeVisitorInterface
$line = $node->getTemplateLine();
$filter = $env->getFilter('escape');
$args = new Nodes([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]);
$class = $filter->getNodeClass();
$expression = new $class($node, $filter, $args, $line);
return new FilterExpression($node, $filter, $args, $line);
if ($expression instanceof EscapeFilter) {
$this->useTemplateEscaper($expression);
}
return $expression;
}
private function useTemplateEscaper(EscapeFilter $filter): void
{
$filter->setAttribute('template_escaper', true);
$this->usesEscaper = true;
}
public function getPriority(): int
@@ -0,0 +1,35 @@
<?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\Tests\Node\Expression\Filter;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\Filter\EscapeFilter;
use Twig\Node\Nodes;
use Twig\Test\NodeTestCase;
class EscapeTest extends NodeTestCase
{
public static function provideTests(): iterable
{
$env = new Environment(new ArrayLoader());
$arguments = new Nodes([new ConstantExpression('html', 1)]);
$node = new EscapeFilter(new ConstantExpression('foo', 1), $env->getFilter('escape'), $arguments, 1);
yield 'not flagged by the escaper node visitor' => [$node, '$this->env->getRuntime(\'Twig\Runtime\EscaperRuntime\')->escape("foo", "html")', $env];
$node = new EscapeFilter(new ConstantExpression('foo', 1), $env->getFilter('escape'), $arguments, 1);
$node->setAttribute('template_escaper', true);
yield 'flagged by the escaper node visitor' => [$node, '$this->escaper->escape("foo", "html")', $env];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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\Tests\NodeVisitor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Loader\ArrayLoader;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Node\PrintNode;
use Twig\NodeVisitor\NodeVisitorInterface;
class EscaperTest extends TestCase
{
/**
* @dataProvider provideTemplates
*/
#[DataProvider('provideTemplates')]
public function testTemplatesFetchTheEscaperOnlyWhenTheyEscape(string|false $autoescape, string $template, int $escaperClasses, string $expected): void
{
$env = new Environment(new ArrayLoader(['index' => $template, 'embedded' => '{% block content %}{% endblock %}']), ['autoescape' => $autoescape]);
$this->assertSame($escaperClasses, substr_count($env->compileSource($env->getLoader()->getSourceContext('index')), 'private \Twig\Runtime\EscaperRuntime $escaper;'));
$this->assertSame($expected, $env->render('index', ['foo' => '<br>']));
}
public static function provideTemplates(): iterable
{
yield 'autoescaped print' => ['html', '{{ foo }}', 1, '&lt;br&gt;'];
yield 'print marked as safe' => ['html', '{{ foo|raw }}', 0, '<br>'];
yield 'autoescaping disabled' => [false, '{{ foo }}{# not autoescaped #}', 0, '<br>'];
yield 'explicit escape filter' => [false, '{{ foo|e }}', 1, '&lt;br&gt;'];
yield 'autoescape tag' => [false, '{% autoescape "html" %}{{ foo }}{% endautoescape %}', 1, '&lt;br&gt;'];
yield 'escaping in an embedded template only' => [false, '{% embed "embedded" %}{% block content %}{{ foo|e }}{% endblock %}{% endembed %}', 1, '&lt;br&gt;'];
}
public function testEscapeFilterAddedByALaterVisitorStillEscapes(): void
{
$env = new Environment(new ArrayLoader(['index' => '{{ "<br>" }}']), ['autoescape' => false]);
$env->addExtension(new class extends AbstractExtension {
public function getNodeVisitors(): array
{
return [new class implements NodeVisitorInterface {
public function enterNode(Node $node, Environment $env): Node
{
return $node;
}
public function leaveNode(Node $node, Environment $env): ?Node
{
if (!$node instanceof PrintNode) {
return $node;
}
$filter = $env->getFilter('escape');
$class = $filter->getNodeClass();
return new PrintNode(new $class($node->getNode('expr'), $filter, new Nodes([new ConstantExpression('html', 1)]), 1), 1);
}
public function getPriority(): int
{
return 10;
}
}];
}
});
$this->assertSame('&lt;br&gt;', $env->render('index'));
}
}