Passed
Push — master ( 1d8c1d...f6ed1d )
by Alexander
02:15
created

FileHelper::matchPathname()   C

Complexity

Conditions 12
Paths 72

Size

Total Lines 44
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 12.7571

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 22
c 1
b 0
f 0
nc 72
nop 5
dl 0
loc 44
ccs 19
cts 23
cp 0.8261
crap 12.7571
rs 6.9666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use FilesystemIterator;
8
use RecursiveIteratorIterator;
9
use RecursiveDirectoryIterator;
10
use Yiisoft\Strings\StringHelper;
11
use Yiisoft\Strings\WildcardPattern;
12
13
use function is_string;
14
15
/**
16
 * FileHelper provides useful methods to manage files and directories
17
 */
18
class FileHelper
19
{
20
    /**
21
     * @var int PATTERN_NO_DIR
22
     */
23
    private const PATTERN_NO_DIR = 1;
24
25
    /**
26
     * @var int PATTERN_ENDS_WITH
27
     */
28
    private const PATTERN_ENDS_WITH = 4;
29
30
    /**
31
     * @var int PATTERN_MUST_BE_DIR
32
     */
33
    private const PATTERN_MUST_BE_DIR = 8;
34
35
    /**
36
     * @var int PATTERN_NEGATIVE
37
     */
38
    private const PATTERN_NEGATIVE = 16;
39
40
    /**
41
     * @var int PATTERN_CASE_INSENSITIVE
42
     */
43
    private const PATTERN_CASE_INSENSITIVE = 32;
44
45
    /**
46
     * Creates a new directory.
47
     *
48
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
49
     * created directory in order to avoid the impact of the `umask` setting.
50
     *
51
     * @param string $path path of the directory to be created.
52
     * @param int $mode the permission to be set for the created directory.
53
     *
54
     * @return bool whether the directory is created successfully.
55
     */
56 36
    public static function createDirectory(string $path, int $mode = 0775): bool
57
    {
58 36
        $path = static::normalizePath($path);
59
60
        try {
61 36
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
62 36
                return false;
63
            }
64 2
        } catch (\Exception $e) {
65 2
            if (!is_dir($path)) {
66 1
                throw new \RuntimeException(
67 1
                    "Failed to create directory \"$path\": " . $e->getMessage(),
68 1
                    $e->getCode(),
69
                    $e
70
                );
71
            }
72
        }
73
74 36
        return static::chmod($path, $mode);
75
    }
76
77
    /**
78
     * Set permissions directory.
79
     *
80
     * @param string $path
81
     * @param integer $mode
82
     *
83
     * @throws \RuntimeException
84
     *
85
     * @return boolean|null
86
     */
87 36
    private static function chmod(string $path, int $mode): ?bool
88
    {
89
        try {
90 36
            return chmod($path, $mode);
91
        } catch (\Exception $e) {
92
            throw new \RuntimeException(
93
                "Failed to change permissions for directory \"$path\": " . $e->getMessage(),
94
                $e->getCode(),
95
                $e
96
            );
97
        }
98
    }
99
100
    /**
101
     * Normalizes a file/directory path.
102
     *
103
     * The normalization does the following work:
104
     *
105
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
106
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
107
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
108
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
109
     *
110
     * @param string $path the file/directory path to be normalized
111
     *
112
     * @return string the normalized file/directory path
113
     */
114 36
    public static function normalizePath(string $path): string
115
    {
116 36
        $isWindowsShare = strpos($path, '\\\\') === 0;
117
118 36
        if ($isWindowsShare) {
119 1
            $path = substr($path, 2);
120
        }
121
122 36
        $path = rtrim(strtr($path, '/\\', '//'), '/');
123
124 36
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
125 36
            return $isWindowsShare ? "\\\\$path" : $path;
126
        }
127
128 1
        $parts = [];
129
130 1
        foreach (explode('/', $path) as $part) {
131 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
132 1
                array_pop($parts);
133 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
134 1
                $parts[] = $part;
135
            }
