• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / utils / 21934836766

12 Feb 2026 05:29AM UTC coverage: 93.429% (+0.01%) from 93.415%
21934836766

push

github

dg
added CLAUDE.md

2076 of 2222 relevant lines covered (93.43%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.74
/src/Utils/Finder.php
1
<?php
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Nette\Utils;
11

12
use Nette;
13
use function array_merge, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_ends_with, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, usort;
14
use const GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
15

16

17
/**
18
 * Finder allows searching through directory trees using iterator.
19
 *
20
 * Finder::findFiles('*.php')
21
 *     ->size('> 10kB')
22
 *     ->from('.')
23
 *     ->exclude('temp');
24
 *
25
 * @implements \IteratorAggregate<string, FileInfo>
26
 */
27
class Finder implements \IteratorAggregate
28
{
29
        /** @var array<array{string, string}> */
30
        private array $find = [];
31

32
        /** @var string[] */
33
        private array $in = [];
34

35
        /** @var array<\Closure(FileInfo): bool> */
36
        private array $filters = [];
37

38
        /** @var array<\Closure(FileInfo): bool> */
39
        private array $descentFilters = [];
40

41
        /** @var array<string|self> */
42
        private array $appends = [];
43
        private bool $childFirst = false;
44

45
        /** @var ?(\Closure(FileInfo, FileInfo): int) */
46
        private ?\Closure $sort = null;
47
        private int $maxDepth = -1;
48
        private bool $ignoreUnreadableDirs = true;
49

50

51
        /**
52
         * Begins search for files and directories matching mask.
53
         * @param  string|list<string>  $masks
54
         */
55
        public static function find(string|array $masks = ['*']): static
1✔
56
        {
57
                $masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
1✔
58
                return (new static)->addMask($masks, 'dir')->addMask($masks, 'file');
1✔
59
        }
60

61

62
        /**
63
         * Begins search for files matching mask.
64
         * @param  string|list<string>  $masks
65
         */
66
        public static function findFiles(string|array $masks = ['*']): static
1✔
67
        {
68
                $masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
1✔
69
                return (new static)->addMask($masks, 'file');
1✔
70
        }
71

72

73
        /**
74
         * Begins search for directories matching mask.
75
         * @param  string|list<string>  $masks
76
         */
77
        public static function findDirectories(string|array $masks = ['*']): static
1✔
78
        {
79
                $masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
1✔
80
                return (new static)->addMask($masks, 'dir');
1✔
81
        }
82

83

84
        /**
85
         * Finds files matching the specified masks.
86
         * @param  string|list<string>  $masks
87
         */
88
        public function files(string|array $masks = ['*']): static
1✔
89
        {
90
                return $this->addMask((array) $masks, 'file');
1✔
91
        }
92

93

94
        /**
95
         * Finds directories matching the specified masks.
96
         * @param  string|list<string>  $masks
97
         */
98
        public function directories(string|array $masks = ['*']): static
1✔
99
        {
100
                return $this->addMask((array) $masks, 'dir');
1✔
101
        }
102

103

104
        /** @param  list<string>  $masks */
105
        private function addMask(array $masks, string $mode): static
1✔
106
        {
107
                foreach ($masks as $mask) {
1✔
108
                        $mask = FileSystem::unixSlashes($mask);
1✔
109
                        if ($mode === 'dir') {
1✔
110
                                $mask = rtrim($mask, '/');
1✔
111
                        }
112
                        if ($mask === '' || ($mode === 'file' && str_ends_with($mask, '/'))) {
1✔
113
                                throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
1✔
114
                        }
115
                        if (str_starts_with($mask, '**/')) {
1✔
116
                                $mask = substr($mask, 3);
1✔
117
                        }
118
                        $this->find[] = [$mask, $mode];
1✔
119
                }
120
                return $this;
1✔
121
        }
122

123

124
        /**
125
         * Searches in the given directories. Wildcards are allowed.
126
         * @param  string|list<string>  $paths
127
         */
128
        public function in(string|array $paths): static
1✔
129
        {
130
                $paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
1✔
131
                $this->addLocation($paths, '');
1✔
132
                return $this;
1✔
133
        }
134

135

136
        /**
137
         * Searches recursively from the given directories. Wildcards are allowed.
138
         * @param  string|list<string>  $paths
139
         */
140
        public function from(string|array $paths): static
1✔
141
        {
142
                $paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
1✔
143
                $this->addLocation($paths, '/**');
1✔
144
                return $this;
1✔
145
        }
146

147

148
        /** @param  list<string>  $paths */
149
        private function addLocation(array $paths, string $ext): void
1✔
150
        {
151
                foreach ($paths as $path) {
1✔
152
                        if ($path === '') {
1✔
153
                                throw new Nette\InvalidArgumentException("Invalid directory '$path'");
×
154
                        }
155
                        $path = rtrim(FileSystem::unixSlashes($path), '/');
1✔
156
                        $this->in[] = $path . $ext;
1✔
157
                }
158
        }
1✔
159

160

161
        /**
162
         * Lists directory's contents before the directory itself. By default, this is disabled.
163
         */
164
        public function childFirst(bool $state = true): static
1✔
165
        {
166
                $this->childFirst = $state;
1✔
167
                return $this;
1✔
168
        }
169

170

171
        /**
172
         * Ignores unreadable directories. By default, this is enabled.
173
         */
174
        public function ignoreUnreadableDirs(bool $state = true): static
175
        {
176
                $this->ignoreUnreadableDirs = $state;
×
177
                return $this;
×
178
        }
179

180

181
        /**
182
         * Set a compare function for sorting directory entries. The function will be called to sort entries from the same directory.
183
         * @param  callable(FileInfo, FileInfo): int  $callback
184
         */
185
        public function sortBy(callable $callback): static
1✔
186
        {
187
                $this->sort = $callback(...);
1✔
188
                return $this;
1✔
189
        }
190

191

192
        /**
193
         * Sorts files in each directory naturally by name.
194
         */
195
        public function sortByName(): static
196
        {
197
                $this->sort = fn(FileInfo $a, FileInfo $b): int => strnatcmp($a->getBasename(), $b->getBasename());
1✔
198
                return $this;
1✔
199
        }
200

201

202
        /**
203
         * Adds the specified paths or appends a new finder that returns.
204
         * @param  string|list<string>|null  $paths
205
         */
206
        public function append(string|array|null $paths = null): static
1✔
207
        {
208
                if ($paths === null) {
1✔
209
                        return $this->appends[] = new static;
1✔
210
                }
211

212
                $this->appends = array_merge($this->appends, (array) $paths);
1✔
213
                return $this;
1✔
214
        }
215

216

217
        /********************* filtering ****************d*g**/
218

219

220
        /**
221
         * Skips entries that matches the given masks relative to the ones defined with the in() or from() methods.
222
         * @param  string|list<string>  $masks
223
         */
224
        public function exclude(string|array $masks): static
1✔
225
        {
226
                $masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
1✔
227
                foreach ($masks as $mask) {
1✔
228
                        $mask = FileSystem::unixSlashes($mask);
1✔
229
                        if (!preg_match('~^/?(\*\*/)?(.+)(/\*\*|/\*|/|)$~D', $mask, $m)) {
1✔
230
                                throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
×
231
                        }
232
                        $end = $m[3];
1✔
233
                        $re = $this->buildPattern($m[2]);
1✔
234
                        $filter = fn(FileInfo $file): bool => ($end && !$file->isDir())
1✔
235
                                || !preg_match($re, FileSystem::unixSlashes($file->getRelativePathname()));
1✔
236

237
                        $this->descentFilter($filter);
1✔
238
                        if ($end !== '/*') {
1✔
239
                                $this->filter($filter);
1✔
240
                        }
241
                }
242

243
                return $this;
1✔
244
        }
245

246

247
        /**
248
         * Yields only entries which satisfy the given filter.
249
         * @param  callable(FileInfo): bool  $callback
250
         */
251
        public function filter(callable $callback): static
1✔
252
        {
253
                $this->filters[] = $callback(...);
1✔
254
                return $this;
1✔
255
        }
256

257

258
        /**
259
         * It descends only to directories that match the specified filter.
260
         * @param  callable(FileInfo): bool  $callback
261
         */
262
        public function descentFilter(callable $callback): static
1✔
263
        {
264
                $this->descentFilters[] = $callback(...);
1✔
265
                return $this;
1✔
266
        }
267

268

269
        /**
270
         * Sets the maximum depth of entries.
271
         */
272
        public function limitDepth(?int $depth): static
1✔
273
        {
274
                $this->maxDepth = $depth ?? -1;
1✔
275
                return $this;
1✔
276
        }
277

278

279
        /**
280
         * Restricts the search by size. $operator accepts "[operator] [size] [unit]" example: >=10kB
281
         * @param  '>'|'>='|'<'|'<='|'='|'=='|'==='|'!='|'!=='|'<>'  $operator  or predicate string
282
         */
283
        public function size(string $operator, ?int $size = null): static
1✔
284
        {
285
                if (func_num_args() === 1) { // in $operator is predicate
1✔
286
                        if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?((?:\d*\.)?\d+)\s*(K|M|G|)B?$#Di', $operator, $matches)) {
1✔
287
                                throw new Nette\InvalidArgumentException('Invalid size predicate format.');
×
288
                        }
289

290
                        [, $operator, $size, $unit] = $matches;
1✔
291
                        $units = ['' => 1, 'k' => 1e3, 'm' => 1e6, 'g' => 1e9];
1✔
292
                        $size = (float) $size * $units[strtolower($unit)];
1✔
293
                        $operator = $operator ?: '=';
1✔
294
                }
295

296
                return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getSize(), $operator, $size));
