Failed Conditions
Pull Request — 1.0 (#79)
by Bernhard
02:46
created

JsonRepository::getReferencesForGlob()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 12
Ratio 100 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 4
Bugs 0 Features 1
Metric Value
c 4
b 0
f 1
dl 12
loc 12
ccs 7
cts 7
cp 1
rs 9.4286
cc 2
eloc 7
nc 2
nop 2
crap 2
1
<?php
2
3
/*
4
 * This file is part of the puli/repository package.
5
 *
6
 * (c) Bernhard Schussek <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Puli\Repository;
13
14
use BadMethodCallException;
15
use Puli\Repository\Api\EditableRepository;
16
use Puli\Repository\Api\Resource\PuliResource;
17
use Puli\Repository\Api\ResourceNotFoundException;
18
use Puli\Repository\ChangeStream\ResourceStack;
19
use RecursiveIteratorIterator;
20
use Webmozart\Glob\Glob;
21
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
22
use Webmozart\PathUtil\Path;
23
24
/**
25
 * A development path mapping resource repository.
26
 * Each resource is resolved at `get()` time to improve
27
 * developer experience.
28
 *
29
 * Resources can be added with the method {@link add()}:
30
 *
31
 * ```php
32
 * use Puli\Repository\JsonRepository;
33
 *
34
 * $repo = new JsonRepository();
35
 * $repo->add('/css', new DirectoryResource('/path/to/project/res/css'));
36
 * ```
37
 *
38
 * This repository only supports instances of FilesystemResource.
39
 *
40
 * @since  1.0
41
 *
42
 * @author Bernhard Schussek <[email protected]>
43
 * @author Titouan Galopin <[email protected]>
44
 */
45
class JsonRepository extends AbstractJsonRepository implements EditableRepository
46
{
47
    /**
48
     * Flag: Don't search the contents of mapped directories for matching paths.
49
     *
50
     * @internal
51
     */
52
    const NO_SEARCH_FILESYSTEM = 2;
53
54
    /**
55
     * Flag: Don't filter out references that don't exist on the filesystem.
56
     *
57
     * @internal
58
     */
59
    const NO_CHECK_FILE_EXISTS = 4;
60
61
    /**
62
     * Flag: Include the references for mapped ancestor paths /a of a path /a/b
63
     *
64
     * @internal
65
     */
66
    const INCLUDE_ANCESTORS = 8;
67
68
    /**
69
     * Flag: Include the references for mapped nested paths /a/b of a path /a
70
     *
71
     * @internal
72
     */
73
    const INCLUDE_NESTED = 16;
74
75
    /**
76
     * Creates a new repository.
77
     *
78
     * @param string $path          The path to the JSON file. If relative, it
79
     *                              must be relative to the base directory.
80
     * @param string $baseDirectory The base directory of the store. Paths
81
     *                              inside that directory are stored as relative
82
     *                              paths. Paths outside that directory are
83
     *                              stored as absolute paths.
84
     * @param bool   $validateJson  Whether to validate the JSON file against
85
     *                              the schema. Slow but spots problems.
86
     */
87 98
    public function __construct($path, $baseDirectory, $validateJson = false)
88
    {
89
        // Does not accept ChangeStream objects
90
        // The ChangeStream functionality is implemented by the repository itself
91 98
        parent::__construct($path, $baseDirectory, $validateJson);
92 98
    }
93
94
    /**
95
     * {@inheritdoc}
96
     */
97 8
    public function getStack($path)
98
    {
99 8
        if (!$this->json) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->json of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
100 7
            $this->load();
101 7
        }
102
103 8
        $references = $this->searchReferences($path);
104
105 8
        if (!isset($references[$path])) {
106
            throw ResourceNotFoundException::forPath($path);
107
        }
108
109 8
        $resources = array();
110 8
        $pathReferences = $references[$path];
111
112
        // The first reference is the last (current) version
113
        // Hence traverse in reverse order
114 8
        for ($ref = end($pathReferences); null !== key($pathReferences); $ref = prev($pathReferences)) {
115 8
            $resources[] = $this->createResource($path, $ref);
116 8
        }
117
118 8
        return new ResourceStack($resources);
119
    }
120
121
    /**
122
     * {@inheritdoc}
123
     */
124 80
    protected function appendToChangeStream(PuliResource $resource)
125
    {
126 80
        $path = $resource->getPath();
127
128
        // Newly inserted parent directories and the resource need to be
129
        // sorted before we can correctly search references below
130 80
        krsort($this->json);
131
132
        // If a mapping exists for a sub-path of this resource
133
        // (e.g. $path = /a, mapped sub-path = /a/b)
134
        // we need to record the order, since by default sub-paths are
135
        // preferred over super paths
136
137 80
        $references = $this->searchReferences(
138 80
            $path,
139
            // Don't do filesystem checks here. We only check the filesystem
140
            // when reading, not when adding.
141 80
            self::NO_SEARCH_FILESYSTEM | self::NO_CHECK_FILE_EXISTS
142
                // Include references for mapped ancestor and nested paths
143 80
                | self::INCLUDE_ANCESTORS | self::INCLUDE_NESTED
144 80
        );
145
146
        // Filter virtual resources
147 80
        $references = array_filter($references, function ($currentReferences) {
148 80
            return array(null) !== $currentReferences;
149 80
        });
150
151
        // The $references contain:
152
        // - any sub references (e.g. /a/b/c, /a/b/d)
153
        // - the reference itself at $pos (e.g. /a/b)
154
        // - non-null parent references (e.g. /a)
155
        // (in that order, since long paths are sorted before short paths)
156 80
        $pos = array_search($path, array_keys($references), true);
157
158
        // We need to do three things:
159
160
        // 1. If any parent mapping has an order defined, inherit that order
161
162 80
        if ($pos + 1 < count($references)) {
163
            // Inherit the parent order if necessary
164 8
            if (!isset($this->json['_order'][$path])) {
165 8
                $parentReferences = array_slice($references, $pos + 1);
166
167 8
                $this->initWithParentOrder($path, $parentReferences);
168 8
            }
169
170
            // A parent order was inherited. Insert the path itself.
171 8
            if (isset($this->json['_order'][$path])) {
172 1
                $this->prependOrderEntry($path, $path);
173 1
            }
174 8
        }
175
176
        // 2. If there are child mappings, insert the current path into their order
177
178 80
        if ($pos > 0) {
179 6
            $subReferences = array_slice($references, 0, $pos);
180
181 6
            foreach ($subReferences as $subPath => $_) {
182 6
                if (isset($this->json['_order'][$subPath])) {
183 3
                    continue;
184
                }
185
186 6
                if (isset($this->json['_order'][$path])) {
187
                    $this->json['_order'][$subPath] = $this->json['_order'][$path];
188
                } else {
189 6
                    $this->initWithDefaultOrder($subPath, $path, $references);
190
                }
191 6
            }
192
193
            // After initializing all order entries, insert the new one
194 6
            foreach ($subReferences as $subPath => $_) {
195 6
                $this->prependOrderEntry($subPath, $path);
196 6
            }
197 6
        }
198 80
    }
199
200
    /**
201
     * {@inheritdoc}
202
     */
203 80
    protected function insertReference($path, $reference)
204
    {
205 80
        if (!isset($this->json[$path])) {
206
            // Store first entries as simple reference
207 80
            $this->json[$path] = $reference;
208
209 80
            return;
210
        }
211
212 7
        if ($reference === $this->json[$path]) {
213
            // Reference is already set
214 2
            return;
215
        }
216
217 5
        if (!is_array($this->json[$path])) {
218
            // Convert existing entries to arrays for follow ups
219 5
            $this->json[$path] = array($this->json[$path]);
220 5
        }
221
222 5
        if (!in_array($reference, $this->json[$path], true)) {
223
            // Insert at the beginning of the array
224 5
            array_unshift($this->json[$path], $reference);
225 5
        }
226 5
    }
227
228
    /**
229
     * {@inheritdoc}
230
     */
231 7
    protected function removeReferences($glob)
232
    {
233 7
        $checkResults = $this->getReferencesForGlob($glob);
234 7
        $nonDeletablePaths = array();
235
236 7
        foreach ($checkResults as $path => $filesystemPath) {
237 7
            if (!array_key_exists($path, $this->json)) {
238
                $nonDeletablePaths[] = $filesystemPath;
239
            }
240 7
        }
241
242 7
        if (count($nonDeletablePaths) === 1) {
243
            throw new BadMethodCallException(sprintf(
244
                'The remove query "%s" matched a resource that is not a path mapping', $glob
245
            ));
246 7
        } elseif (count($nonDeletablePaths) > 1) {
247
            throw new BadMethodCallException(sprintf(
248
                'The remove query "%s" matched %s resources that are not path mappings', $glob, count($nonDeletablePaths)
249
            ));
250
        }
251
252 7
        $deletedPaths = $this->getReferencesForGlob($glob.'{,/**/*}', self::NO_SEARCH_FILESYSTEM);
253 7
        $removed = 0;
254
255 7
        foreach ($deletedPaths as $path => $filesystemPath) {
256 7
            $removed += 1 + count($this->getReferencesForGlob($path.'/**/*'));
257
258 7
            unset($this->json[$path]);
259 7
        }
260
261 7
        return $removed;
262
    }
263
264
    /**
265
     * {@inheritdoc}
266
     */
267 55
    protected function getReferencesForPath($path)
268
    {
269
        // Stop on first result and flatten
270 55
        return $this->flatten($this->searchReferences($path, true));
0 ignored issues
show
Documentation introduced by
true is of type boolean, but the function expects a integer.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
271
    }
272
273
    /**
274
     * {@inheritdoc}
275
     */
276 26 View Code Duplication
    protected function getReferencesForGlob($glob, $flags = 0)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
277
    {
278 26
        if (!Glob::isDynamic($glob)) {
279 18
            return $this->getReferencesForPath($glob);
280
        }
281
282 15
        return $this->getReferencesForRegex(
283 15
            Glob::getBasePath($glob),
284 15
            Glob::toRegEx($glob),
285
            $flags
286 15
        );
287
    }
288
289
    /**
290
     * {@inheritdoc}
291
     */
292 32
    protected function getReferencesForRegex($staticPrefix, $regex, $flags = 0, $maxDepth = 0)
293
    {
294 32
        return $this->flattenWithFilter(
295
            // Never stop on the first result before applying the filter since
296
            // the filter may reject the only returned path
297 32
            $this->searchReferences($staticPrefix, self::INCLUDE_NESTED),
298 32
            $regex,
299 32
            $flags,
300
            $maxDepth
301 32
        );
302
    }
303
304
    /**
305
     * {@inheritdoc}
306
     */
307 16 View Code Duplication
    protected function getReferencesInDirectory($path, $flags = 0)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
308
    {
309 16
        $basePath = rtrim($path, '/');
310
311 16
        return $this->getReferencesForRegex(
312 16
            $basePath.'/',
313 16
            '~^'.preg_quote($basePath, '~').'/[^/]+$~',
314 16
            $flags,
315
            // Limit the directory exploration to the depth of the path + 1
316 16
            $this->getPathDepth($path) + 1
317 16
        );
318
    }
319
320
    /**
321
     * Flattens a two-level reference array into a one-level array.
322
     *
323
     * For each entry on the first level, only the first entry of the second
324
     * level is included in the result.
325
     *
326
     * Each reference returned by this method can be:
327
     *
328
     *  * `null`
329
     *  * a link starting with `@`
330
     *  * an absolute filesystem path
331
     *
332
     * The keys of the returned array are Puli paths. Their order is undefined.
333
     *
334
     * @param array $references A two-level reference array as returned by
335
     *                          {@link searchReferences()}.
336
     *
337
     * @return string[]|null[] A one-level array of references with Puli paths
338
     *                         as keys.
339
     */
340 55
    private function flatten(array $references)
341
    {
342 55
        $result = array();
343
344 55
        foreach ($references as $currentPath => $currentReferences) {
345 51
            if (!isset($result[$currentPath])) {
346 51
                $result[$currentPath] = reset($currentReferences);
347 51
            }
348 55
        }
349
350 55
        return $result;
351
    }
352
353
    /**
354
     * Flattens a two-level reference array into a one-level array and filters
355
     * out any references that don't match the given regular expression.
356
     *
357
     * This method takes a two-level reference array as returned by
358
     * {@link searchReferences()}. The references are scanned for Puli paths
359
     * matching the given regular expression. Those matches are returned.
360
     *
361
     * If a matching path refers to more than one reference, the first reference
362
     * is returned in the resulting array.
363
     *
364
     * If `$listDirectories` is set to `true`, all references that contain
365
     * directory paths are traversed recursively and scanned for more paths
366
     * matching the regular expression. This recursive traversal can be limited
367
     * by passing a `$maxDepth` (see {@link getPathDepth()}).
368
     *
369
     * Each reference returned by this method can be:
370
     *
371
     *  * `null`
372
     *  * a link starting with `@`
373
     *  * an absolute filesystem path
374
     *
375
     * The keys of the returned array are Puli paths. Their order is undefined.
376
     *
377
     * @param array  $references A two-level reference array as returned by
378
     *                           {@link searchReferences()}.
379
     * @param string $regex      A regular expression used to filter Puli paths.
380
     * @param int    $flags      A bitwise combination of the flag constants in
381
     *                           this class.
382
     * @param int    $maxDepth   The maximum path depth when searching the
383
     *                           contents of directory references. If 0, the
384
     *                           depth is unlimited.
385
     *
386
     * @return string[]|null[] A one-level array of references with Puli paths
387
     *                         as keys.
388
     */
389 32
    private function flattenWithFilter(array $references, $regex, $flags = 0, $maxDepth = 0)
390
    {
391 32
        $result = array();
392
393 32
        foreach ($references as $currentPath => $currentReferences) {
394
            // Check whether the current entry matches the pattern
395 29 View Code Duplication
            if (!isset($result[$currentPath]) && preg_match($regex, $currentPath)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
396
                // If yes, the first stored reference is returned
397 13
                $result[$currentPath] = reset($currentReferences);
398
399 13
                if ($flags & self::STOP_ON_FIRST) {
400 1
                    return $result;
401
                }
402 12
            }
403
404 29
            if ($flags & self::NO_SEARCH_FILESYSTEM) {
405 7
                continue;
406
            }
407
408
            // First follow any links before we check which of them is a directory
409 29
            $currentReferences = $this->followLinks($currentReferences);
410 29
            $currentPath = rtrim($currentPath, '/');
411
412
            // Search the nested entries if desired
413 29
            foreach ($currentReferences as $baseFilesystemPath) {
414
                // Ignore null values and file paths
415 29
                if (!is_dir($baseFilesystemPath)) {
416 16
                    continue;
417
                }
418
419 19
                $iterator = new RecursiveIteratorIterator(
420 19
                    new RecursiveDirectoryIterator(
421 19
                        $baseFilesystemPath,
422
                        RecursiveDirectoryIterator::CURRENT_AS_PATHNAME
423 19
                            | RecursiveDirectoryIterator::SKIP_DOTS
424 19
                    ),
425
                    RecursiveIteratorIterator::SELF_FIRST
426 19
                );
427
428 19
                if (0 !== $maxDepth) {
429 12
                    $currentDepth = $this->getPathDepth($currentPath);
430 12
                    $maxIteratorDepth = $maxDepth - $currentDepth;
431
432 12
                    if ($maxIteratorDepth < 1) {
433 1
                        continue;
434
                    }
435
436 12
                    $iterator->setMaxDepth($maxIteratorDepth);
437 12
                }
438
439 19
                $basePathLength = strlen($baseFilesystemPath);
440
441 19
                foreach ($iterator as $nestedFilesystemPath) {
442 19
                    $nestedPath = substr_replace($nestedFilesystemPath, $currentPath, 0, $basePathLength);
443
444 19 View Code Duplication
                    if (!isset($result[$nestedPath]) && preg_match($regex, $nestedPath)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
445 19
                        $result[$nestedPath] = $nestedFilesystemPath;
446
447 19
                        if ($flags & self::STOP_ON_FIRST) {
448 4
                            return $result;
449
                        }
450 16
                    }
451 18
                }
452 28
            }
453 31
        }
454
455 31
        return $result;
456
    }
457
458
    /**
459
     * Filters the JSON file for all references relevant to a given search path.
460
     *
461
     * The JSON is scanned starting with the longest mapped Puli path.
462
     *
463
     * If the search path is "/a/b", the result includes:
464
     *
465
     *  * The references of the mapped path "/a/b".
466
     *  * The references of any mapped super path "/a" with the sub-path "/b"
467
     *    appended.
468
     *
469
     * If the argument `$includeNested` is set to `true`, the result
470
     * additionally includes:
471
     *
472
     *  * The references of any mapped sub path "/a/b/c".
473
     *
474
     * This is useful if you want to look for the children of "/a/b" or scan
475
     * all descendants for paths matching a given pattern.
476
     *
477
     * The result of this method is an array with two levels:
478
     *
479
     *  * The first level has Puli paths as keys.
480
     *  * The second level contains all references for that path, where the
481
     *    first reference has the highest, the last reference the lowest
482
     *    priority. The keys of the second level are integers. There may be
483
     *    holes between any two keys.
484
     *
485
     * The references of the second level contain:
486
     *
487
     *  * `null` values for virtual resources
488
     *  * strings starting with "@" for links
489
     *  * absolute filesystem paths for filesystem resources
490
     *
491
     * @param string $searchPath The path to search.
492
     * @param int    $flags      A bitwise combination of the flag constants in
493
     *                           this class.
494
     *
495
     * @return array An array with two levels.
496
     */
497 86
    private function searchReferences($searchPath, $flags = 0)
498
    {
499 86
        $result = array();
500 86
        $foundMatchingMappings = false;
501 86
        $searchPath = rtrim($searchPath, '/');
502 86
        $searchPathForTest = $searchPath.'/';
503
504 86
        foreach ($this->json as $currentPath => $currentReferences) {
505 86
            $currentPathForTest = rtrim($currentPath, '/').'/';
506
507
            // We found a mapping that matches the search path
508
            // e.g. mapping /a/b for path /a/b
509 86 View Code Duplication
            if ($searchPathForTest === $currentPathForTest) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
510 83
                $foundMatchingMappings = true;
511 83
                $result[$currentPath] = $this->resolveReferences($currentReferences, $flags);
512
513
                // Return unless an explicit mapping order is defined
514
                // In that case, the ancestors need to be searched as well
515 83
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
516 29
                    return $result;
517
                }
518
519 82
                continue;
520
            }
521
522
            // We found a mapping that lies within the search path
523
            // e.g. mapping /a/b/c for path /a/b
524 66
            if (($flags & self::INCLUDE_NESTED) && 0 === strpos($currentPathForTest, $searchPathForTest)) {
525 20
                $foundMatchingMappings = true;
526 20
                $result[$currentPath] = $this->resolveReferences($currentReferences, $flags);
527
528
                // Return unless an explicit mapping order is defined
529
                // In that case, the ancestors need to be searched as well
530 20
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
531
                    return $result;
532
                }
533
534 20
                continue;
535
            }
