Skip to content
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

[Finder] Add early directory prunning filter support #50877

Merged
merged 1 commit into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Symfony/Component/Finder/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

6.4
---

* Add early directory prunning to `Finder::filter()`

6.2
---

Expand Down
15 changes: 14 additions & 1 deletion src/Symfony/Component/Finder/Finder.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Finder implements \IteratorAggregate, \Countable
private array $notNames = [];
private array $exclude = [];
private array $filters = [];
private array $pruneFilters = [];
private array $depths = [];
private array $sizes = [];
private bool $followLinks = false;
Expand Down Expand Up @@ -580,14 +581,22 @@ public function sortByModifiedTime(): static
* The anonymous function receives a \SplFileInfo and must return false
* to remove files.
*
* @param \Closure(SplFileInfo): bool $closure
* @param bool $prune Whether to skip traversing directories further
*
* @return $this
*
* @see CustomFilterIterator
*/
public function filter(\Closure $closure): static
public function filter(\Closure $closure /* , bool $prune = false */): static
{
$prune = 1 < \func_num_args() ? func_get_arg(1) : false;
$this->filters[] = $closure;

if ($prune) {
$this->pruneFilters[] = $closure;
}

return $this;
}

Expand Down Expand Up @@ -741,6 +750,10 @@ private function searchInDirectory(string $dir): \Iterator
$exclude = $this->exclude;
$notPaths = $this->notPaths;

if ($this->pruneFilters) {
$exclude = array_merge($exclude, $this->pruneFilters);
}

