Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.07% covered (success)
96.07%
171 / 178
87.50% covered (success)
87.50%
35 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractMatch
96.07% covered (success)
96.07%
171 / 178
87.50% covered (success)
87.50%
35 / 40
144
0.00% covered (danger)
0.00%
0 / 1
 addRoute
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 isDynamicRouteDeclaration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 registerDynamicRoute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseDynamicRoute
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 isDynamicRouteLiteral
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 matchesDynamicRoute
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 getDynamicRouteParamOffset
40.00% covered (warning)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 registerWildcardRoute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalizeController
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 registerRegularRoute
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 addRoutes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 name
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDispatchableParams
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 appendDispatchableParams
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getDispatchableParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDispatchableParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeDispatchableParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRouteString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSegments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSegment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOriginalRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRoutes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreparedRoutes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRouteConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 getRouteConfig
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getFlattenedRoutes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getRouteParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRouteParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDefaultRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDynamicRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDynamicRoutePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDynamicRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDynamicRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDispatchable
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
18
 hasDispatchable
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
14
 getAction
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
9.08
 hasAction
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
12.08
 hasRoute
n/a
0 / 0
n/a
0 / 0
0
 prepare
n/a
0 / 0
n/a
0 / 0
0
 match
n/a
0 / 0
n/a
0 / 0
0
 noRouteFound