136
        }
137
138 1
        $path = implode('/', $parts);
139
140 1
        if ($isWindowsShare) {
141 1
            $path = '\\\\' . $path;
142
        }
143
144 1
        return $path === '' ? '.' : $path;
145
    }
146
147
    /**
148
     * Removes a directory (and all its content) recursively.
149
     *
150
     * @param string $directory the directory to be deleted recursively.
151
     * @param array $options options for directory remove ({@see clearDirectory()}).
152
     *
153
     * @return void
154
     */
155 36
    public static function removeDirectory(string $directory, array $options = []): void
156
    {
157
        try {
158 36
            static::clearDirectory($directory, $options);
159 1
        } catch (\InvalidArgumentException $e) {
160 1
            return;
161
        }
162
163 36
        if (is_link($directory)) {
164 2
            self::unlink($directory);
165
        } else {
166 36
            rmdir($directory);
167
        }
168 36
    }
169
170
    /**
171
     * Clear all directory content.
172
     *
173
     * @param string $directory the directory to be cleared.
174
     * @param array $options options for directory clear . Valid options are:
175
     *
176
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
177
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
178
     *   Only symlink would be removed in that default case.
179
     *
180
     * @throws \InvalidArgumentException if unable to open directory
181
     *
182
     * @return void
183
     */
184 36
    public static function clearDirectory(string $directory, array $options = []): void
185
    {
186 36
        $handle = static::openDirectory($directory);
187 36
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
188 36
            while (($file = readdir($handle)) !== false) {
189 36
                if ($file === '.' || $file === '..') {
190 36
                    continue;
191
                }
192 24
                $path = $directory . '/' . $file;
193 24
                if (is_dir($path)) {
194 24
                    self::removeDirectory($path, $options);
195
                } else {
196 16
                    self::unlink($path);
197
                }
198
            }
199 36
            closedir($handle);
200
        }
201 36
    }
202
203
    /**
204
     * Removes a file or symlink in a cross-platform way.
205
     * @param string $path
206
     * @return bool
207
     */
208 17
    public static function unlink(string $path): bool
209
    {
210 17
        $isWindows = DIRECTORY_SEPARATOR === '\\';
211
212 17
        if (!$isWindows) {
213 17
            return unlink($path);
214
        }
215
216
        if (is_link($path) && is_dir($path)) {
217
            return rmdir($path);
218
        }
219
220
        if (!is_writable($path)) {
221
            chmod($path, 0777);
222
        }
223
224
        return unlink($path);
225
    }
226
227
    /**
228
     * Tells whether the path is a empty directory
229
     * @param string $path
230
     * @return bool
231
     */
232 1
    public static function isEmptyDirectory(string $path): bool
233
    {
234 1
        if (!is_dir($path)) {
235 1
            return false;
236
        }
237
238 1
        return !(new FilesystemIterator($path))->valid();
239
    }
240
241
    /**
242
     * Copies a whole directory as another one.
243
     *
244
     * The files and sub-directories will also be copied over.
245
     *
246
     * @param string $source the source directory.
247
     * @param string $destination the destination directory.
248
     * @param array $options options for directory copy. Valid options are:
249
     *
250
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
251
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
252
     *   setting.
253
     * - filter: callback, a PHP callback that is called for each directory or file.
254
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
255
     *   The callback can return one of the following values:
256
     *
257
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored).
258
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored).
259
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied.
260
     *
261
     * - only: array, list of patterns that the file paths should match if they want to be copied. A path matches a
262
     *   pattern if it contains the pattern string at its end. For example, '.php' matches all file paths ending with
263
     *   '.php'.
264
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths. If a file path matches a pattern
265
     *   in both "only" and "except", it will NOT be copied.
266
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from
267
     *   being copied. A path matches a pattern if it contains the pattern string at its end. Patterns ending with '/'
268
     *   apply to directory paths only, and patterns not ending with '/' apply to file paths only. For example, '/a/b'
269
     *   matches all file paths ending with '/a/b'; and '.svn/' matches directory paths ending with '.svn'. Note, the
270
     *   '/' characters in a pattern matches both '/' and '\' in the paths.
271
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to
272
     *   true.
273
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
274
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
275
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
276
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
277
     *   while `$to` is the copy target.
278
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
279
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
280
     *   copied from, while `$to` is the copy target.
281
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
282
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
283
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
284
     *   `except`. Defaults to true.
285
     *
286
     * @throws \InvalidArgumentException if unable to open directory
287
     * @throws \Exception
288
     *
289
     * @return void
290
     */