1✔
297
        }
298

299

300
        /**
301
         * Restricts the search by modified time. $operator accepts "[operator] [date]" example: >1978-01-23
302
         * @param  '>'|'>='|'<'|'<='|'='|'=='|'==='|'!='|'!=='|'<>'  $operator  or predicate string
303
         */
304
        public function date(string $operator, string|int|\DateTimeInterface|null $date = null): static
1✔
305
        {
306
                if (func_num_args() === 1) { // in $operator is predicate
1✔
307
                        if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?(.+)$#Di', $operator, $matches)) {
1✔
308
                                throw new Nette\InvalidArgumentException('Invalid date predicate format.');
×
309
                        }
310

311
                        [, $operator, $date] = $matches;
1✔
312
                        $operator = $operator ?: '=';
1✔
313
                }
314

315
                $date = DateTime::from($date)->getTimestamp();
1✔
316
                return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getMTime(), $operator, $date));
1✔
317
        }
318

319

320
        /********************* iterator generator ****************d*g**/
321

322

323
        /**
324
         * Returns an array with all found files and directories.
325
         * @return list<FileInfo>
326
         */
327
        public function collect(): array
328
        {
329
                return iterator_to_array($this->getIterator(), preserve_keys: false);
1✔
330
        }
331

332

333
        /** @return \Generator<string, FileInfo> */
