FileHelper::openDirectory()   A
last analyzed

Complexity

Conditions 4
Paths 5

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4.4882

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 15
c 1
b 0
f 0
nc 5
nop 1
dl 0
loc 28
ccs 11
cts 16
cp 0.6875
crap 4.4882
rs 9.7666
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use FilesystemIterator;
8
use InvalidArgumentException;
9
use LogicException;
10
use RecursiveDirectoryIterator;
11
use RecursiveIteratorIterator;
12
use RuntimeException;
13
use Yiisoft\Files\PathMatcher\PathMatcherInterface;
14
15
use function array_key_exists;
16
use function array_pop;
17
use function chmod;
18
use function closedir;
19
use function copy;
20
use function dirname;
21
use function end;
22
use function explode;
23
use function file_exists;
24
use function filemtime;
25
use function fopen;
26
use function get_debug_type;
27
use function implode;
28
use function is_dir;
29
use function is_file;
30
use function is_link;
31
use function is_string;
32
use function is_writable;
33
use function opendir;
34
use function readdir;
35
use function realpath;
36
use function restore_error_handler;
37
use function rmdir;
38
use function rtrim;
39
use function set_error_handler;
40
use function sprintf;
41
use function str_contains;
42
use function str_starts_with;
43
use function strtr;
44
use function substr;
45
46
/**
47
 * Provides useful methods to manage files and directories.
48
 */
49
final class FileHelper
50
{
51
    /**
52
     * Opens a file or URL.
53
     *
54
     * This method is similar to the PHP {@see fopen()} function, except that it suppresses the {@see E_WARNING}
55
     * level error and throws the {@see RuntimeException} exception if it can't open the file.
56
     *
57
     * @param string $filename The file or URL.
58
     * @param string $mode The type of access.
59
     * @param bool $useIncludePath Whether to search for a file in the include path.
60
     * @param resource|null $context The stream context or `null`.
61
     *
62
     * @throws RuntimeException If the file could not be opened.
63
     *
64
     * @return resource The file pointer resource.
65
     *
66
     * @psalm-suppress PossiblyNullArgument
67
     */
68 4
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
69
    {
70
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
71 4
        set_error_handler(static function (int $errorNumber, string $errorString) use ($filename): bool {
72 2
            throw new RuntimeException(
73 2
                sprintf('Failed to open a file "%s". ', $filename) . $errorString,
74 2
                $errorNumber
75 2
            );
76 4
        });
77
        try {
78 4
            $filePointer = fopen($filename, $mode, $useIncludePath, $context);
79
        } finally {
80 4
            restore_error_handler();
81
        }
82
83 2
        if ($filePointer === false) {
84
            throw new RuntimeException(sprintf('Failed to open a file "%s". ', $filename));
85
        }
86
87 2
        return $filePointer;
88
    }
89
90
    /**
91
     * Ensures directory exists and has specific permissions.
92
     *
93
     * This method is similar to the PHP {@see mkdir()} function with some differences:
94
     *
95
     * - It does not fail if directory already exists.
96
     * - It uses {@see chmod()} to set the permission of the created directory in order to avoid the impact
97
     *   of the `umask` setting.
98
     * - It throws exceptions instead of returning false and emitting {@see E_WARNING}.
99
     *
100
     * @param string $path Path of the directory to be created.
101
     * @param int $mode The permission to be set for the created directory.
102
     */
103 68
    public static function ensureDirectory(string $path, int $mode = 0775): void
104
    {
105 68
        $path = self::normalizePath($path);
106
107 68
        if (!is_dir($path)) {
108 68
            set_error_handler(static function (int $errorNumber, string $errorString) use ($path): bool {
109
                // Handle race condition.
110
                // See https://github.com/kalessil/phpinspectionsea/blob/master/docs/probable-bugs.md#mkdir-race-condition
111 2
                if (!is_dir($path)) {
112 2
                    throw new RuntimeException(
113 2
                        sprintf('Failed to create directory "%s". ', $path) . $errorString,
114 2
                        $errorNumber
115 2
                    );
116
                }
117
                return true;
118 68
            });
119
            try {
120 68
                mkdir($path, $mode, true);
121
            } finally {
122 68
                restore_error_handler();
123
            }
124
        }
125
126 67
        if (!chmod($path, $mode)) {
127
            throw new RuntimeException(sprintf('Unable to set mode "%s" for "%s".', $mode, $path));
128
        }
129
    }
130
131
    /**
132
     * Normalizes a file/directory path.
133
     *
134
     * The normalization does the following work:
135
     *
136
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
137
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
138
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
139
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
140
     *
141
     * @param string $path The file/directory path to be normalized.
142
     *
143
     * @return string The normalized file/directory path.
144
     */
145 68
    public static function normalizePath(string $path): string
146
    {
147 68
        $isWindowsShare = str_starts_with($path, '\\\\');
148
149 68
        if ($isWindowsShare) {
150 1
            $path = substr($path, 2);
151
        }
152
153 68
        $path = rtrim(strtr($path, '\\', '/'), '/');
154
155 68
        if (!str_contains('/' . $path, '/.') && !str_contains($path, '//')) {
156 68
            return $isWindowsShare ? "\\\\$path" : $path;
157
        }
158
159 1
        $parts = [];
160
161 1
        foreach (explode('/', $path) as $part) {
162 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
163 1
                array_pop($parts);
164 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
165 1
                $parts[] = $part;
166
            }
167
        }
168
169 1
        $path = implode('/', $parts);
170
171 1
        if ($isWindowsShare) {
172 1
            $path = '\\\\' . $path;
173
        }
174
175 1
        return $path === '' ? '.' : $path;
176
    }
