Skip to content

Commit c0b454c

Browse files
mvoriseknicolas-grekas
authored andcommitted
[Finder] Add early directory prunning filter support
1 parent 1591b45 commit c0b454c

File tree

5 files changed

+285
-3
lines changed

5 files changed

+285
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.4
5+
---
6+
7+
* Add early directory prunning to `Finder::filter()`
8+
49
6.2
510
---
611

Finder.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class Finder implements \IteratorAggregate, \Countable
5050
private array $notNames = [];
5151
private array $exclude = [];
5252
private array $filters = [];
53+
private array $pruneFilters = [];
5354
private array $depths = [];
5455
private array $sizes = [];
5556
private bool $followLinks = false;
@@ -580,14 +581,22 @@ public function sortByModifiedTime(): static
580581
* The anonymous function receives a \SplFileInfo and must return false
581582
* to remove files.
582583
*
584+
* @param \Closure(SplFileInfo): bool $closure
585+
* @param bool $prune Whether to skip traversing directories further
586+
*
583587
* @return $this
584588
*
585589
* @see CustomFilterIterator
586590
*/
587-
public function filter(\Closure $closure): static
591+
public function filter(\Closure $closure /* , bool $prune = false */): static
588592
{
593+
$prune = 1 < \func_num_args() ? func_get_arg(1) : false;
589594
$this->filters[] = $closure;
590595

596+
if ($prune) {
597+
$this->pruneFilters[] = $closure;
598+
}
599+
591600
return $this;
592601
}
593602

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

753+
if ($this->pruneFilters) {
754+
$exclude = array_merge($exclude, $this->pruneFilters);
755+
}
756+
744757
if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) {
745758
$exclude = array_merge($exclude, self::$vcsPatterns);
746759
}

Iterator/ExcludeDirectoryFilterIterator.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,32 @@ class ExcludeDirectoryFilterIterator extends \FilterIterator implements \Recursi
2727
/** @var \Iterator<string, SplFileInfo> */
2828
private \Iterator $iterator;
2929
private bool $isRecursive;
30+
/** @var array<string, true> */
3031
private array $excludedDirs = [];
3132
private ?string $excludedPattern = null;
33+
/** @var list<callable(SplFileInfo):bool> */
34+
private array $pruneFilters = [];
3235

