Test Failed
Push — master ( f6c0fb...d5e0ae )
by Divine Niiquaye
04:37 queued 02:02
created

RouteCompiler::resolveRegex()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 5
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 10
ccs 5
cts 5
cp 1
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.4 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;
19
20
use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException};
21
use Flight\Routing\Generator\{GeneratedUri, RegexGenerator};
22
use Flight\Routing\Interfaces\RouteCompilerInterface;
23
24
/**
25
 * RouteCompiler compiles Route instances to regex.
26
 *
27
 * provides ability to match and generate uris based on given parameters.
28
 *
29
 * @author Divine Niiquaye Ibok <[email protected]>
30
 */
31
final class RouteCompiler implements RouteCompilerInterface
32
{
33
    private const DEFAULT_SEGMENT = '[^\/]+';
34
35
    /**
36
     * This string defines the characters that are automatically considered separators in front of
37
     * optional placeholders (with default and no static text following). Such a single separator
38
     * can be left out together with the optional placeholder from matching and generating URLs.
39
     */
40
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
41
42
    /**
43
     * Using the strtr function is faster than the preg_quote function.
44
     */
45
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
46
47
    /**
48
     * This regex is used to match a certain rule of pattern to be used for routing.
49
     *
50
     * List of string patterns that regex matches:
51
     * - /{var} - A required variable pattern
52
     * - /[{var}] - An optional variable pattern
53
     * - /foo[/{var}] - A path with an optional sub variable pattern
54
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
55
     * - /{var:[a-z]+} - A required variable with lowercase rule
56
     * - /{var=foo} - A required variable with default value
57
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
58
     */
59
    private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?\}?))?(?:\=(\w+))?\}~iu';
60
61
    /**
62
     * This regex is used to reverse a pattern path, matching required and options vars.
63
     */
64
    private const REVERSED_REGEX = '#(?|\<(\w+)\>|(\[(.*)]))#';
65
66
    /**
67
     * This regex is used to strip off a name attached to a group in a regex pattern.
68
     */
69
    private const STRIP_REGEX = '#\?(?|P<\w+>|<\w+>|\'\w+\')#';
70
71
    /**
72
     * A matching requirement helper, to ease matching route pattern when found.
73
     */
74
    private const SEGMENT_TYPES = [
75
        'int' => '\d+',
76
        'lower' => '[a-z]+',
77
        'upper' => '[A-Z]+',
78
        'alpha' => '[A-Za-z]+',
79
        'alnum' => '[A-Za-z0-9]+',
80
        'year' => '[12][0-9]{3}',
81
        'month' => '0[1-9]|1[012]+',
82
        'day' => '0[1-9]|[12][0-9]|3[01]+',
83
        '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}',
84
    ];
85
86
    /**
87
     * A helper in reversing route pattern to URI.
88
     */
89
    private const URI_FIXERS = [
90
        '[]' => '',
91
        '[/]' => '',
92
        '[' => '',
93
        ']' => '',
94
        '://' => '://',
95
        '//' => '/',
96
        '/..' => '/%2E%2E',
97
        '/.' => '/%2E',
98
    ];
99
100
    /**
101
     * The maximum supported length of a PCRE subpattern name
102
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
103
     *
104
     * @internal
105
     */
106
    private const VARIABLE_MAXIMUM_LENGTH = 32;
107
108
    /**
109
     * {@inheritdoc}
110
     */
111 12
    public function build(RouteCollection $routes): array
