Failed Conditions
Pull Request — 1.0 (#79)
by Bernhard
06:38 queued 03:08
created

JsonRepository::removeReferences()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 35
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7.5146

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 35
ccs 15
cts 23
cp 0.6522
rs 8.439
cc 6
eloc 18
nc 12
nop 1
crap 7.5146
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
     * @var bool
49
     */
50
    private $versioning;
0 ignored issues
show
Unused Code introduced by
The property $versioning is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

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