536
537
            // We found a mapping that is an ancestor of the search path
538
            // e.g. mapping /a for path /a/b
539 66
            if (0 === strpos($searchPathForTest, $currentPathForTest)) {
540 65
                $foundMatchingMappings = true;
541
542 65 View Code Duplication
                if ($flags & self::INCLUDE_ANCESTORS) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
543
                    // Include the references of the ancestor
544 32
                    $result[$currentPath] = $this->resolveReferences($currentReferences, $flags);
545
546
                    // Return unless an explicit mapping order is defined
547
                    // In that case, the ancestors need to be searched as well
548 32
                    if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
549
                        return $result;
550
                    }
551
552 32
                    continue;
553
                }
554
555 53
                if ($flags & self::NO_SEARCH_FILESYSTEM) {
556
                    continue;
557
                }
558
559
                // Check the filesystem directories pointed to by the ancestors
560
                // for the searched path
561 53
                $nestedPath = substr($searchPath, strlen($currentPathForTest));
562 53
                $currentPathWithNested = rtrim($currentPath, '/').'/'.$nestedPath;
563
564
                // Follow links so that we can check the nested directories in
565
                // the final transitive link targets
566 53
                $currentReferencesResolved = $this->followLinks(
567
                    // Never stop on first, since appendNestedPath() might
568
                    // discard the first but accept the second entry
569 53
                    $this->resolveReferences($currentReferences, $flags & (~self::STOP_ON_FIRST)),
570
                    // Never stop on first (see above)
571
                    false
0 ignored issues
show
Documentation introduced by
false is of type boolean, but the function expects a integer.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
572 53
                );
