add Routes
authorChuck Hagenbuch <chuck@horde.org>
Sat, 29 Nov 2008 01:25:57 +0000 (20:25 -0500)
committerChuck Hagenbuch <chuck@horde.org>
Sat, 29 Nov 2008 01:25:57 +0000 (20:25 -0500)
16 files changed:
framework/Routes/doc/Horde/Routes/integration.txt [new file with mode: 0644]
framework/Routes/doc/Horde/Routes/manual.txt [new file with mode: 0644]
framework/Routes/lib/Horde/Routes/Exception.php [new file with mode: 0644]
framework/Routes/lib/Horde/Routes/Mapper.php [new file with mode: 0644]
framework/Routes/lib/Horde/Routes/Route.php [new file with mode: 0644]
framework/Routes/lib/Horde/Routes/Utils.php [new file with mode: 0644]
framework/Routes/package.xml [new file with mode: 0644]
framework/Routes/test/Horde/Routes/AllTests.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/GenerationTest.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/RecognitionTest.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/TestHelper.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/UtilTest.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/UtilWithExplicitTest.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/fixtures/controllers/admin/users.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/fixtures/controllers/content.php [new file with mode: 0644]
framework/Routes/test/Horde/Routes/fixtures/controllers/users.php [new file with mode: 0644]

