Passed
Pull Request — master (#59)
by
unknown
02:09
created

FileHelper::modifiedTime()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use Closure;
8
use FilesystemIterator;
9
use InvalidArgumentException;
10
use LogicException;
11
use RecursiveDirectoryIterator;
12
use RecursiveIteratorIterator;
13
use RuntimeException;
14
use Yiisoft\Files\PathMatcher\PathMatcherInterface;
15
16
use function array_key_exists;
17
use function get_class;
18
use function gettype;
19
use function is_object;
20
21
/**
22
 * FileHelper 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 64
    public static function ensureDirectory(string $path, int $mode = 0775): void
78
    {
79 64
        $path = self::normalizePath($path);
80
81 64
        if (!is_dir($path)) {
82 64
            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 64
            mkdir($path, $mode, true);
95
96 64
            restore_error_handler();
97
        }
98
99 64
        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 64
    public static function normalizePath(string $path): string
119
    {
120 64
        $isWindowsShare = strpos($path, '\\\\') === 0;
121
122 64
        if ($isWindowsShare) {
123 1
            $path = substr($path, 2);
124
        }
125
126 64
        $path = rtrim(strtr($path, '/\\', '//'), '/');
127
128 64
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
129 64
            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 exists.
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 64
    public static function removeDirectory(string $directory, array $options = []): void
160
    {
161 64
        if (!file_exists($directory)) {
162 1
            return;
163
        }
164
165 64
        self::clearDirectory($directory, $options);
166
167 64
        if (is_link($directory)) {
168 3
            self::unlink($directory);
169
        } else {
170 64
            set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
171
                throw new RuntimeException(
172
                    sprintf('Failed to remove directory "%s". ', $directory) . $errorString,
173
                    $errorNumber
174
                );
175
            });
176
177 64
            rmdir($directory);
178
179 64
            restore_error_handler();
180
        }
181
    }
182
183
    /**
184
     * Clear all directory content.
185
     *
186
     * @param string $directory The directory to be cleared.
187
     * @param array $options Options for directory clear . Valid options are:
188
     *
189
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
190
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
191
     *   Only symlink would be removed in that default case.
192
     *
193
     * @throws RuntimeException if unable to open directory.
194
     */
195 64
    public static function clearDirectory(string $directory, array $options = []): void
196
    {
197 64
        $handle = self::openDirectory($directory);
198 64
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
199 64
            while (($file = readdir($handle)) !== false) {
200 64
                if ($file === '.' || $file === '..') {
201 64
                    continue;
202
                }
203 45
                $path = $directory . '/' . $file;
204 45
                if (is_dir($path)) {
205 44
                    self::removeDirectory($path, $options);
206
                } else {
207 35
                    self::unlink($path);
208
                }
209
            }
210 64
            closedir($handle);
211
        }
212
    }
213
214
    /**
215
     * Removes a file or symlink in a cross-platform way.
216
     *
217
     * @param string $path Path to unlink.
218
     */
219 39
    public static function unlink(string $path): void
220
    {
221
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
222 39
        set_error_handler(static function (int $errorNumber, string $errorString) use ($path): bool {
223 1
            throw new RuntimeException(
224 1
                sprintf('Failed to unlink "%s". ', $path) . $errorString,
225
                $errorNumber
226
            );
227
        });
228
229 39
        $isWindows = DIRECTORY_SEPARATOR === '\\';
230
231 39
        if ($isWindows) {
232
            if (is_link($path)) {
233
                try {
234
                    unlink($path);
235
                } catch (RuntimeException $e) {
236
                    rmdir($path);
237
                }
238
            } else {
239
                if (file_exists($path) && !is_writable($path)) {
240
                    chmod($path, 0777);
241
                }
242
                unlink($path);
243
            }
244
        } else {
245 39
            unlink($path);
246
        }
247 38
        restore_error_handler();
248
    }
249
250
    /**
251
     * Tells whether the path is a empty directory.
252
     *
253
     * @param string $path Path to check for being an empty directory.
254
     *
255
     * @return bool
256
     */
