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

JsonRepository::initWithParentOrder()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 12
ccs 7
cts 7
cp 1
rs 9.4286
cc 3
eloc 5
nc 3
nop 2
crap 3
1
<?php
2
3
/*
4
 * This file is part of the puli/repository package.
5
 *
6
 * (c) Bernhard Schussek <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Puli\Repository;
13
14
use BadMethodCallException;
15
use Puli\Repository\Api\EditableRepository;
16
use Puli\Repository\Api\Resource\PuliResource;
17
use Puli\Repository\Api\ResourceNotFoundException;
18
use Puli\Repository\ChangeStream\ResourceStack;
19
use RecursiveIteratorIterator;
20
use Webmozart\Glob\Glob;
21
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
22
use Webmozart\PathUtil\Path;
23
24
/**
25
 * A development path mapping resource repository.
26
 * Each resource is resolved at `get()` time to improve
27
 * developer experience.
28
 *
29
 * Resources can be added with the method {@link add()}:
30
 *
31
 * ```php
32
 * use Puli\Repository\JsonRepository;
33
 *
34
 * $repo = new JsonRepository();
35
 * $repo->add('/css', new DirectoryResource('/path/to/project/res/css'));
36
 * ```
37
 *
38
 * This repository only supports instances of FilesystemResource.
39
 *
40
 * @since  1.0
41
 *
42
 * @author Bernhard Schussek <[email protected]>
43
 * @author Titouan Galopin <[email protected]>
44
 */
45
class JsonRepository extends AbstractJsonRepository implements EditableRepository
46
{
47
    /**
48
     * 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 98
    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 98
        parent::__construct($path, $baseDirectory, $validateJson);
64 98
    }
65
66
    /**
67
     * {@inheritdoc}
68
     */
69 9
    public function getStack($path)
70
    {
71 8
        if (!$this->json) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->json of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
72 7
            $this->load();
73 7
        }
74
75 8
        $references = $this->searchReferences($path);
76
77 8
        if (!isset($references[$path])) {
78
            throw ResourceNotFoundException::forPath($path);
79
        }
80
81 8
        $resources = array();
82 8
        $pathReferences = $references[$path];
83
84
        // The first reference is the last (current) version
85
        // Hence traverse in reverse order
86 8
        for ($ref = end($pathReferences); null !== key($pathReferences); $ref = prev($pathReferences)) {
87 8
            $resources[] = $this->createResource($path, $ref);
88 8
        }
89
90 9
        return new ResourceStack($resources);
91
    }
92
93
    /**
94
     * {@inheritdoc}
95
     */
96 80
    protected function appendToChangeStream(PuliResource $resource)
97
    {
98 80
        $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 80
        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 80
        $references = $this->searchReferences(
110 80
            $path,
111
            // Don't stop for the first result
112 80
            false,
113
            // Don't check the filesystem. We only want mappings
114 80
            false,
115
            // Include references mapped to nested paths
116 80
            true,
117
            // Include references mapped to ancestor paths
118
            true
119 80
        );
120
121
        // Filter virtual resources
122 80
        $references = array_filter($references, function ($currentReferences) {
123 80
            return array(null) !== $currentReferences;
124 80
        });
125
126
        // The $references contain:
127
        // - any sub references (e.g. /a/b/c, /a/b/d)
128
        // - the reference itself at $pos (e.g. /a/b)
129
        // - non-null parent references (e.g. /a)
130
        // (in that order, since long paths are sorted before short paths)
131 80
        $pos = array_search($path, array_keys($references), true);
132
133
        // We need to do three things:
134
135
        // 1. If any parent mapping has an order defined, inherit that order
136
137 80
        if ($pos + 1 < count($references)) {
138
            // Inherit the parent order if necessary
139 8
            if (!isset($this->json['_order'][$path])) {
140 8
                $parentReferences = array_slice($references, $pos + 1);
141
142 8
                $this->initWithParentOrder($path, $parentReferences);
143 8
            }
144
145
            // A parent order was inherited. Insert the path itself.
146 8
            if (isset($this->json['_order'][$path])) {
147 1
                $this->prependOrderEntry($path, $path);
148 1
            }
149 8
        }
150
151
        // 2. If there are child mappings, insert the current path into their order
152
153 80
        if ($pos > 0) {
154 6
            $subReferences = array_slice($references, 0, $pos);
155
156 6
            foreach ($subReferences as $subPath => $_) {
157 6
                if (isset($this->json['_order'][$subPath])) {
158 3
                    continue;
159
                }
160
161 6
                if (isset($this->json['_order'][$path])) {
162
                    $this->json['_order'][$subPath] = $this->json['_order'][$path];
163
                } else {
164 6
                    $this->initWithDefaultOrder($subPath, $path, $references);
165
                }
166 6
            }
167
168
            // After initializing all order entries, insert the new one
169 6
            foreach ($subReferences as $subPath => $_) {
170 6
                $this->prependOrderEntry($subPath, $path);
171 6
            }
172 6
        }
173 80
    }
