Skip to content

Instantly share code, notes, and snippets.

@nikic
Created January 17, 2011 18:57
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nikic/783264 to your computer and use it in GitHub Desktop.
Save nikic/783264 to your computer and use it in GitHub Desktop.
Generates Router classes
<?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