Allow skipping the directory check when adding paths to FilesystemLoader

This commit is contained in:
Nicolas Grekas
2026-09-25 15:19:42 +02:00
parent c72c875b91
commit 9e1e2e2cea
6 changed files with 112 additions and 9 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
# 3.30.1 (2026-XX-XX)
# 3.31.0 (2026-XX-XX)
* Fix the `loop` variable being undefined when used in the sequence of a nested `for` tag
* Reject a deprecated `Template` instance created by another environment in `Environment::resolveTemplate()`
@@ -8,6 +8,7 @@
* Fix the sandbox not reporting the line of a rejected `guard` tag
* Fix the spread operator compiling to invalid PHP outside sequences, mappings, and call arguments
* Fix the `deprecated` tag generating invalid PHP for an integer message
* Add a third argument to `FilesystemLoader::addPath()` and `FilesystemLoader::prependPath()` to skip checking that the directory exists
# 3.30.0 (2026-09-25)
+12
View File
@@ -296,6 +296,18 @@ Namespaced templates can be accessed via the special
$twig->render('@admin/index.html.twig', []);
``addPath()`` and ``prependPath()`` throw an exception when the directory does
not exist. If you already know that it does, for instance because a build step
checked it, pass ``false`` as the third argument to skip the check and save a
filesystem call::
$loader->addPath($templateDir, 'admin', false);
.. versionadded:: 3.31
The third argument of ``addPath()`` and ``prependPath()`` was added in
Twig 3.31.
``\Twig\Loader\FilesystemLoader`` supports absolute and relative paths. Using relative
paths is preferred as it makes the cache keys independent of the project root
directory (for instance, it allows warming the cache from a build server where
+7
View File
@@ -472,6 +472,13 @@ Testing Utilities
* The data providers ``getTests()`` and ``getLegacyTests()`` on
``Twig\Test\IntegrationTestCase`` are considered final as of Twig 3.13.
Loaders
-------
* Overriding ``Twig\Loader\FilesystemLoader::addPath()`` or ``prependPath()``
without declaring their ``bool $check = true`` third argument is deprecated as
of Twig 3.31; the argument will be part of their signature in Twig 4.0.
Environment
-----------
+6
View File
@@ -7,6 +7,12 @@ parameters:
path: src/Sandbox/SecurityPolicyInterface.php
- # The "$check" parameter is documented but not declared, as overrides of these methods with 2 parameters would break
message: '#^PHPDoc tag @param references unknown parameter\: \$check$#'
identifier: parameter.notFound
count: 2
path: src/Loader/FilesystemLoader.php
- # The "$tests" argument will be part of the signature in 4.0; 3-parameter implementations are detected and called with 3 arguments
message: '#^Method Twig\\Sandbox\\SecurityPolicyInterface\:\:checkSecurity\(\) invoked with 4 parameters, 3 required\.$#'
identifier: arguments.count
+16 -8
View File
@@ -87,32 +87,40 @@ class FilesystemLoader implements LoaderInterface
}
/**
* @param bool $check Whether to check that the directory exists; pass false when the caller already checked it
*
* @throws LoaderError
*/
public function addPath(string $path, string $namespace = self::MAIN_NAMESPACE): void
public function addPath(string $path, string $namespace = self::MAIN_NAMESPACE/* , bool $check = true */): void
{
// invalidate the cache
$this->cache = $this->errorCache = [];
$checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path;
if (!is_dir($checkPath)) {
throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath));
if (\func_num_args() < 3 || func_get_arg(2)) {
$checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path;
if (!is_dir($checkPath)) {
throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath));
}
}
$this->paths[$namespace][] = rtrim($path, '/\\');
}
/**
* @param bool $check Whether to check that the directory exists; pass false when the caller already checked it
*
* @throws LoaderError
*/
public function prependPath(string $path, string $namespace = self::MAIN_NAMESPACE): void
public function prependPath(string $path, string $namespace = self::MAIN_NAMESPACE/* , bool $check = true */): void
{
// invalidate the cache
$this->cache = $this->errorCache = [];
$checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path;
if (!is_dir($checkPath)) {
throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath));
if (\func_num_args() < 3 || func_get_arg(2)) {
$checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path;
if (!is_dir($checkPath)) {
throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath));
}
}
$path = rtrim($path, '/\\');
+69
View File
@@ -163,6 +163,75 @@ class FilesystemTest extends TestCase
$this->assertEquals([FilesystemLoader::MAIN_NAMESPACE, 'named'], $loader->getNamespaces());
}
/**
* @dataProvider getPathsThatAreNotDirectories
*/
#[DataProvider('getPathsThatAreNotDirectories')]
public function testAddingAPathThatIsNotADirectoryThrows(string $method, string $path, array $extraArgs): void
{
$loader = new FilesystemLoader([], __DIR__);
try {
$loader->$method($path, 'named', ...$extraArgs);
$this->fail('A LoaderError should have been thrown.');
} catch (LoaderError $e) {
$this->assertSame(\sprintf('The "%s" directory does not exist ("%s").', $path, realpath(__DIR__).\DIRECTORY_SEPARATOR.$path), $e->getMessage());
}
$this->assertSame([], $loader->getPaths('named'));
}
public static function getPathsThatAreNotDirectories(): iterable
{
foreach (['addPath', 'prependPath'] as $method) {
foreach (['Fixtures/missing', 'Fixtures/normal/index.html'] as $path) {
yield [$method, $path, []];
yield [$method, $path, [true]];
}
}
}
/**
* @dataProvider getPathMethods
*/
#[DataProvider('getPathMethods')]
public function testAddingAPathWithoutCheckingItAcceptsAMissingDirectory(string $method): void
{
$loader = new FilesystemLoader([], __DIR__);
$loader->addPath('Fixtures/named', 'named', false);
$loader->$method('Fixtures/missing/', 'named', false);
$expected = 'addPath' === $method ? ['Fixtures/named', 'Fixtures/missing'] : ['Fixtures/missing', 'Fixtures/named'];
$this->assertSame($expected, $loader->getPaths('named'));
$this->assertSame("named path\n", $loader->getSourceContext('@named/index.html')->getCode());
$this->expectException(LoaderError::class);
$this->expectExceptionMessage(\sprintf('Unable to find template "@named/nowhere.html" (looked into: %s).', implode(', ', $expected)));
$loader->getSourceContext('@named/nowhere.html');
}
/**
* @dataProvider getPathMethods
*/
#[DataProvider('getPathMethods')]
public function testAddingAPathWithoutCheckingItInvalidatesTheCache(string $method): void
{
$loader = new FilesystemLoader([], __DIR__);
$loader->addPath('Fixtures/normal', 'named');
$this->assertFalse($loader->exists('@named/named_absolute.html'));
$loader->$method('Fixtures/named_quater', 'named', false);
$this->assertSame("named path (quater)\n", $loader->getSourceContext('@named/named_absolute.html')->getCode());
}
public static function getPathMethods(): iterable
{
yield ['addPath'];
yield ['prependPath'];
}
public function testFindTemplateExceptionNamespace(): void
{
$basePath = __DIR__.'/Fixtures';