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 15
    public static function normalizePath(string $path): string
34
    {
35 15
        $isWindowsShare = strpos($path, '\\\\') === 0;
36 15
        if ($isWindowsShare) {
37 1
            $path = substr($path, 2);
38
        }
39
40 15
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41 15
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42 15
            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 15
            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 15
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78 15
        if (!is_dir($directory)) {
79 1
            return;
80
        }
81 15
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82 15
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85 15
            while (($file = readdir($handle)) !== false) {
86 15
                if ($file === '.' || $file === '..') {
87 15
                    continue;
88
                }
89 14
                $path = $directory . '/' . $file;
90 14
                if (is_dir($path)) {
91 14
                    self::removeDirectory($path, $options);
92
                } else {
93 8
                    self::unlink($path);
94
                }
95
            }
96 15
            closedir($handle);
97
        }
98 15
        if (is_link($directory)) {
99 2
            self::unlink($directory);
100
        } else {
101 15
            rmdir($directory);
102
        }
103 15
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112 8
    public static function unlink(string $path): bool
113
    {
114 8
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116 8
        if (!$isWindows) {
117 8
            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 15
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140 15
        if (is_dir($path)) {
141 1
            return true;
142
        }
143
        try {
144 15
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
145 15
                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 15
            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 8
    public static function copyDirectory(string $source, string $destination, array $options = []): void
211
    {
212 8
        $source = static::normalizePath($source);
213 8
        $destination = static::normalizePath($destination);
214
215 8
        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 6
        $destinationExists = is_dir($destination);
220
221 6
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
222 4
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
223 4
            $destinationExists = true;
224
        }
225
226 6
        $handle = opendir($source);
227
228 6
        if ($handle === false) {
229
            throw new \InvalidArgumentException("Unable to open directory: $source");
230
        }
231
232 6
        if (!isset($options['basePath'])) {
233
            // this should be done only once
234 6
            $options['basePath'] = realpath($source);
235 6
            $options = static::normalizeOptions($options);
236
        }
237
238 6
        while (($file = readdir($handle)) !== false) {
239 6
            if ($file === '.' || $file === '..') {
240 6
                continue;
241
            }
242 4
            $from = $source . '/' . $file;
243 4
            $to = $destination . '/' . $file;
244 4
            if (static::filterPath($from, $options)) {
245 4
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
246
                    continue;
247
                }
248 4
                if (is_file($from)) {
249 4
                    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 4
                    copy($from, $to);
255 4
                    if (isset($options['fileMode'])) {
256 4
                        @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 3
                } elseif (!isset($options['recursive']) || $options['recursive']) {
259
                    // recursive copy, defaults to true
260 2
                    static::copyDirectory($from, $to, $options);
261
                }
262 4
                if (isset($options['afterCopy'])) {
263
                    \call_user_func($options['afterCopy'], $from, $to);
264
                }
265
            }
266
        }
267
268 6
        closedir($handle);
269 6
    }
270
271
    /**
272
     * Normalize options.
273
     *
274
     * @param array $options raw options.
275
     *
276
     * @return array normalized options.
277
     */
278 6
    protected static function normalizeOptions(array $options): array
279
    {
280 6
        if (!array_key_exists('caseSensitive', $options)) {
281 6
            $options['caseSensitive'] = true;
282
        }
283
284 6
        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 6
        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 6
        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 5
    public static function filterPath(string $path, array $options): bool
312
    {
313 5
        if (isset($options['filter'])) {
314 1
            $result = \call_user_func($options['filter'], $path);
315
316 1
            if (\is_bool($result)) {
317 1
                return $result;
318
            }
319
        }
320
321 4
        if (empty($options['except']) && empty($options['only'])) {
322 4
            return true;
323
        }
324
325
        $path = str_replace('\\', '/', $path);
326
327
        if (!empty($options['except'])) {
328
            $except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except']);
329
            if ($except !== null) {
330
                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...
331
            }
332
        }
333
334
        if (!empty($options['only']) && !is_dir($path)) {
335
            // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
336
            return self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only']) !== null;
337
        }
338
339
        return true;
340
    }
341
342
    /**
343
     * Searches for the first wildcard character in the pattern.
344
     *
345
     * @param string $pattern the pattern to search in.
346
     *
347
     * @return int|bool position of first wildcard character or false if not found.
348
     */
349
    private static function firstWildcardInPattern(string $pattern)
350
    {
351
        $wildcards = ['*', '?', '[', '\\'];
352
        $wildcardSearch = function ($carry, $item) use ($pattern) {
353
            $position = strpos($pattern, $item);
354
            if ($position === false) {
355
                return $carry === false ? $position : $carry;
356
            }
357
            return $carry === false ? $position : min($carry, $position);
358
        };
359
        return array_reduce($wildcards, $wildcardSearch, false);
360
    }
361
362
363
    /**
364
     * Scan the given exclude list in reverse to see whether pathname should be ignored.
365
     *
366
     * The first match (i.e. the last on the list), if any, determines the fate.  Returns the element which matched,
367
     * or null for undecided.
368
     *
369
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
370
     *
371
     * @param string $basePath.
372
     * @param string $path.
373
     * @param array $excludes list of patterns to match $path against.
374
     *
375
     * @return null|array null or one of $excludes item as an array with keys: 'pattern', 'flags'.
376
     *
377
     * @throws \InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern,
378
     *                                   flags, firstWildcard.
379
     */
380
    private static function lastExcludeMatchingFromList(string $basePath, string $path, array $excludes): ?array
381
    {
382
        foreach (array_reverse($excludes) as $exclude) {
383
            if (\is_string($exclude)) {
384
                $exclude = self::parseExcludePattern($exclude, false);
385
            }
386
387
            if (!isset($exclude['pattern'], $exclude['flags'], $exclude['firstWildcard'])) {
388
                throw new \InvalidArgumentException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
389
            }
390
391
            if (($exclude['flags'] & self::PATTERN_MUST_BE_DIR) && !is_dir($path)) {
392
                continue;
393
            }
394
395
            if ($exclude['flags'] & self::PATTERN_NO_DIR) {
396
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
397
                    return $exclude;
398
                }
399
                continue;
400
            }
401
402
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
403
                return $exclude;
404
            }
405
        }
406
407
        return null;
408
    }
409
410
    /**
411
     * Performs a simple comparison of file or directory names.
412
     *
413
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
414
     *
415
     * @param string $baseName file or directory name to compare with the pattern.
416
     * @param string $pattern the pattern that $baseName will be compared against.
417
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern.
418
     * @param int $flags pattern flags
419
     *
420
     * @return bool whether the name matches against pattern
421
     */
422
    private static function matchBasename(string $baseName, string $pattern, $firstWildcard, int $flags): bool
423
    {
424
        if ($firstWildcard === false) {
425
            if ($pattern === $baseName) {
426
                return true;
427
            }
428
        } elseif ($flags & self::PATTERN_ENDS_WITH) {
429
            /* "*literal" matching against "fooliteral" */
430
            $n = StringHelper::byteLength($pattern);
431
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
432
                return true;
433
            }
434
        }
435
436
        $matchOptions = [];
437
438
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
439
            $matchOptions['caseSensitive'] = false;
440
        }
