Passed
Pull Request — master (#5)
by Wilmer
02:20
created

FileHelper::copyDirectory()   D

Complexity

Conditions 20
Paths 67

Size

Total Lines 59
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 271.8572

Importance

Changes 0
Metric Value
cc 20
eloc 34
nc 67
nop 3
dl 0
loc 59
ccs 5
cts 35
cp 0.1429
crap 271.8572
rs 4.1666
c 0
b 0
f 0

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
declare(strict_types = 1);
3
4
namespace Yiisoft\Files;
5
6
use Yiisoft\Strings\StringHelper;
7
8
/**
9
 * FileHelper provides useful methods to manage files and directories
10
 */
11
class FileHelper
12
{
13
    private const PATTERN_NODIR = 1;
14
    private const PATTERN_ENDSWITH = 4;
15
    private const PATTERN_MUSTBEDIR = 8;
16
    private const PATTERN_NEGATIVE = 16;
17
    private const PATTERN_CASE_INSENSITIVE = 32;
18
19
    /**
20
     * Normalizes a file/directory path.
21
     *
22
     * The normalization does the following work:
23
     *
24
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
25
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
26
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
27
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
28
     *
29
     * @param string $path the file/directory path to be normalized
30
     *
31
     * @return string the normalized file/directory path
32
     */
33 3
    public static function normalizePath(string $path): string
34
    {
35 3
        $isWindowsShare = strpos($path, '\\\\') === 0;
36 3
        if ($isWindowsShare) {
37 1
            $path = substr($path, 2);
38
        }
39
40 3
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41 3
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42 3
            if ($isWindowsShare) {
43 1
                $path = $path = '\\\\' . $path;
0 ignored issues
show
Unused Code introduced by
The assignment to $path is dead and can be removed.
Loading history...
44
            }
45 3
            return $path;
46
        }
47
48 1
        $parts = [];
49
50 1
        foreach (explode('/', $path) as $part) {
51 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
52 1
                array_pop($parts);
53 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
54 1
                $parts[] = $part;
55
            }
56
        }
57 1
        $path = implode('/', $parts);
58 1
        if ($isWindowsShare) {
59
            $path = '\\\\' . $path;
60
        }
61 1
        return $path === '' ? '.' : $path;
62
    }
63
64
    /**
65
     * Removes a directory (and all its content) recursively.
66
     *
67
     * @param string $directory the directory to be deleted recursively.
68
     * @param array $options options for directory remove. Valid options are:
69
     *
70
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
71
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
72
     *   Only symlink would be removed in that default case.
73
     *
74
     * @return void
75
     */
76 8
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78 8
        if (!is_dir($directory)) {
79 1
            return;
80
        }
81 8
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82 8
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85 8
            while (($file = readdir($handle)) !== false) {
86 8
                if ($file === '.' || $file === '..') {
87 8
                    continue;
88
                }
89 7
                $path = $directory . '/' . $file;
90 7
                if (is_dir($path)) {
91 7
                    self::removeDirectory($path, $options);
92
                } else {
93 3
                    self::unlink($path);
94
                }
95
            }
96 8
            closedir($handle);
97
        }
98 8
        if (is_link($directory)) {
99 2
            self::unlink($directory);
100
        } else {
101 8
            rmdir($directory);
102
        }
103 8
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112 3
    public static function unlink(string $path): bool
113
    {
114 3
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116 3
        if (!$isWindows) {
117 3
            return unlink($path);
118
        }
119
120
        if (is_link($path) && is_dir($path)) {
121
            return rmdir($path);
122
        }
123
124
        return unlink($path);
125
    }
126
127
    /**
128
     * Creates a new directory.
129
     *
130
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
131
     * created directory in order to avoid the impact of the `umask` setting.
132
     *
133
     * @param string $path path of the directory to be created.
134
     * @param int $mode the permission to be set for the created directory.
135
     *
136
     * @return bool whether the directory is created successfully.
137
     */
138 8
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140 8
        if (is_dir($path)) {
141 1
            return true;
142
        }
143
        try {
144 8
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
145 8
                return false;
146
            }
