Failed Conditions
Push — 1.0 ( 9f5a0b...fe7a2f )
by Bernhard
30:36 queued 17:00
created

JsonRepository::searchReferences()   D

Complexity

Conditions 33
Paths 40

Size

Total Lines 189
Code Lines 79

Duplication

Lines 36
Ratio 19.05 %

Code Coverage

Tests 70
CRAP Score 33.3232

Importance

Changes 6
Bugs 1 Features 2
Metric Value
c 6
b 1
f 2
dl 36
loc 189
ccs 70
cts 75
cp 0.9333
rs 4.3466
cc 33
eloc 79
nc 40
nop 2
crap 33.3232

How to fix   Long Method    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
/*
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 InvalidArgumentException;
15
use Puli\Repository\Api\ChangeStream\VersionList;
16
use Puli\Repository\Api\EditableRepository;
17
use Puli\Repository\Api\NoVersionFoundException;
18
use Puli\Repository\Api\Resource\PuliResource;
19
use RecursiveIteratorIterator;
20
use Webmozart\Glob\Glob;
21
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
22
use Webmozart\PathUtil\Path;
23
24
/**
25
 * A repository backed by a JSON file optimized for development.
26
 *
27
 * The generated JSON file is described by res/schema/repository-schema-1.0.json.
28
 *
29
 * Resources can be added with the method {@link add()}:
30
 *
31
 * ```php
32
 * use Puli\Repository\JsonRepository;
33
 *
34
 * $repo = new JsonRepository('/path/to/repository.json', '/path/to/project');
35
 * $repo->add('/css', new DirectoryResource('/path/to/project/res/css'));
36
 * ```
37
 *
38
 * When adding a resource, the added filesystem path is stored in the JSON file
39
 * under the key of the Puli path. The path is stored relatively to the base
40
 * directory passed to the constructor:
41
 *
42
 * ```json
43
 * {
44
 *     "/css": "res/css"
45
 * }
46
 * ```
47
 *
48
 * Mapped resources can be read with the method {@link get()}:
49
 *
50
 * ```php
51
 * $cssPath = $repo->get('/css')->getFilesystemPath();
52
 * ```
53
 *
54
 * You can also access nested files:
55
 *
56
 * ```php
57
 * echo $repo->get('/css/style.css')->getBody();
58
 * ```
59
 *
60
 * Nested files are searched during {@link get()}. As a consequence, this
61
 * implementation should not be used in production environments. Use
62
 * {@link OptimizedJsonRepository} instead which searches nested files during
63
 * {@link add()} and has much faster read performance.
64
 *
65
 * @since  1.0
66
 *
67
 * @author Bernhard Schussek <[email protected]>
68
 * @author Titouan Galopin <[email protected]>
69
 */