291 12
    public static function copyDirectory(string $source, string $destination, array $options = []): void
292
    {
293 12
        $source = static::normalizePath($source);
294 12
        $destination = static::normalizePath($destination);
295
296 12
        static::assertNotSelfDirectory($source, $destination);
297
298 10
        $destinationExists = static::setDestination($destination, $options);
299
300 10
        $handle = static::openDirectory($source);
301
302 9
        $options = static::setBasePath($source, $options);
303
304 9
        while (($file = readdir($handle)) !== false) {
305 9
            if ($file === '.' || $file === '..') {
306 9
                continue;
307
            }
308
309 7
            $from = $source . '/' . $file;
310 7
            $to = $destination . '/' . $file;
311
312 7
            if (static::filterPath($from, $options)) {
313 7
                if (is_file($from)) {
314 7
                    if (!$destinationExists) {
315 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
316 2
                        $destinationExists = true;
317
                    }
318 7
                    copy($from, $to);
319 7
                    if (isset($options['fileMode'])) {
320 7
                        static::chmod($to, $options['fileMode']);
321
                    }
322 6
                } elseif (!isset($options['recursive']) || $options['recursive']) {
323 5
                    static::copyDirectory($from, $to, $options);
324
                }
325
            }
326
        }
327
328 9
        closedir($handle);
329 9
    }
330
331
    /**
332
     * Check copy it self directory.
333
     *
334
     * @param string $source
335
     * @param string $destination
336
     *
337
     * @throws \InvalidArgumentException
338
     */
339 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
340
    {
341 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
342 2
            throw new \InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
343
        }
344 10
    }
345
346
    /**
347
     * Open directory handle.
348
     *
349
     * @param string $directory
350
     *
351
     * @return resource
352
     * @throws \InvalidArgumentException
353
     */
354 36
    private static function openDirectory(string $directory)
355
    {
356 36
        $handle = @opendir($directory);
357
358 36
        if ($handle === false) {
359 3
            throw new \InvalidArgumentException("Unable to open directory: $directory");
360
        }
361
362 36
        return $handle;
363
    }
364
365
    /**
366
     * Set base path directory.
367
     *
368
     * @param string $source
369
     * @param array $options
370
     *
371
     * @return array
372
     */
373 9
    private static function setBasePath(string $source, array $options): array
374
    {
375 9
        if (!isset($options['basePath'])) {
376
            // this should be done only once
377 9
            $options['basePath'] = realpath($source);
378 9
            $options = static::normalizeOptions($options);
379
        }
380
381 9
        return $options;
382
    }
383
384
    /**
385
     * Set destination directory.
386
     *
387
     * @param string $destination
388
     * @param array $options
389
     *
390
     * @return bool
391
     */
392 10
    private static function setDestination(string $destination, array $options): bool
393
    {
394 10
        $destinationExists = is_dir($destination);
395
396 10
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
397 6
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
398 6
            $destinationExists = true;
399
        }
400
401 10
        return $destinationExists;
402
    }
403
404
    /**
405
     * Normalize options.
406
     *
407
     * @param array $options raw options.
408
     *
409
     * @return array normalized options.
410
     */
411 9
    protected static function normalizeOptions(array $options): array
412
    {
413 9
        $options = static::setCaseSensitive($options);
414 9
        $options = static::setExcept($options);
415 9
        $options = static::setOnly($options);
416
417 9
        return $options;
418
    }