334
        public function getIterator(): \Generator
1✔
335
        {
336
                $plan = $this->buildPlan();
1✔
337
                foreach ($plan as $dir => $searches) {
1✔
338
                        yield from $this->traverseDir($dir, $searches);
1✔
339
                }
340

341
                foreach ($this->appends as $item) {
1✔
342
                        if ($item instanceof self) {
1✔
343
                                yield from $item->getIterator();
1✔
344
                        } else {
345
                                $item = FileSystem::platformSlashes($item);
1✔
346
                                yield $item => new FileInfo($item);
1✔
347
                        }
348
                }
349
        }
1✔
350

351

352
        /**
353
         * @param  array<object{pattern: string, mode: string, recursive: bool}>  $searches
354
         * @param  string[]  $subdirs
355
         * @return \Generator<string, FileInfo>
356
         */
357
        private function traverseDir(string $dir, array $searches, array $subdirs = []): \Generator
1✔
358
        {
359
                if ($this->maxDepth >= 0 && count($subdirs) > $this->maxDepth) {
1✔
360
                        return;
1✔
361
                } elseif (!is_dir($dir)) {
1✔
362
                        throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($dir, '/\\')));
1✔
363
                }
364

365
                try {
366
                        $pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);
1✔
367
                } catch (\UnexpectedValueException $e) {
×
368
                        if ($this->ignoreUnreadableDirs) {
×
369
                                return;
×
370
                        } else {
371
                                throw new Nette\InvalidStateException($e->getMessage());
×
372
                        }
373
                }
374

375
                $files = $this->convertToFiles($pathNames, implode('/', $subdirs), FileSystem::isAbsolute($dir));
1✔
376

377
                if ($this->sort) {
1✔
378
                        $files = iterator_to_array($files);
1✔
379
                        usort($files, $this->sort);
1✔
380
                }
381

382
                foreach ($files as $file) {
1✔
383
                        $pathName = $file->getPathname();
1✔
384
                        $cache = $subSearch = [];
1✔
385

386
                        if ($file->isDir()) {
1✔
387
                                foreach ($searches as $search) {
1✔
388
                                        if ($search->recursive && $this->proveFilters($this->descentFilters, $file, $cache)) {
1✔
389
                                                $subSearch[] = $search;
1✔
390
                                        }
391
                                }
392
                        }
393

394
                        if ($this->childFirst && $subSearch) {
1✔
395
                                yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
1✔
396
                        }
397

398
                        $relativePathname = FileSystem::unixSlashes($file->getRelativePathname());
1✔
399
                        foreach ($searches as $search) {
1✔
400
                                if (
401
                                        "is_$search->mode"(Helpers::IsWindows && $file->isLink() ? $file->getLinkTarget() : $file->getPathname())
1✔
402
                                        && preg_match($search->pattern, $relativePathname)
1✔
403
                                        && $this->proveFilters($this->filters, $file, $cache)
1✔
404
                                ) {
405
                                        yield $pathName => $file;
1✔
406
                                        break;
1✔
407
                                }
408
                        }
409

410
                        if (!$this->childFirst && $subSearch) {
1✔
411
                                yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
1✔
412
                        }
413
                }