573
574
                // Append the path and check which of the resulting paths exist
575 53
                $nestedReferences = $this->appendPathAndFilterExisting(
576 53
                    $currentReferencesResolved,
577 53
                    $nestedPath,
578
                    $flags
579 53
                );
580
581
                // None of the results exists
582 53
                if (empty($nestedReferences)) {
583 29
                    continue;
584
                }
585
586
                // Return unless an explicit mapping order is defined
587
                // In that case, the ancestors need to be searched as well
588 38
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPathWithNested])) {
589
                    // The nested references already have size 1
590 28
                    return array($currentPathWithNested => $nestedReferences);
591
                }
592
593
                // We are traversing long keys before short keys
594
                // It could be that this entry already exists.
595 21
                if (!isset($result[$currentPathWithNested])) {
596 15
                    $result[$currentPathWithNested] = $nestedReferences;
597
598 15
                    continue;
599
                }
600
601
                // If no explicit mapping order is defined, simply append the
602
                // new references to the existing ones
603 6
                if (!isset($this->json['_order'][$currentPathWithNested])) {
604 1
                    $result[$currentPathWithNested] = array_merge(
605 1
                        $result[$currentPathWithNested],
606
                        $nestedReferences
607 1
                    );
608
609 1
                    continue;
610
                }
