Skip to content

Commit

Permalink
[Routing] simplified route compiler
Browse files Browse the repository at this point in the history
  • Loading branch information
fabpot committed Apr 25, 2011
1 parent 59c6609 commit 7c95bda
Show file tree
Hide file tree
Showing 13 changed files with 92 additions and 326 deletions.
18 changes: 0 additions & 18 deletions src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php
Expand Up @@ -135,24 +135,6 @@ protected function outputRoute(OutputInterface $output, $routes, $name)
$output->writeln(sprintf('<comment>Options</comment> %s', $options));
$output->write('<comment>Regex</comment> ');
$output->writeln(preg_replace('/^ /', '', preg_replace('/^/m', ' ', $route->getRegex())), OutputInterface::OUTPUT_RAW);

$tokens = '';
foreach ($route->getTokens() as $token) {
if (!$tokens) {
$tokens = $this->displayToken($token);
} else {
$tokens .= "\n".str_repeat(' ', 13).$this->displayToken($token);
}
}
$output->writeln(sprintf('<comment>Tokens</comment> %s', $tokens));
}

protected function displayToken($token)
{
$type = array_shift($token);
array_shift($token);

return sprintf('%-10s %s', $type, $this->formatValue($token));
}

protected function formatValue($value)
Expand Down
14 changes: 5 additions & 9 deletions src/Symfony/Component/Routing/Generator/UrlGenerator.php
Expand Up @@ -89,6 +89,8 @@ public function generate($name, array $parameters = array(), $absolute = false)
*/
protected function doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $name, $absolute)
{
$variables = array_flip($variables);

$originParameters = $parameters;
$parameters = array_replace($this->context->getParameters(), $parameters);
$tparams = array_replace($defaults, $parameters);
Expand All @@ -104,8 +106,8 @@ protected function doGenerate($variables, $defaults, $requirements, $tokens, $pa
if ('variable' === $token[0]) {
if (false === $optional || !isset($defaults[$token[3]]) || (isset($parameters[$token[3]]) && $parameters[$token[3]] != $defaults[$token[3]])) {
// check requirement
if (isset($requirements[$token[3]]) && !preg_match('#^'.$requirements[$token[3]].'$#', $tparams[$token[3]])) {
throw new \InvalidArgumentException(sprintf('Parameter "%s" for route "%s" must match "%s" ("%s" given).', $token[3], $name, $requirements[$token[3]], $tparams[$token[3]]));
if (!preg_match('#^'.$token[2].'$#', $tparams[$token[3]])) {
throw new \InvalidArgumentException(sprintf('Parameter "%s" for route "%s" must match "%s" ("%s" given).', $token[3], $name, $token[2], $tparams[$token[3]]));
}

if ($tparams[$token[3]] || !$optional) {
Expand All @@ -116,14 +118,8 @@ protected function doGenerate($variables, $defaults, $requirements, $tokens, $pa
$optional = false;
}
} elseif ('text' === $token[0]) {
$url = $token[1].$token[2].$url;
$url = $token[1].$url;
$optional = false;
} else {
// handle custom tokens
if ($segment = call_user_func_array(array($this, 'generateFor'.ucfirst(array_shift($token))), array_merge(array($optional, $tparams), $token))) {
$url = $segment.$url;
$optional = false;
}
}
}

Expand Down
Expand Up @@ -47,13 +47,13 @@ public function dump(array $options = array())
$compiledRoute = $route->compile();

// prepare the apache regex
$regex = preg_replace('/\?P<.+?>/', '', substr($compiledRoute->getRegex(), 1, -2));
$regex = preg_replace('/\?P<.+?>/', '', substr(str_replace(array("\n", ' '), '', $compiledRoute->getRegex()), 1, -2));
$regex = '^'.preg_quote($options['base_uri']).substr($regex, 1);

$hasTrailingSlash = '/$' == substr($regex, -2) && '^/$' != $regex;

$variables = array('E=_ROUTING__route:'.$name);
foreach (array_keys($compiledRoute->getVariables()) as $i => $variable) {
foreach ($compiledRoute->getVariables() as $i => $variable) {
$variables[] = 'E=_ROUTING_'.$variable.':%'.($i + 1);
}
foreach ($route->getDefaults() as $key => $value) {
Expand Down
Expand Up @@ -60,7 +60,7 @@ private function addMatcher($supportsRedirections)
$conditions = array();
$hasTrailingSlash = false;
$matches = false;
if (!count($compiledRoute->getVariables()) && false !== preg_match('#^(.)\^(?P<url>.*?)\$\1#', $compiledRoute->getRegex(), $m)) {
if (!count($compiledRoute->getVariables()) && false !== preg_match('#^(.)\^(?P<url>.*?)\$\1#', str_replace(array("\n", ' '), '', $compiledRoute->getRegex()), $m)) {
if ($supportsRedirections && substr($m['url'], -1) === '/') {
$conditions[] = sprintf("rtrim(\$pathinfo, '/') === '%s'", rtrim(str_replace('\\', '', $m['url']), '/'));
$hasTrailingSlash = true;
Expand All @@ -72,7 +72,7 @@ private function addMatcher($supportsRedirections)
$conditions[] = sprintf("0 === strpos(\$pathinfo, '%s')", $compiledRoute->getStaticPrefix());
}

$regex = $compiledRoute->getRegex();
$regex = str_replace(array("\n", ' '), '', $compiledRoute->getRegex());
if ($supportsRedirections && $pos = strpos($regex, '/$')) {
$regex = substr($regex, 0, $pos).'/?$'.substr($regex, $pos + 2);
$hasTrailingSlash = true;
Expand Down
6 changes: 1 addition & 5 deletions src/Symfony/Component/Routing/Route.php
Expand Up @@ -31,9 +31,7 @@ class Route
*
* Available options:
*
* * segment_separators: An array of allowed characters for segment separators (/ by default)
* * text_regex: A regex that match a valid text name (.+? by default)
* * compiler_class: A class name able to compile this route instance (RouteCompiler by default)
* * compiler_class: A class name able to compile this route instance (RouteCompiler by default)
*
* @param string $pattern The pattern to match
* @param array $defaults An array of default parameter values
Expand Down Expand Up @@ -101,8 +99,6 @@ public function getOptions()
public function setOptions(array $options)
{
$this->options = array_merge(array(
'segment_separators' => array('/', '.'),
'text_regex' => '.+?',
'compiler_class' => 'Symfony\\Component\\Routing\\RouteCompiler',
), $options);

Expand Down
236 changes: 47 additions & 189 deletions src/Symfony/Component/Routing/RouteCompiler.php
Expand Up @@ -18,15 +18,6 @@
*/
class RouteCompiler implements RouteCompilerInterface
{
protected $options;
protected $route;
protected $variables;
protected $firstOptional;
protected $segments;
protected $tokens;
protected $staticPrefix;
protected $regex;

/**
* Compiles the current route instance.
*
Expand All @@ -36,200 +27,67 @@ class RouteCompiler implements RouteCompilerInterface
*/
public function compile(Route $route)
{
$this->route = $route;
$this->firstOptional = 0;
$this->segments = array();
$this->variables = array();
$this->tokens = array();
$this->staticPrefix = '';
$this->regex = '';
$this->options = $this->getOptions();

$this->preCompile();

$this->tokenize();

foreach ($this->tokens as $token) {
call_user_func_array(array($this, 'compileFor'.ucfirst(array_shift($token))), $token);
}

$this->postCompile();

$separator = '';
if (count($this->tokens)) {
$lastToken = $this->tokens[count($this->tokens) - 1];
$separator = 'separator' == $lastToken[0] ? $lastToken[2] : '';
}

$this->regex = "#^".implode("", $this->segments)."".preg_quote($separator, '#')."$#x";

// optimize tokens for generation
$pattern = $route->getPattern();
$len = strlen($pattern);
$tokens = array();
foreach ($this->tokens as $i => $token) {
if ($i + 1 === count($this->tokens) && 'separator' === $token[0]) {
// trailing /
$tokens[] = array('text', $token[2], '', null);
} elseif ('separator' !== $token[0]) {
$tokens[] = $token;
$variables = array();
$pos = 0;
preg_match_all('#.\{([\w\d_]+)\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($matches as $match) {
if ($text = substr($pattern, $pos, $match[0][1] - $pos)) {
$tokens[] = array('text', $text);
}
}

$tokens = array_reverse($tokens);

return new CompiledRoute($this->route, $this->staticPrefix, $this->regex, $tokens, $this->variables);
}
$pos = $match[0][1] + strlen($match[0][0]);
$var = $match[1][0];

/**
* Pre-compiles a route.
*/
protected function preCompile()
{
}
if ($req = $route->getRequirement($var)) {
$regexp = $req;
} else {
$regexp = $pos !== $len ? sprintf('[^%s]*?', $pattern[$pos]) : '.*?';
}

/**
* Post-compiles a route.
*/
protected function postCompile()
{
// all segments after the last static segment are optional
// be careful, the n-1 is optional only if n is empty
for ($i = $this->firstOptional, $max = count($this->segments); $i < $max; $i++) {
$this->segments[$i] = (0 == $i ? '/?' : '').str_repeat(' ', $i - $this->firstOptional).'(?:'.$this->segments[$i];
$this->segments[] = str_repeat(' ', $max - $i - 1).')?';
$tokens[] = array('variable', $match[0][0][0], $regexp, $var);
$variables[] = $var;
}

$this->staticPrefix = '';
foreach ($this->tokens as $token) {
switch ($token[0]) {
case 'separator':
break;
case 'text':
// text is static
$this->staticPrefix .= $token[1].$token[2];
break;
default:
// everything else indicates variable parts. break switch and for loop
break 2;
}
if ($pos < $len) {
$tokens[] = array('text', substr($pattern, $pos));
}
}

/**
* Tokenizes the route.
*
* @throws \InvalidArgumentException When route can't be parsed
*/
private function tokenize()
{
$this->tokens = array();
$buffer = $this->route->getPattern();
$afterASeparator = false;
$currentSeparator = '';

// a route is an array of (separator + variable) or (separator + text) segments
while (strlen($buffer)) {
if (false !== $this->tokenizeBufferBefore($buffer, $tokens, $afterASeparator, $currentSeparator)) {
// a custom token
$this->customToken = true;
} else if ($afterASeparator && preg_match('#^\{([\w\d_]+)\}#', $buffer, $match)) {
// a variable
$this->tokens[] = array('variable', $currentSeparator, $match[0], $match[1]);

$currentSeparator = '';
$buffer = substr($buffer, strlen($match[0]));
$afterASeparator = false;
} else if ($afterASeparator && preg_match('#^('.$this->options['text_regex'].')(?:'.$this->options['segment_separators_regex'].'|$)#', $buffer, $match)) {
// a text
$this->tokens[] = array('text', $currentSeparator, $match[1], null);

$currentSeparator = '';
$buffer = substr($buffer, strlen($match[1]));
$afterASeparator = false;
} else if (!$afterASeparator && preg_match('#^'.$this->options['segment_separators_regex'].'#', $buffer, $match)) {
// a separator
$this->tokens[] = array('separator', $currentSeparator, $match[0], null);

$currentSeparator = $match[0];
$buffer = substr($buffer, strlen($match[0]));
$afterASeparator = true;
} else if (false !== $this->tokenizeBufferAfter($buffer, $tokens, $afterASeparator, $currentSeparator)) {
// a custom token
$this->customToken = true;
// find the first optional token
$firstOptional = INF;
for ($i = count($tokens) - 1; $i >= 0; $i--) {
if ('variable' === $tokens[$i][0] && $route->hasDefault($tokens[$i][3])) {
$firstOptional = $i;
} else {
// parsing problem
throw new \InvalidArgumentException(sprintf('Unable to parse "%s" route near "%s".', $this->route->getPattern(), $buffer));
break;
}
}
}

/**
* Tokenizes the buffer before default logic is applied.
*
* This method must return false if the buffer has not been parsed.
*
* @param string $buffer The current route buffer
* @param array $tokens An array of current tokens
* @param Boolean $afterASeparator Whether the buffer is just after a separator
* @param string $currentSeparator The last matched separator
*
* @return Boolean true if a token has been generated, false otherwise
*/
protected function tokenizeBufferBefore(&$buffer, &$tokens, &$afterASeparator, &$currentSeparator)
{
return false;
}

/**
* Tokenizes the buffer after default logic is applied.
*
* This method must return false if the buffer has not been parsed.
*
* @param string $buffer The current route buffer
* @param array $tokens An array of current tokens
* @param Boolean $afterASeparator Whether the buffer is just after a separator
* @param string $currentSeparator The last matched separator
*
* @return Boolean true if a token has been generated, false otherwise
*/
protected function tokenizeBufferAfter(&$buffer, &$tokens, &$afterASeparator, &$currentSeparator)
{
return false;
}

protected function compileForText($separator, $text)
{
$this->firstOptional = count($this->segments) + 1;

$this->segments[] = preg_quote($separator, '#').preg_quote($text, '#');
}

protected function compileForVariable($separator, $name, $variable)
{
if (null === $requirement = $this->route->getRequirement($variable)) {
$requirement = $this->options['variable_content_regex'];
// compute the matching regexp
$regex = '';
$indent = 1;
foreach ($tokens as $i => $token) {
if ('text' === $token[0]) {
$regex .= str_repeat(' ', $indent * 4).preg_quote($token[1], '#')."\n";
} else {
if ($i >= $firstOptional) {
$regex .= str_repeat(' ', $indent * 4)."(?:\n";
++$indent;
}
$regex .= str_repeat(' ', $indent * 4).sprintf("%s(?P<%s>%s)\n", preg_quote($token[1], '#'), $token[3], $token[2]);
}
}

$this->segments[] = preg_quote($separator, '#').'(?P<'.$variable.'>'.$requirement.')';
$this->variables[$variable] = $name;

if (!$this->route->hasDefault($variable)) {
$this->firstOptional = count($this->segments);
while (--$indent) {
$regex .= str_repeat(' ', $indent * 4).")?\n";
}
}

protected function compileForSeparator($separator, $regexSeparator)
{
}

private function getOptions()
{
$options = $this->route->getOptions();

// compute some regexes
$quoter = function ($a) { return preg_quote($a, '#'); };
$options['segment_separators_regex'] = '(?:'.implode('|', array_map($quoter, $options['segment_separators'])).')';
$options['variable_content_regex'] = '[^'.implode('', array_map($quoter, $options['segment_separators'])).']+?';

return $options;
return new CompiledRoute(
$route,
'text' === $tokens[0][0] ? $tokens[0][1] : '',
sprintf("#^\n%s$#x", $regex),
array_reverse($tokens),
$variables
);
}
}
2 changes: 0 additions & 2 deletions tests/Symfony/Tests/Component/Routing/CompiledRouteTest.php
Expand Up @@ -37,8 +37,6 @@ public function testgetPatterngetDefaultsgetOptionsgetRequirements()
$this->assertEquals(array('foo' => 'bar'), $compiled->getDefaults(), '->getDefaults() returns the route defaults');
$this->assertEquals(array('foo' => '\d+'), $compiled->getRequirements(), '->getRequirements() returns the route requirements');
$this->assertEquals(array_merge(array(
'segment_separators' => array('/', '.'),
'text_regex' => '.+?',
'compiler_class' => 'Symfony\\Component\\Routing\\RouteCompiler',
), array('foo' => 'bar')), $compiled->getOptions(), '->getOptions() returns the route options');
}
Expand Down

5 comments on commit 7c95bda

@henrikbjorn
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this was merged this:

topic_index:
    pattern: /forum/{forumId}/topic
    defaults:
        _controller: ComwaysGamingBundle:Topic:index

is exploding with Parameter "forumId" for route "topic_index" must match "[^/]*?" ("11/topic" given).

@fabpot
Copy link
Member Author

@fabpot fabpot commented on 7c95bda Apr 25, 2011

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new compiler is indeed stricter (it forbids the usage of the path separator in values). In your case, you must override the default requirement.

@henrikbjorn
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it will parse the same thing just say that it must match \d+ instead

@fabpot
Copy link
Member Author

@fabpot fabpot commented on 7c95bda Apr 26, 2011

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I thought you were passing 11/topic as a value for forumId. I see now the /topic is the suffix of the pattern. I've just tested your route and it works fine for me. Can you give me the code that generates the URL?

@fabpot
Copy link
Member Author

@fabpot fabpot commented on 7c95bda Apr 26, 2011

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've found the problem and it should be fixed now:

035afc1

Please sign in to comment.