Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.41% covered (success)
91.41%
181 / 198
73.33% covered (success)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cli
91.41% covered (success)
91.41%
181 / 198
73.33% covered (success)
73.33%
11 / 15
106.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 hasRoute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
6
 getCommands
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCommandParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCommandOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParameters
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 noRouteFound
75.00% covered (success)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 flattenRoutes
76.92% covered (success)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
8.79
 getRouteRegex
90.59% covered (success)
90.59%
77 / 85
0.00% covered (danger)
0.00%
0 / 1
27.61
 parseRouteParams
93.65% covered (success)
93.65%
59 / 63
0.00% covered (danger)
0.00%
0 / 1
39.39
1<?php
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Router\Match;
15
16/**
17 * Pop router CLI match class
18 *
19 * @category   Pop
20 * @package    Pop\Router
21 * @author     Nick Sagona, III <dev@nolainteractive.com>
22 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
23 * @license    http://www.popphp.org/license     New BSD License
24 * @version    4.2.0
25 */
26class Cli extends AbstractMatch
27{
28
29    /**
30     * Route commands
31     * @var array
32     */
33    protected array $commands = [];
34
35    /**
36     * Allowed route options
37     * @var array
38     */
39    protected array $options = [
40        'options' => [], // [-v|--verbose]
41        'values'  => [], // [-n|--name=]
42        'arrays'  => []  // [-i|--id=*]
43    ];
44
45    /**
46     * Allowed route parameters
47     * @var array
48     */
49    protected array $parameters = [];
50
51    /**
52     * Flag for all required parameters
53     * @var bool
54     */
55    protected bool $hasAllRequired = true;
56
57    /**
58     * Constructor
59     *
60     *   cmd              required command
61     *   [cmd]            optional command
62     *   cmd1|cmd2        required command with alternate options
63     *   [cmd1|cmd2]      optional command with alternate options
64     *
65     *   [-o|--option]    option flag
66     *   [-o|--option=]   option value
67     *   [-o|--option=*]  multiple option values
68     *
69     *   <param>          required parameter
70     *   [<param>]        optional parameter
71     *
72     *
73     * Instantiate the CLI match object
74     */
75    public function __construct()
76    {
77        $argv = $_SERVER['argv'];
78
79        // Trim the script name out of the arguments array
80        array_shift($argv);
81
82        $this->segments    = $argv;
83        $this->routeString = implode(' ', $argv);
84
85        return $this;
86    }
87
88    /**
89     * Prepare the routes
90     *
91     * @return static
92     */
93    public function prepare(): static
94    {
95        $this->flattenRoutes($this->routes);
96        return $this;
97    }
98
99    /**
100     * Match the route
101     *
102     * @param  mixed $forceRoute
103     * @return bool
104     */
105    public function match(mixed $forceRoute = null): bool
106    {
107        if (count($this->preparedRoutes) == 0) {
108            $this->prepare();
109        }
110
111        $routeToMatch = ($forceRoute !== null) ? $forceRoute : $this->routeString;
112
113        foreach ($this->preparedRoutes as $regex => $controller) {
114            if (preg_match($regex, $routeToMatch) != 0) {
115                $this->route = $regex;
116                break;
117            }
118        }
119
120        if (($this->route !== null) || ($this->dynamicRoute !== null)) {
121            $this->parseRouteParams();
122        }
123
124        return $this->hasRoute();
125    }
126
127    /**
128     * Determine if the route has been matched
129     *
130     * @return bool
131     */
132    public function hasRoute(): bool
133    {
134        if (($this->route !== null) && !($this->hasAllRequired)) {
135            return false;
136        }
137
138        return ((($this->route !== null) && ($this->hasAllRequired)) || (($this->dynamicRoute !== null) || ($this->defaultRoute !== null)));
139    }
140
141    /**
142     * Get the route commands
143     *
144     * @return array
145     */
146    public function getCommands(): array
147    {
148        return $this->commands;
149    }
150
151    /**
152     * Get the command parameters
153     *
154     * @return array
155     */
156    public function getCommandParameters(): array
157    {
158        return $this->parameters;
159    }
160
161    /**
162     * Get the command options
163     *
164     * @return array
165     */
166    public function getCommandOptions(): array
167    {
168        return $this->options;
169    }
170
171    /**
172     * Get the parsed route params
173     *
174     * @return array
175     */
176    public function getParameters(): array
177    {
178        $params = $this->routeParams;
179        unset($params['options']);
180        return $params;
181    }
182
183    /**
184     * Get a parsed route param
185     *
186     * @param  string $name
187     * @return mixed
188     */
189    public function getParameter(string $name): mixed
190    {
191        return $this->routeParams[$name] ?? null;
192    }
193
194    /**
195     * Get the parsed route options
196     *
197     * @return array
198     */
199    public function getOptions(): array
200    {
201        return $this->routeParams['options'] ?? [];
202    }
203
204    /**
205     * Get a parsed route option
206     *
207     * @param  string $name
208     * @return mixed
209     */
210    public function getOption(string $name): mixed
211    {
212        return $this->routeParams['options'][$name] ?? null;
213    }
214
215    /**
216     * Method to process if a route was not found
217     *
218     * @param  bool $exit
219     * @return void
220     */
221    public function noRouteFound(bool $exit = true): void
222    {
223        if ((stripos(PHP_OS, 'darwin') === false) && (stripos(PHP_OS, 'win') !== false)) {
224            $string = 'Command Not Found.';
225        } else {
226            $string  = "    \x1b[1;37m\x1b[41m                          \x1b[0m" . PHP_EOL;
227            $string .= "    \x1b[1;37m\x1b[41m    Command Not Found.    \x1b[0m" . PHP_EOL;
228            $string .= "    \x1b[1;37m\x1b[41m                          \x1b[0m";
229        }
230
231        echo PHP_EOL . $string . PHP_EOL . PHP_EOL;
232
233        if ($exit) {
234            exit(127);
235        }
236    }
237
238    /**
239     * Flatten the nested routes
240     *
241     * @param  array|string $route
242     * @param  mixed        $controller
243     * @return void
244     */
245    protected function flattenRoutes(array|string $route, mixed $controller = null): void
246    {
247        if (is_array($route)) {
248            foreach ($route as $r => $c) {
249                $this->flattenRoutes($r, $c);
250            }
251        } else if ($controller !== null) {
252            if (!isset($controller['controller'])) {
253                foreach ($controller as $r => $c) {
254                    $this->flattenRoutes($route . $r, $c);
255                }
256            } else {
257                $routeRegex = $this->getRouteRegex($route);
258                if (isset($controller['default']) && ($controller['default'])) {
259                    $this->defaultRoute['*'] = $controller;
260                }
261                $this->preparedRoutes[$routeRegex['regex']] = array_merge($controller, [
262                    'route' => $route
263                ]);
264            }
265        }
266    }
267
268    /**
269     * Get the REGEX pattern for the route string
270     *
271     * @param  string $route
272     * @return array
273     */
274    protected function getRouteRegex(string $route): array
275    {
276        $routeRegex         = '^';
277        $commands           = [];
278        $options            = [];
279        $optionValues       = [];
280        $optionValueArray   = [];
281        $requiredParameters = [];
282        $optionalParameters = [];
283
284        if (!isset($this->commands[$route])) {
285            $this->commands[$route] = [];
286        }
287
288        // Get route commands
289        if (str_contains($route, '<') || str_contains($route, '[')) {
290            $regexCommands = [];
291            preg_match_all('/[a-zA-Z0-9-_:|\p{L}]*(?=\s)/u', $route, $commands, PREG_OFFSET_CAPTURE);
292            foreach ($commands[0] as $i => $command) {
293                if (!empty($command[0])) {
294                    $regexCommands[] = $command[0];
295                    $this->commands[$route][] = $command[0];
296                }
297            }
298            if (count($regexCommands) > 0) {
299                $routeRegex .= implode(' ', $regexCommands);
300            }
301        } else {
302            $this->commands[$route] = explode(' ', $route);
303            $routeRegex            .= $route . '$';
304        }
305
306        // Get route options
307        //   [-o]
308        //   [--option]
309        //   [-o|--option]
310        //   [--option|-o]
311        preg_match_all('/\[(\-[a-zA-Z0-9]|\-\-[a-zA-Z0-9:\-_]*)(\|(\-\-[a-zA-Z0-9:\-_]*|\-[a-zA-Z0-9]))*\]/', $route, $options, PREG_OFFSET_CAPTURE);
312
313        // Get route option values
314        //   [-o|--option=]
315        //   [--option=|-o]
316        //   [--option=]
317        preg_match_all('/\[(\-[a-zA-z0-9]\|)*\-\-[a-zA-Z0-9:\-_]*=(\|\-[a-zA-z0-9])*\]/', $route, $optionValues, PREG_OFFSET_CAPTURE);
318
319        // Get route option value arrays
320        //   [-o|--option=*]
321        //   [--option=*|-o]
322        //   [--option=*]
323        preg_match_all('/\[(\-[a-zA-z0-9]\|)*\-\-[a-zA-Z0-9:\-_]*=\*(\|\-[a-zA-z0-9])*\]/', $route, $optionValueArray, PREG_OFFSET_CAPTURE);
324
325        // Get route required parameters <param>
326        preg_match_all('/(?<!\[)<[a-zA-Z0-9-_:|]*>/', $route, $requiredParameters, PREG_OFFSET_CAPTURE);
327
328        // Get route optional parameters [<param>]
329        preg_match_all('/\[<[a-zA-Z0-9-_:|]*>\]/', $route, $optionalParameters, PREG_OFFSET_CAPTURE);
330
331        $routeRegex .= (isset($requiredParameters[0]) && isset($requiredParameters[0][0])) ? ' (.*)$' : '(.*)$';
332
333        foreach ($options[0] as $option) {
334            if (str_contains($option[0], '--')) {
335                $name = substr($option[0], (strpos($option[0], '--') + 2));
336                $name = substr($name, 0, strpos($name, ']'));
337                if (str_contains($name, '|')) {
338                    $name = substr($name, 0, strpos($name, '|'));
339                }
340            } else {
341                $name = substr($option[0], (strpos($option[0], '-') + 1));
342                $name = (str_contains($name, '|')) ? substr($name, 0, strpos($name, '|')) : substr($name, 0, strpos($name, ']'));
343            }
344            if (!isset($this->options['options'][$route])) {
345                $this->options['options'][$route] = [];
346            }
347            $this->options['options'][$route][$name] = '/' . str_replace(['[', ']'], ['(', ')'], $option[0]) . '/';
348        }
349
350        foreach ($optionValues[0] as $option) {
351            $opt = str_replace(['[', ']'], ['', ''], $option[0]);
352            if (str_contains($option[0], '--')) {
353                $name = substr($option[0], (strpos($option[0], '--') + 2));
354                $name = substr($name, 0, strpos($name, '='));
355            } else {
356                $name = substr($option[0], (strpos($option[0], '-') + 1));
357                $name = substr($name, 0, 1);
358            }
359            if (str_contains($opt, '|')) {
360                [$opt1, $opt2] = explode('|', $opt);
361                $optionRegex   = '(' . $opt1 . '[a-zA-Z0-9-_:|.@,\/]+|' . $opt1 . '"(.*)"|' . $opt2 .
362                    '[a-zA-Z0-9-_:|.@,\/]+|' . $opt2 . '"(.*)")';
363            } else {
364                $optionRegex = '(' . $opt . '[a-zA-Z0-9-_:|.@,\/]+|' . $opt . '"(.*)")';
365            }
366            if (!isset($this->options['values'][$route])) {
367                $this->options['values'][$route] = [];
368            }
369            $this->options['values'][$route][$name] = '/' . $optionRegex . '/';
370        }
371
372        foreach ($optionValueArray[0] as $option) {
373            $opt = str_replace(['[', ']', '*'], ['', '', ''], $option[0]);
374            if (str_contains($option[0], '--')) {
375                $name = substr($option[0], (strpos($option[0], '--') + 2));
376                $name = substr($name, 0, strpos($name, '='));
377            } else {
378                $name = substr($option[0], (strpos($option[0], '-') + 1));
379                $name = substr($name, 0, 1);
380            }
381            if (str_contains($opt, '|')) {
382                [$opt1, $opt2] = explode('|', $opt);
383                $optionRegex   = '(' . $opt1 . '[a-zA-Z0-9-_:|.@,\/]+|' . $opt1 . '"(.*)"|' . $opt2 .
384                    '[a-zA-Z0-9-_:|.@,\/]+|' . $opt2 . '"(.*)")';
385            } else {
386                $optionRegex = '(' . $opt . '[a-zA-Z0-9-_:|.@,\/]+|' . $opt . '"(.*)")';
387            }
388            if (!isset($this->options['arrays'][$route])) {
389                $this->options['arrays'][$route] = [];
390            }
391            $this->options['arrays'][$route][$name] = '/' . $optionRegex . '/';
392        }
393
394        foreach ($requiredParameters[0] as $i => $parameter) {
395            if (!isset($this->parameters[$route])) {
396                $this->parameters[$route] = [];
397            }
398            $this->parameters[$route][substr($parameter[0], 1, -1)] = [
399                'position' => ($i + 1),
400                'required' => true
401            ];
402        }
403
404        $cur = (isset($this->parameters[$route])) ? count($this->parameters[$route]) : 0;
405
406        foreach ($optionalParameters[0] as $j => $parameter) {
407            if (!isset($this->parameters[$route])) {
408                $this->parameters[$route] = [];
409            }
410            $this->parameters[$route][substr($parameter[0], 2, -2)] = [
411                'position' => ($j + 1 + $cur),
412                'required' => false
413            ];
414        }
415
416        return [
417            'regex' => '/' . $routeRegex . '/'
418        ];
419    }
420
421    /**
422     * Parse route dispatch parameters
423     *
424     * @return void
425     */
426    protected function parseRouteParams(): void
427    {
428        if (($this->dynamicRoute !== null) && (count($this->segments) >= 3)) {
429            $this->routeParams = (str_contains($this->dynamicRoute, 'param*')) ?
430                [array_slice($this->segments, 2)] : array_slice($this->segments, 2);
431        } else {
432            $options = [];
433            $start   = 0;
434            $route   = $this->preparedRoutes[$this->route]['route'];
435            if (isset($this->options['options'][$route])) {
436                foreach ($this->options['options'][$route] as $option => $regex) {
437                    $match = [];
438                    preg_match($regex, $this->routeString, $match);
439                    if (isset($match[0]) && !empty($match[0])) {
440                        $options[$option] = true;
441                        if (array_search($match[0], $this->segments) > $start) {
442                            $start = array_search($match[0], $this->segments);
443                        }
444                    }
445                }
446            }
447
448            if (isset($this->options['values'][$route])) {
449                foreach ($this->options['values'][$route] as $option => $regex) {
450                    $match = [];
451                    $value = null;
452                    preg_match($regex, $this->routeString, $match);
453                    if (isset($match[0]) && !empty($match[0])) {
454                        if (str_contains($match[0], '=')) {
455                            $value = substr($match[0], (strpos($match[0], '=') + 1));
456                        } else if ((str_starts_with($match[0], '-')) && (substr($match[0], 1, 1) != '-') && !str_contains($match[0], $option)) {
457                            $value = substr($match[0], 2);
458                        }
459                        $options[$option] = $value;
460                        if (array_search($match[0], $this->segments) > $start) {
461                            $start = array_search($match[0], $this->segments);
462                        }
463                    }
464                }
465            }
466
467            if (isset($this->options['arrays'][$route])) {
468                foreach ($this->options['arrays'][$route] as $option => $regex) {
469                    $matches = [];
470                    $values  = [];
471                    preg_match_all($regex, $this->routeString, $matches);
472                    if (isset($matches[0]) && !empty($matches[0])) {
473                        foreach ($matches[0] as $match) {
474                            $value = null;
475                            if (str_contains($match, '=')) {
476                                $value = substr($match, (strpos($match, '=') + 1));
477                            } else if ((str_starts_with($match, '-')) && (substr($match, 1, 1) != '-') && !str_contains($match, $option)) {
478                                $value = substr($match, 2);
479                            }
480                            $values[] = $value;
481                            if (array_search($match, $this->segments) > $start) {
482                                $start = array_search($match, $this->segments);
483                            }
484                        }
485                    }
486                    if (count($values) > 0) {
487                        $options[$option] = $values;
488                    }
489                }
490            }
491
492            if (isset($this->parameters[$route])) {
493                // Filter out commands and options from route segments, leaving only potential parameters
494                $paramSegments = $this->segments;
495                if (isset($this->commands[$route]) && is_array($this->commands[$route])) {
496                    foreach ($this->commands[$route] as $command) {
497                        if (in_array($command, $paramSegments)) {
498                            unset($paramSegments[array_search($command, $paramSegments)]);
499                        }
500                    }
501                }
502                $paramSegments = array_values(array_filter($paramSegments, function($value) {
503                    return !str_starts_with($value, '-');
504                }));
505
506                $i = 0;
507
508                foreach ($this->parameters[$route] as $name => $parameter) {
509                    if (isset($paramSegments[$i])) {
510                        $this->routeParams[$name] = $paramSegments[$i];
511                        $i++;
512                    } else {
513                        $this->routeParams[$name] = null;
514                    }
515
516                    if (($parameter['required']) && ($this->routeParams[$name] === null)) {
517                        $this->hasAllRequired = false;
518                    }
519                }
520            }
521
522            if (!empty($options)) {
523                $this->routeParams['options'] = $options;
524            }
525        }
526    }
527
528}