177
178
    /**
179
     * Removes a directory (and all its content) recursively. Does nothing if directory does not exist.
180
     *
181
     * @param string $directory The directory to be deleted recursively.
182
     * @param array $options Options for directory remove. Valid options are:
183
     *
184
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
185
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
186
     *   Only symlink would be removed in that default case.
187
     *
188
     * @psalm-param array{
189
     *     traverseSymlinks?:bool,
190
     * } $options
191
     *
192
     * @throw RuntimeException When unable to remove directory.
193
     */
194 67
    public static function removeDirectory(string $directory, array $options = []): void
195
    {
196 67
        if (!file_exists($directory)) {
197 1
            return;
198
        }
199
200 67
        self::clearDirectory(
201 67
            $directory,
202 67
            ['traverseSymlinks' => $options['traverseSymlinks'] ?? false]
203 67
        );
204
205 67
        self::removeLinkOrEmptyDirectory($directory);
206
    }
207
208
    /**
209
     * Clears all directory content.
210
     *
211
     * @param string $directory The directory to be cleared.
212
     * @param array $options Options for directory clear. Valid options are:
213
     *
214
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
215
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
216
     *   Only symlink would be removed in that default case.
217
     * - filter: a filter to apply while deleting files. It should be an instance of {@see PathMatcherInterface}.
218
     *
219
     * @throws RuntimeException if unable to open directory.
220
     *
221
     * @psalm-param array{
222
     *     traverseSymlinks?:bool,
223
     *     filter?:PathMatcherInterface
224
     * } $options
225
     */
226 67
    public static function clearDirectory(string $directory, array $options = []): void
227
    {
228 67
        $filter = self::getFilter($options);
229 67
        $handle = self::openDirectory($directory);
230 67
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
231 67
            while (($file = readdir($handle)) !== false) {
232 67
                if ($file === '.' || $file === '..') {
233 67
                    continue;
234
                }
235
236 48
                $path = $directory . '/' . $file;
237
238 48
                if ($filter === null || $filter->match($path)) {
239 48
                    if (is_dir($path)) {
240 47
                        self::clearDirectory($path, $options);
241 47
                        if (is_link($path) || self::isEmptyDirectory($path)) {
242 47
                            self::removeLinkOrEmptyDirectory($path);
243
                        }
244
                    } else {
245 38
                        self::unlink($path);
246
                    }
247
                }
248
            }
249 67
            closedir($handle);
250
        }
251
    }
252
253
    /**
254
     * Removes a file or symlink in a cross-platform way.
255
     *
256
     * @param string $path Path to unlink.
257
     */
258 43
    public static function unlink(string $path): void
259
    {
260
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
261 43
        set_error_handler(static function (int $errorNumber, string $errorString) use ($path): bool {
262 2
            throw new RuntimeException(
263 2
                sprintf('Failed to unlink "%s". ', $path) . $errorString,
264 2
                $errorNumber
265 2
            );
266 43
        });
267
        try {
268 43
            $isWindows = DIRECTORY_SEPARATOR === '\\';
269 43
            if ($isWindows) {
270
                if (is_link($path)) {
271
                    try {
272
                        unlink($path);
273
                    } catch (RuntimeException) {
274
                        rmdir($path);
275
                    }
276
                } else {
277
                    if (file_exists($path) && !is_writable($path)) {
278
                        chmod($path, 0777);
279
                    }
280
                    unlink($path);
281
                }
282
            } else {
283 43
                unlink($path);
284
            }
285
        } finally {
286 43
            restore_error_handler();
287
        }
288
    }