174
175
    /**
176
     * {@inheritdoc}
177
     */
178 80
    protected function insertReference($path, $reference)
179
    {
180 80
        if (!isset($this->json[$path])) {
181
            // Store first entries as simple reference
182 80
            $this->json[$path] = $reference;
183
184 80
            return;
185
        }
186
187 7
        if ($reference === $this->json[$path]) {
188
            // Reference is already set
189 2
            return;
190
        }
191
192 5
        if (!is_array($this->json[$path])) {
193
            // Convert existing entries to arrays for follow ups
194 5
            $this->json[$path] = array($this->json[$path]);
195 5
        }
196
197 5
        if (!in_array($reference, $this->json[$path], true)) {
198
            // Insert at the beginning of the array
199 5
            array_unshift($this->json[$path], $reference);
200 5
        }
201 5
    }
202
203
    /**
204
     * {@inheritdoc}
205
     */
206 7
    protected function removeReferences($glob)
207
    {
208 7
        $checkResults = $this->getReferencesForGlob($glob);
209 7
        $nonDeletablePaths = array();
210
211 7
        foreach ($checkResults as $path => $filesystemPath) {
212 7
            if (!array_key_exists($path, $this->json)) {
213
                $nonDeletablePaths[] = $filesystemPath;
214
            }
215 7
        }
216
217 7
        if (count($nonDeletablePaths) === 1) {
218
            throw new BadMethodCallException(sprintf(
219
                'The remove query "%s" matched a resource that is not a path mapping', $glob
220
            ));
221 7
        } elseif (count($nonDeletablePaths) > 1) {
222
            throw new BadMethodCallException(sprintf(
223
                'The remove query "%s" matched %s resources that are not path mappings', $glob, count($nonDeletablePaths)
224
            ));
225
        }
226
227
        // Don't stop on the first result
228
        // Don't list directories. We only want to list the mappings that exist
229
        // in the JSON here.
230 7
        $deletedPaths = $this->getReferencesForGlob($glob.'{,/**/*}', false, false);
231 7
        $removed = 0;
232
233 7
        foreach ($deletedPaths as $path => $filesystemPath) {
234 7
            $removed += 1 + count($this->getReferencesForGlob($path.'/**/*'));
235
236 7
            unset($this->json[$path]);
237 7
        }
238
239 7
        return $removed;
240
    }
241
242
    /**
243
     * {@inheritdoc}
244
     */
245 55
    protected function getReferencesForPath($path)
246
    {
247
        // Stop on first result and flatten
248 55
        return $this->flatten($this->searchReferences($path, true));
249
    }
250
251
    /**
252
     * {@inheritdoc}
253
     */
254 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...
255
    {
256 26
        if (!Glob::isDynamic($glob)) {
257 18
            return $this->getReferencesForPath($glob);
258
        }
259
260 15
        return $this->getReferencesForRegex(
261 15
            Glob::getBasePath($glob),
262 15
            Glob::toRegEx($glob),
263 15
            $stopOnFirst,
264
            $traverseDirectories
265 15
        );
266
    }
267
268
    /**
269
     * {@inheritdoc}
270
     */
271 32
    protected function getReferencesForRegex($staticPrefix, $regex, $stopOnFirst = false, $traverseDirectories = true, $maxDepth = 0)
272
    {
273 32
        return $this->flattenWithFilter(
274
            // Never stop on the first result before applying the filter since
275
            // the filter may reject the only returned path
276
            // Check the filesystem
277
            // Include nested path mappings and match them against the pattern
278 32
            $this->searchReferences($staticPrefix, false, true, true),
279 32
            $regex,
280 32
            $stopOnFirst,
281 32
            $traverseDirectories,
282
            $maxDepth
283 32
        );
284
    }
