pub struct Router {
routes: Vec<Pattern>,
base: String,
}
struct Pattern {
tokens: Vec<Token>,
score: Vec<i32>,
name: Option<String>,
}
enum Token {
Static(String),
Param { name: String, wildcard: bool },
}
const ROOT: i32 = 90;
const STATIC: i32 = 80;
const PARAM: i32 = 60;
const WILDCARD: i32 = 20;
impl Router {
pub fn new(paths: &[&str]) -> Router {
Router::named(&paths.iter().map(|p| (*p, None)).collect::<Vec<_>>())
}
pub fn named(routes: &[(&str, Option<&str>)]) -> Router {
let mut patterns: Vec<Pattern> = routes
.iter()
.map(|(path, name)| Pattern::new(path, name.map(str::to_owned)))
.collect();
for (i, a) in patterns.iter().enumerate() {
if let Some(name) = &a.name {
let again = patterns[i + 1..]
.iter()
.any(|b| b.name.as_ref() == Some(name));
assert!(!again, "two routes are called {name:?}");
}
}
patterns.sort_by(|a, b| compare(&a.score, &b.score));
Router {
routes: patterns,
base: String::new(),
}
}
pub fn with_base(mut self, base: &str) -> Router {
let mut b = if base.is_empty() || base.starts_with('/') || base.starts_with('#') {
base.to_owned()
} else {
format!("/{base}")
};
if b.ends_with('/') {
b.pop();
}
if let Some(i) = b.find('#')
&& i > 0
{
b = b[i..].to_owned();
}
self.base = b;
self
}
pub fn at(&self, location: &str) -> Route<'_> {
let (path, _) = parse_url(location, "/");
let hash = location
.find('#')
.map_or_else(String::new, |h| decode(&location[h..]));
Route {
router: self,
matched: self.matched(&path),
path,
hash,
}
}
fn matched(&self, path: &str) -> Option<(usize, Vec<String>)> {
self.routes
.iter()
.enumerate()
.find_map(|(i, r)| r.parse(path).map(|params| (i, params)))
}
}
pub struct Route<'r> {
router: &'r Router,
path: String,
hash: String,
matched: Option<(usize, Vec<String>)>,
}
pub struct Link {
pub href: String,
pub active: bool,
}
impl Route<'_> {
pub fn path(&self) -> &str {
&self.path
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn name(&self) -> Option<&str> {
let (i, _) = self.matched.as_ref()?;
self.router.routes[*i].name.as_deref()
}
pub fn param(&self, name: &str) -> Option<&str> {
let (i, values) = self.matched.as_ref()?;
let at = self.router.routes[*i]
.tokens
.iter()
.filter_map(|t| match t {
Token::Param { name, .. } => Some(name.as_str()),
Token::Static(_) => None,
})
.position(|n| n == name)?;
values.get(at).map(String::as_str)
}
pub fn link(&self, to: &str) -> Link {
let (path, full) = parse_url(to, &self.path);
let matched = self.router.matched(&path);
Link {
href: format!("{}{full}", self.router.base),
active: matched.is_some() && matched == self.matched,
}
}
pub fn link_named(
&self,
name: &str,
params: &[(&str, &str)],
search: &str,
hash: &str,
) -> Link {
let i = self
.router
.routes
.iter()
.position(|r| r.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("no route is called {name:?}"));
let route = &self.router.routes[i];
let mut path = String::new();
let mut values = Vec::new();
for token in &route.tokens {
path.push('/');
match token {
Token::Static(text) => path.push_str(text),
Token::Param { name, .. } => {
let value = params
.iter()
.find(|(k, _)| k == name)
.map_or("", |(_, v)| v);
let encoded = encode_param(value);
values.push(decode(&encoded));
path.push_str(&encoded);
}
}
}
if path.is_empty() {
path.push('/');
}
let matched = Some((i, values));
Link {
href: self.href(&path, search, hash),
active: matched == self.matched,
}
}
pub fn link_path(&self, path: &str, search: &str, hash: &str) -> Link {
let (path, _) = parse_url(path, &self.path);
let matched = self.router.matched(&path);
Link {
href: self.href(&path, search, hash),
active: matched.is_some() && matched == self.matched,
}
}
fn href(&self, path: &str, search: &str, hash: &str) -> String {
let mut href = format!("{}{path}", self.router.base);
if !search.is_empty() {
href.push('?');
href.push_str(search);
}
href.push_str(&encode_url(hash, Part::Hash));
href
}
}
pub fn query_into(search: &mut String, key: &str, value: &str) {
if !search.is_empty() {
search.push('&');
}
search.push_str(&encode_url(key, Part::QueryKey));
search.push('=');
search.push_str(&encode_url(value, Part::QueryValue));
}
#[derive(Clone, Copy, PartialEq)]
enum Part {
Param,
QueryValue,
QueryKey,
Hash,
}
fn encode_param(text: &str) -> String {
encode_url(text, Part::Param)
}
fn encode_url(text: &str, part: Part) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(text.len());
for c in text.chars() {
let uri = c.is_ascii_alphanumeric() || ";,/?:@&=+$-_.!~*'()#|[]".contains(c);
let keep = match (part, c) {
(Part::Param, '#' | '?' | '/') => false,
(Part::QueryValue | Part::QueryKey, '+' | '#' | '&') => false,
(Part::QueryKey, '=') => false,
(Part::QueryValue | Part::QueryKey, ' ') => {
out.push('+');
continue;
}
(Part::QueryValue | Part::QueryKey, '`' | '{' | '}' | '^') => true,
(Part::Hash, '{' | '}' | '^') => true,
_ => uri,
};
if keep {
out.push(c);
} else {
let mut buf = [0; 4];
for b in c.encode_utf8(&mut buf).bytes() {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
impl Pattern {
fn new(path: &str, name: Option<String>) -> Pattern {
let Some(rest) = path.strip_prefix('/') else {
panic!("a route path starts with `/`: {path:?}");
};
if rest.is_empty() {
return Pattern {
tokens: Vec::new(),
score: vec![ROOT],
name,
};
}
let segments: Vec<&str> = rest.split('/').collect();
let mut tokens = Vec::new();
let mut score = Vec::new();
for (i, seg) in segments.iter().enumerate() {
if let Some(param) = seg.strip_prefix(':') {
let (name, wildcard) = match param.strip_suffix("(.*)") {
Some(name) => (name, true),
None => (param, false),
};
let named = !name.is_empty()
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
assert!(named, "a parameter is `:name` or `:name(.*)`: {path:?}");
assert!(
!wildcard || i == segments.len() - 1,
"`(.*)` is supported on the last segment only: {path:?}"
);
tokens.push(Token::Param {
name: name.to_owned(),
wildcard,
});
score.push(if wildcard { WILDCARD } else { PARAM });
} else {
let plain = !seg.is_empty()
&& seg.is_ascii()
&& !seg.contains([':', '(', ')', '*', '?', '+', '\\']);
assert!(plain, "a static segment is plain ASCII text: {path:?}");
tokens.push(Token::Static((*seg).to_owned()));
score.push(STATIC);
}
}
Pattern {
tokens,
score,
name,
}
}
fn parse(&self, path: &str) -> Option<Vec<String>> {
let mut rest = path;
let mut params = Vec::new();
for token in &self.tokens {
rest = rest.strip_prefix('/')?;
match token {
Token::Static(text) => {
let head = rest.get(..text.len())?;
if !head.eq_ignore_ascii_case(text) {
return None;
}
rest = &rest[text.len()..];
}
Token::Param {
wildcard: false, ..
} => {
let end = rest.find('/').unwrap_or(rest.len());
if end == 0 {
return None;
}
params.push(decode(&rest[..end]));
rest = &rest[end..];
}
Token::Param { wildcard: true, .. } => {
if rest.contains(['\n', '\r', '\u{2028}', '\u{2029}']) {
return None;
}
params.push(decode(rest));
rest = "";
}
}
}
matches!(rest, "" | "/").then_some(params)
}
}
fn compare(a: &[i32], b: &[i32]) -> std::cmp::Ordering {
for (x, y) in a.iter().zip(b) {
if x != y {
return y.cmp(x);
}
}
b.len().cmp(&a.len())
}
fn parse_url(location: &str, current: &str) -> (String, String) {
let hash_pos = location.find('#');
let search_pos = location
.find('?')
.filter(|&s| hash_pos.is_none_or(|h| s < h));
let mut path: Option<&str> = None;
let mut search = "";
let mut hash = "";
if let Some(s) = search_pos {
path = Some(&location[..s]);
let end = hash_pos.filter(|&h| h > 0).unwrap_or(location.len());
search = &location[s..end];
}
if let Some(h) = hash_pos {
path = Some(path.filter(|p| !p.is_empty()).unwrap_or(&location[..h]));
hash = &location[h..];
}
let path = resolve_relative_path(path.unwrap_or(location), current);
let full = format!("{path}{search}{hash}");
(path, full)
}
fn resolve_relative_path(to: &str, from: &str) -> String {
if to.starts_with('/') {
return to.to_owned();
}
if to.is_empty() {
return from.to_owned();
}
let from_segments: Vec<&str> = from.split('/').collect();
let mut to_segments: Vec<&str> = to.split('/').collect();
if matches!(to_segments.last(), Some(&("." | ".."))) {
to_segments.push("");
}
let mut position = from_segments.len() - 1;
let mut to_position = 0;
while to_position < to_segments.len() {
match to_segments[to_position] {
"." => {}
".." => {
if position > 1 {
position -= 1;
}
}
_ => break,
}
to_position += 1;
}
format!(
"{}/{}",
from_segments[..position].join("/"),
to_segments[to_position..].join("/")
)
}
fn decode(text: &str) -> String {
if !text.contains('%') {
return text.to_owned();
}
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = |b: u8| (b as char).to_digit(16);
match (
bytes.get(i + 1).and_then(|&b| hex(b)),
bytes.get(i + 2).and_then(|&b| hex(b)),
) {
(Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
_ => return text.to_owned(),
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| text.to_owned())
}
#[cfg(test)]
mod tests;