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