use std::collections::HashMap;
use std::sync::Mutex;
use crate::events::EventEmitter;
#[derive(Debug, Clone, PartialEq)]
pub struct RouteMatch {
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct RouteState {
pub current_path: String,
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
pub previous_path: Option<String>,
}
pub struct HypenRouter {
inner: Mutex<RouterInner>,
events: EventEmitter,
}
struct RouterInner {
current_path: String,
params: HashMap<String, String>,
query: HashMap<String, String>,
previous_path: Option<String>,
history: Vec<String>,
}
impl HypenRouter {
pub fn new() -> Self {
Self {
inner: Mutex::new(RouterInner {
current_path: "/".to_string(),
params: HashMap::new(),
query: HashMap::new(),
previous_path: None,
history: vec!["/".to_string()],
}),
events: EventEmitter::new(),
}
}
pub fn push(&self, path: &str) {
let (clean_path, query) = parse_path_and_query(path);
let mut inner = self.inner.lock().unwrap();
let prev = inner.current_path.clone();
inner.previous_path = Some(prev);
inner.current_path = clean_path.clone();
inner.query = query;
inner.params.clear();
inner.history.push(clean_path);
drop(inner);
self.events.emit(
crate::events::framework::ROUTE_CHANGED,
&serde_json::json!({
"path": path,
}),
);
}
pub fn replace(&self, path: &str) {
let (clean_path, query) = parse_path_and_query(path);
let mut inner = self.inner.lock().unwrap();
inner.current_path = clean_path.clone();
inner.query = query;
inner.params.clear();
if let Some(last) = inner.history.last_mut() {
*last = clean_path;
}
}
pub fn state(&self) -> RouteState {
let inner = self.inner.lock().unwrap();
RouteState {
current_path: inner.current_path.clone(),
params: inner.params.clone(),
query: inner.query.clone(),
previous_path: inner.previous_path.clone(),
}
}
pub fn current_path(&self) -> String {
self.inner.lock().unwrap().current_path.clone()
}
pub fn query(&self) -> HashMap<String, String> {
self.inner.lock().unwrap().query.clone()
}
pub fn match_path(&self, pattern: &str, path: &str) -> Option<RouteMatch> {
let (clean_path, query) = parse_path_and_query(path);
match_pattern(pattern, &clean_path).map(|params| RouteMatch {
params,
query,
path: clean_path,
})
}
pub fn is_active(&self, pattern: &str) -> bool {
let inner = self.inner.lock().unwrap();
match_pattern(pattern, &inner.current_path).is_some()
}
pub fn on_navigate<F>(&self, handler: F) -> crate::events::SubscriptionId
where
F: Fn(&serde_json::Value) + Send + Sync + 'static,
{
self.events
.on(crate::events::framework::ROUTE_CHANGED, handler)
}
pub fn build_url(path: &str, query: &HashMap<String, String>) -> String {
if query.is_empty() {
return path.to_string();
}
let qs: Vec<String> = query
.iter()
.map(|(k, v)| format!("{}={}", encode_uri_component(k), encode_uri_component(v)))
.collect();
format!("{path}?{}", qs.join("&"))
}
}
impl Default for HypenRouter {
fn default() -> Self {
Self::new()
}
}
fn encode_uri_component(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => {
out.push('%');
out.push(char::from(HEX_CHARS[(byte >> 4) as usize]));
out.push(char::from(HEX_CHARS[(byte & 0x0F) as usize]));
}
}
}
out
}
fn decode_uri_component(input: &str) -> String {
let mut out = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
if bytes[i] == b'+' {
out.push(b' ');
} else {
out.push(bytes[i]);
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
const HEX_CHARS: [u8; 16] = *b"0123456789ABCDEF";
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'A'..=b'F' => Some(b - b'A' + 10),
b'a'..=b'f' => Some(b - b'a' + 10),
_ => None,
}
}
fn parse_path_and_query(full_path: &str) -> (String, HashMap<String, String>) {
if let Some((path, query_str)) = full_path.split_once('?') {
let query = query_str
.split('&')
.filter_map(|pair| {
let (k, v) = pair.split_once('=')?;
Some((decode_uri_component(k), decode_uri_component(v)))
})
.collect();
(path.to_string(), query)
} else {
(full_path.to_string(), HashMap::new())
}
}
fn match_pattern(pattern: &str, path: &str) -> Option<HashMap<String, String>> {
let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if pattern_parts.last() == Some(&"*") {
let prefix = &pattern_parts[..pattern_parts.len() - 1];
if path_parts.len() < prefix.len() {
return None;
}
let mut params = HashMap::new();
for (pp, rp) in prefix.iter().zip(path_parts.iter()) {
if let Some(name) = pp.strip_prefix(':') {
params.insert(name.to_string(), rp.to_string());
} else if pp != rp {
return None;
}
}
return Some(params);
}
if pattern_parts.len() != path_parts.len() {
return None;
}
let mut params = HashMap::new();
for (pp, rp) in pattern_parts.iter().zip(path_parts.iter()) {
if let Some(name) = pp.strip_prefix(':') {
params.insert(name.to_string(), rp.to_string());
} else if pp != rp {
return None;
}
}
Some(params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push_and_state() {
let router = HypenRouter::new();
router.push("/users/42?tab=profile");
let state = router.state();
assert_eq!(state.current_path, "/users/42");
assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
assert_eq!(state.previous_path, Some("/".to_string()));
}
#[test]
fn test_replace() {
let router = HypenRouter::new();
router.push("/page1");
router.replace("/page2");
let state = router.state();
assert_eq!(state.current_path, "/page2");
assert_eq!(state.previous_path, Some("/".to_string()));
}
#[test]
fn test_match_exact() {
let router = HypenRouter::new();
let m = router.match_path("/dashboard", "/dashboard");
assert!(m.is_some());
assert!(m.unwrap().params.is_empty());
}
#[test]
fn test_match_params() {
let router = HypenRouter::new();
let m = router
.match_path("/users/:id/posts/:postId", "/users/42/posts/99")
.unwrap();
assert_eq!(m.params["id"], "42");
assert_eq!(m.params["postId"], "99");
}
#[test]
fn test_match_wildcard() {
let router = HypenRouter::new();
assert!(router.match_path("/api/*", "/api/users/list").is_some());
assert!(router.match_path("/api/*", "/api").is_some());
assert!(router.match_path("/api/*", "/other").is_none());
}
#[test]
fn test_no_match() {
let router = HypenRouter::new();
assert!(router.match_path("/users/:id", "/posts/42").is_none());
assert!(router.match_path("/users/:id", "/users/42/extra").is_none());
}
#[test]
fn test_is_active() {
let router = HypenRouter::new();
router.push("/users/42");
assert!(router.is_active("/users/:id"));
assert!(!router.is_active("/posts/:id"));
}
#[test]
fn test_query_parsing() {
let (path, query) = parse_path_and_query("/search?q=hello&page=2");
assert_eq!(path, "/search");
assert_eq!(query["q"], "hello");
assert_eq!(query["page"], "2");
}
#[test]
fn test_build_url() {
let mut query = HashMap::new();
query.insert("tab".to_string(), "profile".to_string());
let url = HypenRouter::build_url("/users/42", &query);
assert_eq!(url, "/users/42?tab=profile");
}
#[test]
fn test_build_url_no_query() {
let url = HypenRouter::build_url("/home", &HashMap::new());
assert_eq!(url, "/home");
}
#[test]
fn test_build_url_encodes_special_chars() {
let mut query = HashMap::new();
query.insert("msg".to_string(), "hello world".to_string());
query.insert("a&b".to_string(), "1=2".to_string());
let url = HypenRouter::build_url("/search", &query);
assert!(url.contains("msg=hello%20world"));
assert!(url.contains("a%26b=1%3D2"));
assert!(url.starts_with("/search?"));
}
#[test]
fn test_parse_decodes_encoded_query() {
let (path, query) = parse_path_and_query("/search?msg=hello%20world&a%26b=1%3D2");
assert_eq!(path, "/search");
assert_eq!(query.get("msg").map(String::as_str), Some("hello world"));
assert_eq!(query.get("a&b").map(String::as_str), Some("1=2"));
}
#[test]
fn test_encode_decode_roundtrip() {
let original = "hello world&foo=bar?baz#qux%plus+sign";
let encoded = encode_uri_component(original);
let decoded = decode_uri_component(&encoded);
assert_eq!(decoded, original);
assert!(!encoded.contains(' '));
assert!(!encoded.contains('&'));
assert!(!encoded.contains('='));
assert!(!encoded.contains('?'));
assert!(!encoded.contains('#'));
}
#[test]
fn test_plus_decodes_to_space() {
let (path, query) = parse_path_and_query("/search?q=hello+world");
assert_eq!(path, "/search");
assert_eq!(query.get("q").map(String::as_str), Some("hello world"));
}
#[test]
fn test_on_navigate() {
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
let router = HypenRouter::new();
let count = Arc::new(AtomicI32::new(0));
let count_clone = count.clone();
router.on_navigate(move |_| {
count_clone.fetch_add(1, Ordering::SeqCst);
});
router.push("/a");
router.push("/b");
assert_eq!(count.load(Ordering::SeqCst), 2);
}
}