70
class JsonRepository extends AbstractJsonRepository implements EditableRepository
71
{
72
    /**
73
     * Flag: Don't search the contents of mapped directories for matching paths.
74
     *
75
     * @internal
76
     */
77
    const NO_SEARCH_FILESYSTEM = 2;
78
79
    /**
80
     * Flag: Don't filter out references that don't exist on the filesystem.
81
     *
82
     * @internal
83
     */
84
    const NO_CHECK_FILE_EXISTS = 4;
85
86
    /**
87
     * Flag: Include the references for mapped ancestor paths /a of a path /a/b.
88
     *
89
     * @internal
90
     */
91
    const INCLUDE_ANCESTORS = 8;
92
93
    /**
94
     * Flag: Include the references for mapped nested paths /a/b of a path /a.
95
     *
96
     * @internal
97
     */
98
    const INCLUDE_NESTED = 16;
99
100
    /**
101
     * Creates a new repository.
102
     *
103
     * @param string $path          The path to the JSON file. If relative, it
104
     *                              must be relative to the base directory.
105
     * @param string $baseDirectory The base directory of the store. Paths
106
     *                              inside that directory are stored as relative
107
     *                              paths. Paths outside that directory are
108
     *                              stored as absolute paths.
109
     * @param bool   $validateJson  Whether to validate the JSON file against
110
     *                              the schema. Slow but spots problems.
111
     */
112 198
    public function __construct($path, $baseDirectory, $validateJson = false)
113
    {
114
        // Does not accept ChangeStream objects
115
        // The ChangeStream functionality is implemented by the repository itself
116 198
        parent::__construct($path, $baseDirectory, $validateJson);
117 198
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122 32
    public function getVersions($path)
123
    {
124 32
        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...
125 13
            $this->load();
126
        }
127
128 32
        $references = $this->searchReferences($path);
129
130 32
        if (!isset($references[$path])) {
131 10
            throw NoVersionFoundException::forPath($path);
132
        }
133
134 22
        $resources = array();
135 22
        $pathReferences = $references[$path];
136
137
        // The first reference is the last (current) version
138
        // Hence traverse in reverse order
139 22
        for ($ref = end($pathReferences); null !== key($pathReferences); $ref = prev($pathReferences)) {
140 22
            $resources[] = $this->createResource($path, $ref);
141
        }
142
143 22
        return new VersionList($path, $resources);
144
    }
145
146
    /**
147
     * {@inheritdoc}
148
     */
149 170
    protected function storeVersion(PuliResource $resource)
150
    {
151 170
        $path = $resource->getPath();
152
153
        // Newly inserted parent directories and the resource need to be
154
        // sorted before we can correctly search references below
155 170
        krsort($this->json);
156
157
        // If a mapping exists for a sub-path of this resource
158
        // (e.g. $path = /a, mapped sub-path = /a/b)
159
        // we need to record the order, since by default sub-paths are
160
        // preferred over super paths
161
162 170
        $references = $this->searchReferences(
163
            $path,
164
            // Don't do filesystem checks here. We only check the filesystem
165
            // when reading, not when adding.
166 170
            self::NO_SEARCH_FILESYSTEM | self::NO_CHECK_FILE_EXISTS
167
                // Include references for mapped ancestor and nested paths
168 170
                | self::INCLUDE_ANCESTORS | self::INCLUDE_NESTED
169
        );
170
171
        // Filter virtual resources
172 170
        $references = array_filter($references, function ($currentReferences) {
173 170
            return array(null) !== $currentReferences;
174 170
        });
175
176
        // The $references contain:
177
        // - any sub references (e.g. /a/b/c, /a/b/d)
178
        // - the reference itself at $pos (e.g. /a/b)
179
        // - non-null parent references (e.g. /a)
180
        // (in that order, since long paths are sorted before short paths)
181 170
        $pos = array_search($path, array_keys($references), true);
182
183
        // We need to do three things:
184
185
        // 1. If any parent mapping has an order defined, inherit that order
186
187 170
        if ($pos + 1 < count($references)) {
188
            // Inherit the parent order if necessary
189 14
            if (!isset($this->json['_order'][$path])) {
190 14
                $parentReferences = array_slice($references, $pos + 1);
191
192 14
                $this->initWithParentOrder($path, $parentReferences);
193
            }
194
195
            // A parent order was inherited. Insert the path itself.
196 14
            if (isset($this->json['_order'][$path])) {
197 2
                $this->prependOrderEntry($path, $path);
198
            }
199
        }
200
201
        // 2. If there are child mappings, insert the current path into their order
202
203 170
        if ($pos > 0) {
204 10
            $subReferences = array_slice($references, 0, $pos);
205
206 10
            foreach ($subReferences as $subPath => $_) {
207 10
                if (isset($this->json['_order'][$subPath])) {
208 6
                    continue;
209
                }
210
211 10
                if (isset($this->json['_order'][$path])) {
212
                    $this->json['_order'][$subPath] = $this->json['_order'][$path];
213
                } else {
214 10
                    $this->initWithDefaultOrder($subPath, $path, $references);
215
                }
216
            }
217
218
            // After initializing all order entries, insert the new one
219 10
            foreach ($subReferences as $subPath => $_) {
220 10
                $this->prependOrderEntry($subPath, $path);
221
            }
222
        }
223 170
    }
224
225
    /**
226
     * {@inheritdoc}
227
     */
228 170
    protected function insertReference($path, $reference)
229
    {
230 170
        if (!isset($this->json[$path])) {
231
            // Store first entries as simple reference
232 170
            $this->json[$path] = $reference;
233
234 170
            return;
235
        }
236
237 14
        if ($reference === $this->json[$path]) {
238
            // Reference is already set
239 4
            return;
240
        }
241
242 10
        if (!is_array($this->json[$path])) {
243
            // Convert existing entries to arrays for follow ups
244 10
            $this->json[$path] = array($this->json[$path]);
245
        }
246
247 10
        if (!in_array($reference, $this->json[$path], true)) {
248
            // Insert at the beginning of the array
249 10
            array_unshift($this->json[$path], $reference);
250
        }
251 10
    }
252
253
    /**
254
     * {@inheritdoc}
255
     */
256 22
    protected function removeReferences($glob)
257
    {
258 22
        $checkResults = $this->getReferencesForGlob($glob);
259 22
        $nonDeletablePaths = array();
260
261 22
        foreach ($checkResults as $path => $filesystemPath) {
262 22
            if (!array_key_exists($path, $this->json)) {
263 22
                $nonDeletablePaths[] = $filesystemPath;
264
            }
265
        }
266
267 22
        if (count($nonDeletablePaths) > 0) {
268 2
            throw new InvalidArgumentException(sprintf(
269
                'You cannot remove resources that are not mapped in the JSON '.
270 2
                'file. Tried to remove %s%s.',
271
                reset($nonDeletablePaths),
272 2
                count($nonDeletablePaths) > 1
273
                    ? ' and '.(count($nonDeletablePaths) - 1).' more'
274 2
                    : ''
275
            ));
276
        }
277
278 20
        $deletedPaths = $this->getReferencesForGlob($glob.'{,/**/*}', self::NO_SEARCH_FILESYSTEM);
279 20
        $removed = 0;
280
281 20
        foreach ($deletedPaths as $path => $filesystemPath) {
282 20
            $removed += 1 + count($this->getReferencesForGlob($path.'/**/*'));
283
284 20
            unset($this->json[$path]);
285
        }
286
287 20
        return $removed;
288
    }
289
290
    /**
291
     * {@inheritdoc}
292
     */
293 110
    protected function getReferencesForPath($path)
294
    {
295
        // Stop on first result and flatten
296 110
        return $this->flatten($this->searchReferences($path, self::STOP_ON_FIRST));
297
    }
298
299
    /**
300
     * {@inheritdoc}
301
     */
302 50 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...
303
    {
304 50
        if (!Glob::isDynamic($glob)) {
305 34
            return $this->getReferencesForPath($glob);
306
        }
307
308 36
        return $this->getReferencesForRegex(
309 36
            Glob::getBasePath($glob),
310 36
            Glob::toRegEx($glob),
311
            $flags
312
        );
313
    }
314
315
    /**
316
     * {@inheritdoc}
317
     */
318 68
    protected function getReferencesForRegex($staticPrefix, $regex, $flags = 0, $maxDepth = 0)
319
    {
320 68
        return $this->flattenWithFilter(
321
            // Never stop on the first result before applying the filter since
322
            // the filter may reject the only returned path
323 68
            $this->searchReferences($staticPrefix, self::INCLUDE_NESTED),
324
            $regex,
325
            $flags,
326
            $maxDepth
327
        );
328
    }
329
330
    /**
331
     * {@inheritdoc}
332
     */
333 26 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...
334
    {
335 26
        $basePath = rtrim($path, '/');
336
337 26
        return $this->getReferencesForRegex(
338 26
            $basePath.'/',
339 26
            '~^'.preg_quote($basePath, '~').'/[^/]+$~',
340
            $flags,
341
            // Limit the directory exploration to the depth of the path + 1
342 26
            $this->getPathDepth($path) + 1
343
        );
344
    }
345
346
    /**
347
     * Flattens a two-level reference array into a one-level array.
348
     *
349
     * For each entry on the first level, only the first entry of the second
350
     * level is included in the result.
351
     *
352
     * Each reference returned by this method can be:
353
     *
354
     *  * `null`
355
     *  * a link starting with `@`
356
     *  * an absolute filesystem path
357
     *
358
     * The keys of the returned array are Puli paths. Their order is undefined.
359
     *
360
     * @param array $references A two-level reference array as returned by
361
     *                          {@link searchReferences()}.
362
     *
363
     * @return string[]|null[] A one-level array of references with Puli paths
364
     *                         as keys.
365
     */
366 110
    private function flatten(array $references)
367
    {
368 110
        $result = array();
369
370 110
        foreach ($references as $currentPath => $currentReferences) {
371 102
            if (!isset($result[$currentPath])) {
372 102
                $result[$currentPath] = reset($currentReferences);
373
            }
374
        }
375
376 110
        return $result;
377
    }
378
379
    /**
380
     * Flattens a two-level reference array into a one-level array and filters
381
     * out any references that don't match the given regular expression.
382
     *
383
     * This method takes a two-level reference array as returned by
384
     * {@link searchReferences()}. The references are scanned for Puli paths
385
     * matching the given regular expression. Those matches are returned.
386
     *
387
     * If a matching path refers to more than one reference, the first reference
388
     * is returned in the resulting array.
389
     *
390
     * If `$listDirectories` is set to `true`, all references that contain
391
     * directory paths are traversed recursively and scanned for more paths
392
     * matching the regular expression. This recursive traversal can be limited
393
     * by passing a `$maxDepth` (see {@link getPathDepth()}).
394
     *
395
     * Each reference returned by this method can be:
396
     *
397
     *  * `null`
398
     *  * a link starting with `@`
399
     *  * an absolute filesystem path
400
     *
401
     * The keys of the returned array are Puli paths. Their order is undefined.
402
     *
403
     * @param array  $references A two-level reference array as returned by
404
     *                           {@link searchReferences()}.
405
     * @param string $regex      A regular expression used to filter Puli paths.
406
     * @param int    $flags      A bitwise combination of the flag constants in
407
     *                           this class.
408
     * @param int    $maxDepth   The maximum path depth when searching the
409
     *                           contents of directory references. If 0, the
410
     *                           depth is unlimited.
411
     *
412
     * @return string[]|null[] A one-level array of references with Puli paths
413
     *                         as keys.
414
     */
415 68
    private function flattenWithFilter(array $references, $regex, $flags = 0, $maxDepth = 0)
416
    {
417 68
        $result = array();
418
419 68
        foreach ($references as $currentPath => $currentReferences) {
420
            // Check whether the current entry matches the pattern
421 62 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...
422
                // If yes, the first stored reference is returned
423 28
                $result[$currentPath] = reset($currentReferences);
424
425 28
                if ($flags & self::STOP_ON_FIRST) {
426 2
                    return $result;
427
                }
428
            }
429
430 62
            if ($flags & self::NO_SEARCH_FILESYSTEM) {
431 20
                continue;
432
            }
433
434
            // First follow any links before we check which of them is a directory
435 62
            $currentReferences = $this->followLinks($currentReferences);
436 62
            $currentPath = rtrim($currentPath, '/');
437
438
            // Search the nested entries if desired
439 62
            foreach ($currentReferences as $baseFilesystemPath) {
440
                // Ignore null values and file paths
441 62
                if (!is_dir($baseFilesystemPath)) {
442 40
                    continue;
443
                }
444
445 26
                $iterator = new RecursiveIteratorIterator(
446 26
                    new RecursiveDirectoryIterator(
447
                        $baseFilesystemPath,
448 26
                        RecursiveDirectoryIterator::CURRENT_AS_PATHNAME
449 26
                            | RecursiveDirectoryIterator::SKIP_DOTS
450
                    ),
451 26
                    RecursiveIteratorIterator::SELF_FIRST
452
                );
453
454 26
                if (0 !== $maxDepth) {
455 14
                    $currentDepth = $this->getPathDepth($currentPath);
456 14
                    $maxIteratorDepth = $maxDepth - $currentDepth;
457
458 14
                    if ($maxIteratorDepth < 1) {
459
                        continue;
460
                    }
461
462 14
                    $iterator->setMaxDepth($maxIteratorDepth);
463
                }
464
465 26
                $basePathLength = strlen($baseFilesystemPath);
466
467 26
                foreach ($iterator as $nestedFilesystemPath) {
468 24
                    $nestedPath = substr_replace($nestedFilesystemPath, $currentPath, 0, $basePathLength);
469
470 24 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...
471 24
                        $result[$nestedPath] = $nestedFilesystemPath;
472
473 24
                        if ($flags & self::STOP_ON_FIRST) {
474 62
                            return $result;
475
                        }
476
                    }
477
                }
478
            }
479
        }