112
    {
113 12
        $tree = new RegexGenerator();
114 12
        $uriPrefixRegex = '#[^a-zA-Z0-9]+$#';
115 12
        $variables = $staticRegex = [];
116
117 12
        foreach ($routes->getRoutes() as $i => $route) {
118 12
            [$pathRegex, $hostsRegex, $compiledVars] = $this->compile($route);
119 12
            $pathRegex = self::resolveRegex($pathRegex);
120
121 12
            if (!empty($hostsRegex)) {
122 4
                $variables[$i] = [self::resolveRegex($hostsRegex), []];
123
            }
124
125 12
            if (!empty($compiledVars)) {
126 8
                $variables[$i] = [$variables[$i][0] ?? [], $compiledVars];
127
            }
128
129 12
            if ('?' === $pos = $pathRegex[-1]) {
130 8
                if (!\preg_match($uriPrefixRegex, $pathRegex[-2])) {
131 2
                    $pathRegex = \substr($pathRegex, 0, -1);
132
                }
133
134 8
                $tree->addRoute($pathRegex, [$pathRegex, $i]);
135 8
                continue;
136
            }
137
138 12
            if (\preg_match($uriPrefixRegex, $pos)) {
139 3
                $staticRegex[\substr($pathRegex, 0, -1)][] = $i;
140
            }
141
142 12
            $staticRegex[$pathRegex][] = $i;
143
        }
144
145 12
        if (!empty($compiledRegex = $tree->compile(0))) {
146 8
            $compiledRegex = '~^' . \substr($compiledRegex, 1) . '$~sDu';
147
        }
148
149 12
        return [$staticRegex, $compiledRegex ?: null, $variables];
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155 127
    public function compile(Route $route): array
156
    {
157 127
        [$pathRegex, $variables] = self::compilePattern($route->getPath(), false, $rPs = $route->getPatterns());
158
159 117
        if ($hosts = $route->getHosts()) {
160 16
            $hostsRegex = [];
161
162 16
            foreach ($hosts as $host) {
163 16
                [$hostRegex, $hostVars] = self::compilePattern($host, false, $rPs);
164 16
                $variables += $hostVars;
165 16
                $hostsRegex[] = $hostRegex;
166
            }
167
168 16
            $hostsRegex = '{^' . \implode('|', $hostsRegex) . '$}ui';
169
        }
170
171 117
        if ('?' !== $pathRegex[-1]) {
172 102
            $pathRegex .= '?';
173
        }
174
175 117
        return ['{^' . $pathRegex . '$}u', $hostsRegex ?? null, $variables];
176
    }
177
178
    /**
179
     * {@inheritdoc}
180
     */
181 14
    public function generateUri(Route $route, array $parameters): GeneratedUri
182
    {
183 14
        [$pathRegex, $pathVariables] = self::compilePattern($route->getPath(), true);
184
185 14
        $defaults = $route->getDefaults();
186 14
        $createUri = new GeneratedUri(self::interpolate($pathRegex, $parameters, $defaults + $pathVariables));
187
188 13
        foreach ($route->getHosts() as $host) {
189 2
            [$hostRegex, $hostVariables] = self::compilePattern($host, true);
190
191 2
            break;
192
        }
193
194 13
        if (!empty($schemes = $route->getSchemes())) {
195 3
            $createUri->withScheme(\in_array('https', $schemes, true) ? 'https' : \end($schemes) ?? 'http');
196
197 3
            if (!isset($hostRegex)) {
198 1
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
199
            }
200
        }
201
202 13
        if (isset($hostRegex)) {
203 2
            $createUri->withHost(self::interpolate($hostRegex, $parameters, $defaults + ($hostVariables ?? [])));
204
        }
205
206 13
        return $createUri;
207
    }
208
209
    /**
210
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
211
     *
212
     * @param array<int|string,mixed> $parameters
213
     * @param array<string,mixed>     $defaults
214
     */
215 14
    private static function interpolate(string $uriRoute, array $parameters, array $defaults): string
216
    {
217 14
        $required = []; // Parameters required which are missing.
218 14
        $replaces = self::URI_FIXERS;
219
220
        // Fetch and merge all possible parameters + route defaults ...
221 14
        \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
222
223 14
        foreach ($matches as $matched) {
224 11
            if (3 === \count($matched) && isset($matched[2])) {
225 3
                \preg_match_all('#\<(\w+)\>#', $matched[2], $optionalVars, \PREG_SET_ORDER);
226
227 3
                foreach ($optionalVars as [$type, $var]) {
228 3
                    $replaces[$type] = $parameters[$var] ?? $defaults[$var] ?? null;
229
                }
230
231 3
                continue;
232
            }
233
234 11
            $replaces[$matched[0]] = $parameters[$matched[1]] ?? $defaults[$matched[1]] ?? null;
235
236 11
            if (null === $replaces[$matched[0]]) {
237 1
                $required[] = $matched[1];
238
            }
239
        }
240
241 14
        if (!empty($required)) {
242 1
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', $required), $uriRoute));
243
        }
244
245 13
        return \strtr($uriRoute, $replaces);
246
    }
247
248 10
    private static function sanitizeRequirement(string $key, string $regex): string
249
    {
250 10
        if ('' !== $regex) {
251 9
            if ('^' === $regex[0]) {
252 2
                $regex = \substr($regex, 1);
253 7
            } elseif (0 === \strpos($regex, '\\A')) {
254 2
                $regex = \substr($regex, 2);
255
            }
256
        }
257
258 10
        if (\str_ends_with($regex, '$')) {
259 2
            $regex = \substr($regex, 0, -1);
260 8
        } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
261 2
            $regex = \substr($regex, 0, -2);
262
        }
263
264 10
        if ('' === $regex) {
265 7
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
266
        }
267
268 3
        return \strtr($regex, self::SEGMENT_REPLACES);
269
    }