diff --git a/framework/Routes/doc/Horde/Routes/integration.txt b/framework/Routes/doc/Horde/Routes/integration.txt
new file mode 100644 (file)
index 0000000..818ef6a
--- /dev/null
@@ -0,0 +1,114 @@
+Horde_Routes Integration Guide
+
+This document is based on the manual for the Python version available at:
+http://routes.groovie.org/integration.html
+
+
+Integrating Horde_Routes
+
+Horde_Routes is built to function with any PHP 5 web framework that utilizes a
+MVC style approach to design.  However, it can be used completely outside of a
+MVC context as well.
+
+For full integration with a web framework, several steps should be done during
+different phases of the request cycle. The web framework developer should also
+have a way to add routes to the mapper, and accessing utility functions.
+
+Terminology:
+
+Framework Integrator
+    Typically the person who is working to integrate routes into a web framework
+Web/Framework Developer
+    The web application developer using the web framework
+
+
+Route Setup
+
+Horde_Routes requires PHP 5.1 or above.  It does not require() its own files, so
+an autoloader must be registered that will load the Horde_Routes files from its
+PEAR directory.  Alternatively, the files can be loaded explicitly:
+
+  require_once 'Horde/Routes/Mapper.php';
+  require_once 'Horde/Routes/Exception.php';
+  require_once 'Horde/Routes/Route.php';
+  require_once 'Horde/Routes/Utils.php';
+
+The web framework developer will need a place to add routes that will be used
+during the URL resolution phase. Commonly, the mapper object will be directly
+exposed in a configuration file:
+
+$m = new Horde_Routes_Mapper();
+$m->connect(':controller/:action/:id');
+
+The developer can then add additional routes as they see fit. The Framework
+Integrator should make sure that their code takes care of using the Mapper
+instance and preserving it somewhere for use by the framework.
+
+
+Pre-Loading the Controllers
+
+Due to the way Horde_Routes makes use of regular expressions for URL
+recognition, Horde_Routes_Mapper requires a valid list of Controllers before
+it can match URLs. Controller names can contain any character including /.
+Once the controller list is created, the Mapper method createRegs() should be
+called:
+
+$m->createRegs(array('content', 'admin/comments', 'blog'))
+
+After this step, the Mapper object is now ready to match URLs.
+
+Instead of calling Horde_Routes_Mapper->createRegs() directly, a PHP callback
+can also be passed to the Horde_Routes_Mapper constructor.  See the inline
+source documentation.
+
+
+URL Resolution
+
+When the URL is looked up, it should be matched against the Mapper. When
+matching an incoming URL, it is assumed that the URL path is the only string
+being matched. All query args should be stripped before matching:
+
+  $m->connect('articles/:year/:month',
+    array('controller'=>'blog', 'action'=>'view', 'year'=>null));
+
+  $m->match('/articles/2003/10');
+  // Returns:
+  // array('controller'=>'blog', 'action'=>'view',
+           'year'=>'2003', 'month'=>'10')
+
+Matching a URL will return an associative array of the match results.  If
+you'd like to differentiate between where the argument came from you can use
+routematch() which will return the Route object that has all these details:
+
+  $m->connect('articles/:year/:month',
+    array('controller'=>'blog', 'action'=>'view', 'year'=>null));
+
+  $result = $m->routematch('/articles/2003/10');
+  // result is array(matchdata, Horde_Routes_Route)
+
+  // result[0] - array('controller'=>'blog', 'action'=>'view',
+  //                   'year'=>'2003', 'month'=>'10')
+  //
+  // result[1] - Horde_Routes_Route instance
+  // result[1]->defaults  - array('controller'=>'blog', 'action'=>'view',
+  //                              'year'=>null);
+  // result[1]->hardcoded - array('controller', 'action')
+
+Your integration code is then expected to dispatch to a controller and action
+in the associative array. How it does this is entirely up to the framework
+integrator. Your integration should also typically provide the web developer a
+mechanism to access the additional associative array values.
+
+
+Providing Utilities
+
+In addition to allowing the web developer to define routes on the Mapper instance,
+access to a Horde_Routes_Utils instance should be provided:
+
+  $utils = $m->utils;
+
+The $utils instance provides the web developer with the utility functions urlFor() and
+redirectTo(), which will be used to generate URLs from the route mapping.
+
+  $url = $utils->urlFor(array('controller' => 'articles', 'action' => 'view',
+                              'id' => $article->id));
diff --git a/framework/Routes/doc/Horde/Routes/manual.txt b/framework/Routes/doc/Horde/Routes/manual.txt
new file mode 100644 (file)
index 0000000..a571ddf
--- /dev/null
@@ -0,0 +1,730 @@
+Horde_Routes Manual
+
+This document is based on the manual for the Python version available at:
+http://routes.groovie.org/manual.html
+
+
+1   Introduction
+
+Horde_Routes tackles an interesting problem that comes up frequently in web
+development, how do you map a URL to your code? While there are many solutions
+to this problem, that range from using the URL paths as an object publishing
+hierarchy, to regular expression matching; Horde_Routes goes a slightly different
+way.
+
+Using Horde_Routes, you specify parts of the URL path and how to match them to your
+Controllers and Actions. The specific web framework you're using may actually
+call them by slightly different names, but for the sake of consistency we will
+use these names.
+
+Horde_Routes lets you have multiple ways to get to the same Controller and Action,
+and uses an intelligent lookup mechanism to try and guarantee you the URL with
+the least cruft when generating the URL.
+
+URL Cruft
+    Shorthand reference to what will occur if a Route can't handle all the
+    arguments we want to send it. Those arguments become HTTP query args
+    (/something ?query=arg&another=arg ), which we try to avoid when
+    generating a URL.
+
+
+2   Setting Up Routes
+
+To setup Horde_Routes, it is assumed that you are using a web framework that has the
+Horde_Routes mechanism integrated for you. The web framework should have somewhere
+setup for you to add a Route to your Mapper.
+
+Route (Horde_Routes_Route)
+    A Route is a mapping of a URL to a controller, action, and/or additional
+    variables. Matching a Route will always result in a controller and action.
+    Route objects are typically created and managed by the Mapper.
+Mapper (Horde_Routes_Mapper)
+    The Mapper is the main class used to hold, organize, and match Routes.
+    While you can create a Route object independently of the Mapper, its not
+    nearly as useful. The Mapper is what you will use to add Routes and what
+    the web framework uses to match incoming URLs.
+
+We will also assume for this introduction that your Mapper instance is exposed
+to you as $m, for example:
+
+  $m = new Horde_Routes_Mapper();
+  $m->connect(':controller/:action/:id');
+
+The above example covers one of the most common routes that is typically
+considered the default route. This very flexible route allows virtually all of
+your controllers and actions to be called. Adding more routes is done in a
+similar manner, by calling $m->connect(...) and giving the Mapper instance a set
+of arguments.
+
+The following are all valid examples of adding routes:
+
+  $m->connect('archives/:year/:month/:day',
+      array('controller'=>'archives', 'action'=>'view', 'year'=>2004,
+      'requirements'=> array('year'=>'\d{2,4}', 'month'=>'\d{1,2}')));
+
+  $m->connect('feeds/:category/atom.xml',
+      array('controller'=>'feeds', 'action'=>'atom'));
+
+  $m->connect('history', 'archives/by_eon/:century',
+      array('controller'=>'archives', 'action'=>'aggregate', 'century'=>1800));
+
+  $m->connect('article', 'article/:section/:slug/:page.html',
+      array('controller'=>'article', 'action'=>'view'));
+
+  $m->connect(':controller/:action/:id');
+
+  $m->connect('home', '',
+      array('controller'=>'blog', 'action'=>'index'));
+
+In the following sections, we'll highlight the section of the Route we're
+referring to in the first example.
+
+
+2.1   Route Name
+
+Optional
+
+  $m->connect('history', 'archives/by_eon/:century',
+      array('controller'=>'archives...
+
+A Route can have a name, this is also referred to as Named Routes and lets you
+quickly reference the Defaults that the route was configured with. This is the
+first non-keyword argument, and if not present the first non-keyword argument
+is assumed to be the route path.
+
+Route Names are mainly used when generating routes, and have no other effect
+on matching a URL.
+
+
+2.1.1   Static Named Routes
+
+Horde_Routes also supports static named routes. These are routes that do not
+involve actual URL generation, but instead allow you to quickly alias common
+URLs.  For example:
+
+  $m->connect('google_search', 'http://www.google.com/search',
+      array('_static'=>True));
+
+Static Named Routes are ignored entirely when matching a URL.
+
+
+2.1.2   Filter Functions
+
+Named routes can have functions associated with them that will operate on the
+arguments used during generation. If you have a route that requires multiple
+arguments to generate, like:
+
+  $m->connect('archives/:year/:month/:day', array('controller'=>'archives',
+            'action'=>'view', 'year'=>2004,
+            'requirements'=>array('year'=>'\d{2,4}', 'month'=>'\d{1,2}')));
+
+To generate a URL for this will require a month and day argument, and a year
+argument if you don't want to use 2004. When using Routes with a database or
+other objects that might have all this information, it's useful to let Routes
+expand that information so you don't have to.
+
+Consider the case where you have a story object which has a year, month, and
+day attribute. You could generate the URL with:
+
+$utils = $m->utils;
+$utils->urlFor(array('year'  => $story->year,
+                     'month' => $story->month,
+                     'day'   => $story->day));
+
+This isn't terribly convenient, and can be brittle if for some reason you need
+to change the story object's interface. Here's an example of setting up a
+filter function:
+
+function story_expand($kargs) {
+  // only alter $kargs if a story keyword arg is present
+  if (! in_array('story', $kargs)) {
+    return $kargs;
+  }
+
+  $story = $kargs['story'];
+  unset ($kargs['story']);
+
+  $kargs['year']  = $story->year;
+  $kargs['month'] = $story->month;
+  $kargs['day']   = $story->day;
+
+  return $kargs;
+}
+
+$m->connect('archives', 'archives/:year/:month/:day',
+  array('controller' => 'archives', 'action' => 'view', 'year' => 2004,
+        'requirements' => array('year'=>'\d{2,4}', 'month'=>'\d{1,2}'),
+        'filter'=> 'story_expand'));
+
+This filter function will be used when using the named route archives. If a
+story keyword argument is present, it will use that and alter the keyword
+arguments used to generate the actual route.
+
+If you have a story object with those attributes, making the route would now
+be done with the following arguments:
+
+$utils = $m->utils;
+$utils->urlFor('archives', array('story' => $myStory));
+
+If the story interface changes, you can change how the arguments are pulled
+out in a single location. This also makes it substantially easier to generate
+the URL.
+
+*Warning* Using the filter function requires the route to be a named route.
+          This is due to how the filter function can affect the route that
+          actually gets chosen. The only way to reliably ensure the proper
+          filter function gets used is by naming the route, and using its
+          route name with Horde_Routes_Utils->urlFor().
+
+
+2.2   Route Path
+
+Required
+
+  $m->connect('feeds/:category/atom.xml',
+    array('controller'=>'feeds', 'action'=>'atom'));
+
+The Route Path determines the URL mapping for the Route. In the above example
+a URL like /feeds/electronics/atom.xml will match this route.
+
+A Route Path is separated into parts that you define, the naming used when
+referencing the different types of route parts are:
+
+Static Part
+
+  $m->connect('feeds/:category/atom.xml',
+    array('controller'=>'feeds', 'action'=>'atom'));
+
+    A plain-text part of the URL, this doesn't result in any Route variables.
+
+Dynamic Part
+
+  $m->connect('feeds/:category/atom.xml',
+    array('controller'=>'feeds', 'action'=>'atom'))
+
+    A dynamic part matches text in that part of the URL, and assigns what it
+    finds to the name after the : mark.
+
+Wildcard Part
+
+  $m->connect('file/*url',
+    array('controller'=>'file', 'action'=>'serve'));
+
+  A wildcard part will match everything except the other parts around it.
+
+Groupings
+
+  $m->connect('article', 'article/:section/:slug/:(page).html', ...
+
+  $m->connect('file/*(url).html',
+    array('controller'=>'file', 'action'=>'serve'));
+
+    Groupings let you define boundaries for the match with the () characters.
+    This allows you to match wildcards and dynamics next to other static and
+    dynamic parts. Care should be taken when using Groupings next to each
+    other.
+
+
+2.3   Defaults
+
+Optional
+
+  $m->connect('history', 'archives/by_eon/:century',
+    array('controller'=>'archives', 'action'=>'aggregate', 'century'=>1800));
+
+The keyword options in a route (not including the requirements keyword arg)
+that can determine the default for a route. If a default is specified for a
+variable that is not a dynamic part, then its not only a default but is also a
+hardcoded variable. The controller and action are hardcoded variables in the
+example above because despite the URL, they will always be 'archives' and
+'aggregate' respectively.
+
+Hardcoded Variable
+    Default keyword that does not exist in the route path. This keyword
+    variable cannot be changed by the URL coming in.
+
+
+2.4   Requirements
+
+Optional
+
+  $m->connect('archives/:year/:month/:day',
+    array('controller'=>'archives', 'action'=>'view', 'year'=>2004,
+          'requirements' => array('year'=>'\d{2,4}', 'month'=>'\d{1,2}')));
+
+Requirements is a special keyword used by Routes to enforce a regular
+expression restriction on the dynamic part or wildcard part of a route path.
+
+Note in the example above that the regular expressions do not have boundaries
+such as they would with a PHP function like preg_match().  The expression is
+simply "\d{2,4}" and not "/\d{2,4}/".
+
+
+2.5   Conditions
+
+Optional
+
+  $m->connect('user/new;preview',
+    array('controller' => 'user', 'action' => 'preview',
+          'conditions' => array('method' => array('POST'))));
+
+Conditions specifies a set of special conditions that must be met for the
+route to be accepted as a valid match for the URL. The conditions argument
+must always be a dictionary and can accept 3 different keys.
+
+method
+    Request must be one of the HTTP methods defined here. This argument must
+    be a list of HTTP methods, and should be upper-case.
+subDomain
+    Can either be True or an array of sub-domains, one of which must be
+    present.
+function
+    A function that will be used to evaluate if the Route is a match. Must
+    return True or False, and will be called with the environ and match_dict.
+    The match_dict is a dict with all the Route variables for the request.
+    Modifications to match_dict will appear identical to Route variables from
+    the original match.
+
+Examples:
+
+  // The method to be either GET or HEAD
+  m->connect('user/list',
+    array('controller' => 'user', 'action' => 'list',
+          'conditions' => array('method' => array('GET', 'HEAD'))));
+
+
+  // A sub-domain should be present
+  $m->connect('',
+    array('controller' => 'user', 'action' => 'home',
+          'conditions' => array('subDomain' => true)));
+
+  // Sub-domain should be either 'fred' or 'george'
+  $m->connect('',
+    array('controller' => 'user', 'action' => 'home',
+          'conditions' => array('subDomain' => array('fred', 'george')));
+
+
+  /**
+   * Put the referrer into the resulting match dictionary,
+   * this won't stop the match since it always returns True
+   */
+  function referals($environ, $result) {
+    $referer = isset($environ['HTTP_REFERER']) ? $environ['HTTP_REFERER'] : null;
+    $result['referer'] = $referer;
+    return true;
+  }
+
+  $m->connect(':controller/:action/:id',
+    array('conditions' => array('function'=>'referals')));
+
+
+3   The Nitty Gritty of Route Setup
+
+3.1   Minimum URLs
+
+Routes will use your defaults to try and minimize the required length of your
+URL whenever possible. For example:
+
+  $m->connect(':controller/:action/:id',
+    array('action'=>'view', 'id'=>4));
+
+  # Will match all of the following
+  # /content/view/4
+  # /content/view
+  # /content
+
+Trailing dynamic parts of a route path that have defaults setup are not
+required to exist in the URL being matched. This means that each of the URL
+examples shown above will result in the same set of keyword arguments being
+sent to the same controller and action.
+
+If a dynamic part with a default is followed by either static parts or dynamic
+parts without defaults, that dynamic part will be required despite having a
+default:
+
+  // Remember that :action has an implicit default
+  $m->connect('archives/:action/:article',
+    array('controller'=>'blog'));
+
+  # Matches:
+  # /archives/view/introduction
+  # /archives/edit/recipes
+
+  # Does Not Match:
+  # /archives/introduction
+  # /archives/recipes
+
+This way, the URL coming in maps up to the route path you created, part for part.
+
+When using Groupings, parts will still be left off, but only if the remainder
+of the URL has no static after it. This can lead to some odd looking URLs
+being generated if you aren't careful about your requirements and defaults.
+For example:
+
+  # Groupings without requirements
+  $m->connect(':controller/:(action)-:(id)')
+
+  # Matches:
+  # /archives/view-3
+  # /archives/view-
+
+  # Generation:
+  $utils = $m->utils;
+  $utils->urlFor(array('controller'=>'archives', 'action'=>'view');
+  # /archives/view-
+
+It's unlikely you want such a URL, and would prefer to ensure that there's
+always an id supplied. To enforce this behavior we will use Requirements:
+
+  # Groupings without requirements
+  $m->connect(':controller/:(action)-:(id)',
+    array('requirements'=> array('id'=>'\d+')));
+
+  # Matches:
+  # /archives/view-3
+  # /archives/view-2
+
+  # Does Not Match:
+  # /archives/view-
+
+  # Generation:
+  $utils = $m->utils;
+  $utils->urlFor(array('controller'=>'archives', 'action'=>'view', 'id'=>2));
+  # /archives/view-2
+
+If you end up with URLs missing parts you'd like left on when using Groupings,
+add a requirement to that part.
+
+
+3.2   Implicit Defaults
+
+The above rule regarding minimum URLs has two built-in implicit defaults. If
+you use either action or id in your route path and don't specify defaults for
+them, Routes will automatically assign the following defaults to them for you:
+
+  array('action' => 'index', 'id' => null)
+
+This is why using the following setup doesn't require an action or id in the URL:
+
+  $m->connect(':controller/:action/:id');
+
+  # '/blog'  -> controller='blog', action='index', id=None
+
+
+3.3   Search Order
+
+When setting up your routes, remember that when using routes the order in
+which you set them up can affect the URL that's generated. Routes will try and
+use all the keyword args during route generation and if multiple routes can be
+generated given the set of keyword args, the first and shortest route that was
+connected to the mapper will be used. Hardcoded variables are also used first
+if available as they typically result in shorter URLs.
+
+For example:
+  # Route Setup
+  $m->connect('archives/:year',
+    array('controller'=>'blog', 'action'=>'view', 'year'=null));
+  $m->connect(':controller/:action/:id');
+
+  # Route Usage
+  $utils = $m->utils;
+  $utils->urlFor(array('controller'=>'blog', 'action'=>'view'));
+  # -> '/archives'
+
+You will typically want your specific and detailed routes at the top of your
+Route setup and the more generic routes at the bottom.
+
+
+3.4   Wildcard Limitations and Gotchas
+
+Due to the nature of wildcard parts, using wildcards in your route path can
+result in URL matches that you didn't expect. Wildcard parts are extremely
+powerful and when combined with dynamic parts that have defaults can confuse
+the new Routes user.
+
+When you have dynamic parts with defaults, you should never place them
+directly next to a wildcard part. This can result in the wildcard part eating
+the part in the URL that was intended as the dynamic part.
+
+For example:
+
+  $m->connect('*url/:username',
+    array('controller'=>'blog', 'action'=>'view', 'username'=>'george'));
+
+  # When matching                        url variable              username variable
+  # /some/long/url/george                /some/long/url/george     george
+  # /some/other/stuff/fred               /some/other/stuff/fred    george
+
+This occurs because Routes sees the default as being optional, and the
+wildcard part attempts to gobble as much of the URL as possible before a
+required section of the route path is found. By having a trailing dynamic part
+with a default, that section gets dropped.
+
+Notice how removing the dynamic part default results in the variables we expect:
+
+  $m->connect('*url/:username',
+    array('controller'=>'blog', 'action'=>'view'));
+
+  # When matching                        url variable              username variable
+  # /some/long/url/george                /some/long/url            george
+  # /some/other/stuff/fred               /some/other/stuff         fred
+
+Let's try one more time, but put in a static part between the dynamic part
+with a default and the wildcard:
+
+  $m->connect('*url/user/:username',
+    array('controller'=>'blog', 'action'=>'view', 'username'=>'george'));
+
+  # When matching                        url variable              username variable
+  # /some/long/url/user/george           /some/long/url            george
+  # /some/other/stuff/user/fred          /some/other/stuff         fred
+
+
+3.5   Unicode
+
+Not currently supported in the PHP version.
+
+
+4   Using Routes
+
+Once you have setup the Routes to map URLs to your controllers and actions,
+you will likely want to generate URLs from within your web application.
+
+
+Horde_Routes_Utils includes two functions for use in your web application that
+are commonly desired.
+
+    * redirectTo()
+    * urlFor()
+
+Both of these functions take a similar set of arguments. The most important
+being an associative array of keyword arguments that describes the controller,
+action, and additional variables you'd like present for the URL that's created.
+
+To save you from repeating things, Routes has two mechanisms to reduce the
+amount of information you need to supply the urlFor() or redirectTo() function.
+
+
+4.1   Named Routes
+
+We saw earlier how the route name ties a set of defaults to a name. We can use
+this name with our Route functions and its as if we used that set of keyword
+args:
+
+  $m->connect('category_home', 'category/:section',
+    array('controller'=>'blog', 'action'=>'view', 'section'=>'home'));
+
+  $utils = $m->utils;
+  $utils->urlFor('category_home');
+
+  // is equivalent to
+  $utils->urlFor(array('controller'=>'blog', 'action'=>'view', 'section'=>'home'));
+
+You can also specify keyword arguments and it will override defaults
+associated with the route name:
+
+  $utils->urlFor('category_home', array('action'=>'index'));
+
+  // is equivalent to
+  $utils->urlFor(array('controller'=>'blog', 'action'=>'index', 'section'=>'home'));
+
+As you can see, the amount of typing you save yourself by using the route name
+feature is quite handy.
+
+Using the recently introduced static named routes feature allows you to
+quickly use common URLs and easily add query arguments:
+
+  $m->connect('google_search', 'http://www.google.com/search',
+    array('_static' => true));
+
+  $utils = $m->utils;
+  $utils->urlFor('google_search', array('q'=>'routes'));
+  // will result in
+  // http://www.google.com/search?q=routes
+
+
+4.1.1   Non-Existent Route Names
+
+If you supply a route name that does not exist, urlFor() will assume that you
+intend to use the name as the actual URL. It will also prepend it with the
+proper SCRIPT_NAME if applicable:
+
+  $utils->urlFor('/css/source.css');
+  # if running underneath a 'mount' point of /myapp will become
+  # /myapp/css/source.css
+
+For portable web applications, it's highly encouraged that you use urlFor() for
+all your URLs, even those that are static resources and images. This will
+ensure that the URLs are properly handled in various deployment cases.
+
+
+4.2   Route Memory
+
+When your controller and action is matched up from the URL, the variables it
+set to get there are preserved. This lets you update small bits of the
+keywords that got you there without specifying the entire thing:
+
+  $m->connect('archives/:year/:month/:day',
+    array('controller'=>'archives', 'action'=>'view', 'year'=>2004,
+          'requirements'=>array('year'=>'\d{2,4}', 'month'=>'\d{1,2}')));
+
+  # URL used: /archives/2005/10/4
+
+  # Route dict: {'controller': 'archives', 'action': 'view', 'year': '2005',
+  #              'month': '10', 'day': '4'}
+
+  $utils->urlFor(array('day'=>6))                     # =>          /archives/2005/10/6
+  $utils->urlFor(array('month'=>4))                   # =>          /archives/2005/4/4
+  $utils->urlFor()                                    # =>          /archives/2005/10/4
+  $utils->urlFor(array('controller'=>'/archives'))    # =>          /archives
+
+The route memory is always used for values with the following conditions:
+
+    * If the controller name begins with a /, no values from the Route dict are used
+    * If the controller name changes and no action is specified, action will be set to 'index'
+    * If you use named routes, no values from the Route dict are used
+
+
+4.3   Overriding Route Memory
+
+Sometimes one doesn't want to have Route Memory present, as well as removing
+the Implicit Defaults. Routes can disable route memory and implicit defaults
+either globally, or on a per-route basis. Setting explicit routes:
+
+  $m = new Horde_Routes_Mapper(array('explicit'=>true));
+
+When toggling explicit behavior for individual routes, only the implicit route
+defaults will be de-activated. urlFor() behavior can only be set globally with
+the mapper explicit keyword. Setting explicit behavior for a route:
+
+  $m = new Horde_Routes_Mapper();
+
+  # Note no 'id' value will be assumed for a default
+  $m->connect('archives/:year',
+    array('controller'=>'archives', 'action'=>'view', '_explicit'=>true));
+
+  # This will now require an action and id present
+  $m->connect(':controller/:action/:id',
+    array('_explicit'=>true));
+
+
+5   Sub-domain Support
+
+Routes comes with sub-domain support to make it easy to handle sub-domains in
+an integrated fashion. When sub-domain support is turned on, Routes will
+always have a subDomain argument present with the sub-domain if present, or
+None.
+
+To avoid matching common aliases to your main domain like www, the sub-domain
+support can be set to ignore some sub-domains.
+
+Example:
+
+  $m = new Horde_Routes_Mapper();
+
+  // Turn on sub-domain support
+  $m->subDomains = true;
+
+  // Ignore the www sub-domain
+  $m->subDomainsIgnore = array('www');
+
+
+5.1   Generating URLs with sub-domains
+
+When sub-domain support is on, the urlFor() function will accept a subDomain
+keyword argument. Routes will then ensure that the generated URL has the
+sub-domain indicated. This feature works with Route memory to ensure that the
+sub-domain is only added when necessary.
+
+Some examples:
+
+  // Assuming that the current URL from the request is http://george.example.com/users/edit
+  // Also assuming that you're using the map options above with the default routing of
+  // ':controller/:action/:id'
+
+  $utils->urlFor(array('action'=>'update', 'subDomain'=>'fred'));
+  # -> http://fred.example.com/users/update
+
+  $utils->urlFor(array('controller'=>'/content', 'action'=>'view', 'subDomain'=>'www'));
+  # will become -> http://example.com/content/view
+
+  $utils->urlFor(array('action'=>'new', 'subDomain'=>null));
+  # -> http://example.com/users/new
+
+
+6   RESTful Services
+
+To make it easier to setup RESTful web services with Routes, there's a
+shortcut Mapper method that will setup a batch of routes for you along with
+conditions that will restrict them to specific HTTP methods. This is directly
+styled on the Rails version of $map->resource(), which was based heavily on the
+Atom Publishing Protocol.
+
+The Horde_Routes_Mapper->resource() command creates a set of Routes for common
+operations on a collection of resources, individually referred to as
+'members'. Consider the common case where you have a system that deals with
+users. In that case operations dealing with the entire group of users (or
+perhaps a subset) would be considered collection methods. Operations (or
+actions) that act on an individual member of that collection are considered
+member methods. These terms are important to remember as the options to
+$map->resource() rely on a clear understanding of collection actions vs.
+member actions.
+
+The default mapping that $map->resource() sets up looks like this:
+
+  $map->resource('message', 'messages')
+
+  // Will setup all the routes as if you had typed the following map commands:
+  $map->connect('messages',
+    array('controller'=>'messages', 'action'=>'create',
+          'conditions'=>array('method'=>array('POST'))));
+  $map->connect('messages', 'messages',
+    array('controller'=>'messages', 'action'=>'index',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('formatted_messages', 'messages.:(format)',
+    array('controller'=>'messages', action=>'index',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('new_message', 'messages/new',
+    array('controller'=>'messages', 'action'=>'new',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('formatted_new_message', 'messages/new.:(format)',
+    array('controller'=>'messages', 'action'=>'new',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('messages/:id',
+    array('controller'=>'messages', 'action'=>'update',
+      'conditions'=>array('method'=>array('PUT'))));
+  $map->connect('messages/:id',
+    array('controller'=>'messages', 'action'=>'delete',
+      'conditions'=>array('method'=>array('DELETE'))));
+  $map->connect('edit_message', 'messages/:(id);edit',
+    array('controller'=>'messages', 'action'=>'edit',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('formatted_edit_message', 'messages/:(id).:(format);edit',
+    array('controller'=>'messages', 'action'=>'edit',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('message', 'messages/:id',
+    array('controller'=>'messages', 'action'=>'show',
+          'conditions'=>array('method'=>array('GET'))));
+  $map->connect('formatted_message', 'messages/:(id).:(format)',
+    array('controller'=>'messages', 'action'=>'show',
+      'conditions'=>array('method'=>array('GET'))));
+
+The most important aspects of this is the following mapping that is established:
+
+  GET    /messages         -> messages.index()          -> $utils->urlFor('messages')
+  POST   /messages         -> messages.create()         -> $utils->urlFor('messages')
+  GET    /messages/new     -> messages.new()            -> $utils->urlFor('new_message')
+  PUT    /messages/1       -> messages.update(id)       -> $utils->urlFor('message', array('id'=>1))
+  DELETE /messages/1       -> messages.delete(id)       -> $utils->urlFor('message', array('id'=>1))
+  GET    /messages/1       -> messages.show(id)         -> $utils->urlFor('message', array('id'=>1))
+  GET    /messages/1;edit  -> messages.edit(id)         -> $utils->urlFor('edit_message', array('id'=>1))
+
+*Note* Several of these methods map to functions intended to display forms. The new
+       message method should be used to return a form allowing someone to create a
+       new message, while it should POST to /messages. The edit message function
+       should work similarly returning a form to edit a message, which then performs a
+       PUT to the /messages/1 resource.
+
+Additional methods that respond to either a new member, or different ways of
+viewing collections can be added via keyword arguments to $map->resource() as
+shown in the complete list with examples of the $map->resource() options.
diff --git a/framework/Routes/lib/Horde/Routes/Exception.php b/framework/Routes/lib/Horde/Routes/Exception.php
new file mode 100644 (file)
index 0000000..e6f4a0d
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based 
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * Exception class for the Horde_Routes package.  All exceptions thrown
+ * from the package will be of this type.
+ * 
+ * @package Horde_Routes
+ */
+class Horde_Routes_Exception extends Exception 
+{}
diff --git a/framework/Routes/lib/Horde/Routes/Mapper.php b/framework/Routes/lib/Horde/Routes/Mapper.php
new file mode 100644 (file)
index 0000000..742e433
--- /dev/null
@@ -0,0 +1,1152 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * The mapper class handles URL generation and recognition for web applications
+ *
+ * The mapper class is built by handling associated arrays of information and passing
+ * associated arrays back to the application for it to handle and dispatch the
+ * appropriate scripts.
+ *
+ * @package Horde_Routes
+ */
+class Horde_Routes_Mapper
+{
+    /**
+     * Filtered request environment with keys like SCRIPT_NAME
+     * @var array
+     */
+    public $environ = array();
+
+    /**
+     * Callback function used to get array of controller names
+     * @var callback
+     */
+    public $controllerScan;
+
+    /**
+     * Path to controller directory passed to controllerScan function
+     * @var string
+     */
+    public $directory;
+
+    /**
+     * Call controllerScan callback before every route match?
+     * @var boolean
+     */
+    public $alwaysScan;
+
+    /**
+     * Disable route memory and implicit defaults?
+     * @var boolean
+     */
+    public $explicit;
+
+    /**
+     * Collect debug information during route match?
+     * @var boolean
+     */
+    public $debug = false;
+
+    /**
+     * Use sub-domain support?
+     * @var boolean
+     */
+    public $subDomains = false;
+
+    /**
+     * Array of sub-domains to ignore if using sub-domain support
+     * @var array
+     */
+    public $subDomainsIgnore = array();
+
+    /**
+     * Append trailing slash ('/') to generated routes?
+     * @var boolean
+     */
+    public $appendSlash = false;
+
+    /**
+     * Prefix to strip during matching and to append during generation
+     * @var null|string
+     */
+    public $prefix = null;
+
+    /**
+     * Array of connected routes
+     * @var array
+     */
+    public $matchList = array();
+
+    /**
+     * Array of connected named routes, indexed by name
+     * @var array
+     */
+    public $routeNames = array();
+
+    /**
+     * Cache of URLs used in generate()
+     * @var array
+     */
+    public $urlCache = array();
+
+    /**
+     * Encoding of routes URLs (not yet supported)
+     * @var string
+     */
+    public $encoding = 'utf-8';
+
+    /**
+     * What to do on decoding errors?  'ignore' or 'replace'
+     * @var string
+     */
+    public $decodeErrors = 'ignore';
+
+    /**
+     * Partial regexp used to match domain part of the end of URLs to match
+     * @var string
+     */
+    public $domainMatch = '[^\.\/]+?\.[^\.\/]+';
+
+    /**
+     * Array of all connected routes, indexed by the serialized array of all
+     * keys that each route could utilize.
+     * @var array
+     */
+    public $maxKeys = array();
+
+    /**
+     * Array of all connected routes, indexed by the serialized array of the
+     * minimum keys that each route needs.
+     * @var array
+     */
+    public $minKeys = array();
+
+    /**
+     * Utility functions like urlFor() and redirectTo() for this Mapper
+     * @var Horde_Routes_Utils
+     */
+    public $utils;
+
+    /**
+     * Have regular expressions been created for all connected routes?
+     * @var boolean
+     */
+    protected $_createdRegs = false;
+
+    /**
+     * Have generation hashes been created for all connected routes?
+     * @var boolean
+     */
+    protected $_createdGens = false;
+
+    /**
+     * Generation hashes created for all connected routes
+     * @var array
+     */
+    protected $_gendict;
+
+    /**
+     * Temporary variable used to pass array of keys into _keysort() callback
+     * @var array
+     */
+    protected $_keysortTmp;
+
+    /**
+     * Regular expression generated to match after the prefix
+     * @var string
+     */
+    protected $_regPrefix = null;
+
+
+    /**
+     * Constructor.
+     *
+     * Keyword arguments ($kargs):
+     *   ``controllerScan`` (callback)
+     *     Function to return an array of valid controllers
+     *
+     *   ``redirect`` (callback)
+     *     Function to perform a redirect for Horde_Routes_Utils->redirectTo()
+     *
+     *   ``directory`` (string)
+     *     Path to the directory that will be passed to the
+     *     controllerScan callback
+     *
+     *   ``alwaysScan`` (boolean)
+     *     Should the controllerScan callback be called
+     *     before every URL match?
+     *
+     *   ``explicit`` (boolean)
+     *      Should routes be connected with the implicit defaults of
+     *      array('controller'=>'content', 'action'=>'index', 'id'=>null)?
+     *      When set to True, these will not be added to route connections.
+     */
+    public function __construct($kargs = array())
+    {
+        $callback = array('Horde_Routes_Utils', 'controllerScan');
+
+        $defaultKargs = array('controllerScan' => $callback,
+                              'directory'      => null,
+                              'alwaysScan'     => false,
+                              'explicit'       => false);
+        $kargs = array_merge($defaultKargs, $kargs);
+
+        // Most default assignments that were in the construct in the Python
+        // version have been moved to outside the constructor unless they were variable
+
+        $this->directory      = $kargs['directory'];
+        $this->alwaysScan     = $kargs['alwaysScan'];
+        $this->controllerScan = $kargs['controllerScan'];
+        $this->explicit       = $kargs['explicit'];
+
+        $this->utils = new Horde_Routes_Utils($this);
+    }
+
+    /**
+     * Create and connect a new Route to the Mapper.
+     *
+     * Usage:
+     *   $m = new Horde_Routes_Mapper();
+     *   $m->connect(':controller/:action/:id');
+     *   $m->connect('date/:year/:month/:day', array('controller' => "blog", 'action' => 'view');
+     *   $m->connect('archives/:page', array('controller' => 'blog', 'action' => 'by_page',
+     *                                       '     requirements' => array('page' => '\d{1,2}')));
+     *   $m->connect('category_list',
+     *               'archives/category/:section', array('controller' => 'blog', 'action' => 'category',
+     *                                                   'section' => 'home', 'type' => 'list'));
+     *   $m->connect('home',
+     *               '',
+     *               array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+     *
+     * @param  mixed  $first   First argument in vargs, see usage above.
+     * @param  mixed  $second  Second argument in varags
+     * @param  mixed  $third   Third argument in varargs
+     * @return void
+     */
+    public function connect($first, $second = null, $third = null)
+    {
+        if ($third !== null) {
+            // 3 args given
+            // connect('route_name', ':/controller/:action/:id', array('kargs'=>'here'))
+            $routeName = $first;
+            $routePath = $second;
+            $kargs     = $third;
+        } else if ($second !== null) {
+            // 2 args given
+            if (is_array($second)) {
+                // connect(':/controller/:action/:id', array('kargs'=>'here'))
+                $routeName = null;
+                $routePath = $first;
+                $kargs     = $second;
+            } else {
+                // connect('route_name', ':/controller/:action/:id')
+                $routeName = $first;
+                $routePath = $second;
+                $kargs     = array();
+            }
+        } else {
+            // 1 arg given
+            // connect('/:controller/:action/:id')
+            $routeName = null;
+            $routePath = $first;
+            $kargs     = array();
+        }
+
+        if (!in_array('_explicit', $kargs)) {
+            $kargs['_explicit'] = $this->explicit;
+        }
+
+        $route = new Horde_Routes_Route($routePath, $kargs);
+
+        if ($this->encoding != 'utf-8' || $this->decodeErrors != 'ignore') {
+            $route->encoding = $this->encoding;
+            $route->decodeErrors = $this->decodeErrors;
+        }
+
+        $this->matchList[] = $route;
+
+        if (isset($routeName)) {
+            $this->routeNames[$routeName] = $route;
+        }
+
+        if ($route->static) {
+            return;
+        }
+
+        $exists = false;
+        foreach ($this->maxKeys as $key => $value) {
+            if (unserialize($key) == $route->maxKeys) {
+                $this->maxKeys[$key][] = $route;
+                $exists = true;
+                break;
+            }
+        }
+
+        if (!$exists) {
+            $this->maxKeys[serialize($route->maxKeys)] = array($route);
+        }
+
+        $this->_createdGens = false;
+    }
+
+    /**
+     * Create the generation hashes (arrays) for route lookups
+     *
+     * @return void
+     */
+    protected function _createGens()
+    {
+        // Use keys temporarily to assemble the list to avoid excessive
+        // list iteration testing with foreach.  We include the '*' in the
+        // case that a generate contains a controller/action that has no
+        // hardcodes.
+        $actionList = $controllerList = array('*' => true);
+
+        // Assemble all the hardcoded/defaulted actions/controllers used
+        foreach ($this->matchList as $route) {
+            if ($route->static) {
+                continue;
+            }
+            if (isset($route->defaults['controller'])) {
+                $controllerList[$route->defaults['controller']] = true;
+            }
+            if (isset($route->defaults['action'])) {
+                $actionList[$route->defaults['action']] = true;
+            }
+        }
+
+        $actionList = array_keys($actionList);
+        $controllerList = array_keys($controllerList);
+
+        // Go through our list again, assemble the controllers/actions we'll
+        // add each route to. If its hardcoded, we only add it to that dict key.
+        // Otherwise we add it to every hardcode since it can be changed.
+        $gendict = array();  // Our generated two-deep hash
+        foreach ($this->matchList as $route) {
+            if ($route->static) {
+                continue;
+            }
+            $clist = $controllerList;
+            $alist = $actionList;
+            if (in_array('controller', $route->hardCoded)) {
+                $clist = array($route->defaults['controller']);
+            }
+            if (in_array('action', $route->hardCoded)) {
+                $alist = array($route->defaults['action']);
+            }
+            foreach ($clist as $controller) {
+                foreach ($alist as $action) {
+                    if (in_array($controller, array_keys($gendict))) {
+                        $actiondict = &$gendict[$controller];
+                    } else {
+                        $gendict[$controller] = array();
+                        $actiondict = &$gendict[$controller];
+                    }
+                    if (in_array($action, array_keys($actiondict))) {
+                        $tmp = $actiondict[$action];
+                    } else {
+                        $tmp = array(array(), array());
+                    }
+                    $tmp[0][] = $route;
+                    $actiondict[$action] = $tmp;
+                }
+            }
+        }
+        if (!isset($gendict['*'])) {
+            $gendict['*'] = array();
+        }
+        $this->_gendict = $gendict;
+        $this->_createdGens = true;
+    }
+
+    /**
+     * Creates the regexes for all connected routes
+     *
+     * @param  array $clist  controller list, controller_scan will be used otherwise
+     * @return void
+     */
+    public function createRegs($clist = null)
+    {
+        if ($clist === null) {
+            if ($this->directory === null) {
+                $clist = call_user_func($this->controllerScan);
+            } else {
+                $clist = call_user_func($this->controllerScan, $this->directory);
+            }
+        }
+
+        foreach ($this->maxKeys as $key => $val) {
+            foreach ($val as $route) {
+                $route->makeRegexp($clist);
+            }
+        }
+
+        // Create our regexp to strip the prefix
+        if (!empty($this->prefix)) {
+            $this->_regPrefix = $this->prefix . '(.*)';
+        }
+        $this->_createdRegs = true;
+    }
+
+    /**
+     * Internal Route matcher
+     *
+     * Matches a URL against a route, and returns a tuple (array) of the
+     * match dict (array) and the route object if a match is successful,
+     * otherwise it returns null.
+     *
+     * @param   string      $url  URL to match
+     * @return  null|array        Match data if matched, otherwise null
+     */
+    protected function _match($url)
+    {
+        if (!$this->_createdRegs && !empty($this->controllerScan)) {
+            $this->createRegs();
+        } else if (!$this->_createdRegs) {
+            $msg = 'You must generate the regular expressions before matching.';
+            throw new Horde_Routes_Exception($msg);
+        }
+
+        if ($this->alwaysScan) {
+            $this->createRegs();
+        }
+
+        $matchLog = array();
+        if (!empty($this->prefix)) {
+            if (preg_match('@' . $this->_regPrefix . '@', $url)) {
+                $url = preg_replace('@' . $this->_regPrefix . '@', '$1', $url);
+                if (empty($url)) {
+                    $url = '/';
+                }
+            }
+            else {
+                return array(null, null, $matchLog);
+            }
+        }
+
+        foreach ($this->matchList as $route) {
+            if ($route->static) {
+                if ($this->debug) {
+                    $matchLog[] = array('route' => $route, 'static' => true);
+                }
+                continue;
+            }
+
+            $match = $route->match($url, array('environ'          => $this->environ,
+                                               'subDomains'       => $this->subDomains,
+                                               'subDomainsIgnore' => $this->subDomainsIgnore,
+                                               'domainMatch'      => $this->domainMatch));
+            if ($this->debug) {
+                $matchLog[] = array('route' => $route, 'regexp' => (bool)$match);
+            }
+            if ($match) {
+                return array($match, $route, $matchLog);
+            }
+        }
+
+        return array(null, null, $matchLog);
+    }
+
+    /**
+     * Match a URL against one of the routes contained.
+     * It will return null if no valid match is found.
+     *
+     * Usage:
+     *   $resultdict = $m->match('/joe/sixpack');
+     *
+     * @param  string      $url  URL to match
+     * @param  array|null        Array if matched, otherwise null
+     */
+    public function match($url)
+    {
+        if (!strlen($url)) {
+            $msg = 'No URL provided, the minimum URL necessary to match is "/"';
+            throw new Horde_Routes_Exception($msg);
+        }
+
+        $result = $this->_match($url);
+
+        if ($this->debug) {
+            return array($result[0], $result[1], $result[2]);
+        }
+
+        return ($result[0]) ? $result[0] : null;
+    }
+
+    /**
+     * Match a URL against one of the routes contained.
+     * It will return null if no valid match is found, otherwise
+     * a result dict (array) and a route object is returned.
+     *
+     * Usage:
+     *   list($resultdict, $resultobj) = $m->match('/joe/sixpack');
+     *
+     * @param  string      $url  URL to match
+     * @param  array|null        Array if matched, otherwise null
+     */
+    public function routematch($url)
+    {
+        $result = $this->_match($url);
+
+        if ($this->debug) {
+            return array($result[0], $result[1], $result[2]);
+        }
+
+        return ($result[0]) ? array($result[0], $result[1]) : null;
+    }
+
+    /**
+     * Generates the URL from a given set of keywords
+     * Returns the URL text, or null if no URL could be generated.
+     *
+     * Usage:
+     *   $m->generate(array('controller' => 'content', 'action' => 'view', 'id' => 10));
+     *
+     * @param   array        $kargs  Keyword arguments (key/value pairs)
+     * @return  null|string          URL text or null
+     */
+    public function generate($kargs = array())
+    {
+        // Generate ourself if we haven't already
+        if (!$this->_createdGens) {
+            $this->_createGens();
+        }
+
+        if ($this->appendSlash) {
+            $kargs['_appendSlash'] = true;
+        }
+
+        if (!$this->explicit) {
+            if (!in_array('controller', array_keys($kargs))) {
+                $kargs['controller'] = 'content';
+            }
+            if (!in_array('action', array_keys($kargs))) {
+                $kargs['action'] = 'index';
+            }
+        }
+
+        $environ = $this->environ;
+        $controller = isset($kargs['controller']) ? $kargs['controller'] : null;
+        $action = isset($kargs['action']) ? $kargs['action'] : null;
+
+        // If the URL didn't depend on the SCRIPT_NAME, we'll cache it
+        // keyed by just the $kargs; otherwise we need to cache it with
+        // both SCRIPT_NAME and $kargs:
+        $cacheKey = $kargs;
+        if (!empty($environ['SCRIPT_NAME'])) {
+            $cacheKeyScriptName = sprintf('%s:%s', $environ['SCRIPT_NAME'], $cacheKey);
+        } else {
+            $cacheKeyScriptName = $cacheKey;
+        }
+
+        // Check the URL cache to see if it exists, use it if it does.
+        foreach (array($cacheKey, $cacheKeyScriptName) as $key) {
+            if (in_array($key, array_keys($this->urlCache))) {
+                return $this->urlCache[$key];
+            }
+        }
+
+        $actionList = isset($this->_gendict[$controller]) ? $this->_gendict[$controller] : $this->_gendict['*'];
+
+        list($keyList, $sortCache) =
+            (isset($actionList[$action])) ? $actionList[$action] : ((isset($actionList['*'])) ? $actionList['*'] : array(null, null));
+
+        if ($keyList === null) {
+            return null;
+        }
+
+        $keys = array_keys($kargs);
+
+        // necessary to pass $keys to _keysort() callback used by PHP's usort()
+        $this->_keysortTmp = $keys;
+
+        $newList = array();
+        foreach ($keyList as $route) {
+            $tmp = Horde_Routes_Utils::arraySubtract($route->minKeys, $keys);
+            if (count($tmp) == 0) {
+                $newList[] = $route;
+            }
+        }
+        $keyList = $newList;
+
+        // inline python function keysort() moved below as _keycmp()
+
+        $this->_keysort($keyList);
+
+        foreach ($keyList as $route) {
+            $fail = false;
+            foreach ($route->hardCoded as $key) {
+                $kval = isset($kargs[$key]) ? $kargs[$key] : null;
+                if ($kval == null) {
+                    continue;
+                }
+
+                if ($kval != $route->defaults[$key]) {
+                    $fail = true;
+                    break;
+                }
+            }
+            if ($fail) {
+                continue;
+            }
+
+            $path = $route->generate($kargs);
+
+            if ($path) {
+                if ($this->prefix) {
+                    $path = $this->prefix . $path;
+                }
+                if (!empty($environ['SCRIPT_NAME']) && !$route->absolute) {
+                    $path = $environ['SCRIPT_NAME'] . $path;
+                    $key = $cacheKeyScriptName;
+                } else {
+                    $key = $cacheKey;
+                }
+                if ($this->urlCache != null) {
+                    $this->urlCache[$key] = $path;
+                }
+                return $path;
+            } else {
+                continue;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Generate routes for a controller resource
+     *
+     * The $memberName name should be the appropriate singular version of the
+     * resource given your locale and used with members of the collection.
+     *
+     * The $collectionName name will be used to refer to the resource
+     * collection methods and should be a plural version of the $memberName
+     * argument. By default, the $memberName name will also be assumed to map
+     * to a controller you create.
+     *
+     * The concept of a web resource maps somewhat directly to 'CRUD'
+     * operations. The overlying things to keep in mind is that mapping a
+     * resource is about handling creating, viewing, and editing that
+     * resource.
+     *
+     * All keyword arguments ($kargs) are optional.
+     *
+     * ``controller``
+     *     If specified in the keyword args, the controller will be the actual
+     *     controller used, but the rest of the naming conventions used for
+     *     the route names and URL paths are unchanged.
+     *
+     * ``collection``
+     *     Additional action mappings used to manipulate/view the entire set of
+     *     resources provided by the controller.
+     *
+     *     Example::
+     *
+     *         $map->resource('message', 'messages',
+     *                        array('collection' => array('rss' => 'GET)));
+     *         # GET /message;rss (maps to the rss action)
+     *         # also adds named route "rss_message"
+     *
+     * ``member``
+     *      Additional action mappings used to access an individual 'member'
+     *      of this controllers resources.
+     *
+     *      Example::
+     *
+     *          $map->resource('message', 'messages',
+     *                         array('member' => array('mark' => 'POST')));
+     *          # POST /message/1;mark (maps to the mark action)
+     *          # also adds named route "mark_message"
+     *
+     *  ``new``
+     *      Action mappings that involve dealing with a new member in the
+     *      controller resources.
+     *
+     *      Example::
+     *
+     *          $map->resource('message', 'messages',
+     *                         array('new' => array('preview' => 'POST')));
+     *          # POST /message/new;preview (maps to the preview action)
+     *          # also adds a url named "preview_new_message"
+     *
+     *  ``pathPrefix``
+     *      Prepends the URL path for the Route with the pathPrefix given.
+     *      This is most useful for cases where you want to mix resources
+     *      or relations between resources.
+     *
+     *  ``namePrefix``
+     *      Perpends the route names that are generated with the namePrefix
+     *      given. Combined with the pathPrefix option, it's easy to
+     *      generate route names and paths that represent resources that are
+     *      in relations.
+     *
+     *      Example::
+     *
+     *          map.resource('message', 'messages',
+     *                       array('controller' => 'categories',
+     *                             'pathPrefix' => '/category/:category_id',
+     *                             'namePrefix' => 'category_')));
+     *              # GET /category/7/message/1
+     *              # has named route "category_message"
+     *
+     *  ``parentResource``
+     *      An assoc. array containing information about the parent resource,
+     *      for creating a nested resource. It should contain the ``$memberName``
+     *      and ``collectionName`` of the parent resource. This assoc. array will
+     *      be available via the associated ``Route`` object which can be
+     *      accessed during a request via ``request.environ['routes.route']``
+     *
+     *      If ``parentResource`` is supplied and ``pathPrefix`` isn't,
+     *      ``pathPrefix`` will be generated from ``parentResource`` as
+     *      "<parent collection name>/:<parent member name>_id".
+     *
+     *      If ``parentResource`` is supplied and ``namePrefix`` isn't,
+     *      ``namePrefix`` will be generated from ``parentResource`` as
+     *      "<parent member name>_".
+     *
+     *      Example::
+     *
+     *          $m = new Horde_Routes_Mapper();
+     *          $utils = $m->utils;
+     *
+     *          $m->resource('location', 'locations',
+     *                       array('parentResource' =>
+     *                              array('memberName' => 'region',
+     *                                    'collectionName' => 'regions'))));
+     *          # pathPrefix is "regions/:region_id"
+     *          # namePrefix is "region_"
+     *
+     *          $utils->urlFor('region_locations', array('region_id'=>13));
+     *          # '/regions/13/locations'
+     *
+     *          $utils->urlFor('region_new_location', array('region_id'=>13));
+     *          # '/regions/13/locations/new'
+     *
+     *          $utils->urlFor('region_location',
+     *                        array('region_id'=>13, 'id'=>60));
+     *          # '/regions/13/locations/60'
+     *
+     *          $utils->urlFor('region_edit_location',
+     *                        array('region_id'=>13, 'id'=>60));
+     *          # '/regions/13/locations/60/edit'
+     *
+     *   Overriding generated ``pathPrefix``::
+     *
+     *      $m = new Horde_Routes_Mapper();
+     *      $utils = new Horde_Routes_Utils();
+     *
+     *      $m->resource('location', 'locations',
+     *                   array('parentResource' =>
+     *                         array('memberName' => 'region',
+     *                               'collectionName' => 'regions'),
+     *                         'pathPrefix' => 'areas/:area_id')));
+     *       # name prefix is "region_"
+     *
+     *       $utils->urlFor('region_locations', array('area_id'=>51));
+     *       # '/areas/51/locations'
+     *
+     *   Overriding generated ``namePrefix``::
+     *
+     *       $m = new Horde_Routes_Mapper
+     *      $m->resource('location', 'locations',
+     *                   array('parentResource' =>
+     *                         array('memberName' => 'region',
+     *                               'collectionName' => 'regions'),
+     *                         'namePrefix' => '')));
+     *       # pathPrefix is "regions/:region_id"
+     *
+     *       $utils->urlFor('locations', array('region_id'=>51));
+     *       # '/regions/51/locations'
+     *
+     * Note: Since Horde Routes 0.2.0 and Python Routes 1.8, this method is
+     * not compatible with earlier versions inasmuch as the semicolon is no 
+     * longer used to delimit custom actions.  This was a change in Rails
+     * itself (http://dev.rubyonrails.org/changeset/6485) and adopting it
+     * here allows us to keep parity with Rails and ActiveResource.
+     *
+     * @param  string  $memberName      Singular version of the resource name
+     * @param  string  $collectionName  Collection name (plural of $memberName)
+     * @param  array   $kargs           Keyword arguments (see above)
+     * @return void
+     */
+    public function resource($memberName, $collectionName, $kargs = array())
+    {
+        $defaultKargs = array('collection' => array(),
+                              'member' => array(),
+                              'new' => array(),
+                              'pathPrefix' => null,
+                              'namePrefix' => null,
+                              'parentResource' => null);
+        $kargs = array_merge($defaultKargs, $kargs);
+
+        // Generate ``pathPrefix`` if ``pathPrefix`` wasn't specified and
+        // ``parentResource`` was. Likewise for ``namePrefix``. Make sure
+        // that ``pathPrefix`` and ``namePrefix`` *always* take precedence if
+        // they are specified--in particular, we need to be careful when they
+        // are explicitly set to "".
+        if ($kargs['parentResource'] !== null) {
+            if ($kargs['pathPrefix'] === null) {
+                $kargs['pathPrefix'] = $kargs['parentResource']['collectionName'] . '/:'
+                                     . $kargs['parentResource']['memberName']     . '_id';
+            }
+            if ($kargs['namePrefix'] === null) {
+                $kargs['namePrefix'] = $kargs['parentResource']['memberName'] . '_';
+            }
+        } else {
+            if ($kargs['pathPrefix'] === null) {
+                $kargs['pathPrefix'] = '';
+            }
+            if ($kargs['namePrefix'] === null) {
+                $kargs['namePrefix'] = '';
+            }
+        }
+
+        // Ensure the edit and new actions are in and GET
+        $kargs['member']['edit'] = 'GET';
+        $kargs['new']['new'] = 'GET';
+
+        // inline python method swap() moved below as _swap()
+
+        $collectionMethods = $this->_swap($kargs['collection'], array());
+        $memberMethods = $this->_swap($kargs['member'], array());
+        $newMethods = $this->_swap($kargs['new'], array());
+
+        // Insert create, update, and destroy methods
+        if (!isset($collectionMethods['POST'])) {
+            $collectionMethods['POST'] = array();
+        }
+        array_unshift($collectionMethods['POST'], 'create');
+
+        if (!isset($memberMethods['PUT'])) {
+            $memberMethods['PUT'] = array();
+        }
+        array_unshift($memberMethods['PUT'], 'update');
+
+        if (!isset($memberMethods['DELETE'])) {
+            $memberMethods['DELETE'] = array();
+        }
+        array_unshift($memberMethods['DELETE'], 'delete');
+
+        // If there's a path prefix option, use it with the controller
+        $controller = $this->_stripSlashes($collectionName);
+        $kargs['pathPrefix'] = $this->_stripSlashes($kargs['pathPrefix']);
+        if ($kargs['pathPrefix']) {
+            $path = $kargs['pathPrefix'] . '/' . $controller;
+        } else {
+            $path = $controller;
+        }
+        $collectionPath = $path;
+        $newPath = $path . '/new';
+        $memberPath = $path . '/:(id)';
+
+        $options = array(
+            'controller' => (isset($kargs['controller']) ? $kargs['controller'] : $controller),
+            '_memberName'     => $memberName,
+            '_collectionName' => $collectionName,
+            '_parentResource' => $kargs['parentResource']
+        );
+
+        // inline python method requirements_for() moved below as _requirementsFor()
+
+        // Add the routes for handling collection methods
+        foreach ($collectionMethods as $method => $lst) {
+            $primary = ($method != 'GET' && isset($lst[0])) ? array_shift($lst) : null;
+            $routeOptions = $this->_requirementsFor($method, $options);
+
+            foreach ($lst as $action) {
+                $routeOptions['action'] = $action;
+                $routeName = sprintf('%s%s_%s', $kargs['namePrefix'], $action, $collectionName);
+
+                $this->connect($routeName,
+                               sprintf("%s/%s", $collectionPath, $action),
+                               $routeOptions);
+                $this->connect('formatted_' . $routeName,
+                               sprintf("%s/%s.:(format)", $collectionPath, $action),
+                               $routeOptions);
+            }
+            if ($primary) {
+                $routeOptions['action'] = $primary;
+                $this->connect($collectionPath, $routeOptions);
+                $this->connect($collectionPath . '.:(format)', $routeOptions);
+            }
+        }
+
+        // Specifically add in the built-in 'index' collection method and its
+        // formatted version
+        $connectkargs = array('action' => 'index',
+                              'conditions' => array('method' => array('GET')));
+        $this->connect($kargs['namePrefix'] . $collectionName,
+                       $collectionPath,
+                       array_merge($connectkargs, $options));
+        $this->connect('formatted_' . $kargs['namePrefix'] . $collectionName,
+                       $collectionPath . '.:(format)',
+                       array_merge($connectkargs, $options));
+
+        // Add the routes that deal with new resource methods
+        foreach ($newMethods as $method => $lst) {
+            $routeOptions = $this->_requirementsFor($method, $options);
+            foreach ($lst as $action) {
+                if ($action == 'new' && $newPath) {
+                    $path = $newPath;
+                } else {
+                    $path = sprintf('%s/%s', $newPath, $action);
+                }
+
+                $name = 'new_' . $memberName;
+                if ($action != 'new') {
+                    $name = $action . '_' . $name;
+                }
+                $routeOptions['action'] = $action;
+                $this->connect($kargs['namePrefix'] . $name, $path, $routeOptions);
+
+                if ($action == 'new' && $newPath) {
+                    $path = $newPath . '.:(format)';
+                } else {
+                    $path = sprintf('%s/%s.:(format)', $newPath, $action);
+                }
+
+                $this->connect('formatted_' . $kargs['namePrefix'] . $name,
+                               $path, $routeOptions);
+            }
+        }
+
+        $requirementsRegexp = '[\w\-_]+';
+
+        // Add the routes that deal with member methods of a resource
+        foreach ($memberMethods as $method => $lst) {
+            $routeOptions = $this->_requirementsFor($method, $options);
+            $routeOptions['requirements'] = array('id' => $requirementsRegexp);
+
+            if (!in_array($method, array('POST', 'GET', 'any'))) {
+                $primary = array_shift($lst);
+            } else {
+                $primary = null;
+            }
+
+            foreach ($lst as $action) {
+                $routeOptions['action'] = $action;
+                $this->connect(sprintf('%s%s_%s', $kargs['namePrefix'], $action, $memberName),
+                               sprintf('%s/%s', $memberPath, $action),
+                               $routeOptions);
+                $this->connect(sprintf('formatted_%s%s_%s', $kargs['namePrefix'], $action, $memberName),
+                               sprintf('%s/%s.:(format)', $memberPath, $action),
+                               $routeOptions);
+            }
+
+            if ($primary) {
+                $routeOptions['action'] = $primary;
+                $this->connect($memberPath, $routeOptions);
+                $this->connect($memberPath . '.:(format)', $routeOptions);
+            }
+        }
+
+        // Specifically add the member 'show' method
+        $routeOptions = $this->_requirementsFor('GET', $options);
+        $routeOptions['action'] = 'show';
+        $routeOptions['requirements'] = array('id' => $requirementsRegexp);
+        $this->connect($kargs['namePrefix'] . $memberName, $memberPath, $routeOptions);
+        $this->connect('formatted_' . $kargs['namePrefix'] . $memberName,
+                       $memberPath . '.:(format)', $routeOptions);
+    }
+
+    /**
+     * Returns a new dict to be used for all route creation as
+     * the route options.
+     * @see resource()
+     *
+     * @param  string  $method   Request method ('get', 'post', etc.) or 'any'
+     * @param  array   $options  Assoc. array to populate with 'conditions' key
+     * @return                   $options populated
+     */
+    protected function _requirementsFor($meth, $options)
+    {
+        if ($meth != 'any') {
+            $options['conditions'] = array('method' => array(strtoupper($meth)));
+        }
+        return $options;
+    }
+
+    /**
+     * Swap the keys and values in the dict, and uppercase the values
+     * from the dict during the swap.
+     * @see resource()
+     *
+     * @param  array  $dct     Input dict (assoc. array)
+     * @param  array  $newdct  Output dict to populate
+     * @return array           $newdct populated
+     */
+    protected function _swap($dct, $newdct)
+    {
+        foreach ($dct as $key => $val) {
+            $newkey = strtoupper($val);
+            if (!isset($newdct[$newkey])) {
+                $newdct[$newkey] = array();
+            }
+            $newdct[$newkey][] = $key;
+        }
+        return $newdct;
+    }
+
+    /**
+     * Sort an array of Horde_Routes_Routes to using _keycmp() for the comparision
+     * to order them ideally for matching.
+     *
+     * An unfortunate property of PHP's usort() is that if two members compare
+     * equal, their order in the sorted array is undefined (see PHP manual).
+     * This is unsuitable for us because the order that the routes were
+     * connected to the mapper is significant.
+     *
+     * Uses this method uses merge sort algorithm based on the
+     * comments in http://www.php.net/usort
+     *
+     * @param  array  $array  Array Horde_Routes_Route objects to sort (by reference)
+     * @return void
+     */
+    protected function _keysort(&$array)
+    {
+        // arrays of size < 2 require no action.
+        if (count($array) < 2) { return; }
+
+        // split the array in half
+        $halfway = count($array) / 2;
+        $array1 = array_slice($array, 0, $halfway);
+        $array2 = array_slice($array, $halfway);
+
+        // recurse to sort the two halves
+        $this->_keysort($array1);
+        $this->_keysort($array2);
+
+        // if all of $array1 is <= all of $array2, just append them.
+        if ($this->_keycmp(end($array1), $array2[0]) < 1) {
+            $array = array_merge($array1, $array2);
+            return;
+        }
+
+        // merge the two sorted arrays into a single sorted array
+        $array = array();
+        $ptr1 = 0;
+        $ptr2 = 0;
+        while ($ptr1 < count($array1) && $ptr2 < count($array2)) {
+            if ($this->_keycmp($array1[$ptr1], $array2[$ptr2]) < 1) {
+                $array[] = $array1[$ptr1++];
+            }
+            else {
+                $array[] = $array2[$ptr2++];
+            }
+        }
+
+        // merge the remainder
+        while ($ptr1 < count($array1)) { $array[] = $array1[$ptr1++]; }
+        while ($ptr2 < count($array2)) { $array[] = $array2[$ptr2++]; }
+        return;
+    }
+
+    /**
+     * Compare two Horde_Route_Routes objects by their keys against
+     * the instance variable $keysortTmp.  Used by _keysort().
+     *
+     * @param  array  $a  First dict (assoc. array)
+     * @param  array  $b  Second dict
+     * @return integer
+     */
+    protected function _keycmp($a, $b)
+    {
+        $keys = $this->_keysortTmp;
+        $am = $a->minKeys;
+        $a = $a->maxKeys;
+        $b = $b->maxKeys;
+
+        $lendiffa = count(array_diff($keys, $a));
+        $lendiffb = count(array_diff($keys, $b));
+
+        // If they both match, don't switch them
+        if ($lendiffa == 0 && $lendiffb == 0) {
+            return 0;
+        }
+
+        // First, if $a matches exactly, use it
+        if ($lendiffa == 0) {
+            return -1;
+        }
+
+        // Or $b matches exactly, use it
+        if ($lendiffb == 0) {
+            return 1;
+        }
+
+        // Neither matches exactly, return the one with the most in common
+        if ($this->_cmp($lendiffa, $lendiffb) != 0) {
+            return $this->_cmp($lendiffa, $lendiffb);
+        }
+
+        // Neither matches exactly, but if they both have just as much in common
+        if (count($this->_arrayUnion($keys, $b)) == count($this->_arrayUnion($keys, $a))) {
+            return $this->_cmp(count($a), count($b));
+
+        // Otherwise, we return the one that has the most in common
+        } else {
+            return $this->_cmp(count($this->_arrayUnion($keys, $b)), count($this->_arrayUnion($keys, $a)));
+        }
+    }
+
+    /**
+     * Create a union of two arrays.
+     *
+     * @param  array  $a  First array
+     * @param  array  $b  Second array
+     * @return array      Union of $a and $b
+     */
+    protected function _arrayUnion($a, $b)
+    {
+        return array_merge(array_diff($a, $b), array_diff($b, $a), array_intersect($a, $b));
+    }
+
+    /**
+     * Equivalent of Python's cmp() function.
+     *
+     * @param  integer|float  $a  First item to compare
+     * @param  integer|flot   $b  Second item to compare
+     * @param  integer            Result of comparison
+     */
+    protected function _cmp($a, $b)
+    {
+        if ($a < $b) {
+            return -1;
+        }
+        if ($a == $b) {
+            return 0;
+        }
+        return 1;
+    }
+
+    /**
+     * Trims slashes from the beginning or end of a part/URL.
+     *
+     * @param  string  $name  Part or URL with slash at begin/end
+     * @return string         Part or URL with begin/end slashes removed
+     */
+    protected function _stripSlashes($name)
+    {
+        if (substr($name, 0, 1) == '/') {
+            $name = substr($name, 1);
+        }
+        if (substr($name, -1, 1) == '/') {
+            $name = substr($name, 0, -1);
+        }
+        return $name;
+    }
+
+}
+
diff --git a/framework/Routes/lib/Horde/Routes/Route.php b/framework/Routes/lib/Horde/Routes/Route.php
new file mode 100644 (file)
index 0000000..fb7c298
--- /dev/null
@@ -0,0 +1,828 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * The Route object holds a route recognition and generation routine.
+ * See __construct() docs for usage.
+ *
+ * @package Horde_Routes
+ */
+class Horde_Routes_Route
+{
+    /**
+     * The path for this route, such as ':controller/:action/:id'
+     * @var string
+     */
+    public $routePath;
+
+    /**
+     * Encoding of this route (not yet supported)
+     * @var string
+     */
+    public $encoding = 'utf-8';
+
+    /**
+     * What to do on decoding errors?  'ignore' or 'replace'
+     * @var string
+     */
+    public $decodeErrors = 'replace';
+
+    /**
+     * Is this a static route?
+     * @var string
+     */
+    public $static;
+
+    /**
+     * Filter function to operate on arguments before generation
+     * @var callback
+     */
+    public $filter;
+
+    /**
+     * Is this an absolute path?  (Mapper will not prepend SCRIPT_NAME)
+     * @var boolean
+     */
+    public $absolute;
+
+    /**
+     * Does this route use explicit mode (no implicit defaults)?
+     * @var boolean
+     */
+    public $explicit;
+
+    /**
+     * Default keyword arguments for this route
+     * @var array
+     */
+    public $defaults = array();
+
+    /**
+     * Array of keyword args for special conditions (method, subDomain, function)
+     * @var array
+     */
+    public $conditions;
+
+    /**
+     * Maximum keys that this route could utilize.
+     * @var array
+     */
+    public $maxKeys;
+
+    /**
+     * Minimum keys required to generate this route
+     * @var array
+     */
+    public $minKeys;
+
+    /**
+     * Default keywords that don't exist in the path; can't be changed by an incomng URL.
+     * @var array
+     */
+    public $hardCoded;
+
+    /**
+     * Requirements for this route
+     * @var array
+     */
+    public $reqs;
+
+    /**
+     * Regular expression for matching this route
+     * @var string
+     */
+    public $regexp;
+
+    /**
+     * Route path split by '/'
+     * @var array
+     */
+    protected $_routeList;
+
+    /**
+     * Reverse of $routeList
+     * @var array
+     */
+    protected $_routeBackwards;
+
+    /**
+     * Characters that split the parts of a URL
+     * @var array
+     */
+    protected $_splitChars;
+
+    /**
+     * Last path part used by buildNextReg()
+     * @var string
+     */
+    protected $_prior;
+
+    /**
+     * Requirements formatted as regexps suitable for preg_match()
+     * @var array
+     */
+    protected $_reqRegs;
+
+    /**
+     * Member name if this is a RESTful route
+     * @see resource()
+     * @var null|string
+     */
+    protected $_memberName;
+
+    /**
+     * Collection name if this is a RESTful route
+     * @see resource()
+     * @var null|string
+     */
+    protected $_collectionName;
+
+    /**
+     * Name of the parent resource, if this is a RESTful route & has a parent
+     * @see resource
+     * @var string
+     */
+    protected $_parentResource;
+
+
+    /**
+     *  Initialize a route, with a given routepath for matching/generation
+     *
+     *  The set of keyword args will be used as defaults.
+     *
+     *  Usage:
+     *      $route = new Horde_Routes_Route(':controller/:action/:id');
+     *
+     *      $route = new Horde_Routes_Route('date/:year/:month/:day',
+     *                      array('controller'=>'blog', 'action'=>'view'));
+     *
+     *      $route = new Horde_Routes_Route('archives/:page',
+     *                      array('controller'=>'blog', 'action'=>'by_page',
+     *                            'requirements' => array('page'=>'\d{1,2}'));
+     *
+     *  Note:
+     *      Route is generally not called directly, a Mapper instance connect()
+     *      method should be used to add routes.
+     */
+    public function __construct($routePath, $kargs = array())
+    {
+        $this->routePath = $routePath;
+
+        // Don't bother forming stuff we don't need if its a static route
+        $this->static = isset($kargs['_static']) ? $kargs['_static'] : false;
+
+        $this->filter = isset($kargs['_filter']) ? $kargs['_filter'] : null;
+        unset($kargs['_filter']);
+
+        $this->absolute = isset($kargs['_absolute']) ? $kargs['_absolute'] : false;
+        unset($kargs['absolute']);
+
+        // Pull out the member/collection name if present, this applies only to
+        // map.resource
+        $this->_memberName = isset($kargs['_memberName']) ? $kargs['_memberName'] : null;
+        unset($kargs['_memberName']);
+
+        $this->_collectionName = isset($kargs['_collectionName']) ? $kargs['_collectionName'] : null;
+        unset($kargs['_collectionName']);
+
+        $this->_parentResource = isset($kargs['_parentResource']) ? $kargs['_parentResource'] : null;
+        unset($kargs['_parentResource']);
+
+        // Pull out route conditions
+        $this->conditions = isset($kargs['conditions']) ? $kargs['conditions'] : null;
+        unset($kargs['conditions']);
+
+        // Determine if explicit behavior should be used
+        $this->explicit = isset($kargs['_explicit']) ? $kargs['_explicit'] : false;
+        unset($kargs['_explicit']);
+
+        // Reserved keys that don't count
+        $reservedKeys = array('requirements');
+
+        // Name has been changed from the Python version
+        // This is a list of characters natural splitters in a URL
+        $this->_splitChars = array('/', ',', ';', '.', '#');
+
+        // trip preceding '/' if present
+        if (substr($this->routePath, 0, 1) == '/') {
+            $routePath = substr($this->routePath, 1);
+        }
+
+        // Build our routelist, and the keys used in the route
+        $this->_routeList = $this->_pathKeys($routePath);
+        $routeKeys = array();
+        foreach ($this->_routeList as $key) {
+            if (is_array($key)) { $routeKeys[] = $key['name']; }
+        }
+
+        // Build a req list with all the regexp requirements for our args
+        $this->reqs = isset($kargs['requirements']) ? $kargs['requirements'] : array();
+        $this->_reqRegs = array();
+        foreach ($this->reqs as $key => $value) {
+            $this->_reqRegs[$key] = '@^' . str_replace('@', '\@', $value) . '$@';
+        }
+
+        // Update our defaults and set new default keys if needed. defaults
+        // needs to be saved
+        list($this->defaults, $defaultKeys) = $this->_defaults($routeKeys, $reservedKeys, $kargs);
+
+        // Save the maximum keys we could utilize
+        $this->maxKeys = array_keys(array_flip(array_merge($defaultKeys, $routeKeys)));
+        list($this->minKeys, $this->_routeBackwards) = $this->_minKeys($this->_routeList);
+
+        // Populate our hardcoded keys, these are ones that are set and don't
+        // exist in the route
+        $this->hardCoded = array();
+        foreach ($this->maxKeys as $key) {
+            if (!in_array($key, $routeKeys) && $this->defaults[$key] != null) {
+                $this->hardCoded[] = $key;
+            }
+        }
+    }
+
+    /**
+     * Utility method to walk the route, and pull out the valid
+     * dynamic/wildcard keys
+     *
+     * @param  string  $routePath  Route path
+     * @return array               Route list
+     */
+    protected function _pathKeys($routePath)
+    {
+        $collecting = false;
+        $current = '';
+        $doneOn = array();
+        $varType = '';
+        $justStarted = false;
+        $routeList = array();
+
+        foreach (preg_split('//', $routePath, -1, PREG_SPLIT_NO_EMPTY) as $char) {
+            if (!$collecting && in_array($char, array(':', '*'))) {
+                $justStarted = true;
+                $collecting = true;
+                $varType = $char;
+                if (strlen($current) > 0) {
+                   $routeList[] = $current;
+                   $current = '';
+                }
+            } else if ($collecting && $justStarted) {
+                $justStarted = false;
+                if ($char == '(') {
+                    $doneOn = array(')');
+                } else {
+                    $current = $char;
+                    // Basically appends '-' to _splitChars
+                    // Helps it fall in line with the Python idioms.
+                    $doneOn = $this->_splitChars + array('-');
+                }
+            } else if ($collecting && !in_array($char, $doneOn)) {
+                $current .= $char;
+            } else if ($collecting) {
+                $collecting = false;
+                $routeList[] = array('type' => $varType, 'name' => $current);
+                if (in_array($char, $this->_splitChars)) {
+                    $routeList[] = $char;
+                }
+                $doneOn = $varType = $current = '';
+            } else {
+                $current .= $char;
+            }
+        }
+        if ($collecting) {
+            $routeList[] = array('type' => $varType, 'name' => $current);
+        } else if (!empty($current)) {
+            $routeList[] = $current;
+        }
+        return $routeList;
+    }
+
+    /**
+     * Utility function to walk the route backwards
+     *
+     * Will determine the minimum keys we must have to generate a
+     * working route.
+     *
+     * @param  array  $routeList  Route path split by '/'
+     * @return array              [minimum keys for route, route list reversed]
+     */
+    protected function _minKeys($routeList)
+    {
+        $minKeys = array();
+        $backCheck = array_reverse($routeList);
+        $gaps = false;
+        foreach ($backCheck as $part) {
+            if (!is_array($part) && !in_array($part, $this->_splitChars)) {
+                $gaps = true;
+                continue;
+            } else if (!is_array($part)) {
+                continue;
+            }
+            $key = $part['name'];
+            if (array_key_exists($key, $this->defaults) && !$gaps)
+                continue;
+            $minKeys[] = $key;
+            $gaps = true;
+        }
+        return array($minKeys, $backCheck);
+    }
+
+    /**
+     * Creates a default array of strings
+     *
+     * Puts together the array of defaults, turns non-null values to strings,
+     * and add in our action/id default if they use and do not specify it
+     *
+     * Precondition: $this->_defaultKeys is an array of the currently assumed default keys
+     *
+     * @param  array  $routekeys     All the keys found in the route path
+     * @param  array  $reservedKeys  Array of keys not in the route path
+     * @param  array  $kargs         Keyword args passed to the Route constructor
+     * @return array                 [defaults, new default keys]
+     */
+    protected function _defaults($routeKeys, $reservedKeys, $kargs)
+    {
+        $defaults = array();
+
+        // Add in a controller/action default if they don't exist
+        if ((!in_array('controller', $routeKeys)) &&
+            (!in_array('controller', array_keys($kargs))) &&
+            (!$this->explicit)) {
+            $kargs['controller'] = 'content';
+        }
+
+        if (!in_array('action', $routeKeys) &&
+            (!in_array('action', array_keys($kargs))) &&
+            (!$this->explicit)) {
+            $kargs['action'] = 'index';
+        }
+
+        $defaultKeys = array();
+        foreach (array_keys($kargs) as $key) {
+            if (!in_array($key, $reservedKeys)) {
+                $defaultKeys[] = $key;
+            }
+        }
+
+        foreach ($defaultKeys as $key) {
+            if ($kargs[$key] !== null) {
+                $defaults[$key] = (string)$kargs[$key];
+            } else {
+                $defaults[$key] = null;
+            }
+        }
+
+        if (in_array('action', $routeKeys) &&
+            (!array_key_exists('action', $defaults)) &&
+            (!$this->explicit)) {
+            $defaults['action'] = 'index';
+        }
+
+        if (in_array('id', $routeKeys) &&
+            (!array_key_exists('id', $defaults)) &&
+            (!$this->explicit)) {
+            $defaults['id'] = null;
+        }
+
+        $newDefaultKeys = array();
+        foreach (array_keys($defaults) as $key) {
+            if (!in_array($key, $reservedKeys)) {
+                $newDefaultKeys[] = $key;
+            }
+        }
+        return array($defaults, $newDefaultKeys);
+    }
+
+    /**
+     * Create the regular expression for matching.
+     *
+     * Note: This MUST be called before match can function properly.
+     *
+     * clist should be a list of valid controller strings that can be
+     * matched, for this reason makeregexp should be called by the web
+     * framework after it knows all available controllers that can be
+     * utilized.
+     *
+     * @param  array  $clist  List of all possible controllers
+     * @return void
+     */
+    public function makeRegexp($clist)
+    {
+        list($reg, $noreqs, $allblank) = $this->buildNextReg($this->_routeList, $clist);
+
+        if (empty($reg)) {
+            $reg = '/';
+        }
+        $reg = $reg . '(/)?$';
+        if (substr($reg, 0, 1) != '/') {
+            $reg = '/' . $reg;
+        }
+        $reg = '^' . $reg;
+
+        $this->regexp = $reg;
+    }
+
+    /**
+     * Recursively build a regexp given a path, and a controller list.
+     *
+     * Returns the regular expression string, and two booleans that can be
+     * ignored as they're only used internally by buildnextreg.
+     *
+     * @param  array  $path   The RouteList for the path
+     * @param  array  $clist  List of all possible controllers
+     * @return array          [array, boolean, boolean]
+     */
+    public function buildNextReg($path, $clist)
+    {
+        if (!empty($path)) {
+            $part = $path[0];
+        } else {
+            $part = '';
+        }
+
+        // noreqs will remember whether the remainder has either a string
+        // match, or a non-defaulted regexp match on a key, allblank remembers
+        // if the rest could possible be completely empty
+        list($rest, $noreqs, $allblank) = array('', true, true);
+
+        if (count($path) > 1) {
+            $this->_prior = $part;
+            list($rest, $noreqs, $allblank) = $this->buildNextReg(array_slice($path, 1), $clist);
+        }
+
+        if (is_array($part) && $part['type'] == ':') {
+            $var = $part['name'];
+            $partreg = '';
+
+            // First we plug in the proper part matcher
+            if (array_key_exists($var, $this->reqs)) {
+                $partreg = '(?P<' . $var . '>' . $this->reqs[$var] . ')';
+            } else if ($var == 'controller') {
+                $partreg = '(?P<' . $var . '>' . implode('|', array_map('preg_quote', $clist)) . ')';
+            } else if (in_array($this->_prior, array('/', '#'))) {
+                $partreg = '(?P<' . $var . '>[^' . $this->_prior . ']+?)';
+            } else {
+                if (empty($rest)) {
+                    $partreg = '(?P<' . $var . '>[^/]+?)';
+                } else {
+                    $partreg = '(?P<' . $var . '>[^' . implode('', $this->_splitChars) . ']+?)';
+                }
+            }
+
+            if (array_key_exists($var, $this->reqs)) {
+                $noreqs = false;
+            }
+            if (!array_key_exists($var, $this->defaults)) {
+                $allblank = false;
+                $noreqs = false;
+            }
+
+            // Now we determine if its optional, or required. This changes
+            // depending on what is in the rest of the match. If noreqs is
+            // true, then its possible the entire thing is optional as there's
+            // no reqs or string matches.
+            if ($noreqs) {
+                // The rest is optional, but now we have an optional with a
+                // regexp. Wrap to ensure that if we match anything, we match
+                // our regexp first. It's still possible we could be completely
+                // blank as we have a default
+                if (array_key_exists($var, $this->reqs) && array_key_exists($var, $this->defaults)) {
+                    $reg = '(' . $partreg . $rest . ')?';
+
+                // Or we have a regexp match with no default, so now being
+                // completely blank form here on out isn't possible
+                } else if (array_key_exists($var, $this->reqs)) {
+                    $allblank = false;
+                    $reg = $partreg . $rest;
+
+                // If the character before this is a special char, it has to be
+                // followed by this
+                } else if (array_key_exists($var, $this->defaults) && in_array($this->_prior, array(',', ';', '.'))) {
+                    $reg = $partreg . $rest;
+
+                // Or we have a default with no regexp, don't touch the allblank
+                } else if (array_key_exists($var, $this->defaults)) {
+                    $reg = $partreg . '?' . $rest;
+
+                // Or we have a key with no default, and no reqs. Not possible
+                // to be all blank from here
+                } else {
+                    $allblank = false;
+                    $reg = $partreg . $rest;
+                }
+
+            // In this case, we have something dangling that might need to be
+            // matched
+            } else {
+                // If they can all be blank, and we have a default here, we know
+                // its safe to make everything from here optional. Since
+                // something else in the chain does have req's though, we have
+                // to make the partreg here required to continue matching
+                if ($allblank && array_key_exists($var, $this->defaults)) {
+                    $reg = '(' . $partreg . $rest . ')?';
+
+                // Same as before, but they can't all be blank, so we have to
+                // require it all to ensure our matches line up right
+                } else {
+                    $reg = $partreg . $rest;
+                }
+            }
+        } else if (is_array($part) && $part['type'] == '*') {
+            $var = $part['name'];
+            if ($noreqs) {
+                $reg = '(?P<' . $var . '>.*)' . $rest;
+                if (!array_key_exists($var, $this->defaults)) {
+                    $allblank = false;
+                    $noreqs = false;
+                }
+            } else {
+                if ($allblank && array_key_exists($var, $this->defaults)) {
+                    $reg = '(?P<' . $var . '>.*)' . $rest;
+                } else if (array_key_exists($var, $this->defaults)) {
+                    $reg = '(?P<' . $var . '>.*)' . $rest;
+                } else {
+                    $allblank = false;
+                    $noreqs = false;
+                    $reg = '(?P<' . $var . '>.*)' . $rest;
+                }
+            }
+        } else if ($part && in_array(substr($part, -1), $this->_splitChars)) {
+            if ($allblank) {
+                $reg = preg_quote(substr($part, 0, -1)) . '(' . preg_quote(substr($part, -1)) . $rest . ')?';
+            } else {
+                $allblank = false;
+                $reg = preg_quote($part) . $rest;
+            }
+
+        // We have a normal string here, this is a req, and it prevents us from
+        // being all blank
+        } else {
+            $noreqs = false;
+            $allblank = false;
+            $reg = preg_quote($part) . $rest;
+        }
+
+        return array($reg, $noreqs, $allblank);
+    }
+
+    /**
+     * Match a url to our regexp.
+     *
+     * While the regexp might match, this operation isn't
+     * guaranteed as there's other factors that can cause a match to fail
+     * even though the regexp succeeds (Default that was relied on wasn't
+     * given, requirement regexp doesn't pass, etc.).
+     *
+     * Therefore the calling function shouldn't assume this will return a
+     * valid dict, the other possible return is False if a match doesn't work
+     * out.
+     *
+     * @param  string  $url  URL to match
+     * @param  array         Keyword arguments
+     * @return null|array    Array of match data if matched, Null otherwise
+     */
+    public function match($url, $kargs = array())
+    {
+        $defaultKargs = array('environ'          => array(),
+                              'subDomains'       => false,
+                              'subDomainsIgnore' => array(),
+                              'domainMatch'      => '');
+        $kargs = array_merge($defaultKargs, $kargs);
+
+        // Static routes don't match, they generate only
+        if ($this->static) {
+            return false;
+        }
+
+        if (substr($url, -1) == '/' && strlen($url) > 1) {
+            $url = substr($url, 0, -1);
+        }
+
+        // Match the regexps we generated
+        $match = preg_match('@' . str_replace('@', '\@', $this->regexp) . '@', $url, $matches);
+        if ($match == 0) {
+            return false;
+        }
+
+        $host = isset($kargs['environ']['HTTP_HOST']) ? $kargs['environ']['HTTP_HOST'] : null;
+        if ($host !== null && !empty($kargs['subDomains'])) {
+            $host = substr($host, 0, strpos(':', $host));
+            $subMatch = '@^(.+?)\.' . $kargs['domainMatch'] . '$';
+            $subdomain = preg_replace($subMatch, '$1', $host);
+            if (!in_array($subdomain, $kargs['subDomainsIgnore'] && $host != $subdomain)) {
+                $subDomain = $subdomain;
+            }
+        }
+
+        if (!empty($this->conditions)) {
+            if (isset($this->conditions['method'])) {
+                if (empty($kargs['environ']['REQUEST_METHOD'])) { return false; }
+
+                if (!in_array($kargs['environ']['REQUEST_METHOD'], $this->conditions['method'])) {
+                    return false;
+                }
+            }
+
+            // Check sub-domains?
+            $use_sd = isset($this->conditions['subDomain']) ? $this->conditions['subDomain'] : null;
+            if (!empty($use_sd) && empty($subDomain)) {
+                return false;
+            }
+            if (is_array($use_sd) && !in_array($subDomain, $use_sd)) {
+                return false;
+            }
+        }
+        $matchDict = $matches;
+
+        // Clear out int keys as PHP gives us both the named subgroups and numbered subgroups
+        foreach ($matchDict as $key => $val) {
+            if (is_int($key)) {
+                unset($matchDict[$key]);
+            }
+        }
+        $result = array();
+        $extras = Horde_Routes_Utils::arraySubtract(array_keys($this->defaults), array_keys($matchDict));
+
+        foreach ($matchDict as $key => $val) {
+            // TODO: character set decoding
+            if ($key != 'path_info' && $this->encoding) {
+                $val = urldecode($val);
+            }
+
+            if (empty($val) && array_key_exists($key, $this->defaults) && !empty($this->defaults[$key])) {
+                $result[$key] = $this->defaults[$key];
+            } else {
+                $result[$key] = $val;
+            }
+        }
+
+        foreach ($extras as $key) {
+            $result[$key] = $this->defaults[$key];
+        }
+
+        // Add the sub-domain if there is one
+        if (!empty($kargs['subDomains'])) {
+            $result['subDomain'] = $subDomain;
+        }
+
+        // If there's a function, call it with environ and expire if it
+        // returns False
+        if (!empty($this->conditions) && array_key_exists('function', $this->conditions) &&
+            !call_user_func_array($this->conditions['function'], array($kargs['environ'], $result))) {
+            return false;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Generate a URL from ourself given a set of keyword arguments
+     *
+     * @param  array  $kargs   Keyword arguments
+     * @param  boolean|string  False if generation failed, URL otherwise
+     */
+    public function generate($kargs)
+    {
+        $defaultKargs = array('_ignoreReqList' => false,
+                              '_appendSlash'   => false);
+        $kargs = array_merge($defaultKargs, $kargs);
+
+        $_appendSlash = $kargs['_appendSlash'];
+        unset($kargs['_appendSlash']);
+
+        $_ignoreReqList = $kargs['_ignoreReqList'];
+        unset($kargs['_ignoreReqList']);
+
+        // Verify that our args pass any regexp requirements
+        if (!$_ignoreReqList) {
+            foreach ($this->reqs as $key => $v) {
+                $value = (isset($kargs[$key])) ? $kargs[$key] : null;
+
+                if (!empty($value) && !preg_match($this->_reqRegs[$key], $value)) {
+                    return false;
+                }
+            }
+        }
+
+        // Verify that if we have a method arg, it's in the method accept list.
+        // Also, method will be changed to _method for route generation.
+        $meth = (isset($kargs['method'])) ? $kargs['method'] : null;
+
+        if ($meth) {
+            if ($this->conditions && isset($this->conditions['method']) &&
+                (!in_array(strtoupper($meth), $this->conditions['method']))) {
+
+                return false;
+            }
+            unset($kargs['method']);
+        }
+
+        $routeList = $this->_routeBackwards;
+        $urlList = array();
+        $gaps = false;
+        foreach ($routeList as $part) {
+            if (is_array($part) && $part['type'] == ':') {
+                $arg = $part['name'];
+
+                // For efficiency, check these just once
+                $hasArg = array_key_exists($arg, $kargs);
+                $hasDefault = array_key_exists($arg, $this->defaults);
+
+                // Determine if we can leave this part off
+                // First check if the default exists and wasn't provided in the
+                // call (also no gaps)
+                if ($hasDefault && !$hasArg && !$gaps) {
+                    continue;
+                }
+
+                // Now check to see if there's a default and it matches the
+                // incoming call arg
+                if (($hasDefault && $hasArg) && $kargs[$arg] == $this->defaults[$arg] && !$gaps) {
+                    continue;
+                }
+
+                // We need to pull the value to append, if the arg is NULL and
+                // we have a default, use that
+                if ($hasArg && $kargs[$arg] === null && $hasDefault && !$gaps) {
+                    continue;
+
+                // Otherwise if we do have an arg, use that
+                } else if ($hasArg) {
+                    $val = ($kargs[$arg] === null) ? 'null' : $kargs[$arg];
+                } else if ($hasDefault && $this->defaults[$arg] != null) {
+                    $val = $this->defaults[$arg];
+
+                // No arg at all? This won't work
+                } else {
+                    return false;
+                }
+
+                $urlList[] = Horde_Routes_Utils::urlQuote($val, $this->encoding);
+                if ($hasArg) {
+                    unset($kargs[$arg]);
+                }
+                $gaps = true;
+            } else if (is_array($part) && $part['type'] == '*') {
+                $arg = $part['name'];
+                $kar = (isset($kargs[$arg])) ? $kargs[$arg] : null;
+                if ($kar != null) {
+                    $urlList[] = Horde_Routes_Utils::urlQuote($kar, $this->encoding);
+                    $gaps = true;
+                }
+            } else if (!empty($part) && in_array(substr($part, -1), $this->_splitChars)) {
+                if (!$gaps && in_array($part, $this->_splitChars)) {
+                    continue;
+                } else if (!$gaps) {
+                    $gaps = true;
+                    $urlList[] = substr($part, 0, -1);
+                } else {
+                    $gaps = true;
+                    $urlList[] = $part;
+                }
+            } else {
+                $gaps = true;
+                $urlList[] = $part;
+            }
+        }
+
+        $urlList = array_reverse($urlList);
+        $url = implode('', $urlList);
+        if (substr($url, 0, 1) != '/') {
+            $url = '/' . $url;
+        }
+
+        $extras = $kargs;
+        foreach ($this->maxKeys as $key) {
+            unset($extras[$key]);
+        }
+        $extras = array_keys($extras);
+
+        if (!empty($extras)) {
+            if ($_appendSlash && substr($url, -1) != '/') {
+                $url .= '/';
+            }
+            $url .= '?';
+            $newExtras = array();
+            foreach ($kargs as $key => $value) {
+                if (in_array($key, $extras) && ($key != 'action' || $key != 'controller')) {
+                     $newExtras[$key] = $value;
+                }
+            }
+            $url .= http_build_query($newExtras);
+        } else if ($_appendSlash && substr($url, -1) != '/') {
+            $url .= '/';
+        }
+        return $url;
+    }
+
+}
diff --git a/framework/Routes/lib/Horde/Routes/Utils.php b/framework/Routes/lib/Horde/Routes/Utils.php
new file mode 100644 (file)
index 0000000..eb13ec2
--- /dev/null
@@ -0,0 +1,418 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * Utility functions for use in templates and controllers
+ *
+ * @package Horde_Routes
+ */
+class Horde_Routes_Utils
+{
+    /**
+     * @var Horde_Routes_Mapper
+     */
+    public $mapper;
+
+    /**
+     * Match data from last match; implements for urlFor() route memory
+     * @var array
+     */
+    public $mapperDict = array();
+
+    /**
+     * Callback function used for redirectTo()
+     * @var callback
+     */
+    public $redirect;
+
+
+    /**
+     * Constructor
+     *
+     * @param  Horde_Routes_Mapper  $mapper    Mapper for these utilities
+     * @param  callback             $redirect  Redirect callback for redirectTo()
+     */
+    public function __construct($mapper, $redirect = null)
+    {
+        $this->mapper   = $mapper;
+        $this->redirect = $redirect;
+    }
+
+    /**
+     * Generates a URL.
+     *
+     * All keys given to urlFor are sent to the Routes Mapper instance for
+     * generation except for::
+     *
+     *     anchor          specified the anchor name to be appened to the path
+     *     host            overrides the default (current) host if provided
+     *     protocol        overrides the default (current) protocol if provided
+     *     qualified       creates the URL with the host/port information as
+     *                     needed
+     *
+     * The URL is generated based on the rest of the keys. When generating a new
+     * URL, values will be used from the current request's parameters (if
+     * present). The following rules are used to determine when and how to keep
+     * the current requests parameters:
+     *
+     * * If the controller is present and begins with '/', no defaults are used
+     * * If the controller is changed, action is set to 'index' unless otherwise
+     *   specified
+     *
+     * For example, if the current request yielded a dict (associative array) of
+     * array('controller'=>'blog', 'action'=>'view', 'id'=>2), with the standard
+     * ':controller/:action/:id' route, you'd get the following results::
+     *
+     *     urlFor(array('id'=>4))                    =>  '/blog/view/4',
+     *     urlFor(array('controller'=>'/admin'))     =>  '/admin',
+     *     urlFor(array('controller'=>'admin'))      =>  '/admin/view/2'
+     *     urlFor(array('action'=>'edit'))           =>  '/blog/edit/2',
+     *     urlFor(array('action'=>'list', id=NULL))  =>  '/blog/list'
+     *
+     * **Static and Named Routes**
+     *
+     * If there is a string present as the first argument, a lookup is done
+     * against the named routes table to see if there's any matching routes. The
+     * keyword defaults used with static routes will be sent in as GET query
+     * arg's if a route matches.
+     *
+     * If no route by that name is found, the string is assumed to be a raw URL.
+     * Should the raw URL begin with ``/`` then appropriate SCRIPT_NAME data will
+     * be added if present, otherwise the string will be used as the url with
+     * keyword args becoming GET query args.
+     */
+    public function urlFor($first = array(), $second = array())
+    {
+        if (is_array($first)) {
+            // urlFor(array('controller' => 'foo', ...))
+            $routeName = null;
+            $kargs = $first;
+        } else {
+            // urlFor('named_route')
+            // urlFor('named_route', array('id' => 3, ...))
+            // urlFor('static_path')
+            $routeName = $first;
+            $kargs = $second;
+        }
+
+        $anchor    = isset($kargs['anchor'])    ? $kargs['anchor']    : null;
+        $host      = isset($kargs['host'])      ? $kargs['host']      : null;
+        $protocol  = isset($kargs['protocol'])  ? $kargs['protocol']  : null;
+        $qualified = isset($kargs['qualified']) ? $kargs['qualified'] : null;
+        unset($kargs['qualified']);
+
+        // Remove special words from kargs, convert placeholders
+        foreach (array('anchor', 'host', 'protocol') as $key) {
+            if (array_key_exists($key, $kargs)) {
+                unset($kargs[$key]);
+            }
+            if (array_key_exists($key . '_', $kargs)) {
+                $kargs[$key] = $kargs[$key . '_'];
+                unset($kargs[$key . '_']);
+            }
+        }
+
+        $route = null;
+        $static = false;
+        $encoding = $this->mapper->encoding;
+        $environ = $this->mapper->environ;
+        $url = '';
+
+        if (isset($routeName)) {
+
+            if (isset($this->mapper->routeNames[$routeName])) {
+                $route = $this->mapper->routeNames[$routeName];
+            }
+
+            if ($route && array_key_exists('_static', $route->defaults)) {
+                $static = true;
+                $url = $route->routePath;
+            }
+
+            // No named route found, assume the argument is a relative path
+            if ($route === null) {
+                $static = true;
+                $url = $routeName;
+            }
+
+            if ((substr($url, 0, 1) == '/') &&
+                isset($environ['SCRIPT_NAME'])) {
+                $url = $environ['SCRIPT_NAME'] . $url;
+            }
+
+            if ($static) {
+                if (!empty($kargs)) {
+                    $url .= '?';
+                    $query_args = array();
+                    foreach ($kargs as $key => $val) {
+                        $query_args[] = urlencode(utf8_decode($key)) . '=' .
+                            urlencode(utf8_decode($val));
+                    }
+                    $url .= implode('&', $query_args);
+                }
+            }
+        }
+
+        if (! $static) {
+            if ($route) {
+                $newargs = $route->defaults;
+                foreach ($kargs as $key => $value) {
+                    $newargs[$key] = $value;
+                }
+
+                // If this route has a filter, apply it
+                if (!empty($route->filter)) {
+                    $newargs = call_user_func($route->filter, $newargs);
+                }
+
+                $newargs = $this->_subdomainCheck($newargs);
+            } else {
+                $newargs = $this->_screenArgs($kargs);
+            }
+
+            $anchor = (isset($newargs['_anchor'])) ? $newargs['_anchor'] : $anchor;
+            unset($newargs['_anchor']);
+
+            $host = (isset($newargs['_host'])) ? $newargs['_host'] : $host;
+            unset($newargs['_host']);
+
+            $protocol = (isset($newargs['_protocol'])) ? $newargs['_protocol'] : $protocol;
+            unset($newargs['_protocol']);
+
+            $url = $this->mapper->generate($newargs);
+        }
+
+        if (!empty($anchor)) {
+            $url .= '#' . self::urlQuote($anchor, $encoding);
+        }
+
+        if (!empty($host) || !empty($qualified) || !empty($protocol)) {
+            $http_host   = isset($environ['HTTP_HOST']) ? $environ['HTTP_HOST'] : null;
+            $server_name = isset($environ['SERVER_NAME']) ? $environ['SERVER_NAME'] : null;
+            $fullhost = !is_null($http_host) ? $http_host : $server_name;
+
+            if (empty($host) && empty($qualified)) {
+                $host = explode(':', $fullhost);
+                $host = $host[0];
+            } else if (empty($host)) {
+                $host = $fullhost;
+            }
+            if (empty($protocol)) {
+                if (!empty($environ['HTTPS']) && $environ['HTTPS'] != 'off') {
+                    $protocol = 'https';
+                } else {
+                    $protocol = 'http';
+                }
+            }
+            if ($url !== null) {
+                $url = $protocol . '://' . $host . $url;
+            }
+        }
+
+        return $url;
+    }
+
+    /**
+     * Issues a redirect based on the arguments.
+     *
+     * Redirects *should* occur as a "302 Moved" header, however the web
+     * framework may utilize a different method.
+     *
+     * All arguments are passed to urlFor() to retrieve the appropriate URL, then
+     * the resulting URL it sent to the redirect function as the URL.
+     *
+     * @param   mixed  $first   First argument in varargs, same as urlFor()
+     * @param   mixed  $second  Second argument in varargs
+     * @return  mixed           Result of redirect callback
+     */
+    public function redirectTo($first = array(), $second = array())
+    {
+        $target = $this->urlFor($first, $second);
+        return call_user_func($this->redirect, $target);
+    }
+
+    /**
+     * Scan a directory for PHP files and use them as controllers.  Used
+     * as the default scanner callback for Horde_Routes_Mapper.  See the
+     * constructor of that class for more information.
+     *
+     * Given a directory with:
+     *   foo.php, bar.php, baz.php
+     * Returns an array:
+     *   foo, bar, baz
+     *
+     * @param  string  $dirname  Directory to scan for controller files
+     * @param  string  $prefix   Prefix controller names (optional)
+     * @return array             Array of controller names
+     */
+    public static function controllerScan($dirname = null, $prefix = '')
+    {
+        $controllers = array();
+
+        if ($dirname === null) {
+            return $controllers;
+        }
+
+        $baseregexp = preg_quote($dirname . DIRECTORY_SEPARATOR, '/');
+
+        foreach (new RecursiveIteratorIterator(
+                 new RecursiveDirectoryIterator($dirname)) as $entry) {
+
+            if ($entry->isFile()) {
+                 // match .php files that don't start with an underscore
+                 if (preg_match('/^[^_]{1,1}.*\.php$/', basename($entry->getFilename())) != 0) {
+                    // strip off base path: dirname/admin/users.php -> admin/users.php
+                    $controller = preg_replace("/^$baseregexp(.*)\.php/", '\\1', $entry->getPathname());
+
+                    // add to controller list
+                    $controllers[] = $prefix . $controller;
+                }
+            }
+        }
+
+        $callback = array('Horde_Routes_Utils', 'longestFirst');
+        usort($controllers, $callback);
+
+        return $controllers;
+    }
+
+    /**
+     * Private function that takes a dict, and screens it against the current
+     * request dict to determine what the dict should look like that is used.
+     * This is responsible for the requests "memory" of the current.
+     */
+    private function _screenArgs($kargs)
+    {
+        if ($this->mapper->explicit && $this->mapper->subDomains) {
+            return $this->_subdomainCheck($kargs);
+        } else if ($this->mapper->explicit) {
+            return $kargs;
+        }
+
+        $controllerName = (isset($kargs['controller'])) ? $kargs['controller'] : null;
+
+        if (!empty($controllerName) && substr($controllerName, 0, 1) == '/') {
+            // If the controller name starts with '/', ignore route memory
+            $kargs['controller'] = substr($kargs['controller'], 1);
+            return $kargs;
+        } else if (!empty($controllerName) && !array_key_exists('action', $kargs)) {
+            // Fill in an action if we don't have one, but have a controller
+            $kargs['action'] = 'index';
+        }
+
+        $memoryKargs = $this->mapperDict;
+
+        // Remove keys from memory and kargs if kargs has them as null
+        foreach ($kargs as $key => $value) {
+             if ($value === null) {
+                 unset($kargs[$key]);
+                 if (array_key_exists($key, $memoryKargs)) {
+                     unset($memoryKargs[$key]);
+                 }
+             }
+        }
+
+        // Merge the new args on top of the memory args
+        foreach ($kargs as $key => $value) {
+            $memoryKargs[$key] = $value;
+        }
+
+        // Setup a sub-domain if applicable
+        if (!empty($this->mapper->subDomains)) {
+            $memoryKargs = $this->_subdomainCheck($memoryKargs);
+        }
+
+        return $memoryKargs;
+    }
+
+    /**
+     * Screen the kargs for a subdomain and alter it appropriately depending
+     * on the current subdomain or lack therof.
+     */
+    private function _subdomainCheck($kargs)
+    {
+        if ($this->mapper->subDomains) {
+            $subdomain = (isset($kargs['subDomain'])) ? $kargs['subDomain'] : null;
+            unset($kargs['subDomain']);
+
+            $environ = $this->mapper->environ;
+            $http_host   = isset($environ['HTTP_HOST']) ? $environ['HTTP_HOST'] : null;
+            $server_name = isset($environ['SERVER_NAME']) ? $environ['SERVER_NAME'] : null;
+            $fullhost = !is_null($http_host) ? $http_host : $server_name;
+
+            $hostmatch = explode(':', $fullhost);
+            $host = $hostmatch[0];
+            $port = '';
+            if (count($hostmatch) > 1) {
+                $port .= ':' . $hostmatch[1];
+            }
+
+            $subMatch = '^.+?\.(' . $this->mapper->domainMatch . ')$';
+            $domain = preg_replace("@$subMatch@", '$1', $host);
+
+            if ($subdomain && (substr($host, 0, strlen($subdomain)) != $subdomain)
+                    && (! in_array($subdomain, $this->mapper->subDomainsIgnore))) {
+                $kargs['_host'] = $subdomain . '.' . $domain . $port;
+            } else if (($subdomain === null || in_array($subdomain, $this->mapper->subDomainsIgnore))
+                    && $domain != $host) {
+                $kargs['_host'] = $domain . $port;
+            }
+            return $kargs;
+        } else {
+            return $kargs;
+        }
+    }
+
+    /**
+     * Quote a string containing a URL in a given encoding.
+     *
+     * @todo This is a placeholder.  Multiple encodings aren't yet supported.
+     *
+     * @param  string  $url       URL to encode
+     * @param  string  $encoding  Encoding to use
+     */
+    public static function urlQuote($url, $encoding = null)
+    {
+        if ($encoding === null) {
+            return str_replace('%2F', '/', urlencode($url));
+        } else {
+            return str_replace('%2F', '/', urlencode(utf8_decode($url)));
+        }
+    }
+
+    /**
+     * Callback used by usort() in controllerScan() to sort controller
+     * names by the longest first.
+     *
+     * @param   string  $fst  First string to compare
+     * @param   string  $lst  Last string to compare
+     * @return  integer       Difference of string length (first - last)
+     */
+    public static function longestFirst($fst, $lst)
+    {
+        return strlen($lst) - strlen($fst);
+    }
+
+    /**
+     */
+    public static function arraySubtract($a1, $a2)
+    {
+        foreach ($a2 as $key) {
+            if (in_array($key, $a1)) {
+                unset($a1[array_search($key, $a1)]);
+            }
+        }
+        return $a1;
+    }
+}
diff --git a/framework/Routes/package.xml b/framework/Routes/package.xml
new file mode 100644 (file)
index 0000000..5dc5461
--- /dev/null
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package packagerversion="1.4.9" version="2.0" xmlns="http://pear.php.net/dtd/package-2.0" xmlns:tasks="http://pear.php.net/dtd/tasks-1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://pear.php.net/dtd/tasks-1.0
+http://pear.php.net/dtd/tasks-1.0.xsd
+http://pear.php.net/dtd/package-2.0
+http://pear.php.net/dtd/package-2.0.xsd">
+ <name>Routes</name>
+ <channel>pear.horde.org</channel>
+ <summary>Horde Routes URL mapping system</summary>
+ <description>This package provides classes for mapping URLs into
+ the controllers and actions of an MVC system.  It is a port of a
+ Python library, Routes, by Ben Bangert (http://routes.groovie.org).
+ </description>
+ <lead>
+  <name>Mike Naberezny</name>
+  <user>mnaberez</user>
+  <email>mike@maintainable.com</email>
+  <active>yes</active>
+ </lead>
+ <lead>
+  <name>Chuck Hagenbuch</name>
+  <user>chuck</user>
+  <email>chuck@horde.org</email>
+  <active>yes</active>
+ </lead>
+ <date>2008-09-19</date>
+ <version>
+  <release>0.3.1</release>
+  <api>0.3.0</api>
+ </version>
+ <stability>
+  <release>beta</release>
+  <api>beta</api>
+ </stability>
+ <license uri="http://opensource.org/licenses/bsd-license.php">BSD</license>
+ <notes>* Fixed callbacks for PHP versions prior to PHP 5.2.</notes>
+ <contents>
+  <dir name="/">
+   <dir name="lib">
+    <dir name="Horde">
+     <dir name="Routes">
+      <file name="Exception.php" role="php" />
+      <file name="Mapper.php" role="php" />
+      <file name="Route.php" role="php" />
+      <file name="Utils.php" role="php" />
+     </dir> <!-- /lib/Horde/Routes -->
+    </dir> <!-- /lib/Horde -->
+   </dir> <!-- /lib -->
+  </dir> <!-- / -->
+ </contents>
+ <dependencies>
+  <required>
+   <php>
+    <min>5.1.0</min>
+   </php>
+   <pearinstaller>
+    <min>1.4.0b1</min>
+   </pearinstaller>
+  </required>
+ </dependencies>
+ <phprelease>
+  <filelist>
+   <install name="lib/Horde/Routes/Exception.php" as="Horde/Routes/Exception.php" />
+   <install name="lib/Horde/Routes/Mapper.php" as="Horde/Routes/Mapper.php" />
+   <install name="lib/Horde/Routes/Route.php" as="Horde/Routes/Route.php" />
+   <install name="lib/Horde/Routes/Utils.php" as="Horde/Routes/Utils.php" />
+  </filelist>
+ </phprelease>
+</package>
diff --git a/framework/Routes/test/Horde/Routes/AllTests.php b/framework/Routes/test/Horde/Routes/AllTests.php
new file mode 100644 (file)
index 0000000..17712a0
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+if (!defined('PHPUnit_MAIN_METHOD')) {
+    define('PHPUnit_MAIN_METHOD', 'Horde_Routes_AllTests::main');
+}
+
+error_reporting(E_ALL | E_STRICT);
+
+$lib = dirname(dirname(dirname(dirname(__FILE__)))) . '/lib/Horde/Routes/';
+require_once "$lib/Utils.php";
+require_once "$lib/Mapper.php";
+require_once "$lib/Route.php";
+require_once "$lib/Exception.php";
+
+require_once 'PHPUnit/Framework/TestSuite.php';
+require_once 'PHPUnit/TextUI/TestRunner.php';
+
+/**
+ * @package Horde_Routes
+ */
+class Horde_Routes_AllTests
+{
+    public static function main()
+    {
+        PHPUnit_TextUI_TestRunner::run(self::suite());
+    }
+
+    public static function suite()
+    {
+        $suite = new PHPUnit_Framework_TestSuite('Routes');
+
+        $basedir = dirname(__FILE__);
+        $baseregexp = preg_quote($basedir . DIRECTORY_SEPARATOR, '/');
+
+        foreach(new RecursiveIteratorIterator(
+                 new RecursiveDirectoryIterator($basedir)) as $file) {
+            if ($file->isFile() && preg_match('/Test.php$/', $file->getFilename())) {
+                $pathname = $file->getPathname();
+                require $pathname;
+
+                $class = str_replace(DIRECTORY_SEPARATOR, '_',
+                                     preg_replace("/^$baseregexp(.*)\.php/", '\\1', $pathname));
+                $suite->addTestSuite($class);
+            }
+        }
+
+        return $suite;
+    }
+}
+
+if (PHPUnit_MAIN_METHOD == 'Horde_Routes_AllTests::main') {
+    Horde_Routes_AllTests::main();
+}
diff --git a/framework/Routes/test/Horde/Routes/GenerationTest.php b/framework/Routes/test/Horde/Routes/GenerationTest.php
new file mode 100644 (file)
index 0000000..92ba3e7
--- /dev/null
@@ -0,0 +1,985 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based 
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * @package Horde_Routes
+ */
+class GenerationTest extends PHPUnit_Framework_TestCase
+{
+
+    public function testAllStaticNoReqs()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('hello/world');
+
+        $this->assertEquals('/hello/world', $m->generate());
+    }
+
+    public function testBasicDynamic()
+    {
+        foreach (array('hi/:fred', 'hi/:(fred)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/hi/index', $m->generate(array('fred' => 'index')));
+            $this->assertEquals('/hi/show',  $m->generate(array('fred' => 'show')));
+            $this->assertEquals('/hi/list+people', $m->generate(array('fred' => 'list people')));
+            $this->assertNull($m->generate());
+        }
+    }
+
+    public function testDynamicWithDefault()
+    {
+        foreach (array('hi/:action', 'hi/:(action)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/hi',      $m->generate(array('action' => 'index')));
+            $this->assertEquals('/hi/show', $m->generate(array('action' => 'show')));
+            $this->assertEquals('/hi/list+people', $m->generate(array('action' => 'list people')));
+            $this->assertEquals('/hi', $m->generate());
+        }
+    }
+
+    /**
+     * Some of these assertions are invalidated in PHP, it passes in Python because
+     * unicode(None) == unicode('None').  In PHP, we don't have a function similar
+     * to unicode().  These have the comment "unicode false equiv"
+     */
+    public function testDynamicWithFalseEquivs()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('article/:page', array('page' => false));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/blog/view/0',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => '0')));
+
+        // unicode false equiv
+        // $this->assertEquals('/blog/view/0',
+        //                     $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 0)));
+        //$this->assertEquals('/blog/view/False',
+        //                     $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => false)));
+
+        $this->assertEquals('/blog/view/False',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 'False')));
+        $this->assertEquals('/blog/view',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => null)));
+
+        // unicode false equiv
+        // $this->assertEquals('/blog/view',
+        //                    $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 'null')));
+
+        $this->assertEquals('/article',
+                            $m->generate(array('page' => null)));
+    }
+
+    public function testDynamicWithUnderscoreParts()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('article/:small_page', array('small_page' => false));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/blog/view/0',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => '0')));
+
+        // unicode false equiv
+        // $this->assertEquals('/blog/view/False',
+        //                     $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => false)));
+
+        // unicode False Equiv
+        //$this->assertEquals('/blog/view',
+        //                    $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 'null'))); */
+
+        $this->assertEquals('/article',
+                            $m->generate(array('small_page' => null)));
+        $this->assertEquals('/article/hobbes',
+                            $m->generate(array('small_page' => 'hobbes')));
+    }
+
+    public function testDynamicWithFalseEquivsAndSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('article/:(page)', array('page' => false));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/blog/view/0',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => '0')));
+
+        // unicode false equiv
+        // $this->assertEquals('/blog/view/0',
+        //                     $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 0)));
+        // $this->assertEquals('/blog/view/False',
+        //                     $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => false));
+
+        $this->assertEquals('/blog/view/False',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 'False')));
+        $this->assertEquals('/blog/view',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => null)));
+
+        // unicode false equiv
+        //$this->assertEquals('/blog/view',
+        //                    $m->generate(array('controller' => 'blog', 'action' => 'view', 'id' => 'null')));
+
+        $this->assertEquals('/article',
+                            $m->generate(array('page' => null)));
+
+        $m = new Horde_Routes_Mapper();
+        $m->connect('view/:(home)/:(area)', array('home' => 'austere', 'area' => null));
+
+        $this->assertEquals('/view/sumatra',
+                            $m->generate(array('home' => 'sumatra')));
+        $this->assertEquals('/view/austere/chicago',
+                            $m->generate(array('area' => 'chicago')));
+
+        $m = new Horde_Routes_Mapper();
+        $m->connect('view/:(home)/:(area)', array('home' => null, 'area' => null));
+
+        $this->assertEquals('/view/null/chicago',
+                            $m->generate(array('home' => null, 'area' => 'chicago')));
+    }
+
+    public function testDynamicWithRegExpCondition()
+    {
+        foreach (array('hi/:name', 'hi/:(name)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('requirements' => array('name' => '[a-z]+')));
+
+            $this->assertEquals('/hi/index', $m->generate(array('name' => 'index')));
+            $this->assertNull($m->generate(array('name' => 'fox5')));
+            $this->assertNull($m->generate(array('name' => 'something_is_up')));
+            $this->assertEquals('/hi/abunchofcharacter',
+                                $m->generate(array('name' => 'abunchofcharacter')));
+            $this->assertNull($m->generate());
+        }
+    }
+
+    public function testDynamicWithDefaultAndRegexpCondition()
+    {
+        foreach (array('hi/:action', 'hi/:(action)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('requirements' => array('action' => '[a-z]+')));
+
+            $this->assertEquals('/hi', $m->generate(array('action' => 'index')));
+            $this->assertNull($m->generate(array('action' => 'fox5')));
+            $this->assertNull($m->generate(array('action' => 'something_is_up')));
+            $this->assertNull($m->generate(array('action' => 'list people')));
+            $this->assertEquals('/hi/abunchofcharacter',
+                                $m->generate(array('action' => 'abunchofcharacter')));
+            $this->assertEquals('/hi', $m->generate());
+        }
+    }
+
+    public function testPath()
+    {
+        foreach (array('hi/*file', 'hi/*(file)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/hi',
+                                $m->generate(array('file' => null)));
+            $this->assertEquals('/hi/books/learning_python.pdf',
+                                $m->generate(array('file' => 'books/learning_python.pdf')));
+            $this->assertEquals('/hi/books/development%26whatever/learning_python.pdf',
+                                $m->generate(array('file' => 'books/development&whatever/learning_python.pdf')));
+        }
+    }
+
+    public function testPathBackwards()
+    {
+        foreach (array('*file/hi', '*(file)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/hi',
+                                $m->generate(array('file' => null)));
+            $this->assertEquals('/books/learning_python.pdf/hi',
+                                $m->generate(array('file' => 'books/learning_python.pdf')));
+            $this->assertEquals('/books/development%26whatever/learning_python.pdf/hi',
+                                $m->generate(array('file' => 'books/development&whatever/learning_python.pdf')));
+        }
+    }
+
+    public function testController()
+    {
+        foreach (array('hi/:controller', 'hi/:(controller)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/hi/content',
+                                $m->generate(array('controller' => 'content')));
+            $this->assertEquals('/hi/admin/user',
+                                $m->generate(array('controller' => 'admin/user')));
+        }
+    }
+
+    public function testControllerWithStatic()
+    {
+        foreach (array('hi/:controller', 'hi/:(controller)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $utils = $m->utils;
+            $m->connect($path);
+            $m->connect('google', 'http://www.google.com', array('_static' => true));
+
+            $this->assertEquals('/hi/content',
+                                $m->generate(array('controller' => 'content')));
+            $this->assertEquals('/hi/admin/user',
+                                $m->generate(array('controller' => 'admin/user')));
+            $this->assertEquals('http://www.google.com', $utils->urlFor('google'));
+        }
+    }
+
+    public function testStandardRoute()
+    {
+        foreach (array(':controller/:action/:id', ':(controller)/:(action)/:(id)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/content',
+                                $m->generate(array('controller' => 'content', 'action' => 'index')));
+            $this->assertEquals('/content/list',
+                                $m->generate(array('controller' => 'content', 'action' => 'list')));
+            $this->assertEquals('/content/show/10',
+                                $m->generate(array('controller' => 'content', 'action' => 'show', 'id' => '10')));
+
+            $this->assertEquals('/admin/user',
+                                $m->generate(array('controller' => 'admin/user', 'action' => 'index')));
+            $this->assertEquals('/admin/user/list',
+                                $m->generate(array('controller' => 'admin/user', 'action' => 'list')));
+            $this->assertEquals('/admin/user/show/10',
+                                $m->generate(array('controller' => 'admin/user', 'action' => 'show', 'id' => '10')));
+        }
+    }
+
+    public function testMultiroute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:year/:month/:day', array('controller' => 'blog', 'action' => 'view',
+                                                       'month' => null, 'day' => null,
+                                                       'requirements' => array('month' => '\d{1,2}',
+                                                                               'day'   => '\d{1,2}')));
+        $m->connect('viewpost/:id', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/blog/view?year=2004&month=blah',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => 'blah')));
+        $this->assertEquals('/archive/2004/11',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => 11)));
+        $this->assertEquals('/archive/2004/11',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => '11')));
+        $this->assertEquals('/archive/2004/11',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'year' => 2004, 'month' => 11)));
+        $this->assertEquals('/archive/2004', $m->generate(array('controller' => 'blog', 'action' => 'view', 'year' => 2004)));
+        $this->assertEquals('/viewpost/3',
+                            $m->generate(array('controller' => 'post', 'action' => 'view', 'id' => 3)));
+    }
+
+    public function testMultirouteWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:(year)/:(month)/:(day)', array('controller' => 'blog', 'action' => 'view',
+                                                             'month' => null, 'day' => null,
+                                                             'requirements' => array('month' => '\d{1,2}',
+                                                                                           'day'   => '\d{1,2}')));
+        $m->connect('viewpost/:(id)', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/blog/view?year=2004&month=blah',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => 'blah')));
+        $this->assertEquals('/archive/2004/11',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => 11)));
+        $this->assertEquals('/archive/2004/11',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004, 'month' => '11')));
+        $this->assertEquals('/archive/2004',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view', 'year' => 2004)));
+        $this->assertEquals('/viewpost/3',
+                            $m->generate(array('controller' => 'post', 'action' => 'view', 'id' => 3)));
+    }
+
+    public function testBigMultiroute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('', array('controller' => 'articles', 'action' => 'index'));
+        $m->connect('admin', array('controller' => 'admin/general', 'action' => 'index'));
+
+        $m->connect('admin/comments/article/:article_id/:action/:id',
+                    array('controller' => 'admin/comments', 'action' => null, 'id' => null));
+        $m->connect('admin/trackback/article/:article_id/:action/:id',
+                    array('controller' => 'admin/trackback', 'action' => null, 'id' => null));
+        $m->connect('admin/content/:action/:id', array('controller' => 'admin/content'));
+
+        $m->connect('xml/:action/feed.xml', array('controller' => 'xml'));
+        $m->connect('xml/articlerss/:id/feed.xml', array('controller' => 'xml', 'action' => 'articlerss'));
+        $m->connect('index.rdf', array('controller' => 'xml', 'action' => 'rss'));
+
+        $m->connect('articles', array('controller' => 'articles', 'action' => 'index'));
+        $m->connect('articles/page/:page',
+                    array('controller' => 'articles', 'action' => 'index',
+                          'requirements' => array('page' => '\d+')));
+        $m->connect('articles/:year/:month/:day/page/:page',
+                    array('controller' => 'articles', 'action' => 'find_by_date',
+                          'month' => null, 'day' => null,
+                          'requirements' => array('year' => '\d{4}', 'month' => '\d{1,2}',
+                                                                     'day' => '\d{1,2}')));
+        $m->connect('articles/category/:id', array('controller' => 'articles', 'action' => 'category'));
+        $m->connect('pages/*name', array('controller' => 'articles', 'action' => 'view_page'));
+
+        $this->assertEquals('/pages/the/idiot/has/spoken',
+                            $m->generate(array('controller' => 'articles', 'action' => 'view_page',
+                                               'name' => 'the/idiot/has/spoken')));
+        $this->assertEquals('/',
+                            $m->generate(array('controller' => 'articles', 'action' => 'index')));
+        $this->assertEquals('/xml/articlerss/4/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'articlerss', 'id' => 4)));
+        $this->assertEquals('/xml/rss/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'rss')));
+        $this->assertEquals('/admin/comments/article/4/view/2',
+                            $m->generate(array('controller' => 'admin/comments', 'action' => 'view',
+                                               'article_id' => 4, 'id' => 2)));
+        $this->assertEquals('/admin',
+                            $m->generate(array('controller' => 'admin/general')));
+        $this->assertEquals('/admin/comments/article/4/index',
+                            $m->generate(array('controller' => 'admin/comments', 'article_id' => 4)));
+        $this->assertEquals('/admin/comments/article/4',
+                            $m->generate(array('controller' => 'admin/comments', 'action' => null,
+                                               'article_id' => 4)));
+        $this->assertEquals('/articles/2004/2/20/page/1',
+                            $m->generate(array('controller' => 'articles', 'action' => 'find_by_date',
+                                               'year' => 2004, 'month' => 2, 'day' => 20, 'page' => 1)));
+        $this->assertEquals('/articles/category',
+                            $m->generate(array('controller' => 'articles', 'action' => 'category')));
+        $this->assertEquals('/xml/index/feed.xml',
+                            $m->generate(array('controller' => 'xml')));
+        $this->assertEquals('/xml/articlerss/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'articlerss')));
+        $this->assertNull($m->generate(array('controller' => 'admin/comments', 'id' => 2)));
+        $this->assertNull($m->generate(array('controller' => 'articles', 'action' => 'find_by_date',
+                                             'year' => 2004)));
+    }
+
+    public function testBigMultirouteWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('', array('controller' => 'articles', 'action' => 'index'));
+        $m->connect('admin', array('controller' => 'admin/general', 'action' => 'index'));
+
+        $m->connect('admin/comments/article/:(article_id)/:(action)/:(id)',
+                    array('controller' => 'admin/comments', 'action' => null, 'id' => null));
+        $m->connect('admin/trackback/article/:(article_id)/:(action)/:(id)',
+                    array('controller' => 'admin/trackback', 'action' => null, 'id' => null));
+        $m->connect('admin/content/:(action)/:(id)', array('controller' => 'admin/content'));
+
+        $m->connect('xml/:(action)/feed.xml', array('controller' => 'xml'));
+        $m->connect('xml/articlerss/:(id)/feed.xml', array('controller' => 'xml', 'action' => 'articlerss'));
+        $m->connect('index.rdf', array('controller' => 'xml', 'action' => 'rss'));
+
+        $m->connect('articles', array('controller' => 'articles', 'action' => 'index'));
+        $m->connect('articles/page/:(page)',
+                    array('controller' => 'articles', 'action' => 'index',
+                          'requirements' => array('page' => '\d+')));
+        $m->connect('articles/:(year)/:(month)/:(day)/page/:(page)',
+                    array('controller' => 'articles', 'action' => 'find_by_date',
+                          'month' => null, 'day' => null,
+                          'requirements' => array('year' => '\d{4}', 'month' => '\d{1,2}',
+                                                                     'day' => '\d{1,2}')));
+        $m->connect('articles/category/:(id)', array('controller' => 'articles', 'action' => 'category'));
+        $m->connect('pages/*name', array('controller' => 'articles', 'action' => 'view_page'));
+
+        $this->assertEquals('/pages/the/idiot/has/spoken',
+                            $m->generate(array('controller' => 'articles', 'action' => 'view_page',
+                                               'name' => 'the/idiot/has/spoken')));
+        $this->assertEquals('/',
+                            $m->generate(array('controller' => 'articles', 'action' => 'index')));
+        $this->assertEquals('/xml/articlerss/4/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'articlerss', 'id' => 4)));
+        $this->assertEquals('/xml/rss/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'rss')));
+        $this->assertEquals('/admin/comments/article/4/view/2',
+                            $m->generate(array('controller' => 'admin/comments', 'action' => 'view',
+                                               'article_id' => 4, 'id' => 2)));
+        $this->assertEquals('/admin',
+                            $m->generate(array('controller' => 'admin/general')));
+        $this->assertEquals('/admin/comments/article/4/index',
+                            $m->generate(array('controller' => 'admin/comments', 'article_id' => 4)));
+        $this->assertEquals('/admin/comments/article/4',
+                            $m->generate(array('controller' => 'admin/comments', 'action' => null,
+                                               'article_id' => 4)));
+        $this->assertEquals('/articles/2004/2/20/page/1',
+                            $m->generate(array('controller' => 'articles', 'action' => 'find_by_date',
+                                               'year' => 2004, 'month' => 2, 'day' => 20, 'page' => 1)));
+        $this->assertEquals('/articles/category',
+                            $m->generate(array('controller' => 'articles', 'action' => 'category')));
+        $this->assertEquals('/xml/index/feed.xml',
+                            $m->generate(array('controller' => 'xml')));
+        $this->assertEquals('/xml/articlerss/feed.xml',
+                            $m->generate(array('controller' => 'xml', 'action' => 'articlerss')));
+        $this->assertNull($m->generate(array('controller' => 'admin/comments', 'id' => 2)));
+        $this->assertNull($m->generate(array('controller' => 'articles', 'action' => 'find_by_date',
+                                             'year' => 2004)));
+    }
+
+    public function testNoExtras()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:action/:id');
+        $m->connect('archive/:year/:month/:day', array('controller' => 'blog', 'action' => 'view',
+                                                       'month' => null, 'day' => null));
+
+        $this->assertEquals('/archive/2004',
+                            $m->generate(array('controller' => 'blog', 'action' => 'view',
+                                               'year' => 2004)));
+    }
+
+    public function testNoExtrasWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':(controller)/:(action)/:(id)');
+        $m->connect('archive/:(year)/:(month)/:(day)', array('controller' => 'blog', 'action' => 'view',
+                                                             'month' => null, 'day' => null));
+    }
+
+    public function testTheSmallestRoute()
+    {
+        foreach (array('pages/:title', 'pages/:(title)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect('', array('controller' => 'page', 'action' => 'view', 'title' => 'HomePage'));
+            $m->connect($path, array('controller' => 'page', 'action' => 'view'));
+
+            $this->assertEquals('/pages/joe', $m->generate(array('controller' => 'page', 'action' => 'view', 'title' => 'joe')));
+            $this->assertEquals('/',
+                                $m->generate(array('controller' => 'page', 'action' => 'view',
+                                                   'title' => 'HomePage')));
+        }
+    }
+
+    public function testExtras()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('viewpost/:id', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/viewpost/2?extra=x%2Fy',
+                            $m->generate(array('controller' => 'post', 'action' => 'view',
+                                               'id' => 2, 'extra' => 'x/y')));
+        $this->assertEquals('/blog?extra=3',
+                            $m->generate(array('controller' => 'blog', 'action' => 'index', 'extra' => 3)));
+        $this->assertEquals('/viewpost/2?extra=3',
+                            $m->generate(array('controller' => 'post', 'action' => 'view',
+                                               'id' => 2, 'extra' => 3)));
+    }
+
+    public function testExtrasWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('viewpost/:(id)', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/viewpost/2?extra=x%2Fy',
+                            $m->generate(array('controller' => 'post', 'action' => 'view',
+                                               'id' => 2, 'extra' => 'x/y')));
+        $this->assertEquals('/blog?extra=3',
+                            $m->generate(array('controller' => 'blog', 'action' => 'index', 'extra' => 3)));
+        $this->assertEquals('/viewpost/2?extra=3',
+                            $m->generate(array('controller' => 'post', 'action' => 'view',
+                                               'id' => 2, 'extra' => 3)));
+    }
+
+    public function testStatic()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('hello/world', array('controller' => 'content', 'action' => 'index',
+                                         'known' => 'known_value'));
+
+        $this->assertEquals('/hello/world',
+                            $m->generate(array('controller' => 'content', 'action' => 'index',
+                                               'known' => 'known_value')));
+        $this->assertEquals('/hello/world?extra=hi',
+                            $m->generate(array('controller' => 'content', 'action' => 'index',
+                                               'known' => 'known_value', 'extra' => 'hi')));
+        $this->assertNull($m->generate(array('known' => 'foo')));
+    }
+
+    public function testTypical()
+    {
+        foreach (array(':controller/:action/:id', ':(controller)/:(action)/:(id)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('action' => 'index', 'id' => null));
+
+            $this->assertEquals('/content',
+                                $m->generate(array('controller' => 'content', 'action' => 'index')));
+            $this->assertEquals('/content/list',
+                                $m->generate(array('controller' => 'content', 'action' => 'list')));
+            $this->assertEquals('/content/show/10',
+                                $m->generate(array('controller' => 'content', 'action' => 'show', 'id' => 10)));
+
+            $this->assertEquals('/admin/user',
+                                $m->generate(array('controller' => 'admin/user', 'action' => 'index')));
+            $this->assertEquals('/admin/user',
+                                $m->generate(array('controller' => 'admin/user')));
+            $this->assertEquals('/admin/user/show/10',
+                                $m->generate(array('controller' => 'admin/user', 'action' => 'show', 'id' => 10)));
+
+            $this->assertEquals('/content', $m->generate(array('controller' => 'content')));
+        }
+    }
+
+    public function testRouteWithFixnumDefault()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('page/:id', array('controller' => 'content', 'action' => 'show_page', 'id' => 1));
+
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page')));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 1)));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => '1')));
+        $this->assertEquals('/page/10',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 10)));
+        $this->assertEquals('/blog/show/4',
+                            $m->generate(array('controller' => 'blog', 'action' => 'show', 'id' => 4)));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page')));
+        $this->assertEquals('/page/4',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 4)));
+        $this->assertEquals('/content/show',
+                            $m->generate(array('controller' => 'content', 'action' => 'show')));
+    }
+
+    public function testRouteWithFixnumDefaultWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('page/:(id)', array('controller' => 'content', 'action' => 'show_page', 'id' => 1));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page')));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 1)));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => '1')));
+        $this->assertEquals('/page/10',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 10)));
+        $this->assertEquals('/blog/show/4',
+                            $m->generate(array('controller' => 'blog', 'action' => 'show', 'id' => 4)));
+        $this->assertEquals('/page',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page')));
+        $this->assertEquals('/page/4',
+                            $m->generate(array('controller' => 'content', 'action' => 'show_page', 'id' => 4)));
+        $this->assertEquals('/content/show',
+                            $m->generate(array('controller' => 'content', 'action' => 'show')));
+    }
+
+    public function testUppercaseRecognition()
+    {
+        foreach (array(':controller/:action/:id', ':(controller)/:(action)/:(id)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+
+            $this->assertEquals('/Content',
+                                $m->generate(array('controller' => 'Content', 'action' => 'index')));
+            $this->assertEquals('/Content/list',
+                                $m->generate(array('controller' => 'Content', 'action' => 'list')));
+            $this->assertEquals('/Content/show/10',
+                                $m->generate(array('controller' => 'Content', 'action' => 'show', 'id' => '10')));
+
+            $this->assertEquals('/Admin/NewsFeed',
+                                $m->generate(array('controller' => 'Admin/NewsFeed', 'action' => 'index')));
+        }
+    }
+
+    public function testBackwards()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('page/:id/:action', array('controller' => 'pages', 'action' => 'show'));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/page/20',
+                            $m->generate(array('controller' => 'pages', 'action' => 'show', 'id' => 20)));
+        $this->assertEquals('/pages/boo',
+                            $m->generate(array('controller' => 'pages', 'action' => 'boo')));
+    }
+
+    public function testBackwardsWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('page/:(id)/:(action)', array('controller' => 'pages', 'action' => 'show'));
+        $m->connect(':(controller)/:(action)/:(id)');
+
+        $this->assertEquals('/page/20',
+                            $m->generate(array('controller' => 'pages', 'action' => 'show', 'id' => 20)));
+        $this->assertEquals('/pages/boo',
+                            $m->generate(array('controller' => 'pages', 'action' => 'boo')));
+    }
+
+    public function testBothRequirementAndOptional()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('test/:year', array('controller' => 'post', 'action' => 'show',
+                                        'year' => null, 'requirements' => array('year' => '\d{4}')));
+
+        $this->assertEquals('/test',
+                            $m->generate(array('controller' => 'post', 'action' => 'show')));
+        $this->assertEquals('/test',
+                            $m->generate(array('controller' => 'post', 'action' => 'show', 'year' => null)));
+    }
+
+    public function testSetToNilForgets()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('pages/:year/:month/:day',
+                    array('controller' => 'content', 'action' => 'list_pages',
+                          'month' => null, 'day' => null));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/pages/2005',
+                            $m->generate(array('controller' => 'content', 'action' => 'list_pages',
+                                               'year' => 2005)));
+        $this->assertEquals('/pages/2005/6',
+                            $m->generate(array('controller' => 'content', 'action' => 'list_pages',
+                                               'year' => 2005, 'month' => 6)));
+        $this->assertEquals('/pages/2005/6/12',
+                            $m->generate(array('controller' => 'content', 'action' => 'list_pages',
+                                               'year' => 2005, 'month' => 6, 'day' => 12)));
+    }
+
+    public function testUrlWithNoActionSpecified()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('', array('controller' => 'content'));
+        $m->connect(':controller/:action/:id');
+
+        $this->assertEquals('/',
+                            $m->generate(array('controller' => 'content', 'action' => 'index')));
+        $this->assertEquals('/',
+                            $m->generate(array('controller' => 'content')));
+    }
+
+    public function testUrlWithPrefix()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->prefix = '/blog';
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/blog/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/blog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/blog/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+    }
+
+    public function testUrlWithPrefixDeeper()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->prefix = '/blog/phil';
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/blog/phil/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/blog/phil/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/blog/phil/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+    }
+
+    public function testUrlWithEnvironEmpty()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '');
+
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+    }
+
+    public function testUrlWithEnviron()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '/blog');
+
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/blog/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/blog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/blog/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+
+        $m->environ['SCRIPT_NAME'] = '/notblog';
+
+        $this->assertEquals('/notblog/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/notblog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/notblog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/notblog/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+    }
+
+    public function testUrlWithEnvironAndAbsolute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '/blog');
+
+        $utils = $m->utils;
+
+        $m->connect('image', 'image/:name', array('_absolute' => true));
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/blog/content/view',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/blog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/blog/content',
+                            $m->generate(array('controller' => 'content')));
+        $this->assertEquals('/blog/admin/comments',
+                            $m->generate(array('controller' => 'admin/comments')));
+        $this->assertEquals('/image/topnav.jpg',
+                            $utils->urlFor('image', array('name' => 'topnav.jpg')));
+    }
+
+    public function testRouteWithOddLeftovers()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array();
+
+        $m->connect(':controller/:(action)-:(id)');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/content/view-',
+                            $m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index-',
+                            $m->generate(array('controller' => 'content')));
+    }
+
+    public function testRouteWithEndExtension()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array();
+
+        $m->connect(':controller/:(action)-:(id).html');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertNull($m->generate(array('controller' => 'content', 'action' => 'view')));
+        $this->assertNull($m->generate(array('controller' => 'content')));
+
+        $this->assertEquals('/content/view-3.html',
+                            $m->generate(array('controller' => 'content', 'action' => 'view', 'id' => 3)));
+        $this->assertEquals('/content/index-2.html',
+                            $m->generate(array('controller' => 'content', 'id' => 2)));
+    }
+
+    public function testResources()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array();
+
+        $utils = $m->utils;
+
+        $m->resource('message', 'messages');
+        $m->createRegs(array('messages'));
+        $options = array('controller' => 'messages');
+
+        $this->assertEquals('/messages',
+                            $utils->urlFor('messages'));
+        $this->assertEquals('/messages.xml',
+                            $utils->urlFor('messages', array('format' => 'xml')));
+        $this->assertEquals('/messages/1',
+                            $utils->urlFor('message', array('id' => 1)));
+        $this->assertEquals('/messages/1.xml',
+                            $utils->urlFor('message', array('id' => 1, 'format' => 'xml')));
+        $this->assertEquals('/messages/new',
+                            $utils->urlFor('new_message'));
+        $this->assertEquals('/messages/1.xml',
+                            $utils->urlFor('message', array('id' => 1, 'format' => 'xml')));
+        $this->assertEquals('/messages/1/edit',
+                            $utils->urlFor('edit_message', array('id' => 1)));
+        $this->assertEquals('/messages/1/edit.xml',
+                            $utils->urlFor('edit_message', array('id' => 1, 'format' => 'xml')));
+
+        $this->assertRestfulRoutes($m, $options);
+    }
+
+    public function testResourcesWithPathPrefix()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->resource('message', 'messages', array('pathPrefix' => '/thread/:threadid'));
+        $m->createRegs(array('messages'));
+        $options = array('controller' => 'messages', 'threadid' => '5');
+        $this->assertRestfulRoutes($m, $options, 'thread/5/');
+    }
+
+    public function testResourcesWithCollectionAction()
+    {
+        $m = new Horde_Routes_Mapper();
+        $utils = $m->utils;
+        $m->resource('message', 'messages', array('collection' => array('rss' => 'GET')));
+        $m->createRegs(array('messages'));
+        $options = array('controller' => 'messages');
+        $this->assertRestfulRoutes($m, $options);
+
+        $this->assertEquals('/messages/rss',
+                            $m->generate(array('controller' => 'messages', 'action' => 'rss')));
+        $this->assertEquals('/messages/rss',
+                            $utils->urlFor('rss_messages'));
+        $this->assertEquals('/messages/rss.xml',
+                            $m->generate(array('controller' => 'messages', 'action' => 'rss',
+                                               'format' => 'xml')));
+        $this->assertEquals('/messages/rss.xml',
+                            $utils->urlFor('formatted_rss_messages', array('format' => 'xml')));
+    }
+
+    public function testResourcesWithMemberAction()
+    {
+        foreach (array('put', 'post') as $method) {
+            $m = new Horde_Routes_Mapper();
+            $m->resource('message', 'messages', array('member' => array('mark' => $method)));
+            $m->createRegs(array('messages'));
+
+            $options = array('controller' => 'messages');
+            $this->assertRestfulRoutes($m, $options);
+
+            $connectkargs = array('method' => $method, 'action' => 'mark', 'id' => '1');
+            $this->assertEquals('/messages/1/mark',
+                                $m->generate(array_merge($connectkargs, $options)));
+
+            $connectkargs = array('method' => $method, 'action' => 'mark',
+                                  'id' => '1', 'format' => 'xml');
+            $this->assertEquals('/messages/1/mark.xml',
+                                $m->generate(array_merge($connectkargs, $options)));
+        }
+    }
+
+    public function testResourcesWithNewAction()
+    {
+        $m = new Horde_Routes_Mapper();
+        $utils = $m->utils;
+        $m->resource('message', 'messages/', array('new' => array('preview' => 'POST')));
+        $m->createRegs(array('messages'));
+        $this->assertRestfulRoutes($m, array('controller' => 'messages'));
+
+        $this->assertEquals('/messages/new/preview',
+                            $m->generate(array('controller' => 'messages', 'action' => 'preview',
+                                               'method' => 'post')));
+        $this->assertEquals('/messages/new/preview',
+                            $utils->urlFor('preview_new_message'));
+        $this->assertEquals('/messages/new/preview.xml',
+                            $m->generate(array('controller' => 'messages', 'action' => 'preview',
+                                               'method' => 'post', 'format' => 'xml')));
+        $this->assertEquals('/messages/new/preview.xml',
+                            $utils->urlFor('preview_new_message', array('format' => 'xml')));
+    }
+
+    public function testResourcesWithNamePrefix()
+    {
+        $m = new Horde_Routes_Mapper();
+        $utils = $m->utils;
+        $m->resource('message', 'messages', array('namePrefix' => 'category_',
+                                                  'new'        => array('preview' => 'POST')));
+        $m->createRegs(array('messages'));
+        $options = array('controller' => 'messages');
+        $this->assertRestfulRoutes($m, $options);
+
+        $this->assertEquals('/messages/new/preview',
+                            $utils->urlFor('category_preview_new_message'));
+
+        $this->assertNull($utils->urlFor('category_preview_new_message', array('method' => 'get')));
+    }
+
+    public function testUnicode()
+    {
+        // php version does not handing decoding
+    }
+
+    public function testUnicodeStatic()
+    {
+        // php version does not handing decoding
+    }
+
+    public function testOtherSpecialChars()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('/:year/:(slug).:(format),:(locale)', array('locale' => 'en', 'format' => 'html'));
+        $m->createRegs(array('content'));
+
+        $this->assertEquals('/2007/test',
+                            $m->generate(array('year' => 2007, 'slug' => 'test')));
+        $this->assertEquals('/2007/test.xml',
+                            $m->generate(array('year' => 2007, 'slug' => 'test', 'format' => 'xml')));
+        $this->assertEquals('/2007/test.xml,ja',
+                            $m->generate(array('year' => 2007, 'slug' => 'test', 'format' => 'xml',
+                                               'locale' => 'ja')));
+        $this->assertNull($m->generate(array('year' => 2007, 'format' => 'html')));
+    }
+
+    // Test Helpers
+
+    public function assertRestfulRoutes($m, $options, $pathPrefix = '')
+    {
+        $baseroute = '/' . $pathPrefix . $options['controller'];
+
+        $this->assertEquals($baseroute,
+                            $m->generate(array_merge($options, array('action' => 'index'))));
+
+        $this->assertEquals($baseroute . '.xml',
+                            $m->generate(array_merge($options, array('action' => 'index',
+                                                                     'format' => 'xml'))));
+        $this->assertEquals($baseroute . '/new',
+                            $m->generate(array_merge($options, array('action' => 'new'))));
+        $this->assertEquals($baseroute . '/1',
+                            $m->generate(array_merge($options, array('action' => 'show',
+                                                                     'id'     => '1'))));
+        $this->assertEquals($baseroute . '/1/edit',
+                            $m->generate(array_merge($options, array('action' => 'edit',
+                                                                     'id'     => '1'))));
+        $this->assertEquals($baseroute . '/1.xml',
+                            $m->generate(array_merge($options, array('action' => 'show',
+                                                                     'id'     => '1',
+                                                                     'format' => 'xml'))));
+        $this->assertEquals($baseroute,
+                            $m->generate(array_merge($options, array('action' => 'create',
+                                                                     'method' => 'post'))));
+
+        $this->assertEquals($baseroute . '/1',
+                            $m->generate(array_merge($options, array('action' => 'update',
+                                                                     'method' => 'put',
+                                                                     'id'     => '1'))));
+        $this->assertEquals($baseroute . '/1',
+                            $m->generate(array_merge($options, array('action' => 'delete',
+                                                                     'method' => 'delete',
+                                                                     'id'     => '1'))));
+    }
+
+}
diff --git a/framework/Routes/test/Horde/Routes/RecognitionTest.php b/framework/Routes/test/Horde/Routes/RecognitionTest.php
new file mode 100644 (file)
index 0000000..3eabe5d
--- /dev/null
@@ -0,0 +1,1097 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based 
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * @package Horde_Routes
+ */
+class RecognitionTest extends PHPUnit_Framework_TestCase 
+{
+    public function testRegexpCharEscaping()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:(action).:(id)');
+        $m->createRegs(array('content'));
+        
+        $this->assertNull($m->match('/content/view#2'));
+
+        $matchdata = array('action' => 'view', 'controller' => 'content', 'id' => '2');
+        $this->assertEquals($matchdata, $m->match('/content/view.2'));
+
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'find.all'));
+
+        $matchdata = array('action' => 'view#2', 'controller' => 'content', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/content/view#2'));
+
+        $matchdata = array('action' => 'view', 'controller' => 'find.all', 'id'=> null);
+        $this->assertEquals($matchdata, $m->match('/find.all/view'));
+        
+        $this->assertNull($m->match('/findzall/view'));
+    }
+    public function testAllStatic()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('hello/world/how/are/you', array('controller' => 'content', 'action' => 'index'));
+        $m->createRegs(array());
+        
+        $this->assertNull($m->match('/x'));
+        $this->assertNull($m->match('/hello/world/how'));
+        $this->assertNull($m->match('/hello/world/how/are'));
+        $this->assertNull($m->match('/hello/world/how/are/you/today'));
+        
+        $matchdata = array('controller' => 'content', 'action' =>'index');
+        $this->assertEquals($matchdata, $m->match('/hello/world/how/are/you'));
+    }
+
+    public function testUnicode()
+    {
+        // php version does not handing decoding
+    }
+    
+    public function testDisablingUnicode()
+    {
+        // php version does not handing decoding
+    }
+    
+    public function testBasicDynamic()
+    {
+        foreach(array('hi/:name', 'hi/:(name)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content'));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+            $this->assertNull($m->match('/hi/dude/what'));
+
+            $matchdata = array('controller' => 'content', 'name' => 'dude', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi/dude'));
+            $this->assertEquals($matchdata, $m->match('/hi/dude/'));
+        }
+    }
+
+    public function testBasicDynamicBackwards()
+    {
+        foreach (array(':name/hi', ':(name)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/hi'));
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/shop/walmart/hi'));
+            
+            $matchdata = array('name' => 'fred', 'action' => 'index', 'controller' => 'content');
+            $this->assertEquals($matchdata, $m->match('/fred/hi'));
+
+            $matchdata = array('name' => 'index', 'action' => 'index', 'controller' => 'content');
+            $this->assertEquals($matchdata, $m->match('/index/hi'));
+        }
+    }
+
+    public function testDynamicWithUnderscores()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('article/:small_page', array('small_page' => false));
+        $m->connect(':(controller)/:(action)/:(id)');
+        $m->createRegs(array('article', 'blog'));
+        
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => '0');
+        $this->assertEquals($matchdata, $m->match('/blog/view/0'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/view'));
+    }
+
+    public function testDynamicWithDefault()
+    {
+        foreach (array('hi/:action', 'hi/:(action)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content'));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi/dude/what'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi/index'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/hi/dude'));
+        }
+    }
+
+    public function testDynamicWithDefaultBackwards()
+    {
+        foreach (array(':action/hi', ':(action)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content'));
+            $m->createRegs();
+
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/index/hi'));
+            $this->assertEquals($matchdata, $m->match('/index/hi/'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/dude/hi'));
+        }
+    }
+
+    public function testDynamicWithStringCondition()
+    {
+        foreach (array(':name/hi', ':(name)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller'   => 'content', 
+                                     'requirements' => array('name' => 'index')));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+            $this->assertNull($m->match('/dude/what/hi'));
+
+            $matchdata = array('controller' => 'content', 'name' => 'index', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/index/hi'));
+            $this->assertNull($m->match('/dude/hi'));
+        }
+    }
+
+    public function testDynamicWithStringConditionBackwards()
+    {
+        foreach (array('hi/:name', 'hi/:(name)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller'   => 'content',
+                                     'requirements' => array('name' => 'index')));
+            $m->createRegs();
+
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+            $this->assertNull($m->match('/hi/dude/what'));
+            
+            $matchdata = array('controller' => 'content', 'name' => 'index', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi/index'));
+
+            $this->assertEquals($matchdata, $m->match('/hi/index'));
+            
+            $this->assertNull($m->match('/dude/hi'));
+        }
+    }
+
+    public function testDynamicWithRegexpCondition()
+    {
+        foreach (array('hi/:name', 'hi/:(name)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller'   => 'content',
+                                     'requirements' => array('name' => '[a-z]+')));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+            $this->assertNull($m->match('/hi/FOXY'));
+            $this->assertNull($m->match('/hi/138708jkhdf'));
+            $this->assertNull($m->match('/hi/dkjfl8792343dfsf'));
+            $this->assertNull($m->match('/hi/dude/what'));
+            $this->assertNull($m->match('/hi/dude/what/'));
+
+            $matchdata = array('controller' => 'content', 'name' => 'index', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi/index'));
+
+            $matchdata = array('controller' => 'content', 'name' => 'dude', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi/dude'));
+        }
+    }
+
+    public function testDynamicWithRegexpAndDefault()
+    {
+        foreach (array('hi/:action', 'hi/:(action)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller'   => 'content',
+                                     'requirements' => array('action' => '[a-z]+')));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi/FOXY'));
+            $this->assertNull($m->match('/hi/138708jkhdf'));
+            $this->assertNull($m->match('/hi/dkjfl8792343dfsf'));
+            $this->assertNull($m->match('/hi/dude/what/'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'index');
+            $this->assertEquals($matchdata, $m->match('/hi'));
+            $this->assertEquals($matchdata, $m->match('/hi/index'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/hi/dude'));
+        }
+    }
+
+    public function testDynamicWithDefaultAndStringConditionBackwards()
+    {
+        foreach (array(':action/hi', ':(action)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+
+            $matchdata = array('action' => 'index', 'controller' => 'content');
+            $this->assertEquals($matchdata, $m->match('/index/hi'));
+        }
+    }
+
+    public function testDynamicAndControllerWithStringAndDefaultBackwards()
+    {
+        foreach (array(':controller/:action/hi', ':(controller)/:(action)/hi') as $path) {
+            $m = new Horde_Routes_Mapper();
+
+            $m->connect($path, array('controller' => 'content'));
+            $m->createRegs(array('content', 'admin/user'));
+
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/fred'));
+        }
+    }
+
+    public function testMultiroute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:year/:month/:day', array('controller' => 'blog', 'action' => 'view', 
+                                                       'month' => null, 'day' => null,
+                                                          'requirements' => array('month' => '\d{1,2}',
+                                                                                  'day'   => '\d{1,2}')));
+        $m->connect('viewpost/:id', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('post','blog','admin/user'));
+        
+        $this->assertNull($m->match('/'));
+        $this->assertNull($m->match('/archive'));
+        $this->assertNull($m->match('/archive/2004/ab'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/view'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view',
+                           'month' => null, 'day' => null, 'year' => '2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 
+                           'month' => '4', 'day' => null, 'year' =>'2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004/4'));
+    }
+
+    public function testMultirouteWithSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:(year)/:(month)/:(day)', array('controller' => 'blog', 'action' => 'view', 
+                                                             'month' => null, 'day' => null,
+                                                                'requirements' => array('month' => '\d{1,2}',
+                                                                                        'day'   => '\d{1,2}')));
+        $m->connect('viewpost/:(id)', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':(controller)/:(action)/:(id)');
+        $m->createRegs(array('post','blog','admin/user'));
+        
+        $this->assertNull($m->match('/'));
+        $this->assertNull($m->match('/archive'));
+        $this->assertNull($m->match('/archive/2004/ab'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/view'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view',
+                           'month' => null, 'day' => null, 'year' => '2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 
+                           'month' => '4', 'day' => null, 'year' => '2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004/4'));             
+    }
+    
+    public function testDynamicWithRegexpDefaultsAndGaps()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:year/:month/:day', array('controller' => 'blog', 'action' => 'view', 
+                                                       'month' => null, 'day' => null,
+                                                          'requirements' => array('month' => '\d{1,2}')));
+        $m->connect('view/:id/:controller', array('controller' => 'blog', 'action' => 'view',
+                                                      'id' => 2, 'requirements' => array('id' => '\d{1,2}')));
+        $m->createRegs(array('post','blog','admin/user'));        
+        
+        $this->assertNull($m->match('/'));
+        $this->assertNull($m->match('/archive'));
+        $this->assertNull($m->match('/archive/2004/haha'));
+        $this->assertNull($m->match('/view/blog'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => '2');
+        $this->assertEquals($matchdata, $m->match('/view'));
+        
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 
+                           'month' => null, 'day' => null, 'year' => '2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004'));
+    }
+
+    public function testDynamicWithRegexpDefaultsAndGapsAndSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archive/:(year)/:(month)/:(day)', array('controller' => 'blog', 'action' => 'view', 
+                                                             'month' => null, 'day' => null,
+                                                                'requirements' => array('month' => '\d{1,2}')));
+        $m->connect('view/:(id)/:(controller)', array('controller' => 'blog', 'action' => 'view',
+                                                          'id' => 2, 'requirements' => array('id' => '\d{1,2}')));
+        $m->createRegs(array('post','blog','admin/user'));        
+        
+        $this->assertNull($m->match('/'));
+        $this->assertNull($m->match('/archive'));
+        $this->assertNull($m->match('/archive/2004/haha'));
+        $this->assertNull($m->match('/view/blog'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => '2');
+        $this->assertEquals($matchdata, $m->match('/view'));
+        
+        $matchdata = array('controller' => 'blog', 'action' => 'view', 
+                           'month' => null, 'day' => null, 'year' => '2004');
+        $this->assertEquals($matchdata, $m->match('/archive/2004'));        
+    }
+    
+    public function testDynamicWithRegexpGapsControllers()
+    {
+        foreach(array('view/:id/:controller', 'view/:(id)/:(controller)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('id' => 2, 'action' => 'view', 'requirements' => array('id' => '\d{1,2}')));
+            $m->createRegs(array('post', 'blog', 'admin/user'));
+            
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/view'));
+            $this->assertNull($m->match('/view/blog'));
+            $this->assertNull($m->match('/view/3'));
+            $this->assertNull($m->match('/view/4/honker'));
+
+            $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => '2');
+            $this->assertEquals($matchdata, $m->match('/view/2/blog'));
+        }
+    }
+    
+    public function testDynamicWithTrailingStrings()
+    {
+        foreach (array('view/:id/:controller/super', 'view/:(id)/:(controller)/super') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'blog', 'action' => 'view',
+                                     'id' => 2, 'requirements' => array('id' => '\d{1,2}')));
+            $m->createRegs(array('post', 'blog', 'admin/user'));
+
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/view'));
+            $this->assertNull($m->match('/view/blah/blog/super'));
+            $this->assertNull($m->match('/view/ha/super'));
+            $this->assertNull($m->match('/view/super'));
+            $this->assertNull($m->match('/view/4/super'));
+
+            $matchdata = array('controller' => 'blog', 'action' => 'view', 'id' => '2');
+            $this->assertEquals($matchdata, $m->match('/view/2/blog/super'));
+
+            $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'id' => '4');
+            $this->assertEquals($matchdata, $m->match('/view/4/admin/user/super'));
+        }
+    }
+
+    public function testDynamicWithTrailingNonKeywordStrings()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('somewhere/:over/rainbow', array('controller' => 'blog'));
+        $m->connect('somewhere/:over', array('controller' => 'post'));
+        $m->createRegs(array('post', 'blog', 'admin/user'));
+
+        $this->assertNull($m->match('/'));
+        $this->assertNull($m->match('/somewhere'));
+
+        $matchdata = array('controller' => 'blog', 'action' => 'index', 'over' => 'near');
+        $this->assertEquals($matchdata, $m->match('/somewhere/near/rainbow'));
+        
+        $matchdata = array('controller' => 'post', 'action' => 'index', 'over' => 'tomorrow');
+        $this->assertEquals($matchdata, $m->match('/somewhere/tomorrow'));
+    }
+
+    public function testDynamicWithTrailingDynamicDefaults()
+    {
+        foreach (array('archives/:action/:article', 'archives/:(action)/:(article)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'blog'));
+            $m->createRegs(array('blog'));
+            
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/archives'));
+            $this->assertNull($m->match('/archives/introduction'));
+            $this->assertNull($m->match('/archives/sample'));
+            $this->assertNull($m->match('/view/super'));
+            $this->assertNull($m->match('/view/4/super'));
+
+            $matchdata = array('controller' => 'blog', 'action' => 'view', 'article' => 'introduction');
+            $this->assertEquals($matchdata, $m->match('/archives/view/introduction'));
+            
+            $matchdata = array('controller' => 'blog', 'action' => 'edit', 'article' => 'recipes');
+            $this->assertEquals($matchdata, $m->match('/archives/edit/recipes'));
+        }
+    }
+
+    public function testPath()
+    {
+        foreach (array('hi/*file', 'hi/*(file)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content', 'action' => 'download'));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/hi'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download', 
+                               'file' => 'books/learning_python.pdf');
+            $this->assertEquals($matchdata, $m->match('/hi/books/learning_python.pdf'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download',
+                               'file' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/hi/dude'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download',
+                               'file' => 'dude/what');
+            $this->assertEquals($matchdata, $m->match('/hi/dude/what'));
+        }
+    }
+
+    public function testDynamicWithPath()
+    {
+        foreach (array(':controller/:action/*url', ':(controller)/:(action)/*(url)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path);
+            $m->createRegs(array('content', 'admin/user'));
+            
+            $this->assertNull($m->match('/'));
+            $this->assertNull($m->match('/blog'));
+            $this->assertNull($m->match('/content'));
+            $this->assertNull($m->match('/content/view'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'view', 'url' => 'blob');
+            $this->assertEquals($matchdata, $m->match('/content/view/blob'));
+
+            $this->assertNull($m->match('/admin/user'));
+            $this->assertNull($m->match('/admin/user/view'));
+
+            $matchdata = array('controller' => 'admin/user', 'action' => 'view',
+                               'url' => 'blob/check');
+            $this->assertEquals($matchdata, $m->match('/admin/user/view/blob/check'));
+        }
+    }
+
+    public function testPathWithDynamicAndDefault()
+    {
+        foreach (array(':controller/:action/*url', ':(controller)/:(action)/*(url)') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content', 'action' => 'view', 'url' => null));
+            $m->createRegs(array('content', 'admin/user'));
+            
+            $this->assertNull($m->match('/goober/view/here'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'view', 'url' => null);
+            $this->assertEquals($matchdata, $m->match('/content'));
+            $this->assertEquals($matchdata, $m->match('/content/'));
+            $this->assertEquals($matchdata, $m->match('/content/view'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'view', 'url' => 'fred');
+            $this->assertEquals($matchdata, $m->match('/content/view/fred'));
+
+            $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'url' => null);
+            $this->assertEquals($matchdata, $m->match('/admin/user'));
+            $this->assertEquals($matchdata, $m->match('/admin/user/view'));
+        }
+    }
+    
+    public function testPathWithDynamicAndDefaultBackwards()
+    {
+        foreach (array('*file/login', '*(file)/login') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content', 'action' => 'download', 'file' => null));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'download', 'file' => '');
+            $this->assertEquals($matchdata, $m->match('//login'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download',
+                               'file' => 'books/learning_python.pdf');
+            $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/login'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download', 'file' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/dude/login'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download', 'file' => 'dude/what');
+            $this->assertEquals($matchdata, $m->match('/dude/what/login'));
+        }
+    }
+    
+    public function testPathBackwards()
+    {
+        foreach (array('*file/login', '*(file)/login') as $path) {
+            $m = new Horde_Routes_Mapper();
+            $m->connect($path, array('controller' => 'content', 'action' => 'download'));
+            $m->createRegs();
+            
+            $this->assertNull($m->match('/boo'));
+            $this->assertNull($m->match('/boo/blah'));
+            $this->assertNull($m->match('/login'));
+
+            $matchdata = array('controller' => 'content', 'action' => 'download',
+                               'file' => 'books/learning_python.pdf');
+            $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/login'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download', 'file' => 'dude');
+            $this->assertEquals($matchdata, $m->match('/dude/login'));
+            
+            $matchdata = array('controller' => 'content', 'action' => 'download', 'file' => 'dude/what');
+            $this->assertEquals($matchdata, $m->match('/dude/what/login'));
+        }
+    }
+    
+    public function testPathBackwardsWithController()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('*url/login', array('controller' => 'content', 'action' => 'check_access'));
+        $m->connect('*url/:controller', array('action' => 'view'));
+        $m->createRegs(array('content', 'admin/user'));
+        
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/boo/blah'));
+        $this->assertNull($m->match('/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access',
+                           'url' => 'books/learning_python.pdf');
+        $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access', 'url' => 'dude');
+        $this->assertEquals($matchdata, $m->match('/dude/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access', 'url' => 'dude/what');
+        $this->assertEquals($matchdata, $m->match('/dude/what/login'));
+
+        $this->assertNull($m->match('/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view',
+                           'url' => 'books/learning_python.pdf');
+        $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'url' => 'dude');
+        $this->assertEquals($matchdata, $m->match('/dude/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'url' => 'dude/what');
+        $this->assertEquals($matchdata, $m->match('/dude/what/admin/user'));
+    }
+    
+    public function testPathBackwardsWithControllerAndSplits()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('*(url)/login', array('controller' => 'content', 'action' => 'check_access'));
+        $m->connect('*(url)/:(controller)', array('action' => 'view'));
+        $m->createRegs(array('content', 'admin/user'));
+        
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/boo/blah'));
+        $this->assertNull($m->match('/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access',
+                           'url' => 'books/learning_python.pdf');
+        $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access', 'url' => 'dude');
+        $this->assertEquals($matchdata, $m->match('/dude/login'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'check_access', 'url' => 'dude/what');
+        $this->assertEquals($matchdata, $m->match('/dude/what/login'));
+
+        $this->assertNull($m->match('/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view',
+                           'url' => 'books/learning_python.pdf');
+        $this->assertEquals($matchdata, $m->match('/books/learning_python.pdf/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'url' => 'dude');
+        $this->assertEquals($matchdata, $m->match('/dude/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 'url' => 'dude/what');
+        $this->assertEquals($matchdata, $m->match('/dude/what/admin/user'));        
+    }
+    
+    public function testController()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('hi/:controller', array('action' => 'hi'));
+        $m->createRegs(array('content', 'admin/user'));
+        
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/boo/blah'));
+        $this->assertNull($m->match('/hi/13870948'));
+        $this->assertNull($m->match('/hi/content/dog'));
+        $this->assertNull($m->match('/hi/admin/user/foo'));
+        $this->assertNull($m->match('/hi/admin/user/foo/'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'hi');
+        $this->assertEquals($matchdata, $m->match('/hi/content'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'hi');
+        $this->assertEquals($matchdata, $m->match('/hi/admin/user'));
+    }
+    
+    public function testStandardRoute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'admin/user'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/content'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'list', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/content/list'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'show', 'id' => '10');
+        $this->assertEquals($matchdata, $m->match('/content/show/10'));
+
+        $matchdata = array('controller' => 'admin/user', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/admin/user'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'list', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/admin/user/list'));
+                
+        $matchdata = array('controller' => 'admin/user', 'action' => 'show', 'id' => 'bbangert');
+        $this->assertEquals($matchdata, $m->match('/admin/user/show/bbangert'));
+        
+        $this->assertNull($m->match('/content/show/10/20'));
+        $this->assertNull($m->match('/food'));
+    }
+
+    public function testStandardRouteWithGaps()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:action/:(id).py');
+        $m->createRegs(array('content', 'admin/user'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index', 'id' => 'None');
+        $this->assertEquals($matchdata, $m->match('/content/index/None.py'));
+        
+        $matchdata = array('controller' =>'content', 'action' => 'list', 'id' => 'None');
+        $this->assertEquals($matchdata, $m->match('/content/list/None.py'));    
+
+        $matchdata = array('controller' => 'content', 'action' => 'show', 'id' => '10');
+        $this->assertEquals($matchdata, $m->match('/content/show/10.py'));    
+    }
+
+    public function testStandardRouteWithGapsAndDomains()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('manage/:domain.:ext', array('controller' => 'admin/user', 'action' => 'view', 
+                                                 'ext' => 'html'));
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'admin/user'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index', 'id' => 'None.py');
+        $this->assertEquals($matchdata, $m->match('/content/index/None.py'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'list', 'id' => 'None.py');
+        $this->assertEquals($matchdata, $m->match('/content/list/None.py'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'show', 'id' => '10.py');
+        $this->assertEquals($matchdata, $m->match('/content/show/10.py'));
+        $matchdata = array('controller' => 'content', 'action' => 'show.all', 'id' => '10.py');
+        $this->assertEquals($matchdata, $m->match('/content/show.all/10.py'));
+        $matchdata = array('controller' => 'content', 'action' => 'show', 'id' => 'www.groovie.org');
+        $this->assertEquals($matchdata, $m->match('/content/show/www.groovie.org'));
+
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 
+                           'ext' => 'html', 'domain' => 'groovie');
+        $this->assertEquals($matchdata, $m->match('/manage/groovie'));
+        
+        $matchdata = array('controller' => 'admin/user', 'action' => 'view', 
+                           'ext' => 'xml', 'domain' => 'groovie');
+        $this->assertEquals($matchdata, $m->match('/manage/groovie.xml'));        
+    }
+    
+    public function testStandardWithDomains()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('manage/:domain', array('controller' => 'domains', 'action' => 'view'));
+        $m->createRegs(array('domains'));
+
+        $matchdata = array('controller' => 'domains', 'action' => 'view', 'domain' => 'www.groovie.org');
+        $this->assertEquals($matchdata, $m->match('/manage/www.groovie.org'));
+    }
+
+    public function testDefaultRoute()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('', array('controller' => 'content', 'action' => 'index'));
+        $m->createRegs(array('content'));
+
+        $this->assertNull($m->match('/x'));
+        $this->assertNull($m->match('/hello/world'));
+        $this->assertNull($m->match('/hello/world/how/are'));
+        $this->assertNull($m->match('/hello/world/how/are/you/today'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'index');
+        $this->assertEquals($matchdata, $m->match('/'));
+    }
+
+    public function testDynamicWithPrefix()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->prefix = '/blog';
+        $m->connect(':controller/:action/:id');
+        $m->connect('', array('controller' => 'content', 'action' => 'index'));
+        $m->createRegs(array('content', 'archive', 'admin/comments'));
+
+        $this->assertNull($m->match('/x'));
+        $this->assertNull($m->match('/admin/comments'));
+        $this->assertNull($m->match('/content/view'));
+        $this->assertNull($m->match('/archive/view/4'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index');
+        $this->assertEquals($matchdata, $m->match('/blog'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/content'));
+
+        $matchdata = array('controller' => 'admin/comments', 'action' => 'view', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/admin/comments/view'));
+        
+        $matchdata = array('controller' => 'archive', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/archive'));
+        
+        $matchdata = array('controller' => 'archive', 'action' => 'view', 'id' => '4');
+        $this->assertEquals($matchdata, $m->match('/blog/archive/view/4'));
+    }
+
+    public function testDynamicWithMultipleAndPrefix()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->prefix = '/blog';
+        $m->connect(':controller/:action/:id');
+        $m->connect('home/:action', array('controller' => 'archive'));
+        $m->connect('', array('controller' => 'content'));
+        $m->createRegs(array('content', 'archive', 'admin/comments'));
+        
+        $this->assertNull($m->match('/x'));
+        $this->assertNull($m->match('/admin/comments'));
+        $this->assertNull($m->match('/content/view'));
+        $this->assertNull($m->match('/archive/view/4'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'index');
+        $this->assertEquals($matchdata, $m->match('/blog/'));
+
+        $matchdata = array('controller' => 'archive', 'action' => 'view');
+        $this->assertEquals($matchdata, $m->match('/blog/home/view'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/content'));
+
+        $matchdata = array('controller' => 'admin/comments', 'action' => 'view', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/admin/comments/view'));
+
+        $matchdata = array('controller' => 'archive', 'action' => 'index', 'id' => null);
+        $this->assertEquals($matchdata, $m->match('/blog/archive'));
+
+        $matchdata = array('controller' => 'archive', 'action' => 'view', 'id' => '4');
+        $this->assertEquals($matchdata, $m->match('/blog/archive/view/4'));
+    }
+
+    public function testSplitsWithExtension()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('hi/:(action).html', array('controller' => 'content'));
+        $m->createRegs();
+        
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/boo/blah'));
+        $this->assertNull($m->match('/hi/dude/what'));
+        $this->assertNull($m->match('/hi'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'index');
+        $this->assertEquals($matchdata, $m->match('/hi/index.html'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'dude');
+        $this->assertEquals($matchdata, $m->match('/hi/dude.html'));
+    }
+
+    public function testSplitsWithDashes()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archives/:(year)-:(month)-:(day).html', 
+                    array('controller' => 'archives', 'action' => 'view'));
+        $m->createRegs();            
+
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/archives'));
+
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '2004', 'month' => '12', 'day' => '4');
+        $this->assertEquals($matchdata, $m->match('/archives/2004-12-4.html'));
+        
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '04', 'month' => '10', 'day' => '4');
+        $this->assertEquals($matchdata, $m->match('/archives/04-10-4.html'));
+        
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '04', 'month' => '1', 'day' => '1');
+        $this->assertEquals($matchdata, $m->match('/archives/04-1-1.html'));
+    }
+    
+    public function testSplitsPackedWithRegexps()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('archives/:(year):(month):(day).html',
+                    array('controller' => 'archives', 'action' => 'view',
+                          'requirements' => array('year' => '\d{4}', 'month' => '\d{2}', 
+                                                  'day'  => '\d{2}')));
+        $m->createRegs();
+        
+        $this->assertNull($m->match('/boo'));
+        $this->assertNull($m->match('/archives'));
+        $this->assertNull($m->match('/archives/2004020.html'));
+        $this->assertNull($m->match('/archives/200502.html'));        
+
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '2004', 'month' => '12', 'day' => '04');
+        $this->assertEquals($matchdata, $m->match('/archives/20041204.html'));
+        
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '2005', 'month' => '10', 'day' => '04');
+        $this->assertEquals($matchdata, $m->match('/archives/20051004.html'));
+        
+        $matchdata = array('controller' => 'archives', 'action' => 'view',
+                           'year' => '2006', 'month' => '01', 'day' => '01');
+        $this->assertEquals($matchdata, $m->match('/archives/20060101.html'));
+    }
+
+    public function testSplitsWithSlashes()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':name/:(action)-:(day)', array('controller' => 'content'));
+
+        $this->assertNull($m->match('/something'));
+        $this->assertNull($m->match('/something/is-'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'view', 
+                           'day' => '3', 'name' => 'group');
+        $this->assertEquals($matchdata, $m->match('/group/view-3'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'view',
+                           'day' => '5', 'name' => 'group');
+        $this->assertEquals($matchdata, $m->match('/group/view-5'));
+    }
+
+    public function testSplitsWithSlashesAndDefault()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':name/:(action)-:(id)', array('controller' => 'content'));
+        $m->createRegs();
+
+        $this->assertNull($m->match('/something'));
+        $this->assertNull($m->match('/something/is'));
+
+        $matchdata = array('controller' => 'content', 'action' => 'view',
+                           'id' => '3', 'name' => 'group');
+        $this->assertEquals($matchdata, $m->match('/group/view-3'));
+        
+        $matchdata = array('controller' => 'content', 'action' => 'view',
+                           'id' => null, 'name' => 'group');
+        $this->assertEquals($matchdata, $m->match('/group/view-'));
+    }
+    
+    public function testNoRegMake()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':name/:(action)-:(id)', array('controller' => 'content'));
+        $m->controllerScan = false;
+        try {
+            $m->match('/group/view-3');
+            $this->fail();
+        } catch (Horde_Routes_Exception $e) {
+            $this->assertRegExp('/must generate the regular expressions/i', $e->getMessage());
+        }
+    }
+    
+    public function testRoutematch()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content'));
+        $route = $m->matchList[0];
+        
+        list($resultdict, $resultObj) = $m->routematch('/content');
+        
+        $this->assertEquals(array('controller' => 'content', 'action' => 'index', 'id' => null),
+                            $resultdict);
+        $this->assertSame($route, $resultObj);
+        $this->assertNull($m->routematch('/nowhere'));
+    }
+    
+    public function testRoutematchDebug()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect(':controller/:action/:id');
+        $m->debug = true;
+        $m->createRegs(array('content'));
+        $route = $m->matchList[0];
+        
+        list($resultdict, $resultObj, $debug) = $m->routematch('/content');
+
+        $this->assertEquals(array('controller' => 'content', 'action' => 'index', 'id' => null),
+                            $resultdict);        
+        $this->assertSame($route, $resultObj);
+
+        list($resultdict, $resultObj, $debug) = $m->routematch('/nowhere');
+        $this->assertNull($resultdict);
+        $this->assertNull($resultObj);
+        $this->assertEquals(1, count($debug));
+    }
+    
+    public function testMatchDebug()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->connect('nowhere', 'http://nowhere.com/', array('_static' => true));
+        $m->connect(':controller/:action/:id');
+        $m->debug = true;
+        $m->createRegs(array('content'));
+        $route = $m->matchList[1];
+
+        list($resultdict, $resultObj, $debug) = $m->match('/content');
+        $this->assertEquals(array('controller' => 'content', 'action' => 'index', 'id' => null),
+                            $resultdict);
+        
+        $this->assertSame($route, $resultObj);
+        
+        list($resultdict, $resultObj, $debug) = $m->match('/nowhere');
+        $this->assertNull($resultdict);
+        $this->assertNull($resultObj);
+        $this->assertEquals(2, count($debug));
+    }
+
+    public function testResourceCollection()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->resource('message', 'messages');
+        $m->createRegs(array('messages'));
+
+        $path = '/messages';
+
+        $m->environ = array('REQUEST_METHOD' => 'GET');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'index'),
+                            $m->match($path));
+
+        $m->environ = array('REQUEST_METHOD' => 'POST');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'create'),
+                            $m->match($path));
+    }
+
+    public function testFormattedResourceCollection()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->resource('message', 'messages');
+        $m->createRegs(array('messages'));
+
+        $path = '/messages.xml';
+
+        $m->environ = array('REQUEST_METHOD' => 'GET');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'index', 
+                                  'format' => 'xml'),
+                            $m->match($path));
+
+        $m->environ = array('REQUEST_METHOD' => 'POST');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'create', 
+                                  'format' => 'xml'),
+                            $m->match($path));    
+    }
+    
+    public function testResourceMember()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->resource('message', 'messages');
+        $m->createRegs(array('messages'));
+
+        $path = '/messages/42';
+
+        $m->environ = array('REQUEST_METHOD' => 'GET');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'show', 
+                                  'id' => 42),
+                            $m->match($path));
+
+        $m->environ = array('REQUEST_METHOD' => 'POST');
+        $this->assertNull($m->match($path));
+        
+        $m->environ = array('REQUEST_METHOD' => 'PUT');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'update', 
+                                  'id' => 42),
+                            $m->match($path));                            
+
+        $m->environ = array('REQUEST_METHOD' => 'DELETE');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'delete', 
+                                  'id' => 42),
+                            $m->match($path));
+    }
+    
+    public function testFormattedResourceMember()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->resource('message', 'messages');
+        $m->createRegs(array('messages'));
+
+        $path = '/messages/42.xml';
+
+        $m->environ = array('REQUEST_METHOD' => 'GET');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'show', 
+                                  'id' => 42, 'format' => 'xml'),
+                            $m->match($path));
+
+        $m->environ = array('REQUEST_METHOD' => 'POST');
+        $this->assertNull($m->match($path));
+
+        $m->environ = array('REQUEST_METHOD' => 'PUT');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'update', 
+                                  'id' => 42, 'format' => 'xml'),
+                            $m->match($path));                            
+
+        $m->environ = array('REQUEST_METHOD' => 'DELETE');
+        $this->assertEquals(array('controller' => 'messages', 'action' => 'delete', 
+                                  'id' => 42, 'format' => 'xml'),
+                            $m->match($path));
+    }
+
+}
diff --git a/framework/Routes/test/Horde/Routes/TestHelper.php b/framework/Routes/test/Horde/Routes/TestHelper.php
new file mode 100644 (file)
index 0000000..dda49bb
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+/**
+ * @package Horde_Routes
+ */
+class Horde_Routes_TestHelper
+{
+    /**
+     * Update a Mapper instance with a new $environ.  If PATH_INFO
+     * is present, try to match it and update mapperDict.
+     * 
+     * @param  Horde_Routes_Mapper  $mapper   Mapper instance to update
+     * @param  array                $environ  Environ to set in Mapper
+     * @return void
+     */
+    public static function updateMapper($mapper, $environ)
+    {
+        $mapper->environ = $environ;
+        $mapper->utils->mapperdict = null;
+        
+        if (isset($environ['PATH_INFO'])) {
+            $result = $mapper->routeMatch($environ['PATH_INFO']);
+            $mapper->utils->mapperDict = isset($result) ? $result[0] : null;
+        }
+    }
+
+}
diff --git a/framework/Routes/test/Horde/Routes/UtilTest.php b/framework/Routes/test/Horde/Routes/UtilTest.php
new file mode 100644 (file)
index 0000000..7dad846
--- /dev/null
@@ -0,0 +1,601 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+require_once dirname(__FILE__) . '/TestHelper.php';
+
+/**
+ * @package Horde_Routes
+ */
+class UtilTest extends PHPUnit_Framework_TestCase
+{
+
+    public function setUp()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('HTTP_HOST' => 'www.test.com');
+
+        $m->connect('archive/:year/:month/:day',
+            array('controller' => 'blog',
+                  'action' => 'view',
+                  'month' => null,
+                  'day' => null,
+                  'requirements' => array('month' => '\d{1,2}', 'day' => '\d{1,2}')));
+        $m->connect('viewpost/:id', array('controller' => 'post', 'action' => 'view'));
+        $m->connect(':controller/:action/:id');
+
+        $this->mapper = $m;
+        $this->utils = $m->utils;
+    }
+
+    public function testUrlForSelf()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array();
+
+        $this->assertEquals('/blog', $utils->urlFor(array('controller' => 'blog')));
+        $this->assertEquals('/content', $utils->urlFor());
+        $this->assertEquals('https://www.test.com/viewpost', $utils->urlFor(array('controller' => 'post', 'action' => 'view', 'protocol' => 'https')));
+        $this->assertEquals('http://www.test.org/content/view/2', $utils->urlFor(array('host' => 'www.test.org', 'controller' => 'content', 'action' => 'view', 'id' => 2)));
+    }
+
+    public function testUrlForWithDefaults()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'id' => 4);
+
+        $this->assertEquals('/blog/view/4', $utils->urlFor());
+        $this->assertEquals('/post/index/4', $utils->urlFor(array('controller' => 'post')));
+        $this->assertEquals('/blog/view/2', $utils->urlFor(array('id' => 2)));
+        $this->assertEquals('/viewpost/4', $utils->urlFor(array('controller' => 'post', 'action' => 'view', 'id' => 4)));
+
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'year' => 2004);
+        $this->assertEquals('/archive/2004/10', $utils->urlFor(array('month' => 10)));
+        $this->assertEquals('/archive/2004/9/2', $utils->urlFor(array('month' => 9, 'day' => 2)));
+        $this->assertEquals('/blog', $utils->urlFor(array('controller' => 'blog', 'year' => null)));
+    }
+
+    public function testUrlForWithMoreDefaults()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'id' => 4);
+        $this->assertEquals('/blog/view/4', $utils->urlFor());
+        $this->assertEquals('/post/index/4', $utils->urlFor(array('controller' => 'post')));
+        $this->assertEquals('/viewpost/4', $utils->urlfor(array('controller' => 'post', 'action' => 'view', 'id' => 4)));
+
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'year' => 2004);
+        $this->assertEquals('/archive/2004/10', $utils->urlFor(array('month' => 10)));
+        $this->assertEquals('/archive/2004/9/2', $utils->urlFor(array('month' => 9, 'day' => 2)));
+        $this->assertEquals('/blog', $utils->urlFor(array('controller' => 'blog', 'year' => null)));
+        $this->assertEquals('/archive/2004', $utils->urlFor());
+    }
+
+    public function testUrlForWithDefaultsAndQualified()
+    {
+        $m = $this->mapper;
+        $utils = $m->utils;
+
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section', array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'www.example.com',
+                         'PATH_INFO' => '/blog/view/4');
+        Horde_Routes_TestHelper::updateMapper($m, $environ);
+
+        $this->assertEquals('/blog/view/4', $utils->urlFor());
+        $this->assertEquals('/post/index/4', $utils->urlFor(array('controller' => 'post')));
+        $this->assertEquals('http://www.example.com/blog/view/4', $utils->urlFor(array('qualified' => true)));
+        $this->assertEquals('/blog/view/2', $utils->urlFor(array('id' => 2)));
+        $this->assertEquals('/viewpost/4', $utils->urlFor(array('controller' => 'post', 'action' => 'view', 'id' => 4)));
+
+        $environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'www.example.com:8080', 'PATH_INFO' => '/blog/view/4');
+        Horde_Routes_TestHelper::updateMapper($m, $environ);
+
+        $this->assertEquals('/post/index/4',
+                            $utils->urlFor(array('controller' => 'post')));
+        $this->assertEquals('http://www.example.com:8080/blog/view/4',
+                            $utils->urlFor(array('qualified' => true)));
+    }
+
+    public function testWithRouteNames()
+    {
+        $m = $this->mapper;
+
+        $utils = $this->utils;
+        $utils->mapperDict = array();
+
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->assertEquals('/content/view',
+                    $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content',
+                    $utils->urlFor(array('controller' => 'content')));
+        $this->assertEquals('/admin/comments',
+                    $utils->urlFor(array('controller' => 'admin/comments')));
+        $this->assertEquals('/category',
+                    $utils->urlFor('category_home'));
+        $this->assertEquals('/category/food',
+                    $utils->urlFor('category_home', array('section' => 'food')));
+        $this->assertEquals('/category',
+                    $utils->urlFor('home', array('action' => 'view', 'section' => 'home')));
+        $this->assertEquals('/content/splash',
+                    $utils->urlFor('home', array('controller' => 'content')));
+        $this->assertEquals('/',
+                    $utils->urlFor('/'));
+    }
+
+    public function testWithRouteNamesAndDefaults()
+    {
+        $this->markTestSkipped();
+        
+        $m = $this->mapper;
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section', array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect('building', 'building/:campus/:building/alljacks', array('controller' => 'building', 'action' => 'showjacks'));
+        $m->createRegs(array('content', 'blog', 'admin/comments', 'building'));
+
+        $utils->mapperDict = array('controller' => 'building', 'action' => 'showjacks', 'campus' => 'wilma', 'building' => 'port');
+        $this->assertEquals('/building/wilma/port/alljacks', $utils->urlFor());
+        $this->assertEquals('/', $utils->urlFor('home'));
+    }
+
+    // callback used by testRedirectTo
+    // Python version is inlined in test_redirect_to
+    public function printer($echo)
+    {
+        $this->redirectToResult = $echo;
+    }
+
+    public function testRedirectTo()
+    {
+        $m = $this->mapper;
+        $m->environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'www.example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $callback = array($this, 'printer');
+        $utils->redirect = $callback;
+
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+
+        $this->redirectToResult = null;
+        $utils->redirectTo(array('controller' => 'content', 'action' => 'view'));
+        $this->assertEquals('/content/view', $this->redirectToResult);
+
+        $this->redirectToResult = null;
+        $utils->redirectTo(array('controller' => 'content', 'action' => 'lookup', 'id' => 4));
+        $this->assertEquals('/content/lookup/4', $this->redirectToResult);
+
+        $this->redirectToResult = null;
+        $utils->redirectTo(array('controller' => 'admin/comments', 'action' => 'splash'));
+        $this->assertEquals('/admin/comments/splash', $this->redirectToResult);
+
+        $this->redirectToResult = null;
+        $utils->redirectTo('http://www.example.com/');
+        $this->assertEquals('http://www.example.com/', $this->redirectToResult);
+
+        $this->redirectToResult = null;
+        $utils->redirectTo('/somewhere.html', array('var' => 'keyword'));
+        $this->assertEquals('/somewhere.html?var=keyword', $this->redirectToResult);
+    }
+
+    public function testStaticRoute()
+    {
+        $m = $this->mapper;
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'example.com');
+        Horde_Routes_TestHelper::updateMapper($m, $environ);
+
+        $m->connect(':controller/:action/:id');
+        $m->connect('home', 'http://www.groovie.org/', array('_static' => true));
+        $m->connect('space', '/nasa/images', array('_static' => true));
+        $m->createRegs(array('content', 'blog'));
+
+        $this->assertEquals('http://www.groovie.org/',
+                            $utils->urlFor('home'));
+        $this->assertEquals('http://www.groovie.org/?s=stars',
+                            $utils->urlFor('home', array('s' => 'stars')));
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/nasa/images?search=all',
+                            $utils->urlFor('space', array('search' => 'all')));
+    }
+
+    public function testStaticRouteWithScript()
+    {
+        $m = $this->mapper;
+        $m->environ = array('SCRIPT_NAME' => '/webapp', 'HTTP_HOST' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+
+        $m->connect(':controller/:action/:id');
+        $m->connect('home', 'http://www.groovie.org/', array('_static' => true));
+        $m->connect('space', '/nasa/images', array('_static' => true));
+        $m->createRegs(array('content', 'blog'));
+
+        $this->assertEquals('http://www.groovie.org/',
+                            $utils->urlFor('home'));
+        $this->assertEquals('http://www.groovie.org/?s=stars',
+                            $utils->urlFor('home', array('s' => 'stars')));
+        $this->assertEquals('/webapp/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/webapp/nasa/images?search=all',
+                            $utils->urlFor('space', array('search' => 'all')));
+        $this->assertEquals('http://example.com/webapp/nasa/images',
+                            $utils->urlFor('space', array('protocol' => 'http')));
+    }
+
+    public function testNoNamedPath()
+    {
+        $m = $this->mapper;
+        $m->environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect(':controller/:action/:id');
+        $m->connect('home', 'http://www.groovie.org', array('_static' => true));
+        $m->connect('space', '/nasa/images', array('_static' => true));
+        $m->createRegs(array('content', 'blog'));
+
+        $this->assertEquals('http://www.google.com/search',
+                            $utils->urlFor('http://www.google.com/search'));
+        $this->assertEquals('http://www.google.com/search?q=routes',
+                            $utils->urlFor('http://www.google.com/search', array('q'=>'routes')));
+        $this->assertEquals('/delicious.jpg',
+                            $utils->urlFor('/delicious.jpg'));
+        $this->assertEquals('/delicious/search?v=routes',
+                            $utils->urlFor('/delicious/search', array('v'=>'routes')));
+    }
+
+    public function testAppendSlash()
+    {
+        $m = $this->mapper;
+        $m->environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'example.com');
+        $m->appendSlash = true;
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect(':controller/:action/:id');
+        $m->connect('home', 'http://www.groovie.org/', array('_static' => true));
+        $m->connect('space', '/nasa/images', array('_static' => true));
+        $m->createRegs(array('content', 'blog'));
+
+        $this->assertEquals('http://www.google.com/search',
+                            $utils->urlFor('http://www.google.com/search'));
+        $this->assertEquals('http://www.google.com/search?q=routes',
+                            $utils->urlFor('http://www.google.com/search', array('q'=>'routes')));
+        $this->assertEquals('/delicious.jpg',
+                            $utils->urlFor('/delicious.jpg'));
+        $this->assertEquals('/delicious/search?v=routes',
+                            $utils->urlFor('/delicious/search', array('v' => 'routes')));
+        $this->assertEquals('/content/list/',
+                            $utils->urlFor(array('controller' => '/content', 'action' => 'list')));
+        $this->assertEquals('/content/list/?page=1',
+                            $utils->urlFor(array('controller' => '/content', 'action' => 'list', 'page' => '1')));
+    }
+
+    public function testNoNamedPathWithScript()
+    {
+        $m = $this->mapper;
+        $m->environ = array('SCRIPT_NAME' => '/webapp', 'HTTP_HOST' => 'example.com');
+        
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect(':controller/:action/:id');
+        $m->connect('home', 'http://www.groovie.org/', array('_static' => true));
+        $m->connect('space', '/nasa/images', array('_static' => true));
+        $m->createRegs(array('content', 'blog'));
+
+        $this->assertEquals('http://www.google.com/search',
+                            $utils->urlFor('http://www.google.com/search'));
+        $this->assertEquals('http://www.google.com/search?q=routes',
+                            $utils->urlFor('http://www.google.com/search', array('q'=>'routes')));
+        $this->assertEquals('/webapp/delicious.jpg',
+                            $utils->urlFor('/delicious.jpg'));
+        $this->assertEquals('/webapp/delicious/search?v=routes',
+                            $utils->urlFor('/delicious/search', array('v'=>'routes')));
+    }
+
+    // callback used by testRouteFilter
+    // Python version is inlined in test_route_filter
+    public function articleFilter($kargs)
+    {
+        $article = isset($kargs['article']) ? $kargs['article'] : null;
+        unset($kargs['article']);
+
+        if ($article !== null) {
+            $kargs['year']  = isset($article['year'])  ? $article['year']  : 2004;
+            $kargs['month'] = isset($article['month']) ? $article['month'] : 12;
+            $kargs['day']   = isset($article['day'])   ? $article['day']   : 20;
+            $kargs['slug']  = isset($article['slug'])  ? $article['slug']  : 'default';
+        }
+
+        return $kargs;
+    }
+
+    public function testRouteFilter()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $callback = array($this, 'articleFilter');
+
+        $m->connect(':controller/:(action)-:(id).html');
+        $m->connect('archives', 'archives/:year/:month/:day/:slug',
+                    array('controller' =>'archives', 'action' =>'view', '_filter' => $callback));
+        $m->createRegs(array('content', 'archives', 'admin/comments'));
+
+        $this->assertNull($utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertNull($utils->urlFor(array('controller' => 'content')));
+
+        $this->assertEquals('/content/view-3.html',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view', 'id' => 3)));
+        $this->assertEquals('/content/index-2.html',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+
+        $this->assertEquals('/archives/2005/10/5/happy',
+                            $utils->urlFor('archives', array('year' => 2005, 'month' => 10,
+                                                            'day' => 5, 'slug' => 'happy')));
+
+        $story = array('year' => 2003, 'month' => 8, 'day' => 2, 'slug' => 'woopee');
+        $empty = array();
+
+        $expected = array('controller' => 'archives', 'action' => 'view', 'year' => '2005',
+                         'month' => '10', 'day' => '5', 'slug' => 'happy');
+        $this->assertEquals($expected, $m->match('/archives/2005/10/5/happy'));
+
+        $this->assertEquals('/archives/2003/8/2/woopee',
+                            $utils->urlFor('archives', array('article' => $story)));
+        $this->assertEquals('/archives/2004/12/20/default',
+                            $utils->urlFor('archives', array('article' => $empty)));
+    }
+
+    public function testWithSslEnviron()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'HTTPS' => 'on', 'SERVER_PORT' => '443', 
+                            'PATH_INFO' => '/', 'HTTP_HOST' => 'example.com', 
+                            'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'archives', 'admin/comments'));
+
+        // HTTPS is on, but we're running on a different port internally
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+        $this->assertEquals('https://nowhere.com/content',
+                            $utils->urlFor(array('host' => 'nowhere.com', 'controller' => 'content')));
+
+        // If HTTPS is on, but the port isn't 443, we'll need to include the port info
+        $m->environ['SERVER_PORT'] = '8080';
+
+        $utils->mapperDict = array();
+
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => '2')));
+        $this->assertEquals('https://nowhere.com/content',
+                            $utils->urlFor(array('host' => 'nowhere.com', 'controller' => 'content')));
+        $this->assertEquals('https://nowhere.com:8080/content',
+                            $utils->urlFor(array('host' => 'nowhere.com:8080', 'controller' => 'content')));
+        $this->assertEquals('http://nowhere.com/content',
+                            $utils->urlFor(array('host' => 'nowhere.com', 'protocol' => 'http',
+                                                'controller' => 'content')));
+    }
+
+    public function testWithHttpEnviron()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'SERVER_PORT' => '1080', 'PATH_INFO' => '/',
+                            'HTTP_HOST' => 'example.com', 'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'archives', 'admin/comments'));
+
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+        $this->assertEquals('https://example.com/content',
+                            $utils->urlFor(array('protocol' => 'https', 'controller' => 'content')));
+    }
+
+    public function testSubdomains()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'PATH_INFO' => '/',
+                            'HTTP_HOST' => 'example.com', 'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+        
+        $m->subDomains = true;
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'archives', 'admin/comments'));
+
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+
+        $m->environ['HTTP_HOST'] = 'sub.example.com';
+
+        $utils->mapperDict = array('subDomain' => 'sub');
+
+        $this->assertEquals('/content/view/3',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view', 'id' => 3)));
+        $this->assertEquals('http://new.example.com/content',
+                            $utils->urlFor(array('controller' => 'content', 'subDomain' => 'new')));
+    }
+
+    public function testSubdomainsWithExceptions()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'PATH_INFO' => '/',
+                            'HTTP_HOST' => 'example.com', 'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->subDomains = true;
+        $m->subDomainsIgnore = array('www');
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'archives', 'admin/comments'));
+
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+
+        $m->environ = array('HTTP_HOST' => 'sub.example.com');
+
+        $utils->mapperDict = array('subDomain' => 'sub');
+
+        $this->assertEquals('/content/view/3',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view', 'id' => 3)));
+        $this->assertEquals('http://new.example.com/content',
+                            $utils->urlFor(array('controller' => 'content', 'subDomain' => 'new')));
+        $this->assertEquals('http://example.com/content',
+                            $utils->urlFor(array('controller' => 'content', 'subDomain' => 'www')));
+
+        $utils->mapperDict = array('subDomain' => 'www');
+        $this->assertEquals('http://example.com/content/view/3',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view', 'id' => 3)));
+        $this->assertEquals('http://new.example.com/content',
+                            $utils->urlFor(array('controller' => 'content', 'subDomain' => 'new')));
+        $this->assertEquals('/content',
+                            $utils->urlFor(array('controller' => 'content', 'subDomain' => 'sub')));
+    }
+
+    public function testSubdomainsWithNamedRoutes()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'PATH_INFO' => '/',
+                            'HTTP_HOST' => 'example.com', 'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->subDomains = true;
+        $m->connect(':controller/:action/:id');
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect('building', 'building/:campus/:building/alljacks',
+                    array('controller' => 'building', 'action' => 'showjacks'));
+        $m->createRegs(array('content','blog','admin/comments','building'));
+
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/content/index/2',
+                            $utils->urlFor(array('controller' => 'content', 'id' => 2)));
+        $this->assertEquals('/category',
+                            $utils->urlFor('category_home'));
+        $this->assertEquals('http://new.example.com/category',
+                            $utils->urlFor('category_home', array('subDomain' => 'new')));
+    }
+
+    public function testSubdomainsWithPorts()
+    {
+        $m = new Horde_Routes_Mapper();
+        $m->environ = array('SCRIPT_NAME' => '', 'PATH_INFO' => '/',
+                            'HTTP_HOST' => 'example.com:8000', 'SERVER_NAME' => 'example.com');
+
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+
+        $m->subDomains = true;
+        $m->connect(':controller/:action/:id');
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect('building', 'building/:campus/:building/alljacks',
+                    array('controller' => 'building', 'action' => 'showjacks'));
+        $m->createRegs(array('content', 'blog', 'admin/comments', 'building'));
+
+        $this->assertEquals('/content/view',
+                            $utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertEquals('/category',
+                            $utils->urlFor('category_home'));
+        $this->assertEquals('http://new.example.com:8000/category',
+                            $utils->urlFor('category_home', array('subDomain' => 'new')));
+        $this->assertEquals('http://joy.example.com:8000/building/west/merlot/alljacks',
+                            $utils->urlFor('building', array('campus' => 'west', 'building' => 'merlot',
+                                                            'subDomain' => 'joy')));
+
+        $m->environ = array('HTTP_HOST' => 'example.com');
+
+        $this->assertEquals('http://new.example.com/category',
+                            $utils->urlFor('category_home', array('subDomain' => 'new')));
+    }
+
+    public function testControllerScan()
+    {
+        $hereDir = dirname(__FILE__);
+        $controllerDir = "$hereDir/fixtures/controllers";
+
+        $controllers = Horde_Routes_Utils::controllerScan($controllerDir);
+        
+        $this->assertEquals(3, count($controllers));
+        $this->assertEquals('admin/users', $controllers[0]);
+        $this->assertEquals('content', $controllers[1]);
+        $this->assertEquals('users', $controllers[2]);
+    }
+
+    public function testAutoControllerScan()
+    {
+        $hereDir = dirname(__FILE__);
+        $controllerDir = "$hereDir/fixtures/controllers";
+
+        $m = new Horde_Routes_Mapper(array('directory' => $controllerDir));
+        $m->alwaysScan = true;
+        
+        $m->connect(':controller/:action/:id');
+
+        $expected = array('action' => 'index', 'controller' => 'content', 'id' => null);
+        $this->assertEquals($expected, $m->match('/content'));
+
+        $expected = array('action' => 'index', 'controller' => 'users', 'id' => null);
+        $this->assertEquals($expected, $m->match('/users'));
+
+        $expected = array('action' => 'index', 'controller' => 'admin/users', 'id' => null);
+        $this->assertEquals($expected, $m->match('/admin/users'));
+    }
+
+}
diff --git a/framework/Routes/test/Horde/Routes/UtilWithExplicitTest.php b/framework/Routes/test/Horde/Routes/UtilWithExplicitTest.php
new file mode 100644 (file)
index 0000000..161dfb3
--- /dev/null
@@ -0,0 +1,217 @@
+<?php
+/**
+ * Horde Routes package
+ *
+ * This package is heavily inspired by the Python "Routes" library
+ * by Ben Bangert (http://routes.groovie.org).  Routes is based
+ * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
+ *
+ * @author  Maintainable Software, LLC. (http://www.maintainable.com)
+ * @author  Mike Naberezny <mike@maintainable.com>
+ * @license http://opensource.org/licenses/bsd-license.php BSD
+ * @package Horde_Routes
+ */
+
+require_once dirname(__FILE__) . '/TestHelper.php';
+
+/**
+ * @package Horde_Routes
+ */
+class UtilWithExplicitTest extends PHPUnit_Framework_TestCase {
+
+    public function setUp()
+    {
+        $m = new Horde_Routes_Mapper(array('explicit' => true));
+        $m->connect('archive/:year/:month/:day',
+            array('controller' => 'blog',
+                  'action' => 'view',
+                  'month' => null,
+                  'day' => null,
+                  'requirements' => array('month' => '\d{1,2}', 'day' => '\d{1,2}')));
+        $m->connect('viewpost/:id', array('controller' => 'post', 'action' => 'view', 'id' => null));
+        $m->connect(':controller/:action/:id');
+        $m->environ = array('SERVER_NAME' => 'www.test.com');
+        $this->mapper = $m;
+        $this->utils = $m->utils;
+    }
+
+    public function testUrlFor()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array();
+    
+        $this->assertNull($utils->urlFor(array('controller' => 'blog')));
+        $this->assertNull($utils->urlFor());
+        $this->assertEquals('/blog/view/3',
+                            $utils->urlFor(array('controller' => 'blog', 'action' => 'view',
+                                                'id' => 3)));
+        $this->assertEquals('https://www.test.com/viewpost',
+                            $utils->urlFor(array('controller' => 'post', 'action' => 'view',
+                                                'protocol' => 'https')));
+        $this->assertEquals('http://www.test.org/content/view/2',
+                            $utils->urlFor(array('host' => 'www.test.org', 'controller' => 'content',
+                                                'action' => 'view', 'id' => 2)));
+    }
+
+    public function testUrlForWithDefaults()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'id' => 4);
+    
+        $this->assertNull($utils->urlFor());
+        $this->assertNull($utils->urlFor(array('controller' => 'post')));
+        $this->assertNull($utils->urlFor(array('id' => 2)));
+        $this->assertEquals('/viewpost/4',
+                            $utils->urlFor(array('controller' => 'post', 'action' => 'view',
+                                                'id' => 4)));
+    
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'year' => 2004);
+        $this->assertNull($utils->urlFor(array('month' => 10)));
+        $this->assertNull($utils->urlFor(array('month' => 9, 'day' => 2)));
+        $this->assertNull($utils->urlFor(array('controller' => 'blog', 'year' => null)));
+    }
+
+    public function testUrlForWithMoreDefaults()
+    {
+        $utils = $this->utils;
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'id' => 4);
+    
+        $this->assertNull($utils->urlFor());
+        $this->assertNull($utils->urlFor(array('controller' => 'post')));
+        $this->assertNull($utils->urlFor(array('id' => 2)));
+        $this->assertEquals('/viewpost/4',
+                            $utils->urlFor(array('controller' => 'post', 'action' => 'view',
+                                                'id' => 4)));
+    
+        $utils->mapperDict = array('controller' => 'blog', 'action' => 'view', 'year' => 2004);
+        $this->assertNull($utils->urlFor(array('month' => 10)));
+        $this->assertNull($utils->urlFor());
+    }
+
+    public function testUrlForWithDefaultsAndQualified()
+    {
+        $utils = $this->utils;
+    
+        $m = $this->mapper;
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect(':controller/:action/:id');
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+    
+        $environ = array('SCRIPT_NAME' => '', 'SERVER_NAME' => 'www.example.com',
+                         'SERVER_PORT' => '80', 'PATH_INFO' => '/blog/view/4');
+        Horde_Routes_TestHelper::updateMapper($m, $environ);
+    
+        $this->assertNull($utils->urlFor());
+        $this->assertNull($utils->urlFor(array('controller' => 'post')));
+        $this->assertNull($utils->urlFor(array('id' => 2)));
+        $this->assertNull($utils->urlFor(array('qualified' => true, 'controller' => 'blog', 'id' => 4)));
+        $this->assertEquals('http://www.example.com/blog/view/4',
+                            $utils->urlFor(array('qualified' => true, 'controller' => 'blog',
+                                                'action' => 'view', 'id' => 4)));
+        $this->assertEquals('/viewpost/4',
+                            $utils->urlFor(array('controller' => 'post', 'action' => 'view', 'id' => 4)));
+    
+        $environ = array('SCRIPT_NAME' => '', 'HTTP_HOST' => 'www.example.com:8080', 'PATH_INFO' => '/blog/view/4');
+        Horde_Routes_TestHelper::updateMapper($m, $environ);
+    
+        $this->assertNull($utils->urlFor(array('controller' => 'post')));
+        $this->assertEquals('http://www.example.com:8080/blog/view/4',
+                            $utils->urlFor(array('qualified' => true, 'controller' => 'blog',
+                                                'action' => 'view', 'id' => 4)));
+    }
+
+    public function testWithRouteNames()
+    {
+        $m = $this->mapper;
+    
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+    
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->createRegs(array('content', 'blog', 'admin/comments'));
+    
+        $this->assertNull($utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertNull($utils->urlFor(array('controller' => 'content')));
+        $this->assertNull($utils->urlFor(array('controller' => 'admin/comments')));
+        $this->assertEquals('/category',
+                            $utils->urlFor('category_home'));
+        $this->assertEquals('/category/food',
+                            $utils->urlFor('category_home', array('section' => 'food')));
+        $this->assertEquals('/category',
+                            $utils->urlFor('home', array('action' => 'view', 'section' => 'home')));
+        $this->assertNull($utils->urlFor('home', array('controller' => 'content')));
+        $this->assertEquals('/content/splash/2',
+                            $utils->urlFor('home', array('controller' => 'content', 'action' => 'splash',
+                                                        'id' => 2)));
+        $this->assertEquals('/', $utils->urlFor('home'));
+    }
+
+    public function testWithRouteNamesAndDefaults()
+    {
+        $m = $this->mapper;
+    
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+    
+        $m->connect('home', '', array('controller' => 'blog', 'action' => 'splash'));
+        $m->connect('category_home', 'category/:section',
+                    array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
+        $m->connect('building', 'building/:campus/:building/alljacks',
+                    array('controller' => 'building', 'action' => 'showjacks'));
+        $m->createRegs(array('content', 'blog', 'admin/comments', 'building'));
+    
+        $utils->mapperDict = array('controller' => 'building', 'action' => 'showjacks',
+                                  'campus' => 'wilma', 'building' => 'port');
+    
+        $this->assertNull($utils->urlFor());
+        $this->assertEquals('/building/wilma/port/alljacks',
+                            $utils->urlFor(array('controller' => 'building', 'action' => 'showjacks',
+                                                'campus' => 'wilma', 'building' => 'port')));
+        $this->assertEquals('/', $utils->urlFor('home'));
+    }
+
+    public function testWithResourceRouteNames()
+    {
+        $m = new Horde_Routes_Mapper();
+        $utils = $m->utils;
+        $utils->mapperDict = array();
+        
+        $m->resource('message', 'messages', 
+                     array('member'     => array('mark' => 'GET'), 
+                           'collection' => array('rss' => 'GET')));
+        $m->createRegs(array('messages'));
+
+        $this->assertNull($utils->urlFor(array('controller' => 'content', 'action' => 'view')));
+        $this->assertNull($utils->urlFor(array('controller' => 'content')));
+        $this->assertNull($utils->urlFor(array('controller' => 'admin/comments')));
+        $this->assertEquals('/messages', 
+                            $utils->urlFor('messages'));
+        $this->assertEquals('/messages/rss', 
+                            $utils->urlFor('rss_messages'));
+        $this->assertEquals('/messages/4',
+                            $utils->urlFor('message', array('id' => 4)));
+        $this->assertEquals('/messages/4/edit',
+                            $utils->urlFor('edit_message', array('id' => 4)));
+        $this->assertEquals('/messages/4/mark',
+                            $utils->urlFor('mark_message', array('id' => 4)));
+        $this->assertEquals('/messages/new',
+                            $utils->urlFor('new_message'));
+        $this->assertEquals('/messages.xml',
+                            $utils->urlFor('formatted_messages', array('format' => 'xml')));
+        $this->assertEquals('/messages/rss.xml',
+                            $utils->urlFor('formatted_rss_messages', array('format' => 'xml')));
+        $this->assertEquals('/messages/4.xml',
+                            $utils->urlFor('formatted_message', array('id' => 4, 'format' => 'xml')));
+        $this->assertEquals('/messages/4/edit.xml',
+                            $utils->urlFor('formatted_edit_message', array('id' => 4, 'format' => 'xml')));
+        $this->assertEquals('/messages/4/mark.xml',
+                            $utils->urlFor('formatted_mark_message', array('id' => 4, 'format' => 'xml')));
+        $this->assertEquals('/messages/new.xml',
+                            $utils->urlFor('formatted_new_message', array('format' => 'xml')));
+    }
+
+}
diff --git a/framework/Routes/test/Horde/Routes/fixtures/controllers/admin/users.php b/framework/Routes/test/Horde/Routes/fixtures/controllers/admin/users.php
new file mode 100644 (file)
index 0000000..cadde99
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+/**
+ * @package Horde_Routes
+ */
diff --git a/framework/Routes/test/Horde/Routes/fixtures/controllers/content.php b/framework/Routes/test/Horde/Routes/fixtures/controllers/content.php
new file mode 100644 (file)
index 0000000..cadde99
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+/**
+ * @package Horde_Routes
+ */
diff --git a/framework/Routes/test/Horde/Routes/fixtures/controllers/users.php b/framework/Routes/test/Horde/Routes/fixtures/controllers/users.php
new file mode 100644 (file)
index 0000000..cadde99
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+/**
+ * @package Horde_Routes
+ */