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    public function getIterator()
609    {
610        if (0 === \count($this->dirs) && 0 === \count($this->iterators)) {
611            throw new \LogicException('You must call one of in() or append() methods before iterating over a Finder.');
612        }
613
614        if (1 === \count($this->dirs) && 0 === \count($this->iterators)) {
615            $iterator = $this->searchInDirectory($this->dirs[0]);
616
617            if ($this->sort || $this->reverseSorting) {
618                $iterator = (new Iterator\SortableIterator($iterator, $this->sort, $this->reverseSorting))->getIterator();
619            }
620
621            return $iterator;
622        }
623
624        $iterator = new \AppendIterator();
625        foreach ($this->dirs as $dir) {
626            $iterator->append(new \IteratorIterator(new LazyIterator(function () use ($dir) {
627                return $this->searchInDirectory($dir);
628            })));
629        }
630
631        foreach ($this->iterators as $it) {
632            $iterator->append($it);
633        }
634
635        if ($this->sort || $this->reverseSorting) {
636            $iterator = (new Iterator\SortableIterator($iterator, $this->sort, $this->reverseSorting))->getIterator();
637        }
638
639        return $iterator;
640    }
641
642    /**
643     * Appends an existing set of files/directories to the finder.
644     *
645     * The set can be another Finder, an Iterator, an IteratorAggregate, or even a plain array.
646     *
647     * @return $this
648     *
649     * @throws \InvalidArgumentException when the given argument is not iterable
650     */
651    public function append(iterable $iterator)
652    {
653        if ($iterator instanceof \IteratorAggregate) {
654            $this->iterators[] = $iterator->getIterator();
655        } elseif ($iterator instanceof \Iterator) {
656            $this->iterators[] = $iterator;
657        } elseif ($iterator instanceof \Traversable || \is_array($iterator)) {
658            $it = new \ArrayIterator();
659            foreach ($iterator as $file) {
660                $file = $file instanceof \SplFileInfo ? $file : new \SplFileInfo($file);
661                $it[$file->getPathname()] = $file;
662            }
663            $this->iterators[] = $it;
664        } else {
665            throw new \InvalidArgumentException('Finder::append() method wrong argument type.');
666        }
667
668        return $this;
669    }
670
671    /**
672     * Check if any results were found.
673     *
674     * @return bool
675     */
676    public function hasResults()
677    {
678        foreach ($this->getIterator() as $_) {
679            return true;
680        }
681
682        return false;
683    }
684
685    /**
686     * Counts all the results collected by the iterators.
687     *
688     * @return int
689     */
690    public function count()
691    {
692        return iterator_count($this->getIterator());
693    }
694
695    private function searchInDirectory(string $dir): \Iterator
696    {
697        $exclude = $this->exclude;
698        $notPaths = $this->notPaths;
699
700        if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) {
701            $exclude = array_merge($exclude, self::$vcsPatterns);
702        }
703
704        if (static::IGNORE_DOT_FILES === (static::IGNORE_DOT_FILES & $this->ignore)) {
705            $notPaths[] = '#(^|/)\..+(/|$)#';
706        }
707
708        if (static::IGNORE_VCS_IGNORED_FILES === (static::IGNORE_VCS_IGNORED_FILES & $this->ignore)) {
709            $gitignoreFilePath = sprintf('%s/.gitignore', $dir);
710            if (!is_readable($gitignoreFilePath)) {
711                throw new \RuntimeException(sprintf('The "ignoreVCSIgnored" option cannot be used by the Finder as the "%s" file is not readable.', $gitignoreFilePath));
712            }
713            $notPaths = array_merge($notPaths, [Gitignore::toRegex(file_get_contents($gitignoreFilePath))]);
714        }
715
716        $minDepth = 0;
717        $maxDepth = \PHP_INT_MAX;
718
719        foreach ($this->depths as $comparator) {
720            switch ($comparator->getOperator()) {
721                case '>':
722                    $minDepth = $comparator->getTarget() + 1;
723                    break;
724                case '>=':
725                    $minDepth = $comparator->getTarget();
726                    break;
727                case '<':
728                    $maxDepth = $comparator->getTarget() - 1;
729                    break;
730                case '<=':
731                    $maxDepth = $comparator->getTarget();
732                    break;
733                default:
734                    $minDepth = $maxDepth = $comparator->getTarget();
735            }
736        }
737
738        $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
739
740        if ($this->followLinks) {
741            $flags |= \RecursiveDirectoryIterator::FOLLOW_SYMLINKS;
742        }
743
744        $iterator = new Iterator\RecursiveDirectoryIterator($dir, $flags, $this->ignoreUnreadableDirs);
745
746        if ($exclude) {
747            $iterator = new Iterator\ExcludeDirectoryFilterIterator($iterator, $exclude);
748        }
749
750        $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::SELF_FIRST);
751
752        if ($minDepth > 0 || $maxDepth < \PHP_INT_MAX) {
753            $iterator = new Iterator\DepthRangeFilterIterator($iterator, $minDepth, $maxDepth);
754        }
755
756        if ($this->mode) {
757            $iterator = new Iterator\FileTypeFilterIterator($iterator, $this->mode);
758        }
759
760        if ($this->names || $this->notNames) {
761            $iterator = new Iterator\FilenameFilterIterator($iterator, $this->names, $this->notNames);
762        }
763
764        if ($this->contains || $this->notContains) {
765            $iterator = new Iterator\FilecontentFilterIterator($iterator, $this->contains, $this->notContains);
766        }
767
768        if ($this->sizes) {
769            $iterator = new Iterator\SizeRangeFilterIterator($iterator, $this->sizes);
770        }
771
772        if ($this->dates) {
773            $iterator = new Iterator\DateRangeFilterIterator($iterator, $this->dates);
774        }
775
776        if ($this->filters) {
777            $iterator = new Iterator\CustomFilterIterator($iterator, $this->filters);
778        }
779
780        if ($this->paths || $notPaths) {
781            $iterator = new Iterator\PathFilterIterator($iterator, $this->paths, $notPaths);
782        }
783
784        return $iterator;
785    }
786
787    /**
788     * Normalizes given directory names by removing trailing slashes.
789     *
790     * Excluding: (s)ftp:// or ssh2.(s)ftp:// wrapper
791     */
792    private function normalizeDir(string $dir): string
793    {
794        if ('/' === $dir) {
795            return $dir;
796        }
797
798        $dir = rtrim($dir, '/'.\DIRECTORY_SEPARATOR);
799
800        if (preg_match('#^(ssh2\.)?s?ftp://#', $dir)) {
801            $dir .= '/';
802        }
803
804        return $dir;
805    }
806}
807