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 public const IGNORE_VCS_FILES = 1; 42 public const IGNORE_DOT_FILES = 2; 43 public 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 * @return $this 340 * 341 * @see ExcludeDirectoryFilterIterator 342 */ 343 public function ignoreDotFiles(bool $ignoreDotFiles) 344 { 345 if ($ignoreDotFiles) { 346 $this->ignore |= static::IGNORE_DOT_FILES; 347 } else { 348 $this->ignore &= ~static::IGNORE_DOT_FILES; 349 } 350 351 return $this; 352 } 353 354 /** 355 * Forces the finder to ignore version control directories. 356 * 357 * This option is enabled by default. 358 * 359 * @return $this 360 * 361 * @see ExcludeDirectoryFilterIterator 362 */ 363 public function ignoreVCS(bool $ignoreVCS) 364 { 365 if ($ignoreVCS) { 366 $this->ignore |= static::IGNORE_VCS_FILES; 367 } else { 368 $this->ignore &= ~static::IGNORE_VCS_FILES; 369 } 370 371 return $this; 372 } 373 374 /** 375 * Forces Finder to obey .gitignore and ignore files based on rules listed there. 376 * 377 * This option is disabled by default. 378 * 379 * @return $this 380 */ 381 public function ignoreVCSIgnored(bool $ignoreVCSIgnored) 382 { 383 if ($ignoreVCSIgnored) { 384 $this->ignore |= static::IGNORE_VCS_IGNORED_FILES; 385 } else { 386 $this->ignore &= ~static::IGNORE_VCS_IGNORED_FILES; 387 } 388 389 return $this; 390 } 391 392 /** 393 * Adds VCS patterns. 394 * 395 * @see ignoreVCS() 396 * 397 * @param string|string[] $pattern VCS patterns to ignore 398 */ 399 public static function addVCSPattern($pattern) 400 { 401 foreach ((array) $pattern as $p) { 402 self::$vcsPatterns[] = $p; 403 } 404 405 self::$vcsPatterns = array_unique(self::$vcsPatterns); 406 } 407 408 /** 409 * Sorts files and directories by an anonymous function. 410 * 411 * The anonymous function receives two \SplFileInfo instances to compare. 412 * 413 * This can be slow as all the matching files and directories must be retrieved for comparison. 414 * 415 * @return $this 416 * 417 * @see SortableIterator 418 */ 419 public function sort(\Closure $closure) 420 { 421 $this->sort = $closure; 422 423 return $this; 424 } 425 426 /** 427 * Sorts files and directories by name. 428 * 429 * This can be slow as all the matching files and directories must be retrieved for comparison. 430 * 431 * @return $this 432 * 433 * @see SortableIterator 434 */ 435 public function sortByName(bool $useNaturalSort = false) 436 { 437 $this->sort = $useNaturalSort ? Iterator\SortableIterator::SORT_BY_NAME_NATURAL : Iterator\SortableIterator::SORT_BY_NAME; 438 439 return $this; 440 } 441 442 /** 443 * Sorts files and directories by type (directories before files), then by name. 444 * 445 * This can be slow as all the matching files and directories must be retrieved for comparison. 446 * 447 * @return $this 448 * 449 * @see SortableIterator 450 */ 451 public function sortByType() 452 { 453 $this->sort = Iterator\SortableIterator::SORT_BY_TYPE; 454 455 return $this; 456 } 457 458 /** 459 * Sorts files and directories by the last accessed time. 460 * 461 * This is the time that the file was last accessed, read or written to. 462 * 463 * This can be slow as all the matching files and directories must be retrieved for comparison. 464 * 465 * @return $this 466 * 467 * @see SortableIterator 468 */ 469 public function sortByAccessedTime() 470 { 471 $this->sort = Iterator\SortableIterator::SORT_BY_ACCESSED_TIME; 472 473 return $this; 474 } 475 476 /** 477 * Reverses the sorting. 478 * 479 * @return $this 480 */ 481 public function reverseSorting() 482 { 483 $this->reverseSorting = true; 484 485 return $this; 486 } 487 488 /** 489 * Sorts files and directories by the last inode changed time. 490 * 491 * This is the time that the inode information was last modified (permissions, owner, group or other metadata). 492 * 493 * On Windows, since inode is not available, changed time is actually the file creation time. 494 * 495 * This can be slow as all the matching files and directories must be retrieved for comparison. 496 * 497 * @return $this 498 * 499 * @see SortableIterator 500 */ 501 public function sortByChangedTime() 502 { 503 $this->sort = Iterator\SortableIterator::SORT_BY_CHANGED_TIME; 504 505 return $this; 506 } 507 508 /** 509 * Sorts files and directories by the last modified time. 510 * 511 * This is the last time the actual contents of the file were last modified. 512 * 513 * This can be slow as all the matching files and directories must be retrieved for comparison. 514 * 515 * @return $this 516 * 517 * @see SortableIterator 518 */ 519 public function sortByModifiedTime() 520 { 521 $this->sort = Iterator\SortableIterator::SORT_BY_MODIFIED_TIME; 522 523 return $this; 524 } 525 526 /** 527 * Filters the iterator with an anonymous function. 528 * 529 * The anonymous function receives a \SplFileInfo and must return false 530 * to remove files. 531 * 532 * @return $this 533 * 534 * @see CustomFilterIterator 535 */ 536 public function filter(\Closure $closure) 537 { 538 $this->filters[] = $closure; 539 540 return $this; 541 } 542 543 /** 544 * Forces the following of symlinks. 545 * 546 * @return $this 547 */ 548 public function followLinks() 549 { 550 $this->followLinks = true; 551 552 return $this; 553 } 554 555 /** 556 * Tells finder to ignore unreadable directories. 557 * 558 * By default, scanning unreadable directories content throws an AccessDeniedException. 559 * 560 * @return $this 561 */ 562 public function ignoreUnreadableDirs(bool $ignore = true) 563 { 564 $this->ignoreUnreadableDirs = $ignore; 565 566 return $this; 567 } 568 569 /** 570 * Searches files and directories which match defined rules. 571 * 572 * @param string|string[] $dirs A directory path or an array of directories 573 * 574 * @return $this 575 * 576 * @throws DirectoryNotFoundException if one of the directories does not exist 577 */ 578 public function in($dirs) 579 { 580 $resolvedDirs = []; 581 582 foreach ((array) $dirs as $dir) { 583 if (is_dir($dir)) { 584 $resolvedDirs[] = $this->normalizeDir($dir); 585 } elseif ($glob = glob($dir, (\defined('GLOB_BRACE') ? \GLOB_BRACE : 0) | \GLOB_ONLYDIR | \GLOB_NOSORT)) { 586 sort($glob); 587 $resolvedDirs = array_merge($resolvedDirs, array_map([$this, 'normalizeDir'], $glob)); 588 } else { 589 throw new DirectoryNotFoundException(sprintf('The "%s" directory does not exist.', $dir)); 590 } 591 } 592 593 $this->dirs = array_merge($this->dirs, $resolvedDirs); 594 595 return $this; 596 } 597 598 /** 599 * Returns an Iterator for the current Finder configuration. 600 * 601 * This method implements the IteratorAggregate interface. 602 * 603 * @return \Iterator|SplFileInfo[] An iterator 604 * 605 * @throws \LogicException if the in() method has not been called 606 */ 607 public function getIterator() 608 { 609 if (0 === \count($this->dirs) && 0 === \count($this->iterators)) { 610 throw new \LogicException('You must call one of in() or append() methods before iterating over a Finder.'); 611 } 612 613 if (1 === \count($this->dirs) && 0 === \count($this->iterators)) { 614 return $this->searchInDirectory($this->dirs[0]); 615 } 616 617 $iterator = new \AppendIterator(); 618 foreach ($this->dirs as $dir) { 619 $iterator->append($this->searchInDirectory($dir)); 620 } 621 622 foreach ($this->iterators as $it) { 623 $iterator->append($it); 624 } 625 626 return $iterator; 627 } 628 629 /** 630 * Appends an existing set of files/directories to the finder. 631 * 632 * The set can be another Finder, an Iterator, an IteratorAggregate, or even a plain array. 633 * 634 * @return $this 635 * 636 * @throws \InvalidArgumentException when the given argument is not iterable 637 */ 638 public function append(iterable $iterator) 639 { 640 if ($iterator instanceof \IteratorAggregate) { 641 $this->iterators[] = $iterator->getIterator(); 642 } elseif ($iterator instanceof \Iterator) { 643 $this->iterators[] = $iterator; 644 } elseif ($iterator instanceof \Traversable || \is_array($iterator)) { 645 $it = new \ArrayIterator(); 646 foreach ($iterator as $file) { 647 $it->append($file instanceof \SplFileInfo ? $file : new \SplFileInfo($file)); 648 } 649 $this->iterators[] = $it; 650 } else { 651 throw new \InvalidArgumentException('Finder::append() method wrong argument type.'); 652 } 653 654 return $this; 655 } 656 657 /** 658 * Check if any results were found. 659 * 660 * @return bool 661 */ 662 public function hasResults() 663 { 664 foreach ($this->getIterator() as $_) { 665 return true; 666 } 667 668 return false; 669 } 670 671 /** 672 * Counts all the results collected by the iterators. 673 * 674 * @return int 675 */ 676 public function count() 677 { 678 return iterator_count($this->getIterator()); 679 } 680 681 private function searchInDirectory(string $dir): \Iterator 682 { 683 $exclude = $this->exclude; 684 $notPaths = $this->notPaths; 685 686 if (static::IGNORE_VCS_FILES === (static::IGNORE_VCS_FILES & $this->ignore)) { 687 $exclude = array_merge($exclude, self::$vcsPatterns); 688 } 689 690 if (static::IGNORE_DOT_FILES === (static::IGNORE_DOT_FILES & $this->ignore)) { 691 $notPaths[] = '#(^|/)\..+(/|$)#'; 692 } 693 694 if (static::IGNORE_VCS_IGNORED_FILES === (static::IGNORE_VCS_IGNORED_FILES & $this->ignore)) { 695 $gitignoreFilePath = sprintf('%s/.gitignore', $dir); 696 if (!is_readable($gitignoreFilePath)) { 697 throw new \RuntimeException(sprintf('The "ignoreVCSIgnored" option cannot be used by the Finder as the "%s" file is not readable.', $gitignoreFilePath)); 698 } 699 $notPaths = array_merge($notPaths, [Gitignore::toRegex(file_get_contents($gitignoreFilePath))]); 700 } 701 702 $minDepth = 0; 703 $maxDepth = \PHP_INT_MAX; 704 705 foreach ($this->depths as $comparator) { 706 switch ($comparator->getOperator()) { 707 case '>': 708 $minDepth = $comparator->getTarget() + 1; 709 break; 710 case '>=': 711 $minDepth = $comparator->getTarget(); 712 break; 713 case '<': 714 $maxDepth = $comparator->getTarget() - 1; 715 break; 716 case '<=': 717 $maxDepth = $comparator->getTarget(); 718 break; 719 default: 720 $minDepth = $maxDepth = $comparator->getTarget(); 721 } 722 } 723 724 $flags = \RecursiveDirectoryIterator::SKIP_DOTS; 725 726 if ($this->followLinks) { 727 $flags |= \RecursiveDirectoryIterator::FOLLOW_SYMLINKS; 728 } 729 730 $iterator = new Iterator\RecursiveDirectoryIterator($dir, $flags, $this->ignoreUnreadableDirs); 731 732 if ($exclude) { 733 $iterator = new Iterator\ExcludeDirectoryFilterIterator($iterator, $exclude); 734 } 735 736 $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::SELF_FIRST); 737 738 if ($minDepth > 0 || $maxDepth < \PHP_INT_MAX) { 739 $iterator = new Iterator\DepthRangeFilterIterator($iterator, $minDepth, $maxDepth); 740 } 741 742 if ($this->mode) { 743 $iterator = new Iterator\FileTypeFilterIterator($iterator, $this->mode); 744 } 745 746 if ($this->names || $this->notNames) { 747 $iterator = new Iterator\FilenameFilterIterator($iterator, $this->names, $this->notNames); 748 } 749 750 if ($this->contains || $this->notContains) { 751 $iterator = new Iterator\FilecontentFilterIterator($iterator, $this->contains, $this->notContains); 752 } 753 754 if ($this->sizes) { 755 $iterator = new Iterator\SizeRangeFilterIterator($iterator, $this->sizes); 756 } 757 758 if ($this->dates) { 759 $iterator = new Iterator\DateRangeFilterIterator($iterator, $this->dates); 760 } 761 762 if ($this->filters) { 763 $iterator = new Iterator\CustomFilterIterator($iterator, $this->filters); 764 } 765 766 if ($this->paths || $notPaths) { 767 $iterator = new Iterator\PathFilterIterator($iterator, $this->paths, $notPaths); 768 } 769 770 if ($this->sort || $this->reverseSorting) { 771 $iteratorAggregate = new Iterator\SortableIterator($iterator, $this->sort, $this->reverseSorting); 772 $iterator = $iteratorAggregate->getIterator(); 773 } 774 775 return $iterator; 776 } 777 778 /** 779 * Normalizes given directory names by removing trailing slashes. 780 * 781 * Excluding: (s)ftp:// or ssh2.(s)ftp:// wrapper 782 */ 783 private function normalizeDir(string $dir): string 784 { 785 if ('/' === $dir) { 786 return $dir; 787 } 788 789 $dir = rtrim($dir, '/'.\DIRECTORY_SEPARATOR); 790 791 if (preg_match('#^(ssh2\.)?s?ftp://#', $dir)) { 792 $dir .= '/'; 793 } 794 795 return $dir; 796 } 797} 798