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