n/a
0 / 0
n/a
0 / 0
0
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Router\Match;
16
17use Pop\Utils\CallableObject;
18
19/**
20 * Pop router match abstract class
21 *
22 * @category   Pop
23 * @package    Pop\Router
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29abstract class AbstractMatch implements MatchInterface
30{
31
32    /**
33     * Route string
34     * @var ?string
35     */
36    protected ?string $routeString = null;
37
38    /**
39     * Segments of route string
40     * @var array
41     */
42    protected array $segments = [];
43
44    /**
45     * Matched route
46     * @var ?string
47     */
48    protected ?string $route = null;
49
50    /**
51     * Default route
52     * @var ?array
53     */
54    protected ?array $defaultRoute = null;
55
56    /**
57     * Dynamic route
58     * @var mixed
59     */
60    protected mixed $dynamicRoute = null;
61
62    /**
63     * Dynamic route prefix
64     * @var mixed
65     */
66    protected mixed $dynamicRoutePrefix = null;
67
68    /**
69     * Literal segments of the dynamic route declaration, keyed by their
70     * position within that declaration
71     * @var array
72     */
73    protected array $dynamicRouteLiterals = [];
74
75    /**
76     * Position of the ':controller'/'<controller>' token within the dynamic route declaration
77     * @var ?int
78     */
79    protected ?int $dynamicRouteControllerIndex = null;
80
81    /**
82     * Position of the ':action'/'<action>' token within the dynamic route declaration
83     * @var ?int
84     */
85    protected ?int $dynamicRouteActionIndex = null;
86
87    /**
88     * Flag for dynamic route
89     * @var bool
90     */
91    protected bool $isDynamicRoute = false;
92
93    /**
94     * Routes
95     * @var array
96     */
97    protected array $routes = [];
98
99    /**
100     * Prepared routes
101     * @var array
102     */
103    protected array $preparedRoutes = [];
104
105    /**
106     * Dispatchable parameters
107     * @var array
108     */
109    protected array $dispatchableParams = [];
110
111    /**
112     * Route parameters
113     * @var array
114     */
115    protected array $routeParams = [];
116
117    /**
118     * Route names
119     * @var array
120     */
121    protected array $routeNames = [];
122
123    /**
124     * Whether the dispatchable has been resolved for the current match cycle
125     * @var bool
126     */
127    protected bool $dispatchableResolved = false;
128
129    /**
130     * Cached dispatchable resolution for the current match cycle
131     * @var mixed
132     */
133    protected mixed $dispatchableCache = null;
134
135    /**
136     * Add a route
137     *
138     * @param  string $route
139     * @param  mixed  $controller
140     * @return AbstractMatch
141     */
142    public function addRoute(string $route, mixed $controller): AbstractMatch
143    {
144        if ($this->isDynamicRouteDeclaration($route)) {
145            $this->registerDynamicRoute($route, $controller);
146        } else if (($route == '*') || (str_ends_with($route, '/*'))) {
147            $controller = $this->registerWildcardRoute($route, $controller);
148        } else {
149            $controller = $this->registerRegularRoute($route, $controller);
150        }
151
152        if (isset($controller['name'])) {
153            $this->name($controller['name']);
154        }
155
156        if (isset($controller['params'])) {
157            $this->addDispatchableParams($controller['controller'], $controller['params']);
158        }
159
160        return $this;
161    }
162
163    /**
164     * Determine if a route declaration is the dynamic-route (":controller"/"<controller") pattern
165     *
166     * @param  string $route
167     * @return bool
168     */
169    protected function isDynamicRouteDeclaration(string $route): bool
170    {
171        return (($this instanceof Http) && str_contains($route, ':controller')) ||
172            (($this instanceof Cli) && str_contains($route, '<controller'));
173    }
174
175    /**
176     * Register a dynamic route
177     *
178     * @param  string $route
179     * @param  mixed  $controller
180     * @return void
181     */
182    protected function registerDynamicRoute(string $route, mixed $controller): void
183    {
184        $this->dynamicRoute = $route;
185        $this->parseDynamicRoute($route);
186        if (isset($controller['prefix'])) {
187            $this->dynamicRoutePrefix = $controller['prefix'];
188        }
189    }
190
191    /**
192     * Parse a dynamic route declaration, recording where its
193     * ':controller'/':action' (HTTP) or '<controller>'/'<action>' (CLI) tokens
194     * sit within the declaration, along with any literal segments the request
195     * has to match for the route to apply
196     *
197     * @param  string $route
198     * @return void
199     */
200    protected function parseDynamicRoute(string $route): void
201    {
202        $this->dynamicRouteLiterals        = [];
203        $this->dynamicRouteControllerIndex = null;
204        $this->dynamicRouteActionIndex     = null;
205
206        $tokens = ($this instanceof Http) ?
207            explode('/', trim($route, '/')) : preg_split('/\s+/', trim($route), -1, PREG_SPLIT_NO_EMPTY);
208
209        foreach ($tokens as $i => $token) {
210            $name = trim($token, '[]<>:*/ ');
211            if (($name == 'controller') && ($this->dynamicRouteControllerIndex === null)) {
212                $this->dynamicRouteControllerIndex = $i;
213            } else if (($name == 'action') && ($this->dynamicRouteActionIndex === null)) {
214                $this->dynamicRouteActionIndex = $i;
215            } else if ($this->isDynamicRouteLiteral($token)) {
216                $this->dynamicRouteLiterals[$i] = $token;
217            }
218        }
219
220        // A declaration with no explicit action token keeps the historical behavior
221        // of taking the action from the segment right after the controller
222        if (($this->dynamicRouteActionIndex === null) && ($this->dynamicRouteControllerIndex !== null)) {
223            $this->dynamicRouteActionIndex = $this->dynamicRouteControllerIndex + 1;
224        }
225    }
226
227    /**
228     * Determine if a dynamic route declaration token is a literal segment,
229     * i.e. neither a param token nor an option
230     *
231     * @param  string $token
232     * @return bool
233     */
234    protected function isDynamicRouteLiteral(string $token): bool
235    {
236        if ($token == '') {
237            return false;
238        }
239
240        return ($this instanceof Http) ?
241            !str_contains($token, ':') : (!str_contains($token, '<') && !str_starts_with($token, '-') &&
242                !str_starts_with($token, '['));
243    }
244
245    /**
246     * Determine if the current request matches the dynamic route declaration:
247     * every literal segment of the declaration has to line up with the
248     * request's segment in that same position, and the request has to carry a
249     * segment where the declaration puts its controller token
250     *
251     * @return bool
252     */
253    protected function matchesDynamicRoute(): bool
254    {
255        if (($this->dynamicRoute === null) || ($this->dynamicRouteControllerIndex === null) ||
256            !isset($this->segments[$this->dynamicRouteControllerIndex])) {
257            return false;
258        }
259
260        foreach ($this->dynamicRouteLiterals as $i => $literal) {
261            if (!isset($this->segments[$i]) || !in_array($this->segments[$i], explode('|', $literal), true)) {
262                return false;
263            }
264        }
265
266        return true;
267    }
268
269    /**
270     * Get the segment offset at which the dynamic route's params start,
271     * i.e. the segment right after the action token
272     *
273     * @return int
274     */
275    protected function getDynamicRouteParamOffset(): int
276    {
277        if ($this->dynamicRouteActionIndex !== null) {
278            return $this->dynamicRouteActionIndex + 1;
279        } else if ($this->dynamicRouteControllerIndex !== null) {
280            return $this->dynamicRouteControllerIndex + 1;
281        }
282
283        return 2;
284    }
285
286    /**
287     * Register a wildcard/default route
288     *
289     * @param  string $route
290     * @param  mixed  $controller
291     * @return mixed  the normalized controller config
292     */
293    protected function registerWildcardRoute(string $route, mixed $controller): mixed
294    {
295        $routeKey   = (str_ends_with($route, '/*')) ? substr($route, 0, -2) : $route;
296        $controller = $this->normalizeController($controller);
297        $this->defaultRoute[$routeKey] = $controller;
298
299        return $controller;
300    }
301
302    /**
303     * Normalize a raw dispatchable value (closure, native callable, or a
304     * pop-utils pseudo-callable string such as 'Class->method',
305     * 'Class::method', 'Class', or 'new Class') into a
306     * ['controller' => $controller] config array. Already-formed config
307     * arrays (and nested-route sub-arrays) are returned unchanged.
308     *
309     * @param  mixed $controller
310     * @return mixed
311     */
312    protected function normalizeController(mixed $controller): mixed
313    {
314        if (is_array($controller)) {
315            return $controller;
316        }
317
318        if ((new CallableObject($controller))->isCallable()) {
319            $controller = ['controller' => $controller];
320        }
321
322        return $controller;
323    }
324
325    /**
326     * Register a regular (literal or param-bearing) route, recursing for nested route configs
327     *
328     * @param  string $route
329     * @param  mixed  $controller
330     * @return mixed  the normalized controller config
331     */
332    protected function registerRegularRoute(string $route, mixed $controller): mixed
333    {
334        $this->routeString = urldecode($this->routeString);
335
336        // Handle nested routes
337        if (is_array($controller) && !isset($controller['controller'])) {
338            foreach ($controller as $r => $c) {
339                $fullRoute = ($r == '*') ? $route . '/*' : $route . $r;
340                $this->addRoute($fullRoute, $c);
341            }
342        } else {
343            $controller = $this->normalizeController($controller);
344
345            $this->routes[$route] = (isset($this->routes[$route])) ?
346                array_merge($this->routes[$route], $controller) : $controller;
347        }
348
349        return $controller;
350    }
351
352    /**
353     * Add multiple controller routes
354     *
355     * @param  array $routes
356     * @return AbstractMatch
357     */
358    public function addRoutes(array $routes): AbstractMatch
359    {
360        foreach ($routes as $route => $controller) {
361            $this->addRoute($route, $controller);
362        }
363
364        return $this;
365    }
366
367    /**
368     * Add a route name
369     *
370     * @param  string $routeName
371     * @throws Exception
372     * @return AbstractMatch
373     */
374    public function name(string $routeName): AbstractMatch
375    {
376        if (empty($this->routes)) {
377            throw new Exception('Error: No routes have been added to name.');
378        }
379
380        $this->routeNames[$routeName] = key(array_slice($this->routes, -1));
381        return $this;
382    }
383
384    /**
385     * Has a route name
386     *
387     * @param  string $routeName
388     * @return bool
389     */
390    public function hasName(string $routeName): bool
391    {
392        return (isset($this->routeNames[$routeName]));
393    }
394
395    /**
396     * Add dispatchable params to be passed into a new dispatchable instance
397     *
398     * @param  string $dispatchable
399     * @param  mixed  $params
400     * @return AbstractMatch
401     */
402    public function addDispatchableParams(string $dispatchable, mixed $params): AbstractMatch
403    {
404        if (!is_array($params)) {
405            $params = [$params];
406        }
407        $this->dispatchableParams[$dispatchable] = $params;
408
409        return $this;
410    }
411
412    /**
413     * Append dispatchable params to be passed into a new dispatchable instance
414     *
415     * @param  string $dispatchable
416     * @param  mixed  $params
417     * @return AbstractMatch
418     */
419    public function appendDispatchableParams(string $dispatchable, mixed $params): AbstractMatch
420    {
421        if (!is_array($params)) {
422            $params = [$params];
423        }
424        $this->dispatchableParams[$dispatchable] = (isset($this->dispatchableParams[$dispatchable])) ?
425            array_merge($this->dispatchableParams[$dispatchable], $params) : $params;
426
427        return $this;
428    }
429
430    /**
431     * Get the params assigned to the dispatchable
432     *
433     * @param  string $dispatchable
434     * @return mixed
435     */
436    public function getDispatchableParams(string $dispatchable): mixed
437    {
438        return $this->dispatchableParams[$dispatchable] ?? null;
439    }
440
441    /**
442     * Determine if the dispatchable has params
443     *
444     * @param  string $dispatchable
445     * @return bool
446     */
447    public function hasDispatchableParams(string $dispatchable): bool
448    {
449        return (isset($this->dispatchableParams[$dispatchable]));
450    }
451
452    /**
453     * Remove dispatchable params
454     *
455     * @param  string $dispatchable
456     * @return AbstractMatch
457     */
458    public function removeDispatchableParams(string $dispatchable): AbstractMatch
459    {
460        if (isset($this->dispatchableParams[$dispatchable])) {
461            unset($this->dispatchableParams[$dispatchable]);
462        }
463        return $this;
464    }
465
466    /**
467     * Get the route string
468     *
469     * @return string
470     */
471    public function getRouteString(): string
472    {
473        return $this->routeString;
474    }
475
476    /**
477     * Get the route string segments
478     *
479     * @return array
480     */
481    public function getSegments(): array
482    {
483        return $this->segments;
484    }
485
486    /**
487     * Get a route string segment
488     *
489     * @param  int $i
490     * @return ?string
491     */
492    public function getSegment(int $i): ?string
493    {
494        return $this->segments[$i] ?? null;
495    }
496
497    /**
498     * Get original route string
499     *
500     * @return ?string
501     */
502    public function getOriginalRoute(): ?string
503    {
504        return ($this->route !== null) ? ($this->preparedRoutes[$this->route]['route'] ?? null) : null;
505    }
506
507    /**
508     * Get route regex
509     *
510     * @return string
511     */
512    public function getRoute(): string
513    {
514        return $this->route;
515    }
516
517    /**
518     * Get routes
519     *
520     * @return array
521     */
522    public function getRoutes(): array
523    {
524        return $this->routes;
525    }
526
527    /**
528     * Get prepared routes
529     *
530     * @return array
531     */
532    public function getPreparedRoutes(): array
533    {
534        return $this->preparedRoutes;
535    }
536
537    /**
538     * Has route config
539     *
540     * @param  ?string $key
541     * @return bool
542     */
543    public function hasRouteConfig(?string $key = null): bool
544    {
545        if (($this->route !== null) && isset($this->preparedRoutes[$this->route])) {
546            return ((($key !== null) && isset($this->preparedRoutes[$this->route][$key])) ||
547                (($key === null) && !empty($this->preparedRoutes[$this->route])));
548        } else {
549            return false;
550        }
551    }
552
553    /**
554     * Get route config
555     *
556     * @param  ?string $key
557     * @return mixed
558     */
559    public function getRouteConfig(?string $key = null): mixed
560    {
561        if (($this->route !== null) && isset($this->preparedRoutes[$this->route])) {
562            if ($key === null) {
563                return $this->preparedRoutes[$this->route];
564            } else {
565                return $this->preparedRoutes[$this->route][$key] ?? null;
566            }
567        } else {
568            return null;
569        }
570    }
571
572    /**
573     * Get flattened routes
574     *
575     * @return array
576     */
577    public function getFlattenedRoutes(): array
578    {
579        $routes = [];
580        foreach ($this->preparedRoutes as $value) {
581            if (isset($value['route'])) {
582                $routes[$value['route']] = $value;
583                unset($routes[$value['route']]['route']);
584            }
585        }
586        return $routes;
587    }
588
589    /**
590     * Get the params discovered from the route
591     *
592     * @return array
593     */
594    public function getRouteParams(): array
595    {
596        return $this->routeParams;
597    }
598
599    /**
600     * Determine if the route has params
601     *
602     * @return bool
603     */
604    public function hasRouteParams(): bool
605    {
606        return (count($this->routeParams) > 0);
607    }
608
609    /**
610     * Get the default route
611     *
612     * @return array
613     */
614    public function getDefaultRoute(): array
615    {
616        return $this->defaultRoute;
617    }
618
619    /**
620     * Determine if there is a default route
621     *
622     * @return bool
623     */
624    public function hasDefaultRoute(): bool
625    {
626        return ($this->defaultRoute !== null);
627    }
628
629    /**
630     * Get the dynamic route
631     *
632     * @return mixed
633     */
634    public function getDynamicRoute(): mixed
635    {
636        return $this->dynamicRoute;
637    }
638
639    /**
640     * Get the dynamic route prefix
641     *
642     * @return mixed
643     */
644    public function getDynamicRoutePrefix(): mixed
645    {
646        return $this->dynamicRoutePrefix;
647    }
648
649    /**
650     * Determine if there is a dynamic route
651     *
652     * @return bool
653     */
654    public function hasDynamicRoute(): bool
655    {
656        return ($this->dynamicRoute !== null);
657    }
658
659    /**
660     * Determine if it is a dynamic route
661     *
662     * @return bool
663     */
664    public function isDynamicRoute(): bool
665    {
666        return $this->isDynamicRoute;
667    }
668
669    /**
670     * Get the dispatchable
671     *
672     * @return mixed
673     */
674    public function getDispatchable(): mixed
675    {
676        if ($this->dispatchableResolved) {
677            return $this->dispatchableCache;
678        }
679
680        $routeDispatchable = null;
681
682        if (($this->route !== null) && isset($this->preparedRoutes[$this->route]) &&
683            isset($this->preparedRoutes[$this->route]['controller'])) {
684            $routeDispatchable = $this->preparedRoutes[$this->route]['controller'];
685        } else {
686            $controllerIndex = $this->dynamicRouteControllerIndex;
687            if (($this->dynamicRoutePrefix !== null) && ($controllerIndex !== null) && $this->matchesDynamicRoute()) {
688                $routeDispatchable = $this->dynamicRoutePrefix .
689                    ucfirst(strtolower($this->segments[$controllerIndex])) . 'Controller';
690                if (!class_exists($routeDispatchable)) {
691                    $routeDispatchable    = null;
692                    $this->isDynamicRoute = false;
693                } else {
694                    $this->isDynamicRoute = true;
695                }
696            }
697            if (($routeDispatchable === null) && !empty($this->defaultRoute)) {
698                foreach ($this->defaultRoute as $routeKey => $controller) {
699                    if ($routeKey != '*') {
700                        if (str_starts_with($this->routeString, $routeKey) && isset($controller['controller'])) {
701                            $routeDispatchable = $controller['controller'];
702                        }
703                    }
704                }
705                if (($routeDispatchable === null) && isset($this->defaultRoute['*']) && isset($this->defaultRoute['*']['controller'])) {
706                    $routeDispatchable = $this->defaultRoute['*']['controller'];
707                }
708            }
709        }
710
711        $this->dispatchableResolved = true;
712        $this->dispatchableCache    = $routeDispatchable;
713
714        return $routeDispatchable;
715    }
716
717    /**
718     * Determine if there is a dispatchable
719     *
720     * @return bool
721     */
722    public function hasDispatchable(): bool
723    {
724        $result = false;
725
726        if (($this->route !== null) && isset($this->preparedRoutes[$this->route]) &&
727            isset($this->preparedRoutes[$this->route]['controller'])) {
728            $result = true;
729        } else if (($this->dynamicRoutePrefix !== null) && $this->matchesDynamicRoute()) {
730            // getDispatchable() already runs class_exists() internally and nulls out the
731            // result if the class doesn't exist, so a non-null return here already implies it exists.
732            $result = ($this->getDispatchable() !== null);
733        } else if (!empty($this->defaultRoute)) {
734            foreach ($this->defaultRoute as $routeKey => $controller) {
735                if (($routeKey != '*') && str_starts_with($this->routeString, $routeKey) && isset($controller['controller'])) {
736                    $result = true;
737                    break;
738                }
739            }
740            if ((!$result) && isset($this->defaultRoute['*']) && isset($this->defaultRoute['*']['controller'])) {
741                $result = true;
742            }
743        }
744
745        return $result;
746    }
747
748    /**
749     * Get the action
750     *
751     * @return mixed
752     */
753    public function getAction(): mixed
754    {
755        $action = null;
756
757        if (($this->route !== null) && isset($this->preparedRoutes[$this->route]) &&
758            isset($this->preparedRoutes[$this->route]['action'])) {
759            $action = $this->preparedRoutes[$this->route]['action'];
760        } else if (($this->dynamicRoutePrefix !== null) && ($this->dynamicRouteActionIndex !== null) &&
761            $this->matchesDynamicRoute()) {
762            $action = $this->segments[$this->dynamicRouteActionIndex] ?? null;
763        } else if (($this->defaultRoute !== null) && isset($this->defaultRoute['action'])) {
764            $action = $this->defaultRoute['action'];
765        }
766
767        return $action;
768    }
769
770    /**
771     * Determine if there is an action
772     *
773     * @return bool
774     */
775    public function hasAction(): bool
776    {
777        $result = false;
778
779        if (($this->route !== null) && isset($this->preparedRoutes[$this->route]) &&
780            isset($this->preparedRoutes[$this->route]['action'])) {
781            $result = true;
782        } else {
783            if (($this->dynamicRoutePrefix !== null) && $this->matchesDynamicRoute()) {
784                $dispatchable = $this->getDispatchable();
785                $action       = $this->getAction();
786                $result       = (is_string($dispatchable) || is_object($dispatchable)) && is_string($action) &&
787                    method_exists($dispatchable, $action);
788            }
789            if (!($result) && ($this->defaultRoute !== null) && isset($this->defaultRoute['action'])) {
790                $result = true;
791            }
792        }
793
794        return $result;
795    }
796
797    /**
798     * Determine if the route has been matched
799     *
800     * @return bool
801     */
802    abstract public function hasRoute(): bool;
803
804    /**
805     * Prepare the routes
806     *
807     * @return AbstractMatch
808     */
809    abstract public function prepare(): AbstractMatch;
810
811    /**
812     * Match the route
813     *
814     * @param  string|array|null $forceRoute
815     * @return bool
816     */
817    abstract public function match(mixed $forceRoute = null): bool;
818
819    /**
820     * Method to process if a route was not found
821     *
822     * @param  bool $exit
823     * @return void
824     */
825    abstract public function noRouteFound(bool $exit = true): void;
826
827}