419
420
    /**
421
     * Set options case sensitive.
422
     *
423
     * @param array $options
424
     *
425
     * @return array
426
     */
427 9
    private static function setCaseSensitive(array $options): array
428
    {
429 9
        if (!array_key_exists('caseSensitive', $options)) {
430 9
            $options['caseSensitive'] = true;
431
        }
432
433 9
        return $options;
434
    }
435
436
    /**
437
     * Set options except.
438
     *
439
     * @param array $options
440
     *
441
     * @return array
442
     */
443 9
    private static function setExcept(array $options): array
444
    {
445 9
        if (isset($options['except'])) {
446 1
            foreach ($options['except'] as $key => $value) {
447 1
                if (is_string($value)) {
448 1
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
449
                }
450
            }
451
        }
452
453 9
        return $options;
454
    }
455
456
    /**
457
     * Set options only.
458
     *
459
     * @param array $options
460
     *
461
     * @return array
462
     */
463 9
    private static function setOnly(array $options): array
464
    {
465 9
        if (isset($options['only'])) {
466 2
            foreach ($options['only'] as $key => $value) {
467 2
                if (is_string($value)) {
468 2
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
469
                }
470
            }
471
        }
472
473 9
        return $options;
474
    }
475
476
    /**
477
     * Checks if the given file path satisfies the filtering options.
478
     *
479
     * @param string $path the path of the file or directory to be checked.
480
     * @param array $options the filtering options.
481
     *
482
     * @return bool whether the file or directory satisfies the filtering options.
483
     */
484 18
    public static function filterPath(string $path, array $options): bool
485
    {
486 18
        $path = str_replace('\\', '/', $path);
487
488 18
        if (isset($options['filter'])) {
489 5
            if (!is_callable($options['filter'])) {
490 1
                $type = gettype($options['filter']);
491 1
                throw new \InvalidArgumentException("Option \"filter\" must be callable, $type given.");
492
            }
493 4
            $result = call_user_func($options['filter'], $path);
494 4
            if (is_bool($result)) {
495 2
                return $result;
496
            }
497
        }
498
499 15
        if (!empty($options['except']) && self::lastExcludeMatchingFromList(
500 3
            $options['basePath'] ?? '',
501
            $path,
502 3
            (array)$options['except']
503 15
        ) !== null) {
504 2
            return false;
505
        }
506
507 14
        if (!empty($options['only']) && !is_dir($path)) {
508
            // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
509
            return
510 6
                self::lastExcludeMatchingFromList(
511 6
                    $options['basePath'] ?? '',
512
                    $path,
513 6
                    (array) $options['only']
514 5
                ) !== null;
515
        }
516
517 10
        return true;
518
    }
519
520
    /**
521
     * Searches for the first wildcard character in the pattern.
522
     *
523
     * @param string $pattern the pattern to search in.
524
     *
525
     * @return int|bool position of first wildcard character or false if not found.
526
     */
527 7
    private static function firstWildcardInPattern(string $pattern)
528
    {
529 7
        $wildcards = ['*', '?', '[', '\\'];
530 7
        $wildcardSearch = static function ($carry, $item) use ($pattern) {
531 7
            $position = strpos($pattern, $item);
532 7
            if ($position === false) {
533 7
                return $carry === false ? $position : $carry;
534
            }
535 7
            return $carry === false ? $position : min($carry, $position);
536 7
        };
537 7
        return array_reduce($wildcards, $wildcardSearch, false);
538
    }
539
540
541
    /**
542
     * Scan the given exclude list in reverse to see whether pathname should be ignored.
543
     *
544
     * The first match (i.e. the last on the list), if any, determines the fate.  Returns the element which matched,
545
     * or null for undecided.
546
     *
547
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
548
     *
549
     * @param string $basePath.
550
     * @param string $path.
551
     * @param array $excludes list of patterns to match $path against.
552
     *
553
     * @return null|array null or one of $excludes item as an array with keys: 'pattern', 'flags'.
554
     *
555
     * @throws \InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern,
556
     *                                   flags, firstWildcard.
557
     */
