Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
259 / 259
100.00% covered (success)
100.00%
49 / 49
CRAP
100.00% covered (success)
100.00%
1 / 1
Router
100.00% covered (success)
100.00%
259 / 259
100.00% covered (success)
100.00%
49 / 49
94
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
2
 __get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getResponse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultRouteActionMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultRouteActionMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultRouteNotFound
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 setDefaultRouteNotFound
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRouteNotFound
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addPlaceholder
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getPlaceholders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replacePlaceholders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fillPlaceholders
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 serve
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 addServedCollection
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 addCollection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCollections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedCollection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedCollection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedRoute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedPathArguments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedPathArguments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMatchedOrigin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedOrigin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchedOriginArguments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchedOriginArguments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 makeMatchedRoute
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 makePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAlternativeRoute
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 matchCollection
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 matchRoute
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 setAutoOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isAutoOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAutoMethods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isAutoMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRouteWithAllowHeader
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 getAllowedMethods
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 getNamedRoute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 hasNamedRoute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getRoutes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 jsonSerialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDebugCollector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Routing Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Routing;
11
12use Closure;
13use Framework\HTTP\Method;
14use Framework\HTTP\Request;
15use Framework\HTTP\Response;
16use Framework\HTTP\ResponseHeader;
17use Framework\HTTP\Status;
18use Framework\Language\Language;
19use Framework\Routing\Debug\RoutingCollector;
20use InvalidArgumentException;
21use JetBrains\PhpStorm\Pure;
22use OutOfBoundsException;
23use RuntimeException;
24
25/**
26 * Class Router.
27 *
28 * @package routing
29 */
30class Router implements \JsonSerializable
31{
32    protected string $defaultRouteActionMethod = 'index';
33    protected Closure | string $defaultRouteNotFound;
34    /**
35     * @var array<string,string>
36     */
37    protected static array $placeholders = [
38        '{alpha}' => '([a-zA-Z]+)',
39        '{alphanum}' => '([a-zA-Z0-9]+)',
40        '{any}' => '(.*)',
41        '{hex}' => '([[:xdigit:]]+)',
42        '{int}' => '([0-9]{1,18}+)',
43        '{md5}' => '([a-f0-9]{32}+)',
44        '{num}' => '([0-9]+)',
45        '{port}' => '([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])',
46        '{scheme}' => '(https?)',
47        '{segment}' => '([^/]+)',
48        '{slug}' => '([a-z0-9_-]+)',
49        '{subdomain}' => '([^.]+)',
50        //'{subdomain}' => '([A-Za-z0-9](?:[a-zA-Z0-9\-]{0,61}[A-Za-z0-9])?)',
51        '{title}' => '([a-zA-Z0-9_-]+)',
52        '{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}+)',
53    ];
54    /**
55     * @var array<int,RouteCollection>
56     */
57    protected array $collections = [];
58    protected ?RouteCollection $matchedCollection = null;
59    protected ?Route $matchedRoute = null;
60    protected ?string $matchedOrigin = null;
61    /**
62     * @var array<int,string>
63     */
64    protected array $matchedOriginArguments = [];
65    protected ?string $matchedPath = null;
66    /**
67     * @var array<int,string>
68     */
69    protected array $matchedPathArguments = [];
70    protected bool $autoOptions = false;
71    protected bool $autoMethods = false;
72    protected Response $response;
73    protected Language $language;
74    protected RoutingCollector $debugCollector;
75
76    /**
77     * Router constructor.
78     *
79     * @param Response $response
80     * @param Language|null $language
81     */
82    public function __construct(Response $response, Language $language = null)
83    {
84        $this->response = $response;
85        if ($language) {
86            $this->setLanguage($language);
87        }
88    }
89
90    public function __get(string $property) : mixed
91    {
92        if (\property_exists($this, $property)) {
93            return $this->{$property} ?? null;
94        }
95        throw new OutOfBoundsException(
96            'Property not exists: ' . static::class . '::$' . $property
97        );
98    }
99
100    /**
101     * Gets the HTTP Response instance.
102     *
103     * @return Response
104     */
105    #[Pure]
106    public function getResponse() : Response
107    {
108        return $this->response;
109    }
110
111    public function setLanguage(Language $language = null) : static
112    {
113        $this->language = $language ?? new Language();
114        $this->language->addDirectory(__DIR__ . '/Languages');
115        return $this;
116    }
117
118    public function getLanguage() : Language
119    {
120        if ( ! isset($this->language)) {
121            $this->setLanguage();
122        }
123        return $this->language;
124    }
125
126    /**
127     * Gets the default route action method.
128     *
129     * Normally, it is "index".
130     *
131     * @see Router::setDefaultRouteActionMethod()
132     *
133     * @return string
134     */
135    #[Pure]
136    public function getDefaultRouteActionMethod() : string
137    {
138        return $this->defaultRouteActionMethod;
139    }
140
141    /**
142     * Set the class method name to be called when a Route action is set without
143     * a method.
144     *
145     * @param string $action
146     *
147     * @return static
148     */
149    public function setDefaultRouteActionMethod(string $action) : static
150    {
151        $this->defaultRouteActionMethod = $action;
152        return $this;
153    }
154
155    protected function getDefaultRouteNotFound() : Route
156    {
157        return (new Route(
158            $this,
159            $this->getMatchedOrigin(),
160            $this->getMatchedPath(),
161            $this->defaultRouteNotFound ?? function () {
162                $this->response->setStatus(Status::NOT_FOUND);
163                if ($this->response->getRequest()->isJson()) {
164                    return $this->response->setJson([
165                        'status' => [
166                            'code' => Status::NOT_FOUND,
167                            'reason' => Status::getReason(Status::NOT_FOUND),
168                        ],
169                    ]);
170                }
171                $language = $this->getLanguage();
172                $lang = $language->getCurrentLocale();
173                $dir = $language->getCurrentLocaleDirection();
174                $title = $language->render('routing', 'error404');
175                $message = $language->render('routing', 'pageNotFound');
176                return $this->response->setBody(
177                    <<<HTML
178                        <!doctype html>
179                        <html lang="{$lang}" dir="{$dir}">
180                        <head>
181                            <meta charset="utf-8">
182                            <meta name="viewport" content="width=device-width, initial-scale=1">
183                            <title>{$title}</title>
184                            <style>
185                                body {
186                                    background: #fff;
187                                    color: #000;
188                                    font-family: Arial, Helvetica, sans-serif;
189                                    font-size: 1.2rem;
190                                    line-height: 1.5rem;
191                                    margin: 1rem;
192                                }
193                            </style>
194                        </head>
195                        <body>
196                        <h1>{$title}</h1>
197                        <p>{$message}</p>
198                        </body>
199                        </html>
200
201                        HTML
202                );
203            }
204        ))->setName('not-found');
205    }
206
207    /**
208     * Sets the Default Route Not Found action.
209     *
210     * @param Closure|string $action the function to run when no Route path is found
211     *
212     * @return static
213     */
214    public function setDefaultRouteNotFound(Closure | string $action) : static
215    {
216        $this->defaultRouteNotFound = $action;
217        return $this;
218    }
219
220    /**
221     * Gets the Route Not Found.
222     *
223     * Must be called after {@see Router::match()} and will return the Route
224     * Not Found from the matched collection or the Default Route Not Found
225     * from the router.
226     *
227     * @see RouteCollection::notFound()
228     * @see Router::setDefaultRouteNotFound()
229     *
230     * @return Route
231     */
232    public function getRouteNotFound() : Route
233    {
234        // @phpstan-ignore-next-line
235        return $this->getMatchedCollection()?->getRouteNotFound()
236            ?? $this->getDefaultRouteNotFound();
237    }
238
239    /**
240     * Adds Router placeholders.
241     *
242     * @param array<string,string>|string $placeholder
243     * @param string|null $pattern
244     *
245     * @return static
246     */
247    public function addPlaceholder(array | string $placeholder, string $pattern = null) : static
248    {
249        if (\is_array($placeholder)) {
250            foreach ($placeholder as $key => $value) {
251                static::$placeholders['{' . $key . '}'] = $value;
252            }
253            return $this;
254        }
255        static::$placeholders['{' . $placeholder . '}'] = $pattern;
256        return $this;
257    }
258
259    /**
260     * Gets all Router placeholders.
261     *
262     * @return array<string,string>
263     */
264    #[Pure]
265    public function getPlaceholders() : array
266    {
267        return static::$placeholders;
268    }
269
270    /**
271     * Replaces string placeholders with patterns or patterns with placeholders.
272     *
273     * @param string $string The string with placeholders or patterns
274     * @param bool $flip Set true to replace patterns with placeholders
275     *
276     * @return string
277     */
278    #[Pure]
279    public function replacePlaceholders(
280        string $string,
281        bool $flip = false
282    ) : string {
283        $placeholders = $this->getPlaceholders();
284        if ($flip) {
285            $placeholders = \array_flip($placeholders);
286        }
287        return \strtr($string, $placeholders);
288    }
289
290    /**
291     * Fills argument values into a string with placeholders.
292     *
293     * @param string $string The input string
294     * @param string ...$arguments Values to fill the string placeholders
295     *
296     * @throws InvalidArgumentException if param not required, empty or invalid
297     * @throws RuntimeException if a pattern position is not found
298     *
299     * @return string The string with argument values in place of placeholders
300     */
301    public function fillPlaceholders(string $string, string ...$arguments) : string
302    {
303        $string = $this->replacePlaceholders($string);
304        \preg_match_all('#\(([^)]+)\)#', $string, $matches);
305        if (empty($matches[0])) {
306            if ($arguments) {
307                throw new InvalidArgumentException(
308                    'String has no placeholders. Arguments not required'
309                );
310            }
311            return $string;
312        }
313        foreach ($matches[0] as $index => $pattern) {
314            if ( ! isset($arguments[$index])) {
315                throw new InvalidArgumentException("Placeholder argument is not set: {$index}");
316            }
317            if ( ! \preg_match('#' . $pattern . '#', $arguments[$index])) {
318                throw new InvalidArgumentException("Placeholder argument is invalid: {$index}");
319            }
320            $string = \substr_replace(
321                $string,
322                $arguments[$index],
323                \strpos($string, $pattern), // @phpstan-ignore-line
324                \strlen($pattern)
325            );
326        }
327        return $string;
328    }
329
330    /**
331     * Serves a RouteCollection to a specific Origin.
332     *
333     * @param string|null $origin URL Origin. A string in the following format:
334     * `{scheme}://{hostname}[:{port}]`. Null to auto-detect.
335     * @param callable $callable A function receiving an instance of RouteCollection
336     * as the first parameter
337     * @param string|null $collectionName The RouteCollection name
338     *
339     * @return static
340     */
341    public function serve(?string $origin, callable $callable, string $collectionName = null) : static
342    {
343        if (isset($this->debugCollector)) {
344            $start = \microtime(true);
345            $this->addServedCollection($origin, $callable, $collectionName);
346            $end = \microtime(true);
347            $this->debugCollector->addData([
348                'type' => 'serve',
349                'start' => $start,
350                'end' => $end,
351                'collectionId' => \spl_object_id(
352                    $this->collections[\array_key_last($this->collections)]
353                ),
354            ]);
355            return $this;
356        }
357        return $this->addServedCollection($origin, $callable, $collectionName);
358    }
359
360    protected function addServedCollection(
361        ?string $origin,
362        callable $callable,
363        string $collectionName = null
364    ) : static {
365        if ($origin === null) {
366            $origin = $this->response->getRequest()->getUrl()->getOrigin();
367        }
368        $collection = new RouteCollection($this, $origin, $collectionName);
369        $callable($collection);
370        $this->addCollection($collection);
371        return $this;
372    }
373
374    /**
375     * @param RouteCollection $collection
376     *
377     * @return static
378     */
379    protected function addCollection(RouteCollection $collection) : static
380    {
381        $this->collections[] = $collection;
382        return $this;
383    }
384
385    /**
386     * Gets all Route Collections.
387     *
388     * @return array<int,RouteCollection>
389     */
390    #[Pure]
391    public function getCollections() : array
392    {
393        return $this->collections;
394    }
395
396    /**
397     * Gets the matched Route Collection.
398     *
399     * Note: Will return null if no URL Origin was matched in a Route Collection
400     *
401     * @return RouteCollection|null
402     */
403    #[Pure]
404    public function getMatchedCollection() : ?RouteCollection
405    {
406        return $this->matchedCollection;
407    }
408
409    protected function setMatchedCollection(RouteCollection $matchedCollection) : static
410    {
411        $this->matchedCollection = $matchedCollection;
412        return $this;
413    }
414
415    /**
416     * Gets the matched Route.
417     *
418     * @return Route|null
419     */
420    #[Pure]
421    public function getMatchedRoute() : ?Route
422    {
423        return $this->matchedRoute;
424    }
425
426    /**
427     * @param Route $route
428     *
429     * @return static
430     */
431    protected function setMatchedRoute(Route $route) : static
432    {
433        $this->matchedRoute = $route;
434        return $this;
435    }
436
437    /**
438     * Gets the matched URL Path.
439     *
440     * @return string|null
441     */
442    #[Pure]
443    public function getMatchedPath() : ?string
444    {
445        return $this->matchedPath;
446    }
447
448    /**
449     * @param string $path
450     *
451     * @return static
452     */
453    protected function setMatchedPath(string $path) : static
454    {
455        $this->matchedPath = $path;
456        return $this;
457    }
458
459    /**
460     * Gets the matched URL Path arguments.
461     *
462     * @return array<int,string>
463     */
464    #[Pure]
465    public function getMatchedPathArguments() : array
466    {
467        return $this->matchedPathArguments;
468    }
469
470    /**
471     * @param array<int,string> $arguments
472     *
473     * @return static
474     */
475    protected function setMatchedPathArguments(array $arguments) : static
476    {
477        $this->matchedPathArguments = $arguments;
478        return $this;
479    }
480
481    /**
482     * Gets the matched URL.
483     *
484     * Note: This method does not return the URL query. If it is needed, get
485     * with {@see Request::getUrl()}.
486     *
487     * @return string|null
488     */
489    #[Pure]
490    public function getMatchedUrl() : ?string
491    {
492        return $this->getMatchedOrigin()
493            ? $this->getMatchedOrigin() . $this->getMatchedPath()
494            : null;
495    }
496
497    /**
498     * Gets the matched URL Origin.
499     *
500     * @return string|null
501     */
502    #[Pure]
503    public function getMatchedOrigin() : ?string
504    {
505        return $this->matchedOrigin;
506    }
507
508    /**
509     * @param string $origin
510     *
511     * @return static
512     */
513    protected function setMatchedOrigin(string $origin) : static
514    {
515        $this->matchedOrigin = $origin;
516        return $this;
517    }
518
519    /**
520     * Gets the matched URL Origin arguments.
521     *
522     * @return array<int,string>
523     */
524    #[Pure]
525    public function getMatchedOriginArguments() : array
526    {
527        return $this->matchedOriginArguments;
528    }
529
530    /**
531     * @param array<int,string> $arguments
532     *
533     * @return static
534     */
535    protected function setMatchedOriginArguments(array $arguments) : static
536    {
537        $this->matchedOriginArguments = $arguments;
538        return $this;
539    }
540
541    /**
542     * Match HTTP Method and URL against RouteCollections to process the request.
543     *
544     * @see Router::serve()
545     *
546     * @return Route Always returns a Route, even if it is the Route Not Found
547     */
548    public function match() : Route
549    {
550        if (isset($this->debugCollector)) {
551            $start = \microtime(true);
552            $route = $this->makeMatchedRoute();
553            $end = \microtime(true);
554            $this->debugCollector->addData([
555                'type' => 'match',
556                'start' => $start,
557                'end' => $end,
558            ]);
559            return $route;
560        }
561        return $this->makeMatchedRoute();
562    }
563
564    protected function makeMatchedRoute() : Route
565    {
566        $method = $this->response->getRequest()->getMethod();
567        if ($method === 'HEAD') {
568            $method = 'GET';
569        }
570        $url = $this->response->getRequest()->getUrl();
571        $path = $this->makePath($url->getPath());
572        $this->setMatchedPath($path);
573        $this->setMatchedOrigin($url->getOrigin());
574        $this->matchedCollection = $this->matchCollection($url->getOrigin());
575        if ( ! $this->matchedCollection) {
576            return $this->matchedRoute = $this->getDefaultRouteNotFound();
577        }
578        return $this->matchedRoute = $this->matchRoute(
579            $method,
580            $this->matchedCollection,
581            $path
582        ) ?? $this->getAlternativeRoute($method, $this->matchedCollection);
583    }
584
585    /**
586     * Creates a path without a trailing slash to be able to match both with and
587     * without a slash at the end.
588     *
589     * @since 3.4.3
590     *
591     * @param string $path
592     *
593     * @return string
594     */
595    protected function makePath(string $path) : string
596    {
597        return '/' . \trim($path, '/');
598    }
599
600    protected function getAlternativeRoute(string $method, RouteCollection $collection) : Route
601    {
602        if ($method === 'OPTIONS' && $this->isAutoOptions()) {
603            $route = $this->getRouteWithAllowHeader($collection, Status::OK);
604        } elseif ($this->isAutoMethods()) {
605            $route = $this->getRouteWithAllowHeader(
606                $collection,
607                Status::METHOD_NOT_ALLOWED
608            );
609        }
610        if ( ! isset($route)) {
611            // @phpstan-ignore-next-line
612            $route = $collection->getRouteNotFound() ?? $this->getDefaultRouteNotFound();
613        }
614        return $route;
615    }
616
617    protected function matchCollection(string $origin) : ?RouteCollection
618    {
619        foreach ($this->getCollections() as $collection) {
620            $pattern = $this->replacePlaceholders($collection->origin);
621            $matched = \preg_match(
622                '#^' . $pattern . '$#',
623                $origin,
624                $matches
625            );
626            if ($matched) {
627                $this->setMatchedOrigin($matches[0]);
628                unset($matches[0]);
629                $this->setMatchedOriginArguments(\array_values($matches));
630                return $collection;
631            }
632        }
633        return null;
634    }
635
636    protected function matchRoute(
637        string $method,
638        RouteCollection $collection,
639        string $path
640    ) : ?Route {
641        $routes = $collection->routes;
642        if (empty($routes[$method])) {
643            return null;
644        }
645        foreach ($routes[$method] as $route) {
646            $pattern = $this->replacePlaceholders($route->getPath());
647            $matched = \preg_match(
648                '#^' . $pattern . '$#',
649                $path,
650                $matches
651            );
652            if ($matched) {
653                unset($matches[0]);
654                $this->setMatchedPathArguments(\array_values($matches));
655                $route->setActionArguments($this->getMatchedPathArguments());
656                return $route;
657            }
658        }
659        return null;
660    }
661
662    /**
663     * Enable/disable the feature of auto-detect and show HTTP allowed methods
664     * via the Allow header when the Request has the OPTIONS method.
665     *
666     * @param bool $enabled true to enable, false to disable
667     *
668     * @see Method::OPTIONS
669     * @see ResponseHeader::ALLOW
670     *
671     * @return static
672     */
673    public function setAutoOptions(bool $enabled = true) : static
674    {
675        $this->autoOptions = $enabled;
676        return $this;
677    }
678
679    /**
680     * Tells if auto options is enabled.
681     *
682     * @see Router::setAutoOptions()
683     *
684     * @return bool
685     */
686    #[Pure]
687    public function isAutoOptions() : bool
688    {
689        return $this->autoOptions;
690    }
691
692    /**
693     * Enable/disable the feature of auto-detect and show HTTP allowed methods
694     * via the Allow header when a route with the requested method does not exist.
695     *
696     * A response with code 405 "Method Not Allowed" will trigger.
697     *
698     * @param bool $enabled true to enable, false to disable
699     *
700     * @see Status::METHOD_NOT_ALLOWED
701     * @see ResponseHeader::ALLOW
702     *
703     * @return static
704     */
705    public function setAutoMethods(bool $enabled = true) : static
706    {
707        $this->autoMethods = $enabled;
708        return $this;
709    }
710
711    /**
712     * Tells if auto methods is enabled.
713     *
714     * @see Router::setAutoMethods()
715     *
716     * @return bool
717     */
718    #[Pure]
719    public function isAutoMethods() : bool
720    {
721        return $this->autoMethods;
722    }
723
724    protected function getRouteWithAllowHeader(RouteCollection $collection, int $code) : ?Route
725    {
726        $allowed = $this->getAllowedMethods($collection);
727        $response = $this->response;
728        return empty($allowed)
729            ? null
730            : (new Route(
731                $this,
732                $this->getMatchedOrigin(),
733                $this->getMatchedPath(),
734                static function () use ($allowed, $code, $response) : void {
735                    $response->setStatus($code);
736                    $response->setHeader('Allow', \implode(', ', $allowed));
737                }
738            ))->setName('auto-allow-' . $code);
739    }
740
741    /**
742     * @param RouteCollection $collection
743     *
744     * @return array<int,string>
745     */
746    protected function getAllowedMethods(RouteCollection $collection) : array
747    {
748        $allowed = [];
749        foreach ($collection->routes as $method => $routes) {
750            foreach ($routes as $route) {
751                $pattern = $this->replacePlaceholders($route->getPath());
752                $matched = \preg_match(
753                    '#^' . $pattern . '$#',
754                    $this->getMatchedPath()
755                );
756                if ($matched) {
757                    $allowed[] = $method;
758                    continue 2;
759                }
760            }
761        }
762        if ($allowed) {
763            if (\in_array('GET', $allowed, true)) {
764                $allowed[] = 'HEAD';
765            }
766            if ($this->isAutoOptions()) {
767                $allowed[] = 'OPTIONS';
768            }
769            $allowed = \array_unique($allowed);
770            \sort($allowed);
771        }
772        return $allowed;
773    }
774
775    /**
776     * Gets a named route.
777     *
778     * @param string $name
779     *
780     * @throws RuntimeException if named route not found
781     *
782     * @return Route
783     */
784    public function getNamedRoute(string $name) : Route
785    {
786        foreach ($this->getCollections() as $collection) {
787            foreach ($collection->routes as $routes) {
788                foreach ($routes as $route) {
789                    if ($route->getName() === $name) {
790                        return $route;
791                    }
792                }
793            }
794        }
795        throw new RuntimeException('Named route not found: ' . $name);
796    }
797
798    /**
799     * Tells if it has a named route.
800     *
801     * @param string $name
802     *
803     * @return bool
804     */
805    #[Pure]
806    public function hasNamedRoute(
807        string $name
808    ) : bool {
809        foreach ($this->getCollections() as $collection) {
810            foreach ($collection->routes as $routes) {
811                foreach ($routes as $route) {
812                    if ($route->getName() === $name) {
813                        return true;
814                    }
815                }
816            }
817        }
818        return false;
819    }
820
821    /**
822     * Gets all routes, except the not found.
823     *
824     * @return array<string,Route[]> The HTTP Methods as keys and its Routes as
825     * values
826     */
827    #[Pure]
828    public function getRoutes() : array
829    {
830        $result = [];
831        foreach ($this->getCollections() as $collection) {
832            foreach ($collection->routes as $method => $routes) {
833                foreach ($routes as $route) {
834                    $result[$method][] = $route;
835                }
836            }
837        }
838        return $result;
839    }
840
841    /**
842     * @return array<string,mixed>
843     */
844    public function jsonSerialize() : array
845    {
846        return [
847            'matched' => $this->getMatchedRoute(),
848            'collections' => $this->getCollections(),
849            'isAutoMethods' => $this->isAutoMethods(),
850            'isAutoOptions' => $this->isAutoOptions(),
851            'placeholders' => $this->getPlaceholders(),
852        ];
853    }
854
855    public function setDebugCollector(RoutingCollector $debugCollector) : static
856    {
857        $this->debugCollector = $debugCollector;
858        $this->debugCollector->setRouter($this);
859        return $this;
860    }
861
862    public function getDebugCollector() : ?RoutingCollector
863    {
864        return $this->debugCollector ?? null;
865    }
866}