1<?php
2declare(strict_types=1);
3
4namespace ILIAS\Filesystem\Finder;
5
6use ILIAS\Filesystem\DTO\Metadata;
7use ILIAS\Filesystem\Filesystem;
8use ILIAS\Filesystem\MetadataType;
9
10/**
11 * Class Finder
12 * Port of the Symfony2 bundle to work with the ILIAS FileSystem abstraction
13 * @package ILIAS\Filesystem\Finder
14 * @see     : https://github.com/symfony/finder
15 * @author  Michael Jansen <mjansen@databay.de>
16 */
17final class Finder implements \IteratorAggregate, \Countable
18{
19    const IGNORE_VCS_FILES = 1;
20    const IGNORE_DOT_FILES = 2;
21
22    /** @var Filesystem */
23    private $filesystem;
24
25    /** @var string[] */
26    private $vcsPatterns = ['.svn', '_svn', 'CVS', '_darcs', '.arch-params', '.monotone', '.bzr', '.git', '.hg'];
27
28    /** @var \Iterator[] */
29    private $iterators = [];
30
31    /** @var string[] */
32    protected $dirs = [];
33
34    /** @var string[] */
35    private $exclude = [];
36
37    /** @var int */
38    private $ignore = 0;
39
40    /** @var int */
41    private $mode = Iterator\FileTypeFilterIterator::ALL;
42
43    /** @var bool */
44    private $reverseSorting = false;
45
46    /** @var Comparator\DateComparator[] */
47    private $dates = [];
48
49    /** @var Comparator\NumberComparator[] */
50    private $sizes = [];
51
52    /** @var Comparator\NumberComparator[] */
53    private $depths = [];
54
55    /** @var bool */
56    private $sort = false;
57
58    /**
59     * Finder constructor.
60     * @param Filesystem $filesystem
61     */
62    public function __construct(Filesystem $filesystem)
63    {
64        $this->filesystem = $filesystem;
65        $this->ignore = static::IGNORE_VCS_FILES | static::IGNORE_DOT_FILES;
66    }
67
68    /**
69     * @return Finder
70     */
71    public function files() : self
72    {
73        $clone = clone $this;
74        $clone->mode = Iterator\FileTypeFilterIterator::ONLY_FILES;
75
76        return $clone;
77    }
78
79    /**
80     * @return Finder
81     */
82    public function directories() : self
83    {
84        $clone = clone $this;
85        $clone->mode = Iterator\FileTypeFilterIterator::ONLY_DIRECTORIES;
86
87        return $clone;
88    }
89
90    /**
91     * @return Finder
92     */
93    public function allTypes() : self
94    {
95        $clone = clone $this;
96        $clone->mode = Iterator\FileTypeFilterIterator::ALL;
97
98        return $clone;
99    }
100
101    /**
102     * @param string[] $directories
103     * @return Finder
104     */
105    public function exclude(array $directories) : self
106    {
107        array_walk($directories, function ($directory) {
108            if (!is_string($directory)) {
109                if (is_object($directory)) {
110                    throw new \InvalidArgumentException(sprintf('Invalid directory given: %s', get_class($directory)));
111                }
112
113                throw new \InvalidArgumentException(sprintf('Invalid directory given: %s', gettype($directory)));
114            }
115        });
116
117        $clone = clone $this;
118        $clone->exclude = array_merge($clone->exclude, $directories);
119
120        return $clone;
121    }
122
123    /**
124     * @param string[] $directories
125     * @return Finder
126     */
127    public function in(array $directories) : self
128    {
129        array_walk($directories, function ($directory) {
130            if (!is_string($directory)) {
131                if (is_object($directory)) {
132                    throw new \InvalidArgumentException(sprintf('Invalid directory given: %s', get_class($directory)));
133                }
134
135                throw new \InvalidArgumentException(sprintf('Invalid directory given: %s', gettype($directory)));
136            }
137        });
138
139        $clone = clone $this;
140        $clone->dirs = array_unique(array_merge($clone->dirs, $directories));
141
142        return $clone;
143    }
144
145    /**
146     * Adds tests for the directory depth.
147     * Usage:
148     *
149     *     $finder->depth('> 1') // the Finder will start matching at level 1.
150     *     $finder->depth('< 3') // the Finder will descend at most 3 levels of directories below the starting point.
151     *
152     * @param string|int $level The depth level expression
153     * @return Finder
154     * @see DepthRangeFilterIterator
155     * @see NumberComparator
156     */
157    public function depth($level) : self
158    {
159        $clone = clone $this;
160        $clone->depths[] = new Comparator\NumberComparator((string) $level);
161
162        return $clone;
163    }
164
165    /**
166     * Adds tests for file dates.
167     * The date must be something that strtotime() is able to parse:
168     *
169     *     $finder->date('since yesterday');
170     *     $finder->date('until 2 days ago');
171     *     $finder->date('> now - 2 hours');
172     *     $finder->date('>= 2005-10-15');
173     *
174     * @param string $date A date range string
175     * @return Finder
176     * @see strtotime
177     * @see DateRangeFilterIterator
178     * @see DateComparator
179     * @see \ILIAS\FileSystem\Filesystem::getTimestamp()
180     */
181    public function date(string $date) : self
182    {
183        $clone = clone $this;
184        $clone->dates[] = new Comparator\DateComparator($date);
185
186        return $clone;
187    }
188
189    /**
190     * Adds tests for file sizes.
191     *
192     *     $finder->size('> 10K');
193     *     $finder->size('<= 1Ki');
194     *     $finder->size(4);
195     *     $finder->size(['> 10K', '< 20K'])
196     *
197     * @param string|int|string[]|int[] $sizes A size range string or an integer or an array of size ranges
198     * @return Finder
199     * @see SizeRangeFilterIterator
200     * @see NumberComparator
201     * @see \ILIAS\FileSystem\Filesystem::getSize()
202     */
203    public function size($sizes) : self
204    {
205        if (!is_array($sizes)) {
206            $sizes = [$sizes];
207        }
208
209        $clone = clone $this;
210
211        foreach ($sizes as $size) {
212            $clone->sizes[] = new Comparator\NumberComparator((string) $size);
213        }
214
215        return $clone;
216    }
217
218    /**
219     * @return Finder
220     */
221    public function reverseSorting() : self
222    {
223        $clone = clone $this;
224        $clone->reverseSorting = true;
225
226        return $clone;
227    }
228
229    /**
230     * @param bool $ignoreVCS
231     * @return Finder
232     */
233    public function ignoreVCS(bool $ignoreVCS) : self
234    {
235        $clone = clone $this;
236        if ($ignoreVCS) {
237            $clone->ignore |= static::IGNORE_VCS_FILES;
238        } else {
239            $clone->ignore &= ~static::IGNORE_VCS_FILES;
240        }
241
242        return $clone;
243    }
244
245    /**
246     * @param string[] $pattern
247     * @return Finder
248     */
249    public function addVCSPattern(array $pattern) : self
250    {
251        array_walk($pattern, function ($p) {
252            if (!is_string($p)) {
253                if (is_object($p)) {
254                    throw new \InvalidArgumentException(sprintf('Invalid pattern given: %s', get_class($p)));
255                }
256
257                throw new \InvalidArgumentException(sprintf('Invalid pattern given: %s', gettype($p)));
258            }
259        });
260
261        $clone = clone $this;
262        foreach ($pattern as $p) {
263            $clone->vcsPatterns[] = $p;
264        }
265
266        $clone->vcsPatterns = array_unique($clone->vcsPatterns);
267
268        return $clone;
269    }
270
271    /**
272     * Sorts files and directories by an anonymous function.
273     * The anonymous function receives two Metadata instances to compare.
274     * This can be slow as all the matching files and directories must be retrieved for comparison.
275     * @param \Closure $closure
276     * @return Finder
277     */
278    public function sort(\Closure $closure) : self
279    {
280        $clone = clone $this;
281        $clone->sort = $closure;
282
283        return $clone;
284    }
285
286    /**
287     * @param bool $useNaturalSort
288     * @return Finder
289     */
290    public function sortByName(bool $useNaturalSort = false) : self
291    {
292        $clone = clone $this;
293        $clone->sort = Iterator\SortableIterator::SORT_BY_NAME;
294        if ($useNaturalSort) {
295            $clone->sort = Iterator\SortableIterator::SORT_BY_NAME_NATURAL;
296        }
297
298        return $clone;
299    }
300
301    /**
302     * @return Finder
303     */
304    public function sortByType() : self
305    {
306        $clone = clone $this;
307        $clone->sort = Iterator\SortableIterator::SORT_BY_TYPE;
308
309        return $clone;
310    }
311
312    /**
313     * @return Finder
314     */
315    public function sortByTime() : self
316    {
317        $clone = clone $this;
318        $clone->sort = Iterator\SortableIterator::SORT_BY_TIME;
319
320        return $clone;
321    }
322
323    /**
324     * Appends an existing set of files/directories to the finder.
325     * The set can be another Finder, an Iterator, an IteratorAggregate, or even a plain array.
326     * @param iterable $iterator
327     * @return Finder
328     * @throws \InvalidArgumentException when the given argument is not iterable
329     */
330    public function append(iterable $iterator) : self
331    {
332        $clone = clone $this;
333
334        if ($iterator instanceof \IteratorAggregate) {
335            $clone->iterators[] = $iterator->getIterator();
336        } elseif ($iterator instanceof \Iterator) {
337            $clone->iterators[] = $iterator;
338        } elseif ($iterator instanceof \Traversable || is_array($iterator)) {
339            $it = new \ArrayIterator();
340            foreach ($iterator as $file) {
341                if ($file instanceof MetadataType) {
342                    $it->append($file);
343                } else {
344                    throw new \InvalidArgumentException('Finder::append() method wrong argument type in passed iterator.');
345                }
346            }
347            $clone->iterators[] = $it;
348        } else {
349            throw new \InvalidArgumentException('Finder::append() method wrong argument type.');
350        }
351
352        return $clone;
353    }
354
355    /**
356     * @param string $dir
357     * @return \Iterator
358     */
359    private function searchInDirectory(string $dir) : \Iterator
360    {
361        if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) {
362            $this->exclude = array_merge($this->exclude, $this->vcsPatterns);
363        }
364
365        $iterator = new Iterator\RecursiveDirectoryIterator($this->filesystem, $dir);
366
367        if ($this->exclude) {
368            $iterator = new Iterator\ExcludeDirectoryFilterIterator($iterator, $this->exclude);
369        }
370
371        $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::SELF_FIRST);
372
373        if ($this->depths) {
374            $iterator = new Iterator\DepthRangeFilterIterator($iterator, $this->depths);
375        }
376
377        if ($this->mode) {
378            $iterator = new Iterator\FileTypeFilterIterator($iterator, $this->mode);
379        }
380
381        if ($this->dates) {
382            $iterator = new Iterator\DateRangeFilterIterator($this->filesystem, $iterator, $this->dates);
383        }
384
385        if ($this->sizes) {
386            $iterator = new Iterator\SizeRangeFilterIterator($this->filesystem, $iterator, $this->sizes);
387        }
388
389        if ($this->sort || $this->reverseSorting) {
390            $iteratorAggregate = new Iterator\SortableIterator(
391                $this->filesystem,
392                $iterator,
393                $this->sort,
394                $this->reverseSorting
395            );
396            $iterator = $iteratorAggregate->getIterator();
397        }
398
399        return $iterator;
400    }
401
402    /**
403     * @inheritdoc
404     * @return \Iterator|Metadata[]
405     */
406    public function getIterator()
407    {
408        if (0 === count($this->dirs) && 0 === count($this->iterators)) {
409            throw new \LogicException('You must call one of in() or append() methods before iterating over a Finder.');
410        }
411
412        if (1 === count($this->dirs) && 0 === count($this->iterators)) {
413            return $this->searchInDirectory($this->dirs[0]);
414        }
415
416        $iterator = new \AppendIterator();
417        foreach ($this->dirs as $dir) {
418            $iterator->append($this->searchInDirectory($dir));
419        }
420
421        foreach ($this->iterators as $it) {
422            $iterator->append($it);
423        }
424
425        return $iterator;
426    }
427
428    /**
429     * @inheritdoc
430     */
431    public function count()
432    {
433        return iterator_count($this->getIterator());
434    }
435}
436