Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
90.73% covered (success)
90.73%
137 / 151
15.35% covered (danger)
15.35%
37 / 241
59.09% covered (warning)
59.09%
13 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoutePattern
100.00% covered (success)
100.00%
124 / 124
90.73% covered (success)
90.73%
137 / 151
15.35% covered (danger)
15.35%
37 / 241
100.00% covered (success)
100.00%
22 / 22
2548.27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
4 / 4
83.33% covered (warning)
83.33%
5 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 namedMatches
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 generate
100.00% covered (success)
100.00%
9 / 9
83.33% covered (warning)
83.33%
10 / 12
16.67% covered (danger)
16.67%
1 / 6
100.00% covered (success)
100.00%
1 / 1
19.47
 generateParameter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 generateRemainder
100.00% covered (success)
100.00%
4 / 4
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 stringParam
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 matchesConstraint
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 assertSafeRemainder
100.00% covered (success)
100.00%
6 / 6
85.71% covered (warning)
85.71%
18 / 21
3.33% covered (danger)
3.33%
3 / 90
100.00% covered (success)
100.00%
1 / 1
38.52
 assertKnownParams
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
8 / 8
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
6.00
 parameterNames
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 normalize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 parse
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
11 / 11
20.00% covered (danger)
20.00%
1 / 5
100.00% covered (success)
100.00%
1 / 1
17.80
 flushLiteral
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertUniqueName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hideInnerBraces
100.00% covered (success)
100.00%
17 / 17
92.31% covered (success)
92.31%
24 / 26
3.33% covered (danger)
3.33%
3 / 90
100.00% covered (success)
100.00%
1 / 1
82.17
 restoreInnerBraces
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 compile
100.00% covered (success)
100.00%
8 / 8
83.33% covered (warning)
83.33%
10 / 12
16.67% covered (danger)
16.67%
1 / 6
100.00% covered (success)
100.00%
1 / 1
19.47
 compileParameter
