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

src/JsonRepository.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
/*
4
 * This file is part of the puli/repository package.
5
 *
6
 * (c) Bernhard Schussek <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Puli\Repository;
13
14
use InvalidArgumentException;
15
use Puli\Repository\Api\ChangeStream\VersionList;
16
use Puli\Repository\Api\EditableRepository;
17
use Puli\Repository\Api\NoVersionFoundException;
18
use Puli\Repository\Api\Resource\PuliResource;
19
use RecursiveIteratorIterator;
20
use Webmozart\Glob\Glob;
21
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
22
use Webmozart\PathUtil\Path;
23
24
/**
25
 * A repository backed by a JSON file optimized for development.
26
 *
27
 * The generated JSON file is described by res/schema/repository-schema-1.0.json.
28
 *
29
 * Resources can be added with the method {@link add()}:
30
 *
31
 * ```php
32
 * use Puli\Repository\JsonRepository;
33
 *
34
 * $repo = new JsonRepository('/path/to/repository.json', '/path/to/project');
35
 * $repo->add('/css', new DirectoryResource('/path/to/project/res/css'));
36
 * ```
37
 *
38
 * When adding a resource, the added filesystem path is stored in the JSON file
39
 * under the key of the Puli path. The path is stored relatively to the base
40
 * directory passed to the constructor:
41
 *
42
 * ```json
43
 * {
44
 *     "/css": "res/css"
45
 * }
46
 * ```
47
 *
48
 * Mapped resources can be read with the method {@link get()}:
49
 *
50
 * ```php
51
 * $cssPath = $repo->get('/css')->getFilesystemPath();
52
 * ```
53
 *
54
 * You can also access nested files:
55
 *
56
 * ```php
57
 * echo $repo->get('/css/style.css')->getBody();
58
 * ```
59
 *
60
 * Nested files are searched during {@link get()}. As a consequence, this
61
 * implementation should not be used in production environments. Use
62
 * {@link OptimizedJsonRepository} instead which searches nested files during
63
 * {@link add()} and has much faster read performance.
64
 *
65
 * @since  1.0
66
 *
67
 * @author Bernhard Schussek <[email protected]>
68
 * @author Titouan Galopin <[email protected]>
69
 */
70
class JsonRepository extends AbstractJsonRepository implements EditableRepository
71
{
72
    /**
73
     * Flag: Don't search the contents of mapped directories for matching paths.
74
     *
75
     * @internal
76
     */
77
    const NO_SEARCH_FILESYSTEM = 2;
78
79
    /**
80
     * Flag: Don't filter out references that don't exist on the filesystem.
81
     *
82
     * @internal
83
     */
84
    const NO_CHECK_FILE_EXISTS = 4;
85
86
    /**
87
     * Flag: Include the references for mapped ancestor paths /a of a path /a/b.
88
     *
89
     * @internal
90
     */
91
    const INCLUDE_ANCESTORS = 8;
92
93
    /**
94
     * Flag: Include the references for mapped nested paths /a/b of a path /a.
95
     *
96
     * @internal
97
     */
98
    const INCLUDE_NESTED = 16;
99
100
    /**
101
     * Creates a new repository.
102
     *
103
     * @param string $path          The path to the JSON file. If relative, it
104
     *                              must be relative to the base directory.
105
     * @param string $baseDirectory The base directory of the store. Paths
106
     *                              inside that directory are stored as relative
107
     *                              paths. Paths outside that directory are
108
     *                              stored as absolute paths.
109
     * @param bool   $validateJson  Whether to validate the JSON file against
110
     *                              the schema. Slow but spots problems.
111
     */
112 198
    public function __construct($path, $baseDirectory, $validateJson = false)
113
    {
114
        // Does not accept ChangeStream objects
115
        // The ChangeStream functionality is implemented by the repository itself
116 198
        parent::__construct($path, $baseDirectory, $validateJson);
117 198
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122 32
    public function getVersions($path)
123
    {
124 32
        if (!$this->json) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->json of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

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