Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
Resolver
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
5 / 5
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 segments
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 path
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 prepareDirs
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Boiler;
6
7use Celema\Boiler\Exception\LookupException;
8use Celema\Boiler\Exception\UnexpectedValueException;
9use Override;
10
11/**
12 * @psalm-type DirsInput = non-empty-string|array<array-key, non-empty-string>
13 * @psalm-type Dirs = non-empty-array<array-key, non-empty-string>
14 */
15final class Resolver implements Contract\Resolver
16{
17    /** @psalm-var Dirs */
18    private readonly array $dirs;
19    /** @var array<string, non-empty-string> */
20    private array $pathCache = [];
21
22    /** @psalm-param DirsInput $dirs */
23    public function __construct(
24        array|string $dirs,
25    ) {
26        $this->dirs = $this->prepareDirs($dirs);
27    }
28
29    /** @return non-empty-string */
30    #[Override]
31    public function resolve(string $path): string
32    {
33        if (isset($this->pathCache[$path])) {
34            return $this->pathCache[$path];
35        }
36
37        if (!preg_match('/^[\w\.\/:_-]+$/u', $path)) {
38            throw new UnexpectedValueException('The template path is invalid or empty');
39        }
40
41        [$namespace, $file] = $this->segments($path);
42
43        return $this->pathCache[$path] = $this->path($namespace, $file);
44    }
45
46    /** @return list{null|non-empty-string, non-empty-string} */
47    private function segments(string $path): array
48    {
49        if (!str_contains($path, ':')) {
50            $path = trim($path);
51            assert($path !== '', 'Template path must not be empty after trimming');
52
53            return [null, $path];
54        }
55
56        $segments = array_map(trim(...), explode(':', $path));
57
58        if (count($segments) === 2) {
59            [$namespace, $file] = $segments;
60
61            if ($namespace !== '' && $file !== '') {
62                return [$namespace, $file];
63            }
64        }
65
66        throw new LookupException(
67            "Invalid template format: '{$path}'. " . "Use 'namespace:template/path or template/path'.",
68        );
69    }
70
71    /**
72     * @param non-empty-string $file
73     *
74     * @return non-empty-string
75     */
76    private function path(?string $namespace, string $file): string
77    {
78        if ($namespace === null) {
79            $dirs = $this->dirs;
80        } elseif (array_key_exists($namespace, $this->dirs)) {
81            $dirs = [$this->dirs[$namespace]];
82        } else {
83            throw new LookupException("Template namespace `{$namespace}` does not exist");
84        }
85
86        $errors = [];
87
88        foreach ($dirs as $dir) {
89            $candidate = new Path($dir, $file);
90
91            if ($candidate->isValid()) {
92                return $candidate->path();
93            }
94
95            $errors[] = $candidate->error();
96        }
97
98        // Name every directory searched, not just the last, as each can fail
99        // for its own reason, such as a path that leads outside its root.
100        throw new LookupException(implode('; ', $errors));
101    }
102
103    /**
104     * @psalm-param DirsInput $dirs
105     *
106     * @psalm-return Dirs
107     */
108    private function prepareDirs(array|string $dirs): array
109    {
110        $preparePath = static function (string $dir): string {
111            $realpath = realpath($dir);
112
113            if ($realpath === false) {
114                throw new LookupException(
115                    'Template directory does not exist ' . $dir,
116                );
117            }
118
119            assert($realpath !== '', 'Resolved template directory path must not be empty');
120
121            return $realpath;
122        };
123
124        if (is_string($dirs)) {
125            return [$preparePath($dirs)];
126        }
127
128        if ($dirs === []) {
129            throw new LookupException('At least one template directory must be configured');
130        }
131
132        return array_map($preparePath, $dirs);
133    }
134}