Completed
Push — master ( 5163d9...817b84 )
by Mike
07:06 queued 11s
created

Glob::assertValidGlob()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.9666
c 0
b 0
f 0
cc 3
nc 2
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of phpDocumentor.
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 *
11
 * Many thanks to webmozart by providing the original code in webmozart/glob
12
 *
13
 * @link https://github.com/webmozart/glob/blob/master/src/Glob.php
14
 * @link      http://phpdoc.org
15
 */
16
17
namespace Flyfinder\Specification;
18
19
use InvalidArgumentException;
20
use function array_slice;
21
use function count;
22
use function explode;
23
use function implode;
24
use function max;
25
use function min;
26
use function preg_match;
27
use function rtrim;
28
use function sprintf;
29
use function strlen;
30
use function strpos;
31
use function substr;
32
33
/**
34
 * Glob specification class
35
 *
36
 * @psalm-immutable
37
 */
38
final class Glob extends CompositeSpecification
39
{
40
    /** @var string */
41
    private $regex;
42
43
    /**
44
     * The "static prefix" is the part of the glob up to the first wildcard "*".
45
     * If the glob does not contain wildcards, the full glob is returned.
46
     *
47
     * @var string
48
     */
49
    private $staticPrefix;
50
51
    /**
52
     * The "bounded prefix" is the part of the glob up to the first recursive wildcard "**".
53
     * It is the longest prefix for which the number of directory segments in the partial match
54
     * is known. If the glob does not contain the recursive wildcard "**", the full glob is returned.
55
     *
56
     * @var string
57
     */
58
    private $boundedPrefix;
59
60
    /**
61
     * The "total prefix" is the part of the glob before the trailing catch-all wildcard sequence if the glob
62
     * ends with one, otherwise null. It is needed for implementing the A-quantifier pruning hint.
63
     *
64
     * @var string|null
65
     */
66
    private $totalPrefix;
67
68
    public function __construct(string $glob)
69
    {
70
        $this->regex         = self::toRegEx($glob);
71
        $this->staticPrefix  = self::getStaticPrefix($glob);
72
        $this->boundedPrefix = self::getBoundedPrefix($glob);
73
        $this->totalPrefix   = self::getTotalPrefix($glob);
74
    }
75
76
    /**
77
     * @inheritDoc
78
     */
79
    public function isSatisfiedBy(array $value) : bool
80
    {
81
        //Flysystem paths are not absolute, so make it that way.
82
        $path = '/' . $value['path'];
83
        if (strpos($path, $this->staticPrefix) !== 0) {
84
            return false;
85
        }
86
87
        if (preg_match($this->regex, $path)) {
88
            return true;
89
        }
90
91
        return false;
92
    }
93
94
    /**
95
     * Returns the static prefix of a glob.
96
     *
97
     * The "static prefix" is the part of the glob up to the first wildcard "*".
98
     * If the glob does not contain wildcards, the full glob is returned.
99
     *
100
     * @param string $glob The canonical glob. The glob should contain forward
101
     *                      slashes as directory separators only. It must not
102
     *                      contain any "." or ".." segments.
103
     *
104
     * @return string The static prefix of the glob.
105
     *
106
     * @psalm-pure
107
     */
108
    private static function getStaticPrefix(string $glob) : string
109
    {
110
        self::assertValidGlob($glob);
111
        $prefix = '';
112
        $length = strlen($glob);
113
        for ($i = 0; $i < $length; ++$i) {
114
            $c = $glob[$i];
115
            switch ($c) {
116
                case '/':
117
                    $prefix .= '/';
118
                    if (self::isRecursiveWildcard($glob, $i)) {
119
                        break 2;
120
                    }
121
                    break;
122
                case '*':
123
                case '?':
124
                case '{':
125
                case '[':
126
                    break 2;
127
                case '\\':
128
                    [$unescaped, $consumedChars] = self::scanBackslashSequence($glob, $i);
0 ignored issues
show
Bug introduced by
The variable $unescaped does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $consumedChars does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
129
                    $prefix                     .= $unescaped;
130
                    $i                          += $consumedChars;
131
                    break;
132
                default:
133
                    $prefix .= $c;
134
                    break;
135
            }
136
        }
137
        return $prefix;
138
    }
139
140
    private static function getBoundedPrefix(string $glob) : string
141
    {
142
        self::assertValidGlob($glob);
143
        $prefix = '';
144
        $length = strlen($glob);
145
146
        for ($i = 0; $i < $length; ++$i) {
147
            $c = $glob[$i];
148
            switch ($c) {
149
                case '/':
150
                    $prefix .= '/';
151
                    if (self::isRecursiveWildcard($glob, $i)) {
152
                        break 2;
153
                    }
154
                    break;
155
                case '\\':
156
                    [$unescaped, $consumedChars] = self::scanBackslashSequence($glob, $i);
0 ignored issues
show
Bug introduced by
The variable $unescaped does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $consumedChars does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
157
                    $prefix                     .= $unescaped;
158
                    $i                          += $consumedChars;
159
                    break;
160
                default:
161
                    $prefix .= $c;
162
                    break;
163
            }
164
        }
165
        return $prefix;
166
    }
167
168
    private static function getTotalPrefix(string $glob) : ?string
169
    {
170
        self::assertValidGlob($glob);
171
        $matches = [];
172
        return preg_match('~(?<!\\\\)/\\*\\*(?:/\\*\\*?)+$~', $glob, $matches)
173
            ? substr($glob, 0, strlen($glob)-strlen($matches[0]))
174
            : null;
175
    }
176
177
    /**
178
     * @return mixed[]
179
     *
180
     * @psalm-return array{0: string, 1:int}
181
     * @psalm-pure
182
     */
183
    private static function scanBackslashSequence(string $glob, int $offset) : array
184
    {
185
        $startOffset = $offset;
186
        $result      = '';
187
        switch ($c = $glob[$offset + 1] ?? '') {
188
            case '*':
189
            case '?':
190
            case '{':
191
            case '}':
192
            case '[':
193
            case ']':
194
            case '-':
195
            case '^':
196
            case '$':
197
            case '~':
198
            case '\\':
199
                $result .= $c;
200
                ++$offset;
201
                break;
202
            default:
203
                $result .= '\\';
204
        }
205
        return [$result, $offset - $startOffset];
206
    }
207
208
    /**
209
     * Asserts that glob is well formed
210
     *
211
     * @psalm-pure
212
     */
213
    private static function assertValidGlob(string $glob) : void
214
    {
215
        if (strpos($glob, '/') !== 0 && strpos($glob, '://') === false) {
216
            throw new InvalidArgumentException(sprintf(
217
                'The glob "%s" is not absolute and not a URI.',
218
                $glob
219
            ));
220
        }
221
    }
222
223
    /**
224
     * Checks if the current position the glob is start of a Recursive directory wildcard
225
     *
226
     * @psalm-pure
227
     */
228
    private static function isRecursiveWildcard(string $glob, int $i) : bool
229
    {
230
        return isset($glob[$i + 3]) && $glob[$i + 1] . $glob[$i + 2] . $glob[$i + 3] === '**/';
231
    }
232
233
    /**
234
     * Converts a glob to a regular expression.
235
     *
236
     * @param string $glob The canonical glob. The glob should contain forward
237
     *                      slashes as directory separators only. It must not
238
     *                      contain any "." or ".." segments.
239
     *
240
     * @return string The regular expression for matching the glob.
241
     *
242
     * @psalm-pure
243
     */
244
    private static function toRegEx(string $glob) : string
245
    {
246
        $delimiter   = '~';
247
        $inSquare    = false;
248
        $curlyLevels = 0;
249
        $regex       = '';
250
        $length      = strlen($glob);
251
        for ($i = 0; $i < $length; ++$i) {
252
            $c = $glob[$i];
253
            switch ($c) {
254
                case '.':
255
                case '(':
256
                case ')':
257
                case '|':
258
                case '+':
259
                case '^':
260
                case '$':
261
                case $delimiter:
262
                    $regex .= '\\' . $c;
263
                    break;
264
                case '/':
265
                    if (self::isRecursiveWildcard($glob, $i)) {
266
                        $regex .= '/([^/]+/)*';
267
                        $i     += 3;
268
                    } else {
269
                        $regex .= '/';
270
                    }
271
                    break;
272
                case '*':
273
                    $regex .= '[^/]*';
274
                    break;
275
                case '?':
276
                    $regex .= '.';
277
                    break;
278
                case '{':
279
                    $regex .= '(';
280
                    ++$curlyLevels;
281
                    break;
282
                case '}':
283
                    if ($curlyLevels > 0) {
284
                        $regex .= ')';
285
                        --$curlyLevels;
286
                    } else {
287
                        $regex .= '}';
288
                    }
289
                    break;
290
                case ',':
291
                    $regex .= $curlyLevels > 0 ? '|' : ',';
292
                    break;
293
                case '[':
294
                    $regex   .= '[';
295
                    $inSquare = true;
296
                    if (isset($glob[$i + 1]) && $glob[$i + 1] === '^') {
297
                        $regex .= '^';
298
                        ++$i;
299
                    }
300
                    break;
301
                case ']':
302
                    $regex   .= $inSquare ? ']' : '\\]';
303
                    $inSquare = false;
304
                    break;
305
                case '-':
306
                    $regex .= $inSquare ? '-' : '\\-';
307
                    break;
308
                case '\\':
309
                    if (isset($glob[$i + 1])) {
310
                        switch ($glob[$i + 1]) {
311
                            case '*':
312
                            case '?':
313
                            case '{':
314
                            case '}':
315
                            case '[':
316
                            case ']':
317
                            case '-':
318
                            case '^':
319
                            case '$':
320
                            case '~':
321
                            case '\\':
322
                                $regex .= '\\' . $glob[$i + 1];
323
                                ++$i;
324
                                break;
325
                            default:
326
                                $regex .= '\\\\';
327
                        }
328
                    }
329
                    break;
330
                default:
331
                    $regex .= $c;
332
                    break;
333
            }
334
        }
335
        if ($inSquare) {
336
            throw new InvalidArgumentException(sprintf(
337
                'Invalid glob: missing ] in %s',
338
                $glob
339
            ));
340
        }
341
        if ($curlyLevels > 0) {
342
            throw new InvalidArgumentException(sprintf(
343
                'Invalid glob: missing } in %s',
344
                $glob
345
            ));
346
        }
347
        return $delimiter . '^' . $regex . '$' . $delimiter;
348
    }
349
350
    /** @inheritDoc */
351
    public function canBeSatisfiedBySomethingBelow(array $value) : bool
352
    {
353
        $valueSegments             = explode('/', '/' . $value['path']);
354
        $boundedPrefixSegments     = explode('/', rtrim($this->boundedPrefix, '/'));
355
        $howManySegmentsToConsider = min(count($valueSegments), count($boundedPrefixSegments));
356
        $boundedPrefixGlob         = implode('/', array_slice($boundedPrefixSegments, 0, $howManySegmentsToConsider));
357
        $valuePathPrefix           = implode('/', array_slice($valueSegments, 1, max($howManySegmentsToConsider-1, 0)));
358
        $prefixValue               = $value;
359
        $prefixValue['path']       = $valuePathPrefix;
360
        $spec                      = new Glob($boundedPrefixGlob);
361
        return $spec->isSatisfiedBy($prefixValue);
362
    }
363
364
    /** @inheritDoc */
365
    public function willBeSatisfiedByEverythingBelow(array $value) : bool
366
    {
367
        if ($this->totalPrefix === null) {
368
            return false;
369
        }
370
        $spec                    = new Glob(rtrim($this->totalPrefix, '/') . '/**/*');
371
        $terminatedValue         = $value;
372
        $terminatedValue['path'] = rtrim($terminatedValue['path'], '/') . '/x/x';
373
        return $spec->isSatisfiedBy($terminatedValue);
374
    }
375
}
376