558 8
    private static function lastExcludeMatchingFromList(string $basePath, string $path, array $excludes): ?array
559
    {
560 8
        foreach (array_reverse($excludes) as $exclude) {
561 8
            if (is_string($exclude)) {
562 5
                $exclude = self::parseExcludePattern($exclude, false);
563
            }
564
565 8
            if (!isset($exclude['pattern'], $exclude['flags'], $exclude['firstWildcard'])) {
566 1
                throw new \InvalidArgumentException(
567 1
                    'If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.'
568
                );
569
            }
570
571 7
            if (($exclude['flags'] & self::PATTERN_MUST_BE_DIR) && !is_dir($path)) {
572
                continue;
573
            }
574
575 7
            if ($exclude['flags'] & self::PATTERN_NO_DIR) {
576 5
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
577 2
                    return $exclude;
578
                }
579 3
                continue;
580
            }
581
582 2
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
583 2
                return $exclude;
584
            }
585
        }
586
587 5
        return null;
588
    }
589
590
    /**
591
     * Performs a simple comparison of file or directory names.
592
     *
593
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
594
     *
595
     * @param string $baseName file or directory name to compare with the pattern.
596
     * @param string $pattern the pattern that $baseName will be compared against.
597
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern.
598
     * @param int $flags pattern flags
599
     *
600
     * @return bool whether the name matches against pattern
601
     */
602 5
    private static function matchBasename(string $baseName, string $pattern, $firstWildcard, int $flags): bool
603
    {
604 5
        if ($firstWildcard === false) {
605
            if ($pattern === $baseName) {
606
                return true;
607
            }
608 5
        } elseif ($flags & self::PATTERN_ENDS_WITH) {
609
            /* "*literal" matching against "fooliteral" */
610 5
            $n = StringHelper::byteLength($pattern);
611 5
            if (StringHelper::byteSubstring($pattern, 1, $n) === StringHelper::byteSubstring($baseName, -$n, $n)) {
612
                return true;
613
            }
614
        }
615
616
617 5
        $wildcardPattern = new WildcardPattern($pattern);
618
619 5
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
620 5
            $wildcardPattern = $wildcardPattern->ignoreCase();
621
        }
622
623 5
        return $wildcardPattern->match($baseName);
624
    }
625
626
    /**
627
     * Compares a path part against a pattern with optional wildcards.
628
     *
629
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
630
     *
631
     * @param string $path full path to compare
632
     * @param string $basePath base of path that will not be compared
633
     * @param string $pattern the pattern that path part will be compared against
634
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
635
     * @param int $flags pattern flags
636
     *
637
     * @return bool whether the path part matches against pattern
638
     */
639 2
    private static function matchPathname(string $path, string $basePath, string $pattern, $firstWildcard, int $flags): bool
640
    {
641
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
642 2
        if (strpos($pattern, '/') === 0) {
643
            $pattern = StringHelper::byteSubstring($pattern, 1, StringHelper::byteLength($pattern));
644
            if ($firstWildcard !== false && $firstWildcard !== 0) {
645
                $firstWildcard--;
646
            }
647
        }
648
649 2
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
650 2
        $name = StringHelper::byteSubstring($path, -$namelen, $namelen);
651
652 2
        if ($firstWildcard !== 0) {
653 2
            if ($firstWildcard === false) {
654 1
                $firstWildcard = StringHelper::byteLength($pattern);
655
            }
656
657
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
658 2
            if ($firstWildcard > $namelen) {
659 1
                return false;
660
            }
661
662 2
            if (strncmp($pattern, $name, (int) $firstWildcard)) {
663 2
                return false;
664
            }
665
666 2
            $pattern = StringHelper::byteSubstring($pattern, (int) $firstWildcard, StringHelper::byteLength($pattern));
667 2
            $name = StringHelper::byteSubstring($name, (int) $firstWildcard, $namelen);
668
669
            // 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.
670 2
            if (empty($pattern) && empty($name)) {
671 1
                return true;
672
            }
673
        }
674
675 2
        $wildcardPattern = (new WildcardPattern($pattern))
676 2
            ->withExactSlashes();
677
678 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
679
            $wildcardPattern = $wildcardPattern->ignoreCase();
680
        }
