Failed Conditions
Pull Request — 1.0 (#79)
by Bernhard
12:18
created

JsonRepository::getReferencesForGlob()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 12
Ratio 100 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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