480
481 66
        return $result;
482
    }
483
484
    /**
485
     * Filters the JSON file for all references relevant to a given search path.
486
     *
487
     * The JSON is scanned starting with the longest mapped Puli path.
488
     *
489
     * If the search path is "/a/b", the result includes:
490
     *
491
     *  * The references of the mapped path "/a/b".
492
     *  * The references of any mapped super path "/a" with the sub-path "/b"
493
     *    appended.
494
     *
495
     * If the argument `$includeNested` is set to `true`, the result
496
     * additionally includes:
497
     *
498
     *  * The references of any mapped sub path "/a/b/c".
499
     *
500
     * This is useful if you want to look for the children of "/a/b" or scan
501
     * all descendants for paths matching a given pattern.
502
     *
503
     * The result of this method is an array with two levels:
504
     *
505
     *  * The first level has Puli paths as keys.
506
     *  * The second level contains all references for that path, where the
507
     *    first reference has the highest, the last reference the lowest
508
     *    priority. The keys of the second level are integers. There may be
509
     *    holes between any two keys.
510
     *
511
     * The references of the second level contain:
512
     *
513
     *  * `null` values for virtual resources
514
     *  * strings starting with "@" for links
515
     *  * absolute filesystem paths for filesystem resources
516
     *
517
     * @param string $searchPath The path to search.
518
     * @param int    $flags      A bitwise combination of the flag constants in
519
     *                           this class.
520
     *
521
     * @return array An array with two levels.
522
     */
