1<?php
2/**
3 * Horde Routes package
4 *
5 * This package is heavily inspired by the Python "Routes" library
6 * by Ben Bangert (http://routes.groovie.org).  Routes is based
7 * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
8 *
9 * @author  Maintainable Software, LLC. (http://www.maintainable.com)
10 * @author  Mike Naberezny <mike@maintainable.com>
11 * @license http://www.horde.org/licenses/bsd BSD
12 * @package Routes
13 */
14
15/**
16 * The Route object holds a route recognition and generation routine.
17 * See __construct() docs for usage.
18 *
19 * @package Routes
20 */
21class Horde_Routes_Route
22{
23    /**
24     * The path for this route, such as ':controller/:action/:id'
25     * @var string
26     */
27    public $routePath;
28
29    /**
30     * Encoding of this route (not yet supported)
31     * @var string
32     */
33    public $encoding = 'utf-8';
34
35    /**
36     * What to do on decoding errors?  'ignore' or 'replace'
37     * @var string
38     */
39    public $decodeErrors = 'replace';
40
41    /**
42     * Is this a static route?
43     * @var string
44     */
45    public $static;
46
47    /**
48     * Filter function to operate on arguments before generation
49     * @var callback
50     */
51    public $filter;
52
53    /**
54     * Is this an absolute path?  (Mapper will not prepend SCRIPT_NAME)
55     * @var boolean
56     */
57    public $absolute;
58
59    /**
60     * Does this route use explicit mode (no implicit defaults)?
61     * @var boolean
62     */
63    public $explicit;
64
65    /**
66     * Default keyword arguments for this route
67     * @var array
68     */
69    public $defaults = array();
70
71    /**
72     * Array of keyword args for special conditions (method, subDomain, function)
73     * @var array
74     */
75    public $conditions;
76
77    /**
78     * Maximum keys that this route could utilize.
79     * @var array
80     */
81    public $maxKeys;
82
83    /**
84     * Minimum keys required to generate this route
85     * @var array
86     */
87    public $minKeys;
88
89    /**
90     * Default keywords that don't exist in the path; can't be changed by an incomng URL.
91     * @var array
92     */
93    public $hardCoded;
94
95    /**
96     * Requirements for this route
97     * @var array
98     */
99    public $reqs;
100
101    /**
102     * Regular expression for matching this route
103     * @var string
104     */
105    public $regexp;
106
107    /**
108     * Route path split by '/'
109     * @var array
110     */
111    protected $_routeList;
112
113    /**
114     * Reverse of $routeList
115     * @var array
116     */
117    protected $_routeBackwards;
118
119    /**
120     * Characters that split the parts of a URL
121     * @var array
122     */
123    protected $_splitChars;
124
125    /**
126     * Last path part used by buildNextReg()
127     * @var string
128     */
129    protected $_prior;
130
131    /**
132     * Requirements formatted as regexps suitable for preg_match()
133     * @var array
134     */
135    protected $_reqRegs;
136
137    /**
138     * Member name if this is a RESTful route
139     * @see resource()
140     * @var null|string
141     */
142    protected $_memberName;
143
144    /**
145     * Collection name if this is a RESTful route
146     * @see resource()
147     * @var null|string
148     */
149    protected $_collectionName;
150
151    /**
152     * Name of the parent resource, if this is a RESTful route & has a parent
153     * @see resource
154     * @var string
155     */
156    protected $_parentResource;
157
158
159    /**
160     *  Initialize a route, with a given routepath for matching/generation
161     *
162     *  The set of keyword args will be used as defaults.
163     *
164     *  Usage:
165     *      $route = new Horde_Routes_Route(':controller/:action/:id');
166     *
167     *      $route = new Horde_Routes_Route('date/:year/:month/:day',
168     *                      array('controller'=>'blog', 'action'=>'view'));
169     *
170     *      $route = new Horde_Routes_Route('archives/:page',
171     *                      array('controller'=>'blog', 'action'=>'by_page',
172     *                            'requirements' => array('page'=>'\d{1,2}'));
173     *
174     *  Note:
175     *      Route is generally not called directly, a Mapper instance connect()
176     *      method should be used to add routes.
177     */
178    public function __construct($routePath, $kargs = array())
179    {
180        $this->routePath = $routePath;
181
182        // Don't bother forming stuff we don't need if its a static route
183        $this->static = isset($kargs['_static']) ? $kargs['_static'] : false;
184
185        $this->filter = isset($kargs['_filter']) ? $kargs['_filter'] : null;
186        unset($kargs['_filter']);
187
188        $this->absolute = isset($kargs['_absolute']) ? $kargs['_absolute'] : false;
189        unset($kargs['_absolute']);
190
191        // Pull out the member/collection name if present, this applies only to
192        // map.resource
193        $this->_memberName = isset($kargs['_memberName']) ? $kargs['_memberName'] : null;
194        unset($kargs['_memberName']);
195
196        $this->_collectionName = isset($kargs['_collectionName']) ? $kargs['_collectionName'] : null;
197        unset($kargs['_collectionName']);
198
199        $this->_parentResource = isset($kargs['_parentResource']) ? $kargs['_parentResource'] : null;
200        unset($kargs['_parentResource']);
201
202        // Pull out route conditions
203        $this->conditions = isset($kargs['conditions']) ? $kargs['conditions'] : null;
204        unset($kargs['conditions']);
205
206        // Determine if explicit behavior should be used
207        $this->explicit = isset($kargs['_explicit']) ? $kargs['_explicit'] : false;
208        unset($kargs['_explicit']);
209
210        // Reserved keys that don't count
211        $reservedKeys = array('requirements');
212
213        // Name has been changed from the Python version
214        // This is a list of characters natural splitters in a URL
215        $this->_splitChars = array('/', ',', ';', '.', '#');
216
217        // trim preceding '/' if present
218        if (substr($this->routePath, 0, 1) == '/') {
219            $routePath = substr($this->routePath, 1);
220        }
221
222        // Build our routelist, and the keys used in the route
223        $this->_routeList = $this->_pathKeys($routePath);
224        $routeKeys = array();
225        foreach ($this->_routeList as $key) {
226            if (is_array($key)) { $routeKeys[] = $key['name']; }
227        }
228
229        // Build a req list with all the regexp requirements for our args
230        $this->reqs = isset($kargs['requirements']) ? $kargs['requirements'] : array();
231        $this->_reqRegs = array();
232        foreach ($this->reqs as $key => $value) {
233            $this->_reqRegs[$key] = '@^' . str_replace('@', '\@', $value) . '$@';
234        }
235
236        // Update our defaults and set new default keys if needed. defaults
237        // needs to be saved
238        list($this->defaults, $defaultKeys) = $this->_defaults($routeKeys, $reservedKeys, $kargs);
239
240        // Save the maximum keys we could utilize
241        $this->maxKeys = array_keys(array_flip(array_merge($defaultKeys, $routeKeys)));
242        list($this->minKeys, $this->_routeBackwards) = $this->_minKeys($this->_routeList);
243
244        // Populate our hardcoded keys, these are ones that are set and don't
245        // exist in the route
246        $this->hardCoded = array();
247        foreach ($this->maxKeys as $key) {
248            if (!in_array($key, $routeKeys) && $this->defaults[$key] != null) {
249                $this->hardCoded[] = $key;
250            }
251        }
252    }
253
254    /**
255     * Utility method to walk the route, and pull out the valid
256     * dynamic/wildcard keys
257     *
258     * @param  string  $routePath  Route path
259     * @return array               Route list
260     */
261    protected function _pathKeys($routePath)
262    {
263        $collecting = false;
264        $current = '';
265        $doneOn = array();
266        $varType = '';
267        $justStarted = false;
268        $routeList = array();
269
270        foreach (preg_split('//', $routePath, -1, PREG_SPLIT_NO_EMPTY) as $char) {
271            if (!$collecting && in_array($char, array(':', '*'))) {
272                $justStarted = true;
273                $collecting = true;
274                $varType = $char;
275                if (strlen($current) > 0) {
276                   $routeList[] = $current;
277                   $current = '';
278                }
279            } elseif ($collecting && $justStarted) {
280                $justStarted = false;
281                if ($char == '(') {
282                    $doneOn = array(')');
283                } else {
284                    $current = $char;
285                    // Basically appends '-' to _splitChars
286                    // Helps it fall in line with the Python idioms.
287                    $doneOn = $this->_splitChars + array('-');
288                }
289            } elseif ($collecting && !in_array($char, $doneOn)) {
290                $current .= $char;
291            } elseif ($collecting) {
292                $collecting = false;
293                $routeList[] = array('type' => $varType, 'name' => $current);
294                if (in_array($char, $this->_splitChars)) {
295                    $routeList[] = $char;
296                }
297                $doneOn = $varType = $current = '';
298            } else {
299                $current .= $char;
300            }
301        }
302        if ($collecting) {
303            $routeList[] = array('type' => $varType, 'name' => $current);
304        } elseif (!empty($current)) {
305            $routeList[] = $current;
306        }
307        return $routeList;
308    }
309
310    /**
311     * Utility function to walk the route backwards
312     *
313     * Will determine the minimum keys we must have to generate a
314     * working route.
315     *
316     * @param  array  $routeList  Route path split by '/'
317     * @return array              [minimum keys for route, route list reversed]
318     */
319    protected function _minKeys($routeList)
320    {
321        $minKeys = array();
322        $backCheck = array_reverse($routeList);
323        $gaps = false;
324        foreach ($backCheck as $part) {
325            if (!is_array($part) && !in_array($part, $this->_splitChars)) {
326                $gaps = true;
327                continue;
328            } elseif (!is_array($part)) {
329                continue;
330            }
331            $key = $part['name'];
332            if (array_key_exists($key, $this->defaults) && !$gaps)
333                continue;
334            $minKeys[] = $key;
335            $gaps = true;
336        }
337        return array($minKeys, $backCheck);
338    }
339
340    /**
341     * Creates a default array of strings
342     *
343     * Puts together the array of defaults, turns non-null values to strings,
344     * and add in our action/id default if they use and do not specify it
345     *
346     * Precondition: $this->_defaultKeys is an array of the currently assumed default keys
347     *
348     * @param  array  $routekeys     All the keys found in the route path
349     * @param  array  $reservedKeys  Array of keys not in the route path
350     * @param  array  $kargs         Keyword args passed to the Route constructor
351     * @return array                 [defaults, new default keys]
352     */
353    protected function _defaults($routeKeys, $reservedKeys, $kargs)
354    {
355        $defaults = array();
356
357        // Add in a controller/action default if they don't exist
358        if ((!in_array('controller', $routeKeys)) &&
359            (!in_array('controller', array_keys($kargs))) &&
360            (!$this->explicit)) {
361            $kargs['controller'] = 'content';
362        }
363
364        if (!in_array('action', $routeKeys) &&
365            (!in_array('action', array_keys($kargs))) &&
366            (!$this->explicit)) {
367            $kargs['action'] = 'index';
368        }
369
370        $defaultKeys = array();
371        foreach (array_keys($kargs) as $key) {
372            if (!in_array($key, $reservedKeys)) {
373                $defaultKeys[] = $key;
374            }
375        }
376
377        foreach ($defaultKeys as $key) {
378            if ($kargs[$key] !== null) {
379                $defaults[$key] = (string)$kargs[$key];
380            } else {
381                $defaults[$key] = null;
382            }
383        }
384
385        if (in_array('action', $routeKeys) &&
386            (!array_key_exists('action', $defaults)) &&
387            (!$this->explicit)) {
388            $defaults['action'] = 'index';
389        }
390
391        if (in_array('id', $routeKeys) &&
392            (!array_key_exists('id', $defaults)) &&
393            (!$this->explicit)) {
394            $defaults['id'] = null;
395        }
396
397        $newDefaultKeys = array();
398        foreach (array_keys($defaults) as $key) {
399            if (!in_array($key, $reservedKeys)) {
400                $newDefaultKeys[] = $key;
401            }
402        }
403        return array($defaults, $newDefaultKeys);
404    }
405
406    /**
407     * Create the regular expression for matching.
408     *
409     * Note: This MUST be called before match can function properly.
410     *
411     * clist should be a list of valid controller strings that can be
412     * matched, for this reason makeregexp should be called by the web
413     * framework after it knows all available controllers that can be
414     * utilized.
415     *
416     * @param  array  $clist  List of all possible controllers
417     * @return void
418     */
419    public function makeRegexp($clist)
420    {
421        list($reg, $noreqs, $allblank) = $this->buildNextReg($this->_routeList, $clist);
422
423        if (empty($reg)) {
424            $reg = '/';
425        }
426        $reg = $reg . '(/)?$';
427        if (substr($reg, 0, 1) != '/') {
428            $reg = '/' . $reg;
429        }
430        $reg = '^' . $reg;
431
432        $this->regexp = $reg;
433    }
434
435    /**
436     * Recursively build a regexp given a path, and a controller list.
437     *
438     * Returns the regular expression string, and two booleans that can be
439     * ignored as they're only used internally by buildnextreg.
440     *
441     * @param  array  $path   The RouteList for the path
442     * @param  array  $clist  List of all possible controllers
443     * @return array          [array, boolean, boolean]
444     */
445    public function buildNextReg($path, $clist)
446    {
447        if (!empty($path)) {
448            $part = $path[0];
449        } else {
450            $part = '';
451        }
452
453        // noreqs will remember whether the remainder has either a string
454        // match, or a non-defaulted regexp match on a key, allblank remembers
455        // if the rest could possible be completely empty
456        list($rest, $noreqs, $allblank) = array('', true, true);
457
458        if (count($path) > 1) {
459            $this->_prior = $part;
460            list($rest, $noreqs, $allblank) = $this->buildNextReg(array_slice($path, 1), $clist);
461        }
462
463        if (is_array($part) && $part['type'] == ':') {
464            $var = $part['name'];
465            $partreg = '';
466
467            // First we plug in the proper part matcher
468            if (array_key_exists($var, $this->reqs)) {
469                $partreg = '(?P<' . $var . '>' . $this->reqs[$var] . ')';
470            } elseif ($var == 'controller') {
471                $partreg = '(?P<' . $var . '>' . implode('|', array_map('preg_quote', $clist)) . ')';
472            } elseif (in_array($this->_prior, array('/', '#'))) {
473                $partreg = '(?P<' . $var . '>[^' . $this->_prior . ']+?)';
474            } else {
475                if (empty($rest)) {
476                    $partreg = '(?P<' . $var . '>[^/]+?)';
477                } else {
478                    $partreg = '(?P<' . $var . '>[^' . implode('', $this->_splitChars) . ']+?)';
479                }
480            }
481
482            if (array_key_exists($var, $this->reqs)) {
483                $noreqs = false;
484            }
485            if (!array_key_exists($var, $this->defaults)) {
486                $allblank = false;
487                $noreqs = false;
488            }
489
490            // Now we determine if its optional, or required. This changes
491            // depending on what is in the rest of the match. If noreqs is
492            // true, then its possible the entire thing is optional as there's
493            // no reqs or string matches.
494            if ($noreqs) {
495                // The rest is optional, but now we have an optional with a
496                // regexp. Wrap to ensure that if we match anything, we match
497                // our regexp first. It's still possible we could be completely
498                // blank as we have a default
499                if (array_key_exists($var, $this->reqs) && array_key_exists($var, $this->defaults)) {
500                    $reg = '(' . $partreg . $rest . ')?';
501
502                // Or we have a regexp match with no default, so now being
503                // completely blank form here on out isn't possible
504                } elseif (array_key_exists($var, $this->reqs)) {
505                    $allblank = false;
506                    $reg = $partreg . $rest;
507
508                // If the character before this is a special char, it has to be
509                // followed by this
510                } elseif (array_key_exists($var, $this->defaults) && in_array($this->_prior, array(',', ';', '.'))) {
511                    $reg = $partreg . $rest;
512
513                // Or we have a default with no regexp, don't touch the allblank
514                } elseif (array_key_exists($var, $this->defaults)) {
515                    $reg = $partreg . '?' . $rest;
516
517                // Or we have a key with no default, and no reqs. Not possible
518                // to be all blank from here
519                } else {
520                    $allblank = false;
521                    $reg = $partreg . $rest;
522                }
523
524            // In this case, we have something dangling that might need to be
525            // matched
526            } else {
527                // If they can all be blank, and we have a default here, we know
528                // its safe to make everything from here optional. Since
529                // something else in the chain does have req's though, we have
530                // to make the partreg here required to continue matching
531                if ($allblank && array_key_exists($var, $this->defaults)) {
532                    $reg = '(' . $partreg . $rest . ')?';
533
534                // Same as before, but they can't all be blank, so we have to
535                // require it all to ensure our matches line up right
536                } else {
537                    $reg = $partreg . $rest;
538                }
539            }
540        } elseif (is_array($part) && $part['type'] == '*') {
541            $var = $part['name'];
542            if ($noreqs) {
543                $reg = '(?P<' . $var . '>.*)' . $rest;
544                if (!array_key_exists($var, $this->defaults)) {
545                    $allblank = false;
546                    $noreqs = false;
547                }
548            } else {
549                if ($allblank && array_key_exists($var, $this->defaults)) {
550                    $reg = '(?P<' . $var . '>.*)' . $rest;
551                } elseif (array_key_exists($var, $this->defaults)) {
552                    $reg = '(?P<' . $var . '>.*)' . $rest;
553                } else {
554                    $allblank = false;
555                    $noreqs = false;
556                    $reg = '(?P<' . $var . '>.*)' . $rest;
557                }
558            }
559        } elseif ($part && in_array(substr($part, -1), $this->_splitChars)) {
560            if ($allblank) {
561                $reg = preg_quote(substr($part, 0, -1)) . '(' . preg_quote(substr($part, -1)) . $rest . ')?';
562            } else {
563                $allblank = false;
564                $reg = preg_quote($part) . $rest;
565            }
566
567        // We have a normal string here, this is a req, and it prevents us from
568        // being all blank
569        } else {
570            $noreqs = false;
571            $allblank = false;
572            $reg = preg_quote($part) . $rest;
573        }
574
575        return array($reg, $noreqs, $allblank);
576    }
577
578    /**
579     * Match a url to our regexp.
580     *
581     * While the regexp might match, this operation isn't
582     * guaranteed as there's other factors that can cause a match to fail
583     * even though the regexp succeeds (Default that was relied on wasn't
584     * given, requirement regexp doesn't pass, etc.).
585     *
586     * Therefore the calling function shouldn't assume this will return a
587     * valid dict, the other possible return is False if a match doesn't work
588     * out.
589     *
590     * @param  string  $url  URL to match
591     * @param  array         Keyword arguments
592     * @return null|array    Array of match data if matched, Null otherwise
593     */
594    public function match($url, $kargs = array())
595    {
596        $defaultKargs = array('environ'          => array(),
597                              'subDomains'       => false,
598                              'subDomainsIgnore' => array(),
599                              'domainMatch'      => '');
600        $kargs = array_merge($defaultKargs, $kargs);
601
602        // Static routes don't match, they generate only
603        if ($this->static) {
604            return false;
605        }
606
607        if (substr($url, -1) == '/' && strlen($url) > 1) {
608            $url = substr($url, 0, -1);
609        }
610
611        // Match the regexps we generated
612        $match = preg_match('@' . str_replace('@', '\@', $this->regexp) . '@', $url, $matches);
613        if ($match == 0) {
614            return false;
615        }
616
617        $host = isset($kargs['environ']['HTTP_HOST']) ? $kargs['environ']['HTTP_HOST'] : null;
618        if ($host !== null && !empty($kargs['subDomains'])) {
619            $host = substr($host, 0, strpos(':', $host));
620            $subMatch = '@^(.+?)\.' . $kargs['domainMatch'] . '$';
621            $subdomain = preg_replace($subMatch, '$1', $host);
622            if (!in_array($subdomain, $kargs['subDomainsIgnore']) && $host != $subdomain) {
623                $subDomain = $subdomain;
624            }
625        }
626
627        if (!empty($this->conditions)) {
628            if (isset($this->conditions['method'])) {
629                if (empty($kargs['environ']['REQUEST_METHOD'])) { return false; }
630
631                if (!in_array($kargs['environ']['REQUEST_METHOD'], $this->conditions['method'])) {
632                    return false;
633                }
634            }
635
636            // Check sub-domains?
637            $use_sd = isset($this->conditions['subDomain']) ? $this->conditions['subDomain'] : null;
638            if (!empty($use_sd) && empty($subDomain)) {
639                return false;
640            }
641            if (is_array($use_sd) && !in_array($subDomain, $use_sd)) {
642                return false;
643            }
644        }
645        $matchDict = $matches;
646
647        // Clear out int keys as PHP gives us both the named subgroups and numbered subgroups
648        foreach ($matchDict as $key => $val) {
649            if (is_int($key)) {
650                unset($matchDict[$key]);
651            }
652        }
653        $result = array();
654        $extras = Horde_Routes_Utils::arraySubtract(array_keys($this->defaults), array_keys($matchDict));
655
656        foreach ($matchDict as $key => $val) {
657            // TODO: character set decoding
658            if ($key != 'path_info' && $this->encoding) {
659                $val = urldecode($val);
660            }
661
662            if (empty($val) && array_key_exists($key, $this->defaults) && !empty($this->defaults[$key])) {
663                $result[$key] = $this->defaults[$key];
664            } else {
665                $result[$key] = $val;
666            }
667        }
668
669        foreach ($extras as $key) {
670            $result[$key] = $this->defaults[$key];
671        }
672
673        // Add the sub-domain if there is one
674        if (!empty($kargs['subDomains'])) {
675            $result['subDomain'] = $subDomain;
676        }
677
678        // If there's a function, call it with environ and expire if it
679        // returns False
680        if (!empty($this->conditions) && array_key_exists('function', $this->conditions) &&
681            !call_user_func_array($this->conditions['function'], array($kargs['environ'], $result))) {
682            return false;
683        }
684
685        return $result;
686    }
687
688    /**
689     * Generate a URL from ourself given a set of keyword arguments
690     *
691     * @param  array  $kargs   Keyword arguments
692     * @param  null|string     Null if generation failed, URL otherwise
693     */
694    public function generate($kargs)
695    {
696        $defaultKargs = array('_ignoreReqList' => false,
697                              '_appendSlash'   => false);
698        $kargs = array_merge($defaultKargs, $kargs);
699
700        $_appendSlash = $kargs['_appendSlash'];
701        unset($kargs['_appendSlash']);
702
703        $_ignoreReqList = $kargs['_ignoreReqList'];
704        unset($kargs['_ignoreReqList']);
705
706        // Verify that our args pass any regexp requirements
707        if (!$_ignoreReqList) {
708            foreach ($this->reqs as $key => $v) {
709                $value = (isset($kargs[$key])) ? $kargs[$key] : null;
710
711                if (!empty($value) && !preg_match($this->_reqRegs[$key], $value)) {
712                    return null;
713                }
714            }
715        }
716
717        // Verify that if we have a method arg, it's in the method accept list.
718        // Also, method will be changed to _method for route generation.
719        $meth = (isset($kargs['method'])) ? $kargs['method'] : null;
720
721        if ($meth) {
722            if ($this->conditions && isset($this->conditions['method']) &&
723                (!in_array(Horde_String::upper($meth), $this->conditions['method']))) {
724
725                return null;
726            }
727            unset($kargs['method']);
728        }
729
730        $routeList = $this->_routeBackwards;
731        $urlList = array();
732        $gaps = false;
733        foreach ($routeList as $part) {
734            if (is_array($part) && $part['type'] == ':') {
735                $arg = $part['name'];
736
737                // For efficiency, check these just once
738                $hasArg = array_key_exists($arg, $kargs);
739                $hasDefault = array_key_exists($arg, $this->defaults);
740
741                // Determine if we can leave this part off
742                // First check if the default exists and wasn't provided in the
743                // call (also no gaps)
744                if ($hasDefault && !$hasArg && !$gaps) {
745                    continue;
746                }
747
748                // Now check to see if there's a default and it matches the
749                // incoming call arg
750                if (($hasDefault && $hasArg) && $kargs[$arg] == $this->defaults[$arg] && !$gaps) {
751                    continue;
752                }
753
754                // We need to pull the value to append, if the arg is NULL and
755                // we have a default, use that
756                if ($hasArg && $kargs[$arg] === null && $hasDefault && !$gaps) {
757                    continue;
758
759                // Otherwise if we do have an arg, use that
760                } elseif ($hasArg) {
761                    $val = ($kargs[$arg] === null) ? 'null' : $kargs[$arg];
762                } elseif ($hasDefault && $this->defaults[$arg] != null) {
763                    $val = $this->defaults[$arg];
764
765                // No arg at all? This won't work
766                } else {
767                    return null;
768                }
769
770                $urlList[] = Horde_Routes_Utils::urlQuote($val, $this->encoding);
771                if ($hasArg) {
772                    unset($kargs[$arg]);
773                }
774                $gaps = true;
775            } elseif (is_array($part) && $part['type'] == '*') {
776                $arg = $part['name'];
777                $kar = (isset($kargs[$arg])) ? $kargs[$arg] : null;
778                if ($kar != null) {
779                    $urlList[] = Horde_Routes_Utils::urlQuote($kar, $this->encoding);
780                    $gaps = true;
781                }
782            } elseif (!empty($part) && in_array(substr($part, -1), $this->_splitChars)) {
783                if (!$gaps && in_array($part, $this->_splitChars)) {
784                    continue;
785                } elseif (!$gaps) {
786                    $gaps = true;
787                    $urlList[] = substr($part, 0, -1);
788                } else {
789                    $gaps = true;
790                    $urlList[] = $part;
791                }
792            } else {
793                $gaps = true;
794                $urlList[] = $part;
795            }
796        }
797
798        $urlList = array_reverse($urlList);
799        $url = implode('', $urlList);
800        if (substr($url, 0, 1) != '/') {
801            $url = '/' . $url;
802        }
803
804        $extras = $kargs;
805        foreach ($this->maxKeys as $key) {
806            unset($extras[$key]);
807        }
808        $extras = array_keys($extras);
809
810        if (!empty($extras)) {
811            if ($_appendSlash && substr($url, -1) != '/') {
812                $url .= '/';
813            }
814            $url .= '?';
815            $newExtras = array();
816            foreach ($kargs as $key => $value) {
817                if (in_array($key, $extras) && ($key != 'action' || $key != 'controller')) {
818                    $newExtras[$key] = $value;
819                }
820            }
821            $url .= http_build_query($newExtras);
822        } elseif ($_appendSlash && substr($url, -1) != '/') {
823            $url .= '/';
824        }
825        return $url;
826    }
827
828}
829