147
        } catch (\Exception $e) {
148
            if (!is_dir($path)) { // https://github.com/yiisoft/yii2/issues/9288
149
                throw new \RuntimeException("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
150
            }
151
        }
152
153
        try {
154 8
            return chmod($path, $mode);
155
        } catch (\Exception $e) {
156
            throw new \RuntimeException("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
157
        }
158
    }
159
160
    /**
161
     * Copies a whole directory as another one.
162
     *
163
     * The files and sub-directories will also be copied over.
164
     *
165
     * @param string $source the source directory.
166
     * @param string $destination the destination directory.
167
     * @param array $options options for directory copy. Valid options are:
168
     *
169
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
170
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
171
     *   setting.
172
     * - filter: callback, a PHP callback that is called for each directory or file.
173
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
174
     *   The callback can return one of the following values:
175
     *
176
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored).
177
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored).
178
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied.
179
     *
180
     * - only: array, list of patterns that the file paths should match if they want to be copied. A path matches a
181
     *   pattern if it contains the pattern string at its end. For example, '.php' matches all file paths ending with
182
     *   '.php'.
183
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths. If a file path matches a pattern
184
     *   in both "only" and "except", it will NOT be copied.
185
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from
186
     *   being copied. A path matches a pattern if it contains the pattern string at its end. Patterns ending with '/'
187
     *   apply to directory paths only, and patterns not ending with '/' apply to file paths only. For example, '/a/b'
188
     *   matches all file paths ending with '/a/b'; and '.svn/' matches directory paths ending with '.svn'. Note, the
189
     *   '/' characters in a pattern matches both '/' and '\' in the paths.
190
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to
191
     *   true.
192
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
193
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
194
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
195
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
196
     *   while `$to` is the copy target.
197
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
198
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
199
     *   copied from, while `$to` is the copy target.
200
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
201
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
202
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
203
     *   `except`. Defaults to true.
204
     *
205
     * @throws \InvalidArgumentException if unable to open directory
206
     * @throws \Exception
207
     *
208
     * @return void
209
     */
210 2
    public static function copyDirectory(string $source, string $destination, array $options = []): void
211
    {
212 2
        $source = static::normalizePath($source);
213 2
        $destination = static::normalizePath($destination);
214
215 2
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
216 2
            throw new \InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
217
        }
218
219
        $destinationExists = is_dir($destination);
220
221
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
222
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
223
            $destinationExists = true;
224
        }
225
226
        $handle = opendir($source);
227
228
        if ($handle === false) {
229
            throw new \InvalidArgumentException("Unable to open directory: $source");
230
        }
231
232
        if (!isset($options['basePath'])) {
233
            // this should be done only once
234
            $options['basePath'] = realpath($source);
235
            $options = static::normalizeOptions($options);
236
        }
237
238
        while (($file = readdir($handle)) !== false) {
239
            if ($file === '.' || $file === '..') {
240
                continue;
241
            }
242
            $from = $source . '/' . $file;
243
            $to = $destination . '/' . $file;
244
            if (static::filterPath($from, $options)) {
245
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
246
                    continue;
247
                }
248
                if (is_file($from)) {
249
                    if (!$destinationExists) {
250
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
251
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
252
                        $destinationExists = true;
253
                    }
254
                    copy($from, $to);
255
                    if (isset($options['fileMode'])) {
256
                        @chmod($to, $options['fileMode']);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

256
                        /** @scrutinizer ignore-unhandled */ @chmod($to, $options['fileMode']);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
257
                    }
258
                } elseif (!isset($options['recursive']) || $options['recursive']) {
259
                    // recursive copy, defaults to true
260
                    static::copyDirectory($from, $to, $options);
261
                }
262
                if (isset($options['afterCopy'])) {
263
                    \call_user_func($options['afterCopy'], $from, $to);
264
                }
265
            }
266
        }
267
268
        closedir($handle);
269
    }
270
271
    /**
272
     * Normalize options.
273
     *
274
     * @param array $options raw options.
275
     *
276
     * @return array normalized options.
277
     */
278
    protected static function normalizeOptions(array $options): array
279
    {
280
        if (!array_key_exists('caseSensitive', $options)) {
281
            $options['caseSensitive'] = true;
282
        }
283
284
        if (isset($options['except'])) {
285
            foreach ($options['except'] as $key => $value) {
286
                if (\is_string($value)) {
287
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
288
                }
289
            }
290
        }
291
292
        if (isset($options['only'])) {
293
            foreach ($options['only'] as $key => $value) {
294
                if (\is_string($value)) {
295
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
296
                }
297
            }
298
        }
299
300
        return $options;
301
    }
