diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php index a2aee6998d09d..70e1fb290597e 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php @@ -97,10 +97,10 @@ private function generateMatchMethod(bool $supportsRedirections): string foreach ($this->getRoutes()->all() as $name => $route) { if ($host = $route->getHost()) { $matchHost = true; - $host = '/'.str_replace('.', '/', rtrim(explode('}', strrev($host), 2)[0], '.')); + $host = '/'.strtr(strrev($host), '}.{', '(/)'); } - $routes->addRoute($host ?: '/', array($name, $route)); + $routes->addRoute($host ?: '/(.*)', array($name, $route)); } $routes = $matchHost ? $routes->populateCollection(new RouteCollection()) : $this->getRoutes(); @@ -139,7 +139,7 @@ private function compileRoutes(RouteCollection $routes, bool $supportsRedirectio while (true) { try { - $this->signalingException = new \RuntimeException('PCRE compilation failed: regular expression is too large'); + $this->signalingException = new \RuntimeException('preg_match(): Compilation failed: regular expression is too large'); $code .= $this->compileDynamicRoutes($dynamicRoutes, $supportsRedirections, $matchHost, $chunkLimit); break; } catch (\Exception $e) { @@ -377,7 +377,7 @@ private function compileDynamicRoutes(RouteCollection $collection, bool $support $state->regex .= $rx; // if the regex is too large, throw a signaling exception to recompute with smaller chunk size - set_error_handler(function ($type, $message) { throw $this->signalingException; }); + set_error_handler(function ($type, $message) { throw 0 === strpos($message, $this->signalingException->getMessage()) ? $this->signalingException : new \ErrorException($message); }); try { preg_match($state->regex, ''); } finally { diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/StaticPrefixCollection.php b/src/Symfony/Component/Routing/Matcher/Dumper/StaticPrefixCollection.php index dbc42caf52bde..a8ee045fe9bc7 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/StaticPrefixCollection.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/StaticPrefixCollection.php @@ -17,14 +17,18 @@ * Prefix tree of routes preserving routes order. * * @author Frank de Jonge + * @author Nicolas Grekas * * @internal */ class StaticPrefixCollection { private $prefix; - private $staticPrefix; - private $matchStart = 0; + + /** + * @var string[] + */ + private $staticPrefixes = array(); /** * @var string[] @@ -36,10 +40,9 @@ class StaticPrefixCollection */ private $items = array(); - public function __construct(string $prefix = '/', string $staticPrefix = '/') + public function __construct(string $prefix = '/') { $this->prefix = $prefix; - $this->staticPrefix = $staticPrefix; } public function getPrefix(): string @@ -60,41 +63,38 @@ public function getRoutes(): array * * @param array|self $route */ - public function addRoute(string $prefix, $route) + public function addRoute(string $prefix, $route, string $staticPrefix = null) { $this->guardAgainstAddingNotAcceptedRoutes($prefix); - list($prefix, $staticPrefix) = $this->detectCommonPrefix($prefix, $prefix) ?: array(rtrim($prefix, '/') ?: '/', '/'); - - if ($this->staticPrefix === $staticPrefix) { - // When a prefix is exactly the same as the base we move up the match start position. - // This is needed because otherwise routes that come afterwards have higher precedence - // than a possible regular expression, which goes against the input order sorting. - $this->prefixes[] = $prefix; - $this->items[] = $route; - $this->matchStart = count($this->items); - - return; + if (null === $staticPrefix) { + list($prefix, $staticPrefix) = $this->getCommonPrefix($prefix, $prefix); } - for ($i = $this->matchStart; $i < \count($this->items); ++$i) { + for ($i = \count($this->items) - 1; 0 <= $i; --$i) { $item = $this->items[$i]; if ($item instanceof self && $item->accepts($prefix)) { - $item->addRoute($prefix, $route); + $item->addRoute($prefix, $route, $staticPrefix); return; } - if ($group = $this->groupWithItem($i, $prefix, $route)) { - $this->prefixes[$i] = $group->getPrefix(); - $this->items[$i] = $group; - + if ($this->groupWithItem($i, $prefix, $staticPrefix, $route)) { return; } + + if ($this->staticPrefixes[$i] !== $this->prefixes[$i] && 0 === strpos($staticPrefix, $this->staticPrefixes[$i])) { + break; + } + + if ($staticPrefix !== $prefix && 0 === strpos($this->staticPrefixes[$i], $staticPrefix)) { + break; + } } // No optimised case was found, in this case we simple add the route for possible // grouping when new routes are added. + $this->staticPrefixes[] = $staticPrefix; $this->prefixes[] = $prefix; $this->items[] = $route; } @@ -118,25 +118,25 @@ public function populateCollection(RouteCollection $routes): RouteCollection /** * Tries to combine a route with another route or group. */ - private function groupWithItem(int $i, string $prefix, $route): ?self + private function groupWithItem(int $i, string $prefix, string $staticPrefix, $route): bool { - if (!$commonPrefix = $this->detectCommonPrefix($prefix, $this->prefixes[$i])) { - return null; + list($commonPrefix, $commonStaticPrefix) = $this->getCommonPrefix($prefix, $this->prefixes[$i]); + + if (\strlen($this->prefix) >= \strlen($commonPrefix)) { + return false; } - $child = new self(...$commonPrefix); - $item = $this->items[$i]; + $child = new self($commonPrefix); - if ($item instanceof self) { - $child->prefixes = array($commonPrefix[0]); - $child->items = array($item); - } else { - $child->addRoute($this->prefixes[$i], $item); - } + $child->staticPrefixes = array($this->staticPrefixes[$i], $staticPrefix); + $child->prefixes = array($this->prefixes[$i], $prefix); + $child->items = array($this->items[$i], $route); - $child->addRoute($prefix, $route); + $this->staticPrefixes[$i] = $commonStaticPrefix; + $this->prefixes[$i] = $commonPrefix; + $this->items[$i] = $child; - return $child; + return true; } /** @@ -144,18 +144,18 @@ private function groupWithItem(int $i, string $prefix, $route): ?self */ private function accepts(string $prefix): bool { - return '' === $this->prefix || 0 === strpos($prefix, $this->prefix); + return 0 === strpos($prefix, $this->prefix) && '?' !== ($prefix[\strlen($this->prefix)] ?? ''); } /** - * Detects whether there's a common prefix relative to the group prefix and returns it. + * Gets the full and static common prefixes between two route patterns. * - * @return null|array A common prefix, longer than the base/group prefix, or null when none available + * The static prefix stops at last at the first opening bracket. */ - private function detectCommonPrefix(string $prefix, string $anotherPrefix): ?array + private function getCommonPrefix(string $prefix, string $anotherPrefix): array { - $baseLength = strlen($this->prefix); - $end = min(strlen($prefix), strlen($anotherPrefix)); + $baseLength = \strlen($this->prefix); + $end = min(\strlen($prefix), \strlen($anotherPrefix)); $staticLength = null; for ($i = $baseLength; $i < $end && $prefix[$i] === $anotherPrefix[$i]; ++$i) { @@ -177,21 +177,23 @@ private function detectCommonPrefix(string $prefix, string $anotherPrefix): ?arr if (0 < $n) { break; } - $i = $j; + if (('?' === ($prefix[$j] ?? '') || '?' === ($anotherPrefix[$j] ?? '')) && ($prefix[$j] ?? '') !== ($anotherPrefix[$j] ?? '')) { + break; + } + $i = $j - 1; } elseif ('\\' === $prefix[$i] && (++$i === $end || $prefix[$i] !== $anotherPrefix[$i])) { --$i; break; } } - - $staticLength = $staticLength ?? $i; - $commonPrefix = rtrim(substr($prefix, 0, $i), '/'); - - if (strlen($commonPrefix) > $baseLength) { - return array($commonPrefix, rtrim(substr($prefix, 0, $staticLength), '/') ?: '/'); + if (1 < $i && '/' === $prefix[$i - 1]) { + --$i; + } + if (null !== $staticLength && 1 < $staticLength && '/' === $prefix[$staticLength - 1]) { + --$staticLength; } - return null; + return array(substr($prefix, 0, $i), substr($prefix, 0, $staticLength ?? $i)); } /** diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php index d3d2826f5fb2b..a998fcc1f3a46 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php @@ -98,23 +98,25 @@ public function match($rawPathinfo) .')' .')' .'|/multi/hello(?:/([^/]++))?(*:238)' - .'|/([^/]++)/b/([^/]++)(*:266)' - .'|/([^/]++)/b/([^/]++)(*:294)' - .'|/aba/([^/]++)(*:315)' + .'|/([^/]++)/b/([^/]++)(?' + .'|(*:269)' + .'|(*:277)' + .')' + .'|/aba/([^/]++)(*:299)' .')|(?i:([^\\.]++)\\.example\\.com)(?' .'|/route1(?' - .'|3/([^/]++)(*:375)' - .'|4/([^/]++)(*:393)' + .'|3/([^/]++)(*:359)' + .'|4/([^/]++)(*:377)' .')' .')|(?i:c\\.example\\.com)(?' - .'|/route15/([^/]++)(*:443)' + .'|/route15/([^/]++)(*:427)' .')|[^/]*+(?' - .'|/route16/([^/]++)(*:478)' + .'|/route16/([^/]++)(*:462)' .'|/a(?' - .'|/a\\.\\.\\.(*:499)' + .'|/a\\.\\.\\.(*:483)' .'|/b(?' - .'|/([^/]++)(*:521)' - .'|/c/([^/]++)(*:540)' + .'|/([^/]++)(*:505)' + .'|/c/([^/]++)(*:524)' .')' .')' .')' @@ -172,7 +174,7 @@ public function match($rawPathinfo) return $this->mergeDefaults(array_replace($matches, array('_route' => 'foo2')), array()); break; - case 266: + case 269: $matches = array('_locale' => $matches[1] ?? null, 'foo' => $matches[2] ?? null); // foo3 @@ -189,15 +191,15 @@ public function match($rawPathinfo) 170 => array(array('_route' => 'overridden'), array('var'), null, null), 202 => array(array('_route' => 'bar2'), array('bar1'), null, null), 238 => array(array('_route' => 'helloWorld', 'who' => 'World!'), array('who'), null, null), - 294 => array(array('_route' => 'bar3'), array('_locale', 'bar'), null, null), - 315 => array(array('_route' => 'foo4'), array('foo'), null, null), - 375 => array(array('_route' => 'route13'), array('var1', 'name'), null, null), - 393 => array(array('_route' => 'route14', 'var1' => 'val'), array('var1', 'name'), null, null), - 443 => array(array('_route' => 'route15'), array('name'), null, null), - 478 => array(array('_route' => 'route16', 'var1' => 'val'), array('name'), null, null), - 499 => array(array('_route' => 'a'), array(), null, null), - 521 => array(array('_route' => 'b'), array('var'), null, null), - 540 => array(array('_route' => 'c'), array('var'), null, null), + 277 => array(array('_route' => 'bar3'), array('_locale', 'bar'), null, null), + 299 => array(array('_route' => 'foo4'), array('foo'), null, null), + 359 => array(array('_route' => 'route13'), array('var1', 'name'), null, null), + 377 => array(array('_route' => 'route14', 'var1' => 'val'), array('var1', 'name'), null, null), + 427 => array(array('_route' => 'route15'), array('name'), null, null), + 462 => array(array('_route' => 'route16', 'var1' => 'val'), array('name'), null, null), + 483 => array(array('_route' => 'a'), array(), null, null), + 505 => array(array('_route' => 'b'), array('var'), null, null), + 524 => array(array('_route' => 'c'), array('var'), null, null), ); list($ret, $vars, $requiredMethods, $requiredSchemes) = $routes[$m]; @@ -216,7 +218,7 @@ public function match($rawPathinfo) return $ret; } - if (540 === $m) { + if (524 === $m) { break; } $regex = substr_replace($regex, 'F', $m - $offset, 1 + strlen($m)); diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher11.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher11.php new file mode 100644 index 0000000000000..a9902498a1348 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher11.php @@ -0,0 +1,127 @@ +context = $context; + } + + public function match($rawPathinfo) + { + $allow = array(); + $pathinfo = rawurldecode($rawPathinfo); + $trimmedPathinfo = rtrim($pathinfo, '/'); + $context = $this->context; + $requestMethod = $canonicalMethod = $context->getMethod(); + + if ('HEAD' === $requestMethod) { + $canonicalMethod = 'GET'; + } + + $matchedPathinfo = $pathinfo; + $regexList = array( + 0 => '{^(?' + .'|/(en|fr)(?' + .'|/admin/post(?' + .'|/?(*:34)' + .'|/new(*:45)' + .'|/(\\d+)(?' + .'|(*:61)' + .'|/edit(*:73)' + .'|/delete(*:87)' + .')' + .')' + .'|/blog(?' + .'|/?(*:106)' + .'|/rss\\.xml(*:123)' + .'|/p(?' + .'|age/([^/]++)(*:148)' + .'|osts/([^/]++)(*:169)' + .')' + .'|/comments/(\\d+)/new(*:197)' + .'|/search(*:212)' + .')' + .'|/log(?' + .'|in(*:230)' + .'|out(*:241)' + .')' + .')' + .'|/(en|fr)?(*:260)' + .')$}sD', + ); + + foreach ($regexList as $offset => $regex) { + while (preg_match($regex, $matchedPathinfo, $matches)) { + switch ($m = (int) $matches['MARK']) { + default: + $routes = array( + 34 => array(array('_route' => 'a', '_locale' => 'en'), array('_locale'), null, null, true), + 45 => array(array('_route' => 'b', '_locale' => 'en'), array('_locale'), null, null), + 61 => array(array('_route' => 'c', '_locale' => 'en'), array('_locale', 'id'), null, null), + 73 => array(array('_route' => 'd', '_locale' => 'en'), array('_locale', 'id'), null, null), + 87 => array(array('_route' => 'e', '_locale' => 'en'), array('_locale', 'id'), null, null), + 106 => array(array('_route' => 'f', '_locale' => 'en'), array('_locale'), null, null, true), + 123 => array(array('_route' => 'g', '_locale' => 'en'), array('_locale'), null, null), + 148 => array(array('_route' => 'h', '_locale' => 'en'), array('_locale', 'page'), null, null), + 169 => array(array('_route' => 'i', '_locale' => 'en'), array('_locale', 'page'), null, null), + 197 => array(array('_route' => 'j', '_locale' => 'en'), array('_locale', 'id'), null, null), + 212 => array(array('_route' => 'k', '_locale' => 'en'), array('_locale'), null, null), + 230 => array(array('_route' => 'l', '_locale' => 'en'), array('_locale'), null, null), + 241 => array(array('_route' => 'm', '_locale' => 'en'), array('_locale'), null, null), + 260 => array(array('_route' => 'n', '_locale' => 'en'), array('_locale'), null, null), + ); + + list($ret, $vars, $requiredMethods, $requiredSchemes) = $routes[$m]; + + foreach ($vars as $i => $v) { + if (isset($matches[1 + $i])) { + $ret[$v] = $matches[1 + $i]; + } + } + + if (empty($routes[$m][4]) || '/' === $pathinfo[-1]) { + // no-op + } elseif ('GET' !== $canonicalMethod) { + $allow['GET'] = 'GET'; + break; + } else { + return array_replace($ret, $this->redirect($rawPathinfo.'/', $ret['_route'])); + } + + if ($requiredSchemes && !isset($requiredSchemes[$context->getScheme()])) { + if ('GET' !== $canonicalMethod) { + $allow['GET'] = 'GET'; + break; + } + + return array_replace($ret, $this->redirect($rawPathinfo, $ret['_route'], key($requiredSchemes))); + } + + if ($requiredMethods && !isset($requiredMethods[$canonicalMethod]) && !isset($requiredMethods[$requestMethod])) { + $allow += $requiredMethods; + break; + } + + return $ret; + } + + if (260 === $m) { + break; + } + $regex = substr_replace($regex, 'F', $m - $offset, 1 + strlen($m)); + $offset += strlen($m); + } + } + + throw $allow ? new MethodNotAllowedException(array_keys($allow)) : new ResourceNotFoundException(); + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php index e15535a78e849..9e204494f7d7c 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php @@ -118,23 +118,25 @@ public function match($rawPathinfo) .')' .')' .'|/multi/hello(?:/([^/]++))?(*:239)' - .'|/([^/]++)/b/([^/]++)(*:267)' - .'|/([^/]++)/b/([^/]++)(*:295)' - .'|/aba/([^/]++)(*:316)' + .'|/([^/]++)/b/([^/]++)(?' + .'|(*:270)' + .'|(*:278)' + .')' + .'|/aba/([^/]++)(*:300)' .')|(?i:([^\\.]++)\\.example\\.com)(?' .'|/route1(?' - .'|3/([^/]++)(*:376)' - .'|4/([^/]++)(*:394)' + .'|3/([^/]++)(*:360)' + .'|4/([^/]++)(*:378)' .')' .')|(?i:c\\.example\\.com)(?' - .'|/route15/([^/]++)(*:444)' + .'|/route15/([^/]++)(*:428)' .')|[^/]*+(?' - .'|/route16/([^/]++)(*:479)' + .'|/route16/([^/]++)(*:463)' .'|/a(?' - .'|/a\\.\\.\\.(*:500)' + .'|/a\\.\\.\\.(*:484)' .'|/b(?' - .'|/([^/]++)(*:522)' - .'|/c/([^/]++)(*:541)' + .'|/([^/]++)(*:506)' + .'|/c/([^/]++)(*:525)' .')' .')' .')' @@ -207,7 +209,7 @@ public function match($rawPathinfo) return $this->mergeDefaults(array_replace($matches, array('_route' => 'foo2')), array()); break; - case 267: + case 270: $matches = array('_locale' => $matches[1] ?? null, 'foo' => $matches[2] ?? null); // foo3 @@ -224,15 +226,15 @@ public function match($rawPathinfo) 171 => array(array('_route' => 'overridden'), array('var'), null, null), 203 => array(array('_route' => 'bar2'), array('bar1'), null, null), 239 => array(array('_route' => 'helloWorld', 'who' => 'World!'), array('who'), null, null), - 295 => array(array('_route' => 'bar3'), array('_locale', 'bar'), null, null), - 316 => array(array('_route' => 'foo4'), array('foo'), null, null), - 376 => array(array('_route' => 'route13'), array('var1', 'name'), null, null), - 394 => array(array('_route' => 'route14', 'var1' => 'val'), array('var1', 'name'), null, null), - 444 => array(array('_route' => 'route15'), array('name'), null, null), - 479 => array(array('_route' => 'route16', 'var1' => 'val'), array('name'), null, null), - 500 => array(array('_route' => 'a'), array(), null, null), - 522 => array(array('_route' => 'b'), array('var'), null, null), - 541 => array(array('_route' => 'c'), array('var'), null, null), + 278 => array(array('_route' => 'bar3'), array('_locale', 'bar'), null, null), + 300 => array(array('_route' => 'foo4'), array('foo'), null, null), + 360 => array(array('_route' => 'route13'), array('var1', 'name'), null, null), + 378 => array(array('_route' => 'route14', 'var1' => 'val'), array('var1', 'name'), null, null), + 428 => array(array('_route' => 'route15'), array('name'), null, null), + 463 => array(array('_route' => 'route16', 'var1' => 'val'), array('name'), null, null), + 484 => array(array('_route' => 'a'), array(), null, null), + 506 => array(array('_route' => 'b'), array('var'), null, null), + 525 => array(array('_route' => 'c'), array('var'), null, null), ); list($ret, $vars, $requiredMethods, $requiredSchemes) = $routes[$m]; @@ -260,7 +262,7 @@ public function match($rawPathinfo) return $ret; } - if (541 === $m) { + if (525 === $m) { break; } $regex = substr_replace($regex, 'F', $m - $offset, 1 + strlen($m)); diff --git a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php index 4720236b9cbff..6032fbdd3c03b 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php @@ -438,6 +438,26 @@ public function getRouteCollections() $chunkedCollection->add('_'.$i, new Route('/'.$h.'/{a}/{b}/{c}/'.$h)); } + /* test case 11 */ + $demoCollection = new RouteCollection(); + $demoCollection->add('a', new Route('/admin/post/')); + $demoCollection->add('b', new Route('/admin/post/new')); + $demoCollection->add('c', (new Route('/admin/post/{id}'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('d', (new Route('/admin/post/{id}/edit'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('e', (new Route('/admin/post/{id}/delete'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('f', new Route('/blog/')); + $demoCollection->add('g', new Route('/blog/rss.xml')); + $demoCollection->add('h', (new Route('/blog/page/{page}'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('i', (new Route('/blog/posts/{page}'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('j', (new Route('/blog/comments/{id}/new'))->setRequirements(array('id' => '\d+'))); + $demoCollection->add('k', new Route('/blog/search')); + $demoCollection->add('l', new Route('/login')); + $demoCollection->add('m', new Route('/logout')); + $demoCollection->addPrefix('/{_locale}'); + $demoCollection->add('n', new Route('/{_locale}')); + $demoCollection->addRequirements(array('_locale' => 'en|fr')); + $demoCollection->addDefaults(array('_locale' => 'en')); + return array( array(new RouteCollection(), 'url_matcher0.php', array()), array($collection, 'url_matcher1.php', array()), @@ -450,6 +470,7 @@ public function getRouteCollections() array($unicodeCollection, 'url_matcher8.php', array()), array($hostTreeCollection, 'url_matcher9.php', array()), array($chunkedCollection, 'url_matcher10.php', array()), + array($demoCollection, 'url_matcher11.php', array('base_class' => 'Symfony\Component\Routing\Tests\Fixtures\RedirectableUrlMatcher')), ); } diff --git a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php index ea3ff6ff4dc16..e23344a8156bf 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php @@ -86,7 +86,7 @@ public function routeProvider() array('/group/aa/', 'aa'), array('/group/bb/', 'bb'), array('/group/cc/', 'cc'), - array('/', 'root'), + array('/(.*)', 'root'), array('/group/dd/', 'dd'), array('/group/ee/', 'ee'), array('/group/ff/', 'ff'),