Passed
Pull Request — master (#60)
by Wilmer
17:16
created

FileHelper::copyDirectory()   C

Complexity

Conditions 15
Paths 144

Size

Total Lines 50
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 15

Importance

Changes 2
Bugs 1 Features 0
Metric Value
eloc 31
c 2
b 1
f 0
dl 0
loc 50
ccs 32
cts 32
cp 1
rs 5.55
cc 15
nc 144
nop 3
crap 15

How to fix   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 2
            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
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
307 12
        $fileMode = $options['fileMode'] ?? null;
308 12
        $dirMode = $options['dirMode'] ?? 0775;
309
310 12
        $source = self::normalizePath($source);
311 12
        $destination = self::normalizePath($destination);
312 12
        $copyEmptyDirectories = !array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'];
313
314 12
        self::assertNotSelfDirectory($source, $destination);
315
316 10
        $destinationExists = is_dir($destination);
317 10
        if (!$destinationExists && $copyEmptyDirectories) {
318 6
            self::ensureDirectory($destination, $dirMode);
319 6
            $destinationExists = true;
320
        }
321
322 10
        $handle = self::openDirectory($source);
323
324 9
        if (!array_key_exists('basePath', $options)) {
325 9
            $options['basePath'] = realpath($source);
326
        }
327
328 9
        while (($file = readdir($handle)) !== false) {
329 9
            if ($file === '.' || $file === '..') {
330 9
                continue;
331
            }
332
333 7
            $from = $source . '/' . $file;
334 7
            $to = $destination . '/' . $file;
335
336 7
            if ($filter === null || $filter->match($from)) {
337 7
                if (is_file($from)) {
338 7
                    if (!$destinationExists) {
339 2
                        self::ensureDirectory($destination, $dirMode);
340 2
                        $destinationExists = true;
341
                    }
342 7
                    copy($from, $to);
343 7
                    if ($fileMode !== null) {
344 7
                        chmod($to, $fileMode);
345
                    }
346 6
                } elseif ($recursive) {
347 5
                    self::copyDirectory($from, $to, $options);
348
                }
349
            }
350
        }
351
352 9
        closedir($handle);
353
    }
354
355 24
    private static function getFilter(array $options): ?PathMatcherInterface
356
    {
357 24
        if (!array_key_exists('filter', $options)) {
358 16
            return null;
359
        }
360
361 8
        if (!$options['filter'] instanceof PathMatcherInterface) {
362 2
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
363 2
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
364
        }
365
366 6
        return $options['filter'];
367
    }
368
369
    /**
370
     * Assert that destination is not within the source directory.
371
     *
372
     * @param string $source Path to source.
373
     * @param string $destination Path to destination.
374
     *
375
     * @throws InvalidArgumentException
376
     */
377 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
378
    {
379 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
380 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
381
        }
382
    }
383
384
    /**
385
     * Open directory handle.
386
     *
387
     * @param string $directory Path to directory.
388
     *
389
     * @throws RuntimeException if unable to open directory.
390
     * @throws InvalidArgumentException if argument is not a directory.
391
     *
392
     * @return resource
393
     */
394 56
    private static function openDirectory(string $directory)
395
    {
396 56
        if (!file_exists($directory)) {
397 3
            throw new InvalidArgumentException("\"$directory\" does not exist.");
398
        }
399
400 56
        if (!is_dir($directory)) {
401 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
402
        }
403
404
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
405 56
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
406
            throw new RuntimeException(
407
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
408
                $errorNumber
409
            );
410
        });
411
412 56
        $handle = opendir($directory);
413
414 56
        if ($handle === false) {
415
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
416
        }
417
418 56
        restore_error_handler();
419
420 56
        return $handle;
421
    }
422
423
    /**
424
     * Returns the last modification time for the given paths.
425
     *
426
     * If the path is a directory, any nested files/directories will be checked as well.
427
     *
428
     * @param string ...$paths The directories to be checked.
429
     *
430
     * @throws LogicException If path is not set.
431
     *
432
     * @return int Unix timestamp representing the last modification time.
433
     */