302
303
    /**
304
     * Checks if the given file path satisfies the filtering options.
305
     *
306
     * @param string $path the path of the file or directory to be checked.
307
     * @param array $options the filtering options.
308
     *
309
     * @return bool whether the file or directory satisfies the filtering options.
310
     */
311
    public static function filterPath(string $path, array $options): bool
312
    {
313
        if (isset($options['filter'])) {
314
            $result = \call_user_func($options['filter'], $path);
315
            if (\is_bool($result)) {
316
                return $result;
317
            }
318
        }
319
320
        if (empty($options['except']) && empty($options['only'])) {
321
            return true;
322
        }
323
324
        $path = str_replace('\\', '/', $path);
325
326
        if (!empty($options['except'])) {
327
            $except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except']);
328
            if ($except !== null) {
329
                return $except['flags'] & self::PATTERN_NEGATIVE;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $except['flags'] & self::PATTERN_NEGATIVE returns the type integer which is incompatible with the type-hinted return boolean.
Loading history...
330
            }
331
        }
332
333
        if (!empty($options['only']) && !is_dir($path)) {
334
            // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
335
            return self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only']) !== null;
336
        }
337
338
        return true;
339
    }
340
341
    /**
342
     * Searches for the first wildcard character in the pattern.
343
     *
344
     * @param string $pattern the pattern to search in.
345
     *
346
     * @return int|bool position of first wildcard character or false if not found.
347
     */
348
    private static function firstWildcardInPattern($pattern)
349
    {
350
        $wildcards = ['*', '?', '[', '\\'];
351
        $wildcardSearch = function ($carry, $item) use ($pattern) {
352
            $position = strpos($pattern, $item);
353
            if ($position === false) {
354
                return $carry === false ? $position : $carry;
355
            }
356
            return $carry === false ? $position : min($carry, $position);
357
        };
358
        return array_reduce($wildcards, $wildcardSearch, false);
359
    }
360
361
362
    /**
363
     * Scan the given exclude list in reverse to see whether pathname should be ignored.
364
     *
365
     * The first match (i.e. the last on the list), if any, determines the fate.  Returns the element which matched,
366
     * or null for undecided.
367
     *
368
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
369
     *
370
     * @param string $basePath.
371
     * @param string $path.
372
     * @param array $excludes list of patterns to match $path against.
373
     *
374
     * @return null|array null or one of $excludes item as an array with keys: 'pattern', 'flags'.
375
     *
376
     * @throws \InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern,
377
     *                                   flags, firstWildcard.
378
     */
379
    private static function lastExcludeMatchingFromList(string $basePath, string $path, array $excludes): ?array
380
    {
381
        foreach (array_reverse($excludes) as $exclude) {
382
            if (\is_string($exclude)) {
383
                $exclude = self::parseExcludePattern($exclude, false);
384
            }
385
386
            if (!isset($exclude['pattern'], $exclude['flags'], $exclude['firstWildcard'])) {
387
                throw new \InvalidArgumentException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
388
            }
389
390
            if (($exclude['flags'] & self::PATTERN_MUSTBEDIR) && !is_dir($path)) {
391
                continue;
392
            }
393
394
            if ($exclude['flags'] & self::PATTERN_NODIR) {
395
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
396
                    return $exclude;
397
                }
398
                continue;
399
            }
400
401
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
402
                return $exclude;
403
            }
404
        }
405
406
        return null;
407
    }
408
409
    /**
410
     * Performs a simple comparison of file or directory names.
411
     *
412
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
413
     *
414
     * @param string $baseName file or directory name to compare with the pattern.
415
     * @param string $pattern the pattern that $baseName will be compared against.
416
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern.
417
     * @param int $flags pattern flags
418
     *
419
     * @return bool whether the name matches against pattern
420
     */
421
    private static function matchBasename(string $baseName, string $pattern, ?bool $firstWildcard, int $flags): bool
422
    {
423
        if ($firstWildcard === false) {
424
            if ($pattern === $baseName) {
425
                return true;
426
            }
427
        } elseif ($flags & self::PATTERN_ENDSWITH) {
428
            /* "*literal" matching against "fooliteral" */
429
            $n = StringHelper::byteLength($pattern);
430
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
431
                return true;
432
            }
433
        }
