Passed
Pull Request — master (#24)
by Sergei
12:51
created

FileHelper::matchPathname()   C

Complexity

Conditions 12
Paths 72

Size

Total Lines 44
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 12.2341

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