523 174
    private function searchReferences($searchPath, $flags = 0)
524
    {
525 174
        $result = array();
526 174
        $foundMatchingMappings = false;
527 174
        $searchPath = rtrim($searchPath, '/');
528 174
        $searchPathForTest = $searchPath.'/';
529
530 174
        foreach ($this->json as $currentPath => $currentReferences) {
531 174
            $currentPathForTest = rtrim($currentPath, '/').'/';
532
533
            // We found a mapping that matches the search path
534
            // e.g. mapping /a/b for path /a/b
535 174 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...
536 174
                $foundMatchingMappings = true;
537 174
                $currentReferences = $this->resolveReferences($currentPath, $currentReferences, $flags);
538
539 174
                if (empty($currentReferences)) {
540 4
                    continue;
541
                }
542
543 174
                $result[$currentPath] = $currentReferences;
544
545
                // Return unless an explicit mapping order is defined
546
                // In that case, the ancestors need to be searched as well
547 174
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
548 58
                    return $result;
549
                }
550
551 174
                continue;
552
            }
553
554
            // We found a mapping that lies within the search path
555
            // e.g. mapping /a/b/c for path /a/b
556 130
            if (($flags & self::INCLUDE_NESTED) && 0 === strpos($currentPathForTest, $searchPathForTest)) {
557 42
                $foundMatchingMappings = true;
558 42
                $currentReferences = $this->resolveReferences($currentPath, $currentReferences, $flags);
559
560 42
                if (empty($currentReferences)) {
561 8
                    continue;
562
                }
563
564 34
                $result[$currentPath] = $currentReferences;
565
566
                // Return unless an explicit mapping order is defined
567
                // In that case, the ancestors need to be searched as well
568 34
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
569
                    return $result;
570
                }