3336
/**
34-
* @param \Iterator<string, SplFileInfo> $iterator The Iterator to filter
35-
* @param string[] $directories An array of directories to exclude
37+
* @param \Iterator<string, SplFileInfo> $iterator The Iterator to filter
38+
* @param list<string|callable(SplFileInfo):bool> $directories An array of directories to exclude
3639
*/
3740
public function __construct(\Iterator $iterator, array $directories)
3841
{
3942
$this->iterator = $iterator;
4043
$this->isRecursive = $iterator instanceof \RecursiveIterator;
4144
$patterns = [];
4245
foreach ($directories as $directory) {
46+
if (!\is_string($directory)) {
47+
if (!\is_callable($directory)) {
48+
throw new \InvalidArgumentException('Invalid PHP callback.');
49+
}
50+
51+
$this->pruneFilters[] = $directory;
52+
53+
continue;
54+
}
55+
4356
$directory = rtrim($directory, '/');
4457
if (!$this->isRecursive || str_contains($directory, '/')) {
4558
$patterns[] = preg_quote($directory, '#');
@@ -70,6 +83,14 @@ public function accept(): bool
7083
return !preg_match($this->excludedPattern, $path);
7184
}
7285

86+
if ($this->pruneFilters && $this->hasChildren()) {
87+
foreach ($this->pruneFilters as $pruneFilter) {
88+
if (!$pruneFilter($this->current())) {
89+
return false;
90+
}
91+
}
92+
}
93+
7394
return true;
7495
}
7596

Tests/FinderTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
class FinderTest extends Iterator\RealIteratorTestCase
1818
{
19+
use Iterator\VfsIteratorTestTrait;
20+
1921
public function testCreate()
2022
{
2123
$this->assertInstanceOf(Finder::class, Finder::create());
@@ -989,6 +991,72 @@ public function testFilter()
989991
$this->assertIterator($this->toAbsolute(['test.php', 'test.py']), $finder->in(self::$tmpDir)->getIterator());
990992
}
991993

994+
public function testFilterPrune()
995+
{
996+
$this->setupVfsProvider([
997+
'x' => [
998+
'a.php' => '',
999+
'b.php' => '',
1000+
'd' => [
1001+
'u.php' => '',
1002+
],
1003+
'x' => [
1004+
'd' => [
1005+
'u2.php' => '',
1006+
],
1007+
],
1008+
],
1009+
'y' => [
1010+
'c.php' => '',
1011+
],
1012+
]);
1013+
1014+
$finder = $this->buildFinder();
1015+
$finder
1016+
->in($this->vfsScheme.'://x')
1017+
->filter(fn (): bool => true, true) // does nothing
1018+
->filter(function (\SplFileInfo $file): bool {
1019+
$path = $this->stripSchemeFromVfsPath($file->getPathname());
1020+
1021+
$res = 'x/d' !== $path;
1022+
1023+
$this->vfsLog[] = [$path, 'exclude_filter', $res];
1024+
1025+
return $res;
1026+
}, true)
1027+
->filter(fn (): bool => true, true); // does nothing
1028+
1029+
$this->assertSameVfsIterator([
1030+
'x/a.php',
1031+
'x/b.php',
1032+
'x/x',
1033+
'x/x/d',
1034+
'x/x/d/u2.php',
1035+
], $finder->getIterator());
1036+
1037+
// "x/d" directory must be pruned early
1038+
// "x/x/d" directory must not be pruned
1039+
$this->assertSame([
1040+
['x', 'is_dir', true],
1041+
['x', 'list_dir_open', ['a.php', 'b.php', 'd', 'x']],
1042+
['x/a.php', 'is_dir', false],
1043+
['x/a.php', 'exclude_filter', true],
1044+
['x/b.php', 'is_dir', false],
1045+
['x/b.php', 'exclude_filter', true],
1046+
['x/d', 'is_dir', true],
1047+
['x/d', 'exclude_filter', false],
1048+
['x/x', 'is_dir', true],
1049+
['x/x', 'exclude_filter', true], // from ExcludeDirectoryFilterIterator::accept() (prune directory filter)
1050+
['x/x', 'exclude_filter', true], // from CustomFilterIterator::accept() (regular filter)
1051+
['x/x', 'list_dir_open', ['d']],
1052+
['x/x/d', 'is_dir', true],
1053+
['x/x/d', 'exclude_filter', true],
1054+
['x/x/d', 'list_dir_open', ['u2.php']],
1055+
['x/x/d/u2.php', 'is_dir', false],
1056+
['x/x/d/u2.php', 'exclude_filter', true],
1057+
], $this->vfsLog);
1058+
}
1059+
9921060
public function testFollowLinks()
9931061
{
9941062
if ('\\' == \DIRECTORY_SEPARATOR) {
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Finder\Tests\Iterator;
13+
14+
trait VfsIteratorTestTrait
15+
{
16+
private static int $vfsNextSchemeIndex = 0;
17+
18+
/** @var array<string, \Closure(string, 'list_dir_open'|'list_dir_rewind'|'is_dir'): (list<string>|bool)> */
19+
public static array $vfsProviders;
20+
21+
protected string $vfsScheme;
22+
23+
/** @var list<array{string, string, mixed}> */
24+
protected array $vfsLog = [];
25+
26+
protected function setUp(): void
27+
{
28+
parent::setUp();
29+
30+
$this->vfsScheme = 'symfony-finder-vfs-test-'.++self::$vfsNextSchemeIndex;
31+
32+
$vfsWrapperClass = \get_class(new class() {
33+
/** @var array<string, \Closure(string, 'list_dir_open'|'list_dir_rewind'|'is_dir'): (list<string>|bool)> */
34+
public static array $vfsProviders = [];
35+
36+
/** @var resource */
37+
public $context;
38+
39+
private string $scheme;
40+
41+
private string $dirPath;
42+
43+
/** @var list<string> */
44+
private array $dirData;
45+
46+
private function parsePathAndSetScheme(string $url): string
47+
{
48+
$urlArr = parse_url($url);
49+
\assert(\is_array($urlArr));
50+
\assert(isset($urlArr['scheme']));
51+
\assert(isset($urlArr['host']));
52+
53+
$this->scheme = $urlArr['scheme'];
54+
55+
return str_replace(\DIRECTORY_SEPARATOR, '/', $urlArr['host'].($urlArr['path'] ?? ''));
56+
}
57+
58+
public function processListDir(bool $fromRewind): bool
59+
{
60+
$providerFx = self::$vfsProviders[$this->scheme];
61+
$data = $providerFx($this->dirPath, 'list_dir'.($fromRewind ? '_rewind' : '_open'));
62+
\assert(\is_array($data));
63+
$this->dirData = $data;
64+
65+
return true;
66+
}
67+
68+
public function dir_opendir(string $url): bool
69+
{
70+
$this->dirPath = $this->parsePathAndSetScheme($url);
71+
72+
return $this->processListDir(false);
73+
}
74+
75+
public function dir_readdir(): string|false
76+
{
77+
return array_shift($this->dirData) ?? false;
78+
}
79+
80+
public function dir_closedir(): bool
81+
{
82+
unset($this->dirPath);
83+
unset($this->dirData);
84+
85+
return true;
86+
}
87+
88+
public function dir_rewinddir(): bool
89+
{
90+
return $this->processListDir(true);
91+
}
92+
93+
/**
94+
* @return array<string, mixed>
95+
*/
96+
public function stream_stat(): array
97+
{
98+
return [];
99+
}
100+
101+
/**
102+
* @return array<string, mixed>
103+
*/
104+
public function url_stat(string $url): array
105+
{
106+
$path = $this->parsePathAndSetScheme($url);
107+
$providerFx = self::$vfsProviders[$this->scheme];
108+
$isDir = $providerFx($path, 'is_dir');
109+
\assert(\is_bool($isDir));
110+
111+
return ['mode' => $isDir ? 0040755 : 0100644];
112+
}
113+
});
114+
self::$vfsProviders = &$vfsWrapperClass::$vfsProviders;
115+
116+
stream_wrapper_register($this->vfsScheme, $vfsWrapperClass);
117+
}
118+
119+
protected function tearDown(): void
120+
{
121+
stream_wrapper_unregister($this->vfsScheme);
122+
123+
parent::tearDown();
124+
}
125+
126+
/**
127+
* @param array<string, mixed> $data
128+
*/
129+
protected function setupVfsProvider(array $data): void
130+
{
131+
self::$vfsProviders[$this->vfsScheme] = function (string $path, string $op) use ($data) {
132+
$pathArr = explode('/', $path);
133+
$fileEntry = $data;
134+
while (($name = array_shift($pathArr)) !== null) {
135+
if (!isset($fileEntry[$name])) {
136+
$fileEntry = false;
137+
138+
break;
139+
}
140+
141+
$fileEntry = $fileEntry[$name];
142+
}
143+
144+
if ('list_dir_open' === $op || 'list_dir_rewind' === $op) {
145+
/** @var list<string> $res */
146+
$res = array_keys($fileEntry);
147+
} elseif ('is_dir' === $op) {
148+
$res = \is_array($fileEntry);
149+
} else {
150+
throw new \Exception('Unexpected operation type');
151+
}
152+
153+
$this->vfsLog[] = [$path, $op, $res];
154+
155+
return $res;
156+
};
157+
}
158+
159+
protected function stripSchemeFromVfsPath(string $url): string
160+
{
161+
$urlArr = parse_url($url);
162+
\assert(\is_array($urlArr));
163+
\assert($urlArr['scheme'] === $this->vfsScheme);
164+
\assert(isset($urlArr['host']));
165+
166+
return str_replace(\DIRECTORY_SEPARATOR, '/', $urlArr['host'].($urlArr['path'] ?? ''));
167+
}
168+
169+
protected function assertSameVfsIterator(array $expected, \Traversable $iterator)
170+
{
171+
$values = array_map(fn (\SplFileInfo $fileinfo) => $this->stripSchemeFromVfsPath($fileinfo->getPathname()), iterator_to_array($iterator));
172+
173+
$this->assertEquals($expected, array_values($values));
174+
}
175+
}

0 commit comments

Comments
 (0)