if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) {
$exclude = array_merge($exclude, self::$vcsPatterns);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,32 @@ class ExcludeDirectoryFilterIterator extends \FilterIterator implements \Recursi
/** @var \Iterator<string, SplFileInfo> */
private \Iterator $iterator;
private bool $isRecursive;
/** @var array<string, true> */
private array $excludedDirs = [];
private ?string $excludedPattern = null;
/** @var list<callable(SplFileInfo):bool> */
private array $pruneFilters = [];

/**
* @param \Iterator<string, SplFileInfo> $iterator The Iterator to filter
* @param string[] $directories An array of directories to exclude
* @param \Iterator<string, SplFileInfo> $iterator The Iterator to filter
* @param list<string|callable(SplFileInfo):bool> $directories An array of directories to exclude
*/
public function __construct(\Iterator $iterator, array $directories)
{
$this->iterator = $iterator;
$this->isRecursive = $iterator instanceof \RecursiveIterator;
$patterns = [];
foreach ($directories as $directory) {
if (!\is_string($directory)) {
if (!\is_callable($directory)) {
throw new \InvalidArgumentException('Invalid PHP callback.');
}

$this->pruneFilters[] = $directory;

continue;
}

$directory = rtrim($directory, '/');
if (!$this->isRecursive || str_contains($directory, '/')) {
$patterns[] = preg_quote($directory, '#');
Expand Down Expand Up @@ -70,6 +83,14 @@ public function accept(): bool
return !preg_match($this->excludedPattern, $path);
}

if ($this->pruneFilters && $this->hasChildren()) {
foreach ($this->pruneFilters as $pruneFilter) {
if (!$pruneFilter($this->current())) {
return false;
}
}
}

return true;
}

Expand Down
68 changes: 68 additions & 0 deletions src/Symfony/Component/Finder/Tests/FinderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

class FinderTest extends Iterator\RealIteratorTestCase
{
use Iterator\VfsIteratorTestTrait;

public function testCreate()
{
$this->assertInstanceOf(Finder::class, Finder::create());
Expand Down Expand Up @@ -989,6 +991,72 @@ public function testFilter()
$this->assertIterator($this->toAbsolute(['test.php', 'test.py']), $finder->in(self::$tmpDir)->getIterator());
}

public function testFilterPrune()
{
$this->setupVfsProvider([
'x' => [
'a.php' => '',
'b.php' => '',
'd' => [
'u.php' => '',
],
'x' => [
'd' => [
'u2.php' => '',
],
],
],
'y' => [
'c.php' => '',
],
]);

$finder = $this->buildFinder();
$finder
->in($this->vfsScheme.'://x')
->filter(fn (): bool => true, true) // does nothing
->filter(function (\SplFileInfo $file): bool {
$path = $this->stripSchemeFromVfsPath($file->getPathname());

$res = 'x/d' !== $path;

$this->vfsLog[] = [$path, 'exclude_filter', $res];

return $res;
}, true)
->filter(fn (): bool => true, true); // does nothing

$this->assertSameVfsIterator([
'x/a.php',
'x/b.php',
'x/x',
'x/x/d',
'x/x/d/u2.php',
], $finder->getIterator());

// "x/d" directory must be pruned early
// "x/x/d" directory must not be pruned
$this->assertSame([
['x', 'is_dir', true],
['x', 'list_dir_open', ['a.php', 'b.php', 'd', 'x']],
['x/a.php', 'is_dir', false],
['x/a.php', 'exclude_filter', true],
['x/b.php', 'is_dir', false],
['x/b.php', 'exclude_filter', true],
['x/d', 'is_dir', true],
['x/d', 'exclude_filter', false],
['x/x', 'is_dir', true],
['x/x', 'exclude_filter', true], // from ExcludeDirectoryFilterIterator::accept() (prune directory filter)
['x/x', 'exclude_filter', true], // from CustomFilterIterator::accept() (regular filter)
['x/x', 'list_dir_open', ['d']],
['x/x/d', 'is_dir', true],
['x/x/d', 'exclude_filter', true],
['x/x/d', 'list_dir_open', ['u2.php']],
['x/x/d/u2.php', 'is_dir', false],
['x/x/d/u2.php', 'exclude_filter', true],
], $this->vfsLog);
}

public function testFollowLinks()
{
if ('\\' == \DIRECTORY_SEPARATOR) {
Expand Down
175 changes: 175 additions & 0 deletions src/Symfony/Component/Finder/Tests/Iterator/VfsIteratorTestTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Finder\Tests\Iterator;

trait VfsIteratorTestTrait
{
private static int $vfsNextSchemeIndex = 0;

/** @var array<string, \Closure(string, 'list_dir_open'|'list_dir_rewind'|'is_dir'): (list<string>|bool)> */
public static array $vfsProviders;

protected string $vfsScheme;

/** @var list<array{string, string, mixed}> */
protected array $vfsLog = [];

protected function setUp(): void
{
parent::setUp();

$this->vfsScheme = 'symfony-finder-vfs-test-'.++self::$vfsNextSchemeIndex;

$vfsWrapperClass = \get_class(new class() {
/** @var array<string, \Closure(string, 'list_dir_open'|'list_dir_rewind'|'is_dir'): (list<string>|bool)> */
public static array $vfsProviders = [];

/** @var resource */
public $context;

private string $scheme;

private string $dirPath;

/** @var list<string> */
private array $dirData;

private function parsePathAndSetScheme(string $url): string
{
$urlArr = parse_url($url);
\assert(\is_array($urlArr));
\assert(isset($urlArr['scheme']));
\assert(isset($urlArr['host']));

$this->scheme = $urlArr['scheme'];

return str_replace(\DIRECTORY_SEPARATOR, '/', $urlArr['host'].($urlArr['path'] ?? ''));
}

public function processListDir(bool $fromRewind): bool
{
$providerFx = self::$vfsProviders[$this->scheme];
$data = $providerFx($this->dirPath, 'list_dir'.($fromRewind ? '_rewind' : '_open'));
\assert(\is_array($data));
$this->dirData = $data;

return true;
}

public function dir_opendir(string $url): bool
{
$this->dirPath = $this->parsePathAndSetScheme($url);

return $this->processListDir(false);
}

public function dir_readdir(): string|false
{
return array_shift($this->dirData) ?? false;
}

public function dir_closedir(): bool
{
unset($this->dirPath);
unset($this->dirData);

return true;
}

public function dir_rewinddir(): bool
{
return $this->processListDir(true);
}

/**
* @return array<string, mixed>
*/
public function stream_stat(): array
{
return [];
}

/**
* @return array<string, mixed>
*/
public function url_stat(string $url): array
{
$path = $this->parsePathAndSetScheme($url);
$providerFx = self::$vfsProviders[$this->scheme];
$isDir = $providerFx($path, 'is_dir');
\assert(\is_bool($isDir));

return ['mode' => $isDir ? 0040755 : 0100644];
}
});
self::$vfsProviders = &$vfsWrapperClass::$vfsProviders;

stream_wrapper_register($this->vfsScheme, $vfsWrapperClass);
}

protected function tearDown(): void
{
stream_wrapper_unregister($this->vfsScheme);

parent::tearDown();
}

/**
* @param array<string, mixed> $data
*/
protected function setupVfsProvider(array $data): void
{
self::$vfsProviders[$this->vfsScheme] = function (string $path, string $op) use ($data) {
$pathArr = explode('/', $path);
$fileEntry = $data;
while (($name = array_shift($pathArr)) !== null) {
if (!isset($fileEntry[$name])) {
$fileEntry = false;

break;
}

$fileEntry = $fileEntry[$name];
}

if ('list_dir_open' === $op || 'list_dir_rewind' === $op) {
/** @var list<string> $res */
$res = array_keys($fileEntry);
} elseif ('is_dir' === $op) {
$res = \is_array($fileEntry);
} else {
throw new \Exception('Unexpected operation type');
}

$this->vfsLog[] = [$path, $op, $res];

return $res;
};
}

protected function stripSchemeFromVfsPath(string $url): string
{
$urlArr = parse_url($url);
\assert(\is_array($urlArr));
\assert($urlArr['scheme'] === $this->vfsScheme);
\assert(isset($urlArr['host']));

return str_replace(\DIRECTORY_SEPARATOR, '/', $urlArr['host'].($urlArr['path'] ?? ''));
}

protected function assertSameVfsIterator(array $expected, \Traversable $iterator)
{
$values = array_map(fn (\SplFileInfo $fileinfo) => $this->stripSchemeFromVfsPath($fileinfo->getPathname()), iterator_to_array($iterator));

$this->assertEquals($expected, array_values($values));
}
}