Passed
Pull Request — master (#64)
by
unknown
02:39
created

FileHelper::unlink()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 9.6594

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