Created
January 17, 2011 18:57
-
-
Save nikic/783264 to your computer and use it in GitHub Desktop.
Generates Router classes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
class RouteCompilationException extends LogicException | |
{ | |
public function setContext($line, $route) { | |
$this->message = 'Line ' . $line . ': ' . $this->getMessage() . ' (route: "' . $route . '")'; | |
} | |
} | |
class RouterCompiler | |
{ | |
const ROUTE_PLAIN = 0; | |
const ROUTE_REGEX = 1; | |
const ROUTE_SPECIAL = 2; | |
const PARAM_PLAIN = 0; | |
const PARAM_CAPTGR = 1; | |
protected static function compileRouteBody(array $params, array $flags) { | |
// convert values of params and flags to PHP | |
$tmp = array(&$params, &$flags); | |
foreach ($tmp as &$putTo) { | |
foreach ($putTo as &$values) { | |
foreach ($values as &$value) { | |
if ($value[0] == self::PARAM_PLAIN) { | |
if (ctype_digit($value[1])) { | |
$value = $value[1]; | |
} else { | |
$value = '\'' . str_replace(array('\\', '\''), array('\\\\', '\\\''), $value[1]) . '\''; | |
} | |
} elseif ($value[0] == self::PARAM_CAPTGR) { | |
$value = '$matches[' . $value[1] . ']'; | |
} | |
} | |
$values = implode(' . ', $values); | |
} | |
} | |
if (isset($flags['redirect'])) { | |
return ' | |
self::redirect(' . | |
$flags['redirect'] . ', ' . | |
(isset($flags['statusCode']) ? $flags['statusCode'] : '301') . | |
');'; | |
} else { | |
$longest = 0; | |
foreach ($params as $name => $code) { | |
// convert name to string | |
$name = str_replace(array('\\', '\''), array('\\\\', '\\\''), $name); | |
// find longest name for alignment | |
if (strlen($name) > $longest) { | |
$longest = strlen($name); | |
} | |
} | |
$c = ''; | |
foreach ($params as $name => $code) { | |
$c .= ' | |
' . str_pad('$_GET[\'' . str_replace(array('\\', '\''), array('\\\\', '\\\''), $name) . '\']', 9 + $longest, ' ') . ' = ' . $code . ';'; | |
} | |
return $c; | |
} | |
} | |
protected static function compile(array $routes) { | |
$c = | |
'<?php | |
class Router | |
{ | |
public static function getBaseURL() { | |
return \'http\' . (isset($_SERVER[\'HTTPS\']) ? \'s\' : \'\') . \'://\' . $_SERVER[\'HTTP_HOST\'] . rtrim(dirname($_SERVER[\'SCRIPT_NAME\']), \'/\\\\\') . \'/\'; | |
} | |
public static function redirect($path = \'\', $statusCode = 302) { | |
header(\'Location: \' . self::getBaseURL() . $path, true, $statusCode); | |
die(); | |
} | |
public static function route($path = null) { | |
if ($path === null) { | |
$cutPos = strlen(rtrim(dirname($_SERVER[\'SCRIPT_NAME\']), \'/\\\\\')) + 1; | |
if (false !== $queryStrPos = strpos($_SERVER[\'REQUEST_URI\'], \'?\')) { | |
$path = substr($_SERVER[\'REQUEST_URI\'], $cutPos, $queryStrPos - $cutPos); | |
} else { | |
$path = substr($_SERVER[\'REQUEST_URI\'], $cutPos); | |
} | |
} | |
'; | |
foreach ($routes as $i => $route) { | |
if ($route[0] == self::ROUTE_SPECIAL) { | |
if ($route[1] == 'index') { | |
$index = $route; | |
} elseif ($route[1] == 'notFound') { | |
$notFound = $route; | |
} | |
unset($routes[$i]); | |
} | |
} | |
$first = true; | |
if (isset($index)) { | |
$first = false; | |
$c .= ' | |
if (!$path) {' | |
. self::compileRouteBody($index[2], $index[3]) . ' | |
}'; | |
} | |
foreach ($routes as $data) { | |
list($type, $route, $params, $flags) = $data; | |
$route = '\'' . str_replace(array('\\', '\''), array('\\\\', '\\\''), $route) . '\''; | |
if ($first) { | |
$c .= ' | |
if ('; | |
$first = false; | |
} else { | |
$c .= ' elseif ('; | |
} | |
if ($type == self::ROUTE_PLAIN) { | |
$c .= '$path == ' . $route; | |
} elseif ($type == self::ROUTE_REGEX) { | |
$c .= 'preg_match(' . $route . ', $path, $matches)'; | |
} | |
$c .= ') {' . | |
self::compileRouteBody($params, $flags) . ' | |
}'; | |
} | |
if (isset($notFound)) { | |
$c .= ' else {' | |
. self::compileRouteBody($notFound[2], $notFound[3]) . ' | |
}'; | |
} | |
$c .= ' | |
} | |
}'; | |
return $c; | |
} | |
/** | |
* Parses a string with routes and returns the resulting routes | |
* | |
* @param $str Source code | |
* | |
* @return array Array of routes | |
*/ | |
protected static function parse($str) { | |
$routes = array(); // holds the resulting route data | |
$stack = array(); // holds the current route tree | |
$last = 0; // offset of last element in stack | |
$indentStr = ''; // holds the indentation string | |
$indentLength = 0; // holds the length of one indentation | |
foreach (explode("\n", str_replace(array("\r\n", "\r"), "\n", $str)) as $lineNumber => $line) { | |
try { | |
// skip empty lines and comments | |
if (preg_match('~^\s*(?:#.*)?$~', $line)) { | |
continue; | |
} | |
// a route consists of: indentation, route and parameters/flags: | |
if (!preg_match( | |
'~^(?P<indent>\s*) | |
(?P<route>\S+) | |
(?:\s+(?P<params>\S+(?:\s+\S+)*?))? | |
(?:\s+\#.*)?$~x', $line, $matches | |
)) { | |
throw new RouteCompilationException('Not a valid route'); | |
} | |
// determine indentation of line | |
$indent = 0; | |
if ($matches['indent'] != '') { | |
// the indentation string is given by the first indentation used | |
// i.e. if the first indentation occurs using a tab character, | |
// then the tab character will be used as indentation. | |
// if the first indentation occurs using 17 spaces, then 17 spaces | |
// will be used as indentation. | |
if (empty($indentStr)) { | |
$indent = 1; | |
$indentStr = $matches['indent']; | |
$indentLength = strlen($matches['indent']); | |
} else { | |
$indent = substr_count($matches['indent'], $indentStr); | |
if ($indent * $indentLength != strlen($matches['indent'])) { | |
throw new RouteCompilationException('Indentation is not composed of "' . $indentStr . '" only'); | |
} | |
} | |
} | |
// update stack | |
if ($indent < $last) { | |
$stack = array_slice($stack, 0, $indent); | |
$last = $indent; | |
} elseif ($indent != $last && $indent != ++$last) { | |
throw new RouteCompilationException('Too much indentation'); | |
} | |
// parse params | |
$thisParams = array(); // holds params of the current route only (i.e. independent of the tree) | |
$thisFlags = array(); // holds flags of the current route only | |
$offset = isset($stack[$last - 1]) ? $stack[$last - 1][3] : 0; // current match offset (for $) | |
if (isset($matches['params'])) { | |
// params separated by ',' | |
foreach (preg_split('~\s*,\s*~', $matches['params']) as $param) { | |
// everything is a param | |
$putTo =& $thisParams; | |
// apart from entries starting with '!' | |
if ($param[0] == '!') { | |
$putTo =& $thisFlags; | |
$param = substr($param, 1); | |
} | |
// key: value format | |
if (false !== $pos = strpos($param, ':')) { | |
$name = rtrim(substr($param, 0, $pos)); | |
$valueStr = ltrim(substr($param, $pos + 1)); | |
// key only format | |
} else { | |
$name = $param; | |
$valueStr = ''; | |
} | |
// find matches (capturing groups) insertion with '$' | |
$value = array(); | |
while (false !== $pos = strpos($valueStr, '$')) { | |
// push plain string before '$' | |
$beforeCaptgr = substr($valueStr, 0, $pos); | |
if ($beforeCaptgr != '') { | |
$value[] = array(self::PARAM_PLAIN, $beforeCaptgr); | |
} | |
// $n references the n-th match | |
if (isset($valueStr[$pos + 1]) && ctype_digit($valueStr[$pos + 1])) { | |
$value[] = array(self::PARAM_CAPTGR, $valueStr[$pos + 1]); | |
$valueStr = substr($valueStr, $pos + 2); | |
// single '$' not followed by a digit references the next match (i.e. the (offset+1)-th) | |
} else { | |
$value[] = array(self::PARAM_CAPTGR, ++$offset); | |
$valueStr = substr($valueStr, $pos + 1); | |
} | |
} | |
// push the remaining string as plain | |
if ($valueStr != '') { | |
$value[] = array(self::PARAM_PLAIN, $valueStr); | |
} | |
// disallow the same parameter being specified twice | |
if (isset($putTo[$name])) { | |
throw new RouteCompilationException('Flag or parameter "' . $name . '" already specified'); | |
} | |
// put either in the params or the flags array | |
$putTo[$name] = $value; | |
} | |
} | |
// push route data to stack | |
$stack[$last] = array($matches['route'], $thisParams, $thisFlags, $offset); | |
// construct the real route data (i.e. resolve the tree) | |
$route = ''; | |
$params = array(); | |
$flags = array(); | |
foreach ($stack as $d) { | |
$route .= $d[0]; | |
$params = array_merge($params, $d[1]); | |
$flags = array_merge($flags, $d[2]); | |
} | |
// special routes | |
if ($route[0] == '*') { | |
$type = self::ROUTE_SPECIAL; | |
$route = substr($route, 1); | |
if (!in_array($route, array('index', 'notFound'))) { | |
throw new RouteCompilationException('Invalid special route (may be *index or *notFound)'); | |
} | |
// check for duplicate route | |
foreach ($routes as $data) { | |
if ($data[0] == self::ROUTE_SPECIAL && $data[1] == $route) { | |
throw new RouteCompilationException('Duplicate route'); | |
} | |
} | |
} | |
// route is a regex (i.e. contains some special character) | |
else if (preg_match('~[(){}[\].+*?\\^$|]~', $route)) { | |
$type = self::ROUTE_REGEX; | |
$route = '~^' . str_replace('~', '\\~', $route) . '$~'; | |
// test regex for syntactical correctness | |
if (false === @preg_match($route, '')) { | |
throw new RouteCompilationException('Invalid regular expression'); | |
} | |
// perform a check for enough capturing groups | |
if ($offset > preg_match_all('~\((?!\?[#:|>=!]|\?<[=!])~', $route, $matches)) { | |
throw new RouteCompilationException('Less capturing groups than used'); | |
} | |
// check for duplicate route | |
foreach ($routes as $data) { | |
if ($data[0] == self::ROUTE_REGEX && $data[1] == $route) { | |
throw new RouteCompilationException('Duplicate route'); | |
} | |
} | |
} | |
// route is a plain string | |
else { | |
$type = self::ROUTE_PLAIN; | |
// ensure no capturing groups are used with a plain route | |
if ($offset > 0) { | |
throw new RouteCompilationException('Plaintext routes cannot contain references to capturing groups'); | |
} | |
foreach ($routes as $data) { | |
// check for duplicate route | |
if ($data[0] == self::ROUTE_PLAIN && $data[1] == $route) { | |
throw new RouteCompilationException('Duplicate route'); | |
} | |
// optimize away routes already covered by a regex (i.e. unreachable) | |
if ($data[0] == self::ROUTE_REGEX && preg_match($data[1], $route)) { | |
continue 2; | |
} | |
} | |
} | |
$routes[] = array($type, $route, $params, $flags); | |
} catch (RouteCompilationException $e) { | |
$e->setContext($lineNumber + 1, $line); | |
throw $e; | |
} | |
} | |
return $routes; | |
} | |
public static function fromString($str) { | |
$routes = self::parse($str); | |
return self::compile($routes); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment