Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.52% covered (success)
98.52%
133 / 135
96.67% covered (success)
96.67%
29 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dir
98.52% covered (success)
98.52%
133 / 135
96.67% covered (success)
96.67%
29 / 30
84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 rebuild
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIterator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAbsolute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 setRelative
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 setRecursive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setFilesOnly
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isAbsolute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRelative
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRecursive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFilesOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTree
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 copyTo
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 fileExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 emptyDir
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 __get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __set
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __unset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveOffset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 offsetExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 offsetGet
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 offsetSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 offsetUnset
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 walkDirectory
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 resolveEntry
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Dir;
16
17use ArrayIterator;
18use DirectoryIterator;
19use RecursiveIteratorIterator;
20use RecursiveDirectoryIterator;
21
22/**
23 * Directory class
24 *
25 * @category   Pop
26 * @package    Pop\Dir
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    5.0.0
31 */
32class Dir implements \ArrayAccess, \Countable, \IteratorAggregate
33{
34
35    /**
36     * The directory path
37     * @var ?string
38     */
39    protected ?string $path = null;
40
41    /**
42     * The files within the directory
43     * @var array
44     */
45    protected array $files = [];
46
47    /**
48     * The nested tree map of the directory and its files
49     * @var array
50     */
51    protected array $tree = [];
52
53    /**
54     * Flag to store the absolute path.
55     * @var bool
56     */
57    protected bool $absolute = false;
58
59    /**
60     * Flag to store the relative path.
61     * @var bool
62     */
63    protected bool $relative = false;
64
65    /**
66     * Flag to dig recursively.
67     * @var bool
68     */
69    protected bool $recursive = false;
70
71    /**
72     * Flag to include only files and no directories
73     * @var bool
74     */
75    protected bool $filesOnly = false;
76
77    /**
78     * Flag set once the initial traversal has completed
79     * @var bool
80     */
81    protected bool $initialized = false;
82
83    /**
84     * Constructor
85     *
86     * Instantiate a directory object
87     *
88     * @param  string  $dir
89     * @param  array   $options
90     * @throws Exception
91     */
92    public function __construct(string $dir, array $options = [])
93    {
94        // Set the directory path.
95        if ((str_contains($dir, "\\")) && (DIRECTORY_SEPARATOR != "\\")) {
96            $this->path = str_replace("\\", '/', $dir);
97        } else {
98            $this->path = $dir;
99        }
100
101        // Check to see if the directory exists.
102        if (!file_exists($this->path)) {
103            throw new Exception("Error: The directory '" . $this->path . "' does not exist");
104        }
105
106        // Trim the trailing slash.
107        if (strrpos($this->path, DIRECTORY_SEPARATOR) == (strlen($this->path) - 1)) {
108            $this->path = substr($this->path, 0, -1);
109        }
110
111        if (isset($options['absolute'])) {
112            $this->setAbsolute($options['absolute']);
113        }
114        if (isset($options['relative'])) {
115            $this->setRelative($options['relative']);
116        }
117        if (isset($options['recursive'])) {
118            $this->setRecursive($options['recursive']);
119        }
120        if (isset($options['filesOnly'])) {
121            $this->setFilesOnly($options['filesOnly']);
122        }
123
124        $this->initialized = true;
125        $this->rebuild();
126    }
127
128    /**
129     * Rebuild the tree and the flat file list from scratch
130     *
131     * @throws Exception
132     * @return void
133     */
134    protected function rebuild(): void
135    {
136        $this->tree  = [];
137        $this->files = [];
138
139        try {
140            $rootRealPath = realpath($this->path);
141            $this->tree[$rootRealPath] = $this->walkDirectory($this->path, $rootRealPath);
142        } catch (\UnexpectedValueException $e) {
143            throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
144        }
145    }
146
147    /**
148     * Method to get the count of files in the directory
149     *
150     * @return int
151     */
152    public function count(): int
153    {
154        return count($this->files);
155    }
156
157    /**
158     * Method to iterate over the files
159     *
160     * @return ArrayIterator
161     */
162    public function getIterator(): ArrayIterator
163    {
164        return new ArrayIterator($this->files);
165    }
166
167    /**
168     * Set absolute
169     *
170     * @param  bool $absolute
171     * @return Dir
172     */
173    public function setAbsolute(bool $absolute): Dir
174    {
175        $this->absolute = $absolute;
176        if (($this->absolute) && ($this->isRelative())) {
177            $this->relative = false;
178        }
179        if ($this->initialized) {
180            $this->rebuild();
181        }
182        return $this;
183    }
184
185    /**
186     * Set relative
187     *
188     * @param  bool $relative
189     * @return Dir
190     */
191    public function setRelative(bool $relative): Dir
192    {
193        $this->relative = $relative;
194        if (($this->relative) && ($this->isAbsolute())) {
195            $this->absolute = false;
196        }
197        if ($this->initialized) {
198            $this->rebuild();
199        }
200        return $this;
201    }
202
203    /**
204     * Set recursive
205     *
206     * @param  bool $recursive
207     * @return Dir
208     */
209    public function setRecursive(bool $recursive): Dir
210    {
211        $this->recursive = $recursive;
212        if ($this->initialized) {
213            $this->rebuild();
214        }
215        return $this;
216    }
217
218    /**
219     * Set files only
220     *
221     * @param  bool $filesOnly
222     * @return Dir
223     */
224    public function setFilesOnly(bool $filesOnly): Dir
225    {
226        $this->filesOnly = $filesOnly;
227        if ($this->initialized) {
228            $this->rebuild();
229        }
230        return $this;
231    }
232
233    /**
234     * Is absolute
235     *
236     * @return bool
237     */
238    public function isAbsolute(): bool
239    {
240        return $this->absolute;
241    }
242
243    /**
244     * Is relative
245     *
246     * @return bool
247     */
248    public function isRelative(): bool
249    {
250        return $this->relative;
251    }
252
253    /**
254     * Is recursive
255     *
256     * @return bool
257     */
258    public function isRecursive(): bool
259    {
260        return $this->recursive;
261    }
262
263    /**
264     * Is files only
265     *
266     * @return bool
267     */
268    public function isFilesOnly(): bool
269    {
270        return $this->filesOnly;
271    }
272
273    /**
274     * Get the path
275     *
276     * @return string|null
277     */
278    public function getPath(): string|null
279    {
280        return $this->path;
281    }
282
283    /**
284     * Get the files
285     *
286     * @return array
287     */
288    public function getFiles(): array
289    {
290        return $this->files;
291    }
292
293    /**
294     * Get the tree
295     *
296     * @return array
297     */
298    public function getTree(): array
299    {
300        return $this->tree;
301    }
302
303    /**
304     * Copy an entire directory recursively to another destination directory
305     *
306     * @param  string $destination
307     * @param  bool   $full
308     * @throws Exception
309     * @return void
310     */
311    public function copyTo(string $destination, bool $full = true): void
312    {
313        if (!is_dir($destination)) {
314            throw new Exception('Error: The destination path "' . $destination . '" does not exist');
315        }
316
317        if ($full) {
318            $folder = basename($this->path);
319
320            if (!file_exists($destination . DIRECTORY_SEPARATOR . $folder)) {
321                mkdir($destination . DIRECTORY_SEPARATOR . $folder);
322            }
323            $destination = $destination . DIRECTORY_SEPARATOR . $folder;
324        }
325
326        foreach (
327            $iterator = new RecursiveIteratorIterator(
328                new RecursiveDirectoryIterator($this->path, RecursiveDirectoryIterator::SKIP_DOTS),
329                RecursiveIteratorIterator::SELF_FIRST) as $item
330        ) {
331            if ($item->isDir()) {
332                mkdir($destination . DIRECTORY_SEPARATOR . $iterator->getSubPathName());
333            } else {
334                copy($item->getPathname(), $destination . DIRECTORY_SEPARATOR . $iterator->getSubPathName());
335            }
336        }
337    }
338
339    /**
340     * File exists
341     *
342     * @param  string  $file
343     * @return bool
344     */
345    public function fileExists(string $file): bool
346    {
347        return $this->offsetExists($file);
348    }
349
350    /**
351     * Delete a file
352     *
353     * @param  string  $file
354     * @throws Exception
355     * @return void
356     */
357    public function deleteFile(string $file): void
358    {
359        $this->offsetUnset($file);
360    }
361
362    /**
363     * Empty an entire directory
364     *
365     * By default, symlinked subdirectories are not followed â€” a symlink entry is
366     * removed as a link (its target's contents are left untouched), matching the
367     * non-descending behavior of walkDirectory()/copyTo(). Pass $followSymlinks
368     * as true to recurse into and delete the contents a symlinked directory points to.
369     *
370     * @param  bool    $remove
371     * @param  ?string $path
372     * @param  bool    $followSymlinks
373     * @throws Exception
374     * @return void
375     */
376    public function emptyDir(bool $remove = false, ?string $path = null, bool $followSymlinks = false): void
377    {
378        if ($path === null) {
379            $path = $this->path;
380        }
381
382        // Get a directory handle.
383        if (!($dh = @opendir($path))) {
384            throw new Exception('Error: Unable to open the directory path "' . $path . '"');
385        }
386
387        // Recursively dig through the directory, deleting files where applicable.
388        while (false !== ($obj = readdir($dh))) {
389            if ($obj == '.' || $obj == '..') {
390                continue;
391            }
392            $item = $path . DIRECTORY_SEPARATOR . $obj;
393            if (is_dir($item) && (!is_link($item) || $followSymlinks)) {
394                $this->emptyDir(true, $item, $followSymlinks);
395            } else if (!@unlink($item)) {
396                throw new Exception('Error: Unable to delete the file "' . $item . '"');
397            }
398        }
399
400        // Close the directory handle.
401        closedir($dh);
402
403        // If the delete flag was passed, remove the top level directory.
404        if ($remove) {
405            @rmdir($path);
406        }
407    }
408
409    /**
410     * Get a file
411     *
412     * @param  string $name
413     * @return mixed
414     */
415    public function __get(string $name): mixed
416    {
417        return $this->offsetGet($name);
418    }
419
420    /**
421     * Does file exist
422     *
423     * @param  string $name
424     * @return bool
425     */
426    public function __isset(string $name): bool
427    {
428        return $this->offsetExists($name);
429    }
430
431    /**
432     * Set method
433     *
434     * @param  string $name
435     * @param  mixed  $value
436     * @throws Exception
437     * @return void
438     */
439    public function __set(string $name, mixed $value): void
440    {
441        $this->offsetSet($name, $value);
442    }
443
444    /**
445     * Unset method
446     *
447     * @param  string $name
448     * @throws Exception
449     * @return void
450     */
451    public function __unset(string $name): void
452    {
453        $this->offsetUnset($name);
454    }
455
456    /**
457     * Resolve an ArrayAccess offset to its numeric index within $files.
458     *
459     * Accepts either a numeric index or a file/directory name; a non-numeric
460     * offset that doesn't match any entry name is returned unchanged (and will
461     * simply miss the isset()/existence check that follows).
462     *
463     * @param  mixed $offset
464     * @return mixed
465     */
466    protected function resolveOffset(mixed $offset): mixed
467    {
468        if (!is_numeric($offset)) {
469            $found = array_search($offset, $this->files);
470            if ($found !== false) {
471                $offset = $found;
472            }
473        }
474        return $offset;
475    }
476
477    /**
478     * ArrayAccess offsetExists
479     *
480     * @param  mixed $offset
481     * @return bool
482     */
483    public function offsetExists(mixed $offset): bool
484    {
485        $offset = $this->resolveOffset($offset);
486        return isset($this->files[$offset]);
487    }
488
489    /**
490     * ArrayAccess offsetGet
491     *
492     * @param  mixed $offset
493     * @return mixed
494     */
495    public function offsetGet(mixed $offset): mixed
496    {
497        $offset = $this->resolveOffset($offset);
498        return (isset($this->files[$offset])) ? $this->files[$offset] : null;
499    }
500
501    /**
502     * ArrayAccess offsetSet
503     *
504     * @param  mixed $offset
505     * @param  mixed $value
506     * @throws Exception
507     * @return void
508     */
509    public function offsetSet(mixed $offset, mixed $value): void
510    {
511        throw new Exception('Error: The directory object is read-only');
512    }
513
514    /**
515     * ArrayAccess offsetUnset
516     *
517     * @param  mixed $offset
518     * @throws Exception
519     * @return void
520     */
521    public function offsetUnset(mixed $offset): void
522    {
523        $offset = $this->resolveOffset($offset);
524        if (isset($this->files[$offset])) {
525            if (is_dir($this->path . DIRECTORY_SEPARATOR . $this->files[$offset])) {
526                throw new Exception("Error: The file '" . $this->path . DIRECTORY_SEPARATOR . $this->files[$offset] . "' is a directory");
527            } else if (!file_exists($this->path . DIRECTORY_SEPARATOR . $this->files[$offset])) {
528                throw new Exception("Error: The file '" . $this->path . DIRECTORY_SEPARATOR . $this->files[$offset] . "' does not exist");
529            } else if (!is_writable($this->path . DIRECTORY_SEPARATOR . $this->files[$offset])) {
530                throw new Exception("Error: The file '" . $this->path . DIRECTORY_SEPARATOR . $this->files[$offset] . "' is read-only");
531            } else {
532                unlink($this->path . DIRECTORY_SEPARATOR . $this->files[$offset]);
533                unset($this->files[$offset]);
534            }
535        } else {
536            throw new Exception("Error: The file does not exist");
537        }
538    }
539
540    /**
541     * Walk a directory in a single filesystem pass, building this level's tree
542     * entry (returned) and appending resolved flat $files entries as a side effect.
543     * One shared walk backs both getTree() and getFiles()/iteration, instead of
544     * each being built by a separate traversal of the same directory.
545     *
546     * @param  string       $path
547     * @param  string|false $rootRealPath
548     * @return array
549     */
550    protected function walkDirectory(string $path, string|false $rootRealPath): array
551    {
552        $tree = [];
553
554        foreach (new DirectoryIterator($path) as $fileInfo) {
555            if ($fileInfo->isDot()) {
556                continue;
557            }
558
559            $name  = $fileInfo->getBasename();
560            $isDir = $fileInfo->isDir();
561
562            $absolutePath = $this->recursive ?
563                realpath($fileInfo->getPathname()) :
564                ($isDir ?
565                    ($path . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR) :
566                    ($path . DIRECTORY_SEPARATOR . $name));
567
568            $entry = $this->resolveEntry($fileInfo, $absolutePath, $rootRealPath);
569            if ($entry !== null) {
570                $this->files[] = $entry;
571            }
572
573            if ($isDir) {
574                if ($this->recursive && !$fileInfo->isLink()) {
575                    $tree[DIRECTORY_SEPARATOR . $name] = $this->walkDirectory($fileInfo->getPathname(), $rootRealPath);
576                } else {
577                    $tree[DIRECTORY_SEPARATOR . $name] = [];
578                }
579            } else {
580                $tree[] = $name;
581            }
582        }
583
584        return $tree;
585    }
586
587    /**
588     * Resolve the stored value for a walked file/directory entry, honoring the
589     * absolute/relative/filesOnly flags.
590     *
591     * @param  \SplFileInfo $fileInfo
592     * @param  string|false $absolutePath
593     * @param  string|false $rootRealPath
594     * @return ?string
595     */
596    protected function resolveEntry(\SplFileInfo $fileInfo, string|false $absolutePath, string|false $rootRealPath): ?string
597    {
598        if ($this->filesOnly && $fileInfo->isDir()) {
599            return null;
600        }
601
602        if ($this->absolute) {
603            return ($absolutePath !== false) ? $absolutePath : null;
604        } else if ($this->relative) {
605            return ($absolutePath !== false && $rootRealPath !== false) ?
606                substr($absolutePath, (strlen($rootRealPath) + 1)) : null;
607        }
608
609        return $fileInfo->getFilename();
610    }
611
612}