Passed
Pull Request — master (#59)
by
unknown
12:08
created

FileHelper::copyFile()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 10.0933

Importance

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