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