Skip to content

Commit

Permalink
[Finder] Add early directory prunning filter support
Browse files Browse the repository at this point in the history
  • Loading branch information
mvorisek authored and nicolas-grekas committed Oct 11, 2023
1 parent 1591b45 commit c0b454c
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 3 deletions.
5 changes: 5 additions & 0 deletions 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 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
25 changes: 23 additions & 2 deletions Iterator/ExcludeDirectoryFilterIterator.php
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 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 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));
}
}

0 comments on commit c0b454c

Please sign in to comment.