1<?php
2
3/**
4 * Hoa
5 *
6 *
7 * @license
8 *
9 * New BSD License
10 *
11 * Copyright © 2007-2017, Hoa community. All rights reserved.
12 *
13 * Redistribution and use in source and binary forms, with or without
14 * modification, are permitted provided that the following conditions are met:
15 *     * Redistributions of source code must retain the above copyright
16 *       notice, this list of conditions and the following disclaimer.
17 *     * Redistributions in binary form must reproduce the above copyright
18 *       notice, this list of conditions and the following disclaimer in the
19 *       documentation and/or other materials provided with the distribution.
20 *     * Neither the name of the Hoa nor the names of its contributors may be
21 *       used to endorse or promote products derived from this software without
22 *       specific prior written permission.
23 *
24 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
25 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
26 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
27 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE
28 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
29 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
30 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
31 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
32 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
33 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
34 * POSSIBILITY OF SUCH DAMAGE.
35 */
36
37namespace Hoa\File;
38
39use Hoa\Iterator;
40
41/**
42 * Class \Hoa\File\Finder.
43 *
44 * This class allows to find files easily by using filters and flags.
45 *
46 * @copyright  Copyright © 2007-2017 Hoa community
47 * @license    New BSD License
48 */
49class Finder implements Iterator\Aggregate
50{
51    /**
52     * SplFileInfo classname.
53     *
54     * @var string
55     */
56    protected $_splFileInfo = 'Hoa\File\SplFileInfo';
57
58    /**
59     * Paths where to look for.
60     *
61     * @var array
62     */
63    protected $_paths       = [];
64
65    /**
66     * Max depth in recursion.
67     *
68     * @var int
69     */
70    protected $_maxDepth    = -1;
71
72    /**
73     * Filters.
74     *
75     * @var array
76     */
77    protected $_filters     = [];
78
79    /**
80     * Flags.
81     *
82     * @var int
83     */
84    protected $_flags       = -1;
85
86    /**
87     * Types of files to handle.
88     *
89     * @var array
90     */
91    protected $_types       = [];
92
93    /**
94     * What comes first: parent or child?
95     *
96     * @var int
97     */
98    protected $_first       = -1;
99
100    /**
101     * Sorts.
102     *
103     * @var array
104     */
105    protected $_sorts       = [];
106
107
108
109    /**
110     * Initialize.
111     *
112     */
113    public function __construct()
114    {
115        $this->_flags =   Iterator\FileSystem::KEY_AS_PATHNAME
116                        | Iterator\FileSystem::CURRENT_AS_FILEINFO
117                        | Iterator\FileSystem::SKIP_DOTS;
118        $this->_first = Iterator\Recursive\Iterator::SELF_FIRST;
119
120        return;
121    }
122
123    /**
124     * Select a directory to scan.
125     *
126     * @param   string|array  $paths    One or more paths.
127     * @return  \Hoa\File\Finder
128     */
129    public function in($paths)
130    {
131        if (!is_array($paths)) {
132            $paths = [$paths];
133        }
134
135        foreach ($paths as $path) {
136            if (1 === preg_match('/[\*\?\[\]]/', $path)) {
137                $iterator = new Iterator\CallbackFilter(
138                    new Iterator\Glob(rtrim($path, DS)),
139                    function ($current) {
140                        return $current->isDir();
141                    }
142                );
143
144                foreach ($iterator as $fileInfo) {
145                    $this->_paths[] = $fileInfo->getPathname();
146                }
147            } else {
148                $this->_paths[] = $path;
149            }
150        }
151
152        return $this;
153    }
154
155    /**
156     * Set max depth for recursion.
157     *
158     * @param   int  $depth    Depth.
159     * @return  \Hoa\File\Finder
160     */
161    public function maxDepth($depth)
162    {
163        $this->_maxDepth = $depth;
164
165        return $this;
166    }
167
168    /**
169     * Include files in the result.
170     *
171     * @return  \Hoa\File\Finder
172     */
173    public function files()
174    {
175        $this->_types[] = 'file';
176
177        return $this;
178    }
179
180    /**
181     * Include directories in the result.
182     *
183     * @return  \Hoa\File\Finder
184     */
185    public function directories()
186    {
187        $this->_types[] = 'dir';
188
189        return $this;
190    }
191
192    /**
193     * Include links in the result.
194     *
195     * @return  \Hoa\File\Finder
196     */
197    public function links()
198    {
199        $this->_types[] = 'link';
200
201        return $this;
202    }
203
204    /**
205     * Follow symbolink links.
206     *
207     * @param   bool  $flag    Whether we follow or not.
208     * @return  \Hoa\File\Finder
209     */
210    public function followSymlinks($flag = true)
211    {
212        if (true === $flag) {
213            $this->_flags ^= Iterator\FileSystem::FOLLOW_SYMLINKS;
214        } else {
215            $this->_flags |= Iterator\FileSystem::FOLLOW_SYMLINKS;
216        }
217
218        return $this;
219    }
220
221    /**
222     * Include files that match a regex.
223     * Example:
224     *     $this->name('#\.php$#');
225     *
226     * @param   string  $regex    Regex.
227     * @return  \Hoa\File\Finder
228     */
229    public function name($regex)
230    {
231        $this->_filters[] = function (\SplFileInfo $current) use ($regex) {
232            return 0 !== preg_match($regex, $current->getBasename());
233        };
234
235        return $this;
236    }
237
238    /**
239     * Exclude directories that match a regex.
240     * Example:
241     *      $this->notIn('#^\.(git|hg)$#');
242     *
243     * @param   string  $regex    Regex.
244     * @return  \Hoa\File\Finder
245     */
246    public function notIn($regex)
247    {
248        $this->_filters[] = function (\SplFileInfo $current) use ($regex) {
249            foreach (explode(DS, $current->getPathname()) as $part) {
250                if (0 !== preg_match($regex, $part)) {
251                    return false;
252                }
253            }
254
255            return true;
256        };
257
258        return $this;
259    }
260
261    /**
262     * Include files that respect a certain size.
263     * The size is a string of the form:
264     *     operator number unit
265     * where
266     *     • operator could be: <, <=, >, >= or =;
267     *     • number is a positive integer;
268     *     • unit could be: b (default), Kb, Mb, Gb, Tb, Pb, Eb, Zb, Yb.
269     * Example:
270     *     $this->size('>= 12Kb');
271     *
272     * @param   string  $size    Size.
273     * @return  \Hoa\File\Finder
274     */
275    public function size($size)
276    {
277        if (0 === preg_match('#^(<|<=|>|>=|=)\s*(\d+)\s*((?:[KMGTPEZY])b)?$#', $size, $matches)) {
278            return $this;
279        }
280
281        $number   = floatval($matches[2]);
282        $unit     = isset($matches[3]) ? $matches[3] : 'b';
283        $operator = $matches[1];
284
285        switch ($unit) {
286
287            case 'b':
288                break;
289
290            // kilo
291            case 'Kb':
292                $number <<= 10;
293
294                break;
295
296            // mega.
297            case 'Mb':
298                $number <<= 20;
299
300                break;
301
302            // giga.
303            case 'Gb':
304                $number <<= 30;
305
306                break;
307
308            // tera.
309            case 'Tb':
310                $number *= 1099511627776;
311
312                break;
313
314            // peta.
315            case 'Pb':
316                $number *= pow(1024, 5);
317
318                break;
319
320            // exa.
321            case 'Eb':
322                $number *= pow(1024, 6);
323
324                break;
325
326            // zetta.
327            case 'Zb':
328                $number *= pow(1024, 7);
329
330                break;
331
332            // yota.
333            case 'Yb':
334                $number *= pow(1024, 8);
335
336                break;
337        }
338
339        $filter = null;
340
341        switch ($operator) {
342            case '<':
343                $filter = function (\SplFileInfo $current) use ($number) {
344                    return $current->getSize() < $number;
345                };
346
347                break;
348
349            case '<=':
350                $filter = function (\SplFileInfo $current) use ($number) {
351                    return $current->getSize() <= $number;
352                };
353
354                break;
355
356            case '>':
357                $filter = function (\SplFileInfo $current) use ($number) {
358                    return $current->getSize() > $number;
359                };
360
361                break;
362
363            case '>=':
364                $filter = function (\SplFileInfo $current) use ($number) {
365                    return $current->getSize() >= $number;
366                };
367
368                break;
369
370            case '=':
371                $filter = function (\SplFileInfo $current) use ($number) {
372                    return $current->getSize() === $number;
373                };
374
375                break;
376        }
377
378        $this->_filters[] = $filter;
379
380        return $this;
381    }
382
383    /**
384     * Whether we should include dots or not (respectively . and ..).
385     *
386     * @param   bool  $flag    Include or not.
387     * @return  \Hoa\File\Finder
388     */
389    public function dots($flag = true)
390    {
391        if (true === $flag) {
392            $this->_flags ^= Iterator\FileSystem::SKIP_DOTS;
393        } else {
394            $this->_flags |= Iterator\FileSystem::SKIP_DOTS;
395        }
396
397        return $this;
398    }
399
400    /**
401     * Include files that are owned by a certain owner.
402     *
403     * @param   int  $owner    Owner.
404     * @return  \Hoa\File\Finder
405     */
406    public function owner($owner)
407    {
408        $this->_filters[] = function (\SplFileInfo $current) use ($owner) {
409            return $current->getOwner() === $owner;
410        };
411
412        return $this;
413    }
414
415    /**
416     * Format date.
417     * Date can have the following syntax:
418     *     date
419     *     since date
420     *     until date
421     * If the date does not have the “ago” keyword, it will be added.
422     * Example: “42 hours” is equivalent to “since 42 hours” which is equivalent
423     * to “since 42 hours ago”.
424     *
425     * @param   string  $date         Date.
426     * @param   int     &$operator    Operator (-1 for since, 1 for until).
427     * @return  int
428     */
429    protected function formatDate($date, &$operator)
430    {
431        $operator = -1;
432
433        if (0 === preg_match('#\bago\b#', $date)) {
434            $date .= ' ago';
435        }
436
437        if (0 !== preg_match('#^(since|until)\b(.+)$#', $date, $matches)) {
438            $time = strtotime($matches[2]);
439
440            if ('until' === $matches[1]) {
441                $operator = 1;
442            }
443        } else {
444            $time = strtotime($date);
445        }
446
447        return $time;
448    }
449
450    /**
451     * Include files that have been changed from a certain date.
452     * Example:
453     *     $this->changed('since 13 days');
454     *
455     * @param   string  $date    Date.
456     * @return  \Hoa\File\Finder
457     */
458    public function changed($date)
459    {
460        $time = $this->formatDate($date, $operator);
461
462        if (-1 === $operator) {
463            $this->_filters[] = function (\SplFileInfo $current) use ($time) {
464                return $current->getCTime() >= $time;
465            };
466        } else {
467            $this->_filters[] = function (\SplFileInfo $current) use ($time) {
468                return $current->getCTime() < $time;
469            };
470        }
471
472        return $this;
473    }
474
475    /**
476     * Include files that have been modified from a certain date.
477     * Example:
478     *     $this->modified('since 13 days');
479     *
480     * @param   string  $date    Date.
481     * @return  \Hoa\File\Finder
482     */
483    public function modified($date)
484    {
485        $time = $this->formatDate($date, $operator);
486
487        if (-1 === $operator) {
488            $this->_filters[] = function (\SplFileInfo $current) use ($time) {
489                return $current->getMTime() >= $time;
490            };
491        } else {
492            $this->_filters[] = function (\SplFileInfo $current) use ($time) {
493                return $current->getMTime() < $time;
494            };
495        }
496
497        return $this;
498    }
499
500    /**
501     * Add your own filter.
502     * The callback will receive 3 arguments: $current, $key and $iterator. It
503     * must return a boolean: true to include the file, false to exclude it.
504     * Example:
505     *     // Include files that are readable
506     *     $this->filter(function ($current) {
507     *         return $current->isReadable();
508     *     });
509     *
510     * @param   callable  $callback    Callback
511     * @return  \Hoa\File\Finder
512     */
513    public function filter($callback)
514    {
515        $this->_filters[] = $callback;
516
517        return $this;
518    }
519
520    /**
521     * Sort result by name.
522     * If \Collator exists (from ext/intl), the $locale argument will be used
523     * for its constructor. Else, strcmp() will be used.
524     * Example:
525     *     $this->sortByName('fr_FR');
526     *
527     * @param   string  $locale   Locale.
528     * @return  \Hoa\File\Finder
529     */
530    public function sortByName($locale = 'root')
531    {
532        if (true === class_exists('Collator', false)) {
533            $collator = new \Collator($locale);
534
535            $this->_sorts[] = function (\SplFileInfo $a, \SplFileInfo $b) use ($collator) {
536                return $collator->compare($a->getPathname(), $b->getPathname());
537            };
538        } else {
539            $this->_sorts[] = function (\SplFileInfo $a, \SplFileInfo $b) {
540                return strcmp($a->getPathname(), $b->getPathname());
541            };
542        }
543
544        return $this;
545    }
546
547    /**
548     * Sort result by size.
549     * Example:
550     *     $this->sortBySize();
551     *
552     * @return  \Hoa\File\Finder
553     */
554    public function sortBySize()
555    {
556        $this->_sorts[] = function (\SplFileInfo $a, \SplFileInfo $b) {
557            return $a->getSize() < $b->getSize();
558        };
559
560        return $this;
561    }
562
563    /**
564     * Add your own sort.
565     * The callback will receive 2 arguments: $a and $b. Please see the uasort()
566     * function.
567     * Example:
568     *     // Sort files by their modified time.
569     *     $this->sort(function ($a, $b) {
570     *         return $a->getMTime() < $b->getMTime();
571     *     });
572     *
573     * @param   callable  $callable    Callback.
574     * @return  \Hoa\File\Finder
575     */
576    public function sort($callable)
577    {
578        $this->_sorts[] = $callable;
579
580        return $this;
581    }
582
583    /**
584     * Child comes first when iterating.
585     *
586     * @return  \Hoa\File\Finder
587     */
588    public function childFirst()
589    {
590        $this->_first = Iterator\Recursive\Iterator::CHILD_FIRST;
591
592        return $this;
593    }
594
595    /**
596     * Get the iterator.
597     *
598     * @return  \Traversable
599     */
600    public function getIterator()
601    {
602        $_iterator = new Iterator\Append();
603        $types     = $this->getTypes();
604
605        if (!empty($types)) {
606            $this->_filters[] = function (\SplFileInfo $current) use ($types) {
607                return in_array($current->getType(), $types);
608            };
609        }
610
611        $maxDepth    = $this->getMaxDepth();
612        $splFileInfo = $this->getSplFileInfo();
613
614        foreach ($this->getPaths() as $path) {
615            if (1 == $maxDepth) {
616                $iterator = new Iterator\IteratorIterator(
617                    new Iterator\Recursive\Directory(
618                        $path,
619                        $this->getFlags(),
620                        $splFileInfo
621                    ),
622                    $this->getFirst()
623                );
624            } else {
625                $iterator = new Iterator\Recursive\Iterator(
626                    new Iterator\Recursive\Directory(
627                        $path,
628                        $this->getFlags(),
629                        $splFileInfo
630                    ),
631                    $this->getFirst()
632                );
633
634                if (1 < $maxDepth) {
635                    $iterator->setMaxDepth($maxDepth - 1);
636                }
637            }
638
639            $_iterator->append($iterator);
640        }
641
642        foreach ($this->getFilters() as $filter) {
643            $_iterator = new Iterator\CallbackFilter(
644                $_iterator,
645                $filter
646            );
647        }
648
649        $sorts = $this->getSorts();
650
651        if (empty($sorts)) {
652            return $_iterator;
653        }
654
655        $array = iterator_to_array($_iterator);
656
657        foreach ($sorts as $sort) {
658            uasort($array, $sort);
659        }
660
661        return new Iterator\Map($array);
662    }
663
664    /**
665     * Set SplFileInfo classname.
666     *
667     * @param   string  $splFileInfo    SplFileInfo classname.
668     * @return  string
669     */
670    public function setSplFileInfo($splFileInfo)
671    {
672        $old                = $this->_splFileInfo;
673        $this->_splFileInfo = $splFileInfo;
674
675        return $old;
676    }
677
678    /**
679     * Get SplFileInfo classname.
680     *
681     * @return  string
682     */
683    public function getSplFileInfo()
684    {
685        return $this->_splFileInfo;
686    }
687
688    /**
689     * Get all paths.
690     *
691     * @return  array
692     */
693    protected function getPaths()
694    {
695        return $this->_paths;
696    }
697
698    /**
699     * Get max depth.
700     *
701     * @return  int
702     */
703    public function getMaxDepth()
704    {
705        return $this->_maxDepth;
706    }
707
708    /**
709     * Get types.
710     *
711     * @return  array
712     */
713    public function getTypes()
714    {
715        return $this->_types;
716    }
717
718    /**
719     * Get filters.
720     *
721     * @return  array
722     */
723    protected function getFilters()
724    {
725        return $this->_filters;
726    }
727
728    /**
729     * Get sorts.
730     *
731     * @return  array
732     */
733    protected function getSorts()
734    {
735        return $this->_sorts;
736    }
737
738    /**
739     * Get flags.
740     *
741     * @return  int
742     */
743    public function getFlags()
744    {
745        return $this->_flags;
746    }
747
748    /**
749     * Get first.
750     *
751     * @return  int
752     */
753    public function getFirst()
754    {
755        return $this->_first;
756    }
757}
758