257 1
    public static function isEmptyDirectory(string $path): bool
258
    {
259 1
        if (!is_dir($path)) {
260 1
            return false;
261
        }
262
263 1
        return !(new FilesystemIterator($path))->valid();
264
    }
265
266
    /**
267
     * Copies a whole directory as another one.
268
     *
269
     * The files and sub-directories will also be copied over.
270
     *
271
     * @param string $source The source directory.
272
     * @param string $destination The destination directory.
273
     * @param array $options Options for directory copy. Valid options are:
274
     *
275
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
276
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
277
     *   setting.
278
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
279
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
280
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
281
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
282
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
283
     *   while `$to` is the copy target.
284
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
285
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
286
     *   copied from, while `$to` is the copy target.
287
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
288
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
289
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
290
     *   `except`. Defaults to true.
291
     *
292
     * @throws RuntimeException if unable to open directory
293
     *
294
     * @psalm-param array{
295
     *   dirMode?: int,
296
     *   fileMode?: int,
297
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
298
     *   recursive?: bool,
299
     *   beforeCopy?: callable,
300
     *   afterCopy?: callable,
301
     *   copyEmptyDirectories?: bool,
302
     * } $options
303
     */
304 17
    public static function copyDirectory(string $source, string $destination, array $options = []): void
305
    {
306 17
        $filter = self::getFilter($options);
307 17
        $afterCopy = $options['afterCopy'] ?? null;
308 17
        $beforeCopy = $options['beforeCopy'] ?? null;
309 17
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
310
311 17
        if (!isset($options['dirMode'])) {
312 16
            $options['dirMode'] = 0755;
313
        }
314
315 17
        $source = self::normalizePath($source);
316 17
        $destination = self::normalizePath($destination);
317 17
        $copyEmptyDirectories = !array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'];
318
319 17
        self::assertNotSelfDirectory($source, $destination);
320
321 15
        if (self::processCallback($beforeCopy, $source, $destination) === false) {
322 1
            return;
323
        }
324
325 14
        if (!is_dir($destination) && $copyEmptyDirectories) {
326 10
            self::ensureDirectory($destination, $options['dirMode']);
327
        }
328
329 14
        $handle = self::openDirectory($source);
330
331 13
        if (!array_key_exists('basePath', $options)) {
332 13
            $options['basePath'] = realpath($source);
333
        }
334
335 13
        while (($file = readdir($handle)) !== false) {
336 13
            if ($file === '.' || $file === '..') {
337 13
                continue;
338
            }
339
340 11
            $from = $source . '/' . $file;
341 11
            $to = $destination . '/' . $file;
342
343 11
            if ($filter === null || $filter->match($from)) {
344 11
                if (is_file($from)) {
345 11
                    self::copyFile($from, $to, $options);
346 6
                } elseif ($recursive) {
347 5
                    self::copyDirectory($from, $to, $options);
348
                }
349
            }
350
        }
351
352 13
        closedir($handle);
353
354 13
        self::processCallback($afterCopy, $source, $destination);
355
    }
356
357
    /**
358
     * Copies files with some options.
359
     *
360
     * - dirMode: integer or null, the permission to be set for newly copied directories. Defaults to null.
361
     *   When null - directory will be not created
362
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
363
     *   setting.
364
     * - beforeCopy: callback, a PHP callback that is called before copying file. If the callback
365
     *   returns false, the copy operation for file will be cancelled. The signature of the
366
     *   callback should be: `function ($from, $to)`, where `$from` is the file to be copied from,
367
     *   while `$to` is the copy target.
368
     * - afterCopy: callback, a PHP callback that is called after file if successfully copied.
369
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the file
370
     *   copied from, while `$to` is the copy target.
371
     *
372
     * @param string $source The source file
373
     * @param string $destination The destination filename
374
     * @param array $options
375
     *
376
     * @psalm-param array{
377
     *   dirMode?: int,
378
     *   fileMode?: int,
379
     *   beforeCopy?: callable,
380
     *   afterCopy?: callable,
381
     * } $options
382
     */