681
682 2
        return $wildcardPattern->match($name);
683
    }
684
685
    /**
686
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
687
     *
688
     * @param string $pattern
689
     * @param bool $caseSensitive
690
     *
691
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
692
     */
693 7
    private static function parseExcludePattern(string $pattern, bool $caseSensitive): array
694
    {
695
        $result = [
696 7
            'pattern' => $pattern,
697 7
            'flags' => 0,
698
            'firstWildcard' => false,
699
        ];
700
701 7
        $result = static::isCaseInsensitive($caseSensitive, $result);
702
703 7
        if (!isset($pattern[0])) {
704
            return $result;
705
        }
706
707 7
        if (strpos($pattern, '!') === 0) {
708
            $result['flags'] |= self::PATTERN_NEGATIVE;
709
            $pattern = StringHelper::byteSubstring($pattern, 1, StringHelper::byteLength($pattern));
710
        }
711
712 7
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstring($pattern, -1, 1) === '/') {
713
            $pattern = StringHelper::byteSubstring($pattern, 0, -1);
714
            $result['flags'] |= self::PATTERN_MUST_BE_DIR;
715
        }
716
717 7
        $result = static::isPatternNoDir($pattern, $result);
718
719 7
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
720
721 7
        $result = static::isPatternEndsWith($pattern, $result);
722
723 7
        $result['pattern'] = $pattern;
724
725 7
        return $result;
726
    }
727
728
    /**
729
     * Check isCaseInsensitive.
730
     *
731
     * @param boolean $caseSensitive
732
     * @param array $result
733
     *
734
     * @return array
735
     */
736 7
    private static function isCaseInsensitive(bool $caseSensitive, array $result): array
737
    {
738 7
        if (!$caseSensitive) {
739 5
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
740
        }
741
742 7
        return $result;
743
    }
744
745
    /**
746
     * Check pattern no directory.
747
     *
748
     * @param string $pattern
749
     * @param array $result
750
     *
751
     * @return array
752
     */
753 7
    private static function isPatternNoDir(string $pattern, array $result): array
754
    {
755 7
        if (strpos($pattern, '/') === false) {
756 5
            $result['flags'] |= self::PATTERN_NO_DIR;
757
        }
758
759 7
        return $result;
760
    }
761
762
    /**
763
     * Check pattern ends with
764
     *
765
     * @param string $pattern
766
     * @param array $result
767
     *
768
     * @return array
769
     */
770 7
    private static function isPatternEndsWith(string $pattern, array $result): array
771
    {
772 7
        if (strpos($pattern, '*') === 0 && self::firstWildcardInPattern(StringHelper::byteSubstring($pattern, 1, StringHelper::byteLength($pattern))) === false) {
773 5
            $result['flags'] |= self::PATTERN_ENDS_WITH;
774
        }
775
776 7
        return $result;
777
    }
778
779
    /**
780
     * Returns the last modification time for the given path.
781
     *
782
     * If the path is a directory, any nested files/directories will be checked as well.
783
     *
784
     * @param string $path the directory to be checked
785
     *
786
     * @return int Unix timestamp representing the last modification time
787
     */
788 1
    public static function lastModifiedTime(string $path): int
789
    {
790 1
        if (is_file($path)) {
791
            return filemtime($path);
792
        }
793
794 1
        $times = [filemtime($path)];
795
796 1
        $iterator = new RecursiveIteratorIterator(
797 1
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
798 1
            RecursiveIteratorIterator::SELF_FIRST
799
        );
800
801 1
        foreach ($iterator as $p => $info) {
802 1
            $times[] = filemtime($p);
803
        }
804
805 1
        return max($times);
806
    }
807
}
808