Passed
Pull Request — master (#68)
by
unknown
12:01
created

FileHelper::lastModifiedTime()   B

Complexity

Conditions 8
Paths 7

Size

Total Lines 39
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 8

Importance

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