Passed
Push — master ( 95ec9a...864242 )
by Divine Niiquaye
02:52
created

Loader::isAbsolutePath()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 5
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 8
ccs 0
cts 6
cp 0
crap 42
rs 9.2222
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
24
/**
25
 * Loads and locates view files associated with specific extensions.
26
 *
27
 * @author Divine Niiquaye Ibok <[email protected]>
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, $template);
115
        }
116
117
        try {
118
            return new Source($template);
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
            foreach ($this->namespaces[$namespace] ?? [] as $viewPath) {
147
                $this->storage->addLocation($viewPath);
148
            }
149
        } catch (LoaderException $e) {
150
            // Do nothing ...
151
        }
152
153
        return $this->findInStorage($view);
154
    }
155
156
    /**
157
     * Find the given view from storage.
158
     *
159
     * @param string $template
160
     *
161
     * @throws LoaderException if template not found
162
     *
163
     * @return string
164
     */
165
    private function findInStorage(string $template): string
166
    {
167
        foreach ($this->getPossibleFiles($template) as $file) {
168
            if (null !== $found = $this->storage->load($file)) {
169
                return $found;
170
            }
171
        }
172
173
        // If template is a full file path.
174
        if (
175
            $this->isAbsolutePath($template) &&
176
            \in_array(pathinfo($template, PATHINFO_EXTENSION), $this->extensions, true)
177
        ) {
178
            return $template;
179
        }
180
181
        throw new LoaderException("File [{$template}] not found.");
182
    }
183
184
    /**
185
     * Get an array of possible view files.
186
     *
187
     * @param string $name
188
     *
189
     * @return string[]
190
     */
191
    private function getPossibleFiles(string $name): array
192
    {
193
        return \array_map(function ($extension) use ($name): string {
194
            //Cutting extra symbols (see Twig)
195
            return \preg_replace(
196
                '#/{2,}#',
197
                '/',
198
                \str_replace(['\\', '.'], '/', $name)
199
            ) . '.' . $extension;
200
        }, $this->extensions);
201
    }
202
203
    /**
204
     * Get the segments of a template with a named path.
205
     *
206
     * @param string $name
207
     *
208
     * @throws \InvalidArgumentException
209
     *
210
     * @return string[]
211
     */
212
    private function parseNamespaceSegments(string $name): array
213
    {
214
        $segments    = \explode(static::NS_SEPARATOR, $name);
215
        $segments[0] = \str_replace(['@', '#'], '', $segments[0]);
216
217
        if (\count($segments) !== 2) {
218
            throw new \InvalidArgumentException("View [{$name}] has an invalid name.");
219
        }
220
221
        if (!isset($this->namespaces[$segments[0]])) {
222
            throw new \InvalidArgumentException("No hint path defined for [{$segments[0]}] namespace.");
223
        }
224
225
        return $segments;
226
    }
227
228
    /**
229
     * Make sure view filename is OK. Same as in twig.
230
     *
231
     * @param string $path
232
     *
233
     * @throws LoaderException
234
     */
235
    private function validatePath(string $path): void
236
    {
237
        if (empty($path)) {
238
            throw new LoaderException('A view path is empty');
239
        }
240
241
        if (false !== \strpos($path, "\0")) {
242
            throw new LoaderException('A view path cannot contain NULL bytes');
243
        }
244
245
        $path  = \ltrim($path, '/');
246
        $parts = \explode('/', $path);
247
        $level = 0;
248
249
        foreach ($parts as $part) {
250
            if ('..' === $part) {
251
                --$level;
252
            } elseif ('.' !== $part) {
253
                ++$level;
254
            }
255
256
            if ($level < 0) {
257
                throw new LoaderException(\sprintf('Looks like you try to load a view outside configured directories (%s)', $path));
258
            }
259
        }
260
    }
261
262
    private function isAbsolutePath(string $file): bool
263
    {
264
        return strspn($file, '/\\', 0, 1)
265
            || (\strlen($file) > 3 && ctype_alpha($file[0])
266
                && ':' === $file[1]
267
                && strspn($file, '/\\', 2, 1)
268
            )
269
            || null !== parse_url($file, PHP_URL_SCHEME)
270
        ;
271
    }
272
}
273