285
286
    /**
287
     * {@inheritdoc}
288
     */
289 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...
290
    {
291 16
        $basePath = rtrim($path, '/');
292
293 16
        return $this->getReferencesForRegex(
294 16
            $basePath.'/',
295 16
            '~^'.preg_quote($basePath, '~').'/[^/]+$~',
296 16
            $stopOnFirst,
297
            // Traverse directories and match their contents against the glob
298 16
            true,
299
            // Limit the directory exploration to the depth of the path + 1
300 16
            $this->getPathDepth($path) + 1
301 16
        );
302
    }
303
304
    /**
305
     * Flattens a two-level reference array into a one-level array.
306
     *
307
     * For each entry on the first level, only the first entry of the second
308
     * level is included in the result.
309
     *
310
     * Each reference returned by this method can be:
311
     *
312
     *  * `null`
313
     *  * a link starting with `@`
314
     *  * an absolute filesystem path
315
     *
316
     * The keys of the returned array are Puli paths. Their order is undefined.
317
     *
318
     * @param array $references A two-level reference array as returned by
319
     *                          {@link searchReferences()}.
320
     *
321
     * @return string[]|null[] A one-level array of references with Puli paths
322
     *                         as keys.
323
     */
324 55
    private function flatten(array $references)
325
    {
326 55
        $result = array();
327
328 55
        foreach ($references as $currentPath => $currentReferences) {
329 51
            if (!isset($result[$currentPath])) {
330 51
                $result[$currentPath] = reset($currentReferences);
331 51
            }
332 55
        }
333
334 55
        return $result;
335
    }
336
337
    /**
338
     * Flattens a two-level reference array into a one-level array and filters
339
     * out any references that don't match the given regular expression.
340
     *
341
     * This method takes a two-level reference array as returned by
342
     * {@link searchReferences()}. The references are scanned for Puli paths
343
     * matching the given regular expression. Those matches are returned.
344
     *
345
     * If a matching path refers to more than one reference, the first reference
346
     * is returned in the resulting array.
347
     *
348
     * If `$listDirectories` is set to `true`, all references that contain
349
     * directory paths are traversed recursively and scanned for more paths
350
     * matching the regular expression. This recursive traversal can be limited
351
     * by passing a `$maxDepth` (see {@link getPathDepth()}).
352
     *
353
     * Each reference returned by this method can be:
354
     *
355
     *  * `null`
356
     *  * a link starting with `@`
357
     *  * an absolute filesystem path
358
     *
359
     * The keys of the returned array are Puli paths. Their order is undefined.
360
     *
361
     * @param array  $references          A two-level reference array as
362
     *                                    returned by {@link searchReferences()}.
363
     * @param string $regex               A regular expression used to filter
364
     *                                    Puli paths.
365
     * @param bool   $stopOnFirst         Whether to stop after finding a first
366
     *                                    result.
367
     * @param bool   $traverseDirectories Whether to search the contents of
368
     *                                    directory references for more matches.
369
     * @param int    $maxDepth            The maximum path depth when searching
370
     *                                    the contents of directory references.
371
     *                                    If 0, the depth is unlimited.
372
     *
373
     * @return string[]|null[] A one-level array of references with Puli paths
374
     *                         as keys.
375
     */
376 32
    private function flattenWithFilter(array $references, $regex, $stopOnFirst = false, $traverseDirectories = false, $maxDepth = 0)
