1: <?php declare(strict_types=1);
2:
3: namespace Salient\Iterator;
4:
5: use Salient\Contract\Core\Immutable;
6: use Salient\Contract\Iterator\FluentIteratorInterface;
7: use Salient\Iterator\Concern\FluentIteratorTrait;
8: use Salient\Utility\Exception\FilesystemErrorException;
9: use Salient\Utility\Regex;
10: use AppendIterator;
11: use CallbackFilterIterator;
12: use Countable;
13: use EmptyIterator;
14: use FilesystemIterator;
15: use IteratorAggregate;
16: use LogicException;
17: use RecursiveCallbackFilterIterator;
18: use RecursiveDirectoryIterator;
19: use RecursiveIteratorIterator;
20: use SplFileInfo;
21: use Traversable;
22:
23: /**
24: * Iterates over files and directories
25: *
26: * @implements IteratorAggregate<string,SplFileInfo>
27: * @implements FluentIteratorInterface<string,SplFileInfo>
28: */
29: class RecursiveFilesystemIterator implements
30: IteratorAggregate,
31: FluentIteratorInterface,
32: Immutable,
33: Countable
34: {
35: /** @use FluentIteratorTrait<string,SplFileInfo> */
36: use FluentIteratorTrait;
37:
38: private bool $GetFiles = true;
39: private bool $GetDirs = false;
40: private bool $DirsFirst = true;
41: private bool $Recurse = true;
42: private bool $MatchRelative = false;
43: /** @var string[] */
44: private array $Dirs = [];
45: /** @var array<string|callable(SplFileInfo, string, FilesystemIterator): bool> */
46: private array $Exclude = [];
47: /** @var array<string|callable(SplFileInfo, string, FilesystemIterator, RecursiveIteratorIterator<RecursiveDirectoryIterator|RecursiveCallbackFilterIterator<string,SplFileInfo,RecursiveDirectoryIterator>>|null=): bool> */
48: private array $Include = [];
49:
50: /**
51: * Search in one or more directories
52: *
53: * @return $this
54: */
55: public function in(string ...$dirs)
56: {
57: if (!$dirs) {
58: return $this;
59: }
60:
61: $clone = clone $this;
62: array_push($clone->Dirs, ...$dirs);
63: return $clone;
64: }
65:
66: /**
67: * Find files?
68: *
69: * Files are returned by default.
70: *
71: * @return $this
72: */
73: public function files(bool $value = true)
74: {
75: if ($this->GetFiles === $value) {
76: return $this;
77: }
78:
79: $clone = clone $this;
80: $clone->GetFiles = $value;
81: return $clone;
82: }
83:
84: /**
85: * Do not find directories, only files
86: *
87: * This is the default.
88: *
89: * @return $this
90: */
91: public function noDirs()
92: {
93: return $this->dirs(false)->files();
94: }
95:
96: /**
97: * Find directories?
98: *
99: * Directories are not returned by default.
100: *
101: * @return $this
102: */
103: public function dirs(bool $value = true)
104: {
105: if ($this->GetDirs === $value) {
106: return $this;
107: }
108:
109: $clone = clone $this;
110: $clone->GetDirs = $value;
111: return $clone;
112: }
113:
114: /**
115: * Do not find files, only directories
116: *
117: * @return $this
118: */
119: public function noFiles()
120: {
121: return $this->files(false)->dirs();
122: }
123:
124: /**
125: * Return directories before their children?
126: *
127: * Directories are returned before their children by default.
128: *
129: * Ignored unless directories are returned.
130: *
131: * @return $this
132: */
133: public function dirsFirst(bool $value = true)
134: {
135: if ($this->DirsFirst === $value) {
136: return $this;
137: }
138:
139: $clone = clone $this;
140: $clone->DirsFirst = $value;
141: return $clone;
142: }
143:
144: /**
145: * Return directories after their children
146: *
147: * Ignored unless directories are returned.
148: *
149: * @return $this
150: */
151: public function dirsLast()
152: {
153: return $this->dirsFirst(false);
154: }
155:
156: /**
157: * Recurse into directories?
158: *
159: * Recursion into directories is enabled by default.
160: *
161: * @return $this
162: */
163: public function recurse(bool $value = true)
164: {
165: if ($this->Recurse === $value) {
166: return $this;
167: }
168:
169: $clone = clone $this;
170: $clone->Recurse = $value;
171: return $clone;
172: }
173:
174: /**
175: * Do not recurse into directories
176: *
177: * @return $this
178: */
179: public function doNotRecurse()
180: {
181: return $this->recurse(false);
182: }
183:
184: /**
185: * Exclude files that match a regular expression or satisfy a callback
186: *
187: * @param string|callable(SplFileInfo, string, FilesystemIterator): bool $value
188: * @return $this
189: */
190: public function exclude($value)
191: {
192: $this->Exclude[] = $value;
193: return $this;
194: }
195:
196: /**
197: * Include files that match a regular expression or satisfy a callback
198: *
199: * If no regular expressions or callbacks are passed to
200: * {@see RecursiveFilesystemIterator::include()}, all files are included.
201: *
202: * @param string|callable(SplFileInfo, string, FilesystemIterator, RecursiveIteratorIterator<RecursiveDirectoryIterator|RecursiveCallbackFilterIterator<string,SplFileInfo,RecursiveDirectoryIterator>>|null=): bool $value
203: * @return $this
204: */
205: public function include($value)
206: {
207: $this->Include[] = $value;
208: return $this;
209: }
210:
211: /**
212: * Match files to exclude and include by their path relative to the
213: * directory being searched?
214: *
215: * Full pathnames, starting with directory names passed to
216: * {@see RecursiveFilesystemIterator::in()}, are used for file matching
217: * purposes by default.
218: *
219: * @return $this
220: */
221: public function matchRelative(bool $value = true)
222: {
223: if ($this->MatchRelative === $value) {
224: return $this;
225: }
226:
227: $clone = clone $this;
228: $clone->MatchRelative = $value;
229: return $clone;
230: }
231:
232: /**
233: * Do not match files to exclude and include by relative path
234: *
235: * @see RecursiveFilesystemIterator::matchRelative()
236: *
237: * @return $this
238: */
239: public function doNotMatchRelative()
240: {
241: return $this->matchRelative(false);
242: }
243:
244: /**
245: * @inheritDoc
246: */
247: public function count(): int
248: {
249: return iterator_count($this->getIterator());
250: }
251:
252: /**
253: * @return Traversable<string,SplFileInfo>
254: */
255: public function getIterator(): Traversable
256: {
257: if (!$this->Dirs || (!$this->GetFiles && !$this->GetDirs)) {
258: return new EmptyIterator();
259: }
260:
261: $flags =
262: FilesystemIterator::KEY_AS_PATHNAME
263: | FilesystemIterator::CURRENT_AS_FILEINFO
264: | FilesystemIterator::SKIP_DOTS
265: | FilesystemIterator::UNIX_PATHS;
266:
267: $excludeFilter =
268: $this->Exclude
269: ? function (
270: SplFileInfo $file,
271: string $path,
272: FilesystemIterator $iterator
273: ): bool {
274: $path = $this->getPath($path, $iterator);
275:
276: foreach ($this->Exclude as $exclude) {
277: if (is_callable($exclude)) {
278: if ($exclude($file, $path, $iterator)) {
279: return true;
280: }
281: continue;
282: }
283: if (Regex::match($exclude, $path)
284: || ($this->MatchRelative
285: && Regex::match($exclude, "/{$path}"))) {
286: return true;
287: }
288: if ($file->isDir()
289: && (Regex::match($exclude, "{$path}/")
290: || ($this->MatchRelative
291: && Regex::match($exclude, "/{$path}/")))) {
292: return true;
293: }
294: }
295:
296: return false;
297: }
298: : null;
299:
300: $includeFilter =
301: $this->Include
302: ? function (
303: SplFileInfo $file,
304: string $path,
305: FilesystemIterator $iterator,
306: ?RecursiveIteratorIterator $recursiveIterator = null
307: ): bool {
308: $path = $this->getPath($path, $iterator);
309:
310: foreach ($this->Include as $include) {
311: if (is_callable($include)) {
312: if ($include($file, $path, $iterator, $recursiveIterator)) {
313: return true;
314: }
315: continue;
316: }
317: if (Regex::match($include, $path)
318: || ($this->MatchRelative
319: && Regex::match($include, "/{$path}"))) {
320: return true;
321: }
322: if ($file->isDir()
323: && (Regex::match($include, "$path/")
324: || ($this->MatchRelative
325: && Regex::match($include, "/{$path}/")))) {
326: return true;
327: }
328: }
329:
330: return false;
331: }
332: : null;
333:
334: foreach ($this->Dirs as $directory) {
335: if (!is_dir($directory)) {
336: throw new FilesystemErrorException(sprintf('Not a directory: %s', $directory));
337: }
338:
339: if (!$this->Recurse) {
340: $iterator = new FilesystemIterator($directory, $flags);
341:
342: if ($excludeFilter || $includeFilter) {
343: $iterator = new CallbackFilterIterator(
344: $iterator,
345: fn(SplFileInfo $file, string $path, FilesystemIterator $iterator): bool =>
346: (!$excludeFilter || !$excludeFilter($file, $path, $iterator))
347: && (!$includeFilter || $includeFilter($file, $path, $iterator))
348: );
349: }
350:
351: if (!$this->GetDirs) {
352: $iterator = new CallbackFilterIterator(
353: $iterator,
354: fn(SplFileInfo $file): bool => !$file->isDir(),
355: );
356: }
357:
358: if (!$this->GetFiles) {
359: $iterator = new CallbackFilterIterator(
360: $iterator,
361: fn(SplFileInfo $file): bool => !$file->isFile(),
362: );
363: }
364:
365: $iterators[] = $iterator;
366:
367: continue;
368: }
369:
370: $mode =
371: $this->GetDirs
372: ? ($this->DirsFirst
373: ? RecursiveIteratorIterator::SELF_FIRST
374: : RecursiveIteratorIterator::CHILD_FIRST)
375: : RecursiveIteratorIterator::LEAVES_ONLY;
376:
377: $iterator = new RecursiveDirectoryIterator($directory, $flags);
378:
379: // Apply exclude filter early to prevent recursion into excluded
380: // directories
381: if ($excludeFilter) {
382: $iterator = new RecursiveCallbackFilterIterator(
383: $iterator,
384: fn(SplFileInfo $file, string $path, RecursiveDirectoryIterator $iterator): bool =>
385: !$excludeFilter($file, $path, $iterator)
386: );
387: }
388:
389: $iterator = new RecursiveIteratorIterator($iterator, $mode);
390:
391: // Apply include filter after recursion to ensure every possible
392: // match is found
393: if ($includeFilter) {
394: $iterator = new CallbackFilterIterator(
395: $iterator,
396: function (SplFileInfo $file, string $path, RecursiveIteratorIterator $iterator) use ($includeFilter): bool {
397: $recursiveIterator = $iterator;
398: /** @var RecursiveCallbackFilterIterator|RecursiveDirectoryIterator */
399: $iterator = $iterator->getInnerIterator();
400: if ($iterator instanceof RecursiveCallbackFilterIterator) {
401: /** @var RecursiveDirectoryIterator */
402: $iterator = $iterator->getInnerIterator();
403: }
404: return $includeFilter($file, $path, $iterator, $recursiveIterator);
405: }
406: );
407: }
408:
409: if (!$this->GetFiles) {
410: $iterator = new CallbackFilterIterator(
411: $iterator,
412: fn(SplFileInfo $file): bool => !$file->isFile(),
413: );
414: }
415:
416: $iterators[] = $iterator;
417: }
418:
419: if (count($iterators) === 1) {
420: return $iterators[0];
421: }
422:
423: $iterator = new AppendIterator();
424: foreach ($iterators as $dirIterator) {
425: $iterator->append($dirIterator);
426: }
427:
428: return $iterator;
429: }
430:
431: /**
432: * @inheritDoc
433: */
434: public function nextWithValue($key, $value, bool $strict = false)
435: {
436: $name = Regex::replace('/^(?:get|is)/i', '', (string) $key, -1, $count);
437:
438: if (method_exists(SplFileInfo::class, $key)) {
439: // If `$key` is the name of a method, check that it starts with
440: // "get" or "is"
441: if (!$count) {
442: throw new LogicException(sprintf('Illegal key: %s', $key));
443: }
444: $method = $key;
445: } else {
446: foreach (["get{$name}", "is{$name}"] as $method) {
447: if (method_exists(SplFileInfo::class, $method)) {
448: break;
449: }
450: $method = null;
451: }
452: }
453:
454: if (
455: $method === null
456: || !strcasecmp($method, 'getFileInfo')
457: || !strcasecmp($method, 'getPathInfo')
458: ) {
459: throw new LogicException(sprintf('Invalid key: %s', $key));
460: }
461:
462: foreach ($this as $current) {
463: $_value = $current->$method();
464: if ($strict) {
465: if ($_value === $value) {
466: return $current;
467: }
468: } elseif ($_value == $value) {
469: return $current;
470: }
471: }
472:
473: return null;
474: }
475:
476: private function getPath(string $path, FilesystemIterator $iterator): string
477: {
478: if (!$this->MatchRelative) {
479: return $path;
480: }
481:
482: if ($iterator instanceof RecursiveDirectoryIterator) {
483: return $iterator->getSubPathname();
484: }
485:
486: return basename($path);
487: }
488: }
489: