Blame | Letzte Änderung | Log anzeigen | RSS feed
<?php/*** This file is part of the Nette Framework (https://nette.org)* Copyright (c) 2004 David Grudl (https://davidgrudl.com)*/declare(strict_types=1);namespace Nette\Utils;use Nette;/*** Finder allows searching through directory trees using iterator.** Finder::findFiles('*.php')* ->size('> 10kB')* ->from('.')* ->exclude('temp');** @implements \IteratorAggregate<string, FileInfo>*/class Finder implements \IteratorAggregate{use Nette\SmartObject;/** @var array<array{string, string}> */private array $find = [];/** @var string[] */private array $in = [];/** @var \Closure[] */private array $filters = [];/** @var \Closure[] */private array $descentFilters = [];/** @var array<string|self> */private array $appends = [];private bool $childFirst = false;/** @var ?callable */private $sort;private int $maxDepth = -1;private bool $ignoreUnreadableDirs = true;/*** Begins search for files and directories matching mask.*/public static function find(string|array $masks): static{$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadicreturn (new static)->addMask($masks, 'dir')->addMask($masks, 'file');}/*** Begins search for files matching mask.*/public static function findFiles(string|array $masks): static{$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadicreturn (new static)->addMask($masks, 'file');}/*** Begins search for directories matching mask.*/public static function findDirectories(string|array $masks): static{$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadicreturn (new static)->addMask($masks, 'dir');}/*** Finds files matching the specified masks.*/public function files(string|array $masks): static{return $this->addMask((array) $masks, 'file');}/*** Finds directories matching the specified masks.*/public function directories(string|array $masks): static{return $this->addMask((array) $masks, 'dir');}private function addMask(array $masks, string $mode): static{foreach ($masks as $mask) {$mask = FileSystem::unixSlashes($mask);if ($mode === 'dir') {$mask = rtrim($mask, '/');}if ($mask === '' || ($mode === 'file' && str_ends_with($mask, '/'))) {throw new Nette\InvalidArgumentException("Invalid mask '$mask'");}if (str_starts_with($mask, '**/')) {$mask = substr($mask, 3);}$this->find[] = [$mask, $mode];}return $this;}/*** Searches in the given directories. Wildcards are allowed.*/public function in(string|array $paths): static{$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic$this->addLocation($paths, '');return $this;}/*** Searches recursively from the given directories. Wildcards are allowed.*/public function from(string|array $paths): static{$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic$this->addLocation($paths, '/**');return $this;}private function addLocation(array $paths, string $ext): void{foreach ($paths as $path) {if ($path === '') {throw new Nette\InvalidArgumentException("Invalid directory '$path'");}$path = rtrim(FileSystem::unixSlashes($path), '/');$this->in[] = $path . $ext;}}/*** Lists directory's contents before the directory itself. By default, this is disabled.*/public function childFirst(bool $state = true): static{$this->childFirst = $state;return $this;}/*** Ignores unreadable directories. By default, this is enabled.*/public function ignoreUnreadableDirs(bool $state = true): static{$this->ignoreUnreadableDirs = $state;return $this;}/*** Set a compare function for sorting directory entries. The function will be called to sort entries from the same directory.* @param callable(FileInfo, FileInfo): int $callback*/public function sortBy(callable $callback): static{$this->sort = $callback;return $this;}/*** Sorts files in each directory naturally by name.*/public function sortByName(): static{$this->sort = fn(FileInfo $a, FileInfo $b): int => strnatcmp($a->getBasename(), $b->getBasename());return $this;}/*** Adds the specified paths or appends a new finder that returns.*/public function append(string|array|null $paths = null): static{if ($paths === null) {return $this->appends[] = new static;}$this->appends = array_merge($this->appends, (array) $paths);return $this;}/********************* filtering ****************d*g**//*** Skips entries that matches the given masks relative to the ones defined with the in() or from() methods.*/public function exclude(string|array $masks): static{$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadicforeach ($masks as $mask) {$mask = FileSystem::unixSlashes($mask);if (!preg_match('~^/?(\*\*/)?(.+)(/\*\*|/\*|/|)$~D', $mask, $m)) {throw new Nette\InvalidArgumentException("Invalid mask '$mask'");}$end = $m[3];$re = $this->buildPattern($m[2]);$filter = fn(FileInfo $file): bool => ($end && !$file->isDir())|| !preg_match($re, FileSystem::unixSlashes($file->getRelativePathname()));$this->descentFilter($filter);if ($end !== '/*') {$this->filter($filter);}}return $this;}/*** Yields only entries which satisfy the given filter.* @param callable(FileInfo): bool $callback*/public function filter(callable $callback): static{$this->filters[] = \Closure::fromCallable($callback);return $this;}/*** It descends only to directories that match the specified filter.* @param callable(FileInfo): bool $callback*/public function descentFilter(callable $callback): static{$this->descentFilters[] = \Closure::fromCallable($callback);return $this;}/*** Sets the maximum depth of entries.*/public function limitDepth(?int $depth): static{$this->maxDepth = $depth ?? -1;return $this;}/*** Restricts the search by size. $operator accepts "[operator] [size] [unit]" example: >=10kB*/public function size(string $operator, ?int $size = null): static{if (func_num_args() === 1) { // in $operator is predicateif (!preg_match('#^(?:([=<>!]=?|<>)\s*)?((?:\d*\.)?\d+)\s*(K|M|G|)B?$#Di', $operator, $matches)) {throw new Nette\InvalidArgumentException('Invalid size predicate format.');}[, $operator, $size, $unit] = $matches;$units = ['' => 1, 'k' => 1e3, 'm' => 1e6, 'g' => 1e9];$size *= $units[strtolower($unit)];$operator = $operator ?: '=';}return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getSize(), $operator, $size));}/*** Restricts the search by modified time. $operator accepts "[operator] [date]" example: >1978-01-23*/public function date(string $operator, string|int|\DateTimeInterface|null $date = null): static{if (func_num_args() === 1) { // in $operator is predicateif (!preg_match('#^(?:([=<>!]=?|<>)\s*)?(.+)$#Di', $operator, $matches)) {throw new Nette\InvalidArgumentException('Invalid date predicate format.');}[, $operator, $date] = $matches;$operator = $operator ?: '=';}$date = DateTime::from($date)->format('U');return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getMTime(), $operator, $date));}/********************* iterator generator ****************d*g**//*** Returns an array with all found files and directories.*/public function collect(): array{return iterator_to_array($this->getIterator());}/** @return \Generator<string, FileInfo> */public function getIterator(): \Generator{$plan = $this->buildPlan();foreach ($plan as $dir => $searches) {yield from $this->traverseDir($dir, $searches);}foreach ($this->appends as $item) {if ($item instanceof self) {yield from $item->getIterator();} else {$item = FileSystem::platformSlashes($item);yield $item => new FileInfo($item);}}}/*** @param array<\stdClass{pattern: string, mode: string, recursive: bool}> $searches* @param string[] $subdirs* @return \Generator<string, FileInfo>*/private function traverseDir(string $dir, array $searches, array $subdirs = []): \Generator{if ($this->maxDepth >= 0 && count($subdirs) > $this->maxDepth) {return;} elseif (!is_dir($dir)) {throw new Nette\InvalidStateException("Directory '$dir' not found.");}try {$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);} catch (\UnexpectedValueException $e) {if ($this->ignoreUnreadableDirs) {return;} else {throw new Nette\InvalidStateException($e->getMessage());}}$files = $this->convertToFiles($pathNames, implode('/', $subdirs), FileSystem::isAbsolute($dir));if ($this->sort) {$files = iterator_to_array($files);usort($files, $this->sort);}foreach ($files as $file) {$pathName = $file->getPathname();$cache = $subSearch = [];if ($file->isDir()) {foreach ($searches as $search) {if ($search->recursive && $this->proveFilters($this->descentFilters, $file, $cache)) {$subSearch[] = $search;}}}if ($this->childFirst && $subSearch) {yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));}$relativePathname = FileSystem::unixSlashes($file->getRelativePathname());foreach ($searches as $search) {if ($file->getType() === $search->mode&& preg_match($search->pattern, $relativePathname)&& $this->proveFilters($this->filters, $file, $cache)) {yield $pathName => $file;break;}}if (!$this->childFirst && $subSearch) {yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));}}}private function convertToFiles(iterable $pathNames, string $relativePath, bool $absolute): \Generator{foreach ($pathNames as $pathName) {if (!$absolute) {$pathName = preg_replace('~\.?/~A', '', $pathName);}$pathName = FileSystem::platformSlashes($pathName);yield new FileInfo($pathName, $relativePath);}}private function proveFilters(array $filters, FileInfo $file, array &$cache): bool{foreach ($filters as $filter) {$res = &$cache[spl_object_id($filter)];$res ??= $filter($file);if (!$res) {return false;}}return true;}/** @return array<string, array<\stdClass{pattern: string, mode: string, recursive: bool}>> */private function buildPlan(): array{$plan = $dirCache = [];foreach ($this->find as [$mask, $mode]) {$splits = [];if (FileSystem::isAbsolute($mask)) {if ($this->in) {throw new Nette\InvalidStateException("You cannot combine the absolute path in the mask '$mask' and the directory to search '{$this->in[0]}'.");}$splits[] = self::splitRecursivePart($mask);} else {foreach ($this->in ?: ['.'] as $in) {$in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()$splits[] = self::splitRecursivePart($in . '/' . $mask);}}foreach ($splits as [$base, $rest, $recursive]) {$base = $base === '' ? '.' : $base;$dirs = $dirCache[$base] ??= strpbrk($base, '*?[')? glob($base, GLOB_NOSORT | GLOB_ONLYDIR | GLOB_NOESCAPE): [strtr($base, ['[[]' => '[', '[]]' => ']'])]; // unescape [ and ]$search = (object) ['pattern' => $this->buildPattern($rest), 'mode' => $mode, 'recursive' => $recursive];foreach ($dirs as $dir) {$plan[$dir][] = $search;}}}return $plan;}/*** Since glob() does not know ** wildcard, we divide the path into a part for glob and a part for manual traversal.*/private static function splitRecursivePart(string $path): array{$a = strrpos($path, '/');$parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);return isset($parts[1])? [$parts[0], $parts[1] . substr($path, $a + 1), true]: [$parts[0], substr($path, $a + 1), false];}/*** Converts wildcards to regular expression.*/private function buildPattern(string $mask): string{if ($mask === '*') {return '##';} elseif (str_starts_with($mask, './')) {$anchor = '^';$mask = substr($mask, 2);} else {$anchor = '(?:^|/)';}$pattern = strtr(preg_quote($mask, '#'),['\*\*/' => '(.+/)?','\*' => '[^/]*','\?' => '[^/]','\[\!' => '[^','\[' => '[','\]' => ']','\-' => '-',],);return '#' . $anchor . $pattern . '$#D' . (defined('PHP_WINDOWS_VERSION_BUILD') ? 'i' : '');}}