611
612
                // If an explicit mapping order is defined, store the paths
613
                // of the mappings that generated each reference set and
614
                // resolve the order later on
615 5
                if (!isset($result[$currentPathWithNested][$currentPathWithNested])) {
616 5
                    $result[$currentPathWithNested] = array(
617 5
                        $currentPathWithNested => $result[$currentPathWithNested],
618
                    );
619 5
                }
620
621
                // Add the new references generated by the current mapping
622 5
                $result[$currentPathWithNested][$currentPath] = $nestedReferences;
623
624 5
                continue;
625
            }
626
627
            // We did not find anything but previously found mappings
628
            // The mappings are sorted alphabetically, so we can safely abort
629 21
            if ($foundMatchingMappings) {
630 11
                break;
631
            }
632 86
        }
633
634
        // Resolve the order where it is explicitly set
635 85
        if (!isset($this->json['_order'])) {
636 85
            return $result;
637
        }
638
639 6
        foreach ($result as $currentPath => $referencesByMappedPath) {
640
            // If no order is defined for the path or if only one mapped path
641
            // generated references, there's nothing to do
642 5
            if (!isset($this->json['_order'][$currentPath]) || !isset($referencesByMappedPath[$currentPath])) {
643 3
                continue;
644
            }
645
646 5
            $orderedReferences = array();
647
648 5
            foreach ($this->json['_order'][$currentPath] as $orderEntry) {
649 5
                if (!isset($referencesByMappedPath[$orderEntry['path']])) {
650
                    continue;
651
                }
652
653 5
                for ($i = 0; $i < $orderEntry['references'] && count($referencesByMappedPath[$orderEntry['path']]) > 0; ++$i) {
654 5
                    $orderedReferences[] = array_shift($referencesByMappedPath[$orderEntry['path']]);
655 5
                }
656
657
                // Only include references of the first mapped path
658
                // Since $stopOnFirst is set, those references have a
659
                // maximum size of 1
660 5
                if ($flags & self::STOP_ON_FIRST) {
661
                    break;
662
                }
663 5
            }
664
665 5
            $result[$currentPath] = $orderedReferences;
666 6
        }
