PathMapping::getConflictingMappings()   B
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5.025

Importance

Changes 0
Metric Value
dl 0
loc 20
c 0
b 0
f 0
ccs 9
cts 10
cp 0.9
rs 8.8571
cc 5
eloc 10
nc 5
nop 0
crap 5.025
1
<?php
2
3
/*
4
 * This file is part of the puli/manager 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\Manager\Api\Repository;
13
14
use Exception;
15
use InvalidArgumentException;
16
use Puli\Manager\Api\AlreadyLoadedException;
17
use Puli\Manager\Api\FileNotFoundException;
18
use Puli\Manager\Api\Module\Module;
19
use Puli\Manager\Api\Module\ModuleList;
20
use Puli\Manager\Api\Module\NoSuchModuleException;
21
use Puli\Manager\Api\NotLoadedException;
22
use Puli\Manager\Assert\Assert;
23
use RecursiveIteratorIterator;
24
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
25
use Webmozart\PathUtil\Path;
26
27
/**
28
 * Maps a repository path to one or more filesystem paths.
29
 *
30
 * The filesystem paths are passed in the form of *path references* that are
31
 * either paths relative to the module's root directory or paths relative
32
 * to another modules's root directory prefixed with `@vendor/module:`,
33
 * where "vendor/module" is the name of the referenced module.
34
 *
35
 * The path references are turned into absolute filesystem paths when
36
 * {@link load()} is called.
37
 *
38
 * @since  1.0
39
 *
40
 * @author Bernhard Schussek <[email protected]>
41
 */
42
class PathMapping
43
{
44
    /**
45
     * @var string
46
     */
47
    private $repositoryPath;
48
49
    /**
50
     * @var string[]
51
     */
52
    private $pathReferences = array();
53
54
    /**
55
     * @var string[]
56
     */
57
    private $filesystemPaths = array();
58
59
    /**
60
     * @var string[]
61
     */
62
    private $pathMappings = array();
63
64
    /**
65
     * @var string[]
66
     */
67
    private $repositoryPaths = array();
68
69
    /**
70
     * @var Module
71
     */
72
    private $containingModule;
73
74
    /**
75
     * @var int|null
76
     */
77
    private $state;
78
79
    /**
80
     * @var Exception[]
81
     */
82
    private $loadErrors = array();
83
84
    /**
85
     * @var PathConflict[]
86
     */
87
    private $conflicts = array();
88
89
    /**
90
     * Creates a new path mapping.
91
     *
92
     * @param string          $repositoryPath The repository path.
93
     * @param string|string[] $pathReferences The path references.
94
     *
95
     * @throws InvalidArgumentException If any of the arguments is invalid.
96
     */
97 109
    public function __construct($repositoryPath, $pathReferences)
98
    {
99 109
        Assert::path($repositoryPath);
100
101 107
        $pathReferences = (array) $pathReferences;
102
103 107
        Assert::notEmpty($pathReferences, 'At least one filesystem path must be passed.');
104 106
        Assert::allString($pathReferences, 'The filesystem paths must be strings. Got: %s');
105 104
        Assert::allNotEmpty($pathReferences, 'The filesystem paths must not be empty.');
106
107 102
        $this->repositoryPath = $repositoryPath;
108 102
        $this->pathReferences = $pathReferences;
109 102
    }
110
111
    /**
112
     * Loads the mapping.
113
     *
114
     * @param Module     $containingModule The module that contains the
115
     *                                     mapping.
116
     * @param ModuleList $modules          A list of modules that can
117
     *                                     be referenced using
118
     *                                     `@vendor/module:` prefixes
119
     *                                     in the path references.
120
     *
121
     * @throws AlreadyLoadedException If the mapping is already loaded.
122
     */
123 86
    public function load(Module $containingModule, ModuleList $modules)
124
    {
125 86
        if (null !== $this->state) {
126 1
            throw new AlreadyLoadedException('The mapping is already loaded.');
127
        }
128
129 86
        $filesystemPaths = array();
130 86
        $pathMappings = array();
131 86
        $loadErrors = array();
132
133 86
        foreach ($this->pathReferences as $relativePath) {
134 86
            $loadError = null;
135
136
            try {
137 86
                $absolutePath = $this->makeAbsolute($relativePath, $containingModule, $modules);
138 84
                $this->assertFileExists($absolutePath, $relativePath, $containingModule);
139
140 82
                $filesystemPaths[] = $absolutePath;
141 8
            } catch (NoSuchModuleException $loadError) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
142 5
            } catch (FileNotFoundException $loadError) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
143
            }
144
145 86
            if ($loadError) {
146 86
                $loadErrors[] = $loadError;
147
            }
148
        }