571
572 34
                continue;
573
            }
574
575
            // We found a mapping that is an ancestor of the search path
576
            // e.g. mapping /a for path /a/b
577 130
            if (0 === strpos($searchPathForTest, $currentPathForTest)) {
578 130
                $foundMatchingMappings = true;
579
580 130 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...
581
                    // Include the references of the ancestor
582 72
                    $currentReferences = $this->resolveReferences($currentPath, $currentReferences, $flags);
583
584 72
                    if (empty($currentReferences)) {
585
                        continue;
586
                    }
587
588 72
                    $result[$currentPath] = $currentReferences;
589
590
                    // Return unless an explicit mapping order is defined
591
                    // In that case, the ancestors need to be searched as well
592 72
                    if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPath])) {
593
                        return $result;
594
                    }
595
596 72
                    continue;
597
                }
598
599 108
                if ($flags & self::NO_SEARCH_FILESYSTEM) {
600
                    continue;
601
                }
602
603
                // Check the filesystem directories pointed to by the ancestors
604
                // for the searched path
605 108
                $nestedPath = substr($searchPath, strlen($currentPathForTest));
606 108
                $currentPathWithNested = rtrim($currentPath, '/').'/'.$nestedPath;
607
608
                // Follow links so that we can check the nested directories in
609
                // the final transitive link targets
