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

FileHelper::copyDirectory()   F

Complexity

Conditions 17
Paths 400

Size

Total Lines 52
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 17.0775

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 17
eloc 30
c 3
b 1
f 0
nc 400
nop 3
dl 0
loc 52
ccs 29
cts 31
cp 0.9355
crap 17.0775
rs 1.8833

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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