441
442
        return StringHelper::matchWildcard($pattern, $baseName, $matchOptions);
443
    }
444
445
    /**
446
     * Compares a path part against a pattern with optional wildcards.
447
     *
448
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
449
     *
450
     * @param string $path full path to compare
451
     * @param string $basePath base of path that will not be compared
452
     * @param string $pattern the pattern that path part will be compared against
453
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
454
     * @param int $flags pattern flags
455
     *
456
     * @return bool whether the path part matches against pattern
457
     */
458
    private static function matchPathname(string $path, string $basePath, string $pattern, $firstWildcard, int $flags): bool
459
    {
460
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
461
        if (strpos($pattern, '/') === 0) {
462
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
463
            if ($firstWildcard !== false && $firstWildcard !== 0) {
464
                $firstWildcard--;
465
            }
466
        }
467
468
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
469
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
470
471
        if ($firstWildcard !== 0) {
472
            if ($firstWildcard === false) {
473
                $firstWildcard = StringHelper::byteLength($pattern);
474
            }
475
476
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
477
            if ($firstWildcard > $namelen) {
478
                return false;
479
            }
480
481
            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

481
            if (strncmp($pattern, $name, /** @scrutinizer ignore-type */ $firstWildcard)) {
Loading history...
482
                return false;
483
            }
484
485
            $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

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