270
271
    /**
272
     * @param array<string,string|string[]> $requirements
273
     *
274
     * @throws UriHandlerException if a variable name starts with a digit or
275
     *                             if it is too long to be successfully used as a PCRE subpattern or
276
     *                             if a variable is referenced more than once
277
     */
278 139
    private static function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array
279
    {
280
        // A path which doesn't contain {}, should be ignored.
281 139
        if (!\str_contains($uriPattern, '{')) {
282 79
            return [\strtr($uriPattern, $reversed ? ['?' => ''] : self::SEGMENT_REPLACES), []];
283
        }
284
285 77
        $variables = []; // VarNames mapping to values use by route's handler.
286 77
        $replaces = $reversed ? ['?' => ''] : self::PATTERN_REPLACES;
287
288
        // correct [/ first occurrence]
289 77
        if (1 === \strpos($uriPattern, '[/')) {
290 3
            $uriPattern = '/[' . \substr($uriPattern, 3);
291
        }
292
293
        // Match all variables enclosed in "{}" and iterate over them...
294 77
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
295
296 77
        foreach ($matches as [$placeholder, $varName, $segment, $default]) {
297
            // A PCRE subpattern name must start with a non-digit.
298 77
            if (1 === \preg_match('/\d/A', $varName)) {
299
                throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $uriPattern));
300 75
            }
301 1
302
            if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
303
                throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $uriPattern));
304 75
            }
305 75
306
            if (\array_key_exists($varName, $variables)) {
307
                throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName));
308 67
            }
309
310
            $variables[$varName] = $default;
311
            $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $requirements)) . ')' : "<$varName>";
312
        }
313
314 77
        return [\strtr($uriPattern, $replaces), $variables];
315
    }
316
317
    /**
318 77
     * Prepares segment pattern with given constrains.
319 1
     *
320
     * @param array<string,mixed> $requirements
321
     */
322 76
    private static function prepareSegment(string $name, array $requirements): string
323 1
    {
324 1
        if (!\array_key_exists($name, $requirements)) {
325
            return self::DEFAULT_SEGMENT;
326
        }
327
328
        if (!\is_array($segment = $requirements[$name])) {
329
            return self::sanitizeRequirement($name, $segment);
330
        }
331
332
        return \implode('|', $segment);
333
    }
334
335
    /**
336
     * Strips starting and ending modifiers from a path regex.
337
     */
338
    private static function resolveRegex(string $pathRegex): string
339 54
    {
340
        $pos = (int) \strrpos($pathRegex, '$');
341 54
        $pathRegex = \substr($pathRegex, 1 + \strpos($pathRegex, '^'), -(\strlen($pathRegex) - $pos));
342 43
343
        if (\preg_match('/\\(\\?P\\<\w+\\>.*\\)/', $pathRegex)) {
344
            return \preg_replace(self::STRIP_REGEX, '', $pathRegex);
345 13
        }
346 10
347
        return \str_replace(['\\', '?'], '', $pathRegex);
348
    }
349
}
350