149
150 86
        foreach ($filesystemPaths as $filesystemPath) {
151 82
            $pathMappings[$filesystemPath] = $this->repositoryPath;
152
153 82
            if (!is_dir($filesystemPath)) {
154 1
                continue;
155
            }
156
157 81
            $prefixLength = strlen($filesystemPath);
158 81
            $directoryEntries = iterator_to_array(new RecursiveIteratorIterator(
159 81
                new RecursiveDirectoryIterator(
160
                    $filesystemPath,
161 81
                    RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS
162
                ),
163 81
                RecursiveIteratorIterator::SELF_FIRST
164
            ));
165
166
            // RecursiveDirectoryIterator is not guaranteed to sort its results,
167
            // so sort them here
168
            // We need to sort in the loop and not at the very end because the
169
            // order of the $filesystemPaths should be kept in $pathMappings
170 81
            ksort($directoryEntries);
171
172 81
            foreach ($directoryEntries as $nestedFilesystemPath) {
173 81
                $pathMappings[$nestedFilesystemPath] = substr_replace($nestedFilesystemPath, $this->repositoryPath, 0, $prefixLength);
174
            }
175
        }
176
177 86
        $this->repositoryPaths = array_unique($pathMappings);
178 86
        $this->filesystemPaths = $filesystemPaths;
179 86
        $this->pathMappings = $pathMappings;
180 86
        $this->loadErrors = $loadErrors;
181 86
        $this->containingModule = $containingModule;
182
183 86
        sort($this->repositoryPaths);
184
185 86
        $this->refreshState();
186 86
    }
187
188
    /**
189
     * Unloads the mapping.
190
     *
191
     * This method reverses the effects of {@link load()}. Additionally, all
192
     * associated conflicts are dereferenced.
193
     *
194
     * @throws NotLoadedException If the mapping is not loaded.
195
     */
196 18
    public function unload()
197
    {
198 18
        if (null === $this->state) {
199 1
            throw new NotLoadedException('The mapping is not loaded.');
200
        }
201
202 17
        $conflictsToRelease = $this->conflicts;
203
204 17
        $this->conflicts = array();
205
206 17
        foreach ($conflictsToRelease as $conflict) {
207 3
            $conflict->removeMapping($this);
208
        }
209
210 17
        $this->filesystemPaths = array();
211 17
        $this->pathMappings = array();
212 17
        $this->repositoryPaths = array();
213 17
        $this->loadErrors = array();
214 17
        $this->containingModule = null;
215 17
        $this->state = null;
216 17
    }
217
218
    /**
219
     * Returns whether the mapping is loaded.
220
     *
221
     * @return bool Returns `true` if {@link load()} was called.
222
     */
223 82
    public function isLoaded()
224
    {
225 82
        return null !== $this->state;
226
    }
227
228
    /**
229
     * Returns the repository path.
230
     *
231
     * @return string The repository path.
232
     */
233 68
    public function getRepositoryPath()
234
    {
235 68
        return $this->repositoryPath;
236
    }
237
238
    /**
239
     * Returns the path references.
240
     *
241
     * The path references refer to filesystem paths. A path reference is
242
     * either:
243
     *
244
     *  * a path relative to the root directory of the containing module;
245
     *  * a path relative to the root directory of another module, prefixed
246
     *    with `@vendor/module:`, where "vendor/module" is the name of the
247
     *    referenced module.
248
     *
249
     * @return string[] The path references.
250
     */
251 22
    public function getPathReferences()
252
    {
253 22
        return $this->pathReferences;
254
    }
255
256
    /**
257
     * Returns the referenced filesystem paths.
258
     *
259
     * The method {@link load()} needs to be called before calling this method,
260
     * otherwise an exception is thrown.
261
     *
262
     * @return string[] The absolute filesystem paths.
263
     *
264
     * @throws NotLoadedException If the mapping is not loaded.
265
     */