667
668 6
        return $result;
669
    }
670
671
    /**
672
     * Follows any link in a list of references.
673
     *
674
     * This method takes all the given references, checks for links starting
675
     * with "@" and recursively expands those links to their target references.
676
     * The target references may be `null` or absolute filesystem paths.
677
     *
678
     * Null values are returned unchanged.
679
     *
680
     * Absolute filesystem paths are returned unchanged.
681
     *
682
     * @param string[]|null[] $references The references.
683
     * @param int             $flags      A bitwise combination of the flag
684
     *                                    constants in this class.
685
     *
686
     * @return string[]|null[] The references with all links replaced by their
687
     *                         target references. If any link pointed to more
688
     *                         than one target reference, the returned array
689
     *                         is larger than the passed array (unless the
690
     *                         argument `$stopOnFirst` was set to `true`).
691
     */
692 56
    private function followLinks(array $references, $flags = 0)
693
    {
694 56
        $result = array();
695
696 56
        foreach ($references as $key => $reference) {
697
            // Not a link
698 56
            if (!$this->isLinkReference($reference)) {
699 56
                $result[] = $reference;
700
701 56
                if ($flags & self::STOP_ON_FIRST) {
702
                    return $result;
703
                }
704
705 56
                continue;
706
            }
707
708
            $referencedPath = substr($reference, 1);
709
710
            // Get all the file system paths that this link points to
711
            // and append them to the result
712
            foreach ($this->searchReferences($referencedPath, $flags) as $referencedReferences) {
713
                // Follow links recursively
714
                $referencedReferences = $this->followLinks($referencedReferences);
715
716
                // Append all resulting target paths to the result
717
                foreach ($referencedReferences as $referencedReference) {
718
                    $result[] = $referencedReference;
719
720
                    if ($flags & self::STOP_ON_FIRST) {
721
                        return $result;
722
                    }
723
                }
724
            }
725 56
        }
726
727 56
        return $result;
728
    }
