Passed
Pull Request — master (#5)
by Wilmer
01:14
created

FileHelper::removeDirectory()   B

Complexity

Conditions 10
Paths 6

Size

Total Lines 26
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 10.0203

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 18
c 1
b 0
f 0
nc 6
nop 2
dl 0
loc 26
ccs 16
cts 17
cp 0.9412
crap 10.0203
rs 7.6666

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
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_NO_DIR = 1;
14
    private const PATTERN_ENDS_WITH = 4;
15
    private const PATTERN_MUST_BE_DIR = 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 17
    public static function normalizePath(string $path): string
34
    {
35 17
        $isWindowsShare = strpos($path, '\\\\') === 0;
36 17
        if ($isWindowsShare) {
37 1
            $path = substr($path, 2);
38
        }
39
40 17
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41 17
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42 17
            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 17
            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 17
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78 17
        if (!is_dir($directory)) {
79 1
            return;
80
        }
81 17
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82 17
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85 17
            while (($file = readdir($handle)) !== false) {
86 17
                if ($file === '.' || $file === '..') {
87 17
                    continue;
88
                }
89 16
                $path = $directory . '/' . $file;
90 16
                if (is_dir($path)) {
91 16
                    self::removeDirectory($path, $options);
92
                } else {
93 10
                    self::unlink($path);
94
                }
95
            }
96 17
            closedir($handle);
97
        }
98 17
        if (is_link($directory)) {
99 2
            self::unlink($directory);
100
        } else {
101 17
            rmdir($directory);
102
        }
103 17
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112 10
    public static function unlink(string $path): bool
113
    {
114 10
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116 10
        if (!$isWindows) {
117 10
            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 17
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140 17
        if (is_dir($path)) {
141 1
            return true;
142
        }
143
        try {
144 17
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
145 17
                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 17
            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 11
    public static function copyDirectory(string $source, string $destination, array $options = []): void
211
    {
212 11
        $source = static::normalizePath($source);
213 11
        $destination = static::normalizePath($destination);
214
215 11
        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 9
        $destinationExists = is_dir($destination);
220
221 9
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
222 5
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
223 5
            $destinationExists = true;
224
        }
225
226 9
        $handle = opendir($source);
227
228 9
        if ($handle === false) {
229
            throw new \InvalidArgumentException("Unable to open directory: $source");
230
        }
231
232 9
        if (!isset($options['basePath'])) {
233
            // this should be done only once
234 9
            $options['basePath'] = realpath($source);
235 9
            $options = static::normalizeOptions($options);
236
        }
237
238 9
        while (($file = readdir($handle)) !== false) {
239 9
            if ($file === '.' || $file === '..') {
240 9
                continue;
241
            }
242
243 7
            $from = $source . '/' . $file;
244 7
            $to = $destination . '/' . $file;
245
246 7
            if ($status = static::filterPath($from, $options)) {
0 ignored issues
show
Unused Code introduced by
The assignment to $status is dead and can be removed.
Loading history...
247 7
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
248
                    continue;
249
                }
250 7
                if (is_file($from)) {
251 7
                    if (!$destinationExists) {
252
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
253 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
254 2
                        $destinationExists = true;
255
                    }
256 7
                    copy($from, $to);
257 7
                    if (isset($options['fileMode'])) {
258 7
                        @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

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

470
            if (strncmp($pattern, $name, /** @scrutinizer ignore-type */ $firstWildcard)) {
Loading history...
471 2
                return false;
472
            }
473
474 2
            $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

474
            $pattern = StringHelper::byteSubstr($pattern, /** @scrutinizer ignore-type */ $firstWildcard, StringHelper::byteLength($pattern));
Loading history...
475 2
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
476
477
            // 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.
478 2
            if (empty($pattern) && empty($name)) {
479 1
                return true;
480
            }
481
        }
482
483
        $matchOptions = [
484 2
            'filePath' => true
485
        ];
486
487 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
488
            $matchOptions['caseSensitive'] = false;
489
        }
490
491 2
        return StringHelper::matchWildcard($pattern, $name, $matchOptions);
492
    }
493
494
    /**
495
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
496
     *
497
     * @param string $pattern
498
     * @param bool $caseSensitive
499
     *
500
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
501
     */
502 2
    private static function parseExcludePattern(string $pattern, bool $caseSensitive): array
503
    {
504
        $result = [
505 2
            'pattern' => $pattern,
506 2
            'flags' => 0,
507
            'firstWildcard' => false,
508
        ];
509
510 2
        if (!$caseSensitive) {
511
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
512
        }
513
514 2
        if (!isset($pattern[0])) {
515
            return $result;
516
        }
517
518 2
        if (strpos($pattern, '!') === 0) {
519
            $result['flags'] |= self::PATTERN_NEGATIVE;
520
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
521
        }
522
523 2
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
524
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
525
            $result['flags'] |= self::PATTERN_MUST_BE_DIR;
526
        }
527
528 2
        if (strpos($pattern, '/') === false) {
529
            $result['flags'] |= self::PATTERN_NO_DIR;
530
        }
531
532 2
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
533
534 2
        if (strpos($pattern, '*') === 0 && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
535
            $result['flags'] |= self::PATTERN_ENDS_WITH;
536
        }
537
538 2
        $result['pattern'] = $pattern;
539
540 2
        return $result;
541
    }
542
}
543