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

JsonRepository::followLinks()   C

Complexity

Conditions 7
Paths 5

Size

Total Lines 37
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 14.1433

Importance

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