1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Console\RouteMatcher;
11
12use Zend\Console\Exception;
13use Zend\Validator\ValidatorInterface;
14use Zend\Filter\FilterInterface;
15
16class DefaultRouteMatcher implements RouteMatcherInterface
17{
18    /**
19     * Parts of the route.
20     *
21     * @var array
22     */
23    protected $parts;
24
25    /**
26     * Default values.
27     *
28     * @var array
29     */
30    protected $defaults;
31
32    /**
33     * Parameters' name aliases.
34     *
35     * @var array
36     */
37    protected $aliases;
38
39    /**
40     * @var ValidatorInterface[]
41     */
42    protected $validators = array();
43
44    /**
45     * @var FilterInterface[]
46     */
47    protected $filters = array();
48
49    /**
50     * Class constructor
51     *
52     * @param string $route
53     * @param array $constraints
54     * @param array $defaults
55     * @param array $aliases
56     * @param array $filters
57     * @param ValidatorInterface[] $validators
58     * @throws Exception\InvalidArgumentException
59     */
60    public function __construct(
61        $route,
62        array $constraints = array(),
63        array $defaults = array(),
64        array $aliases = array(),
65        array $filters = null,
66        array $validators = null
67    ) {
68        $this->defaults = $defaults;
69        $this->constraints = $constraints;
70        $this->aliases = $aliases;
71
72        if ($filters !== null) {
73            foreach ($filters as $name => $filter) {
74                if (!$filter instanceof FilterInterface) {
75                    throw new Exception\InvalidArgumentException('Cannot use ' . gettype($filters) . ' as filter for ' . __CLASS__);
76                }
77                $this->filters[$name] = $filter;
78            }
79        }
80
81        if ($validators !== null) {
82            foreach ($validators as $name => $validator) {
83                if (!$validator instanceof ValidatorInterface) {
84                    throw new Exception\InvalidArgumentException('Cannot use ' . gettype($validator) . ' as validator for ' . __CLASS__);
85                }
86                $this->validators[$name] = $validator;
87            }
88        }
89
90        $this->parts = $this->parseDefinition($route);
91    }
92
93    /**
94     * Parse a route definition.
95     *
96     * @param  string $def
97     * @return array
98     * @throws Exception\InvalidArgumentException
99     */
100    protected function parseDefinition($def)
101    {
102        $def    = trim($def);
103        $pos    = 0;
104        $length = strlen($def);
105        $parts  = array();
106        $unnamedGroupCounter = 1;
107
108        while ($pos < $length) {
109            /**
110             * Optional value param, i.e.
111             *    [SOMETHING]
112             */
113            if (preg_match('/\G\[(?P<name>[A-Z][A-Z0-9\_\-]*?)\](?: +|$)/s', $def, $m, 0, $pos)) {
114                $item = array(
115                    'name'       => strtolower($m['name']),
116                    'literal'    => false,
117                    'required'   => false,
118                    'positional' => true,
119                    'hasValue'   => true,
120                );
121            }
122            /**
123             * Mandatory value param, i.e.
124             *   SOMETHING
125             */
126            elseif (preg_match('/\G(?P<name>[A-Z][A-Z0-9\_\-]*?)(?: +|$)/s', $def, $m, 0, $pos)) {
127                $item = array(
128                    'name'       => strtolower($m['name']),
129                    'literal'    => false,
130                    'required'   => true,
131                    'positional' => true,
132                    'hasValue'   => true,
133                );
134            }
135            /**
136             * Optional literal param, i.e.
137             *    [something]
138             */
139            elseif (preg_match('/\G\[ *?(?P<name>[a-zA-Z][a-zA-Z0-9\_\-\:]*?) *?\](?: +|$)/s', $def, $m, 0, $pos)) {
140                $item = array(
141                    'name'       => $m['name'],
142                    'literal'    => true,
143                    'required'   => false,
144                    'positional' => true,
145                    'hasValue'   => false,
146                );
147            }
148            /**
149             * Optional value param, syntax 2, i.e.
150             *    [<something>]
151             */
152            elseif (preg_match('/\G\[ *\<(?P<name>[a-zA-Z][a-zA-Z0-9\_\-]*?)\> *\](?: +|$)/s', $def, $m, 0, $pos)) {
153                $item = array(
154                    'name'       => $m['name'],
155                    'literal'    => false,
156                    'required'   => false,
157                    'positional' => true,
158                    'hasValue'   => true,
159                );
160            }
161            /**
162             * Mandatory value param, i.e.
163             *    <something>
164             */
165            elseif (preg_match('/\G\< *(?P<name>[a-zA-Z][a-zA-Z0-9\_\-]*?) *\>(?: +|$)/s', $def, $m, 0, $pos)) {
166                $item = array(
167                    'name'       => $m['name'],
168                    'literal'    => false,
169                    'required'   => true,
170                    'positional' => true,
171                    'hasValue'   => true,
172                );
173            }
174            /**
175             * Mandatory literal param, i.e.
176             *   something
177             */
178            elseif (preg_match('/\G(?P<name>[a-zA-Z][a-zA-Z0-9\_\-\:]*?)(?: +|$)/s', $def, $m, 0, $pos)) {
179                $item = array(
180                    'name'       => $m['name'],
181                    'literal'    => true,
182                    'required'   => true,
183                    'positional' => true,
184                    'hasValue'   => false,
185                );
186            }
187            /**
188             * Mandatory long param
189             *    --param=
190             *    --param=whatever
191             */
192            elseif (preg_match('/\G--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+)(?P<hasValue>=\S*?)?(?: +|$)/s', $def, $m, 0, $pos)) {
193                $item = array(
194                    'name'       => $m['name'],
195                    'short'      => false,
196                    'literal'    => false,
197                    'required'   => true,
198                    'positional' => false,
199                    'hasValue'   => !empty($m['hasValue']),
200                );
201            }
202            /**
203             * Optional long flag
204             *    [--param]
205             */
206            elseif (preg_match(
207                '/\G\[ *?--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+) *?\](?: +|$)/s', $def, $m, 0, $pos
208            )) {
209                $item = array(
210                    'name'       => $m['name'],
211                    'short'      => false,
212                    'literal'    => false,
213                    'required'   => false,
214                    'positional' => false,
215                    'hasValue'   => false,
216                );
217            }
218            /**
219             * Optional long param
220             *    [--param=]
221             *    [--param=whatever]
222             */
223            elseif (preg_match(
224                '/\G\[ *?--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+)(?P<hasValue>=\S*?)? *?\](?: +|$)/s', $def, $m, 0, $pos
225            )) {
226                $item = array(
227                    'name'       => $m['name'],
228                    'short'      => false,
229                    'literal'    => false,
230                    'required'   => false,
231                    'positional' => false,
232                    'hasValue'   => !empty($m['hasValue']),
233                );
234            }
235            /**
236             * Mandatory short param
237             *    -a
238             *    -a=i
239             *    -a=s
240             *    -a=w
241             */
242            elseif (preg_match('/\G-(?P<name>[a-zA-Z0-9])(?:=(?P<type>[ns]))?(?: +|$)/s', $def, $m, 0, $pos)) {
243                $item = array(
244                    'name'       => $m['name'],
245                    'short'      => true,
246                    'literal'    => false,
247                    'required'   => true,
248                    'positional' => false,
249                    'hasValue'  => !empty($m['type']) ? $m['type'] : null,
250                );
251            }
252            /**
253             * Optional short param
254             *    [-a]
255             *    [-a=n]
256             *    [-a=s]
257             */
258            elseif (preg_match('/\G\[ *?-(?P<name>[a-zA-Z0-9])(?:=(?P<type>[ns]))? *?\](?: +|$)/s', $def, $m, 0, $pos)) {
259                $item = array(
260                    'name'       => $m['name'],
261                    'short'      => true,
262                    'literal'    => false,
263                    'required'   => false,
264                    'positional' => false,
265                    'hasValue'  => !empty($m['type']) ? $m['type'] : null,
266                );
267            }
268            /**
269             * Optional literal param alternative
270             *    [ something | somethingElse | anotherOne ]
271             *    [ something | somethingElse | anotherOne ]:namedGroup
272             */
273            elseif (preg_match('/
274                \G
275                \[
276                    (?P<options>
277                        (?:
278                            \ *?
279                            (?P<name>[a-zA-Z][a-zA-Z0-9_\-]*?)
280                            \ *?
281                            (?:\||(?=\]))
282                            \ *?
283                        )+
284                    )
285                \]
286                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
287                (?:\ +|$)
288                /sx', $def, $m, 0, $pos
289            )
290            ) {
291                // extract available options
292                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);
293
294                // remove dupes
295                array_unique($options);
296
297                // prepare item
298                $item = array(
299                    'name'          => isset($m['groupName']) ? $m['groupName'] : 'unnamedGroup' . $unnamedGroupCounter++,
300                    'literal'       => true,
301                    'required'      => false,
302                    'positional'    => true,
303                    'alternatives'  => $options,
304                    'hasValue'      => false,
305                );
306            }
307
308            /**
309             * Required literal param alternative
310             *    ( something | somethingElse | anotherOne )
311             *    ( something | somethingElse | anotherOne ):namedGroup
312             */
313            elseif (preg_match('/
314                \G
315                \(
316                    (?P<options>
317                        (?:
318                            \ *?
319                            (?P<name>[a-zA-Z][a-zA-Z0-9_\-]+)
320                            \ *?
321                            (?:\||(?=\)))
322                            \ *?
323                        )+
324                    )
325                \)
326                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
327                (?:\ +|$)
328                /sx', $def, $m, 0, $pos
329            )) {
330                // extract available options
331                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);
332
333                // remove dupes
334                array_unique($options);
335
336                // prepare item
337                $item = array(
338                    'name'          => isset($m['groupName']) ? $m['groupName']:'unnamedGroupAt' . $unnamedGroupCounter++,
339                    'literal'       => true,
340                    'required'      => true,
341                    'positional'    => true,
342                    'alternatives'  => $options,
343                    'hasValue'      => false,
344                );
345            }
346            /**
347             * Required long/short flag alternative
348             *    ( --something | --somethingElse | --anotherOne | -s | -a )
349             *    ( --something | --somethingElse | --anotherOne | -s | -a ):namedGroup
350             */
351            elseif (preg_match('/
352                \G
353                \(
354                    (?P<options>
355                        (?:
356                            \ *?
357                            \-+(?P<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*?)
358                            \ *?
359                            (?:\||(?=\)))
360                            \ *?
361                        )+
362                    )
363                \)
364                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
365                (?:\ +|$)
366                /sx', $def, $m, 0, $pos
367            )) {
368                // extract available options
369                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);
370
371                // remove dupes
372                array_unique($options);
373
374                // remove prefix
375                array_walk($options, function (&$val) {
376                    $val = ltrim($val, '-');
377                });
378
379                // prepare item
380                $item = array(
381                    'name'          => isset($m['groupName']) ? $m['groupName']:'unnamedGroupAt' . $unnamedGroupCounter++,
382                    'literal'       => false,
383                    'required'      => true,
384                    'positional'    => false,
385                    'alternatives'  => $options,
386                    'hasValue'      => false,
387                );
388            }
389            /**
390             * Optional flag alternative
391             *    [ --something | --somethingElse | --anotherOne | -s | -a ]
392             *    [ --something | --somethingElse | --anotherOne | -s | -a ]:namedGroup
393             */
394            elseif (preg_match('/
395                \G
396                \[
397                    (?P<options>
398                        (?:
399                            \ *?
400                            \-+(?P<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*?)
401                            \ *?
402                            (?:\||(?=\]))
403                            \ *?
404                        )+
405                    )
406                \]
407                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
408                (?:\ +|$)
409                /sx', $def, $m, 0, $pos
410            )) {
411                // extract available options
412                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);
413
414                // remove dupes
415                array_unique($options);
416
417                // remove prefix
418                array_walk($options, function (&$val) {
419                    $val = ltrim($val, '-');
420                });
421
422                // prepare item
423                $item = array(
424                    'name'          => isset($m['groupName']) ? $m['groupName']:'unnamedGroupAt' . $unnamedGroupCounter++,
425                    'literal'       => false,
426                    'required'      => false,
427                    'positional'    => false,
428                    'alternatives'  => $options,
429                    'hasValue'      => false,
430                );
431            } else {
432                throw new Exception\InvalidArgumentException(
433                    'Cannot understand Console route at "' . substr($def, $pos) . '"'
434                );
435            }
436
437            $pos += strlen($m[0]);
438            $parts[] = $item;
439        }
440
441        return $parts;
442    }
443
444    /**
445     * Returns list of names representing single parameter
446     *
447     * @param string $name
448     * @return string
449     */
450    private function getAliases($name)
451    {
452        $namesToMatch = array($name);
453        foreach ($this->aliases as $alias => $canonical) {
454            if ($name == $canonical) {
455                $namesToMatch[] = $alias;
456            }
457        }
458        return $namesToMatch;
459    }
460
461    /**
462     * Returns canonical name of a parameter
463     *
464     * @param string $name
465     * @return string
466     */
467    private function getCanonicalName($name)
468    {
469        if (isset($this->aliases[$name])) {
470            return $this->aliases[$name];
471        }
472        return $name;
473    }
474
475    /**
476     * Match parameters against route passed to constructor
477     *
478     * @param array $params
479     * @return array|null
480     */
481    public function match($params)
482    {
483        $matches = array();
484
485        /*
486         * Extract positional and named parts
487         */
488        $positional = $named = array();
489        foreach ($this->parts as &$part) {
490            if ($part['positional']) {
491                $positional[] = &$part;
492            } else {
493                $named[] = &$part;
494            }
495        }
496
497        /*
498         * Scan for named parts inside Console params
499         */
500        foreach ($named as &$part) {
501            /*
502             * Prepare match regex
503             */
504            if (isset($part['alternatives'])) {
505                // an alternative of flags
506                $regex = '/^\-+(?P<name>';
507
508                $alternativeAliases = array();
509                foreach ($part['alternatives'] as $alternative) {
510                    $alternativeAliases[] = '(?:' . implode('|', $this->getAliases($alternative)) . ')';
511                }
512
513                $regex .= implode('|', $alternativeAliases);
514
515                if ($part['hasValue']) {
516                    $regex .= ')(?:\=(?P<value>.*?)$)?$/';
517                } else {
518                    $regex .= ')$/i';
519                }
520            } else {
521                // a single named flag
522                $name = '(?:' . implode('|', $this->getAliases($part['name'])) . ')';
523
524                if ($part['short'] === true) {
525                    // short variant
526                    if ($part['hasValue']) {
527                        $regex = '/^\-' . $name . '(?:\=(?P<value>.*?)$)?$/i';
528                    } else {
529                        $regex = '/^\-' . $name . '$/i';
530                    }
531                } elseif ($part['short'] === false) {
532                    // long variant
533                    if ($part['hasValue']) {
534                        $regex = '/^\-{2,}' . $name . '(?:\=(?P<value>.*?)$)?$/i';
535                    } else {
536                        $regex = '/^\-{2,}' . $name . '$/i';
537                    }
538                }
539            }
540
541            /*
542             * Look for param
543             */
544            $value = $param = null;
545            for ($x = 0, $count = count($params); $x < $count; $x++) {
546                if (preg_match($regex, $params[$x], $m)) {
547                    // found param
548                    $param = $params[$x];
549
550                    // prevent further scanning of this param
551                    array_splice($params, $x, 1);
552
553                    if (isset($m['value'])) {
554                        $value = $m['value'];
555                    }
556
557                    if (isset($m['name'])) {
558                        $matchedName = $this->getCanonicalName($m['name']);
559                    }
560
561                    break;
562                }
563            }
564
565
566            if (!$param) {
567                /*
568                 * Drop out if that was a mandatory param
569                 */
570                if ($part['required']) {
571                    return;
572                }
573
574                /*
575                 * Continue to next positional param
576                 */
577                else {
578                    continue;
579                }
580            }
581
582
583            /*
584             * Value for flags is always boolean
585             */
586            if ($param && !$part['hasValue']) {
587                $value = true;
588            }
589
590            /*
591             * Try to retrieve value if it is expected
592             */
593            if ((null === $value || "" === $value) && $part['hasValue']) {
594                if ($x < count($params)+1 && isset($params[$x])) {
595                    // retrieve value from adjacent param
596                    $value = $params[$x];
597
598                    // prevent further scanning of this param
599                    array_splice($params, $x, 1);
600                } else {
601                    // there are no more params available
602                    return;
603                }
604            }
605
606            /*
607             * Validate the value against constraints
608             */
609            if ($part['hasValue'] && isset($this->constraints[$part['name']])) {
610                if (
611                    !preg_match($this->constraints[$part['name']], $value)
612                ) {
613                    // constraint failed
614                    return;
615                }
616            }
617
618            /*
619             * Store the value
620             */
621            if ($part['hasValue']) {
622                $matches[$part['name']] = $value;
623            } else {
624                $matches[$part['name']] = true;
625            }
626
627            /*
628             * If there are alternatives, fill them
629             */
630            if (isset($part['alternatives'])) {
631                if ($part['hasValue']) {
632                    foreach ($part['alternatives'] as $alt) {
633                        if ($alt === $matchedName && !isset($matches[$alt])) {
634                            $matches[$alt] = $value;
635                        } elseif (!isset($matches[$alt])) {
636                            $matches[$alt] = null;
637                        }
638                    }
639                } else {
640                    foreach ($part['alternatives'] as $alt) {
641                        if ($alt === $matchedName && !isset($matches[$alt])) {
642                            $matches[$alt] = isset($this->defaults[$alt])? $this->defaults[$alt] : true;
643                        } elseif (!isset($matches[$alt])) {
644                            $matches[$alt] = false;
645                        }
646                    }
647                }
648            }
649        }
650
651        /*
652         * Scan for left-out flags that should result in a mismatch
653         */
654        foreach ($params as $param) {
655            if (preg_match('#^\-+#', $param)) {
656                return; // there is an unrecognized flag
657            }
658        }
659
660        /*
661         * Go through all positional params
662         */
663        $argPos = 0;
664        foreach ($positional as &$part) {
665            /*
666             * Check if param exists
667             */
668            if (!isset($params[$argPos])) {
669                if ($part['required']) {
670                    // cannot find required positional param
671                    return;
672                } else {
673                    // stop matching
674                    break;
675                }
676            }
677
678            $value = $params[$argPos];
679
680            /*
681             * Check if literal param matches
682             */
683            if ($part['literal']) {
684                if (
685                    (isset($part['alternatives']) && !in_array($value, $part['alternatives'])) ||
686                    (!isset($part['alternatives']) && $value != $part['name'])
687                ) {
688                    return;
689                }
690            }
691
692            /*
693             * Validate the value against constraints
694             */
695            if ($part['hasValue'] && isset($this->constraints[$part['name']])) {
696                if (
697                    !preg_match($this->constraints[$part['name']], $value)
698                ) {
699                    // constraint failed
700                    return;
701                }
702            }
703
704            /*
705             * Store the value
706             */
707            if ($part['hasValue']) {
708                $matches[$part['name']] = $value;
709            } elseif (isset($part['alternatives'])) {
710                // from all alternatives set matching parameter to TRUE and the rest to FALSE
711                foreach ($part['alternatives'] as $alt) {
712                    if ($alt == $value) {
713                        $matches[$alt] = isset($this->defaults[$alt])? $this->defaults[$alt] : true;
714                    } else {
715                        $matches[$alt] = false;
716                    }
717                }
718
719                // set alternatives group value
720                $matches[$part['name']] = $value;
721            } elseif (!$part['required']) {
722                // set optional parameter flag
723                $name = $part['name'];
724                $matches[$name] = isset($this->defaults[$name])? $this->defaults[$name] : true;
725            }
726
727            /*
728             * Advance to next argument
729             */
730            $argPos++;
731        }
732
733        /*
734         * Check if we have consumed all positional parameters
735         */
736        if ($argPos < count($params)) {
737            return; // there are extraneous params that were not consumed
738        }
739
740        /*
741         * Any optional flags that were not entered have value false
742         */
743        foreach ($this->parts as &$part) {
744            if (!$part['required'] && !$part['hasValue']) {
745                if (!isset($matches[$part['name']])) {
746                    $matches[$part['name']] = false;
747                }
748                // unset alternatives also should be false
749                if (isset($part['alternatives'])) {
750                    foreach ($part['alternatives'] as $alt) {
751                        if (!isset($matches[$alt])) {
752                            $matches[$alt] = false;
753                        }
754                    }
755                }
756            }
757        }
758
759        // run filters
760        foreach ($matches as $name => $value) {
761            if (isset($this->filters[$name])) {
762                $matches[$name] = $this->filters[$name]->filter($value);
763            }
764        }
765
766        // run validators
767        $valid = true;
768        foreach ($matches as $name => $value) {
769            if (isset($this->validators[$name])) {
770                $valid &= $this->validators[$name]->isValid($value);
771            }
772        }
773
774        if (!$valid) {
775            return;
776        }
777
778        return array_replace($this->defaults, $matches);
779    }
780}
781