377
    {
378 32
        $result = array();
379
380 32
        foreach ($references as $currentPath => $currentReferences) {
381
            // Check whether the current entry matches the pattern
382 29
            if (!isset($result[$currentPath]) && preg_match($regex, $currentPath)) {
383
                // If yes, the first stored reference is returned
384 13
                $result[$currentPath] = reset($currentReferences);
385
386 13
                if ($stopOnFirst) {
387 1
                    return $result;
388
                }
389 12
            }
390
391 29
            if (!$traverseDirectories) {
392 7
                continue;
393
            }
394
395
            // First follow any links before we check which of them is a directory
396 29
            $currentReferences = $this->followLinks($currentReferences);
397 29
            $currentPath = rtrim($currentPath, '/');
398
399
            // Search the nested entries if desired
400 29
            foreach ($currentReferences as $baseFilesystemPath) {
401
                // Ignore null values and file paths
402 29
                if (!is_dir($baseFilesystemPath)) {
403 16
                    continue;
404
                }
405
406 19
                $iterator = new RecursiveIteratorIterator(
407 19
                    new RecursiveDirectoryIterator(
408 19
                        $baseFilesystemPath,
409
                        RecursiveDirectoryIterator::CURRENT_AS_PATHNAME
410 19
                            | RecursiveDirectoryIterator::SKIP_DOTS
411 19
                    ),
412
                    RecursiveIteratorIterator::SELF_FIRST
413 19
                );
414
415 19
                if (0 !== $maxDepth) {
416 12
                    $currentDepth = $this->getPathDepth($currentPath);
417 12
                    $maxIteratorDepth = $maxDepth - $currentDepth;
418
419 12
                    if ($maxIteratorDepth < 1) {
420 1
                        continue;
421
                    }
422
423 12
                    $iterator->setMaxDepth($maxIteratorDepth);
424 12
                }
425
426 19
                $basePathLength = strlen($baseFilesystemPath);
427
428 19
                foreach ($iterator as $nestedFilesystemPath) {
429 19
                    $nestedPath = substr_replace($nestedFilesystemPath, $currentPath, 0, $basePathLength);
430
431 19
                    if (!isset($result[$nestedPath]) && preg_match($regex, $nestedPath)) {
432 19
                        $result[$nestedPath] = $nestedFilesystemPath;
433
434 19
                        if ($stopOnFirst) {
435 4
                            return $result;
436
                        }
437 16
                    }
438 18
                }
439 28
            }
440 31
        }
441
442 31
        return $result;
443
    }
444
445
    /**
446
     * Filters the JSON file for all references relevant to a given search path.
447
     *
448
     * The JSON is scanned starting with the longest mapped Puli path.
449
     *
450
     * If the search path is "/a/b", the result includes:
451
     *
452
     *  * The references of the mapped path "/a/b".
453
     *  * The references of any mapped super path "/a" with the sub-path "/b"
454
     *    appended.
455
     *
456
     * If the argument `$includeNested` is set to `true`, the result
457
     * additionally includes:
458
     *
459
     *  * The references of any mapped sub path "/a/b/c".
460
     *
461
     * This is useful if you want to look for the children of "/a/b" or scan
462
     * all descendants for paths matching a given pattern.
463
     *
464
     * The result of this method is an array with two levels:
465
     *
466
     *  * The first level has Puli paths as keys.
467
     *  * The second level contains all references for that path, where the
468
     *    first reference has the highest, the last reference the lowest
469
     *    priority. The keys of the second level are integers. There may be
470
     *    holes between any two keys.
471
     *
472
     * The references of the second level contain:
473
     *
474
     *  * `null` values for virtual resources
475
     *  * strings starting with "@" for links
476
     *  * absolute filesystem paths for filesystem resources
477
     *
478
     * @param string $searchPath       The path to search.
479
     * @param bool   $stopOnFirst      Whether to stop after finding a first result.
480
     * @param bool   $checkFilesystem  Whether to check directories of ancestor
481
     *                                 references for the searched path.
482
     * @param bool   $includeNested    Whether to include the references of path
483
     *                                 mappings for nested paths.
484
     * @param bool   $includeAncestors Whether to include the references of path
485
     *                                 mappings for ancestor paths.
486
     *
487
     * @return array An array with two levels.
488
     */
489 86
    private function searchReferences($searchPath, $stopOnFirst = false, $checkFilesystem = true, $includeNested = false, $includeAncestors = false)
490
    {
491 86
        $result = array();
492 86
        $foundMatchingMappings = false;
493 86
        $searchPath = rtrim($searchPath, '/');
494 86
        $searchPathForTest = $searchPath.'/';
495
496 86
        foreach ($this->json as $currentPath => $currentReferences) {
497 86
            $currentPathForTest = rtrim($currentPath, '/').'/';
498
499
            // We found a mapping that matches the search path
500
            // e.g. mapping /a/b for path /a/b
501 86 View Code Duplication
            if ($searchPathForTest === $currentPathForTest) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

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