1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Finder;
13
14use Symfony\Component\Finder\Comparator\DateComparator;
15use Symfony\Component\Finder\Comparator\NumberComparator;
16use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
17use Symfony\Component\Finder\Iterator\CustomFilterIterator;
18use Symfony\Component\Finder\Iterator\DateRangeFilterIterator;
19use Symfony\Component\Finder\Iterator\DepthRangeFilterIterator;
20use Symfony\Component\Finder\Iterator\ExcludeDirectoryFilterIterator;
21use Symfony\Component\Finder\Iterator\FilecontentFilterIterator;
22use Symfony\Component\Finder\Iterator\FilenameFilterIterator;
23use Symfony\Component\Finder\Iterator\LazyIterator;
24use Symfony\Component\Finder\Iterator\SizeRangeFilterIterator;
25use Symfony\Component\Finder\Iterator\SortableIterator;
26
27/**
28 * Finder allows to build rules to find files and directories.
29 *
30 * It is a thin wrapper around several specialized iterator classes.
31 *
32 * All rules may be invoked several times.
33 *
34 * All methods return the current Finder object to allow chaining:
35 *
36 * $finder = Finder::create()->files()->name('*.php')->in(__DIR__);
37 *
38 * @author Fabien Potencier <fabien@symfony.com>
39 */
40class Finder implements \IteratorAggregate, \Countable
41{
42 public const IGNORE_VCS_FILES = 1;
43 public const IGNORE_DOT_FILES = 2;
44 public const IGNORE_VCS_IGNORED_FILES = 4;
45
46 private $mode = 0;
47 private $names = [];
48 private $notNames = [];
49 private $exclude = [];
50 private $filters = [];
51 private $depths = [];
52 private $sizes = [];
53 private $followLinks = false;
54 private $reverseSorting = false;
55 private $sort = false;
56 private $ignore = 0;
57 private $dirs = [];
58 private $dates = [];
59 private $iterators = [];
60 private $contains = [];
61 private $notContains = [];
62 private $paths = [];
63 private $notPaths = [];
64 private $ignoreUnreadableDirs = false;
65
66 private static $vcsPatterns = ['.svn', '_svn', 'CVS', '_darcs', '.arch-params', '.monotone', '.bzr', '.git', '.hg'];
67
68 public function __construct()
69 {
70 $this->ignore = static::IGNORE_VCS_FILES | static::IGNORE_DOT_FILES;
71 }
72
73 /**
74 * Creates a new Finder.
75 *
76 * @return static
77 */
78 public static function create()
79 {
80 return new static();
81 }
82
83 /**
84 * Restricts the matching to directories only.
85 *
86 * @return $this
87 */
88 public function directories()
89 {
90 $this->mode = Iterator\FileTypeFilterIterator::ONLY_DIRECTORIES;
91
92 return $this;
93 }
94
95 /**
96 * Restricts the matching to files only.
97 *
98 * @return $this
99 */
100 public function files()
101 {
102 $this->mode = Iterator\FileTypeFilterIterator::ONLY_FILES;
103
104 return $this;
105 }
106
107 /**
108 * Adds tests for the directory depth.
109 *
110 * Usage:
111 *
112 * $finder->depth('> 1') // the Finder will start matching at level 1.
113 * $finder->depth('< 3') // the Finder will descend at most 3 levels of directories below the starting point.
114 * $finder->depth(['>= 1', '< 3'])
115 *
116 * @param string|int|string[]|int[] $levels The depth level expression or an array of depth levels
117 *
118 * @return $this
119 *
120 * @see DepthRangeFilterIterator
121 * @see NumberComparator
122 */
123 public function depth($levels)
124 {
125 foreach ((array) $levels as $level) {
126 $this->depths[] = new Comparator\NumberComparator($level);
127 }
128
129 return $this;
130 }
131
132 /**
133 * Adds tests for file dates (last modified).
134 *
135 * The date must be something that strtotime() is able to parse:
136 *
137 * $finder->date('since yesterday');
138 * $finder->date('until 2 days ago');
139 * $finder->date('> now - 2 hours');
140 * $finder->date('>= 2005-10-15');
141 * $finder->date(['>= 2005-10-15', '<= 2006-05-27']);
142 *
143 * @param string|string[] $dates A date range string or an array of date ranges
144 *
145 * @return $this
146 *
147 * @see strtotime
148 * @see DateRangeFilterIterator
149 * @see DateComparator
150 */
151 public function date($dates)
152 {
153 foreach ((array) $dates as $date) {
154 $this->dates[] = new Comparator\DateComparator($date);
155 }
156
157 return $this;
158 }
159
160 /**
161 * Adds rules that files must match.
162 *
163 * You can use patterns (delimited with / sign), globs or simple strings.
164 *
165 * $finder->name('*.php')
166 * $finder->name('/\.php$/') // same as above
167 * $finder->name('test.php')
168 * $finder->name(['test.py', 'test.php'])
169 *
170 * @param string|string[] $patterns A pattern (a regexp, a glob, or a string) or an array of patterns
171 *
172 * @return $this
173 *
174 * @see FilenameFilterIterator
175 */
176 public function name($patterns)
177 {
178 $this->names = array_merge($this->names, (array) $patterns);
179
180 return $this;
181 }
182
183 /**
184 * Adds rules that files must not match.
185 *
186 * @param string|string[] $patterns A pattern (a regexp, a glob, or a string) or an array of patterns
187 *
188 * @return $this
189 *
190 * @see FilenameFilterIterator
191 */
192 public function notName($patterns)
193 {
194 $this->notNames = array_merge($this->notNames, (array) $patterns);
195
196 return $this;
197 }
198
199 /**
200 * Adds tests that file contents must match.
201 *
202 * Strings or PCRE patterns can be used:
203 *
204 * $finder->contains('Lorem ipsum')
205 * $finder->contains('/Lorem ipsum/i')
206 * $finder->contains(['dolor', '/ipsum/i'])
207 *
208 * @param string|string[] $patterns A pattern (string or regexp) or an array of patterns
209 *
210 * @return $this
211 *
212 * @see FilecontentFilterIterator
213 */
214 public function contains($patterns)
215 {
216 $this->contains = array_merge($this->contains, (array) $patterns);
217
218 return $this;
219 }
220
221 /**
222 * Adds tests that file contents must not match.
223 *
224 * Strings or PCRE patterns can be used:
225 *
226 * $finder->notContains('Lorem ipsum')
227 * $finder->notContains('/Lorem ipsum/i')
228 * $finder->notContains(['lorem', '/dolor/i'])
229 *
230 * @param string|string[] $patterns A pattern (string or regexp) or an array of patterns
231 *
232 * @return $this
233 *
234 * @see FilecontentFilterIterator
235 */
236 public function notContains($patterns)
237 {
238 $this->notContains = array_merge($this->notContains, (array) $patterns);
239
240 return $this;
241 }
242
243 /**
244 * Adds rules that filenames must match.
245 *
246 * You can use patterns (delimited with / sign) or simple strings.
247 *
248 * $finder->path('some/special/dir')
249 * $finder->path('/some\/special\/dir/') // same as above
250 * $finder->path(['some dir', 'another/dir'])
251 *
252 * Use only / as dirname separator.
253 *
254 * @param string|string[] $patterns A pattern (a regexp or a string) or an array of patterns
255 *
256 * @return $this
257 *
258 * @see FilenameFilterIterator
259 */
260 public function path($patterns)
261 {
262 $this->paths = array_merge($this->paths, (array) $patterns);
263
264 return $this;
265 }
266
267 /**
268 * Adds rules that filenames must not match.
269 *
270 * You can use patterns (delimited with / sign) or simple strings.
271 *
272 * $finder->notPath('some/special/dir')
273 * $finder->notPath('/some\/special\/dir/') // same as above
274 * $finder->notPath(['some/file.txt', 'another/file.log'])
275 *
276 * Use only / as dirname separator.
277 *
278 * @param string|string[] $patterns A pattern (a regexp or a string) or an array of patterns
279 *
280 * @return $this
281 *
282 * @see FilenameFilterIterator
283 */
284 public function notPath($patterns)
285 {
286 $this->notPaths = array_merge($this->notPaths, (array) $patterns);
287
288 return $this;
289 }
290
291 /**
292 * Adds tests for file sizes.
293 *
294 * $finder->size('> 10K');
295 * $finder->size('<= 1Ki');
296 * $finder->size(4);
297 * $finder->size(['> 10K', '< 20K'])
298 *
299 * @param string|int|string[]|int[] $sizes A size range string or an integer or an array of size ranges
300 *
301 * @return $this
302 *
303 * @see SizeRangeFilterIterator
304 * @see NumberComparator
305 */
306 public function size($sizes)
307 {
308 foreach ((array) $sizes as $size) {
309 $this->sizes[] = new Comparator\NumberComparator($size);
310 }
311
312 return $this;
313 }
314
315 /**
316 * Excludes directories.
317 *
318 * Directories passed as argument must be relative to the ones defined with the `in()` method. For example:
319 *
320 * $finder->in(__DIR__)->exclude('ruby');
321 *
322 * @param string|array $dirs A directory path or an array of directories
323 *
324 * @return $this
325 *
326 * @see ExcludeDirectoryFilterIterator
327 */
328 public function exclude($dirs)
329 {
330 $this->exclude = array_merge($this->exclude, (array) $dirs);
331
332 return $this;
333 }
334
335 /**
336 * Excludes "hidden" directories and files (starting with a dot).
337 *
338 * This option is enabled by default.
339 *
340 * @return $this
341 *
342 * @see ExcludeDirectoryFilterIterator
343 */
344 public function ignoreDotFiles(bool $ignoreDotFiles)
345 {
346 if ($ignoreDotFiles) {
347 $this->ignore |= static::IGNORE_DOT_FILES;
348 } else {
349 $this->ignore &= ~static::IGNORE_DOT_FILES;
350 }
351
352 return $this;
353 }
354
355 /**
356 * Forces the finder to ignore version control directories.
357 *
358 * This option is enabled by default.
359 *
360 * @return $this
361 *
362 * @see ExcludeDirectoryFilterIterator
363 */
364 public function ignoreVCS(bool $ignoreVCS)
365 {
366 if ($ignoreVCS) {
367 $this->ignore |= static::IGNORE_VCS_FILES;
368 } else {
369 $this->ignore &= ~static::IGNORE_VCS_FILES;
370 }
371
372 return $this;
373 }
374
375 /**
376 * Forces Finder to obey .gitignore and ignore files based on rules listed there.
377 *
378 * This option is disabled by default.
379 *
380 * @return $this
381 */
382 public function ignoreVCSIgnored(bool $ignoreVCSIgnored)
383 {
384 if ($ignoreVCSIgnored) {
385 $this->ignore |= static::IGNORE_VCS_IGNORED_FILES;
386 } else {
387 $this->ignore &= ~static::IGNORE_VCS_IGNORED_FILES;
388 }
389
390 return $this;
391 }
392
393 /**
394 * Adds VCS patterns.
395 *
396 * @see ignoreVCS()
397 *
398 * @param string|string[] $pattern VCS patterns to ignore
399 */
400 public static function addVCSPattern($pattern)
401 {
402 foreach ((array) $pattern as $p) {
403 self::$vcsPatterns[] = $p;
404 }
405
406 self::$vcsPatterns = array_unique(self::$vcsPatterns);
407 }
408
409 /**
410 * Sorts files and directories by an anonymous function.
411 *
412 * The anonymous function receives two \SplFileInfo instances to compare.
413 *
414 * This can be slow as all the matching files and directories must be retrieved for comparison.
415 *
416 * @return $this
417 *
418 * @see SortableIterator
419 */
420 public function sort(\Closure $closure)
421 {
422 $this->sort = $closure;
423
424 return $this;
425 }
426
427 /**
428 * Sorts files and directories by name.
429 *
430 * This can be slow as all the matching files and directories must be retrieved for comparison.
431 *
432 * @return $this
433 *
434 * @see SortableIterator
435 */
436 public function sortByName(bool $useNaturalSort = false)
437 {
438 $this->sort = $useNaturalSort ? Iterator\SortableIterator::SORT_BY_NAME_NATURAL : Iterator\SortableIterator::SORT_BY_NAME;
439
440 return $this;
441 }
442
443 /**
444 * Sorts files and directories by type (directories before files), then by name.
445 *
446 * This can be slow as all the matching files and directories must be retrieved for comparison.
447 *
448 * @return $this
449 *
450 * @see SortableIterator
451 */
452 public function sortByType()
453 {
454 $this->sort = Iterator\SortableIterator::SORT_BY_TYPE;
455
456 return $this;
457 }
458
459 /**
460 * Sorts files and directories by the last accessed time.
461 *
462 * This is the time that the file was last accessed, read or written to.
463 *
464 * This can be slow as all the matching files and directories must be retrieved for comparison.
465 *
466 * @return $this
467 *
468 * @see SortableIterator
469 */
470 public function sortByAccessedTime()
471 {
472 $this->sort = Iterator\SortableIterator::SORT_BY_ACCESSED_TIME;
473
474 return $this;
475 }
476
477 /**
478 * Reverses the sorting.
479 *
480 * @return $this
481 */
482 public function reverseSorting()
483 {
484 $this->reverseSorting = true;
485
486 return $this;
487 }
488
489 /**
490 * Sorts files and directories by the last inode changed time.
491 *
492 * This is the time that the inode information was last modified (permissions, owner, group or other metadata).
493 *
494 * On Windows, since inode is not available, changed time is actually the file creation time.
495 *
496 * This can be slow as all the matching files and directories must be retrieved for comparison.
497 *
498 * @return $this
499 *
500 * @see SortableIterator
501 */
502 public function sortByChangedTime()
503 {
504 $this->sort = Iterator\SortableIterator::SORT_BY_CHANGED_TIME;
505
506 return $this;
507 }
508
509 /**
510 * Sorts files and directories by the last modified time.
511 *
512 * This is the last time the actual contents of the file were last modified.
513 *
514 * This can be slow as all the matching files and directories must be retrieved for comparison.
515 *
516 * @return $this
517 *
518 * @see SortableIterator
519 */
520 public function sortByModifiedTime()
521 {
522 $this->sort = Iterator\SortableIterator::SORT_BY_MODIFIED_TIME;
523
524 return $this;
525 }
526
527 /**
528 * Filters the iterator with an anonymous function.
529 *
530 * The anonymous function receives a \SplFileInfo and must return false
531 * to remove files.
532 *
533 * @return $this
534 *
535 * @see CustomFilterIterator
536 */
537 public function filter(\Closure $closure)
538 {
539 $this->filters[] = $closure;
540
541 return $this;
542 }
543
544 /**
545 * Forces the following of symlinks.
546 *
547 * @return $this
548 */
549 public function followLinks()
550 {
551 $this->followLinks = true;
552
553 return $this;
554 }
555
556 /**
557 * Tells finder to ignore unreadable directories.
558 *
559 * By default, scanning unreadable directories content throws an AccessDeniedException.
560 *
561 * @return $this
562 */
563 public function ignoreUnreadableDirs(bool $ignore = true)
564 {
565 $this->ignoreUnreadableDirs = $ignore;
566
567 return $this;
568 }
569
570 /**
571 * Searches files and directories which match defined rules.
572 *
573 * @param string|string[] $dirs A directory path or an array of directories
574 *
575 * @return $this
576 *
577 * @throws DirectoryNotFoundException if one of the directories does not exist
578 */
579 public function in($dirs)
580 {
581 $resolvedDirs = [];
582
583 foreach ((array) $dirs as $dir) {
584 if (is_dir($dir)) {
585 $resolvedDirs[] = $this->normalizeDir($dir);
586 } elseif ($glob = glob($dir, (\defined('GLOB_BRACE') ? \GLOB_BRACE : 0) | \GLOB_ONLYDIR | \GLOB_NOSORT)) {
587 sort($glob);
588 $resolvedDirs = array_merge($resolvedDirs, array_map([$this, 'normalizeDir'], $glob));
589 } else {
590 throw new DirectoryNotFoundException(sprintf('The "%s" directory does not exist.', $dir));
591 }
592 }
593
594 $this->dirs = array_merge($this->dirs, $resolvedDirs);
595
596 return $this;
597 }
598
599 /**
600 * Returns an Iterator for the current Finder configuration.
601 *
602 * This method implements the IteratorAggregate interface.
603 *
604 * @return \Iterator|SplFileInfo[] An iterator
605 *
606 * @throws \LogicException if the in() method has not been called
607 */
608 #[\ReturnTypeWillChange]
609 public function getIterator()
610 {
611 if (0 === \count($this->dirs) && 0 === \count($this->iterators)) {
612 throw new \LogicException('You must call one of in() or append() methods before iterating over a Finder.');
613 }
614
615 if (1 === \count($this->dirs) && 0 === \count($this->iterators)) {
616 $iterator = $this->searchInDirectory($this->dirs[0]);
617
618 if ($this->sort || $this->reverseSorting) {
619 $iterator = (new Iterator\SortableIterator($iterator, $this->sort, $this->reverseSorting))->getIterator();
620 }
621
622 return $iterator;
623 }
624
625 $iterator = new \AppendIterator();
626 foreach ($this->dirs as $dir) {
627 $iterator->append(new \IteratorIterator(new LazyIterator(function () use ($dir) {
628 return $this->searchInDirectory($dir);
629 })));
630 }
631
632 foreach ($this->iterators as $it) {
633 $iterator->append($it);
634 }
635
636 if ($this->sort || $this->reverseSorting) {
637 $iterator = (new Iterator\SortableIterator($iterator, $this->sort, $this->reverseSorting))->getIterator();
638 }
639
640 return $iterator;
641 }
642
643 /**
644 * Appends an existing set of files/directories to the finder.
645 *
646 * The set can be another Finder, an Iterator, an IteratorAggregate, or even a plain array.
647 *
648 * @return $this
649 *
650 * @throws \InvalidArgumentException when the given argument is not iterable
651 */
652 public function append(iterable $iterator)
653 {
654 if ($iterator instanceof \IteratorAggregate) {
655 $this->iterators[] = $iterator->getIterator();
656 } elseif ($iterator instanceof \Iterator) {
657 $this->iterators[] = $iterator;
658 } elseif (is_iterable($iterator)) {
659 $it = new \ArrayIterator();
660 foreach ($iterator as $file) {
661 $file = $file instanceof \SplFileInfo ? $file : new \SplFileInfo($file);
662 $it[$file->getPathname()] = $file;
663 }
664 $this->iterators[] = $it;
665 } else {
666 throw new \InvalidArgumentException('Finder::append() method wrong argument type.');
667 }
668
669 return $this;
670 }
671
672 /**
673 * Check if any results were found.
674 *
675 * @return bool
676 */
677 public function hasResults()
678 {
679 foreach ($this->getIterator() as $_) {
680 return true;
681 }
682
683 return false;
684 }
685
686 /**
687 * Counts all the results collected by the iterators.
688 *
689 * @return int
690 */
691 #[\ReturnTypeWillChange]
692 public function count()
693 {
694 return iterator_count($this->getIterator());
695 }
696
697 private function searchInDirectory(string $dir): \Iterator
698 {
699 $exclude = $this->exclude;
700 $notPaths = $this->notPaths;
701
702 if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) {
703 $exclude = array_merge($exclude, self::$vcsPatterns);
704 }
705
706 if (static::IGNORE_DOT_FILES === (static::IGNORE_DOT_FILES & $this->ignore)) {
707 $notPaths[] = '#(^|/)\..+(/|$)#';
708 }
709
710 if (static::IGNORE_VCS_IGNORED_FILES === (static::IGNORE_VCS_IGNORED_FILES & $this->ignore)) {
711 $gitignoreFilePath = sprintf('%s/.gitignore', $dir);
712 if (!is_readable($gitignoreFilePath)) {
713 throw new \RuntimeException(sprintf('The "ignoreVCSIgnored" option cannot be used by the Finder as the "%s" file is not readable.', $gitignoreFilePath));
714 }
715 $notPaths = array_merge($notPaths, [Gitignore::toRegex(file_get_contents($gitignoreFilePath))]);
716 }
717
718 $minDepth = 0;
719 $maxDepth = \PHP_INT_MAX;
720
721 foreach ($this->depths as $comparator) {
722 switch ($comparator->getOperator()) {
723 case '>':
724 $minDepth = $comparator->getTarget() + 1;
725 break;
726 case '>=':
727 $minDepth = $comparator->getTarget();
728 break;
729 case '<':
730 $maxDepth = $comparator->getTarget() - 1;
731 break;
732 case '<=':
733 $maxDepth = $comparator->getTarget();
734 break;
735 default:
736 $minDepth = $maxDepth = $comparator->getTarget();
737 }
738 }
739
740 $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
741
742 if ($this->followLinks) {
743 $flags |= \RecursiveDirectoryIterator::FOLLOW_SYMLINKS;
744 }
745
746 $iterator = new Iterator\RecursiveDirectoryIterator($dir, $flags, $this->ignoreUnreadableDirs);
747
748 if ($exclude) {
749 $iterator = new Iterator\ExcludeDirectoryFilterIterator($iterator, $exclude);
750 }
751
752 $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::SELF_FIRST);
753
754 if ($minDepth > 0 || $maxDepth < \PHP_INT_MAX) {
755 $iterator = new Iterator\DepthRangeFilterIterator($iterator, $minDepth, $maxDepth);
756 }
757
758 if ($this->mode) {
759 $iterator = new Iterator\FileTypeFilterIterator($iterator, $this->mode);
760 }
761
762 if ($this->names || $this->notNames) {
763 $iterator = new Iterator\FilenameFilterIterator($iterator, $this->names, $this->notNames);
764 }
765
766 if ($this->contains || $this->notContains) {
767 $iterator = new Iterator\FilecontentFilterIterator($iterator, $this->contains, $this->notContains);
768 }
769
770 if ($this->sizes) {
771 $iterator = new Iterator\SizeRangeFilterIterator($iterator, $this->sizes);
772 }
773
774 if ($this->dates) {
775 $iterator = new Iterator\DateRangeFilterIterator($iterator, $this->dates);
776 }
777
778 if ($this->filters) {
779 $iterator = new Iterator\CustomFilterIterator($iterator, $this->filters);
780 }
781
782 if ($this->paths || $notPaths) {
783 $iterator = new Iterator\PathFilterIterator($iterator, $this->paths, $notPaths);
784 }
785
786 return $iterator;
787 }
788
789 /**
790 * Normalizes given directory names by removing trailing slashes.
791 *
792 * Excluding: (s)ftp:// or ssh2.(s)ftp:// wrapper
793 */
794 private function normalizeDir(string $dir): string
795 {
796 if ('/' === $dir) {
797 return $dir;
798 }
799
800 $dir = rtrim($dir, '/'.\DIRECTORY_SEPARATOR);
801
802 if (preg_match('#^(ssh2\.)?s?ftp://#', $dir)) {
803 $dir .= '/';
804 }
805
806 return $dir;
807 }
808}
809