610 108
                $currentReferencesResolved = $this->followLinks(
611
                    // Never stop on first, since appendNestedPath() might
612
                    // discard the first but accept the second entry
613 108
                    $this->resolveReferences($currentPath, $currentReferences, $flags & (~self::STOP_ON_FIRST))
614
                );
615
616
                // Append the path and check which of the resulting paths exist
617 108
                $nestedReferences = $this->appendPathAndFilterExisting(
618
                    $currentReferencesResolved,
619
                    $nestedPath,
620
                    $flags
621
                );
622
623
                // None of the results exists
624 108
                if (empty($nestedReferences)) {
625 62
                    continue;
626
                }
627
628
                // Return unless an explicit mapping order is defined
629
                // In that case, the ancestors need to be searched as well
630 64
                if (($flags & self::STOP_ON_FIRST) && !isset($this->json['_order'][$currentPathWithNested])) {
631
                    // The nested references already have size 1
632 50
                    return array($currentPathWithNested => $nestedReferences);
633
                }
634
635
                // We are traversing long keys before short keys
636
                // It could be that this entry already exists.
637 34
                if (!isset($result[$currentPathWithNested])) {
638 22
                    $result[$currentPathWithNested] = $nestedReferences;
639
640 22
                    continue;
641
                }
642
643
                // If no explicit mapping order is defined, simply append the
644
                // new references to the existing ones
645 12
                if (!isset($this->json['_order'][$currentPathWithNested])) {
646 2
                    $result[$currentPathWithNested] = array_merge(
647 2
                        $result[$currentPathWithNested],
648
                        $nestedReferences
649
                    );
650
651 2
                    continue;
652
                }
653
654
                // If an explicit mapping order is defined, store the paths
655
                // of the mappings that generated each reference set and
656
                // resolve the order later on
657 10
                if (!isset($result[$currentPathWithNested][$currentPathWithNested])) {
658 10
                    $result[$currentPathWithNested] = array(
659 10
                        $currentPathWithNested => $result[$currentPathWithNested],
660
                    );
661
                }
662
663
                // Add the new references generated by the current mapping
664 10
                $result[$currentPathWithNested][$currentPath] = $nestedReferences;
665
666 10
                continue;
667
            }
668
669
            // We did not find anything but previously found mappings
670
            // The mappings are sorted alphabetically, so we can safely abort
671 40
            if ($foundMatchingMappings) {
672 40
                break;
673
            }
674
        }
675
676
        // Resolve the order where it is explicitly set
677 174
        if (!isset($this->json['_order'])) {
678 174
            return $result;
679
        }
680
681 10
        foreach ($result as $currentPath => $referencesByMappedPath) {
682
            // If no order is defined for the path or if only one mapped path
683
            // generated references, there's nothing to do
684 10
            if (!isset($this->json['_order'][$currentPath]) || !isset($referencesByMappedPath[$currentPath])) {
685 6
                continue;
686
            }
687
688 10
            $orderedReferences = array();
689
690 10
            foreach ($this->json['_order'][$currentPath] as $orderEntry) {
691 10
                if (!isset($referencesByMappedPath[$orderEntry['path']])) {
692
                    continue;
693
                }
694
695 10
                for ($i = 0; $i < $orderEntry['references'] && count($referencesByMappedPath[$orderEntry['path']]) > 0; ++$i) {
696 10
                    $orderedReferences[] = array_shift($referencesByMappedPath[$orderEntry['path']]);
697
                }
698
699
                // Only include references of the first mapped path
700
                // Since $stopOnFirst is set, those references have a
701
                // maximum size of 1
702 10
                if ($flags & self::STOP_ON_FIRST) {
703 10
                    break;
704
                }
705
            }
706
707 10
            $result[$currentPath] = $orderedReferences;
708
        }
709
710 10
        return $result;
711
    }
712
713
    /**
714
     * Follows any link in a list of references.
715
     *
716
     * This method takes all the given references, checks for links starting
717
     * with "@" and recursively expands those links to their target references.
718
     * The target references may be `null` or absolute filesystem paths.
719
     *
720
     * Null values are returned unchanged.
721
     *
722
     * Absolute filesystem paths are returned unchanged.
723
     *
724
     * @param string[]|null[] $references The references.
725
     * @param int             $flags      A bitwise combination of the flag
726
     *                                    constants in this class.
727
     *
728
     * @return string[]|null[] The references with all links replaced by their
729
     *                         target references. If any link pointed to more
730
     *                         than one target reference, the returned array
731
     *                         is larger than the passed array (unless the
732
     *                         argument `$stopOnFirst` was set to `true`).
733
     */
