diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php index 00b426c..924c553 100644 --- a/core/lib/Drupal/Core/Routing/RouteCompiler.php +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -5,12 +5,11 @@ use Drupal\Component\Utility\Unicode; use Symfony\Component\Routing\RouteCompilerInterface; use Symfony\Component\Routing\Route; -use Symfony\Component\Routing\RouteCompiler as SymfonyRouteCompiler; /** * Compiler to generate derived information from a Route necessary for matching. */ -class RouteCompiler extends SymfonyRouteCompiler implements RouteCompilerInterface { +class RouteCompiler implements RouteCompilerInterface { /** * Utility constant to use for regular expressions against the path. @@ -28,26 +27,44 @@ class RouteCompiler extends SymfonyRouteCompiler implements RouteCompilerInterfa * @param \Symfony\Component\Routing\Route $route * A Route instance. * + * @throws \LogicException If a variable is referenced more than once + * @throws \DomainException If a variable name is numeric because PHP raises an error for such + * subpatterns in PCRE and thus would break matching, e.g. "(?P<123>.+)". + * * @return \Drupal\Core\Routing\CompiledRoute * A CompiledRoute instance. */ public static function compile(Route $route) { - // Split the path up on the slashes, ignoring multiple slashes in a row // or leading or trailing slashes. $parts = preg_split('@/+@', Unicode::strtolower($route->getPath()), NULL, PREG_SPLIT_NO_EMPTY); + + // The overall flow of this is copied from \Symfony\Component\Routing\RouteCompiler::compile + $hostVariables = []; + $host_variables = []; + $hostRegex = NULL; + $hostTokens = []; + + $host = $route->getHost(); + if ($host) { + // @todo - skip for now. + } + + $result = self::compilePattern($route, $parts, false); + + $path_variables = $result['variables']; + $host_and_path_variables = array_unique(array_merge($host_variables, $path_variables)); + // This seemingly round-about approach accounts for the fact that Drupal // may be installed on PHP without multibyte support, so we need to be // consistent and transform paths parts using Unicode::strtolower() to // perform any matching. - $lowered_path_route = clone($route); - $lowered_path_route->setPath('/' . implode('/', $parts)); - $symfony_compiled = parent::compile($lowered_path_route); + // The Drupal-specific compiled information. $stripped_path = static::getPathWithoutDefaults($route); $fit = static::getFit($stripped_path); - $pattern_outline = static::getPatternOutline($stripped_path); + $pattern_outline = Unicode::strtolower(static::getPatternOutline($stripped_path)); // We count the number of parts including any optional trailing parts. This // allows the RouteProvider to filter candidate routes more efficiently. $num_parts = count($parts); @@ -57,50 +74,141 @@ public static function compile(Route $route) { $pattern_outline, $num_parts, // These are the Symfony compiled parts. - $symfony_compiled->getStaticPrefix(), - $symfony_compiled->getRegex(), - $symfony_compiled->getTokens(), - static::getMappedPathVariables($parts, $symfony_compiled->getPathVariables()), - $symfony_compiled->getHostRegex(), - $symfony_compiled->getHostTokens(), - $symfony_compiled->getHostVariables(), - $symfony_compiled->getVariables() - ); + $result['staticPrefix'], + $result['regex'], + $result['tokens'], + $result['variables'], + $hostRegex, + $hostTokens, + $hostVariables, + $host_and_path_variables + ); + } + + protected static function compilePattern(Route $route, $parts, $isHost) { + // This seemingly round-about approach accounts for the fact that Drupal + // may be installed on PHP without multibyte support, so we need to be + // consistent and transform paths parts using Unicode::strtolower() to + // perform any matching. However, we want to retain the original path + // in the tokens since that's used in the UrlGenerator. + $regex_tokens = $tokens = []; + $variables = []; + $default_separator = $isHost ? '.' : '/'; + // The default regexp gets ++ which is a possessive qualifier which is + // faster since it avoids backtracking if the pattern stops matching. + $default_regexp = sprintf('[^%s]++', preg_quote($default_separator, self::REGEX_DELIMITER)); + $original_parts = preg_split('@/+@', $route->getPath(), NULL, PREG_SPLIT_NO_EMPTY); + + foreach ($parts as $idx => $part) { + if (preg_match('#^\{(\w+)\}$#', $part, $matches)) { + $varName = $matches[1]; + if (is_numeric($varName)) { + throw new \DomainException(sprintf('Variable name "%s" cannot be numeric in route pattern "%s". Please use a different name.', $varName, $route->getPath())); + } + if (in_array($varName, $variables)) { + throw new \LogicException(sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route->getPath(), $varName)); + } + // Maintain a mapping of path position like arg() from Drupal 7. + $variables[$idx] = $varName; + $regexp = $route->getRequirement($varName); + if (null === $regexp) { + $regexp = $default_regexp; + } + $tokens[$idx] = ['variable', $default_separator, $regexp, $varName]; + $regex_tokens[$idx] = $tokens[$idx]; + } + else { + $regex_tokens[$idx] = ['text', '/' . $part]; + $tokens[$idx] = ['text', '/' . $original_parts[$idx]]; + } + } + // find the first optional token + $firstOptional = PHP_INT_MAX; + if (!$isHost) { + for ($i = count($tokens) - 1; $i >= 0; --$i) { + $token = $tokens[$i]; + if ('variable' === $token[0] && $route->hasDefault($token[3])) { + $firstOptional = $i; + } + else { + break; + } + } + } + $regexp = ''; + $ft_idx = 0; + $final_tokens = []; + $prior = !empty($tokens[0]) ? $tokens[0][0] : FALSE; + for ($i = 0, $nbToken = count($tokens); $i < $nbToken; $i++) { + $regexp .= self::computeRegexp($regex_tokens, $i, $firstOptional); + // Rebuild the tokens with successive text sections merged like symfony. + if ($prior != $tokens[$i][0]) { + $ft_idx++; + $prior = $tokens[$i][0]; + } + if ('text' === $tokens[$i][0]) { + if (!isset($final_tokens[$ft_idx])) { + $final_tokens[$ft_idx] = ['text', '']; + } + $final_tokens[$ft_idx][1] .= $tokens[$i][1]; + } + else { + $final_tokens[$ft_idx] = $tokens[$i]; + // Always increment after finding a variable. + $prior = FALSE; + } + } + return [ + 'staticPrefix' => '', + 'regex' => self::REGEX_DELIMITER . '^' . $regexp . '$' .self::REGEX_DELIMITER . 's' . ($isHost ? 'i' : ''), + 'tokens' => array_reverse($final_tokens), + 'variables' => $variables, + ]; } /** - * Returns the mapping of route variables to numeric path part. + * Computes the regexp used to match a specific token. It can be static text or a subpattern. * - * Drupal only supports path wildcard (variables) that consist of a complete - * part of the path separated by slashes. + * @param array $tokens The route tokens + * @param int $index The index of the current token + * @param int $firstOptional The index of the first optional token * - * @see \Symfony\Component\Routing\RouteCompiler::compilePattern() - * - * @param array $parts - * The path parts for which we want the mapping. - * @param array $variables - * The names of placeholder variables in the path. - * - * @return array - * The mapping of numeric path part to variable name. + * @return string The regexp pattern for a single token */ - public static function getMappedPathVariables($parts, array $variables) { - $map = []; - foreach ($variables as $name) { - $index = array_search('{' . $name . '}', $parts, TRUE); - if ($index !== FALSE) { - $map[$index] = $name; + protected static function computeRegexp(array $tokens, $index, $firstOptional) { + $token = $tokens[$index]; + if ('text' === $token[0]) { + // Text tokens + return preg_quote($token[1], self::REGEX_DELIMITER); + } else { + // Variable tokens + if (0 === $index && 0 === $firstOptional) { + // When the only token is an optional variable token, the separator is required + return sprintf('%s(?P<%s>%s)?', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); + } else { + $regexp = sprintf('%s(?P<%s>%s)', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); + if ($index >= $firstOptional) { + // Enclose each optional token in a subpattern to make it optional. + // "?:" means it is non-capturing, i.e. the portion of the subject string that + // matched the optional subpattern is not passed back. + $regexp = "(?:$regexp"; + $nbTokens = count($tokens); + if ($nbTokens - 1 == $index) { + // Close the optional subpatterns + $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0)); + } + } + + return $regexp; } } - return $map; } /** * Returns the pattern outline. * * The pattern outline is the path pattern but normalized so that all - * placeholders are equal strings and default values are removed, and the path - * is converted to lowercase. + * placeholders are equal strings. * * @param string $path * The path for which we want the normalized outline. @@ -109,7 +217,7 @@ public static function getMappedPathVariables($parts, array $variables) { * The path pattern outline. */ public static function getPatternOutline($path) { - return Unicode::strtolower(preg_replace('#\{\w+\}#', '%', $path)); + return preg_replace('#\{\w+\}#', '%', $path); } /** diff --git a/core/tests/Drupal/Tests/Core/Routing/RouteCompilerTest.php b/core/tests/Drupal/Tests/Core/Routing/RouteCompilerTest.php index 271e313..134011a 100644 --- a/core/tests/Drupal/Tests/Core/Routing/RouteCompilerTest.php +++ b/core/tests/Drupal/Tests/Core/Routing/RouteCompilerTest.php @@ -4,6 +4,7 @@ use Drupal\Core\Routing\RouteCompiler; use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCompiler as SymfonyRouteCompiler; use Drupal\Tests\UnitTestCase; @@ -55,6 +56,7 @@ public function providerTestGetFit() { public function testCompilation() { $route = new Route('/test/{something}/more'); $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + /** @var \Drupal\Core\Routing\CompiledRoute $compiled */ $compiled = $route->compile(); $this->assertEquals($compiled->getFit(), 5 /* That's 101 binary*/, 'The fit was incorrect.'); @@ -71,10 +73,57 @@ public function testCompilationDefaultValue() { 'here' => 'there', )); $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + /** @var \Drupal\Core\Routing\CompiledRoute $compiled */ $compiled = $route->compile(); $this->assertEquals($compiled->getFit(), 5 /* That's 101 binary*/, 'The fit was not correct.'); $this->assertEquals($compiled->getPatternOutline(), '/test/%/more', 'The pattern outline was not correct.'); } + /** + * Test Drupal versus Symfony route compiler output. + * + * @param $path + * @param array $defaults + * @param array $requirements + * @param array $options + * + * @dataProvider routeCompilerTestDataProvider + */ + public function testDrupalVsSymfonyRouteCompiler($path, array $defaults = [], array $requirements = [], array $options = []) { + $route = new Route($path, $defaults, $requirements, $options); + $route->setOption('compiler_class', SymfonyRouteCompiler::class); + $symfony_compiled = $route->compile(); + // Setting an option resets the compiled route. + $route->setOption('compiler_class', RouteCompiler::class); + /** @var \Drupal\Core\Routing\CompiledRoute $compiled */ + $drupal_compiled = $route->compile(); + // For these examples we expect the same compilation result from both + // classes except for the prefix which Drupal does not use. + $this->assertEquals($symfony_compiled->getRegex(), $drupal_compiled->getRegex()); + $this->assertEquals($symfony_compiled->getVariables(), $drupal_compiled->getVariables()); + $this->assertEquals($symfony_compiled->getTokens(), $drupal_compiled->getTokens()); + } + + /** + * Some examples taken from \Symfony\Component\Routing\Tests\RouteCompilerTest + * @return array + */ + public function routeCompilerTestDataProvider() { + return [ + ['/foo'], + ['/foo/bar'], + ['/foo/bar/baz'], + ['/foo/{bar}'], + ['/foo/{bar}/baz'], + // Route with a variable that has a default value + ['/foo/{bar}', ['bar' => 'none']], + // Route with several variables + ['/foo/{bar}/{foobar}'], + // Route with several variables that have default values + ['/foo/{bar}/{foobar}', ['bar' => 'bar', 'foobar' => '']], + // Route with several variables but some of them have no default values + ['/foo/{bar}/{foobar}', ['bar' => 'bar']], + ]; + } }