diff --git a/extra/intl-extra/IntlExtension.php b/extra/intl-extra/IntlExtension.php index 4bb3b343f..efb876be4 100644 --- a/extra/intl-extra/IntlExtension.php +++ b/extra/intl-extra/IntlExtension.php @@ -186,7 +186,10 @@ final class IntlExtension extends AbstractExtension private $listFormatters = []; private $dateFormatterPrototype; private $numberFormatterPrototype; - private array $prototypeDerivedPatterns = []; + /** + * @var array + */ + private array $prototypePatterns = []; public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null) { @@ -552,11 +555,19 @@ final class IntlExtension extends AbstractExtension private function prototypePattern(): string { $pattern = $this->dateFormatterPrototype->getPattern(); + if (!\is_string($pattern) || '' === $pattern) { + return ''; + } + + return $this->prototypePatterns[$pattern] ??= $pattern === $this->derivedPrototypePattern() ? '' : $pattern; + } + + private function derivedPrototypePattern(): ?string + { $dateType = $this->dateFormatterPrototype->getDateType(); $timeType = $this->dateFormatterPrototype->getTimeType(); - - if (!\is_string($pattern) || '' === $pattern || false === $dateType || false === $timeType) { - return \is_string($pattern) ? $pattern : ''; + if (false === $dateType || false === $timeType) { + return null; } $locale = $this->prototypeLocale() ?: \Locale::getDefault(); @@ -566,10 +577,9 @@ final class IntlExtension extends AbstractExtension $locale .= '@calendar='.$calendar->getType(); } - $key = $locale.'|'.$dateType.'|'.$timeType; - $this->prototypeDerivedPatterns[$key] ??= (new \IntlDateFormatter($locale, $dateType, $timeType))->getPattern(); + $pattern = (new \IntlDateFormatter($locale, $dateType, $timeType))->getPattern(); - return $pattern === $this->prototypeDerivedPatterns[$key] ? '' : $pattern; + return \is_string($pattern) ? $pattern : null; } private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter diff --git a/src/Environment.php b/src/Environment.php index 1dfb90f53..2e19fdc66 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -80,7 +80,7 @@ class Environment * @var array */ private array $runtimes = []; - private string $optionsHash; + private ?string $optionsHash = null; private FactoryRuntimeLoader $defaultRuntimeLoader; private array $hotCache = []; /** @@ -159,7 +159,7 @@ class Environment public function enableDebug(): void { $this->debug = true; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -168,7 +168,7 @@ class Environment public function disableDebug(): void { $this->debug = false; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -213,7 +213,7 @@ class Environment public function enableStrictVariables(): void { $this->strictVariables = true; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -222,7 +222,7 @@ class Environment public function disableStrictVariables(): void { $this->strictVariables = false; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -302,7 +302,7 @@ class Environment */ public function getTemplateClass(string $name, ?int $index = null): string { - $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash; + $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).($this->optionsHash ??= $this->getOptionsHash()); return '__TwigTemplate_'.hash('xxh128', $key).(null === $index ? '' : '___'.$index); } @@ -648,7 +648,7 @@ class Environment public function addExtension(ExtensionInterface $extension): void { $this->extensionSet->addExtension($extension); - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -657,7 +657,7 @@ class Environment public function setExtensions(array $extensions): void { $this->extensionSet->setExtensions($extensions); - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -872,9 +872,9 @@ class Environment return $this->extensionSet->getExpressionParsers(); } - private function updateOptionsHash(): void + private function getOptionsHash(): string { - $this->optionsHash = implode(':', [ + return implode(':', [ $this->extensionSet->getSignature(), \PHP_MAJOR_VERSION, \PHP_MINOR_VERSION, diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 377311ea6..01bb22505 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1236,19 +1236,19 @@ final class CoreExtension extends AbstractExtension } /** - * Unicode version of str_split(): splits at every position except after the start and before the end. + * Unicode version of str_split(), an empty string giving a single empty character. * - * @return list + * @return non-empty-list * * @throws RuntimeError When the string cannot be split into characters */ private static function splitIntoCharacters(string $string, string $name): array { - if (false === $characters = preg_split('/(?$item) - || ($propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item))($object, $item) + || (($propertyChecker = $propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item)) && $propertyChecker($object, $item)) ) { if ($isDefinedTest) { return true; @@ -1802,7 +1802,9 @@ final class CoreExtension extends AbstractExtension return ((array) $object)[$item]; } - if (\defined($object::class.'::'.$item)) { + static $constants = []; + + if ($constants[$object::class][$item] ??= \defined($object::class.'::'.$item)) { if ($isDefinedTest) { return true; } @@ -2139,7 +2141,7 @@ final class CoreExtension extends AbstractExtension return $expr; } - private static function getPropertyChecker(string $class, string $property): \Closure + private static function getPropertyChecker(string $class, string $property): \Closure|false { static $classReflectors = []; @@ -2154,9 +2156,7 @@ final class CoreExtension extends AbstractExtension $property = $class->getProperty($property); if (!$property->isPublic() || $property->isStatic()) { - static $false; - - return $false ??= static fn () => false; + return false; } return static fn ($object) => $property->isInitialized($object); diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index 564e5a510..4b97a4c62 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -13,6 +13,7 @@ namespace Twig\Extension; 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; @@ -46,8 +47,8 @@ final class EscaperExtension extends AbstractExtension public function getFilters(): array { return [ - new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => self::escapeFilterIsSafe(...)]), - new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => self::escapeFilterIsSafe(...)]), + new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => self::escapeFilterIsSafe(...), 'node_class' => EscapeFilter::class]), + new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => self::escapeFilterIsSafe(...), 'node_class' => EscapeFilter::class]), new TwigFilter('raw', null, ['is_safe' => ['all'], 'node_class' => RawFilter::class]), ]; } diff --git a/src/MacroNamespace.php b/src/MacroNamespace.php index 855463ba6..3c28ae767 100644 --- a/src/MacroNamespace.php +++ b/src/MacroNamespace.php @@ -50,7 +50,7 @@ final class MacroNamespace return $macro->call($arguments, $source, $line); } - if (null === $macro = $this->resolve($name, $context)) { + if (null === $macro = $this->getParent($context)?->resolve($name, $context)) { throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', $name, $this->template->getTemplateName()), $line, $source); } diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index 36c47752b..5bb49177a 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -40,7 +40,7 @@ class BlockNode extends Node $compiler ->subcompile($this->getNode('body')) - ->write("yield from [];\n") + ->write("return; yield;\n") ->outdent() ->write("}\n\n") ; diff --git a/src/Node/CaptureNode.php b/src/Node/CaptureNode.php index 0ee31a8f2..1bda9dfb4 100644 --- a/src/Node/CaptureNode.php +++ b/src/Node/CaptureNode.php @@ -36,7 +36,7 @@ class CaptureNode extends Node ->raw("(function () use (&\$context, \$macros, \$blocks) {\n") ->indent() ->subcompile($this->getNode('body')) - ->write("yield from [];\n") + ->write("return; yield;\n") ->outdent() ->write('})(), false))') ; diff --git a/src/Node/Expression/Filter/EscapeFilter.php b/src/Node/Expression/Filter/EscapeFilter.php new file mode 100644 index 000000000..c4b5bd985 --- /dev/null +++ b/src/Node/Expression/Filter/EscapeFilter.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/src/Node/ForNode.php b/src/Node/ForNode.php index 2adee2d66..15ce3956f 100644 --- a/src/Node/ForNode.php +++ b/src/Node/ForNode.php @@ -84,7 +84,7 @@ class ForNode extends Node $compiler->write("\$context = array_intersect_key(\$context, \$parent) + \$parent;\n"); $compiler - ->write("yield from [];\n") + ->write("return; yield;\n") ->outdent() ->write("})(\$$iteratorVar, \$context, \$blocks, \$$functionVar, 0);\n") ; diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index de2f5cd0a..62ade50d9 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -50,6 +50,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 @@ -177,8 +178,14 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ->write("/**\n") ->write(" * @var array\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 @@ -191,6 +198,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"); @@ -355,7 +366,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface $compiler->subcompile($this->getNode('display_end')); if (!$this->hasNode('parent')) { - $compiler->write("yield from [];\n"); + $compiler->write("return; yield;\n"); } $compiler diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index 3bc218aa0..60e44d255 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -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; @@ -46,6 +47,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface * @var array */ private array $safeVars = []; + private bool $usesEscaper = false; public function __construct() { @@ -58,10 +60,10 @@ final class EscaperNodeVisitor implements NodeVisitorInterface if ($env->hasExtension(EscaperExtension::class) && $defaultStrategy = $env->getExtension(EscaperExtension::class)->getDefaultStrategy($node->getTemplateName())) { $this->defaultStrategy = $defaultStrategy; } - // the compiled template exposes the strategy it was compiled with $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) { @@ -76,10 +78,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()) { /** @var AbstractExpression $expression */ @@ -171,8 +178,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 diff --git a/src/TwigMacro.php b/src/TwigMacro.php index e7c2d8e72..c2b66918a 100644 --- a/src/TwigMacro.php +++ b/src/TwigMacro.php @@ -80,7 +80,7 @@ final class TwigMacro return ($this->body)(...$arguments); } - // The keys are then exactly the declared names. + // Every declared argument is named: none can be missing, unknown, duplicated, or misordered. if (\count($arguments) === \count($this->arguments) && !array_diff_key($arguments, $this->argumentIndexes)) { return ($this->body)(...$this->renameArguments($arguments)); } diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index 09c2d97e9..e0c995def 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -627,6 +627,44 @@ class EnvironmentTest extends TestCase FilesystemHelper::removeDir($dir); } } + + public function testTemplateClassFollowsExtensionsAddedAfterItWasComputed(): void + { + $twig = new Environment(new ArrayLoader(['index' => ''])); + $cls = $twig->getTemplateClass('index'); + + $twig->addExtension(new EnvironmentTest_Extension_WithGlobals()); + $this->assertNotSame($cls, $cls = $twig->getTemplateClass('index')); + + $twig->setExtensions([new EnvironmentTest_ExtensionWithoutRuntime()]); + $this->assertNotSame($cls, $cls = $twig->getTemplateClass('index')); + + $other = new Environment(new ArrayLoader(['index' => ''])); + $other->setExtensions([new EnvironmentTest_Extension_WithGlobals(), new EnvironmentTest_ExtensionWithoutRuntime()]); + $this->assertSame($cls, $other->getTemplateClass('index')); + } + + public function testTemplateClassFollowsDebugAndStrictVariablesChanges(): void + { + $twig = new Environment(new ArrayLoader(['index' => ''])); + $cls = $twig->getTemplateClass('index'); + + $twig->enableDebug(); + $debugCls = $twig->getTemplateClass('index'); + $this->assertNotSame($cls, $debugCls); + $this->assertSame((new Environment(new ArrayLoader(['index' => '']), ['debug' => true]))->getTemplateClass('index'), $debugCls); + + $twig->disableDebug(); + $this->assertSame($cls, $twig->getTemplateClass('index')); + + $twig->enableStrictVariables(); + $strictCls = $twig->getTemplateClass('index'); + $this->assertNotSame($cls, $strictCls); + $this->assertSame((new Environment(new ArrayLoader(['index' => '']), ['strict_variables' => true]))->getTemplateClass('index'), $strictCls); + + $twig->disableStrictVariables(); + $this->assertSame($cls, $twig->getTemplateClass('index')); + } } class EnvironmentTest_Extension_WithGlobals extends AbstractExtension diff --git a/tests/Fixtures/filters/characters_trailing_newline.test b/tests/Fixtures/filters/characters_trailing_newline.test new file mode 100644 index 000000000..9846fe325 --- /dev/null +++ b/tests/Fixtures/filters/characters_trailing_newline.test @@ -0,0 +1,14 @@ +--TEST-- +"reverse", "split", "shuffle", and "random" keep a trailing newline as its own character +--TEMPLATE-- +{{ value|reverse|json_encode|raw }} +{{ value|split('')|json_encode|raw }} +{{ value|shuffle|length }} +{{ random("a\n")|length }} +--DATA-- +return ['value' => "ab\n"] +--EXPECT-- +"\nba" +["a","b","\n"] +3 +1 diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index f54aaec03..dd973545e 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -50,7 +50,7 @@ public function block_foo(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; yield "foo"; - yield from []; + return; yield; } EOF, new Environment(new ArrayLoader()), ]; @@ -63,7 +63,7 @@ EOF, new Environment(new ArrayLoader()), public function block_foo(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; - yield from []; + return; yield; } EOF, new Environment(new ArrayLoader()), ]; diff --git a/tests/Node/Expression/Filter/EscapeTest.php b/tests/Node/Expression/Filter/EscapeTest.php new file mode 100644 index 000000000..a3bb28ddc --- /dev/null +++ b/tests/Node/Expression/Filter/EscapeTest.php @@ -0,0 +1,35 @@ +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]; + } +} diff --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index 8bf974d59..3c6147aba 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -86,7 +86,7 @@ yield from (\$_v1 = function (\$iterator, &\$context, \$blocks, \$recurseFunc, \ } unset(\$context['key'], \$context['item']); \$context = array_intersect_key(\$context, \$parent) + \$parent; - yield from []; + return; yield; })(\$_v0, \$context, \$blocks, \$_v1, 0); EOF ]; @@ -111,7 +111,7 @@ yield from (\$_v1 = function (\$iterator, &\$context, \$blocks, \$recurseFunc, \ } unset(\$context['k'], \$context['v'], \$context['loop']); \$context = array_intersect_key(\$context, \$parent) + \$parent; - yield from []; + return; yield; })(\$_v0, \$context, \$blocks, \$_v1, 0); EOF ]; @@ -136,7 +136,7 @@ yield from (\$_v1 = function (\$iterator, &\$context, \$blocks, \$recurseFunc, \ } unset(\$context['k'], \$context['v'], \$context['loop']); \$context = array_intersect_key(\$context, \$parent) + \$parent; - yield from []; + return; yield; })(\$_v0, \$context, \$blocks, \$_v1, 0); EOF ]; @@ -169,7 +169,7 @@ yield from (\$_v1 = function (\$iterator, &\$context, \$blocks, \$recurseFunc, \ } unset(\$context['k'], \$context['v'], \$context['loop']); \$context = array_intersect_key(\$context, \$parent) + \$parent; - yield from []; + return; yield; })(\$_v0, \$context, \$blocks, \$_v1, 0); EOF ]; @@ -190,7 +190,7 @@ yield from (\$_v1 = function (\$iterator, &\$context, \$blocks, \$recurseFunc, \ } unset(\$context['_key'], \$context['item'], \$context['loop']); \$context = array_intersect_key(\$context, \$parent) + \$parent; - yield from []; + return; yield; })(\$_v0, \$context, \$blocks, \$_v1, 0); EOF, $env]; diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php index 1dc8974b2..cab0f2bee 100644 --- a/tests/Node/MacroTest.php +++ b/tests/Node/MacroTest.php @@ -76,7 +76,7 @@ new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscor return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); }, ["foo" => true, "bar" => true, "_underscore" => true], false) EOF, new Environment(new ArrayLoader()), diff --git a/tests/Node/MacrosTest.php b/tests/Node/MacrosTest.php index 24a166b24..edb1fc9ed 100644 --- a/tests/Node/MacrosTest.php +++ b/tests/Node/MacrosTest.php @@ -73,7 +73,7 @@ protected function loadDeclaredMacros(): array return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); }, ["foo" => true], false), ]; diff --git a/tests/Node/ModuleTest.php b/tests/Node/ModuleTest.php index 4f038d08e..6c1d9bc41 100644 --- a/tests/Node/ModuleTest.php +++ b/tests/Node/ModuleTest.php @@ -140,7 +140,7 @@ class __TwigTemplate_%x extends Template \$macros = \$this->macros; // line 1 yield "foo"; - yield from []; + return; yield; } /** diff --git a/tests/Node/SetTest.php b/tests/Node/SetTest.php index 77ee1aa85..676e50636 100644 --- a/tests/Node/SetTest.php +++ b/tests/Node/SetTest.php @@ -65,7 +65,7 @@ EOF // line 1 \$context["foo"] = ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); EOF, new Environment(new ArrayLoader()), ]; diff --git a/tests/NodeVisitor/EscaperTest.php b/tests/NodeVisitor/EscaperTest.php new file mode 100644 index 000000000..35e9d071c --- /dev/null +++ b/tests/NodeVisitor/EscaperTest.php @@ -0,0 +1,83 @@ + $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' => '
'])); + } + + public static function provideTemplates(): iterable + { + yield 'autoescaped print' => ['html', '{{ foo }}', 1, '<br>']; + yield 'print marked as safe' => ['html', '{{ foo|raw }}', 0, '
']; + yield 'autoescaping disabled' => [false, '{{ foo }}{# not autoescaped #}', 0, '
']; + yield 'explicit escape filter' => [false, '{{ foo|e }}', 1, '<br>']; + yield 'autoescape tag' => [false, '{% autoescape "html" %}{{ foo }}{% endautoescape %}', 1, '<br>']; + yield 'escaping in an embedded template only' => [false, '{% embed "embedded" %}{% block content %}{{ foo|e }}{% endblock %}{% endembed %}', 1, '<br>']; + } + + public function testEscapeFilterAddedByALaterVisitorStillEscapes(): void + { + $env = new Environment(new ArrayLoader(['index' => '{{ "
" }}']), ['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('<br>', $env->render('index')); + } +} diff --git a/tests/NodeVisitor/SafeAnalysisTest.php b/tests/NodeVisitor/SafeAnalysisTest.php index 9dbf539dd..b9c2ba976 100644 --- a/tests/NodeVisitor/SafeAnalysisTest.php +++ b/tests/NodeVisitor/SafeAnalysisTest.php @@ -19,7 +19,7 @@ use Twig\Source; class SafeAnalysisTest extends TestCase { - public function testCompilingDoesNotRetainTheAst() + public function testCompilingDoesNotRetainTheAst(): void { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => 'html']); $source = new Source('{{ "a"|upper }}{{ foo.bar }}{{ [1, 2]|join("-") }}{{ b ? "y" : "n" }}', 'index'); diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index 1f7fc6f58..bdcd06ea5 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -698,6 +698,34 @@ class TemplateTest extends TestCase // 0 should be in the method cache now, so this should fail $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 0)); } + + public function testRepeatedObjectAttributeLookupsResolveConsistently(): void + { + $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); + $source = new Source('', 'index.twig'); + $object = new TemplateAttributeResolution(); + + for ($i = 0; $i < 2; ++$i) { + $this->assertSame('constant', CoreExtension::getAttribute($twig, $source, $object, 'NAME')); + $this->assertTrue(CoreExtension::getAttribute($twig, $source, $object, 'NAME', [], Template::ANY_CALL, true)); + $this->assertSame('getter', CoreExtension::getAttribute($twig, $source, $object, 'hidden')); + $this->assertTrue(CoreExtension::getAttribute($twig, $source, $object, 'hidden', [], Template::ANY_CALL, true)); + $this->assertFalse(CoreExtension::getAttribute($twig, $source, $object, 'secret', [], Template::ANY_CALL, true)); + } + } +} + +class TemplateAttributeResolution +{ + public const NAME = 'constant'; + + private $hidden = 'property'; + private $secret = 'property'; + + public function getHidden() + { + return 'getter'; + } } class TemplateForTest extends Template