434 2
    public static function lastModifiedTime(string ...$paths): int
435
    {
436 2
        if (empty($paths)) {
437 1
            throw new LogicException('Path is required.');
438
        }
439
440 1
        $times = [];
441
442 1
        foreach ($paths as $path) {
443 1
            $times[] = self::modifiedTime($path);
444
445 1
            if (is_file($path)) {
446 1
                continue;
447
            }
448
449
            /** @var iterable<string, string> $iterator */
450 1
            $iterator = new RecursiveIteratorIterator(
451 1
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
452
                RecursiveIteratorIterator::SELF_FIRST
453
            );
454
455 1
            foreach ($iterator as $p => $_info) {
456 1
                $times[] = self::modifiedTime($p);
457
            }
458
        }
459
460
        /** @psalm-suppress ArgumentTypeCoercion */
461 1
        return max($times);
462
    }
463
464 1
    private static function modifiedTime(string $path): int
465
    {
466 1
        return (int)filemtime($path);
467
    }
468
469
    /**
470
     * Returns the directories found under the specified directory and subdirectories.
471
     *
472
     * @param string $directory The directory under which the files will be looked for.
473
     * @param array $options Options for directory searching. Valid options are:
474
     *
475
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
476
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
477
     *
478
     * @psalm-param array{
479
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
480
     *   recursive?: bool,
481
     * } $options
482
     *
483
     * @throws InvalidArgumentException If the directory is invalid.
484
     *
485
     * @return string[] Directories found under the directory specified, in no particular order.
486
     * Ordering depends on the file system used.
487
     */
488 6
    public static function findDirectories(string $directory, array $options = []): array
489
    {
490 6
        $filter = self::getFilter($options);
491 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
492 5
        $directory = self::normalizePath($directory);
493
494 5
        $result = [];
495
496 5
        $handle = self::openDirectory($directory);
497 4
        while (false !== $file = readdir($handle)) {
498 4
            if ($file === '.' || $file === '..') {
499 4
                continue;
500
            }
501
502 4
            $path = $directory . '/' . $file;
503 4
            if (is_file($path)) {
504 3
                continue;
505
            }
506
507 4
            if ($filter === null || $filter->match($path)) {
508 4
                $result[] = $path;
509
            }
510
511 4
            if ($recursive) {
512 3
                $result = array_merge($result, self::findDirectories($path, $options));
513
            }
514
        }
515 4
        closedir($handle);
516
517 4
        return $result;
518
    }
519
520
    /**
521
     * Returns the files found under the specified directory and subdirectories.
522
     *
523
     * @param string $directory The directory under which the files will be looked for.
524
     * @param array $options Options for file searching. Valid options are:
525
     *
526
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
527
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
528
     *
529
     * @psalm-param array{
530
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
531
     *   recursive?: bool,
532
     * } $options
533
     *
534
     * @throws InvalidArgumentException If the directory is invalid.
535
     *
536
     * @return string[] Files found under the directory specified, in no particular order.
537
     * Ordering depends on the files system used.
538
     */
539 6
    public static function findFiles(string $directory, array $options = []): array
540
    {
541 6
        $filter = self::getFilter($options);
542 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
543
544 5
        $directory = self::normalizePath($directory);
545
546 5
        $result = [];
547
548 5
        $handle = self::openDirectory($directory);
549 4
        while (false !== $file = readdir($handle)) {
550 4
            if ($file === '.' || $file === '..') {
551 4
                continue;
552
            }
553
554 4
            $path = $directory . '/' . $file;
555
556 4
            if (is_file($path)) {
557 4
                if ($filter === null || $filter->match($path)) {
558 4
                    $result[] = $path;
559
                }
560 4
                continue;
561
            }
562
563 4
            if ($recursive) {
564 3
                $result = array_merge($result, self::findFiles($path, $options));
565
            }
566
        }
567 4
        closedir($handle);
568
569 4
        return $result;
570
    }
571
}
572