289
290
    /**
291
     * Tells whether the path is an empty directory.
292
     *
293
     * @param string $path Path to check for being an empty directory.
294
     */
295 47
    public static function isEmptyDirectory(string $path): bool
296
    {
297 47
        if (!is_dir($path)) {
298 1
            return false;
299
        }
300
301 47
        return !(new FilesystemIterator($path))->valid();
302
    }
303
304
    /**
305
     * Copies a whole directory as another one.
306
     *
307
     * The files and subdirectories will also be copied over.
308
     *
309
     * @param string $source The source directory.
310
     * @param string $destination The destination directory.
311
     * @param array $options Options for directory copy. Valid options are:
312
     *
313
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
314
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
315
     *   setting.
316
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
317
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
318
     * - beforeCopy: callback, a PHP callback that is called before copying each subdirectory or file. If the callback
319
     *   returns false, the copy operation for the subdirectory or file will be cancelled. The signature of the
320
     *   callback should be: `function ($from, $to)`, where `$from` is the subdirectory or file to be copied from,
321
     *   while `$to` is the copy target.
322
     * - afterCopy: callback, a PHP callback that is called after each subdirectory or file is successfully copied.
323
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the subdirectory or file
324
     *   copied from, while `$to` is the copy target.
325
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
326
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
327
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
328
     *   `except`. Defaults to true.
329
     *
330
     * @throws RuntimeException if unable to open directory
331
     *
332
     * @psalm-param array{
333
     *   dirMode?: int,
334
     *   fileMode?: int,
335
     *   filter?: PathMatcherInterface,
336
     *   recursive?: bool,
337
     *   beforeCopy?: callable,
338
     *   afterCopy?: callable,
339
     *   copyEmptyDirectories?: bool,
340
     * } $options
341
     */
342 18
    public static function copyDirectory(string $source, string $destination, array $options = []): void
343
    {
344 18
        $source = self::normalizePath($source);
345 18
        $destination = self::normalizePath($destination);
346
347 18
        self::assertNotSelfDirectory($source, $destination);
348
349 16
        if (self::processCallback($options['beforeCopy'] ?? null, $source, $destination) === false) {
350 1
            return;
351
        }
352
353 14
        $filter = self::getFilter($options);
354 14
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
355 14
        $copyEmptyDirectories = !array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'];
356
357 14
        if (!isset($options['dirMode'])) {
358 13
            $options['dirMode'] = 0755;
359
        }
360
361 14
        if ($copyEmptyDirectories && !is_dir($destination)) {
362 10
            self::ensureDirectory($destination, $options['dirMode']);
363
        }
364
365 14
        $handle = self::openDirectory($source);
366
367 13
        if (!array_key_exists('basePath', $options)) {
368 13
            $options['basePath'] = realpath($source);
369
        }
370
371 13
        while (($file = readdir($handle)) !== false) {
372 13
            if ($file === '.' || $file === '..') {
373 13
                continue;
374
            }
375
376 11
            $from = $source . '/' . $file;
377 11
            $to = $destination . '/' . $file;
378
379 11
            if ($filter === null || $filter->match($from)) {
380
                /** @psalm-suppress InvalidArgument $options is compatible with `copyFile()` and `copyDirectory()` */
381 11
                if (is_file($from)) {
382 11
                    self::copyFile($from, $to, $options);
383 6
                } elseif ($recursive) {
384 5
                    self::copyDirectory($from, $to, $options);
385
                }
386
            }
387
        }
388
389 13
        closedir($handle);
390
391 13
        self::processCallback($options['afterCopy'] ?? null, $source, $destination);
392
    }
393
394
    /**
395
     * Copies files with some options.
396
     *
397
     * - dirMode: integer or null, the permission to be set for newly copied directories. Defaults to null.
398
     *   When null - directory will be not created
399
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
400
     *   setting.
401
     * - beforeCopy: callback, a PHP callback that is called before copying file. If the callback
402
     *   returns false, the copy operation for file will be cancelled. The signature of the
403
     *   callback should be: `function ($from, $to)`, where `$from` is the file to be copied from,
404
     *   while `$to` is the copy target.
405
     * - afterCopy: callback, a PHP callback that is called after file if successfully copied.
406
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the file
407
     *   copied from, while `$to` is the copy target.
408
     *
409
     * @param string $source The source file
410
     * @param string $destination The destination filename
411
     *
412
     * @psalm-param array{
413
     *   dirMode?: int,
414
     *   fileMode?: int,
415
     *   beforeCopy?: callable,
416
     *   afterCopy?: callable,
417
     * } $options
418
     */
