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