use crate::AnyElement;
use std::{
any::Any,
collections::HashMap,
ops::{Deref, DerefMut},
sync::Arc,
};
mod outlet;
pub use outlet::*;
mod router_provider;
pub use router_provider::*;
pub(crate) mod history;
pub struct Route {
pub path: String,
pub component: AnyElement<'static>,
pub children: Routes,
matcher: Option<Arc<regex::Regex>>,
}
impl Route {
pub fn new(path: String, component: AnyElement<'static>, children: Routes) -> Self {
let matcher = if path.contains("/:") {
let pattern = path
.split('/')
.map(|seg| match seg.strip_prefix(':') {
Some(name) => format!("(?<{name}>[^/]+)"),
None => regex::escape(seg),
})
.collect::<Vec<_>>()
.join("/");
Some(Arc::new(
regex::Regex::new(&format!("^{pattern}")).expect("Invalid route path regex"),
))
} else {
None
};
Route {
path,
component,
children,
matcher,
}
}
pub(crate) fn match_path(&self, path: &str) -> Option<(String, HashMap<String, String>)> {
if let Some(regexp) = &self.matcher {
let matched_len = regexp.find(path).map(|m| m.end()).unwrap_or(0);
if matched_len == 0 {
return None;
}
if !matches!(path[matched_len..].chars().next(), None | Some('/')) {
return None;
}
let mut params = HashMap::new();
if let Some(caps) = regexp.captures(path) {
for name in regexp.capture_names().flatten() {
if let Some(matched) = caps.name(name) {
params.insert(name.to_string(), matched.as_str().to_string());
}
}
}
Some((path[matched_len..].to_string(), params))
} else if self.path == "/" {
None
} else if path.starts_with(&self.path)
&& matches!(path[self.path.len()..].chars().next(), None | Some('/'))
{
Some((path[self.path.len()..].to_string(), HashMap::new()))
} else {
None
}
}
}
pub struct Routes(Vec<Route>);
#[allow(clippy::derivable_impls)]
impl Default for Routes {
fn default() -> Self {
Routes(Vec::new())
}
}
impl From<Vec<Route>> for Routes {
fn from(routes: Vec<Route>) -> Self {
Routes(routes)
}
}
impl Deref for Routes {
type Target = Vec<Route>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Routes {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Default, Clone)]
pub(crate) struct RouteContext {
pub path: String,
pub params: HashMap<String, String>,
pub state: Option<RouteState>,
}
#[derive(Debug, Clone)]
pub struct RouteState(pub Arc<dyn Any + Send + Sync>);
impl RouteState {
pub fn new<T>(state: T) -> Self
where
T: Any + Send + Sync + 'static,
{
RouteState(Arc::new(state))
}
pub fn downcast<T>(&self) -> Option<Arc<T>>
where
T: Any + Send + Sync + 'static,
{
self.0.clone().downcast().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::Fragment;
fn route(path: &str) -> Route {
Route::new(
path.to_string(),
crate::element!(Fragment).into_any(),
Routes::default(),
)
}
#[test]
fn dynamic_param_extracted_and_rest_empty() {
let (rest, params) = route("/users/:id").match_path("/users/42").expect("应匹配");
assert_eq!(params.get("id").map(String::as_str), Some("42"));
assert_eq!(rest, "");
}
#[test]
fn dynamic_segment_does_not_cross_slash() {
let (rest, params) = route("/users/:id")
.match_path("/users/42/profile")
.expect("应匹配前缀");
assert_eq!(params.get("id").map(String::as_str), Some("42"));
assert_eq!(rest, "/profile");
}
#[test]
fn static_match_respects_segment_boundary() {
let r = route("/book-source");
assert!(r.match_path("/book-source-login").is_none());
assert_eq!(r.match_path("/book-source").unwrap().0, "");
assert_eq!(r.match_path("/book-source/detail").unwrap().0, "/detail");
}
#[test]
fn root_route_is_not_matched_here() {
assert!(route("/").match_path("/anything").is_none());
}
#[test]
fn no_match_returns_none() {
assert!(route("/settings").match_path("/profile").is_none());
}
#[test]
fn multiple_dynamic_params_extracted() {
let (rest, params) = route("/users/:uid/posts/:pid")
.match_path("/users/7/posts/42")
.expect("应匹配多参数");
assert_eq!(params.get("uid").map(String::as_str), Some("7"));
assert_eq!(params.get("pid").map(String::as_str), Some("42"));
assert_eq!(rest, "");
}
#[test]
fn dynamic_route_is_anchored_at_start() {
assert!(
route("/users/:id").match_path("/foo/users/42").is_none(),
"动态路由不应匹配中段"
);
}
#[test]
fn static_segment_in_dynamic_route_is_escaped() {
let r = route("/v1.0/:id");
let (rest, params) = r.match_path("/v1.0/9").expect("字面点应匹配");
assert_eq!(params.get("id").map(String::as_str), Some("9"));
assert_eq!(rest, "");
assert!(r.match_path("/v1x0/9").is_none(), "点不应作通配");
}
#[test]
fn dynamic_param_requires_nonempty_segment() {
assert!(
route("/users/:id").match_path("/users/").is_none(),
"空参数段不应匹配"
);
assert!(
route("/users/:id").match_path("/users").is_none(),
"缺参数段不应匹配"
);
}
#[test]
fn dynamic_param_accepts_dots_and_dashes() {
let (_, params) = route("/file/:name")
.match_path("/file/a-b.txt")
.expect("应匹配");
assert_eq!(params.get("name").map(String::as_str), Some("a-b.txt"));
}
#[test]
fn dynamic_route_no_prefix_match_is_none() {
assert!(route("/users/:id").match_path("/posts/1").is_none());
}
#[test]
fn dynamic_param_value_does_not_swallow_following_segment() {
let (rest, params) = route("/u/:id")
.match_path("/u/5/detail")
.expect("应匹配前缀");
assert_eq!(params.get("id").map(String::as_str), Some("5"));
assert_eq!(rest, "/detail");
}
#[test]
fn dynamic_static_tail_respects_segment_boundary() {
let route = route("/users/:id/edit");
assert!(route.match_path("/users/42/edit-more").is_none());
assert_eq!(route.match_path("/users/42/edit").unwrap().0, "");
assert_eq!(route.match_path("/users/42/edit/sub").unwrap().0, "/sub");
}
#[test]
fn static_trailing_slash_is_rest() {
assert_eq!(route("/a").match_path("/a").unwrap().0, "");
assert_eq!(route("/a").match_path("/a/").unwrap().0, "/");
}
#[test]
fn route_state_downcasts_to_correct_type() {
let s = RouteState::new(42u32);
assert_eq!(s.downcast::<u32>().map(|a| *a), Some(42));
}
#[test]
fn route_state_downcast_wrong_type_is_none() {
let s = RouteState::new(42u32);
assert!(s.downcast::<String>().is_none());
}
use crate::components::Text;
use ratatui_kit_macros::{Props, component};
#[derive(Default, Props)]
struct GreetProps {
name: String,
}
#[component]
fn Greet(props: &GreetProps) -> impl Into<crate::AnyElement<'static>> {
crate::element!(Text(text: props.name.clone()))
}
#[test]
fn routes_macro_accepts_props() {
let routes: Vec<Route> = crate::routes! {
"/hi" => Greet(name: "world".to_string()),
};
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].path, "/hi");
}
#[test]
fn routes_macro_accepts_props_with_children() {
let routes: Vec<Route> = crate::routes! {
"/a" => Greet(name: "x".to_string()) {
"/b" => Greet(name: "y".to_string()),
},
};
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].path, "/a");
assert_eq!(routes[0].children.len(), 1);
assert_eq!(routes[0].children[0].path, "/b");
}
#[test]
fn routes_macro_no_props_still_works() {
let routes: Vec<Route> = crate::routes! {
"/home" => Fragment,
};
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].path, "/home");
}
}