Passed
Push — master ( 60bdb5...b34181 )
by Sergei
22:51 queued 09:55
created

FileHelper::unlink()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 8.1174

Importance

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