Test Failed
Pull Request — master (#16)
by Divine Niiquaye
03:21
created

SimpleRouteCompiler::computeHosts()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 13
c 0
b 0
f 0
nc 16
nop 3
dl 0
loc 25
ccs 6
cts 6
cp 1
crap 7
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.1 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 Flight\Routing\Matchers;
19
20
use Flight\Routing\CompiledRoute;
21
use Flight\Routing\Exceptions\UriHandlerException;
22
use Flight\Routing\Interfaces\RouteCompilerInterface;
23
use Flight\Routing\Route;
24
25
/**
26
 * RouteCompiler compiles Route instances to regex.
27
 *
28
 * provides ability to match and generate uris based on given parameters.
29
 *
30
 * @author Divine Niiquaye Ibok <[email protected]>
31
 */
32
class SimpleRouteCompiler implements RouteCompilerInterface
33
{
34
    private const DEFAULT_SEGMENT = '[^\/]+';
35
36
    /**
37
     * This string defines the characters that are automatically considered separators in front of
38
     * optional placeholders (with default and no static text following). Such a single separator
39
     * can be left out together with the optional placeholder from matching and generating URLs.
40
     */
41
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
42
43
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
44
45
    /**
46
     * This regex is used to match a certain rule of pattern to be used for routing.
47
     *
48
     * List of string patterns that regex matches:
49
     * - /{var} - A required variable pattern
50
     * - /[{var}] - An optional variable pattern
51
     * - /foo[/{var}] - A path with an optional sub variable pattern
52
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
53
     * - /{var:[a-z]+} - A required variable with lowercase rule
54
     * - /{var=foo} - A required variable with default value
55
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
56
     */
57
    private const COMPILER_REGEX = '#\{(\w+)(?:\:((?U).*\}|.*))?(?:\=(\w+))?\}#i';
58
59
    /**
60
     * A matching requirement helper, to ease matching route pattern when found.
61
     */
62
    private const SEGMENT_TYPES = [
63
        'int' => '\d+',
64
        'lower' => '[a-z]+',
65
        'upper' => '[A-Z]+',
66
        'alpha' => '[A-Za-z]+',
67
        'alnum' => '[A-Za-z0-9]+',
68
        'year' => '[12][0-9]{3}',
69
        'month' => '0[1-9]|1[012]+',
70
        'day' => '0[1-9]|[12][0-9]|3[01]+',
71
        'uuid' => '0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}',
72
    ];
73
74
    /**
75
     * The maximum supported length of a PCRE subpattern name
76
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
77
     *
78
     * @internal
79
     */
80
    private const VARIABLE_MAXIMUM_LENGTH = 32;
81
82
    /**
83
     * {@inheritdoc}
84
     */
85
    public function compile(Route $route, bool $reversed = false): CompiledRoute
86
    {
87
        $requirements = $this->getRequirements($route->get('patterns'));
88
        $routePath = $route->get('path');
89
90
        if ('/' !== $routePath[0]) {
91
            $routePath = '/' . $routePath;
92
        }
93
94
        if (!empty($hosts = $route->get('domain'))) {
95
            $hostsRegex = $this->computeHosts($hosts, $reversed, $requirements);
96
        }
97
98
        [$pathRegex, $pathVariable] = '/' === $routePath
99
            ? [(!$reversed ? '\\' : '') . '/', []] // making homepage url much faster ...
100
            : $this->compilePattern($requirements, $routePath, $reversed);
101
102
        // Resolves $pathRegex and host and pattern variables.
103
        $pathRegex = !$reversed ? '/^' . $pathRegex . '$/sDu' : \stripslashes($pathRegex);
104 1
        $variables = isset($hostVariables) ? $hostVariables += $pathVariable : $pathVariable;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $hostVariables seems to never exist and therefore isset should always be false.
Loading history...
105
106
        return new CompiledRoute($pathRegex, $hostsRegex ?? [], $variables, empty($pathVariable) ? $routePath : null);
107 1
    }
108 1
109 1
    /**
110 1
     * Get the route requirements.
111 1
     *
112 1
     * @param array<string,string|string[]> $requirements
113 1
     *
114
     * @return array<string,string|string[]>
115
     */
116
    protected function getRequirements(array $requirements): array
117
    {
118
        $newParameters = [];
119
120 1
        foreach ($requirements as $key => $regex) {
121
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
122 1
        }
123 1
124 1
        return $newParameters;
125 1
    }
126 1
127 1
    private function sanitizeRequirement(string $key, string $regex): string
128 1
    {
129 1
        if ('^' === @$regex[0]) {
130
            $regex = \substr($regex, 1); // returns false for a single character
131
        }
132
133
        if ('$' === @$regex[-1]) {
134 99
            $regex = \substr($regex, 0, -1);
135
        }
136 99
137 99
        if (empty($regex)) {
138
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
139 95
        }
140 14
141 14
        return $regex;
142
    }
143 14
144
    /**
145 14
     * @param array<string,string|string[]> $requirements
146 14
     *
147
     * @throws UriHandlerException if a variable name starts with a digit or
148
     *                             if it is too long to be successfully used as a PCRE subpattern or
149
     *                             if a variable is referenced more than once
150 95
     *
151 92
     * @return array<string,mixed>
152
     */
153 92
    private function compilePattern(array $requirements, string $uriPattern, bool $reversed): array
154 92
    {
155 92
        // correct [/ first occurrence]
156 92
        if (0 === \strpos($uriPattern, '[/')) {
157 92
            $uriPattern = '[' . \substr($uriPattern, 3);
158 92
        }
159 92
160
        // Strip supported browser prefix of $uriPattern ...
161 92
        if (isset(Route::URL_PREFIX_SLASHES[$uriPattern[-1]])) {
162
            $uriPattern = \substr($uriPattern, 0, -1);
163
        }
164
165
        // Match all variables enclosed in "{}" and iterate over them...
166
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_UNMATCHED_AS_NULL);
167 10
168
        if (!empty($matches)) {
169 10
            [$variables, $replaces] = $this->computePattern($matches, $uriPattern, $reversed, $requirements);
170
        }
171
172
        // Resolves route pattern place holders ...
173
        $replaces = ($replaces ?? []) + (!$reversed ? self::PATTERN_REPLACES : ['?' => '']);
174
175
        return [\strtr($uriPattern, $replaces), $variables ?? []];
176
    }
177 10
178
    /**
179 10
     * Compile hosts from route and return computed hosts.
180
     *
181
     * @param array<string,string|string[]> $requirements
182
     *
183
     * @return string[]|string
184
     */
185 42
    private function computeHosts(array $hosts, bool $isReversed, array $requirements)
186
    {
187 42
        $hostVariables = $hostRegexs = [];
188
        $compliledHosts = '/^(?|';
189
190
        foreach ($hosts as $host) {
191
            [$hostRegex, $hostVariable] = $this->compilePattern($requirements, $host, $isReversed);
192
            $hostVariables += $hostVariable;
193
194
            if (1 === \count($hosts)) {
195 9
                $compliledHosts = !$isReversed ? '/^' . $hostRegex : \stripslashes($hostRegex);
196
197 9
                break;
198
            }
199
200
            if (!$isReversed) {
201
                $compliledHosts .= $hostRegex . '|';
202
203
                continue;
204
            }
205 12
206
            $hostRegexs[] = \stripslashes($hostRegex);
207 12
        }
208
209
        return empty($hostRegexs) ? $compliledHosts . ('|' === $compliledHosts[-1] ? ')' : '') . '$/sDi' : $hostRegexs;
210
    }
211
212
    /**
213
     * Compute prepared pattern and return it's replacements and arguments.
214
     *
215 85
     * @param array<string,string[]>        $matches
216
     * @param array<string,string|string[]> $requirements
217 85
     *
218
     * @return array<int,array<int|string,mixed>>
219
     */
220
    private function computePattern(array $matches, string $pattern, bool $isReversed, array $requirements): array
221
    {
222
        $variables = $replaces = [];
223
        [$placeholders, $names, $rules, $defaults] = $matches;
224
225 70
        $count = \count($names);
226
227 70
        foreach ($names as $index => $varName) {
228
            // Filter variable name to meet requirement
229
            $this->filterVariableName($varName, $pattern);
230
231
            if (\array_key_exists($varName, $variables)) {
232
                throw new UriHandlerException(
233
                    \sprintf(
234
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
235 1
                        $pattern,
236
                        $varName
237 1
                    )
238
                );
239
            }
240
241
            if (!$isReversed) {
242
                $replace = self::SEGMENT_TYPES[$rules[$index]] ?? $rules[$index] ?? $this->prepareSegment($varName, $requirements);
243
244
                // optimize the regex with a possessive quantifier.
245
                if (1 === $count && ('/' === $pattern[0] && '+' === @$replace[-1])) {
246
                    // This optimization cannot be applied when the next char is no real separator.
247 1
                    \preg_match('#\{.*\}(.+?)#', $pattern, $nextSeperator);
248
249 1
                    $replace .= !(isset($nextSeperator[1]) && (1 === \count($names) || '{' === $nextSeperator[1])) ? '+' : '';
250 1
                }
251
252
                $replace = \sprintf('(?P<%s>%s)', $varName, $replace);
253
            }
254
255
            $replaces[$placeholders[$index]] = $replace ?? "<$varName>";
256
            $variables[$varName] = $defaults[$index] ?? null;
257
258
            --$count;
259 99
        }
260
261 99
        return [$variables, $replaces];
262
    }
263 99
264 12
    /**
265
     * Prevent variables with same name used more than once.
266
     */
267 95
    private function filterVariableName(string $varName, string $pattern): void
268
    {
269
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
270 9
        // variable would not be usable as a Controller action argument.
271
        if (1 === \preg_match('/^\d/', $varName)) {
272 9
            throw new UriHandlerException(
273 2
                \sprintf(
274
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
275
                    $varName,
276 9
                    $pattern
277 2
                )
278
            );
279
        }
280 9
281 4
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
282
            throw new UriHandlerException(
283
                \sprintf(
284 5
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
285
                    $varName,
286
                    self::VARIABLE_MAXIMUM_LENGTH,
287
                    $pattern
288
                )
289
            );
290
        }
291
    }
292
293
    /**
294
     * Prepares segment pattern with given constrains.
295
     *
296 95
     * @param array<string,mixed> $requirements
297
     */
298 95
    private function prepareSegment(string $name, array $requirements): string
299 95
    {
300
        if (!isset($requirements[$name])) {
301
            return self::DEFAULT_SEGMENT;
302
        }
303 95
304 1
        if (\is_array($requirements[$name])) {
305
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
306
307
            return \implode('|', $values);
308 95
        }
309
310 95
        return $this->filterSegment((string) $requirements[$name]);
311
    }
312
313 92
    private function filterSegment(string $segment): string
314
    {
315
        return \strtr($segment, self::SEGMENT_REPLACES);
316 92
    }
317
}
318