Completed
Pull Request — master (#15)
by
unknown
06:50
created

Glob::canBeSatisfiedBySomethingBelow()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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