434
435
        $matchOptions = [];
436
437
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
438
            $matchOptions['caseSensitive'] = false;
439
        }
440
441
        return StringHelper::matchWildcard($pattern, $baseName, $matchOptions);
442
    }
443
444
    /**
445
     * Compares a path part against a pattern with optional wildcards.
446
     *
447
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
448
     *
449
     * @param string $path full path to compare
450
     * @param string $basePath base of path that will not be compared
451
     * @param string $pattern the pattern that path part will be compared against
452
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
453
     * @param int $flags pattern flags
454
     *
455
     * @return bool whether the path part matches against pattern
456
     */
457
    private static function matchPathname(string $path, string $basePath, string $pattern, ?bool $firstWildcard, int $flags): bool
458
    {
459
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
460
        if (strpos($pattern, '/') === 0) {
461
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
462
            if ($firstWildcard !== false && $firstWildcard !== 0) {
463
                $firstWildcard--;
464
            }
465
        }
466
467
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
468
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
469
470
        if ($firstWildcard !== 0) {
0 ignored issues
show
introduced by
The condition $firstWildcard !== 0 is always true.
Loading history...
471
            if ($firstWildcard === false) {
472
                $firstWildcard = StringHelper::byteLength($pattern);
473
            }
474
475
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
476
            if ($firstWildcard > $namelen) {
477
                return false;
478
            }
479
480
            if (strncmp($pattern, $name, $firstWildcard)) {
0 ignored issues
show
Bug introduced by
It seems like $firstWildcard can also be of type true; however, parameter $len of strncmp() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

480
            if (strncmp($pattern, $name, /** @scrutinizer ignore-type */ $firstWildcard)) {
Loading history...
481
                return false;
482
            }
483
484
            $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern));
0 ignored issues
show
Bug introduced by
It seems like $firstWildcard can also be of type true; however, parameter $start of Yiisoft\Strings\StringHelper::byteSubstr() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

484
            $pattern = StringHelper::byteSubstr($pattern, /** @scrutinizer ignore-type */ $firstWildcard, StringHelper::byteLength($pattern));
Loading history...
485
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
486
487
            // If the whole pattern did not have a wildcard, then our prefix match is all we need; we do not need to call fnmatch at all.
488
            if (empty($pattern) && empty($name)) {
489
                return true;
490
            }
491
        }
492
493
        $matchOptions = [
494
            'filePath' => true
495
        ];
496
497
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
498
            $matchOptions['caseSensitive'] = false;
499
        }
500
501
        return StringHelper::matchWildcard($pattern, $name, $matchOptions);
502
    }
503
504
    /**
505
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
506
     *
507
     * @param string $pattern
508
     * @param bool $caseSensitive
509
     *
510
     * @throws \InvalidArgumentException
511
     *
512
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
513
     */
514
    private static function parseExcludePattern(string $pattern, bool $caseSensitive): array
515
    {
516
        if (!\is_string($pattern)) {
0 ignored issues
show
introduced by
The condition is_string($pattern) is always true.
Loading history...
517
            throw new \InvalidArgumentException('Exclude/include pattern must be a string.');
518
        }
519
520
        $result = [
521
            'pattern' => $pattern,
522
            'flags' => 0,
523
            'firstWildcard' => false,
524
        ];
525
526
        if (!$caseSensitive) {
527
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
528
        }
529
530
        if (!isset($pattern[0])) {
531
            return $result;
532
        }
533
534
        if (strpos($pattern, '!') === 0) {
535
            $result['flags'] |= self::PATTERN_NEGATIVE;
536
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
537
        }
538
539
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
540
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
541
            $result['flags'] |= self::PATTERN_MUSTBEDIR;
542
        }
543
544
        if (strpos($pattern, '/') === false) {
545
            $result['flags'] |= self::PATTERN_NODIR;
546
        }
547
548
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
549
550
        if (strpos($pattern, '*') === 0 && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
551
            $result['flags'] |= self::PATTERN_ENDSWITH;
552
        }
553
554
        $result['pattern'] = $pattern;
555
        
556
        return $result;
557
    }
558
}
559