266 41
    public function getFilesystemPaths()
267
    {
268 41
        if (null === $this->state) {
269
            throw new NotLoadedException('The mapping is not loaded.');
270
        }
271
272 41
        return $this->filesystemPaths;
273
    }
274
275
    /**
276
     * Lists all filesystem path to repository path mappings of this mapping.
277
     *
278
     * @return string[] An array of repository paths with their corresponding
279
     *                  filesystem paths as keys. If the mapping has multiple
280
     *                  filesystem paths, then repository paths may occur
281
     *                  multiple times in the returned array.
282
     */
283 4
    public function listPathMappings()
284
    {
285 4
        if (null === $this->state) {
286
            throw new NotLoadedException('The mapping is not loaded.');
287
        }
288
289 4
        return $this->pathMappings;
290
    }
291
292
    /**
293
     * Lists all mapped repository paths.
294
     *
295
     * Contrary to {@link getRepositoryPath()}, this array also contains all
296
     * nested repository paths that are mapped by this mapping.
297
     *
298
     * @return string[] A list of all mapped repository paths.
299
     */
300 61
    public function listRepositoryPaths()
301
    {
302 61
        if (null === $this->state) {
303
            throw new NotLoadedException('The mapping is not loaded.');
304
        }
305
306 61
        return $this->repositoryPaths;
307
    }
308
309
    /**
310
     * Returns the module that contains the mapping.
311
     *
312
     * The method {@link load()} needs to be called before calling this method,
313
     * otherwise an exception is thrown.
314
     *
315
     * @return Module The containing module or `null` if the mapping has not
316
     *                been loaded.
317
     *
318
     * @throws NotLoadedException If the mapping is not loaded.
319
     */
320 79
    public function getContainingModule()
321
    {
322 79
        if (null === $this->state) {
323
            throw new NotLoadedException('The mapping is not loaded.');
324
        }
325
326 79
        return $this->containingModule;
327
    }
328
329
    /**
330
     * Returns the errors that occurred during loading of the mapping.
331
     *
332
     * The method {@link load()} needs to be called before calling this method,
333
     * otherwise an exception is thrown.
334
     *
335
     * @return Exception[] The errors that occurred during loading. If the
336
     *                     returned array is empty, the mapping was loaded
337
     *                     successfully.
338
     *
339
     * @throws NotLoadedException If the mapping is not loaded.
340
     */
341 20
    public function getLoadErrors()
342
    {
343 20
        if (null === $this->state) {
344
            throw new NotLoadedException('The mapping is not loaded.');
345
        }
346
347 20
        return $this->loadErrors;
348
    }
349
350
    /**
351
     * Adds a conflict to the mapping.
352
     *
353
     * A mapping can refer to at most one conflict per conflicting repository
354
     * path. If the same conflict is added twice, the second addition is
355
     * ignored. If a different conflict is added for an existing repository
356
     * path, the previous conflict is removed before adding the new conflict
357
     * for the repository path.
358
     *
359
     * The repository path of the conflict must either be the repository path
360
     * of the mapping or any path within. If a conflict with a different path
361
     * is added, an exception is thrown.
362
     *
363
     * The method {@link load()} needs to be called before calling this method,
364
     * otherwise an exception is thrown.
365
     *
366
     * @param PathConflict $conflict The conflict to be added.
367
     *
368
     * @throws NotLoadedException       If the mapping is not loaded.
369
     * @throws InvalidArgumentException If the path of the conflict is not
370
     *                                  within the repository path of the
371
     *                                  mapping.
372
     */
373 39
    public function addConflict(PathConflict $conflict)
374
    {
375 39
        if (null === $this->state) {
376 1
            throw new NotLoadedException('The mapping is not loaded.');
377
        }
378
379 38
        if (!Path::isBasePath($this->repositoryPath, $conflict->getRepositoryPath())) {
380 1
            throw new InvalidArgumentException(sprintf(
381
                'The conflicting path %s is not within the path %s of the '.
382 1
                'mapping.',
383 1
                $conflict->getRepositoryPath(),
384 1
                $this->repositoryPath
385
            ));
386
        }
387
388 37
        $repositoryPath = $conflict->getRepositoryPath();
389 37
        $previousConflict = isset($this->conflicts[$repositoryPath]) ? $this->conflicts[$repositoryPath] : null;
390
391 37
        if ($previousConflict === $conflict) {
392 9
            return;
393
        }
394
395 37
        if ($previousConflict) {
396 1
            $previousConflict->removeMapping($this);
397
        }
398
399 37
        $this->conflicts[$repositoryPath] = $conflict;
400 37
        $conflict->addMapping($this);
401
402 37
        $this->refreshState();
403 37
    }