100.00% covered (success)
100.00%
5 / 5
85.71% covered (warning)
85.71%
6 / 7
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 compileRemainder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Router;
6
7use Celemas\Router\Exception\InvalidArgumentException;
8use Celemas\Router\Exception\ValueError;
9use Stringable;
10
11/** @internal */
12final readonly class RoutePattern
13{
14    private const string LEFT_BRACE = '§§§€§§§';
15    private const string RIGHT_BRACE = '§§§£§§§';
16
17    private string $pattern;
18
19    /** @var list<RouteToken> */
20    private array $tokens;
21
22    private string $regex;
23
24    public function __construct(string $pattern)
25    {
26        $this->pattern = self::normalize($pattern);
27        $this->tokens = $this->parse($this->pattern);
28        $this->regex = '~^' . $this->compile($this->tokens) . '$~';
29    }
30
31    public function pattern(): string
32    {
33        return $this->pattern;
34    }
35
36    /**
37     * @psalm-suppress PossiblyUnusedMethod -- exposed for parser tests.
38     * @return list<RouteToken>
39     */
40    public function tokens(): array
41    {
42        return $this->tokens;
43    }
44
45    /** @return null|array<string, string> */
46    public function match(string $path): ?array
47    {
48        $path = $path === '' ? '/' : $path;
49
50        /** @psalm-suppress ArgumentTypeCoercion -- route regexes are compiled as non-empty strings */
51        if (preg_match($this->regex, $path, $matches) !== 1) {
52            return null;
53        }
54
55        return self::namedMatches($matches);
56    }
57
58    /**
59     * @param array<array-key, string> $matches
60     * @return array<string, string>
61     */
62    private static function namedMatches(array $matches): array
63    {
64        $params = [];
65
66        foreach ($matches as $key => $value) {
67            if (!is_string($key)) {
68                continue;
69            }
70
71            $params[$key] = $value;
72        }
73
74        return $params;
75    }
76
77    /** @param array<array-key, mixed> $params */
78    public function generate(array $params = []): string
79    {
80        $this->assertKnownParams($params);
81        $path = '';
82
83        foreach ($this->tokens as $token) {
84            $path .= match ($token->type()) {
85                RouteToken::LITERAL => $token->value(),
86                RouteToken::PARAMETER => $this->generateParameter($token, $params),
87                RouteToken::REMAINDER => $this->generateRemainder($token, $params),
88            };
89        }
90
91        return $path;
92    }
93
94    /** @param array<array-key, mixed> $params */
95    private function generateParameter(RouteToken $part, array $params): string
96    {
97        $name = (string) $part->name();
98        $value = $this->stringParam($params, $name);
99        $constraint = $part->constraint() ?? '[.\w-]+';
100
101        if (!$this->matchesConstraint($constraint, $value)) {
102            throw new InvalidArgumentException('Route parameter does not match constraint: ' . $name);
103        }
104
105        return rawurlencode($value);
106    }
107
108    /** @param array<array-key, mixed> $params */
109    private function generateRemainder(RouteToken $part, array $params): string
110    {
111        $name = (string) $part->name();
112        $value = $this->stringParam($params, $name);
113        $this->assertSafeRemainder($name, $value);
114
115        return implode('/', array_map(rawurlencode(...), explode('/', $value)));
116    }
117
118    /** @param array<array-key, mixed> $params */
119    private function stringParam(array $params, string $name): string
120    {
121        if (!array_key_exists($name, $params)) {
122            throw new InvalidArgumentException('Missing route parameter: ' . $name);
123        }
124
125        /** @psalm-suppress MixedAssignment -- route params are intentionally mixed and validated below */
126        $value = $params[$name];
127
128        if (is_scalar($value) || $value instanceof Stringable) {
129            return (string) $value;
130        }
131
132        throw new InvalidArgumentException('Route parameter must be scalar or Stringable: ' . $name);
133    }
134
135    private function matchesConstraint(string $constraint, string $value): bool
136    {
137        return preg_match('~^(?:' . str_replace('~', '\\~', $constraint) . ')$~', $value) === 1;
138    }
139
140    private function assertSafeRemainder(string $name, string $value): void
141    {
142        if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) {
143            throw new InvalidArgumentException('Remainder route parameter must be a relative path: '
144            . $name);
145        }
146
147        foreach (explode('/', $value) as $segment) {
148            if ($segment === '..') {
149                throw new InvalidArgumentException('Remainder route parameter must stay relative: ' . $name);
150            }
151        }
152    }
153
154    /** @param array<array-key, mixed> $params */
155    private function assertKnownParams(array $params): void
156    {
157        $names = array_flip($this->parameterNames());
158
159        foreach ($params as $name => $_) {
160            if (!is_string($name) || !array_key_exists($name, $names)) {
161                throw new InvalidArgumentException('Unknown route parameter: ' . (string) $name);
162            }
163        }
164    }
165
166    /** @return list<string> */
167    private function parameterNames(): array
168    {
169        $names = [];
170
171        foreach ($this->tokens as $token) {
172            $name = $token->name();
173
174            if ($name !== null) {
175                $names[] = $name;
176            }
177        }
178
179        return $names;
180    }
181
182    private static function normalize(string $pattern): string
183    {
184        $pattern = '/' . ltrim($pattern, '/');
185
186        return strlen($pattern) > 1 ? rtrim($pattern, '/') : $pattern;
187    }
188
189    /** @return list<RouteToken> */
190    private function parse(string $pattern): array
191    {
192        $pattern = $this->hideInnerBraces($pattern);
193        $tokens = [];
194        $names = [];
195        $literal = '';
196        $offset = 0;
197        $length = strlen($pattern);
198
199        while ($offset < $length) {
200            if (preg_match('/\G\{(\w+)(?::([^}]+))?\}/', $pattern, $matches, 0, $offset) === 1) {
201                $this->flushLiteral($tokens, $literal);
202                $name = $matches[1];
203                $this->assertUniqueName($names, $name);
204                $constraint = isset($matches[2]) ? $this->restoreInnerBraces($matches[2]) : null;
205                $tokens[] = RouteToken::parameter($name, $constraint);
206                $offset += strlen($matches[0]);
207
208                continue;
209            }
210
211            if (preg_match('/\G\.\.\.(\w+)\z/', $pattern, $matches, 0, $offset) === 1) {
212                $this->flushLiteral($tokens, $literal);
213                $name = $matches[1];
214                $this->assertUniqueName($names, $name);
215                $tokens[] = RouteToken::remainder($name);
216                $offset += strlen($matches[0]);
217
218                continue;
219            }
220
221            $literal .= $pattern[$offset];
222            $offset++;
223        }
224
225        $this->flushLiteral($tokens, $literal);
226
227        return $tokens;
228    }
229
230    /**
231     * @param list<RouteToken> $tokens
232     * @param-out list<RouteToken> $tokens
233     */
234    private function flushLiteral(array &$tokens, string &$literal): void
235    {
236        if ($literal === '') {
237            return;
238        }
239
240        $tokens[] = RouteToken::literal($literal);
241        $literal = '';
242    }
243
244    /** @param array<string, true> $names */
245    private function assertUniqueName(array &$names, string $name): void
246    {
247        if (array_key_exists($name, $names)) {
248            throw new ValueError('Duplicate route parameter: ' . $name);
249        }
250
251        $names[$name] = true;
252    }
253
254    private function hideInnerBraces(string $str): string
255    {
256        if (str_contains($str, '\\{') || str_contains($str, '\\}')) {
257            throw new ValueError('Escaped braces are not allowed: ' . $this->pattern);
258        }
259
260        $new = '';
261        $level = 0;
262
263        foreach (str_split($str) as $c) {
264            if ($c === '{') {
265                $level++;
266                $new .= $level > 1 ? self::LEFT_BRACE : '{';
267
268                continue;
269            }
270
271            if ($c === '}') {
272                $new .= $level > 1 ? self::RIGHT_BRACE : '}';
273                $level--;
274
275                continue;
276            }
277
278            $new .= $c;
279        }
280
281        if ($level !== 0) {
282            throw new ValueError('Unbalanced braces in route pattern: ' . $this->pattern);
283        }
284
285        return $new;
286    }
287
288    private function restoreInnerBraces(string $str): string
289    {
290        return str_replace(self::LEFT_BRACE, '{', str_replace(self::RIGHT_BRACE, '}', $str));
291    }
292
293    /** @param list<RouteToken> $tokens */
294    private function compile(array $tokens): string
295    {
296        $regex = '';
297
298        foreach ($tokens as $token) {
299            $regex .= match ($token->type()) {
300                RouteToken::LITERAL => preg_quote($token->value(), '~'),
301                RouteToken::PARAMETER => $this->compileParameter($token),
302                RouteToken::REMAINDER => $this->compileRemainder($token),
303            };
304        }
305
306        return $regex;
307    }
308
309    private function compileParameter(RouteToken $part): string
310    {
311        $name = (string) $part->name();
312        $constraint = $part->constraint();
313
314        return $constraint === null
315            ? "(?P<{$name}>[.\w-]+)"
316            : "(?P<{$name}>" . str_replace('~', '\\~', $constraint) . ')';
317    }
318
319    private function compileRemainder(RouteToken $part): string
320    {
321        return '(?P<' . (string) $part->name() . '>.*)';
322    }
323}