Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
124 / 124 |
|
90.73% |
137 / 151 |
|
15.35% |
37 / 241 |
|
59.09% |
13 / 22 |
CRAP | |
0.00% |
0 / 1 |
| RoutePattern | |
100.00% |
124 / 124 |
|
90.73% |
137 / 151 |
|
15.35% |
37 / 241 |
|
100.00% |
22 / 22 |
2548.27 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| pattern | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| tokens | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| match | |
100.00% |
4 / 4 |
|
83.33% |
5 / 6 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
4.12 | |||
| namedMatches | |
100.00% |
6 / 6 |
|
100.00% |
6 / 6 |
|
25.00% |
1 / 4 |
|
100.00% |
1 / 1 |
6.80 | |||
| generate | |
100.00% |
9 / 9 |
|
83.33% |
10 / 12 |
|
16.67% |
1 / 6 |
|
100.00% |
1 / 1 |
19.47 | |||
| generateParameter | |
100.00% |
6 / 6 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| generateRemainder | |
100.00% |
4 / 4 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| stringParam | |
100.00% |
6 / 6 |
|
100.00% |
7 / 7 |
|
80.00% |
4 / 5 |
|
100.00% |
1 / 1 |
4.13 | |||
| matchesConstraint | |
100.00% |
1 / 1 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| assertSafeRemainder | |
100.00% |
6 / 6 |
|
85.71% |
18 / 21 |
|
3.33% |
3 / 90 |
|
100.00% |
1 / 1 |
38.52 | |||
| assertKnownParams | |
100.00% |
4 / 4 |
|
100.00% |
8 / 8 |
|
50.00% |
3 / 6 |
|
100.00% |
1 / 1 |
6.00 | |||
| parameterNames | |
100.00% |
6 / 6 |
|
100.00% |
6 / 6 |
|
25.00% |
1 / 4 |
|
100.00% |
1 / 1 |
6.80 | |||
| normalize | |
100.00% |
2 / 2 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| parse | |
100.00% |
26 / 26 |
|
100.00% |
11 / 11 |
|
20.00% |
1 / 5 |
|
100.00% |
1 / 1 |
17.80 | |||
| flushLiteral | |
100.00% |
4 / 4 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| assertUniqueName | |
100.00% |
3 / 3 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| hideInnerBraces | |
100.00% |
17 / 17 |
|
92.31% |
24 / 26 |
|
3.33% |
3 / 90 |
|
100.00% |
1 / 1 |
82.17 | |||
| restoreInnerBraces | |
100.00% |
1 / 1 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| compile | |
100.00% |
8 / 8 |
|
83.33% |
10 / 12 |
|
16.67% |
1 / 6 |
|
100.00% |
1 / 1 |
19.47 | |||
| compileParameter | |
100.00% |
5 / 5 |
|
85.71% |
6 / 7 |
|
66.67% |
2 / 3 |
|
100.00% |
1 / 1 |
2.15 | |||
| compileRemainder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celemas\Router; |
| 6 | |
| 7 | use Celemas\Router\Exception\InvalidArgumentException; |
| 8 | use Celemas\Router\Exception\ValueError; |
| 9 | use Stringable; |
| 10 | |
| 11 | /** @internal */ |
| 12 | final 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 | } |
Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not
necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once.
Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement
always has an else as part of its logical flow even if you didn't write one.
| 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 | } |
| 155 | private function assertKnownParams(array $params): void |
| 156 | { |
| 157 | $names = array_flip($this->parameterNames()); |
| 158 | |
| 159 | foreach ($params as $name => $_) { |
| 159 | foreach ($params as $name => $_) { |
| 159 | foreach ($params as $name => $_) { |
| 160 | if (!is_string($name) || !array_key_exists($name, $names)) { |
| 160 | if (!is_string($name) || !array_key_exists($name, $names)) { |
| 160 | if (!is_string($name) || !array_key_exists($name, $names)) { |
| 161 | throw new InvalidArgumentException('Unknown route parameter: ' . (string) $name); |
| 159 | foreach ($params as $name => $_) { |
| 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 | } |
| 140 | private function assertSafeRemainder(string $name, string $value): void |
| 141 | { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 142 | if (str_contains($value, "\0") || str_contains($value, '\\') || str_starts_with($value, '/')) { |
| 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); |
| 147 | foreach (explode('/', $value) as $segment) { |
| 147 | foreach (explode('/', $value) as $segment) { |
| 148 | if ($segment === '..') { |
| 149 | throw new InvalidArgumentException('Remainder route parameter must stay relative: ' . $name); |
| 147 | foreach (explode('/', $value) as $segment) { |
| 147 | foreach (explode('/', $value) as $segment) { |
| 148 | if ($segment === '..') { |
| 149 | throw new InvalidArgumentException('Remainder route parameter must stay relative: ' . $name); |
| 150 | } |
| 151 | } |
| 152 | } |
| 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); |
| 251 | $names[$name] = true; |
| 252 | } |
| 294 | private function compile(array $tokens): string |
| 295 | { |
| 296 | $regex = ''; |
| 297 | |
| 298 | foreach ($tokens as $token) { |
| 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), |
| 302 | RouteToken::REMAINDER => $this->compileRemainder($token), |
| 302 | RouteToken::REMAINDER => $this->compileRemainder($token), |
| 300 | RouteToken::LITERAL => preg_quote($token->value(), '~'), |
| 301 | RouteToken::PARAMETER => $this->compileParameter($token), |
| 302 | RouteToken::REMAINDER => $this->compileRemainder($token), |
| 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), |
| 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 | } |
| 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) . ')'; |
| 316 | : "(?P<{$name}>" . str_replace('~', '\\~', $constraint) . ')'; |
| 316 | : "(?P<{$name}>" . str_replace('~', '\\~', $constraint) . ')'; |
| 316 | : "(?P<{$name}>" . str_replace('~', '\\~', $constraint) . ')'; |
| 316 | : "(?P<{$name}>" . str_replace('~', '\\~', $constraint) . ')'; |
| 317 | } |
| 319 | private function compileRemainder(RouteToken $part): string |
| 320 | { |
| 321 | return '(?P<' . (string) $part->name() . '>.*)'; |
| 322 | } |
| 234 | private function flushLiteral(array &$tokens, string &$literal): void |
| 235 | { |
| 236 | if ($literal === '') { |
| 237 | return; |
| 240 | $tokens[] = RouteToken::literal($literal); |
| 241 | $literal = ''; |
| 242 | } |
| 78 | public function generate(array $params = []): string |
| 79 | { |
| 80 | $this->assertKnownParams($params); |
| 81 | $path = ''; |
| 82 | |
| 83 | foreach ($this->tokens as $token) { |
| 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), |
| 87 | RouteToken::REMAINDER => $this->generateRemainder($token, $params), |
| 87 | RouteToken::REMAINDER => $this->generateRemainder($token, $params), |
| 85 | RouteToken::LITERAL => $token->value(), |
| 86 | RouteToken::PARAMETER => $this->generateParameter($token, $params), |
| 87 | RouteToken::REMAINDER => $this->generateRemainder($token, $params), |
| 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), |
| 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 | } |
| 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); |
| 105 | return rawurlencode($value); |
| 106 | } |
| 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))); |
| 115 | return implode('/', array_map(rawurlencode(...), explode('/', $value))); |
| 115 | return implode('/', array_map(rawurlencode(...), explode('/', $value))); |
| 115 | return implode('/', array_map(rawurlencode(...), explode('/', $value))); |
| 116 | } |
| 254 | private function hideInnerBraces(string $str): string |
| 255 | { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 256 | if (str_contains($str, '\\{') || str_contains($str, '\\}')) { |
| 257 | throw new ValueError('Escaped braces are not allowed: ' . $this->pattern); |
| 260 | $new = ''; |
| 261 | $level = 0; |
| 262 | |
| 263 | foreach (str_split($str) as $c) { |
| 263 | foreach (str_split($str) as $c) { |
| 264 | if ($c === '{') { |
| 265 | $level++; |
| 266 | $new .= $level > 1 ? self::LEFT_BRACE : '{'; |
| 266 | $new .= $level > 1 ? self::LEFT_BRACE : '{'; |
| 266 | $new .= $level > 1 ? self::LEFT_BRACE : '{'; |
| 266 | $new .= $level > 1 ? self::LEFT_BRACE : '{'; |
| 267 | |
| 268 | continue; |
| 271 | if ($c === '}') { |
| 272 | $new .= $level > 1 ? self::RIGHT_BRACE : '}'; |
| 272 | $new .= $level > 1 ? self::RIGHT_BRACE : '}'; |
| 272 | $new .= $level > 1 ? self::RIGHT_BRACE : '}'; |
| 272 | $new .= $level > 1 ? self::RIGHT_BRACE : '}'; |
| 273 | $level--; |
| 274 | |
| 275 | continue; |
| 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; |
| 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); |
| 285 | return $new; |
| 286 | } |
| 46 | public function match(string $path): ?array |
| 47 | { |
| 48 | $path = $path === '' ? '/' : $path; |
| 48 | $path = $path === '' ? '/' : $path; |
| 48 | $path = $path === '' ? '/' : $path; |
| 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; |
| 55 | return self::namedMatches($matches); |
| 56 | } |
| 135 | private function matchesConstraint(string $constraint, string $value): bool |
| 136 | { |
| 137 | return preg_match('~^(?:' . str_replace('~', '\\~', $constraint) . ')$~', $value) === 1; |
| 137 | return preg_match('~^(?:' . str_replace('~', '\\~', $constraint) . ')$~', $value) === 1; |
| 137 | return preg_match('~^(?:' . str_replace('~', '\\~', $constraint) . ')$~', $value) === 1; |
| 137 | return preg_match('~^(?:' . str_replace('~', '\\~', $constraint) . ')$~', $value) === 1; |
| 138 | } |
| 62 | private static function namedMatches(array $matches): array |
| 63 | { |
| 64 | $params = []; |
| 65 | |
| 66 | foreach ($matches as $key => $value) { |
| 66 | foreach ($matches as $key => $value) { |
| 66 | foreach ($matches as $key => $value) { |
| 67 | if (!is_string($key)) { |
| 68 | continue; |
| 66 | foreach ($matches as $key => $value) { |
| 67 | if (!is_string($key)) { |
| 68 | continue; |
| 69 | } |
| 70 | |
| 71 | $params[$key] = $value; |
| 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 | } |
| 182 | private static function normalize(string $pattern): string |
| 183 | { |
| 184 | $pattern = '/' . ltrim($pattern, '/'); |
| 185 | |
| 186 | return strlen($pattern) > 1 ? rtrim($pattern, '/') : $pattern; |
| 186 | return strlen($pattern) > 1 ? rtrim($pattern, '/') : $pattern; |
| 186 | return strlen($pattern) > 1 ? rtrim($pattern, '/') : $pattern; |
| 186 | return strlen($pattern) > 1 ? rtrim($pattern, '/') : $pattern; |
| 187 | } |
| 169 | $names = []; |
| 170 | |
| 171 | foreach ($this->tokens as $token) { |
| 171 | foreach ($this->tokens as $token) { |
| 172 | $name = $token->name(); |
| 173 | |
| 174 | if ($name !== null) { |
| 171 | foreach ($this->tokens as $token) { |
| 172 | $name = $token->name(); |
| 173 | |
| 174 | if ($name !== null) { |
| 175 | $names[] = $name; |
| 171 | foreach ($this->tokens as $token) { |
| 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 | } |
| 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; |
| 204 | $constraint = isset($matches[2]) ? $this->restoreInnerBraces($matches[2]) : null; |
| 204 | $constraint = isset($matches[2]) ? $this->restoreInnerBraces($matches[2]) : null; |
| 204 | $constraint = isset($matches[2]) ? $this->restoreInnerBraces($matches[2]) : null; |
| 205 | $tokens[] = RouteToken::parameter($name, $constraint); |
| 206 | $offset += strlen($matches[0]); |
| 207 | |
| 208 | continue; |
| 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; |
| 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]; |
| 199 | while ($offset < $length) { |
| 225 | $this->flushLiteral($tokens, $literal); |
| 226 | |
| 227 | return $tokens; |
| 228 | } |
| 33 | return $this->pattern; |
| 34 | } |
| 288 | private function restoreInnerBraces(string $str): string |
| 289 | { |
| 290 | return str_replace(self::LEFT_BRACE, '{', str_replace(self::RIGHT_BRACE, '}', $str)); |
| 290 | return str_replace(self::LEFT_BRACE, '{', str_replace(self::RIGHT_BRACE, '}', $str)); |
| 290 | return str_replace(self::LEFT_BRACE, '{', str_replace(self::RIGHT_BRACE, '}', $str)); |
| 290 | return str_replace(self::LEFT_BRACE, '{', str_replace(self::RIGHT_BRACE, '}', $str)); |
| 291 | } |
| 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); |
| 126 | $value = $params[$name]; |
| 127 | |
| 128 | if (is_scalar($value) || $value instanceof Stringable) { |
| 128 | if (is_scalar($value) || $value instanceof Stringable) { |
| 128 | if (is_scalar($value) || $value instanceof Stringable) { |
| 129 | return (string) $value; |
| 132 | throw new InvalidArgumentException('Route parameter must be scalar or Stringable: ' . $name); |
| 133 | } |
| 42 | return $this->tokens; |
| 43 | } |