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

FileHelper   F

Complexity

Total Complexity 112

Size/Duplication

Total Lines 541
Duplicated Lines 0 %

Test Coverage

Coverage 45.64%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 194
dl 0
loc 541
ccs 89
cts 195
cp 0.4564
rs 2
c 1
b 0
f 0
wmc 112

12 Methods

Rating   Name   Duplication   Size   Complexity  
C normalizePath() 0 29 14
A unlink() 0 13 4
B createDirectory() 0 19 7
B removeDirectory() 0 26 10
D copyDirectory() 0 59 20
B normalizeOptions() 0 23 8
C matchPathname() 0 45 12
A matchBasename() 0 21 6
B parseExcludePattern() 0 39 9
A firstWildcardInPattern() 0 11 4
B filterPath() 0 29 9
B lastExcludeMatchingFromList() 0 28 9

How to fix   Complexity   

Complex Class

Complex classes like FileHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FileHelper, and based on these observations, apply Extract Interface, too.

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