* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Routing\Matcher\Dumper; use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Routing\Matcher\RedirectableUrlMatcherInterface; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; /** * PhpMatcherDumper creates a PHP class able to match URLs for a given set of routes. * * @author Fabien Potencier * @author Tobias Schultze * @author Arnaud Le Blanc * @author Nicolas Grekas */ class PhpMatcherDumper extends MatcherDumper { private $expressionLanguage; private $signalingException; /** * @var ExpressionFunctionProviderInterface[] */ private $expressionLanguageProviders = array(); /** * Dumps a set of routes to a PHP class. * * Available options: * * * class: The class name * * base_class: The base class name * * @param array $options An array of options * * @return string A PHP class representing the matcher class */ public function dump(array $options = array()) { $options = array_replace(array( 'class' => 'ProjectUrlMatcher', 'base_class' => 'Symfony\\Component\\Routing\\Matcher\\UrlMatcher', ), $options); // trailing slash support is only enabled if we know how to redirect the user $interfaces = class_implements($options['base_class']); $supportsRedirections = isset($interfaces[RedirectableUrlMatcherInterface::class]); return <<context = \$context; } {$this->generateMatchMethod($supportsRedirections)} } EOF; } public function addExpressionLanguageProvider(ExpressionFunctionProviderInterface $provider) { $this->expressionLanguageProviders[] = $provider; } /** * Generates the code for the match method implementing UrlMatcherInterface. */ private function generateMatchMethod(bool $supportsRedirections): string { // Group hosts by same-suffix, re-order when possible $matchHost = false; $routes = new StaticPrefixCollection(); foreach ($this->getRoutes()->all() as $name => $route) { if ($host = $route->getHost()) { $matchHost = true; $host = '/'.strtr(strrev($host), '}.{', '(/)'); } $routes->addRoute($host ?: '/(.*)', array($name, $route)); } $routes = $matchHost ? $routes->populateCollection(new RouteCollection()) : $this->getRoutes(); $code = rtrim($this->compileRoutes($routes, $matchHost), "\n"); $fetchHost = $matchHost ? " \$host = strtolower(\$context->getHost());\n" : ''; $code = <<context; \$requestMethod = \$canonicalMethod = \$context->getMethod(); {$fetchHost} if ('HEAD' === \$requestMethod) { \$canonicalMethod = 'GET'; } $code EOF; if ($supportsRedirections) { return <<<'EOF' public function match($pathinfo) { $allow = $allowSchemes = array(); if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) { return $ret; } if ($allow) { throw new MethodNotAllowedException(array_keys($allow)); } if (!in_array($this->context->getMethod(), array('HEAD', 'GET'), true)) { // no-op } elseif ($allowSchemes) { redirect_scheme: $scheme = $this->context->getScheme(); $this->context->setScheme(key($allowSchemes)); try { if ($ret = $this->doMatch($pathinfo)) { return $this->redirect($pathinfo, $ret['_route'], $this->context->getScheme()) + $ret; } } finally { $this->context->setScheme($scheme); } } elseif ('/' !== $pathinfo) { $pathinfo = '/' !== $pathinfo[-1] ? $pathinfo.'/' : substr($pathinfo, 0, -1); if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) { return $this->redirect($pathinfo, $ret['_route']) + $ret; } if ($allowSchemes) { goto redirect_scheme; } } throw new ResourceNotFoundException(); } private function doMatch(string $rawPathinfo, array &$allow = array(), array &$allowSchemes = array()): ?array EOF .$code."\n return null;\n }"; } return " public function match(\$rawPathinfo)\n".$code."\n throw \$allow ? new MethodNotAllowedException(array_keys(\$allow)) : new ResourceNotFoundException();\n }"; } /** * Generates PHP code to match a RouteCollection with all its routes. */ private function compileRoutes(RouteCollection $routes, bool $matchHost): string { list($staticRoutes, $dynamicRoutes) = $this->groupStaticRoutes($routes); $code = $this->compileStaticRoutes($staticRoutes, $matchHost); $chunkLimit = \count($dynamicRoutes); while (true) { try { $this->signalingException = new \RuntimeException('preg_match(): Compilation failed: regular expression is too large'); $code .= $this->compileDynamicRoutes($dynamicRoutes, $matchHost, $chunkLimit); break; } catch (\Exception $e) { if (1 < $chunkLimit && $this->signalingException === $e) { $chunkLimit = 1 + ($chunkLimit >> 1); continue; } throw $e; } } // used to display the Welcome Page in apps that don't define a homepage $code .= " if ('/' === \$pathinfo && !\$allow && !\$allowSchemes) {\n"; $code .= " throw new Symfony\Component\Routing\Exception\NoConfigurationException();\n"; $code .= " }\n"; return $code; } /** * Splits static routes from dynamic routes, so that they can be matched first, using a simple switch. */ private function groupStaticRoutes(RouteCollection $collection): array { $staticRoutes = $dynamicRegex = array(); $dynamicRoutes = new RouteCollection(); foreach ($collection->all() as $name => $route) { $compiledRoute = $route->compile(); $hostRegex = $compiledRoute->getHostRegex(); $regex = $compiledRoute->getRegex(); if (!$compiledRoute->getPathVariables()) { $host = !$compiledRoute->getHostVariables() ? $route->getHost() : ''; $url = $route->getPath(); foreach ($dynamicRegex as list($hostRx, $rx)) { if (preg_match($rx, $url) && (!$host || !$hostRx || preg_match($hostRx, $host))) { $dynamicRegex[] = array($hostRegex, $regex); $dynamicRoutes->add($name, $route); continue 2; } } $staticRoutes[$url][$name] = $route; } else { $dynamicRegex[] = array($hostRegex, $regex); $dynamicRoutes->add($name, $route); } } return array($staticRoutes, $dynamicRoutes); } /** * Compiles static routes in a switch statement. * * Condition-less paths are put in a static array in the switch's default, with generic matching logic. * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. * * @throws \LogicException */ private function compileStaticRoutes(array $staticRoutes, bool $matchHost): string { if (!$staticRoutes) { return ''; } $code = $default = ''; foreach ($staticRoutes as $url => $routes) { if (1 === \count($routes)) { foreach ($routes as $name => $route) { } if (!$route->getCondition()) { $defaults = $route->getDefaults(); if (isset($defaults['_canonical_route'])) { $name = $defaults['_canonical_route']; unset($defaults['_canonical_route']); } $default .= sprintf( "%s => array(%s, %s, %s, %s),\n", self::export($url), self::export(array('_route' => $name) + $defaults), self::export(!$route->compile()->getHostVariables() ? $route->getHost() : $route->compile()->getHostRegex() ?: null), self::export(array_flip($route->getMethods()) ?: null), self::export(array_flip($route->getSchemes()) ?: null) ); continue; } } $code .= sprintf(" case %s:\n", self::export($url)); foreach ($routes as $name => $route) { $code .= $this->compileRoute($route, $name, true); } $code .= " break;\n"; } if ($default) { $code .= <<indent($default, 4)} ); if (!isset(\$routes[\$pathinfo])) { break; } list(\$ret, \$requiredHost, \$requiredMethods, \$requiredSchemes) = \$routes[\$pathinfo]; {$this->compileSwitchDefault(false, $matchHost)} EOF; } return sprintf(" switch (\$pathinfo) {\n%s }\n\n", $this->indent($code)); } /** * Compiles a regular expression followed by a switch statement to match dynamic routes. * * The regular expression matches both the host and the pathinfo at the same time. For stellar performance, * it is built as a tree of patterns, with re-ordering logic to group same-prefix routes together when possible. * * Patterns are named so that we know which one matched (https://pcre.org/current/doc/html/pcre2syntax.html#SEC23). * This name is used to "switch" to the additional logic required to match the final route. * * Condition-less paths are put in a static array in the switch's default, with generic matching logic. * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. * * Last but not least: * - Because it is not possibe to mix unicode/non-unicode patterns in a single regexp, several of them can be generated. * - The same regexp can be used several times when the logic in the switch rejects the match. When this happens, the * matching-but-failing subpattern is blacklisted by replacing its name by "(*F)", which forces a failure-to-match. * To ease this backlisting operation, the name of subpatterns is also the string offset where the replacement should occur. */ private function compileDynamicRoutes(RouteCollection $collection, bool $matchHost, int $chunkLimit): string { if (!$collection->all()) { return ''; } $code = ''; $state = (object) array( 'regex' => '', 'switch' => '', 'default' => '', 'mark' => 0, 'markTail' => 0, 'hostVars' => array(), 'vars' => array(), ); $state->getVars = static function ($m) use ($state) { if ('_route' === $m[1]) { return '?:'; } $state->vars[] = $m[1]; return ''; }; $chunkSize = 0; $prev = null; $perModifiers = array(); foreach ($collection->all() as $name => $route) { preg_match('#[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); if ($chunkLimit < ++$chunkSize || $prev !== $rx[0] && $route->compile()->getPathVariables()) { $chunkSize = 1; $routes = new RouteCollection(); $perModifiers[] = array($rx[0], $routes); $prev = $rx[0]; } $routes->add($name, $route); } foreach ($perModifiers as list($modifiers, $routes)) { $prev = false; $perHost = array(); foreach ($routes->all() as $name => $route) { $regex = $route->compile()->getHostRegex(); if ($prev !== $regex) { $routes = new RouteCollection(); $perHost[] = array($regex, $routes); $prev = $regex; } $routes->add($name, $route); } $prev = false; $rx = '{^(?'; $code .= "\n {$state->mark} => ".self::export($rx); $state->mark += \strlen($rx); $state->regex = $rx; foreach ($perHost as list($hostRegex, $routes)) { if ($matchHost) { if ($hostRegex) { preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $hostRegex, $rx); $state->vars = array(); $hostRegex = '(?i:'.preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]).')\.'; $state->hostVars = $state->vars; } else { $hostRegex = '(?:(?:[^./]*+\.)++)'; $state->hostVars = array(); } $state->mark += \strlen($rx = ($prev ? ')' : '')."|{$hostRegex}(?"); $code .= "\n .".self::export($rx); $state->regex .= $rx; $prev = true; } $tree = new StaticPrefixCollection(); foreach ($routes->all() as $name => $route) { preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); $state->vars = array(); $regex = preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]); $tree->addRoute($regex, array($name, $regex, $state->vars, $route)); } $code .= $this->compileStaticPrefixCollection($tree, $state); } if ($matchHost) { $code .= "\n .')'"; $state->regex .= ')'; } $rx = ")$}{$modifiers}"; $code .= "\n .'{$rx}',"; $state->regex .= $rx; $state->markTail = 0; // if the regex is too large, throw a signaling exception to recompute with smaller chunk size set_error_handler(function ($type, $message) { throw 0 === strpos($message, $this->signalingException->getMessage()) ? $this->signalingException : new \ErrorException($message); }); try { preg_match($state->regex, ''); } finally { restore_error_handler(); } } if ($state->default) { $state->switch .= <<indent($state->default, 4)} ); list(\$ret, \$vars, \$requiredMethods, \$requiredSchemes) = \$routes[\$m]; {$this->compileSwitchDefault(true, $matchHost)} EOF; } $matchedPathinfo = $matchHost ? '$host.\'.\'.$pathinfo' : '$pathinfo'; unset($state->getVars); return << \$regex) { while (preg_match(\$regex, \$matchedPathinfo, \$matches)) { switch (\$m = (int) \$matches['MARK']) { {$this->indent($state->switch, 3)} } if ({$state->mark} === \$m) { break; } \$regex = substr_replace(\$regex, 'F', \$m - \$offset, 1 + strlen(\$m)); \$offset += strlen(\$m); } } EOF; } /** * Compiles a regexp tree of subpatterns that matches nested same-prefix routes. * * @param \stdClass $state A simple state object that keeps track of the progress of the compilation, * and gathers the generated switch's "case" and "default" statements */ private function compileStaticPrefixCollection(StaticPrefixCollection $tree, \stdClass $state, int $prefixLen = 0): string { $code = ''; $prevRegex = null; $routes = $tree->getRoutes(); foreach ($routes as $i => $route) { if ($route instanceof StaticPrefixCollection) { $prevRegex = null; $prefix = substr($route->getPrefix(), $prefixLen); $state->mark += \strlen($rx = "|{$prefix}(?"); $code .= "\n .".self::export($rx); $state->regex .= $rx; $code .= $this->indent($this->compileStaticPrefixCollection($route, $state, $prefixLen + \strlen($prefix))); $code .= "\n .')'"; $state->regex .= ')'; ++$state->markTail; continue; } list($name, $regex, $vars, $route) = $route; $compiledRoute = $route->compile(); if ($compiledRoute->getRegex() === $prevRegex) { $state->switch = substr_replace($state->switch, $this->compileRoute($route, $name, false)."\n", -19, 0); continue; } $state->mark += 3 + $state->markTail + \strlen($regex) - $prefixLen; $state->markTail = 2 + \strlen($state->mark); $rx = sprintf('|%s(*:%s)', substr($regex, $prefixLen), $state->mark); $code .= "\n .".self::export($rx); $state->regex .= $rx; $vars = array_merge($state->hostVars, $vars); if (!$route->getCondition() && (!\is_array($next = $routes[1 + $i] ?? null) || $regex !== $next[1])) { $prevRegex = null; $defaults = $route->getDefaults(); if (isset($defaults['_canonical_route'])) { $name = $defaults['_canonical_route']; unset($defaults['_canonical_route']); } $state->default .= sprintf( "%s => array(%s, %s, %s, %s),\n", $state->mark, self::export(array('_route' => $name) + $defaults), self::export($vars), self::export(array_flip($route->getMethods()) ?: null), self::export(array_flip($route->getSchemes()) ?: null) ); } else { $prevRegex = $compiledRoute->getRegex(); $combine = ' $matches = array('; foreach ($vars as $j => $m) { $combine .= sprintf('%s => $matches[%d] ?? null, ', self::export($m), 1 + $j); } $combine = $vars ? substr_replace($combine, ");\n\n", -2) : ''; $state->switch .= <<mark}: {$combine}{$this->compileRoute($route, $name, false)} break; EOF; } } return $code; } /** * A simple helper to compiles the switch's "default" for both static and dynamic routes. */ private function compileSwitchDefault(bool $hasVars, bool $matchHost): string { if ($hasVars) { $code = << \$v) { if (isset(\$matches[1 + \$i])) { \$ret[\$v] = \$matches[1 + \$i]; } } EOF; } elseif ($matchHost) { $code = <<mergeDefaults(\$hostMatches, \$ret); } } EOF; } else { $code = ''; } $code .= <<getScheme()]); if (\$requiredMethods && !isset(\$requiredMethods[\$canonicalMethod]) && !isset(\$requiredMethods[\$requestMethod])) { if (\$hasRequiredScheme) { \$allow += \$requiredMethods; } break; } if (!\$hasRequiredScheme) { \$allowSchemes += \$requiredSchemes; break; } return \$ret; EOF; return $code; } /** * Compiles a single Route to PHP code used to match it against the path info. * * @throws \LogicException */ private function compileRoute(Route $route, string $name, bool $checkHost): string { $code = ''; $compiledRoute = $route->compile(); $conditions = array(); $matches = (bool) $compiledRoute->getPathVariables(); $hostMatches = (bool) $compiledRoute->getHostVariables(); $methods = array_flip($route->getMethods()); if ($route->getCondition()) { $expression = $this->getExpressionLanguage()->compile($route->getCondition(), array('context', 'request')); if (false !== strpos($expression, '$request')) { $conditions[] = '($request = $request ?? $this->request ?: $this->createRequest($pathinfo))'; } $conditions[] = $expression; } if (!$checkHost || !$compiledRoute->getHostRegex()) { // no-op } elseif ($hostMatches) { $conditions[] = sprintf('preg_match(%s, $host, $hostMatches)', self::export($compiledRoute->getHostRegex())); } else { $conditions[] = sprintf('%s === $host', self::export($route->getHost())); } $conditions = implode(' && ', $conditions); if ($conditions) { $code .= <<getDefaults(); if (isset($defaults['_canonical_route'])) { $name = $defaults['_canonical_route']; unset($defaults['_canonical_route']); } // optimize parameters array if ($matches || $hostMatches) { $vars = array("array('_route' => '$name')"); if ($matches || ($hostMatches && !$checkHost)) { $vars[] = '$matches'; } if ($hostMatches && $checkHost) { $vars[] = '$hostMatches'; } $code .= sprintf( " \$ret = \$this->mergeDefaults(%s, %s);\n", implode(' + ', $vars), self::export($defaults) ); } elseif ($defaults) { $code .= sprintf(" \$ret = %s;\n", self::export(array('_route' => $name) + $defaults)); } else { $code .= sprintf(" \$ret = array('_route' => '%s');\n", $name); } if ($methods) { $methodVariable = isset($methods['GET']) ? '$canonicalMethod' : '$requestMethod'; $methods = self::export($methods); } if ($schemes = $route->getSchemes()) { $schemes = self::export(array_flip($schemes)); if ($methods) { $code .= <<getScheme()]); if (!isset((\$a = {$methods})[{$methodVariable}])) { if (\$hasRequiredScheme) { \$allow += \$a; } goto $gotoname; } if (!\$hasRequiredScheme) { \$allowSchemes += \$requiredSchemes; goto $gotoname; } EOF; } else { $code .= <<getScheme()])) { \$allowSchemes += \$requiredSchemes; goto $gotoname; } EOF; } } elseif ($methods) { $code .= <<indent($code) : $code; } private function getExpressionLanguage() { if (null === $this->expressionLanguage) { if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { throw new \RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); } $this->expressionLanguage = new ExpressionLanguage(null, $this->expressionLanguageProviders); } return $this->expressionLanguage; } private function indent($code, $level = 1) { return preg_replace('/^./m', str_repeat(' ', $level).'$0', $code); } /** * @internal */ public static function export($value): string { if (null === $value) { return 'null'; } if (!\is_array($value)) { if (\is_object($value)) { throw new \InvalidArgumentException('Symfony\Component\Routing\Route cannot contain objects.'); } return str_replace("\n", '\'."\n".\'', var_export($value, true)); } if (!$value) { return 'array()'; } $i = 0; $export = 'array('; foreach ($value as $k => $v) { if ($i === $k) { ++$i; } else { $export .= self::export($k).' => '; if (\is_int($k) && $i < $k) { $i = 1 + $k; } } $export .= self::export($v).', '; } return substr_replace($export, ')', -2); } }