734 118
    private function followLinks(array $references, $flags = 0)
735
    {
736 118
        $result = array();
737
738 118
        foreach ($references as $key => $reference) {
739
            // Not a link
740 118
            if (!$this->isLinkReference($reference)) {
741 118
                $result[] = $reference;
742
743 118
                if ($flags & self::STOP_ON_FIRST) {
744
                    return $result;
745
                }
746
747 118
                continue;
748
            }
749
750
            $referencedPath = substr($reference, 1);
751
752
            // Get all the file system paths that this link points to
753
            // and append them to the result
754
            foreach ($this->searchReferences($referencedPath, $flags) as $referencedReferences) {
755
                // Follow links recursively
756
                $referencedReferences = $this->followLinks($referencedReferences);
757
758
                // Append all resulting target paths to the result
759
                foreach ($referencedReferences as $referencedReference) {
760
                    $result[] = $referencedReference;
761
762
                    if ($flags & self::STOP_ON_FIRST) {
763
                        return $result;
764
                    }
765
                }
766
            }
767
        }
768
769 118
        return $result;
770
    }
771
772
    /**
773
     * Appends nested paths to references and filters out the existing ones.
774
     *
775
     * This method takes all the given references, appends the nested path to
776
     * each of them and then filters out the results that actually exist on the
777
     * filesystem.
778
     *
779
     * Null references are filtered out.
780
     *
781
     * Link references should be followed with {@link followLinks()} before
782
     * calling this method.
783
     *
784
     * @param string[]|null[] $references The references.
785
     * @param string          $nestedPath The nested path to append without
786
     *                                    leading slash ("/").
787
     * @param int             $flags      A bitwise combination of the flag
788
     *                                    constants in this class.
789
     *
790
     * @return string[] The references with the nested path appended. Each
791
     *                  reference is guaranteed to exist on the filesystem.
792
     */
793 108
    private function appendPathAndFilterExisting(array $references, $nestedPath, $flags = 0)
794
    {
795 108
        $result = array();
796
797 108
        foreach ($references as $reference) {
798
            // Filter out null values
799
            // Links should be followed before calling this method
800 108
            if (null === $reference) {
801 48
                continue;
802
            }
803
804 76
            $nestedReference = rtrim($reference, '/').'/'.$nestedPath;
805
806 76
            if (file_exists($nestedReference)) {
807 64
                $result[] = $nestedReference;
808
809 64
                if ($flags & self::STOP_ON_FIRST) {
810 76
                    return $result;
811
                }
812
            }
813
        }
814
815 82
        return $result;
816
    }
817
818
    /**
819
     * Resolves a list of references stored in the JSON.
820
     *
821
     * Each reference passed in can be:
822
     *
823
     *  * `null`
824
     *  * a link starting with `@`
825
     *  * a filesystem path relative to the base directory
826
     *  * an absolute filesystem path
827
     *
828
     * Each reference returned by this method can be:
829
     *
830
     *  * `null`
831
     *  * a link starting with `@`
832
     *  * an absolute filesystem path
833
     *
834
     * Additionally, the results are guaranteed to be an array. If the
835
     * argument `$stopOnFirst` is set, that array has a maximum size of 1.
836
     *
837
     * @param string $path       The mapped Puli path.
838
     * @param mixed  $references The reference(s).
839
     * @param int    $flags      A bitwise combination of the flag constants in
840
     *                           this class.
841
     *
842
     * @return string[]|null[] The resolved references.
843
     */
844 174
    private function resolveReferences($path, $references, $flags = 0)
845
    {
846 174
        $result = array();
847
848 174
        if (!is_array($references)) {
849 174
            $references = array($references);
850
        }
851
852 174
        foreach ($references as $key => $reference) {
853
            // Keep non-filesystem references as they are
854 174
            if (!$this->isFilesystemReference($reference)) {
855 76
                $result[] = $reference;
856
857 76
                if ($flags & self::STOP_ON_FIRST) {
858 26
                    return $result;
859
                }
860
861 76
                continue;
862
            }
863
864 170
            $absoluteReference = Path::makeAbsolute($reference, $this->baseDirectory);
865 170
            $referenceExists = file_exists($absoluteReference);
866
867 170
            if (($flags & self::NO_CHECK_FILE_EXISTS) || $referenceExists) {
868 170
                $result[] = $absoluteReference;
869
870 170
                if ($flags & self::STOP_ON_FIRST) {
871 52
                    return $result;
872
                }
873
            }
874
875 170
            if (!$referenceExists) {
876 170
                $this->logReferenceNotFound($path, $reference, $absoluteReference);
877
            }
878
        }
879
880 174
        return $result;
881
    }
