1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Routing\Matcher\Dumper; 13 14use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; 15use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 16use Symfony\Component\Routing\Route; 17use Symfony\Component\Routing\RouteCollection; 18 19/** 20 * CompiledUrlMatcherDumper creates PHP arrays to be used with CompiledUrlMatcher. 21 * 22 * @author Fabien Potencier <fabien@symfony.com> 23 * @author Tobias Schultze <http://tobion.de> 24 * @author Arnaud Le Blanc <arnaud.lb@gmail.com> 25 * @author Nicolas Grekas <p@tchwork.com> 26 */ 27class CompiledUrlMatcherDumper extends MatcherDumper 28{ 29 private $expressionLanguage; 30 private $signalingException; 31 32 /** 33 * @var ExpressionFunctionProviderInterface[] 34 */ 35 private $expressionLanguageProviders = []; 36 37 /** 38 * {@inheritdoc} 39 */ 40 public function dump(array $options = []) 41 { 42 return <<<EOF 43<?php 44 45/** 46 * This file has been auto-generated 47 * by the Symfony Routing Component. 48 */ 49 50return [ 51{$this->generateCompiledRoutes()}]; 52 53EOF; 54 } 55 56 public function addExpressionLanguageProvider(ExpressionFunctionProviderInterface $provider) 57 { 58 $this->expressionLanguageProviders[] = $provider; 59 } 60 61 /** 62 * Generates the arrays for CompiledUrlMatcher's constructor. 63 */ 64 public function getCompiledRoutes(bool $forDump = false): array 65 { 66 // Group hosts by same-suffix, re-order when possible 67 $matchHost = false; 68 $routes = new StaticPrefixCollection(); 69 foreach ($this->getRoutes()->all() as $name => $route) { 70 if ($host = $route->getHost()) { 71 $matchHost = true; 72 $host = '/'.strtr(strrev($host), '}.{', '(/)'); 73 } 74 75 $routes->addRoute($host ?: '/(.*)', [$name, $route]); 76 } 77 78 if ($matchHost) { 79 $compiledRoutes = [true]; 80 $routes = $routes->populateCollection(new RouteCollection()); 81 } else { 82 $compiledRoutes = [false]; 83 $routes = $this->getRoutes(); 84 } 85 86 list($staticRoutes, $dynamicRoutes) = $this->groupStaticRoutes($routes); 87 88 $conditions = [null]; 89 $compiledRoutes[] = $this->compileStaticRoutes($staticRoutes, $conditions); 90 $chunkLimit = \count($dynamicRoutes); 91 92 while (true) { 93 try { 94 $this->signalingException = new \RuntimeException('Compilation failed: regular expression is too large'); 95 $compiledRoutes = array_merge($compiledRoutes, $this->compileDynamicRoutes($dynamicRoutes, $matchHost, $chunkLimit, $conditions)); 96 97 break; 98 } catch (\Exception $e) { 99 if (1 < $chunkLimit && $this->signalingException === $e) { 100 $chunkLimit = 1 + ($chunkLimit >> 1); 101 continue; 102 } 103 throw $e; 104 } 105 } 106 107 if ($forDump) { 108 $compiledRoutes[2] = $compiledRoutes[4]; 109 } 110 unset($conditions[0]); 111 112 if ($conditions) { 113 foreach ($conditions as $expression => $condition) { 114 $conditions[$expression] = "case {$condition}: return {$expression};"; 115 } 116 117 $checkConditionCode = <<<EOF 118 static function (\$condition, \$context, \$request) { // \$checkCondition 119 switch (\$condition) { 120{$this->indent(implode("\n", $conditions), 3)} 121 } 122 } 123EOF; 124 $compiledRoutes[4] = $forDump ? $checkConditionCode.",\n" : eval('return '.$checkConditionCode.';'); 125 } else { 126 $compiledRoutes[4] = $forDump ? " null, // \$checkCondition\n" : null; 127 } 128 129 return $compiledRoutes; 130 } 131 132 private function generateCompiledRoutes(): string 133 { 134 list($matchHost, $staticRoutes, $regexpCode, $dynamicRoutes, $checkConditionCode) = $this->getCompiledRoutes(true); 135 136 $code = self::export($matchHost).', // $matchHost'."\n"; 137 138 $code .= '[ // $staticRoutes'."\n"; 139 foreach ($staticRoutes as $path => $routes) { 140 $code .= sprintf(" %s => [\n", self::export($path)); 141 foreach ($routes as $route) { 142 $code .= sprintf(" [%s, %s, %s, %s, %s, %s, %s],\n", ...array_map([__CLASS__, 'export'], $route)); 143 } 144 $code .= " ],\n"; 145 } 146 $code .= "],\n"; 147 148 $code .= sprintf("[ // \$regexpList%s\n],\n", $regexpCode); 149 150 $code .= '[ // $dynamicRoutes'."\n"; 151 foreach ($dynamicRoutes as $path => $routes) { 152 $code .= sprintf(" %s => [\n", self::export($path)); 153 foreach ($routes as $route) { 154 $code .= sprintf(" [%s, %s, %s, %s, %s, %s, %s],\n", ...array_map([__CLASS__, 'export'], $route)); 155 } 156 $code .= " ],\n"; 157 } 158 $code .= "],\n"; 159 $code = preg_replace('/ => \[\n (\[.+?),\n \],/', ' => [$1],', $code); 160 161 return $this->indent($code, 1).$checkConditionCode; 162 } 163 164 /** 165 * Splits static routes from dynamic routes, so that they can be matched first, using a simple switch. 166 */ 167 private function groupStaticRoutes(RouteCollection $collection): array 168 { 169 $staticRoutes = $dynamicRegex = []; 170 $dynamicRoutes = new RouteCollection(); 171 172 foreach ($collection->all() as $name => $route) { 173 $compiledRoute = $route->compile(); 174 $staticPrefix = rtrim($compiledRoute->getStaticPrefix(), '/'); 175 $hostRegex = $compiledRoute->getHostRegex(); 176 $regex = $compiledRoute->getRegex(); 177 if ($hasTrailingSlash = '/' !== $route->getPath()) { 178 $pos = strrpos($regex, '$'); 179 $hasTrailingSlash = '/' === $regex[$pos - 1]; 180 $regex = substr_replace($regex, '/?$', $pos - $hasTrailingSlash, 1 + $hasTrailingSlash); 181 } 182 183 if (!$compiledRoute->getPathVariables()) { 184 $host = !$compiledRoute->getHostVariables() ? $route->getHost() : ''; 185 $url = $route->getPath(); 186 if ($hasTrailingSlash) { 187 $url = substr($url, 0, -1); 188 } 189 foreach ($dynamicRegex as list($hostRx, $rx, $prefix)) { 190 if (('' === $prefix || 0 === strpos($url, $prefix)) && (preg_match($rx, $url) || preg_match($rx, $url.'/')) && (!$host || !$hostRx || preg_match($hostRx, $host))) { 191 $dynamicRegex[] = [$hostRegex, $regex, $staticPrefix]; 192 $dynamicRoutes->add($name, $route); 193 continue 2; 194 } 195 } 196 197 $staticRoutes[$url][$name] = [$route, $hasTrailingSlash]; 198 } else { 199 $dynamicRegex[] = [$hostRegex, $regex, $staticPrefix]; 200 $dynamicRoutes->add($name, $route); 201 } 202 } 203 204 return [$staticRoutes, $dynamicRoutes]; 205 } 206 207 /** 208 * Compiles static routes in a switch statement. 209 * 210 * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 211 * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 212 * 213 * @throws \LogicException 214 */ 215 private function compileStaticRoutes(array $staticRoutes, array &$conditions): array 216 { 217 if (!$staticRoutes) { 218 return []; 219 } 220 $compiledRoutes = []; 221 222 foreach ($staticRoutes as $url => $routes) { 223 $compiledRoutes[$url] = []; 224 foreach ($routes as $name => list($route, $hasTrailingSlash)) { 225 $compiledRoutes[$url][] = $this->compileRoute($route, $name, (!$route->compile()->getHostVariables() ? $route->getHost() : $route->compile()->getHostRegex()) ?: null, $hasTrailingSlash, false, $conditions); 226 } 227 } 228 229 return $compiledRoutes; 230 } 231 232 /** 233 * Compiles a regular expression followed by a switch statement to match dynamic routes. 234 * 235 * The regular expression matches both the host and the pathinfo at the same time. For stellar performance, 236 * it is built as a tree of patterns, with re-ordering logic to group same-prefix routes together when possible. 237 * 238 * Patterns are named so that we know which one matched (https://pcre.org/current/doc/html/pcre2syntax.html#SEC23). 239 * This name is used to "switch" to the additional logic required to match the final route. 240 * 241 * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 242 * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 243 * 244 * Last but not least: 245 * - Because it is not possibe to mix unicode/non-unicode patterns in a single regexp, several of them can be generated. 246 * - The same regexp can be used several times when the logic in the switch rejects the match. When this happens, the 247 * matching-but-failing subpattern is blacklisted by replacing its name by "(*F)", which forces a failure-to-match. 248 * To ease this backlisting operation, the name of subpatterns is also the string offset where the replacement should occur. 249 */ 250 private function compileDynamicRoutes(RouteCollection $collection, bool $matchHost, int $chunkLimit, array &$conditions): array 251 { 252 if (!$collection->all()) { 253 return [[], [], '']; 254 } 255 $regexpList = []; 256 $code = ''; 257 $state = (object) [ 258 'regexMark' => 0, 259 'regex' => [], 260 'routes' => [], 261 'mark' => 0, 262 'markTail' => 0, 263 'hostVars' => [], 264 'vars' => [], 265 ]; 266 $state->getVars = static function ($m) use ($state) { 267 if ('_route' === $m[1]) { 268 return '?:'; 269 } 270 271 $state->vars[] = $m[1]; 272 273 return ''; 274 }; 275 276 $chunkSize = 0; 277 $prev = null; 278 $perModifiers = []; 279 foreach ($collection->all() as $name => $route) { 280 preg_match('#[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 281 if ($chunkLimit < ++$chunkSize || $prev !== $rx[0] && $route->compile()->getPathVariables()) { 282 $chunkSize = 1; 283 $routes = new RouteCollection(); 284 $perModifiers[] = [$rx[0], $routes]; 285 $prev = $rx[0]; 286 } 287 $routes->add($name, $route); 288 } 289 290 foreach ($perModifiers as list($modifiers, $routes)) { 291 $prev = false; 292 $perHost = []; 293 foreach ($routes->all() as $name => $route) { 294 $regex = $route->compile()->getHostRegex(); 295 if ($prev !== $regex) { 296 $routes = new RouteCollection(); 297 $perHost[] = [$regex, $routes]; 298 $prev = $regex; 299 } 300 $routes->add($name, $route); 301 } 302 $prev = false; 303 $rx = '{^(?'; 304 $code .= "\n {$state->mark} => ".self::export($rx); 305 $startingMark = $state->mark; 306 $state->mark += \strlen($rx); 307 $state->regex = $rx; 308 309 foreach ($perHost as list($hostRegex, $routes)) { 310 if ($matchHost) { 311 if ($hostRegex) { 312 preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $hostRegex, $rx); 313 $state->vars = []; 314 $hostRegex = '(?i:'.preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]).')\.'; 315 $state->hostVars = $state->vars; 316 } else { 317 $hostRegex = '(?:(?:[^./]*+\.)++)'; 318 $state->hostVars = []; 319 } 320 $state->mark += \strlen($rx = ($prev ? ')' : '')."|{$hostRegex}(?"); 321 $code .= "\n .".self::export($rx); 322 $state->regex .= $rx; 323 $prev = true; 324 } 325 326 $tree = new StaticPrefixCollection(); 327 foreach ($routes->all() as $name => $route) { 328 preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 329 330 $state->vars = []; 331 $regex = preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]); 332 if ($hasTrailingSlash = '/' !== $regex && '/' === $regex[-1]) { 333 $regex = substr($regex, 0, -1); 334 } 335 $hasTrailingVar = (bool) preg_match('#\{\w+\}/?$#', $route->getPath()); 336 337 $tree->addRoute($regex, [$name, $regex, $state->vars, $route, $hasTrailingSlash, $hasTrailingVar]); 338 } 339 340 $code .= $this->compileStaticPrefixCollection($tree, $state, 0, $conditions); 341 } 342 if ($matchHost) { 343 $code .= "\n .')'"; 344 $state->regex .= ')'; 345 } 346 $rx = ")/?$}{$modifiers}"; 347 $code .= "\n .'{$rx}',"; 348 $state->regex .= $rx; 349 $state->markTail = 0; 350 351 // if the regex is too large, throw a signaling exception to recompute with smaller chunk size 352 set_error_handler(function ($type, $message) { throw false !== strpos($message, $this->signalingException->getMessage()) ? $this->signalingException : new \ErrorException($message); }); 353 try { 354 preg_match($state->regex, ''); 355 } finally { 356 restore_error_handler(); 357 } 358 359 $regexpList[$startingMark] = $state->regex; 360 } 361 362 $state->routes[$state->mark][] = [null, null, null, null, false, false, 0]; 363 unset($state->getVars); 364 365 return [$regexpList, $state->routes, $code]; 366 } 367 368 /** 369 * Compiles a regexp tree of subpatterns that matches nested same-prefix routes. 370 * 371 * @param \stdClass $state A simple state object that keeps track of the progress of the compilation, 372 * and gathers the generated switch's "case" and "default" statements 373 */ 374 private function compileStaticPrefixCollection(StaticPrefixCollection $tree, \stdClass $state, int $prefixLen, array &$conditions): string 375 { 376 $code = ''; 377 $prevRegex = null; 378 $routes = $tree->getRoutes(); 379 380 foreach ($routes as $i => $route) { 381 if ($route instanceof StaticPrefixCollection) { 382 $prevRegex = null; 383 $prefix = substr($route->getPrefix(), $prefixLen); 384 $state->mark += \strlen($rx = "|{$prefix}(?"); 385 $code .= "\n .".self::export($rx); 386 $state->regex .= $rx; 387 $code .= $this->indent($this->compileStaticPrefixCollection($route, $state, $prefixLen + \strlen($prefix), $conditions)); 388 $code .= "\n .')'"; 389 $state->regex .= ')'; 390 ++$state->markTail; 391 continue; 392 } 393 394 list($name, $regex, $vars, $route, $hasTrailingSlash, $hasTrailingVar) = $route; 395 $compiledRoute = $route->compile(); 396 $vars = array_merge($state->hostVars, $vars); 397 398 if ($compiledRoute->getRegex() === $prevRegex) { 399 $state->routes[$state->mark][] = $this->compileRoute($route, $name, $vars, $hasTrailingSlash, $hasTrailingVar, $conditions); 400 continue; 401 } 402 403 $state->mark += 3 + $state->markTail + \strlen($regex) - $prefixLen; 404 $state->markTail = 2 + \strlen($state->mark); 405 $rx = sprintf('|%s(*:%s)', substr($regex, $prefixLen), $state->mark); 406 $code .= "\n .".self::export($rx); 407 $state->regex .= $rx; 408 409 $prevRegex = $compiledRoute->getRegex(); 410 $state->routes[$state->mark] = [$this->compileRoute($route, $name, $vars, $hasTrailingSlash, $hasTrailingVar, $conditions)]; 411 } 412 413 return $code; 414 } 415 416 /** 417 * Compiles a single Route to PHP code used to match it against the path info. 418 */ 419 private function compileRoute(Route $route, string $name, $vars, bool $hasTrailingSlash, bool $hasTrailingVar, array &$conditions): array 420 { 421 $defaults = $route->getDefaults(); 422 423 if (isset($defaults['_canonical_route'])) { 424 $name = $defaults['_canonical_route']; 425 unset($defaults['_canonical_route']); 426 } 427 428 if ($condition = $route->getCondition()) { 429 $condition = $this->getExpressionLanguage()->compile($condition, ['context', 'request']); 430 $condition = $conditions[$condition] ?? $conditions[$condition] = (false !== strpos($condition, '$request') ? 1 : -1) * \count($conditions); 431 } else { 432 $condition = null; 433 } 434 435 return [ 436 ['_route' => $name] + $defaults, 437 $vars, 438 array_flip($route->getMethods()) ?: null, 439 array_flip($route->getSchemes()) ?: null, 440 $hasTrailingSlash, 441 $hasTrailingVar, 442 $condition, 443 ]; 444 } 445 446 private function getExpressionLanguage(): ExpressionLanguage 447 { 448 if (null === $this->expressionLanguage) { 449 if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { 450 throw new \LogicException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); 451 } 452 $this->expressionLanguage = new ExpressionLanguage(null, $this->expressionLanguageProviders); 453 } 454 455 return $this->expressionLanguage; 456 } 457 458 private function indent(string $code, int $level = 1): string 459 { 460 return preg_replace('/^./m', str_repeat(' ', $level).'$0', $code); 461 } 462 463 /** 464 * @internal 465 */ 466 public static function export($value): string 467 { 468 if (null === $value) { 469 return 'null'; 470 } 471 if (!\is_array($value)) { 472 if (\is_object($value)) { 473 throw new \InvalidArgumentException('Symfony\Component\Routing\Route cannot contain objects.'); 474 } 475 476 return str_replace("\n", '\'."\n".\'', var_export($value, true)); 477 } 478 if (!$value) { 479 return '[]'; 480 } 481 482 $i = 0; 483 $export = '['; 484 485 foreach ($value as $k => $v) { 486 if ($i === $k) { 487 ++$i; 488 } else { 489 $export .= self::export($k).' => '; 490 491 if (\is_int($k) && $i < $k) { 492 $i = 1 + $k; 493 } 494 } 495 496 $export .= self::export($v).', '; 497 } 498 499 return substr_replace($export, ']', -2); 500 } 501} 502