383 14
    public static function copyFile(string $source, string $destination, array $options = []): void
384
    {
385 14
        if (!is_file($source)) {
386 1
            throw new InvalidArgumentException('Argument $source must be an existing file.');
387
        }
388
389 13
        $dirname = dirname($destination);
390 13
        $dirMode = $options['dirMode'] ?? null;
391 13
        $fileMode = $options['fileMode'] ?? null;
392 13
        $afterCopy = $options['afterCopy'] ?? null;
393 13
        $beforeCopy = $options['beforeCopy'] ?? null;
394
395 13
        if (self::processCallback($beforeCopy, $source, $destination) === false) {
396 1
            return;
397
        }
398
399 13
        if (!is_dir($dirname)) {
400 4
            if ($dirMode === null) {
401 1
                throw new RuntimeException('Directory ' . $dirname . ' does not exist and cannot be created.');
402
            }
403
404 3
            self::ensureDirectory($dirname, $dirMode);
405
        }
406
407 12
        if (!copy($source, $destination)) {
408
            throw new RuntimeException('Failed to copy the file.');
409
        }
410
411 12
        if ($fileMode !== null && !chmod($destination, $fileMode)) {
412
            throw new RuntimeException('Failed to change file permissions.');
413
        }
414
415 12
        self::processCallback($afterCopy, $source, $destination);
416
    }
417
418
    /**
419
     * @param mixed $callback
420
     * @param array $arguments
421
     *
422
     * @throws InvalidArgumentException
423
     *
424
     * @return mixed
425
     */
426 17
    private static function processCallback($callback, ...$arguments)
427
    {
428 17
        if ($callback === null) {
429 15
            return;
430
        }
431
432 5
        if ($callback instanceof Closure || is_callable($callback)) {
433 5
            return call_user_func_array($callback, $arguments);
434
        }
435
436
        $type = is_object($callback) ? get_class($callback) : gettype($callback);
437
438
        throw new InvalidArgumentException('Argument $callback must be null, callable or Closure instance. "' . $type . '" given.');
439
    }
440
441 29
    private static function getFilter(array $options): ?PathMatcherInterface
442
    {
443 29
        if (!array_key_exists('filter', $options)) {
444 21
            return null;
445
        }
446
447 8
        if (!$options['filter'] instanceof PathMatcherInterface) {
448 2
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
449 2
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
450
        }
451
452 6
        return $options['filter'];
453
    }
454
455
    /**
456
     * Assert that destination is not within the source directory.
457
     *
458
     * @param string $source Path to source.
459
     * @param string $destination Path to destination.
460
     *
461
     * @throws InvalidArgumentException
462
     */
463 17
    private static function assertNotSelfDirectory(string $source, string $destination): void
464
    {
465 17
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
466 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
467
        }
468
    }
469
470
    /**
471
     * Open directory handle.
472
     *
473
     * @param string $directory Path to directory.
474
     *
475
     * @throws RuntimeException if unable to open directory.
476
     * @throws InvalidArgumentException if argument is not a directory.
477
     *
478
     * @return resource
479
     */
480 64
    private static function openDirectory(string $directory)
481
    {
482 64
        if (!file_exists($directory)) {
483 3
            throw new InvalidArgumentException("\"$directory\" does not exist.");
484
        }
485
486 64
        if (!is_dir($directory)) {
487 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
488
        }
489
490
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
491 64
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
492
            throw new RuntimeException(
493
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
494
                $errorNumber
495
            );
496
        });
497
498 64
        $handle = opendir($directory);
499
500 64
        if ($handle === false) {
501
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
502
        }
503
504 64
        restore_error_handler();
505
506 64
        return $handle;
507
    }
508
509
    /**
510
     * Returns the last modification time for the given paths.
511
     *
512
     * If the path is a directory, any nested files/directories will be checked as well.
513
     *
514
     * @param string ...$paths The directories to be checked.
515
     *
516
     * @throws LogicException If path is not set.
517
     *
518
     * @return int Unix timestamp representing the last modification time.
519
     */