729
730
    /**
731
     * Appends nested paths to references and filters out the existing ones.
732
     *
733
     * This method takes all the given references, appends the nested path to
734
     * each of them and then filters out the results that actually exist on the
735
     * filesystem.
736
     *
737
     * Null references are filtered out.
738
     *
739
     * Link references should be followed with {@link followLinks()} before
740
     * calling this method.
741
     *
742
     * @param string[]|null[] $references  The references.
743
     * @param string          $nestedPath  The nested path to append without
744
     *                                     leading slash ("/").
745
     * @param int             $flags       A bitwise combination of the flag
746
     *                                     constants in this class.
747
     *
748
     * @return string[] The references with the nested path appended. Each
749
     *                  reference is guaranteed to exist on the filesystem.
750
     */
751 53
    private function appendPathAndFilterExisting(array $references, $nestedPath, $flags = 0)
752
    {
753 53
        $result = array();
754
755 53
        foreach ($references as $reference) {
756
            // Filter out null values
757
            // Links should be followed before calling this method
758 53
            if (null === $reference) {
759 22
                continue;
760
            }
761
762 44
            $nestedReference = rtrim($reference, '/').'/'.$nestedPath;
763
764 44
            if (file_exists($nestedReference)) {
765 38
                $result[] = $nestedReference;
766
767 38
                if ($flags & self::STOP_ON_FIRST) {
768 28
                    return $result;
769
                }
770 21
            }
771 39
        }
772
773 39
        return $result;
774
    }
