Browse Source

Merge pull request #3898 from markstory/3.0-route-collection

3.0 Router cleanup
José Lorenzo Rodríguez 11 years ago
parent
commit
106fee0939

+ 1 - 10
src/Controller/Controller.php

@@ -392,20 +392,11 @@ class Controller implements EventListener {
  * @return bool
  */
 	protected function _isPrivateAction(\ReflectionMethod $method, Request $request) {
-		$privateAction = (
+		return (
 			$method->name[0] === '_' ||
 			!$method->isPublic() ||
 			!in_array($method->name, $this->methods)
 		);
-		$prefixes = Router::prefixes();
-
-		if (!$privateAction && !empty($prefixes)) {
-			if (empty($request->params['prefix']) && strpos($request->params['action'], '_') > 0) {
-				list($prefix) = explode('_', $request->params['action']);
-				$privateAction = in_array($prefix, $prefixes);
-			}
-		}
-		return $privateAction;
 	}
 
 /**

+ 2 - 1
src/Routing/Filter/ControllerFactoryFilter.php

@@ -17,6 +17,7 @@ namespace Cake\Routing\Filter;
 use Cake\Core\App;
 use Cake\Event\Event;
 use Cake\Routing\DispatcherFilter;
+use Cake\Utility\Inflector;
 
 /**
  * A dispatcher filter that builds the controller to dispatch
@@ -64,7 +65,7 @@ class ControllerFactoryFilter extends DispatcherFilter {
 			$controller = $request->params['controller'];
 		}
 		if (!empty($request->params['prefix'])) {
-			$namespace .= '/' . $request->params['prefix'];
+			$namespace .= '/' . Inflector::camelize($request->params['prefix']);
 		}
 		$className = false;
 		if ($pluginPath . $controller) {

+ 0 - 6
src/Routing/Route/InflectedRoute.php

@@ -41,9 +41,6 @@ class InflectedRoute extends Route {
 		if (!empty($params['plugin'])) {
 			$params['plugin'] = Inflector::camelize($params['plugin']);
 		}
-		if (!empty($params['prefix'])) {
-			$params['prefix'] = Inflector::camelize($params['prefix']);
-		}
 		return $params;
 	}
 
@@ -64,9 +61,6 @@ class InflectedRoute extends Route {
 		if (!empty($url['plugin'])) {
 			$url['plugin'] = Inflector::underscore($url['plugin']);
 		}
-		if (!empty($url['prefix'])) {
-			$url['prefix'] = Inflector::underscore($url['prefix']);
-		}
 		return parent::match($url, $context);
 	}
 

+ 36 - 14
src/Routing/Route/Route.php

@@ -227,22 +227,27 @@ class Route {
 			return $this->_name;
 		}
 		$name = '';
-		if (isset($this->defaults['plugin'])) {
-			$name = $this->defaults['plugin'] . '.';
-		}
-		if (strpos($this->template, ':plugin') !== false) {
-			$name = '_plugin.';
-		}
-		foreach (array('controller', 'action') as $key) {
-			if ($key === 'action') {
-				$name .= ':';
-			}
-			$var = ':' . $key;
-			if (strpos($this->template, $var) !== false) {
-				$name .= '_' . $key;
+		$keys = [
+			'prefix' => ':',
+			'plugin' => '.',
+			'controller' => ':',
+			'action' => ''
+		];
+		foreach ($keys as $key => $glue) {
+			$value = null;
+			if (strpos($this->template, ':' . $key) !== false) {
+				$value = '_' . $key;
 			} elseif (isset($this->defaults[$key])) {
-				$name .= $this->defaults[$key];
+				$value = $this->defaults[$key];
+			}
+
+			if ($value === null) {
+				continue;
 			}
+			if (is_bool($value)) {
+				$value = $value ? '1' : '0';
+			}
+			$name .= $value . $glue;
 		}
 		return $this->_name = strtolower($name);
 	}
@@ -571,4 +576,21 @@ class Route {
 		return $out;
 	}
 
+/**
+ * Get the static path portion for this route.
+ *
+ * @return string
+ */
+	public function staticPath() {
+		$routeKey = strpos($this->template, ':');
+		if ($routeKey !== false) {
+			return substr($this->template, 0, $routeKey);
+		}
+		$star = strpos($this->template, '*');
+		if ($star !== false) {
+			return substr($this->template, 0, $star);
+		}
+		return $this->template;
+	}
+
 }

+ 23 - 173
src/Routing/ScopedRouteCollection.php

@@ -21,10 +21,12 @@ use Cake\Routing\Route\Route;
 use Cake\Utility\Inflector;
 
 /**
- * Contains a collection of routes related to a specific path scope.
- * Path scopes can be read with the `path()` method.
+ * Provides features for building routes inside scopes.
+ *
+ * Gives an easy to use way to build routes and append them
+ * into a route collection.
  */
-class ScopedRouteCollection {
+class RouteBuilder {
 
 /**
  * Regular expression for auto increment IDs
@@ -76,18 +78,11 @@ class ScopedRouteCollection {
 	protected $_params;
 
 /**
- * The routes connected to this collection.
- *
- * @var array
- */
-	protected $_routes = [];
-
-/**
- * The hash map of named routes that are in this collection.
+ * The route collection routes should be added to.
  *
- * @var array
+ * @var Cake\Routing\RouteCollection
  */
-	protected $_named = [];
+	protected $_collection;
 
 /**
  * Constructor
@@ -95,7 +90,8 @@ class ScopedRouteCollection {
  * @param string $path The path prefix the scope is for.
  * @param array $params The scope's routing parameters.
  */
-	public function __construct($path, array $params = [], array $extensions = []) {
+	public function __construct($collection, $path, array $params = [], array $extensions = []) {
+		$this->_collection = $collection;
 		$this->_path = $path;
 		$this->_params = $params;
 		$this->_extensions = $extensions;
@@ -139,39 +135,6 @@ class ScopedRouteCollection {
 	}
 
 /**
- * Get the explicity named routes in the collection.
- *
- * @return array An array of named routes indexed by their name.
- */
-	public function named() {
-		return $this->_named;
-	}
-
-/**
- * Get all the routes in this collection.
- *
- * @return array An array of routes.
- */
-	public function routes() {
-		return $this->_routes;
-	}
-
-/**
- * Get a route by its name.
- *
- * *Note* This method only works on explicitly named routes.
- *
- * @param string $name The name of the route to get.
- * @return false|\Cake\Routing\Route The route.
- */
-	public function get($name) {
-		if (isset($this->_named[$name])) {
-			return $this->_named[$name];
-		}
-		return false;
-	}
-
-/**
  * Generate REST resource routes for the given controller(s).
  *
  * A quick way to generate a default routes to a set of REST resources (controller(s)).
@@ -267,7 +230,7 @@ class ScopedRouteCollection {
 		if (is_callable($callback)) {
 			$idName = Inflector::singularize($urlName) . '_id';
 			$path = $this->_path . '/' . $urlName . '/:' . $idName;
-			Router::scope($path, $this->params(), $callback);
+			$this->scope($path, $this->params(), $callback);
 		}
 	}
 
@@ -352,16 +315,7 @@ class ScopedRouteCollection {
 		}
 
 		$route = $this->_makeRoute($route, $defaults, $options);
-		if (isset($options['_name'])) {
-			$this->_named[$options['_name']] = $route;
-		}
-
-		$name = $route->getName();
-		if (!isset($this->_routeTable[$name])) {
-			$this->_routeTable[$name] = [];
-		}
-		$this->_routeTable[$name][] = $route;
-		$this->_routes[] = $route;
+		$this->_collection->add($route, $options);
 	}
 
 /**
@@ -385,9 +339,6 @@ class ScopedRouteCollection {
 			unset($options['routeClass']);
 
 			$route = str_replace('//', '/', $this->_path . $route);
-			if (!is_array($defaults)) {
-				debug(\Cake\Utility\Debugger::trace());
-			}
 			foreach ($this->_params as $param => $val) {
 				if (isset($defaults[$param]) && $defaults[$param] !== $val) {
 					$msg = 'You cannot define routes that conflict with the scope. ' .
@@ -477,7 +428,7 @@ class ScopedRouteCollection {
 			$name = $this->_params['prefix'] . '/' . $name;
 		}
 		$params = ['prefix' => $name] + $this->_params;
-		Router::scope($path, $params, $callback);
+		$this->scope($path, $params, $callback);
 	}
 
 /**
@@ -508,122 +459,21 @@ class ScopedRouteCollection {
 			$options['path'] = '/' . Inflector::underscore($name);
 		}
 		$options['path'] = $this->_path . $options['path'];
-		Router::scope($options['path'], $params, $callback);
-	}
-
-/**
- * Takes the URL string and iterates the routes until one is able to parse the route.
- *
- * @param string $url Url to parse.
- * @return array An array of request parameters parsed from the url.
- */
-	public function parse($url) {
-		$queryParameters = null;
-		if (strpos($url, '?') !== false) {
-			list($url, $queryParameters) = explode('?', $url, 2);
-			parse_str($queryParameters, $queryParameters);
-		}
-		$out = [];
-		for ($i = 0, $len = count($this->_routes); $i < $len; $i++) {
-			$r = $this->_routes[$i]->parse($url);
-			if ($r === false) {
-				continue;
-			}
-			if ($queryParameters) {
-				$r['?'] = $queryParameters;
-				return $r;
-			}
-			return $r;
-		}
-		return $out;
-	}
-
-/**
- * Reverse route or match a $url array with the defined routes.
- * Returns either the string URL generate by the route, or false on failure.
- *
- * @param array $url The url to match.
- * @param array $context The request context to use. Contains _base, _port,
- *    _host, and _scheme keys.
- * @return string|false Either a string on match, or false on failure.
- */
-	public function match($url, $context) {
-		foreach ($this->_getNames($url) as $name) {
-			if (empty($this->_routeTable[$name])) {
-				continue;
-			}
-			foreach ($this->_routeTable[$name] as $route) {
-				$match = $route->match($url, $context);
-				if ($match) {
-					return strlen($match) > 1 ? trim($match, '/') : $match;
-				}
-			}
-		}
-		return false;
+		$this->scope($options['path'], $params, $callback);
 	}
 
-/**
- * Get the set of names from the $url.  Accepts both older style array urls,
- * and newer style urls containing '_name'
- *
- * @param array $url The url to match.
- * @return string The name of the url
- */
-	protected function _getNames($url) {
-		$name = false;
-		if (isset($url['_name'])) {
-			return [$url['_name']];
-		}
-		$plugin = false;
-		if (isset($url['plugin'])) {
-			$plugin = $url['plugin'];
-		}
-		$fallbacks = [
-			'%2$s:%3$s',
-			'%2$s:_action',
-			'_controller:%3$s',
-			'_controller:_action'
-		];
-		if ($plugin) {
-			$fallbacks = [
-				'%1$s.%2$s:%3$s',
-				'%1$s.%2$s:_action',
-				'%1$s._controller:%3$s',
-				'%1$s._controller:_action',
-				'_plugin.%2$s:%3$s',
-				'_plugin._controller:%3$s',
-				'_plugin._controller:_action',
-				'_controller:_action'
-			];
-		}
-		foreach ($fallbacks as $i => $template) {
-			$fallbacks[$i] = strtolower(sprintf($template, $plugin, $url['controller'], $url['action']));
+	public function scope($path, $params, $callback) {
+		if ($callback === null) {
+			$callback = $params;
+			$params = [];
 		}
-		if ($name) {
-			array_unshift($fallbacks, $name);
+		if (!is_callable($callback)) {
+			$msg = 'Need a callable function/object to connect routes.';
+			throw new \InvalidArgumentException($msg);
 		}
-		return $fallbacks;
-	}
 
-/**
- * Merge another ScopedRouteCollection with this one.
- *
- * Combines all the routes, from one collection into the current one.
- * Used internally when scopes are duplicated.
- *
- * @param \Cake\Routing\ScopedRouteCollection $collection
- * @return void
- */
-	public function merge(ScopedRouteCollection $collection) {
-		foreach ($collection->routes() as $route) {
-			$name = $route->getName();
-			if (!isset($this->_routeTable[$name])) {
-				$this->_routeTable[$name] = [];
-			}
-			$this->_routeTable[$name][] = $route;
-			$this->_routes[] = $route;
-		}
-		$this->_named += $collection->named();
+		$builder = new static($this->_collection, $path, $params, $this->_extensions);
+		$callback($builder);
 	}
 
 /**

+ 257 - 0
src/Routing/RouteCollection.php

@@ -0,0 +1,257 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.0.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Routing;
+
+use Cake\Core\App;
+use Cake\Error;
+use Cake\Routing\Error\MissingRouteException;
+use Cake\Routing\Router;
+use Cake\Routing\Route\Route;
+use Cake\Utility\Inflector;
+
+/**
+ * Contains a collection of routes.
+ *
+ * Provides an interface for adding/removing routes
+ * and parsing/generating URLs with the routes it contains.
+ *
+ * @internal
+ */
+class RouteCollection {
+
+/**
+ * The routes connected to this collection.
+ *
+ * @var array
+ */
+	protected $_routeTable = [];
+
+/**
+ * The routes connected to this collection.
+ *
+ * @var array
+ */
+	protected $_routes = [];
+
+/**
+ * The hash map of named routes that are in this collection.
+ *
+ * @var array
+ */
+	protected $_named = [];
+
+/**
+ * Add a route to the collection.
+ *
+ * @param \Cake\Routing\Route\Route $route The route object to add.
+ * @param array $options Addtional options for the route. Primarily for the
+ *   `_name` option, which enables named routes.
+ */
+	public function add(Route $route, $options = []) {
+		$this->_routes[] = $route;
+
+		// Explicit names
+		if (isset($options['_name'])) {
+			$this->_named[$options['_name']] = $route;
+		}
+
+		// Generated names.
+		$name = $route->getName();
+		if (!isset($this->_routeTable[$name])) {
+			$this->_routeTable[$name] = [];
+		}
+		$this->_routeTable[$name][] = $route;
+
+		// Index path prefixes (for parsing)
+		$path = $route->staticPath();
+		if (empty($this->_paths[$path])) {
+			$this->_paths[$path] = [];
+			krsort($this->_paths);
+		}
+		$this->_paths[$path][] = $route;
+	}
+
+/**
+ * Takes the URL string and iterates the routes until one is able to parse the route.
+ *
+ * @param string $url URL to parse.
+ * @return array An array of request parameters parsed from the URL.
+ * @throws \Cake\Routing\Error\MissingRouteException When a URL has no matching route.
+ */
+	public function parse($url) {
+		foreach (array_keys($this->_paths) as $path) {
+			if (strpos($url, $path) === 0) {
+				break;
+			}
+		}
+
+		$queryParameters = null;
+		if (strpos($url, '?') !== false) {
+			list($url, $queryParameters) = explode('?', $url, 2);
+			parse_str($queryParameters, $queryParameters);
+		}
+		foreach ($this->_paths[$path] as $route) {
+			$r = $route->parse($url);
+			if ($r === false) {
+				continue;
+			}
+			if ($queryParameters) {
+				$r['?'] = $queryParameters;
+			}
+			return $r;
+		}
+		throw new MissingRouteException(['url' => $url]);
+	}
+
+/**
+ * Get the set of names from the $url.  Accepts both older style array urls,
+ * and newer style urls containing '_name'
+ *
+ * @param array $url The url to match.
+ * @return string The name of the url
+ */
+	protected function _getNames($url) {
+		$plugin = false;
+		if (isset($url['plugin']) && $url['plugin'] !== false) {
+			$plugin = strtolower($url['plugin']);
+		}
+		$prefix = false;
+		if (isset($url['prefix']) && $url['prefix'] !== false) {
+			$prefix = strtolower($url['prefix']);
+		}
+		$controller = strtolower($url['controller']);
+		$action = strtolower($url['action']);
+
+		$names = [
+			"${controller}:${action}",
+			"${controller}:_action",
+			"_controller:${action}",
+			"_controller:_action"
+		];
+
+		// No prefix, no plugin
+		if ($prefix === false && $plugin === false) {
+			return $names;
+		}
+
+		// Only a plugin
+		if ($prefix === false) {
+			return [
+				"${plugin}.${controller}:${action}",
+				"${plugin}.${controller}:_action",
+				"${plugin}._controller:${action}",
+				"${plugin}._controller:_action",
+				"_plugin.${controller}:${action}",
+				"_plugin.${controller}:_action",
+				"_plugin._controller:${action}",
+				"_plugin._controller:_action",
+			];
+		}
+
+		// Only a prefix
+		if ($plugin === false) {
+			return [
+				"${prefix}:${controller}:${action}",
+				"${prefix}:${controller}:_action",
+				"${prefix}:_controller:${action}",
+				"${prefix}:_controller:_action",
+				"_prefix:${controller}:${action}",
+				"_prefix:${controller}:_action",
+				"_prefix:_controller:${action}",
+				"_prefix:_controller:_action"
+			];
+		}
+
+		// Prefix and plugin has the most options
+		// as there are 4 factors.
+		return [
+			"${prefix}:${plugin}.${controller}:${action}",
+			"${prefix}:${plugin}.${controller}:_action",
+			"${prefix}:${plugin}._controller:${action}",
+			"${prefix}:${plugin}._controller:_action",
+			"${prefix}:_plugin.${controller}:${action}",
+			"${prefix}:_plugin.${controller}:_action",
+			"${prefix}:_plugin._controller:${action}",
+			"${prefix}:_plugin._controller:_action",
+			"_prefix:${plugin}.${controller}:${action}",
+			"_prefix:${plugin}.${controller}:_action",
+			"_prefix:${plugin}._controller:${action}",
+			"_prefix:${plugin}._controller:_action",
+			"_prefix:_plugin.${controller}:${action}",
+			"_prefix:_plugin.${controller}:_action",
+			"_prefix:_plugin._controller:${action}",
+			"_prefix:_plugin._controller:_action",
+		];
+	}
+
+/**
+ * Reverse route or match a $url array with the defined routes.
+ * Returns either the string URL generate by the route, or false on failure.
+ *
+ * @param array $url The url to match.
+ * @param array $context The request context to use. Contains _base, _port,
+ *    _host, and _scheme keys.
+ * @return string|false Either a string on match, or false on failure.
+ * @throws \Cake\Routing\Error\MissingRouteException when a route cannot be matched.
+ */
+	public function match($url, $context) {
+		// Named routes support optimization.
+		if (isset($url['_name'])) {
+			$name = $url['_name'];
+			unset($url['_name']);
+			$out = false;
+			if (isset($this->_named[$name])) {
+				$route = $this->_named[$name];
+				$out = $route->match($url + $route->defaults, $context);
+			}
+			if ($out) {
+				return $out;
+			}
+			throw new MissingRouteException(['url' => $name]);
+		}
+
+		foreach ($this->_getNames($url) as $name) {
+			if (empty($this->_routeTable[$name])) {
+				continue;
+			}
+			foreach ($this->_routeTable[$name] as $route) {
+				$match = $route->match($url, $context);
+				if ($match) {
+					return strlen($match) > 1 ? trim($match, '/') : $match;
+				}
+			}
+		}
+		throw new MissingRouteException(['url' => var_export($url, true)]);
+	}
+
+/**
+ * Get all the connected routes as a flat list.
+ *
+ * @return array
+ */
+	public function routes() {
+		return $this->_routes;
+	}
+
+/**
+ * Get the connected named routes.
+ *
+ * @return array
+ */
+	public function named() {
+		return $this->_named;
+	}
+
+}

+ 15 - 178
src/Routing/Router.php

@@ -17,8 +17,8 @@ namespace Cake\Routing;
 use Cake\Core\App;
 use Cake\Core\Configure;
 use Cake\Network\Request;
-use Cake\Routing\Error\MissingRouteException;
-use Cake\Routing\ScopedRouteCollection;
+use Cake\Routing\RouteBuilder;
+use Cake\Routing\RouteCollection;
 use Cake\Routing\Route\Route;
 use Cake\Utility\Inflector;
 
@@ -53,14 +53,6 @@ class Router {
 	protected static $_fullBaseUrl;
 
 /**
- * List of action prefixes used in connected routes.
- * Includes admin prefix
- *
- * @var array
- */
-	protected static $_prefixes = [];
-
-/**
  * List of valid extensions to parse from a URL. If null, any extension is allowed.
  *
  * @var array
@@ -109,19 +101,7 @@ class Router {
  */
 	const UUID = '[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}';
 
-/**
- * A hash of ScopedRouteCollection objects indexed by path.
- *
- * @var array
- */
-	protected static $_pathScopes = [];
-
-/**
- * A hash of ScopedRouteCollection objects indexed by plugin + prefix
- *
- * @var array
- */
-	protected static $_paramScopes = [];
+	protected static $_collection;
 
 /**
  * A hash of request context data.
@@ -131,15 +111,6 @@ class Router {
 	protected static $_requestContext = [];
 
 /**
- * A hash of named routes. Indexed
- * as an optimization so reverse routing does not
- * have to traverse all the route collections.
- *
- * @var array
- */
-	protected static $_named = [];
-
-/**
  * Named expressions
  *
  * @var array
@@ -168,13 +139,6 @@ class Router {
 	);
 
 /**
- * List of resource-mapped controllers
- *
- * @var array
- */
-	protected static $_resourceMapped = [];
-
-/**
  * Maintains the request object stack for the current request.
  * This will contain more than one request object when requestAction is used.
  *
@@ -200,35 +164,6 @@ class Router {
 	protected static $_urlFilters = [];
 
 /**
- * Validates that the passed route class exists and is a subclass of Cake Route
- *
- * @param string $routeClass Route class name
- * @return string
- * @throws \Cake\Error\Exception
- */
-	protected static function _validateRouteClass($routeClass) {
-		if (
-			$routeClass != 'Cake\Routing\Route\Route' &&
-			(!class_exists($routeClass) || !is_subclass_of($routeClass, 'Cake\Routing\Route\Route'))
-		) {
-			throw new Error\Exception('Route class not found, or route class is not a subclass of Cake\Routing\Route\Route');
-		}
-		return $routeClass;
-	}
-
-/**
- * Sets the Routing prefixes.
- *
- * @return void
- */
-	protected static function _setPrefixes() {
-		$routing = Configure::read('Routing');
-		if (!empty($routing['prefixes'])) {
-			static::$_prefixes = array_merge(static::$_prefixes, (array)$routing['prefixes']);
-		}
-	}
-
-/**
  * Gets the named route patterns for use in app/Config/routes.php
  *
  * @return array Named route elements
@@ -329,7 +264,7 @@ class Router {
  */
 	public static function connect($route, $defaults = [], $options = []) {
 		static::$initialized = true;
-		Router::scope('/', function($routes) use ($route, $defaults, $options) {
+		static::scope('/', function($routes) use ($route, $defaults, $options) {
 			$routes->connect($route, $defaults, $options);
 		});
 	}
@@ -416,7 +351,7 @@ class Router {
  *
  * @param string|array $controller A controller name or array of controller names (i.e. "Posts" or "ListItems")
  * @param array $options Options to use when generating REST routes
- * @return array Array of mapped resources
+ * @return void
  */
 	public static function mapResources($controller, $options = []) {
 		$options += array(
@@ -457,23 +392,9 @@ class Router {
 					'id' => $options['id'],
 					'pass' => array('id')
 				), $connectOptions);
-				Router::connect($url, $params, $routeOptions);
+				static::connect($url, $params, $routeOptions);
 			}
-			static::$_resourceMapped[] = $urlName;
-		}
-		return static::$_resourceMapped;
-	}
-
-/**
- * Returns the list of prefixes used in connected routes
- *
- * @return array A list of prefixes used in connected routes
- */
-	public static function prefixes() {
-		if (empty(static::$_prefixes)) {
-			return (array)Configure::read('Routing.prefixes');
 		}
-		return static::$_prefixes;
 	}
 
 /**
@@ -490,17 +411,7 @@ class Router {
 		if (strpos($url, '/') !== 0) {
 			$url = '/' . $url;
 		}
-
-		foreach (static::$_pathScopes as $path => $collection) {
-			if (strpos($url, $path) === 0) {
-				break;
-			}
-		}
-		$result = $collection->parse($url);
-		if ($result) {
-			return $result;
-		}
-		throw new MissingRouteException(['url' => $url]);
+		return static::$_collection->parse($url);
 	}
 
 /**
@@ -599,6 +510,7 @@ class Router {
  */
 	public static function reload() {
 		if (empty(static::$_initialState)) {
+			static::$_collection = new RouteCollection();
 			static::$_initialState = get_class_vars(get_called_class());
 			return;
 		}
@@ -607,6 +519,7 @@ class Router {
 				static::${$key} = $val;
 			}
 		}
+		static::$_collection = new RouteCollection();
 	}
 
 /**
@@ -785,7 +698,7 @@ class Router {
 				'_ext' => $params['_ext']
 			);
 			$url = static::_applyUrlFilters($url);
-			$output = static::_match($url);
+			$output = static::$_collection->match($url, static::$_requestContext);
 		} elseif (
 			$urlType === 'string' &&
 			!$hasLeadingSlash &&
@@ -794,7 +707,7 @@ class Router {
 			// named route.
 			$url = $options + ['_name' => $url];
 			$url = static::_applyUrlFilters($url);
-			$output = static::_match($url);
+			$output = static::$_collection->match($url, static::$_requestContext);
 		} else {
 			// String urls.
 			if ($plainString) {
@@ -813,49 +726,6 @@ class Router {
 	}
 
 /**
- * Find a Route that matches the given URL data.
- *
- * @param string|array The URL to match.
- * @return string The generated URL
- * @throws \Cake\Error\Exception When a matching URL cannot be found.
- */
-	protected static function _match($url) {
-		// Named routes support hack.
-		if (isset($url['_name'])) {
-			$route = false;
-			if (isset(static::$_named[$url['_name']])) {
-				$route = static::$_named[$url['_name']];
-			}
-			if ($route) {
-				unset($url['_name']);
-				return $route->match($url + $route->defaults, static::$_requestContext);
-			}
-		}
-
-		// Check the scope that matches key params.
-		$plugin = isset($url['plugin']) ? $url['plugin'] : '';
-		$prefix = isset($url['prefix']) ? $url['prefix'] : '';
-
-		$collection = null;
-		$attempts = [[$plugin, $prefix], ['', '']];
-		foreach ($attempts as $attempt) {
-			if (isset(static::$_paramScopes[$attempt[0]][$attempt[1]])) {
-				$collection = static::$_paramScopes[$attempt[0]][$attempt[1]];
-				break;
-			}
-		}
-
-		if ($collection) {
-			$match = $collection->match($url, static::$_requestContext);
-			if ($match !== false) {
-				return $match;
-			}
-		}
-
-		throw new MissingRouteException(['url' => var_export($url, true)]);
-	}
-
-/**
  * Sets the full base URL that will be used as a prefix for generating
  * fully qualified URLs for this application. If not parameters are passed,
  * the currently configured value is returned.
@@ -1083,45 +953,12 @@ class Router {
  *   If you have no parameters, this argument can be a callable.
  * @param callable $callback The callback to invoke with the scoped collection.
  * @throws \InvalidArgumentException When an invalid callable is provided.
- * @return null|\Cake\Routing\ScopedRouteCollection The scoped collection that
+ * @return null|\Cake\Routing\RouteBuilder The route builder
  *   was created/used.
  */
 	public static function scope($path, $params = [], $callback = null) {
-		if ($params === [] && $callback === null && isset(static::$_pathScopes[$path])) {
-			return static::$_pathScopes[$path];
-		}
-
-		if ($callback === null) {
-			$callback = $params;
-			$params = [];
-		}
-		if (!is_callable($callback)) {
-			$msg = 'Need a callable function/object to connect routes.';
-			throw new \InvalidArgumentException($msg);
-		}
-
-		$collection = new ScopedRouteCollection($path, $params, static::$_validExtensions);
-		$callback($collection);
-
-		// Index named routes for fast lookup.
-		static::$_named += $collection->named();
-
-		// Index scopes by path (for parsing)
-		if (empty(static::$_pathScopes[$path])) {
-			static::$_pathScopes[$path] = $collection;
-			krsort(static::$_pathScopes);
-		} else {
-			static::$_pathScopes[$path]->merge($collection);
-		}
-
-		// Index scopes by key params (for reverse routing).
-		$plugin = isset($params['plugin']) ? $params['plugin'] : '';
-		$prefix = isset($params['prefix']) ? $params['prefix'] : '';
-		if (!isset(static::$_paramScopes[$plugin][$prefix])) {
-			static::$_paramScopes[$plugin][$prefix] = $collection;
-		} else {
-			static::$_paramScopes[$plugin][$prefix]->merge($collection);
-		}
+		$builder = new RouteBuilder(static::$_collection, '/', [], static::$_validExtensions);
+		$builder->scope($path, $params, $callback);
 	}
 
 /**
@@ -1184,7 +1021,7 @@ class Router {
  * @return array
  */
 	public static function routes() {
-		return array_values(static::$_pathScopes);
+		return static::$_collection->routes();
 	}
 
 /**

+ 13 - 16
src/Template/Error/missing_route.ctp

@@ -27,25 +27,22 @@ use Cake\Utility\Debugger;
 Add a matching route to <?= APP_DIR . DS . 'Config' . DS . 'routes.php' ?></p>
 
 <h3>Connected Routes</h3>
+<table cellspacing="0" cellpadding="0">
+<tr><th>Template</th><th>Defaults</th><th>Options</th></tr>
 <?php
-foreach (Router::routes() as $scope):
-	printf('<h4>Scope: %s</h4>', $scope->path());
-	echo '<table cellspacing="0" cellpadding="0">';
-	echo '<tr><th>Template</th><th>Defaults</th><th>Options</th></tr>';
-
-	foreach ($scope->routes() as $route):
-		echo '<tr>';
-		printf(
-			'<th width="25%%">%s</th><th>%s</th><th width="20%%">%s</th>',
-			$route->template,
-			Debugger::exportVar($route->defaults),
-			Debugger::exportVar($route->options)
-		);
-		echo '</tr>';
-	endforeach;
-	echo '</table>';
+foreach (Router::routes() as $route):
+	echo '<tr>';
+	printf(
+		'<th width="25%%">%s</th><th>%s</th><th width="20%%">%s</th>',
+		$route->template,
+		Debugger::exportVar($route->defaults),
+		Debugger::exportVar($route->options)
+	);
+	echo '</tr>';
 endforeach;
 ?>
+</table>
+
 <p class="notice">
 	<strong>Notice: </strong>
 	<?= sprintf('If you want to customize this error message, create %s', APP_DIR . DS . 'Template' . DS . 'Error' . DS . 'missing_route.ctp'); ?>

+ 8 - 0
tests/TestCase/Controller/Component/AuthComponentTest.php

@@ -858,9 +858,13 @@ class AuthComponentTest extends TestCase {
  */
 	public function testAdminRoute() {
 		$event = new Event('Controller.startup', $this->Controller);
+		Router::reload();
 		Router::prefix('admin', function($routes) {
 			$routes->fallbacks();
 		});
+		Router::scope('/', function($routes) {
+			$routes->fallbacks();
+		});
 
 		$url = '/admin/auth_test/add';
 		$this->Auth->request->addParams(Router::parse($url));
@@ -912,9 +916,13 @@ class AuthComponentTest extends TestCase {
  */
 	public function testLoginActionRedirect() {
 		$event = new Event('Controller.startup', $this->Controller);
+		Router::reload();
 		Router::prefix('admin', function($routes) {
 			$routes->fallbacks();
 		});
+		Router::scope('/', function($routes) {
+			$routes->fallbacks();
+		});
 
 		$url = '/admin/auth_test/login';
 		$request = $this->Auth->request;

+ 0 - 20
tests/TestCase/Controller/ControllerTest.php

@@ -803,26 +803,6 @@ class ControllerTest extends TestCase {
 /**
  * test invoking controller methods.
  *
- * @expectedException \Cake\Controller\Error\PrivateActionException
- * @expectedExceptionMessage Private Action TestController::admin_add() is not directly accessible.
- * @return void
- */
-	public function testInvokeActionPrefixProtection() {
-		Configure::write('Routing.prefixes', array('admin'));
-		Router::reload();
-		Router::connect('/admin/:controller/:action/*', array('prefix' => 'admin'));
-
-		$url = new Request('test/admin_add/');
-		$url->addParams(array('controller' => 'test_controller', 'action' => 'admin_add'));
-		$response = $this->getMock('Cake\Network\Response');
-
-		$Controller = new TestController($url, $response);
-		$Controller->invokeAction();
-	}
-
-/**
- * test invoking controller methods.
- *
  * @return void
  */
 	public function testInvokeActionReturnValue() {

+ 51 - 1
tests/TestCase/Routing/Route/RouteTest.php

@@ -22,7 +22,6 @@ use Cake\TestSuite\TestCase;
 
 /**
  * Test case for Route
- *
  */
 class RouteTest extends TestCase {
 
@@ -69,6 +68,7 @@ class RouteTest extends TestCase {
 		$this->assertRegExp($result, '/posts/super_delete');
 		$this->assertNotRegExp($result, '/posts');
 		$this->assertNotRegExp($result, '/posts/super_delete/1');
+		$this->assertSame($result, $route->compile());
 
 		$route = new Route('/posts/foo:id', array('controller' => 'posts', 'action' => 'view'));
 		$result = $route->compile();
@@ -881,6 +881,37 @@ class RouteTest extends TestCase {
 	}
 
 /**
+ * Test getName() with prefixes.
+ *
+ * @return void
+ */
+	public function testGetNamePrefix() {
+		$route = new Route(
+			'/admin/:controller/:action',
+			array('prefix' => 'admin')
+		);
+		$this->assertEquals('admin:_controller:_action', $route->getName());
+
+		$route = new Route(
+			'/:prefix/assets/:action',
+			array('controller' => 'assets')
+		);
+		$this->assertEquals('_prefix:assets:_action', $route->getName());
+
+		$route = new Route(
+			'/admin/assets/get',
+			array('prefix' => 'admin', 'plugin' => 'asset', 'controller' => 'assets', 'action' => 'get')
+		);
+		$this->assertEquals('admin:asset.assets:get', $route->getName());
+
+		$route = new Route(
+			'/:prefix/:plugin/:controller/:action/*',
+			[]
+		);
+		$this->assertEquals('_prefix:_plugin._controller:_action', $route->getName());
+	}
+
+/**
  * test that utf-8 patterns work for :section
  *
  * @return void
@@ -904,4 +935,23 @@ class RouteTest extends TestCase {
 		$this->assertEquals($expected, $result);
 	}
 
+/**
+ * Test getting the static path for a route.
+ *
+ * @return void
+ */
+	public function testStaticPath() {
+		$route = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']);
+		$this->assertEquals('/pages/', $route->staticPath());
+
+		$route = new Route('/pages/:id/*', ['controller' => 'Pages', 'action' => 'display']);
+		$this->assertEquals('/pages/', $route->staticPath());
+
+		$route = new Route('/:controller/:action/*');
+		$this->assertEquals('/', $route->staticPath());
+
+		$route = new Route('/books/reviews', ['controller' => 'Reviews', 'action' => 'index']);
+		$this->assertEquals('/books/reviews', $route->staticPath());
+	}
+
 }

+ 46 - 151
tests/TestCase/Routing/ScopedRouteCollectionTest.php

@@ -16,13 +16,24 @@ namespace Cake\Test\TestCase\Routing;
 
 use Cake\Routing\Route\Route;
 use Cake\Routing\Router;
-use Cake\Routing\ScopedRouteCollection;
+use Cake\Routing\RouteBuilder;
+use Cake\Routing\RouteCollection;
 use Cake\TestSuite\TestCase;
 
 /**
- * ScopedRouteCollection test case
+ * RouteBuilder test case
  */
-class ScopedRouteCollectionTest extends TestCase {
+class RouteBuilderTest extends TestCase {
+
+/**
+ * Setup method
+ *
+ * @return void
+ */
+	public function setUp() {
+		parent::setUp();
+		$this->collection = new RouteCollection();
+	}
 
 /**
  * Test path()
@@ -30,16 +41,16 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testPath() {
-		$routes = new ScopedRouteCollection('/some/path');
+		$routes = new RouteBuilder($this->collection, '/some/path');
 		$this->assertEquals('/some/path', $routes->path());
 
-		$routes = new ScopedRouteCollection('/:book_id');
+		$routes = new RouteBuilder($this->collection, '/:book_id');
 		$this->assertEquals('/', $routes->path());
 
-		$routes = new ScopedRouteCollection('/path/:book_id');
+		$routes = new RouteBuilder($this->collection, '/path/:book_id');
 		$this->assertEquals('/path/', $routes->path());
 
-		$routes = new ScopedRouteCollection('/path/book:book_id');
+		$routes = new RouteBuilder($this->collection, '/path/book:book_id');
 		$this->assertEquals('/path/book', $routes->path());
 	}
 
@@ -49,7 +60,7 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testParams() {
-		$routes = new ScopedRouteCollection('/api', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'api']);
 		$this->assertEquals(['prefix' => 'api'], $routes->params());
 	}
 
@@ -59,60 +70,28 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testRoutes() {
-		$routes = new ScopedRouteCollection('/l');
+		$routes = new RouteBuilder($this->collection, '/l');
 		$routes->connect('/:controller', ['action' => 'index']);
 		$routes->connect('/:controller/:action/*');
 
-		$all = $routes->routes();
+		$all = $this->collection->routes();
 		$this->assertCount(2, $all);
 		$this->assertInstanceOf('Cake\Routing\Route\Route', $all[0]);
 		$this->assertInstanceOf('Cake\Routing\Route\Route', $all[1]);
 	}
 
 /**
- * Test getting named routes.
- *
- * @return void
- */
-	public function testNamed() {
-		$routes = new ScopedRouteCollection('/l');
-		$routes->connect('/:controller', ['action' => 'index'], ['_name' => 'cntrl']);
-		$routes->connect('/:controller/:action/*');
-
-		$all = $routes->named();
-		$this->assertCount(1, $all);
-		$this->assertInstanceOf('Cake\Routing\Route\Route', $all['cntrl']);
-		$this->assertEquals('/l/:controller', $all['cntrl']->template);
-	}
-
-/**
- * Test getting named routes.
- *
- * @return void
- */
-	public function testGetNamed() {
-		$routes = new ScopedRouteCollection('/l');
-		$routes->connect('/:controller', ['action' => 'index'], ['_name' => 'cntrl']);
-		$routes->connect('/:controller/:action/*');
-
-		$this->assertFalse($routes->get('nope'));
-		$route = $routes->get('cntrl');
-		$this->assertInstanceOf('Cake\Routing\Route\Route', $route);
-		$this->assertEquals('/l/:controller', $route->template);
-	}
-
-/**
  * Test connecting an instance routes.
  *
  * @return void
  */
 	public function testConnectInstance() {
-		$routes = new ScopedRouteCollection('/l', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/l', ['prefix' => 'api']);
 
 		$route = new Route('/:controller');
 		$this->assertNull($routes->connect($route));
 
-		$result = $routes->routes()[0];
+		$result = $this->collection->routes()[0];
 		$this->assertSame($route, $result);
 	}
 
@@ -122,10 +101,10 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testConnectBasic() {
-		$routes = new ScopedRouteCollection('/l', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/l', ['prefix' => 'api']);
 
 		$this->assertNull($routes->connect('/:controller'));
-		$route = $routes->routes()[0];
+		$route = $this->collection->routes()[0];
 
 		$this->assertInstanceOf('Cake\Routing\Route\Route', $route);
 		$this->assertEquals('/l/:controller', $route->template);
@@ -139,17 +118,17 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testConnectExtensions() {
-		$routes = new ScopedRouteCollection('/l', [], ['json']);
+		$routes = new RouteBuilder($this->collection, '/l', [], ['json']);
 		$this->assertEquals(['json'], $routes->extensions());
 
 		$routes->connect('/:controller');
-		$route = $routes->routes()[0];
+		$route = $this->collection->routes()[0];
 
 		$this->assertEquals(['json'], $route->options['_ext']);
 		$routes->extensions(['xml', 'json']);
 
 		$routes->connect('/:controller/:action');
-		$new = $routes->routes()[1];
+		$new = $this->collection->routes()[1];
 		$this->assertEquals(['json'], $route->options['_ext']);
 		$this->assertEquals(['xml', 'json'], $new->options['_ext']);
 	}
@@ -162,7 +141,7 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testConnectErrorInvalidRouteClass() {
-		$routes = new ScopedRouteCollection('/l', [], ['json']);
+		$routes = new RouteBuilder($this->collection, '/l', [], ['json']);
 		$routes->connect('/:controller', [], ['routeClass' => '\StdClass']);
 	}
 
@@ -174,7 +153,7 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testConnectConflictingParameters() {
-		$routes = new ScopedRouteCollection('/admin', ['prefix' => 'admin'], []);
+		$routes = new RouteBuilder($this->collection, '/admin', ['prefix' => 'admin'], []);
 		$routes->connect('/', ['prefix' => 'manager', 'controller' => 'Dashboard', 'action' => 'view']);
 	}
 
@@ -184,14 +163,14 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testRedirect() {
-		$routes = new ScopedRouteCollection('/');
+		$routes = new RouteBuilder($this->collection, '/');
 		$routes->redirect('/p/:id', ['controller' => 'posts', 'action' => 'view'], ['status' => 301]);
-		$route = $routes->routes()[0];
+		$route = $this->collection->routes()[0];
 
 		$this->assertInstanceOf('Cake\Routing\Route\RedirectRoute', $route);
 
 		$routes->redirect('/old', '/forums', ['status' => 301]);
-		$route = $routes->routes()[1];
+		$route = $this->collection->routes()[1];
 
 		$this->assertInstanceOf('Cake\Routing\Route\RedirectRoute', $route);
 		$this->assertEquals('/forums', $route->redirect[0]);
@@ -203,10 +182,10 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testPrefix() {
-		$routes = new ScopedRouteCollection('/path', ['key' => 'value']);
+		$routes = new RouteBuilder($this->collection, '/path', ['key' => 'value']);
 		$res = $routes->prefix('admin', function($r) {
-			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $r);
-			$this->assertCount(0, $r->routes());
+			$this->assertInstanceOf('Cake\Routing\RouteBuilder', $r);
+			$this->assertCount(0, $this->collection->routes());
 			$this->assertEquals('/path/admin', $r->path());
 			$this->assertEquals(['prefix' => 'admin', 'key' => 'value'], $r->params());
 		});
@@ -219,7 +198,7 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testNestedPrefix() {
-		$routes = new ScopedRouteCollection('/admin', ['prefix' => 'admin']);
+		$routes = new RouteBuilder($this->collection, '/admin', ['prefix' => 'admin']);
 		$res = $routes->prefix('api', function($r) {
 			$this->assertEquals('/admin/api', $r->path());
 			$this->assertEquals(['prefix' => 'admin/api'], $r->params());
@@ -233,13 +212,13 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testNestedPlugin() {
-		$routes = new ScopedRouteCollection('/b', ['key' => 'value']);
+		$routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
 		$res = $routes->plugin('Contacts', function($r) {
 			$this->assertEquals('/b/contacts', $r->path());
 			$this->assertEquals(['plugin' => 'Contacts', 'key' => 'value'], $r->params());
 
 			$r->connect('/:controller');
-			$route = $r->routes()[0];
+			$route = $this->collection->routes()[0];
 			$this->assertEquals(
 				['key' => 'value', 'plugin' => 'Contacts', 'action' => 'index'],
 				$route->defaults
@@ -254,7 +233,7 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testNestedPluginPathOption() {
-		$routes = new ScopedRouteCollection('/b', ['key' => 'value']);
+		$routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
 		$routes->plugin('Contacts', ['path' => '/people'], function($r) {
 			$this->assertEquals('/b/people', $r->path());
 			$this->assertEquals(['plugin' => 'Contacts', 'key' => 'value'], $r->params());
@@ -262,99 +241,15 @@ class ScopedRouteCollectionTest extends TestCase {
 	}
 
 /**
- * Test parsing routes.
- *
- * @return void
- */
-	public function testParse() {
-		$routes = new ScopedRouteCollection('/b', ['key' => 'value']);
-		$routes->connect('/', ['controller' => 'Articles']);
-		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
-
-		$result = $routes->parse('/');
-		$this->assertEquals([], $result, 'Should not match, missing /b');
-
-		$result = $routes->parse('/b/');
-		$expected = [
-			'controller' => 'Articles',
-			'action' => 'index',
-			'pass' => [],
-			'plugin' => null,
-			'key' => 'value',
-		];
-		$this->assertEquals($expected, $result);
-
-		$result = $routes->parse('/b/the-thing?one=two');
-		$expected = [
-			'controller' => 'Articles',
-			'action' => 'view',
-			'id' => 'the-thing',
-			'pass' => [],
-			'plugin' => null,
-			'key' => 'value',
-			'?' => ['one' => 'two'],
-		];
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * Test matching routes.
- *
- * @return void
- */
-	public function testMatch() {
-		$context = [
-			'_base' => '/',
-			'_scheme' => 'http',
-			'_host' => 'example.org',
-		];
-		$routes = new ScopedRouteCollection('/b');
-		$routes->connect('/', ['controller' => 'Articles']);
-		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
-
-		$result = $routes->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'index'], $context);
-		$this->assertEquals('b', $result);
-
-		$result = $routes->match(
-			['id' => 'thing', 'plugin' => null, 'controller' => 'Articles', 'action' => 'view'],
-			$context);
-		$this->assertEquals('b/thing', $result);
-
-		$result = $routes->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'add'], $context);
-		$this->assertFalse($result, 'No matches');
-	}
-
-/**
- * Test matching plugin routes.
- *
- * @return void
- */
-	public function testMatchPlugin() {
-		$context = [
-			'_base' => '/',
-			'_scheme' => 'http',
-			'_host' => 'example.org',
-		];
-		$routes = new ScopedRouteCollection('/contacts', ['plugin' => 'Contacts']);
-		$routes->connect('/', ['controller' => 'Contacts']);
-
-		$result = $routes->match(['controller' => 'Contacts', 'action' => 'index'], $context);
-		$this->assertFalse($result);
-
-		$result = $routes->match(['plugin' => 'Contacts', 'controller' => 'Contacts', 'action' => 'index'], $context);
-		$this->assertEquals('contacts', $result);
-	}
-
-/**
  * Test connecting resources.
  *
  * @return void
  */
 	public function testResources() {
-		$routes = new ScopedRouteCollection('/api', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'api']);
 		$routes->resources('Articles', ['_ext' => 'json']);
 
-		$all = $routes->routes();
+		$all = $this->collection->routes();
 		$this->assertCount(6, $all);
 
 		$this->assertEquals('/api/articles', $all[0]->template);
@@ -368,13 +263,13 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testResourcesNested() {
-		$routes = new ScopedRouteCollection('/api', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'api']);
 		$routes->resources('Articles', function($routes) {
 			$this->assertEquals('/api/articles/', $routes->path());
 			$this->assertEquals(['prefix' => 'api'], $routes->params());
 
 			$routes->resources('Comments');
-			$route = $routes->routes()[0];
+			$route = $this->collection->routes()[6];
 			$this->assertEquals('/api/articles/:article_id/comments', $route->template);
 		});
 	}
@@ -385,10 +280,10 @@ class ScopedRouteCollectionTest extends TestCase {
  * @return void
  */
 	public function testFallbacks() {
-		$routes = new ScopedRouteCollection('/api', ['prefix' => 'api']);
+		$routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'api']);
 		$routes->fallbacks();
 
-		$all = $routes->routes();
+		$all = $this->collection->routes();
 		$this->assertEquals('/api/:controller', $all[0]->template);
 		$this->assertEquals('/api/:controller/:action/*', $all[1]->template);
 	}

+ 250 - 0
tests/TestCase/Routing/RouteCollectionTest.php

@@ -0,0 +1,250 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.0.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Routing;
+
+use Cake\Routing\Route\Route;
+use Cake\Routing\Router;
+use Cake\Routing\RouteBuilder;
+use Cake\Routing\RouteCollection;
+use Cake\TestSuite\TestCase;
+
+class RouteCollectionTest extends TestCase {
+
+/**
+ * Setup method
+ *
+ * @return void
+ */
+	public function setUp() {
+		parent::setUp();
+		$this->collection = new RouteCollection();
+	}
+
+/**
+ * Test parse() throws an error on unknown routes.
+ *
+ * @expectedException Cake\Routing\Error\MissingRouteException
+ * @expectedExceptionMessage A route matching "/" could not be found
+ */
+	public function testParseMissingRoute() {
+		$routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
+		$routes->connect('/', ['controller' => 'Articles']);
+		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
+
+		$result = $this->collection->parse('/');
+		$this->assertEquals([], $result, 'Should not match, missing /b');
+	}
+
+/**
+ * Test parsing routes.
+ *
+ * @return void
+ */
+	public function testParse() {
+		$routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
+		$routes->connect('/', ['controller' => 'Articles']);
+		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
+
+		$result = $this->collection->parse('/b/');
+		$expected = [
+			'controller' => 'Articles',
+			'action' => 'index',
+			'pass' => [],
+			'plugin' => null,
+			'key' => 'value',
+		];
+		$this->assertEquals($expected, $result);
+
+		$result = $this->collection->parse('/b/the-thing?one=two');
+		$expected = [
+			'controller' => 'Articles',
+			'action' => 'view',
+			'id' => 'the-thing',
+			'pass' => [],
+			'plugin' => null,
+			'key' => 'value',
+			'?' => ['one' => 'two'],
+		];
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Test match() throws an error on unknown routes.
+ *
+ * @expectedException Cake\Routing\Error\MissingRouteException
+ * @expectedExceptionMessage A route matching "array (
+ */
+	public function testMatchError() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/b');
+		$routes->connect('/', ['controller' => 'Articles']);
+
+		$result = $this->collection->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'add'], $context);
+		$this->assertFalse($result, 'No matches');
+	}
+
+/**
+ * Test matching routes.
+ *
+ * @return void
+ */
+	public function testMatch() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/b');
+		$routes->connect('/', ['controller' => 'Articles']);
+		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
+
+		$result = $this->collection->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'index'], $context);
+		$this->assertEquals('b', $result);
+
+		$result = $this->collection->match(
+			['id' => 'thing', 'plugin' => null, 'controller' => 'Articles', 'action' => 'view'],
+			$context);
+		$this->assertEquals('b/thing', $result);
+	}
+
+/**
+ * Test matching routes with names
+ *
+ * @return void
+ */
+	public function testMatchNamed() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/b');
+		$routes->connect('/', ['controller' => 'Articles']);
+		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view'], ['_name' => 'article:view']);
+
+		$result = $this->collection->match(['_name' => 'article:view', 'id' => '2'], $context);
+		$this->assertEquals('/b/2', $result);
+	}
+
+/**
+ * Test matching routes with names and failing
+ *
+ * @expectedException Cake\Routing\Error\MissingRouteException
+ * @return void
+ */
+	public function testMatchNamedError() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/b');
+		$routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view'], ['_name' => 'article:view']);
+
+		$this->collection->match(['_name' => 'derp'], $context);
+	}
+
+/**
+ * Test matching plugin routes.
+ *
+ * @return void
+ */
+	public function testMatchPlugin() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/contacts', ['plugin' => 'Contacts']);
+		$routes->connect('/', ['controller' => 'Contacts']);
+
+		$result = $this->collection->match(['plugin' => 'Contacts', 'controller' => 'Contacts', 'action' => 'index'], $context);
+		$this->assertEquals('contacts', $result);
+	}
+
+/**
+ * Test that prefixes increase the specificity of a route match.
+ *
+ * Connect the admin route after the non prefixed version, this means
+ * the non-prefix route would have a more specific name (users:index)
+ *
+ * @return void
+ */
+	public function testMatchPrefixSpecificity() {
+		$context = [
+			'_base' => '/',
+			'_scheme' => 'http',
+			'_host' => 'example.org',
+		];
+		$routes = new RouteBuilder($this->collection, '/');
+		$routes->connect('/:action/*', ['controller' => 'Users']);
+		$routes->connect('/admin/:controller', ['prefix' => 'admin', 'action' => 'index']);
+
+		$url = [
+			'plugin' => null,
+			'prefix' => 'admin',
+			'controller' => 'Users',
+			'action' => 'index'
+		];
+		$result = $this->collection->match($url, $context);
+		$this->assertEquals('admin/Users', $result);
+
+		$url = [
+			'plugin' => null,
+			'controller' => 'Users',
+			'action' => 'index'
+		];
+		$result = $this->collection->match($url, $context);
+		$this->assertEquals('index', $result);
+	}
+
+/**
+ * Test getting named routes.
+ *
+ * @return void
+ */
+	public function testNamed() {
+		$routes = new RouteBuilder($this->collection, '/l');
+		$routes->connect('/:controller', ['action' => 'index'], ['_name' => 'cntrl']);
+		$routes->connect('/:controller/:action/*');
+
+		$all = $this->collection->named();
+		$this->assertCount(1, $all);
+		$this->assertInstanceOf('Cake\Routing\Route\Route', $all['cntrl']);
+		$this->assertEquals('/l/:controller', $all['cntrl']->template);
+	}
+
+/**
+ * Test the add() and routes() method.
+ *
+ * @return void
+ */
+	public function testAddingRoutes() {
+		$one = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']);
+		$two = new Route('/', ['controller' => 'Dashboards', 'action' => 'display']);
+		$this->collection->add($one);
+		$this->collection->add($two);
+
+		$routes = $this->collection->routes();
+		$this->assertCount(2, $routes);
+		$this->assertSame($one, $routes[0]);
+		$this->assertSame($two, $routes[1]);
+	}
+
+}

+ 14 - 60
tests/TestCase/Routing/RouterTest.php

@@ -108,7 +108,7 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testMapResources() {
-		$resources = Router::mapResources('Posts');
+		Router::mapResources('Posts');
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$expected = [
@@ -121,7 +121,6 @@ class RouterTest extends TestCase {
 		];
 		$result = Router::parse('/posts');
 		$this->assertEquals($expected, $result);
-		$this->assertEquals($resources, ['posts']);
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$expected = [
@@ -187,8 +186,7 @@ class RouterTest extends TestCase {
 		$this->assertEquals($expected, $result);
 
 		Router::reload();
-		$result = Router::mapResources('Posts', ['id' => '[a-z0-9_]+']);
-		$this->assertEquals(['posts'], $result);
+		Router::mapResources('Posts', ['id' => '[a-z0-9_]+']);
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$expected = [
@@ -223,7 +221,7 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testPluginMapResources() {
-		$resources = Router::mapResources('TestPlugin.TestPlugin');
+		Router::mapResources('TestPlugin.TestPlugin');
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$result = Router::parse('/test_plugin/test_plugin');
@@ -236,7 +234,6 @@ class RouterTest extends TestCase {
 			'_ext' => null
 		);
 		$this->assertEquals($expected, $result);
-		$this->assertEquals(array('test_plugin'), $resources);
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$result = Router::parse('/test_plugin/test_plugin/13');
@@ -258,8 +255,7 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testMapResourcesWithPrefix() {
-		$resources = Router::mapResources('Posts', array('prefix' => 'api'));
-		$this->assertEquals(array('posts'), $resources);
+		Router::mapResources('Posts', array('prefix' => 'api'));
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$result = Router::parse('/api/posts');
@@ -284,9 +280,7 @@ class RouterTest extends TestCase {
 	public function testMapResourcesWithExtension() {
 		Router::parseExtensions(['json', 'xml'], false);
 
-		$resources = Router::mapResources('Posts', ['_ext' => 'json']);
-		$this->assertEquals(['posts'], $resources);
-
+		Router::mapResources('Posts', ['_ext' => 'json']);
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 
 		$expected = array(
@@ -320,8 +314,8 @@ class RouterTest extends TestCase {
 				'foo' => '^(bar)$',
 			),
 		));
-		$routes = Router::scope('/');
-		$route = $routes->routes()[0];
+		$routes = Router::routes();
+		$route = $routes[0];
 		$this->assertInstanceOf('TestPlugin\Routing\Route\TestRoute', $route);
 		$this->assertEquals('^(bar)$', $route->options['foo']);
 	}
@@ -332,7 +326,7 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testPluginMapResourcesWithPrefix() {
-		$resources = Router::mapResources('TestPlugin.TestPlugin', array('prefix' => 'api'));
+		Router::mapResources('TestPlugin.TestPlugin', array('prefix' => 'api'));
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
 		$result = Router::parse('/api/test_plugin/test_plugin');
@@ -346,7 +340,6 @@ class RouterTest extends TestCase {
 			'_ext' => null
 		);
 		$this->assertEquals($expected, $result);
-		$this->assertEquals(array('test_plugin'), $resources);
 
 		$resources = Router::mapResources('Posts', array('prefix' => 'api'));
 
@@ -1499,30 +1492,6 @@ class RouterTest extends TestCase {
 	}
 
 /**
- * Test that Routing.prefixes are used when a Router instance is created
- * or reset
- *
- * @return void
- */
-	public function testRoutingPrefixesSetting() {
-		$restore = Configure::read('Routing');
-
-		Configure::write('Routing.prefixes', array('admin', 'member', 'super_user'));
-		Router::reload();
-		$result = Router::prefixes();
-		$expected = array('admin', 'member', 'super_user');
-		$this->assertEquals($expected, $result);
-
-		Configure::write('Routing.prefixes', array('admin', 'member'));
-		Router::reload();
-		$result = Router::prefixes();
-		$expected = array('admin', 'member');
-		$this->assertEquals($expected, $result);
-
-		Configure::write('Routing', $restore);
-	}
-
-/**
  * testParseExtensions method
  *
  * @return void
@@ -2018,7 +1987,6 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testParsingWithLiteralPrefixes() {
-		Configure::write('Routing.prefixes', []);
 		Router::reload();
 		$adminParams = array('prefix' => 'admin');
 		Router::connect('/admin/:controller', $adminParams);
@@ -2047,10 +2015,6 @@ class RouterTest extends TestCase {
 		$expected = '/base/admin/posts';
 		$this->assertEquals($expected, $result);
 
-		$result = Router::prefixes();
-		$expected = [];
-		$this->assertEquals($expected, $result);
-
 		Router::reload();
 
 		$prefixParams = array('prefix' => 'members');
@@ -2087,16 +2051,10 @@ class RouterTest extends TestCase {
 		Router::connect('/company/:controller/:action/*', array('prefix' => 'company'));
 		Router::connect('/:action', array('controller' => 'users'));
 
-		/*
 		$result = Router::url(array('controller' => 'users', 'action' => 'login', 'prefix' => 'company'));
 		$expected = '/company/users/login';
 		$this->assertEquals($expected, $result);
 
-		$result = Router::url(array('controller' => 'users', 'action' => 'login', 'prefix' => 'company'));
-		$expected = '/company/users/login';
-		$this->assertEquals($expected, $result);
-		 */
-
 		$request = new Request();
 		Router::setRequestInfo(
 			$request->addParams(array(
@@ -2546,8 +2504,8 @@ class RouterTest extends TestCase {
  */
 	public function testRedirect() {
 		Router::redirect('/mobile', '/', ['status' => 301]);
-		$scope = Router::scope('/');
-		$route = $scope->routes()[0];
+		$routes = Router::routes();
+		$route = $routes[0];
 		$this->assertInstanceOf('Cake\Routing\Route\RedirectRoute', $route);
 	}
 
@@ -2627,16 +2585,12 @@ class RouterTest extends TestCase {
  */
 	public function testScope() {
 		Router::scope('/path', ['param' => 'value'], function($routes) {
-			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
-			$this->assertCount(0, $routes->routes());
+			$this->assertInstanceOf('Cake\Routing\RouteBuilder', $routes);
 			$this->assertEquals('/path', $routes->path());
 			$this->assertEquals(['param' => 'value'], $routes->params());
 
 			$routes->connect('/articles', ['controller' => 'Articles']);
 		});
-		Router::scope('/path', function($routes) {
-			$this->assertCount(0, $routes->routes());
-		});
 	}
 
 /**
@@ -2656,7 +2610,7 @@ class RouterTest extends TestCase {
  */
 	public function testPrefix() {
 		Router::prefix('admin', function($routes) {
-			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertInstanceOf('Cake\Routing\RouteBuilder', $routes);
 			$this->assertEquals('/admin', $routes->path());
 			$this->assertEquals(['prefix' => 'admin'], $routes->params());
 		});
@@ -2669,7 +2623,7 @@ class RouterTest extends TestCase {
  */
 	public function testPlugin() {
 		Router::plugin('DebugKit', function($routes) {
-			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertInstanceOf('Cake\Routing\RouteBuilder', $routes);
 			$this->assertEquals('/debug_kit', $routes->path());
 			$this->assertEquals(['plugin' => 'DebugKit'], $routes->params());
 		});
@@ -2682,7 +2636,7 @@ class RouterTest extends TestCase {
  */
 	public function testPluginOptions() {
 		Router::plugin('DebugKit', ['path' => '/debugger'], function($routes) {
-			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertInstanceOf('Cake\Routing\RouteBuilder', $routes);
 			$this->assertEquals('/debugger', $routes->path());
 			$this->assertEquals(['plugin' => 'DebugKit'], $routes->params());
 		});