882
883
    /**
884
     * Returns the depth of a Puli path.
885
     *
886
     * The depth is used in order to limit the recursion when recursively
887
     * iterating directories.
888
     *
889
     * The depth starts at 0 for the root:
890
     *
891
     * /                0
892
     * /webmozart       1
893
     * /webmozart/puli  2
894
     * ...
895
     *
896
     * @param string $path A Puli path.
897
     *
898
     * @return int The depth starting with 0 for the root node.
899
     */
900 26
    private function getPathDepth($path)
901
    {
902
        // / has depth 0
903
        // /webmozart has depth 1
904
        // /webmozart/puli has depth 2
905
        // ...
906 26
        return substr_count(rtrim($path, '/'), '/');
907
    }
908
909
    /**
910
     * Inserts a path at the beginning of the order list of a mapped path.
911
     *
912
     * @param string $path          The path of the mapping where to prepend.
913
     * @param string $prependedPath The path of the mapping to prepend.
914
     */
915 10
    private function prependOrderEntry($path, $prependedPath)
916
    {
917 10
        $lastEntry = reset($this->json['_order'][$path]);
918
919 10
        if ($prependedPath === $lastEntry['path']) {
920
            // If the first entry matches the new one, add the reference
921
            // of the current resource to the limit
922 2
            ++$lastEntry['references'];
923
        } else {
924 10
            array_unshift($this->json['_order'][$path], array(
925 10
                'path' => $prependedPath,
926 10
                'references' => 1,
927
            ));
928
        }
929 10
    }
930
931
    /**
932
     * Initializes a path with the order of the closest parent path.
933
     *
934
     * @param string $path             The path to initialize.
935
     * @param array  $parentReferences The defined references for parent paths,
936
     *                                 with long paths /a/b sorted before short
937
     *                                 paths /a.
938
     */
939 14
    private function initWithParentOrder($path, array $parentReferences)
940
    {
941 14
        foreach ($parentReferences as $parentPath => $_) {
942
            // Look for the first parent entry for which an order is defined
943 14
            if (isset($this->json['_order'][$parentPath])) {
944
                // Inherit that order
945 2
                $this->json['_order'][$path] = $this->json['_order'][$parentPath];
946
947 14
                return;
948
            }
949
        }
950 12
    }
951
952
    /**
953
     * Initializes the order of a path with the default order.
954
     *
955
     * This is necessary if we want to insert a non-default order entry for
956
     * the first time.
957
     *
958
     * @param string $path         The path to initialize.
959
     * @param string $insertedPath The path that is being inserted.
960
     * @param array  $references   The references for each defined path mapping
961
     *                             in the path chain.
962
     */
963 10
    private function initWithDefaultOrder($path, $insertedPath, $references)
964
    {
965 10
        $this->json['_order'][$path] = array();
966
967
        // Insert the default order, if none exists
968
        // i.e. long paths /a/b/c before short paths /a/b
969 10
        $parentPath = $path;
970
971 10
        while (true) {
972 10
            if (isset($references[$parentPath])) {
973
                $parentEntry = array(
974 10
                    'path' => $parentPath,
975 10
                    'references' => count($references[$parentPath]),
976
                );
977
978
                // Edge case: $parentPath equals $insertedPath. In this case we have
979
                // to subtract the entry that we're adding
980 10
                if ($parentPath === $insertedPath) {
981 10
                    --$parentEntry['references'];
982
                }
983
984 10
                if (0 !== $parentEntry['references']) {
985 10
                    $this->json['_order'][$path][] = $parentEntry;
986
                }
987
            }
988
989 10
            if ('/' === $parentPath) {
990 10
                break;
991
            }
992
993 10
            $parentPath = Path::getDirectory($parentPath);
994
        };
995 10
    }
996
}
997