775
776
    /**
777
     * Resolves a list of references stored in the JSON.
778
     *
779
     * Each reference passed in can be:
780
     *
781
     *  * `null`
782
     *  * a link starting with `@`
783
     *  * a filesystem path relative to the base directory
784
     *  * an absolute filesystem path
785
     *
786
     * Each reference returned by this method can be:
787
     *
788
     *  * `null`
789
     *  * a link starting with `@`
790
     *  * an absolute filesystem path
791
     *
792
     * Additionally, the results are guaranteed to be an array. If the
793
     * argument `$stopOnFirst` is set, that array has a maximum size of 1.
794
     *
795
     * @param mixed $references The reference(s).
796
     * @param int   $flags      A bitwise combination of the flag constants in
797
     *                          this class.
798
     *
799
     * @return string[]|null[] The resolved references.
800
     */
801 86
    private function resolveReferences($references, $flags = 0)
802
    {
803 86
        if (!is_array($references)) {
804 86
            $references = array($references);
805 86
        }
806
807 86
        foreach ($references as $key => $reference) {
808 86
            if ($this->isFilesystemReference($reference)) {
809 85
                $reference = Path::makeAbsolute($reference, $this->baseDirectory);
810
811
                // Ignore non-existing files. Not sure this is the right
812
                // thing to do.
813 85
                if (($flags & self::NO_CHECK_FILE_EXISTS) || file_exists($reference)) {
814 85
                    $references[$key] = $reference;
815 85
                }
816 85
            }
817
818 86
            if ($flags & self::STOP_ON_FIRST) {
819 29
                return $references;
820
            }
821 85
        }
822
823 85
        return $references;
824
    }
