Files
Twig/tests/EnvironmentTest.php
T
Fabien Potencier 6032fd320c Merge branch '3.x' into 4.x
* 3.x:
  Fix version
  Fix CS
  Remove obsolete code
  Use generics in ExpressionParsers
  Fix precedence rules
  Add deprecation notices in CHANGELOG and docs
  Move Operators to ExpressionParsers, deprecate ExpressionParser
  Add a script to update operator precedence documentation
  Extract operators logic from ExpressionParser to their own classes
  Introduce operator classes to describe operators provided by extensions instead of arrays
  Bump version to 3.21
2025-02-14 09:08:33 +01:00

668 lines
22 KiB
PHP

<?php
namespace Twig\Tests;
/*
* 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.
*/
use PHPUnit\Framework\TestCase;
use Twig\Cache\CacheInterface;
use Twig\Cache\FilesystemCache;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser;
use Twig\ExpressionParser\InfixExpressionParserInterface;
use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
use Twig\ExpressionParser\PrefixExpressionParserInterface;
use Twig\Extension\AbstractExtension;
use Twig\Extension\ExtensionInterface;
use Twig\Extension\GlobalsInterface;
use Twig\Loader\ArrayLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Loader\LoaderInterface;
use Twig\Node\Node;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\RuntimeLoader\FactoryRuntimeLoader;
use Twig\Source;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
use Twig\TokenParser\TokenParserInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
class EnvironmentTest extends TestCase
{
public function testVersionConstants()
{
$version = Environment::VERSION;
$exploded = explode('-', $version);
$this->assertEquals(Environment::EXTRA_VERSION, $exploded[1] ?? '');
$version = $exploded[0];
$exploded = explode('.', $version);
$this->assertEquals(Environment::MAJOR_VERSION, $exploded[0]);
$this->assertEquals(Environment::MINOR_VERSION, $exploded[1]);
$this->assertEquals(Environment::RELEASE_VERSION, $exploded[2]);
$this->assertEquals(Environment::VERSION_ID, Environment::MAJOR_VERSION * 10000 + Environment::MINOR_VERSION * 100 + Environment::RELEASE_VERSION);
}
public function testAutoescapeOption()
{
$loader = new ArrayLoader([
'html' => '{{ foo }} {{ foo }}',
'js' => '{{ bar }} {{ bar }}',
]);
$twig = new Environment($loader, [
'debug' => true,
'cache' => false,
'autoescape' => [$this, 'escapingStrategyCallback'],
]);
$this->assertEquals('foo&lt;br/ &gt; foo&lt;br/ &gt;', $twig->render('html', ['foo' => 'foo<br/ >']));
$this->assertEquals('foo\u003Cbr\/\u0020\u003E foo\u003Cbr\/\u0020\u003E', $twig->render('js', ['bar' => 'foo<br/ >']));
}
public function escapingStrategyCallback($name)
{
return $name;
}
public function testGlobals()
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->any())->method('getSourceContext')->willReturn(new Source('', ''));
$getGlobals = new \ReflectionMethod(Environment::class, 'getGlobals');
// globals can be added after calling getGlobals
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->addGlobal('foo', 'bar');
$globals = $getGlobals->invoke($twig);
$this->assertEquals('bar', $globals['foo']);
// globals can be modified after a template has been loaded
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->load('index');
$twig->addGlobal('foo', 'bar');
$globals = $getGlobals->invoke($twig);
$this->assertEquals('bar', $globals['foo']);
// globals can be modified after extensions init
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->getFunctions();
$twig->addGlobal('foo', 'bar');
$globals = $getGlobals->invoke($twig);
$this->assertEquals('bar', $globals['foo']);
// globals can be modified after extensions and a template has been loaded
$arrayLoader = new ArrayLoader(['index' => '{{foo}}']);
$twig = new Environment($arrayLoader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->getFunctions();
$twig->load('index');
$twig->addGlobal('foo', 'bar');
$globals = $getGlobals->invoke($twig);
$this->assertEquals('bar', $globals['foo']);
$twig = new Environment($arrayLoader);
$getGlobals->invoke($twig);
$twig->addGlobal('foo', 'bar');
$template = $twig->load('index');
$this->assertEquals('bar', $template->render([]));
// globals cannot be added after a template has been loaded
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->load('index');
try {
$twig->addGlobal('bar', 'bar');
$this->fail();
} catch (\LogicException $e) {
$this->assertArrayNotHasKey('bar', $getGlobals->invoke($twig));
}
// globals cannot be added after extensions init
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->getFunctions();
try {
$twig->addGlobal('bar', 'bar');
$this->fail();
} catch (\LogicException $e) {
$this->assertArrayNotHasKey('bar', $getGlobals->invoke($twig));
}
// globals cannot be added after extensions and a template has been loaded
$twig = new Environment($loader);
$twig->addGlobal('foo', 'foo');
$getGlobals->invoke($twig);
$twig->getFunctions();
$twig->load('index');
try {
$twig->addGlobal('bar', 'bar');
$this->fail();
} catch (\LogicException $e) {
$this->assertArrayNotHasKey('bar', $getGlobals->invoke($twig));
}
// test adding globals after a template has been loaded without call to getGlobals
$twig = new Environment($loader);
$twig->load('index');
try {
$twig->addGlobal('bar', 'bar');
$this->fail();
} catch (\LogicException $e) {
$this->assertArrayNotHasKey('bar', $getGlobals->invoke($twig));
}
}
public function testExtensionsAreNotInitializedWhenRenderingACompiledTemplate()
{
$cache = new FilesystemCache($dir = sys_get_temp_dir().'/twig');
$options = ['cache' => $cache, 'auto_reload' => false, 'debug' => false];
// force compilation
$twig = new Environment($loader = new ArrayLoader(['index' => '{{ foo }}']), $options);
$twig->addExtension($extension = new class extends AbstractExtension {
public bool $throw = false;
public function getFilters(): array
{
if ($this->throw) {
throw new \RuntimeException('Extension are not supposed to be initialized.');
}
return parent::getFilters();
}
});
$key = $cache->generateKey('index', $twig->getTemplateClass('index'));
$cache->write($key, $twig->compileSource(new Source('{{ foo }}', 'index')));
// check that extensions won't be initialized when rendering a template that is already in the cache
$twig = new Environment($loader, $options);
$extension->throw = true;
$twig->addExtension($extension);
// render template
$output = $twig->render('index', ['foo' => 'bar']);
$this->assertEquals('bar', $output);
FilesystemHelper::removeDir($dir);
}
public function testAutoReloadCacheMiss()
{
$templateName = __FUNCTION__;
$templateContent = __FUNCTION__;
$cache = $this->createMock(CacheInterface::class);
$loader = $this->getMockLoader($templateName, $templateContent);
$twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]);
// Cache miss: getTimestamp returns 0 and as a result the load() is
// skipped.
$cache->expects($this->once())
->method('generateKey')
->willReturn('key');
$cache->expects($this->once())
->method('getTimestamp')
->willReturn(0);
$loader->expects($this->never())
->method('isFresh');
$cache->expects($this->once())
->method('write');
$cache->expects($this->once())
->method('load');
$twig->load($templateName);
}
public function testAutoReloadCacheHit()
{
$templateName = __FUNCTION__;
$templateContent = __FUNCTION__;
$cache = $this->createMock(CacheInterface::class);
$loader = $this->getMockLoader($templateName, $templateContent);
$twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]);
$now = time();
// Cache hit: getTimestamp returns something > extension timestamps and
// the loader returns true for isFresh().
$cache->expects($this->once())
->method('generateKey')
->willReturn('key');
$cache->expects($this->once())
->method('getTimestamp')
->willReturn($now);
$loader->expects($this->once())
->method('isFresh')
->willReturn(true);
$cache->expects($this->atLeastOnce())
->method('load');
$twig->load($templateName);
}
public function testAutoReloadOutdatedCacheHit()
{
$templateName = __FUNCTION__;
$templateContent = __FUNCTION__;
$cache = $this->createMock(CacheInterface::class);
$loader = $this->getMockLoader($templateName, $templateContent);
$twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]);
$now = time();
$cache->expects($this->once())
->method('generateKey')
->willReturn('key');
$cache->expects($this->once())
->method('getTimestamp')
->willReturn($now);
$loader->expects($this->once())
->method('isFresh')
->willReturn(false);
$cache->expects($this->once())
->method('write');
$cache->expects($this->once())
->method('load');
$twig->load($templateName);
}
public function testHasGetExtensionByClassName()
{
$twig = new Environment(new ArrayLoader());
$twig->addExtension($ext = new EnvironmentTest_Extension());
$this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class));
$this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class));
}
public function testAddExtension()
{
$twig = new Environment(new ArrayLoader());
$twig->addExtension(new EnvironmentTest_Extension());
$getGlobals = new \ReflectionMethod(Environment::class, 'getGlobals');
$this->assertArrayHasKey('test', $twig->getTokenParsers());
$this->assertArrayHasKey('foo_filter', $twig->getFilters());
$this->assertArrayHasKey('foo_function', $twig->getFunctions());
$this->assertArrayHasKey('foo_test', $twig->getTests());
$this->assertNotNull($twig->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'foo_unary'));
$this->assertNotNull($twig->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'foo_binary'));
$this->assertArrayHasKey('foo_global', $getGlobals->invoke($twig));
$visitors = $twig->getNodeVisitors();
$found = false;
foreach ($visitors as $visitor) {
if ($visitor instanceof EnvironmentTest_NodeVisitor) {
$found = true;
}
}
$this->assertTrue($found);
}
public function testAddMockExtension()
{
$extension = $this->createMock(ExtensionInterface::class);
$loader = new ArrayLoader(['page' => 'hey']);
$twig = new Environment($loader);
$twig->addExtension($extension);
$this->assertInstanceOf(ExtensionInterface::class, $twig->getExtension($extension::class));
$this->assertTrue($twig->isTemplateFresh('page', time()));
}
public function testOverrideExtension()
{
$twig = new Environment(new ArrayLoader());
$twig->addExtension(new EnvironmentTest_Extension());
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Unable to register extension "Twig\Tests\EnvironmentTest_Extension" as it is already registered.');
$twig->addExtension(new EnvironmentTest_Extension());
}
public function testAddRuntimeLoader()
{
$runtimeLoader = new FactoryRuntimeLoader([
EnvironmentTest_Runtime::class => function () { return new EnvironmentTest_Runtime(); },
]);
$loader = new ArrayLoader([
'func_array' => '{{ from_runtime_array("foo") }}',
'func_array_default' => '{{ from_runtime_array() }}',
'func_array_named_args' => '{{ from_runtime_array(name="foo") }}',
'func_string' => '{{ from_runtime_string("foo") }}',
'func_string_default' => '{{ from_runtime_string() }}',
'func_string_named_args' => '{{ from_runtime_string(name="foo") }}',
]);
$twig = new Environment($loader, ['autoescape' => false]);
$twig->addExtension(new EnvironmentTest_ExtensionWithoutRuntime());
$twig->addRuntimeLoader($runtimeLoader);
$this->assertEquals('foo', $twig->render('func_array'));
$this->assertEquals('bar', $twig->render('func_array_default'));
$this->assertEquals('foo', $twig->render('func_array_named_args'));
$this->assertEquals('foo', $twig->render('func_string'));
$this->assertEquals('bar', $twig->render('func_string_default'));
$this->assertEquals('foo', $twig->render('func_string_named_args'));
}
public function testFailLoadTemplate()
{
$template = 'testFailLoadTemplate.twig';
$twig = new Environment(new ArrayLoader([$template => false]));
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Failed to load Twig template "testFailLoadTemplate.twig", index "112233": cache might be corrupted in "testFailLoadTemplate.twig".');
$twig->loadTemplate($twig->getTemplateClass($template), $template, 112233);
}
public function testUndefinedFunctionCallback()
{
$twig = new Environment(new ArrayLoader());
$twig->registerUndefinedFunctionCallback(function (string $name) {
if ('dynamic' === $name) {
return new TwigFunction('dynamic', function () { return 'dynamic'; });
}
return false;
});
$this->assertNull($twig->getFunction('does_not_exist'));
$this->assertInstanceOf(TwigFunction::class, $function = $twig->getFunction('dynamic'));
$this->assertSame('dynamic', $function->getName());
}
public function testUndefinedFilterCallback()
{
$twig = new Environment(new ArrayLoader());
$twig->registerUndefinedFilterCallback(function (string $name) {
if ('dynamic' === $name) {
return new TwigFilter('dynamic', function () { return 'dynamic'; });
}
return false;
});
$this->assertNull($twig->getFilter('does_not_exist'));
$this->assertInstanceOf(TwigFilter::class, $filter = $twig->getFilter('dynamic'));
$this->assertSame('dynamic', $filter->getName());
}
public function testUndefinedTokenParserCallback()
{
$twig = new Environment(new ArrayLoader());
$twig->registerUndefinedTokenParserCallback(function (string $name) {
if ('dynamic' === $name) {
$parser = $this->createMock(TokenParserInterface::class);
$parser->expects($this->once())->method('getTag')->willReturn('dynamic');
return $parser;
}
return false;
});
$this->assertNull($twig->getTokenParser('does_not_exist'));
$this->assertInstanceOf(TokenParserInterface::class, $parser = $twig->getTokenParser('dynamic'));
$this->assertSame('dynamic', $parser->getTag());
}
public function testLegacyEchoingNode()
{
$loader = new ArrayLoader(['echo_bar' => 'A{% set v %}B{% test %}C{% endset %}D{% test %}E{{ v }}F{% set w %}{% test %}{% endset %}G{{ w }}H']);
$twig = new Environment($loader);
$twig->addExtension(new EnvironmentTest_Extension());
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('An exception has been thrown during the compilation of a template ("Using "echo" is not supported; use "yield" instead in "Twig\Tests\EnvironmentTest_LegacyEchoingNode".") in "echo_bar".');
$this->assertSame('ADbarEBbarCFGbarH', $twig->render('echo_bar'));
}
protected function getMockLoader($templateName, $templateContent)
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->any())
->method('getSourceContext')
->with($templateName)
->willReturn(new Source($templateContent, $templateName));
$loader->expects($this->any())
->method('getCacheKey')
->with($templateName)
->willReturn($templateName);
return $loader;
}
public function testResettingGlobals()
{
$twig = new Environment(new ArrayLoader(['index' => '']));
$twig->addExtension(new class extends AbstractExtension implements GlobalsInterface {
public function getGlobals(): array
{
return [
'global_ext' => bin2hex(random_bytes(16)),
];
}
});
// Force extensions initialization
$twig->load('index');
// Simulate request
$g1 = $twig->getGlobals();
// Simulate another call from request 1 (the globals are cached)
$g2 = $twig->getGlobals();
$this->assertSame($g1['global_ext'], $g2['global_ext']);
// Simulate request 2
$twig->resetGlobals();
$g3 = $twig->getGlobals();
$this->assertNotSame($g3['global_ext'], $g2['global_ext']);
}
public function testHotCache()
{
$dir = sys_get_temp_dir().'/twig-hot-cache-test';
if (is_dir($dir)) {
FilesystemHelper::removeDir($dir);
}
mkdir($dir);
file_put_contents($dir.'/index.twig', 'x');
try {
$twig = new Environment(new FilesystemLoader($dir), [
'debug' => false,
'auto_reload' => false,
'cache' => $dir.'/cache',
]);
// prime the cache
$this->assertSame('x', $twig->load('index.twig')->render([]));
// update the template
file_put_contents($dir.'/index.twig', 'y');
// re-render, should use the cached version
$this->assertSame('x', $twig->load('index.twig')->render([]));
// clear the cache
$twig->removeCache('index.twig');
// re-render, should use the updated template
$this->assertSame('y', $twig->load('index.twig')->render([]));
// the new template should not be cached
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir.'/cache', \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
$count = 0;
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isDir()) {
++$count;
}
}
$this->assertSame(0, $count);
// re-render, should use the updated template
$this->assertSame('y', $twig->load('index.twig')->render([]));
} finally {
FilesystemHelper::removeDir($dir);
}
}
}
class EnvironmentTest_Extension_WithGlobals extends AbstractExtension
{
public function getGlobals()
{
return [
'foo_global' => 'foo_global',
];
}
}
class EnvironmentTest_Extension extends AbstractExtension implements GlobalsInterface
{
public function getTokenParsers(): array
{
return [
new EnvironmentTest_TokenParser(),
];
}
public function getNodeVisitors(): array
{
return [
new EnvironmentTest_NodeVisitor(),
];
}
public function getFilters(): array
{
return [
new TwigFilter('foo_filter'),
];
}
public function getTests(): array
{
return [
new TwigTest('foo_test'),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('foo_function'),
];
}
public function getExpressionParsers(): array
{
return [
new UnaryOperatorExpressionParser('', 'foo_unary', 0),
new BinaryOperatorExpressionParser('', 'foo_binary', 0),
];
}
public function getGlobals(): array
{
return [
'foo_global' => 'foo_global',
];
}
}
class EnvironmentTest_TokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
return new EnvironmentTest_LegacyEchoingNode([], [], 1);
}
public function getTag(): string
{
return 'test';
}
}
class EnvironmentTest_NodeVisitor implements NodeVisitorInterface
{
public function enterNode(Node $node, Environment $env): Node
{
return $node;
}
public function leaveNode(Node $node, Environment $env): ?Node
{
return $node;
}
public function getPriority(): int
{
return 0;
}
}
class EnvironmentTest_ExtensionWithoutRuntime extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('from_runtime_array', ['Twig\Tests\EnvironmentTest_Runtime', 'fromRuntime']),
new TwigFunction('from_runtime_string', 'Twig\Tests\EnvironmentTest_Runtime::fromRuntime'),
];
}
}
class EnvironmentTest_Runtime
{
public function fromRuntime($name = 'bar')
{
return $name;
}
}
class EnvironmentTest_LegacyEchoingNode extends Node
{
public function compile($compiler): void
{
$compiler
->addDebugInfo($this)
->write('echo "bar";')
;
}
}