419 14
    public static function copyFile(string $source, string $destination, array $options = []): void
420
    {
421 14
        if (!is_file($source)) {
422 1
            throw new InvalidArgumentException('Argument $source must be an existing file.');
423
        }
424
425 13
        if (self::processCallback($options['beforeCopy'] ?? null, $source, $destination) === false) {
426 1
            return;
427
        }
428
429 13
        $dirname = dirname($destination);
430 13
        $dirMode = $options['dirMode'] ?? 0755;
431 13
        $fileMode = $options['fileMode'] ?? null;
432
433 13
        if (!is_dir($dirname)) {
434 4
            self::ensureDirectory($dirname, $dirMode);
435
        }
436
437 13
        if (!copy($source, $destination)) {
438
            throw new RuntimeException('Failed to copy the file.');
439
        }
440
441 13
        if ($fileMode !== null && !chmod($destination, $fileMode)) {
442
            throw new RuntimeException(sprintf('Unable to set mode "%s" for "%s".', $fileMode, $destination));
443
        }
444
445 13
        self::processCallback($options['afterCopy'] ?? null, $source, $destination);
446
    }
447
448
    /**
449
     * @throws InvalidArgumentException
450
     */
451 17
    private static function processCallback(?callable $callback, mixed ...$arguments): mixed
452
    {
453 17
        return $callback ? $callback(...$arguments) : null;
454
    }
455
456 67
    private static function getFilter(array $options): ?PathMatcherInterface
457
    {
458 67
        if (!array_key_exists('filter', $options)) {
459 67
            return null;
460
        }
461
462 9
        if (!$options['filter'] instanceof PathMatcherInterface) {
463 2
            $type = get_debug_type($options['filter']);
464 2
            throw new InvalidArgumentException(
465 2
                sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type)
466 2
            );
467
        }
468
469 7
        return $options['filter'];
470
    }
471
472
    /**
473
     * Assert that destination is not within the source directory.
474
     *
475
     * @param string $source Path to source.
476
     * @param string $destination Path to destination.
477
     *
478
     * @throws InvalidArgumentException
479
     */
480 18
    private static function assertNotSelfDirectory(string $source, string $destination): void
481
    {
482 18
        if ($source === $destination || str_starts_with($destination, $source . '/')) {
483 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
484
        }
485
    }
486
487
    /**
488
     * Open directory handle.
489
     *
490
     * @param string $directory Path to directory.
491
     *
492
     * @throws RuntimeException if unable to open directory.
493
     * @throws InvalidArgumentException if argument is not a directory.
494
     *
495
     * @return resource
496
     */
497 67
    private static function openDirectory(string $directory)
498
    {
499 67
        if (!file_exists($directory)) {
500 3
            throw new InvalidArgumentException("\"$directory\" does not exist.");
501
        }
502
503 67
        if (!is_dir($directory)) {
504 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
505
        }
506
507
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
508 67
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
509
            throw new RuntimeException(
510
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
511
                $errorNumber
512
            );
513 67
        });
514
515
        try {
516 67
            $handle = opendir($directory);
517 67
            if ($handle === false) {
518
                throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
519
            }
520
        } finally {
521 67
            restore_error_handler();
522
        }
523
524 67
        return $handle;
525
    }
526
527
    /**
528
     * Returns the last modification time for the given paths.
529
     *
530
     * If the path is a directory, any nested files/directories will be checked as well.
531
     *
532
     * @param RecursiveDirectoryIterator[]|string[] $paths The directories to be checked.
533
     *
534
     * @throws LogicException If path is not set.
535
     *
536
     * @return int|null Unix timestamp representing the last modification time.
537
     */
538 3
    public static function lastModifiedTime(string|RecursiveDirectoryIterator ...$paths): ?int
539
    {
540 3
        if (empty($paths)) {
541 1
            throw new LogicException('Path is required.');
542
        }
543
544 2
        $time = null;
545
546 2
        foreach ($paths as $path) {
547 2
            if (is_string($path)) {
548 2
                $timestamp = self::modifiedTime($path);
549
550 2
                if ($timestamp > $time) {
551 2
                    $time = $timestamp;
552
                }
553
554 2
                if (is_file($path)) {
555 1
                    continue;
556
                }
557
558 2
                $path = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS);
559
            }
560
561
            /** @var iterable<string, string> $iterator */
562 2
            $iterator = new RecursiveIteratorIterator(
563 2
                $path,
564 2
                RecursiveIteratorIterator::SELF_FIRST
565 2
            );
566
567 2
            foreach ($iterator as $path => $_info) {
0 ignored issues
show
Comprehensibility Bug introduced by
$path is overwriting a variable from outer foreach loop.
Loading history...
568 2
                $timestamp = self::modifiedTime($path);
569
570 2
                if ($timestamp > $time) {
571 1
                    $time = $timestamp;
572
                }
573
            }
574
        }