825
826
    /**
827
     * Returns the depth of a Puli path.
828
     *
829
     * The depth is used in order to limit the recursion when recursively
830
     * iterating directories.
831
     *
832
     * The depth starts at 0 for the root:
833
     *
834
     * /                0
835
     * /webmozart       1
836
     * /webmozart/puli  2
837
     * ...
838
     *
839
     * @param string $path A Puli path.
840
     *
841
     * @return int The depth starting with 0 for the root node.
842
     */
843 16
    private function getPathDepth($path)
844
    {
845
        // / has depth 0
846
        // /webmozart has depth 1
847
        // /webmozart/puli has depth 2
848
        // ...
849 16
        return substr_count(rtrim($path, '/'), '/');
850
    }
851
852
    /**
853
     * Inserts a path at the beginning of the order list of a mapped path.
854
     *
855
     * @param string $path          The path of the mapping where to prepend.
856
     * @param string $prependedPath The path of the mapping to prepend.
857
     */
858 6
    private function prependOrderEntry($path, $prependedPath)
859
    {
860 6
        $lastEntry = reset($this->json['_order'][$path]);
861
862 6
        if ($prependedPath === $lastEntry['path']) {
863
            // If the first entry matches the new one, add the reference
864
            // of the current resource to the limit
865 1
            ++$lastEntry['references'];
866 1
        } else {
867 6
            array_unshift($this->json['_order'][$path], array(
868 6
                'path' => $prependedPath,
869 6
                'references' => 1,
870 6
            ));
871
        }
872 6
    }
873
874
    /**
875
     * Initializes a path with the order of the closest parent path.
876
     *
877
     * @param string $path             The path to initialize.
878
     * @param array  $parentReferences The defined references for parent paths,
879
     *                                 with long paths /a/b sorted before short
880
     *                                 paths /a.
881
     */
882 8
    private function initWithParentOrder($path, array $parentReferences)
883
    {
884 8
        foreach ($parentReferences as $parentPath => $_) {
885
            // Look for the first parent entry for which an order is defined
886 8
            if (isset($this->json['_order'][$parentPath])) {
887
                // Inherit that order
888 1
                $this->json['_order'][$path] = $this->json['_order'][$parentPath];
889
890 1
                return;
891
            }
892 7
        }
893 7
    }
894
895
    /**
896
     * Initializes the order of a path with the default order.
897
     *
898
     * This is necessary if we want to insert a non-default order entry for
899
     * the first time.
900
     *
901
     * @param string $path         The path to initialize.
902
     * @param string $insertedPath The path that is being inserted.
903
     * @param array  $references   The references for each defined path mapping
904
     *                             in the path chain.
905
     */
906 6
    private function initWithDefaultOrder($path, $insertedPath, $references)
907
    {
908 6
        $this->json['_order'][$path] = array();
909
910
        // Insert the default order, if none exists
911
        // i.e. long paths /a/b/c before short paths /a/b
912 6
        $parentPath = $path;
913
914 6
        while (true) {
915 6
            if (isset($references[$parentPath])) {
916
                $parentEntry = array(
917 6
                    'path' => $parentPath,
918 6
                    'references' => count($references[$parentPath]),
919 6
                );
920
921
                // Edge case: $parentPath equals $insertedPath. In this case we have
922
                // to subtract the entry that we're adding
923 6
                if ($parentPath === $insertedPath) {
924 6
                    --$parentEntry['references'];
925 6
                }
926
927 6
                if (0 !== $parentEntry['references']) {
928 6
                    $this->json['_order'][$path][] = $parentEntry;
929 6
                }
930 6
            }
931
932 6
            if ('/' === $parentPath) {
933 6
                break;
934
            }
935
936 6
            $parentPath = Path::getDirectory($parentPath);
937 6
        };
938 6
    }
939
}
940