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