575
576 2
        return $time;
577
    }
578
579 2
    private static function modifiedTime(string $path): ?int
580
    {
581 2
        if (false !== $timestamp = filemtime($path)) {
582 2
            return $timestamp;
583
        }
584
585
        return null;
586
    }
587
588
    /**
589
     * Returns the directories found under the specified directory and subdirectories.
590
     *
591
     * @param string $directory The directory under which the files will be looked for.
592
     * @param array $options Options for directory searching. Valid options are:
593
     *
594
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
595
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
596
     *
597
     * @psalm-param array{
598
     *   filter?: PathMatcherInterface,
599
     *   recursive?: bool,
600
     * } $options
601
     *
602
     * @throws InvalidArgumentException If the directory is invalid.
603
     *
604
     * @return string[] Directories found under the directory specified, in no particular order.
605
     * Ordering depends on the file system used.
606
     */
607 6
    public static function findDirectories(string $directory, array $options = []): array
608
    {
609 6
        $filter = self::getFilter($options);
610 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
611 5
        $directory = self::normalizePath($directory);
612
613 5
        $result = [];
614
615 5
        $handle = self::openDirectory($directory);
616 4
        while (false !== $file = readdir($handle)) {
617 4
            if ($file === '.' || $file === '..') {
618 4
                continue;
619
            }
620
621 4
            $path = $directory . '/' . $file;
622 4
            if (is_file($path)) {
623 3
                continue;
624
            }
625
626 4
            if ($filter === null || $filter->match($path)) {
627 4
                $result[] = $path;
628
            }
629
630 4
            if ($recursive) {
631 3
                $result = array_merge($result, self::findDirectories($path, $options));
632
            }
633
        }
634 4
        closedir($handle);
635
636 4
        return $result;
637
    }
638
639
    /**
640
     * Returns the files found under the specified directory and subdirectories.
641
     *
642
     * @param string $directory The directory under which the files will be looked for.
643
     * @param array $options Options for file searching. Valid options are:
644
     *
645
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
646
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
647
     *
648
     * @psalm-param array{
649
     *   filter?: PathMatcherInterface,
650
     *   recursive?: bool,
651
     * } $options
652
     *
653
     * @throws InvalidArgumentException If the directory is invalid.
654
     *
655
     * @return string[] Files found under the directory specified, in no particular order.
656
     * Ordering depends on the files system used.
657
     */
658 6
    public static function findFiles(string $directory, array $options = []): array
659
    {
660 6
        $filter = self::getFilter($options);
661 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
662
663 5
        $directory = self::normalizePath($directory);
664
665 5
        $result = [];
666
667 5
        $handle = self::openDirectory($directory);
668 4
        while (false !== $file = readdir($handle)) {
669 4
            if ($file === '.' || $file === '..') {
670 4
                continue;
671
            }
672
673 4
            $path = $directory . '/' . $file;
674
675 4
            if (is_file($path)) {
676 4
                if ($filter === null || $filter->match($path)) {
677 4
                    $result[] = $path;
678
                }
679 4
                continue;
680
            }
681
682 4
            if ($recursive) {
683 3
                $result = array_merge($result, self::findFiles($path, $options));
684
            }
685
        }
686 4
        closedir($handle);
687
688 4
        return $result;
689
    }
690
691
    /**
692
     * Removes a link or an empty directory.
693
     *
694
     * @param string $directory The empty directory or the link to be deleted.
695
     *
696
     * @throw RuntimeException When unable to remove directory or link.
697
     */
698 67
    private static function removeLinkOrEmptyDirectory(string $directory): void
699
    {
700 67
        if (is_link($directory)) {
701 3
            self::unlink($directory);
702
        } else {
703 67
            set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
704
                throw new RuntimeException(
705
                    sprintf('Failed to remove directory "%s". ', $directory) . $errorString,
706
                    $errorNumber
707
                );
708 67
            });
709
            try {
710 67
                rmdir($directory);
711
            } finally {
712 67
                restore_error_handler();
713
            }
714
        }
715
    }
716
}
717