520 2
    public static function lastModifiedTime(string ...$paths): int
521
    {
522 2
        if (empty($paths)) {
523 1
            throw new LogicException('Path is required.');
524
        }
525
526 1
        $times = [];
527
528 1
        foreach ($paths as $path) {
529 1
            $times[] = self::modifiedTime($path);
530
531 1
            if (is_file($path)) {
532 1
                continue;
533
            }
534
535
            /** @var iterable<string, string> $iterator */
536 1
            $iterator = new RecursiveIteratorIterator(
537 1
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
538
                RecursiveIteratorIterator::SELF_FIRST
539
            );
540
541 1
            foreach ($iterator as $p => $_info) {
542 1
                $times[] = self::modifiedTime($p);
543
            }
544
        }
545
546
        /** @psalm-suppress ArgumentTypeCoercion */
547 1
        return max($times);
548
    }
549
550 1
    private static function modifiedTime(string $path): int
551
    {
552 1
        return (int)filemtime($path);
553
    }
554
555
    /**
556
     * Returns the directories found under the specified directory and subdirectories.
557
     *
558
     * @param string $directory The directory under which the files will be looked for.
559
     * @param array $options Options for directory searching. Valid options are:
560
     *
561
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
562
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
563
     *
564
     * @psalm-param array{
565
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
566
     *   recursive?: bool,
567
     * } $options
568
     *
569
     * @throws InvalidArgumentException If the directory is invalid.
570
     *
571
     * @return string[] Directories found under the directory specified, in no particular order.
572
     * Ordering depends on the file system used.
573
     */
574 6
    public static function findDirectories(string $directory, array $options = []): array
575
    {
576 6
        $filter = self::getFilter($options);
577 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
578 5
        $directory = self::normalizePath($directory);
579
580 5
        $result = [];
581
582 5
        $handle = self::openDirectory($directory);
583 4
        while (false !== $file = readdir($handle)) {
584 4
            if ($file === '.' || $file === '..') {
585 4
                continue;
586
            }
587
588 4
            $path = $directory . '/' . $file;
589 4
            if (is_file($path)) {
590 3
                continue;
591
            }
592
593 4
            if ($filter === null || $filter->match($path)) {
594 4
                $result[] = $path;
595
            }
596
597 4
            if ($recursive) {
598 3
                $result = array_merge($result, self::findDirectories($path, $options));
599
            }
600
        }
601 4
        closedir($handle);
602
603 4
        return $result;
604
    }
605
606
    /**
607
     * Returns the files found under the specified directory and subdirectories.
608
     *
609
     * @param string $directory The directory under which the files will be looked for.
610
     * @param array $options Options for file searching. Valid options are:
611
     *
612
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
613
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
614
     *
615
     * @psalm-param array{
616
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
617
     *   recursive?: bool,
618
     * } $options
619
     *
620
     * @throws InvalidArgumentException If the directory is invalid.
621
     *
622
     * @return string[] Files found under the directory specified, in no particular order.
623
     * Ordering depends on the files system used.
624
     */
625 6
    public static function findFiles(string $directory, array $options = []): array
626
    {
627 6
        $filter = self::getFilter($options);
628 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
629
630 5
        $directory = self::normalizePath($directory);
631
632 5
        $result = [];
633
634 5
        $handle = self::openDirectory($directory);
635 4
        while (false !== $file = readdir($handle)) {
636 4
            if ($file === '.' || $file === '..') {
637 4
                continue;
638
            }
639
640 4
            $path = $directory . '/' . $file;
641
642 4
            if (is_file($path)) {
643 4
                if ($filter === null || $filter->match($path)) {
644 4
                    $result[] = $path;
645
                }
646 4
                continue;
647
            }
648
649 4
            if ($recursive) {
650 3
                $result = array_merge($result, self::findFiles($path, $options));
651
            }
652
        }
653 4
        closedir($handle);
654
655 4
        return $result;
656
    }
657
}
658