Failed Conditions
Pull Request — 1.0 (#79)
by Bernhard
04:22 queued 01:32
created

JsonRepository::followLinks()   C

Complexity

Conditions 7
Paths 5

Size

Total Lines 37
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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