404
405
    /**
406
     * Removes a conflict from the mapping.
407
     *
408
     * The method {@link load()} needs to be called before calling this method,
409
     * otherwise an exception is thrown.
410
     *
411
     * @param PathConflict $conflict The conflict to remove.
412
     *
413
     * @throws NotLoadedException If the mapping is not loaded.
414
     */
415 19
    public function removeConflict(PathConflict $conflict)
416
    {
417 19
        if (null === $this->state) {
418 1
            throw new NotLoadedException('The mapping is not loaded.');
419
        }
420
421 18
        $repositoryPath = $conflict->getRepositoryPath();
422
423 18
        if (!isset($this->conflicts[$repositoryPath]) || $conflict !== $this->conflicts[$repositoryPath]) {
424 14
            return;
425
        }
426
427 16
        unset($this->conflicts[$repositoryPath]);
428 16
        $conflict->removeMapping($this);
429
430 16
        $this->refreshState();
431 16
    }
432
433
    /**
434
     * Returns the conflicts of the mapping.
435
     *
436
     * The method {@link load()} needs to be called before calling this method,
437
     * otherwise an exception is thrown.
438
     *
439
     * @return PathConflict[] The conflicts.
440
     *
441
     * @throws NotLoadedException If the mapping is not loaded.
442
     */
443 29
    public function getConflicts()
444
    {
445 29
        if (null === $this->state) {
446
            throw new NotLoadedException('The mapping is not loaded.');
447
        }
448
449 29
        return array_values($this->conflicts);
450
    }
451
452
    /**
453
     * Returns all modules with conflicting path mappings.
454
     *
455
     * The method {@link load()} needs to be called before calling this method,
456
     * otherwise an exception is thrown.
457
     *
458
     * @return ModuleList The conflicting modules.
459
     *
460
     * @throws NotLoadedException If the mapping is not loaded.
461
     */
462 14
    public function getConflictingModules()
463
    {
464 14
        if (null === $this->state) {
465
            throw new NotLoadedException('The mapping is not loaded.');
466
        }
467
468 14
        $collection = new ModuleList();
469
470 14
        foreach ($this->conflicts as $conflict) {
471 8
            foreach ($conflict->getMappings() as $mapping) {
472 8
                if ($this === $mapping) {
473 8
                    continue;
474
                }
475
476 8
                $collection->add($mapping->getContainingModule());
477
            }
478
        }
479
480 14
        return $collection;
481
    }
482
483
    /**
484
     * Returns all conflicting path mappings.
485
     *
486
     * The method {@link load()} needs to be called before calling this method,
487
     * otherwise an exception is thrown.
488
     *
489
     * @return PathMapping[] The conflicting path mappings.
490
     *
491
     * @throws NotLoadedException If the mapping is not loaded.
492
     */
493 1
    public function getConflictingMappings()
494
    {
495 1
        if (null === $this->state) {
496
            throw new NotLoadedException('The mapping is not loaded.');
497
        }
498
499 1
        $conflictingMappings = array();
500
501 1
        foreach ($this->conflicts as $conflict) {
502 1
            foreach ($conflict->getMappings() as $mapping) {
503 1
                if ($this === $mapping) {
504 1
                    continue;
505
                }
506
507 1
                $conflictingMappings[spl_object_hash($mapping)] = $mapping;
508
            }
509
        }
510
511 1
        return array_values($conflictingMappings);
512
    }
513
514
    /**
515
     * Returns the state of the mapping.
516
     *
517
     * The method {@link load()} needs to be called before calling this method,
518
     * otherwise an exception is thrown.
519
     *
520
     * @return int One of the {@link PathMappingState} constants.
521
     *
522
     * @throws NotLoadedException If the mapping is not loaded.
523
     */
524
    public function getState()