414
        }
1✔
415

416

417
        /** @param  iterable<string>  $pathNames */
418
        private function convertToFiles(iterable $pathNames, string $relativePath, bool $absolute): \Generator
1✔
419
        {
420
                foreach ($pathNames as $pathName) {
1✔
421
                        if (!$absolute) {
1✔
422
                                $pathName = preg_replace('~\.?/~A', '', $pathName);
1✔
423
                        }
424
                        $pathName = FileSystem::platformSlashes($pathName);
1✔
425
                        yield new FileInfo($pathName, $relativePath);
1✔
426
                }
427
        }
1✔
428

429

430
        /**
431
         * @param  (\Closure(FileInfo): bool)[]  $filters
432
         * @param  array<int, bool>  $cache
433
         */
434
        private function proveFilters(array $filters, FileInfo $file, array &$cache): bool
1✔
435
        {
436
                foreach ($filters as $filter) {
1✔
437
                        $res = &$cache[spl_object_id($filter)];
1✔
438
                        $res ??= $filter($file);
1✔
439
                        if (!$res) {
1✔
440
                                return false;
1✔
441
                        }
442
                }
443

444
                return true;
1✔
445
        }
446

447

448
        /** @return array<string, array<object{pattern: string, mode: string, recursive: bool}>> */
449
        private function buildPlan(): array
450
        {
451
                $plan = $dirCache = [];
1✔
452
                foreach ($this->find as [$mask, $mode]) {
1✔
453
                        $splits = [];
1✔
454
                        if (FileSystem::isAbsolute($mask)) {
1✔
455
                                if ($this->in) {
1✔
456
                                        throw new Nette\InvalidStateException("You cannot combine the absolute path in the mask '$mask' and the directory to search '{$this->in[0]}'.");
1✔
457
                                }
458
                                $splits[] = self::splitRecursivePart($mask);
1✔
459
                        } else {
460
                                foreach ($this->in ?: ['.'] as $in) {
1✔
461
                                        $in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()
1✔
462
                                        $splits[] = self::splitRecursivePart($in . '/' . $mask);
1✔
463
                                }
464
                        }
465

466
                        foreach ($splits as [$base, $rest, $recursive]) {
1✔
467
                                $base = $base === '' ? '.' : $base;
1✔
468
                                $dirs = $dirCache[$base] ??= strpbrk($base, '*?[')
1✔
469
                                        ? glob($base, GLOB_NOSORT | GLOB_ONLYDIR | GLOB_NOESCAPE)
1✔
470
                                        : [strtr($base, ['[[]' => '[', '[]]' => ']'])]; // unescape [ and ]
1✔
471

472
                                if (!$dirs) {
1✔
473
                                        throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($base, '/\\')));
1✔
474
                                }
475

476
                                $search = (object) ['pattern' => $this->buildPattern($rest), 'mode' => $mode, 'recursive' => $recursive];
1✔
477
                                foreach ($dirs as $dir) {
1✔
478
                                        $plan[$dir][] = $search;
1✔
479
                                }
480
                        }
481
                }
482

483
                return $plan;
1✔
484
        }
485

486

487
        /**
488
         * Since glob() does not know ** wildcard, we divide the path into a part for glob and a part for manual traversal.
489
         * @return array{string, string, bool}
490
         */
491
        private static function splitRecursivePart(string $path): array
1✔
492
        {
493
                $a = strrpos($path, '/');
1✔
494
                $parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);
1✔
495
                return isset($parts[1])
1✔
496
                        ? [$parts[0], $parts[1] . substr($path, $a + 1), true]
1✔
497
                        : [$parts[0], substr($path, $a + 1), false];
1✔
498
        }
499

500

501
        /**
502
         * Converts wildcards to regular expression.
503
         */
504
        private function buildPattern(string $mask): string
1✔
505
        {
506
                if ($mask === '*') {
1✔
507
                        return '##';
1✔
508
                } elseif (str_starts_with($mask, './')) {
1✔
509
                        $anchor = '^';
1✔
510
                        $mask = substr($mask, 2);
1✔
511
                } else {
512
                        $anchor = '(?:^|/)';
1✔
513
                }
514

515
                $pattern = strtr(
1✔
516
                        preg_quote($mask, '#'),
1✔
517
                        [
518
                                '\*\*/' => '(.+/)?',
1✔
519
                                '\*' => '[^/]*',
520
                                '\?' => '[^/]',
521
                                '\[\!' => '[^',
522
                                '\[' => '[',
523
                                '\]' => ']',
524
                                '\-' => '-',
525
                        ],
526
                );
527
                return '#' . $anchor . $pattern . '$#D' . (Helpers::IsWindows ? 'i' : '');
1✔
528
        }
529
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc