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

nette / utils / 15764605036

19 Jun 2025 06:55PM UTC coverage: 92.467% (-0.05%) from 92.516%
15764605036

push

github

dg
Strings::trim() trims Line Separator and Ideographic Space (#326)

These characters occur in our data.

There are more characters listed at https://en.wikipedia.org/wiki/Whitespace_character#Unicode, maybe all of them should be included?

2050 of 2217 relevant lines covered (92.47%)

0.92 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
        use Nette\SmartObject;
30

31
        /** @var array<array{string, string}> */
32
        private array $find = [];
33

34
        /** @var string[] */
35
        private array $in = [];
36

37
        /** @var \Closure[] */
38
        private array $filters = [];
39

40
        /** @var \Closure[] */
41
        private array $descentFilters = [];
42

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

47
        /** @var ?callable */
48
        private $sort;
49
        private int $maxDepth = -1;
50
        private bool $ignoreUnreadableDirs = true;
51

52

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

62

63
        /**
64
         * Begins search for files matching mask.
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
         */
76
        public static function findDirectories(string|array $masks = ['*']): static
1✔
77
        {
78
                $masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
1✔
79
                return (new static)->addMask($masks, 'dir');
1✔
80
        }
81

82

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

91

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

100

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

119

120
        /**
121
         * Searches in the given directories. Wildcards are allowed.
122
         */
123
        public function in(string|array $paths): static
1✔
124
        {
125
                $paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
1✔
126
                $this->addLocation($paths, '');
1✔
127
                return $this;
1✔
128
        }
129

130

131
        /**
132
         * Searches recursively from the given directories. Wildcards are allowed.
133
         */
134
        public function from(string|array $paths): static
1✔
135
        {
136
                $paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
1✔
137
                $this->addLocation($paths, '/**');
1✔
138
                return $this;
1✔
139
        }
140

141

142
        private function addLocation(array $paths, string $ext): void
1✔
143
        {
144
                foreach ($paths as $path) {
1✔
145
                        if ($path === '') {
1✔
146
                                throw new Nette\InvalidArgumentException("Invalid directory '$path'");
×
147
                        }
148
                        $path = rtrim(FileSystem::unixSlashes($path), '/');
1✔
149
                        $this->in[] = $path . $ext;
1✔
150
                }
151
        }
1✔
152

153

154
        /**
155
         * Lists directory's contents before the directory itself. By default, this is disabled.
156
         */
157
        public function childFirst(bool $state = true): static
1✔
158
        {
159
                $this->childFirst = $state;
1✔
160
                return $this;
1✔
161
        }
162

163

164
        /**
165
         * Ignores unreadable directories. By default, this is enabled.
166
         */
167
        public function ignoreUnreadableDirs(bool $state = true): static
168
        {
169
                $this->ignoreUnreadableDirs = $state;
×
170
                return $this;
×
171
        }
172

173

174
        /**
175
         * Set a compare function for sorting directory entries. The function will be called to sort entries from the same directory.
176
         * @param  callable(FileInfo, FileInfo): int  $callback
177
         */
178
        public function sortBy(callable $callback): static
1✔
179
        {
180
                $this->sort = $callback;
1✔
181
                return $this;
1✔
182
        }
183

184

185
        /**
186
         * Sorts files in each directory naturally by name.
187
         */
188
        public function sortByName(): static
189
        {
190
                $this->sort = fn(FileInfo $a, FileInfo $b): int => strnatcmp($a->getBasename(), $b->getBasename());
1✔
191
                return $this;
1✔
192
        }
193

194

195
        /**
196
         * Adds the specified paths or appends a new finder that returns.
197
         */
198
        public function append(string|array|null $paths = null): static
1✔
199
        {
200
                if ($paths === null) {
1✔
201
                        return $this->appends[] = new static;
1✔
202
                }
203

204
                $this->appends = array_merge($this->appends, (array) $paths);
1✔
205
                return $this;
1✔
206
        }
207

208

209
        /********************* filtering ****************d*g**/
210

211

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

228
                        $this->descentFilter($filter);
1✔
229
                        if ($end !== '/*') {
1✔
230
                                $this->filter($filter);
1✔
231
                        }
232
                }
233

234
                return $this;
1✔
235
        }
236

237

238
        /**
239
         * Yields only entries which satisfy the given filter.
240
         * @param  callable(FileInfo): bool  $callback
241
         */
242
        public function filter(callable $callback): static
1✔
243
        {
244
                $this->filters[] = \Closure::fromCallable($callback);
1✔
245
                return $this;
1✔
246
        }
247

248

249
        /**
250
         * It descends only to directories that match the specified filter.
251
         * @param  callable(FileInfo): bool  $callback
252
         */
253
        public function descentFilter(callable $callback): static
1✔
254
        {
255
                $this->descentFilters[] = \Closure::fromCallable($callback);
1✔
256
                return $this;
1✔
257
        }
258

259

260
        /**
261
         * Sets the maximum depth of entries.
262
         */
263
        public function limitDepth(?int $depth): static
1✔
264
        {
265
                $this->maxDepth = $depth ?? -1;
1✔
266
                return $this;
1✔
267
        }
268

269

270
        /**
271
         * Restricts the search by size. $operator accepts "[operator] [size] [unit]" example: >=10kB
272
         */
273
        public function size(string $operator, ?int $size = null): static
1✔
274
        {
275
                if (func_num_args() === 1) { // in $operator is predicate
1✔
276
                        if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?((?:\d*\.)?\d+)\s*(K|M|G|)B?$#Di', $operator, $matches)) {
1✔
277
                                throw new Nette\InvalidArgumentException('Invalid size predicate format.');
×
278
                        }
279

280
                        [, $operator, $size, $unit] = $matches;
1✔
281
                        $units = ['' => 1, 'k' => 1e3, 'm' => 1e6, 'g' => 1e9];
1✔
282
                        $size *= $units[strtolower($unit)];
1✔
283
                        $operator = $operator ?: '=';
1✔
284
                }
285

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

289

290
        /**
291
         * Restricts the search by modified time. $operator accepts "[operator] [date]" example: >1978-01-23
292
         */
293
        public function date(string $operator, string|int|\DateTimeInterface|null $date = null): static
1✔
294
        {
295
                if (func_num_args() === 1) { // in $operator is predicate
1✔
296
                        if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?(.+)$#Di', $operator, $matches)) {
1✔
297
                                throw new Nette\InvalidArgumentException('Invalid date predicate format.');
×
298
                        }
299

300
                        [, $operator, $date] = $matches;
1✔
301
                        $operator = $operator ?: '=';
1✔
302
                }
303

304
                $date = DateTime::from($date)->getTimestamp();
1✔
305
                return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getMTime(), $operator, $date));
1✔
306
        }
307

308

309
        /********************* iterator generator ****************d*g**/
310

311

312
        /**
313
         * Returns an array with all found files and directories.
314
         * @return list<FileInfo>
315
         */
316
        public function collect(): array
317
        {
318
                return iterator_to_array($this->getIterator(), preserve_keys: false);
1✔
319
        }
320

321

322
        /** @return \Generator<string, FileInfo> */
323
        public function getIterator(): \Generator
1✔
324
        {
325
                $plan = $this->buildPlan();
1✔
326
                foreach ($plan as $dir => $searches) {
1✔
327
                        yield from $this->traverseDir($dir, $searches);
1✔
328
                }
329

330
                foreach ($this->appends as $item) {
1✔
331
                        if ($item instanceof self) {
1✔
332
                                yield from $item->getIterator();
1✔
333
                        } else {
334
                                $item = FileSystem::platformSlashes($item);
1✔
335
                                yield $item => new FileInfo($item);
1✔
336
                        }
337
                }
338
        }
1✔
339

340

341
        /**
342
         * @param  array<object{pattern: string, mode: string, recursive: bool}>  $searches
343
         * @param  string[]  $subdirs
344
         * @return \Generator<string, FileInfo>
345
         */
346
        private function traverseDir(string $dir, array $searches, array $subdirs = []): \Generator
1✔
347
        {
348
                if ($this->maxDepth >= 0 && count($subdirs) > $this->maxDepth) {
1✔
349
                        return;
1✔
350
                } elseif (!is_dir($dir)) {
1✔
351
                        throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($dir, '/\\')));
1✔
352
                }
353

354
                try {
355
                        $pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);
1✔
356
                } catch (\UnexpectedValueException $e) {
×
357
                        if ($this->ignoreUnreadableDirs) {
×
358
                                return;
×
359
                        } else {
360
                                throw new Nette\InvalidStateException($e->getMessage());
×
361
                        }
362
                }
363

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

366
                if ($this->sort) {
1✔
367
                        $files = iterator_to_array($files);
1✔
368
                        usort($files, $this->sort);
1✔
369
                }
370

371
                foreach ($files as $file) {
1✔
372
                        $pathName = $file->getPathname();
1✔
373
                        $cache = $subSearch = [];
1✔
374

375
                        if ($file->isDir()) {
1✔
376
                                foreach ($searches as $search) {
1✔
377
                                        if ($search->recursive && $this->proveFilters($this->descentFilters, $file, $cache)) {
1✔
378
                                                $subSearch[] = $search;
1✔
379
                                        }
380
                                }
381
                        }
382

383
                        if ($this->childFirst && $subSearch) {
1✔
384
                                yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
1✔
385
                        }
386

387
                        $relativePathname = FileSystem::unixSlashes($file->getRelativePathname());
1✔
388
                        foreach ($searches as $search) {
1✔
389
                                if (
390
                                        $file->{'is' . $search->mode}()
1✔
391
                                        && preg_match($search->pattern, $relativePathname)
1✔
392
                                        && $this->proveFilters($this->filters, $file, $cache)
1✔
393
                                ) {
394
                                        yield $pathName => $file;
1✔
395
                                        break;
1✔
396
                                }
397
                        }
398

399
                        if (!$this->childFirst && $subSearch) {
1✔
400
                                yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
1✔
401
                        }
402
                }
403
        }
1✔
404

405

406
        private function convertToFiles(iterable $pathNames, string $relativePath, bool $absolute): \Generator
1✔
407
        {
408
                foreach ($pathNames as $pathName) {
1✔
409
                        if (!$absolute) {
1✔
410
                                $pathName = preg_replace('~\.?/~A', '', $pathName);
1✔
411
                        }
412
                        $pathName = FileSystem::platformSlashes($pathName);
1✔
413
                        yield new FileInfo($pathName, $relativePath);
1✔
414
                }
415
        }
1✔
416

417

418
        private function proveFilters(array $filters, FileInfo $file, array &$cache): bool
1✔
419
        {
420
                foreach ($filters as $filter) {
1✔
421
                        $res = &$cache[spl_object_id($filter)];
1✔
422
                        $res ??= $filter($file);
1✔
423
                        if (!$res) {
1✔
424
                                return false;
1✔
425
                        }
426
                }
427

428
                return true;
1✔
429
        }
430

431

432
        /** @return array<string, array<object{pattern: string, mode: string, recursive: bool}>> */
433
        private function buildPlan(): array
434
        {
435
                $plan = $dirCache = [];
1✔
436
                foreach ($this->find as [$mask, $mode]) {
1✔
437
                        $splits = [];
1✔
438
                        if (FileSystem::isAbsolute($mask)) {
1✔
439
                                if ($this->in) {
1✔
440
                                        throw new Nette\InvalidStateException("You cannot combine the absolute path in the mask '$mask' and the directory to search '{$this->in[0]}'.");
1✔
441
                                }
442
                                $splits[] = self::splitRecursivePart($mask);
1✔
443
                        } else {
444
                                foreach ($this->in ?: ['.'] as $in) {
1✔
445
                                        $in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()
1✔
446
                                        $splits[] = self::splitRecursivePart($in . '/' . $mask);
1✔
447
                                }
448
                        }
449

450
                        foreach ($splits as [$base, $rest, $recursive]) {
1✔
451
                                $base = $base === '' ? '.' : $base;
1✔
452
                                $dirs = $dirCache[$base] ??= strpbrk($base, '*?[')
1✔
453
                                        ? glob($base, GLOB_NOSORT | GLOB_ONLYDIR | GLOB_NOESCAPE)
1✔
454
                                        : [strtr($base, ['[[]' => '[', '[]]' => ']'])]; // unescape [ and ]
1✔
455

456
                                if (!$dirs) {
1✔
457
                                        throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($base, '/\\')));
1✔
458
                                }
459

460
                                $search = (object) ['pattern' => $this->buildPattern($rest), 'mode' => $mode, 'recursive' => $recursive];
1✔
461
                                foreach ($dirs as $dir) {
1✔
462
                                        $plan[$dir][] = $search;
1✔
463
                                }
464
                        }
465
                }
466

467
                return $plan;
1✔
468
        }
469

470

471
        /**
472
         * Since glob() does not know ** wildcard, we divide the path into a part for glob and a part for manual traversal.
473
         */
474
        private static function splitRecursivePart(string $path): array
1✔
475
        {
476
                $a = strrpos($path, '/');
1✔
477
                $parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);
1✔
478
                return isset($parts[1])
1✔
479
                        ? [$parts[0], $parts[1] . substr($path, $a + 1), true]
1✔
480
                        : [$parts[0], substr($path, $a + 1), false];
1✔
481
        }
482

483

484
        /**
485
         * Converts wildcards to regular expression.
486
         */
487
        private function buildPattern(string $mask): string
1✔
488
        {
489
                if ($mask === '*') {
1✔
490
                        return '##';
1✔
491
                } elseif (str_starts_with($mask, './')) {
1✔
492
                        $anchor = '^';
1✔
493
                        $mask = substr($mask, 2);
1✔
494
                } else {
495
                        $anchor = '(?:^|/)';
1✔
496
                }
497

498
                $pattern = strtr(
1✔
499
                        preg_quote($mask, '#'),
1✔
500
                        [
501
                                '\*\*/' => '(.+/)?',
1✔
502
                                '\*' => '[^/]*',
503
                                '\?' => '[^/]',
504
                                '\[\!' => '[^',
505
                                '\[' => '[',
506
                                '\]' => ']',
507
                                '\-' => '-',
508
                        ],
509
                );
510
                return '#' . $anchor . $pattern . '$#D' . (Helpers::IsWindows ? 'i' : '');
1✔
511
        }
512
}
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