525
    {
526
        if (null === $this->state) {
527
            throw new NotLoadedException('The mapping is not loaded.');
528
        }
529
530
        return $this->state;
531
    }
532
533
    /**
534
     * Returns whether the mapping is enabled.
535
     *
536
     * The method {@link load()} needs to be called before calling this method,
537
     * otherwise an exception is thrown.
538
     *
539
     * @return bool Returns `true` if the state is
540
     *              {@link PathMappingState::ENABLED}.
541
     *
542
     * @see PathMappingState::ENABLED
543
     *
544
     * @throws NotLoadedException If the mapping is not loaded.
545
     * @throws NotLoadedException If the mapping is not loaded.
546
     */
547 43 View Code Duplication
    public function isEnabled()
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...
548
    {
549 43
        if (null === $this->state) {
550
            throw new NotLoadedException('The mapping is not loaded.');
551
        }
552
553 43
        return PathMappingState::ENABLED === $this->state;
554
    }
555
556
    /**
557
     * Returns whether the path referenced by the mapping was not found.
558
     *
559
     * The method {@link load()} needs to be called before calling this method,
560
     * otherwise an exception is thrown.
561
     *
562
     * @return bool Returns `true` if the state is
563
     *              {@link PathMappingState::NOT_FOUND}.
564
     *
565
     * @throws NotLoadedException If the mapping is not loaded.
566
     *
567
     * @see PathMappingState::NOT_FOUND
568
     */
569 2 View Code Duplication
    public function isNotFound()
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...
570
    {
571 2
        if (null === $this->state) {
572
            throw new NotLoadedException('The mapping is not loaded.');
573
        }
574
575 2
        return PathMappingState::NOT_FOUND === $this->state;
576
    }
577
578
    /**
579
     * Returns whether the mapping conflicts with a mapping in another module.
580
     *
581
     * The method {@link load()} needs to be called before calling this method,
582
     * otherwise an exception is thrown.
583
     *
584
     * @return bool Returns `true` if the state is
585
     *              {@link PathMappingState::CONFLICT}.
586
     *
587
     * @throws NotLoadedException If the mapping is not loaded.
588
     *
589
     * @see PathMappingState::CONFLICT
590
     */
591 8 View Code Duplication
    public function isConflicting()
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...
592
    {
593 8
        if (null === $this->state) {
594
            throw new NotLoadedException('The mapping is not loaded.');
595
        }
596
597 8
        return PathMappingState::CONFLICT === $this->state;
598
    }
599
600 86
    private function refreshState()
601
    {
602 86
        if (count($this->conflicts) > 0) {
603 37
            $this->state = PathMappingState::CONFLICT;
604 86
        } elseif (0 === count($this->filesystemPaths)) {
605 6
            $this->state = PathMappingState::NOT_FOUND;
606
        } else {
607 82
            $this->state = PathMappingState::ENABLED;
608
        }
609 86
    }
610
611 86
    private function makeAbsolute($relativePath, Module $containingModule, ModuleList $modules)
612
    {
613
        // Reference to install path of other module
614 86
        if ('@' !== $relativePath[0] || false === ($pos = strpos($relativePath, ':'))) {
615 82
            return $containingModule->getInstallPath().'/'.$relativePath;
616
        }
617
618 5
        $refModuleName = substr($relativePath, 1, $pos - 1);
619
620 5
        if (!$modules->contains($refModuleName)) {
621 3
            throw new NoSuchModuleException(sprintf(
622
                'The module "%s" referenced in the resource path "%s" was not '.
623 3
                'found. Maybe the module is not installed?',
624
                $refModuleName,
625
                $relativePath
626
            ));
627
        }
628
629 2
        $refModule = $modules->get($refModuleName);
630
631 2
        return $refModule->getInstallPath().'/'.substr($relativePath, $pos + 1);
632
    }
633
634 84
    private function assertFileExists($absolutePath, $relativePath, Module $containingModule)
635
    {
636 84
        if (!file_exists($absolutePath)) {
637 5
            throw new FileNotFoundException(sprintf(
638 5
                'The path %s mapped to %s by module "%s" does not exist.',
639
                $relativePath,
640 5
                $this->repositoryPath,
641 5
                $containingModule->getName()
642
            ));
643
        }
644 82
    }
645
}
646