Passed
Pull Request — master (#48)
by
unknown
07:13
created

FileHelper::lastModifiedTime()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 28
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 5

Importance

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