Passed
Push — master ( 4e618b...a1b109 )
by Divine Niiquaye
02:27
created

Loader::getPossibleFiles()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 10
ccs 0
cts 8
cp 0
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Biurad opensource projects.
7
 *
8
 * PHP version 7.2 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Biurad\UI;
19
20
use Biurad\UI\Exceptions\LoaderException;
21
use Biurad\UI\Interfaces\LoaderInterface;
22
use Biurad\UI\Interfaces\StorageInterface;
23
use Biurad\UI\Storage\CacheStorage;
24
use InvalidArgumentException;
25
26
/**
27
 * Loads and locates view files associated with specific extensions.
28
 */
29
final class Loader implements LoaderInterface
30
{
31
    /** @var null|Profile */
32
    protected $profiler;
33
34
    /** @var string[] */
35
    protected $extensions = [];
36
37
    /** @var StorageInterface */
38
    private $storage;
39
40
    /** @var array<string,mixed> */
41
    private $namespaces = [];
42
43
    /**
44
     * @param StorageInterface $storage
45
     */
46
    public function __construct(StorageInterface $storage, ?Profile $profile = null)
47
    {
48
        $this->storage  = $storage;
49
        $this->profiler = $profile;
50
    }
51
52
    /**
53
     * {@inheritdoc}
54
     */
55
    public function addNamespace(string $namespace, $hints): void
56
    {
57
        $hints = (array) $hints;
58
59
        if (isset($this->namespaces[$namespace])) {
60
            $hints = \array_merge($this->namespaces[$namespace], $hints);
61
        }
62
63
        $this->namespaces[$namespace] = $hints;
64
    }
65
66
    /**
67
     * {@inheritdoc}
68
     */
69
    public function withExtensions(array $extensions): LoaderInterface
70
    {
71
        $loader             = clone $this;
72
        $loader->extensions = $extensions;
73
74
        return $loader;
75
    }
76
77
    /**
78
     * {@inheritdoc}
79
     *
80
     * @param string $template
81
     */
82
    public function exists(string $view, string &$template = null): bool
83
    {
84
        try {
85
            if (\strpos($view = \trim($view), static::NS_SEPARATOR) > 0) {
86
                $template = $this->findNamespacedView($view);
87
88
                return true;
89
            }
90
91
            $this->validatePath($view);
92
            $template = $this->findInStorage($view);
93
94
            return true;
95
        } catch (LoaderException $e) {
96
            if ("File [{$view}] not found." !== $e->getMessage()) {
97
                throw $e;
98
            }
99
        }
100
101
        return false;
102
    }
103
104
    /**
105
     * {@inheritdoc}
106
     */
107
    public function find(string $view): Source
108
    {
109
        if (!$this->exists($view, $template)) {
110
            throw new LoaderException("Unable to load view `$view`, file does not exists.");
111
        }
112
113
        if (null !== $this->profiler) {
114
            $profile = new Profile(Profile::TEMPLATE, $view);
115
        }
116
117
        try {
118
            return new Source($template, $this->storage instanceof CacheStorage);
119
        } finally {
120
            if (null !== $this->profiler) {
121
                $this->profiler->addProfile($profile->leave());
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $profile does not seem to be defined for all execution paths leading up to this point.
Loading history...
122
            }
123
        }
124
    }
125
126
    /**
127
     * @return null|Profile
128
     */
129
    public function getProfile(): ?Profile
130
    {
131
        return $this->profiler;
132
    }
133
134
    /**
135
     * Get the path to a template with a named path.
136
     *
137
     * @param string $name
138
     *
139
     * @return string
140
     */
141
    private function findNamespacedView(string $name): string
142
    {
143
        [$namespace, $view] = $this->parseNamespaceSegments($name);
144
145
        try {
146
            $this->storage->addLocation($this->namespaces[$namespace]);
147
        } catch (LoaderException $e) {
148
            // Do nothing ...
149
        }
150
151
        return $this->findInStorage($view);
152
    }
153
154
    /**
155
     * Find the given view from storage.
156
     *
157
     * @param string $template
158
     *
159
     * @throws LoaderException if template not found
160
     *
161
     * @return string
162
     */
163
    private function findInStorage(string $template): string
164
    {
165
        foreach ($this->getPossibleFiles($template) as $file) {
166
            if (null !== $found = $this->storage->load($file)) {
167
                return $found;
168
            }
169
        }
170
171
        throw new LoaderException("File [{$template}] not found.");
172
    }
173
174
    /**
175
     * Get an array of possible view files.
176
     *
177
     * @param string $name
178
     *
179
     * @return string[]
180
     */
181
    private function getPossibleFiles(string $name): array
182
    {
183
        return \array_map(function ($extension) use ($name): string {
184
            //Cutting extra symbols (see Twig)
185
            return \preg_replace(
186
                '#/{2,}#',
187
                '/',
188
                \str_replace(['\\', '.'], '/', $name)
189
            ) . '.' . $extension;
190
        }, $this->extensions);
191
    }
192
193
    /**
194
     * Get the segments of a template with a named path.
195
     *
196
     * @param string $name
197
     *
198
     * @throws InvalidArgumentException
199
     *
200
     * @return string[]
201
     */
202
    private function parseNamespaceSegments(string $name): array
203
    {
204
        $segments    = \explode(static::NS_SEPARATOR, $name);
205
        $segments[0] = \str_replace(['@', '#'], '', $segments[0]);
206
207
        if (\count($segments) !== 2) {
208
            throw new InvalidArgumentException("View [{$name}] has an invalid name.");
209
        }
210
211
        if (!isset($this->namespaces[$segments[0]])) {
212
            throw new InvalidArgumentException("No hint path defined for [{$segments[0]}].");
213
        }
214
215
        return $segments;
216
    }
217
218
    /**
219
     * Make sure view filename is OK. Same as in twig.
220
     *
221
     * @param string $path
222
     *
223
     * @throws LoaderException
224
     */
225
    private function validatePath(string $path): void
226
    {
227
        if (empty($path)) {
228
            throw new LoaderException('A view path is empty');
229
        }
230
231
        if (false !== \strpos($path, "\0")) {
232
            throw new LoaderException('A view path cannot contain NULL bytes');
233
        }
234
235
        $path  = \ltrim($path, '/');
236
        $parts = \explode('/', $path);
237
        $level = 0;
238
239
        foreach ($parts as $part) {
240
            if ('..' === $part) {
241
                --$level;
242
            } elseif ('.' !== $part) {
243
                ++$level;
244
            }
245
246
            if ($level < 0) {
247
                throw new LoaderException(\sprintf('Looks like you try to load a view outside configured directories (%s)', $path));
248
            }
249
        }
250
    }
251
}
252