From 1454c5d272f12546ccceea2a38abc93a2b1325aa Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Sat, 24 Feb 2024 11:52:40 -0500 Subject: [PATCH] work on routing --- TODO.md | 1 + examples/async-demo/index.html | 3 +- examples/async-demo/src/lib.rs | 98 ++++-- routing/Cargo.toml | 4 +- routing/src/lib.rs | 8 + routing/src/location/browser.rs | 279 ++++++++++++++++++ routing/src/location/mod.rs | 46 ++- routing/src/location/server.rs | 47 ++- routing/src/matching/horizontal/mod.rs | 28 ++ .../src/matching/horizontal/param_segments.rs | 148 ++++++++++ .../src/matching/horizontal/static_segment.rs | 159 ++++++++++ routing/src/matching/horizontal/tuples.rs | 81 +++++ routing/src/matching/mod.rs | 65 ++++ routing/src/matching/vertical/mod.rs | 5 + routing/src/params.rs | 3 + routing/src/path_segment.rs | 8 + 16 files changed, 939 insertions(+), 44 deletions(-) create mode 100644 routing/src/location/browser.rs create mode 100644 routing/src/matching/horizontal/mod.rs create mode 100644 routing/src/matching/horizontal/param_segments.rs create mode 100644 routing/src/matching/horizontal/static_segment.rs create mode 100644 routing/src/matching/horizontal/tuples.rs create mode 100644 routing/src/matching/mod.rs create mode 100644 routing/src/matching/vertical/mod.rs create mode 100644 routing/src/path_segment.rs diff --git a/TODO.md b/TODO.md index 65408fe26..f6ee2f853 100644 --- a/TODO.md +++ b/TODO.md @@ -27,6 +27,7 @@ - escaping HTML correctly (attributes + text nodes) - router - nested routes + - trailing slashes - \_meta package (and use in hackernews) - integrations - update tests diff --git a/examples/async-demo/index.html b/examples/async-demo/index.html index bb3d81b74..c038b3ea9 100644 --- a/examples/async-demo/index.html +++ b/examples/async-demo/index.html @@ -3,6 +3,7 @@ + - \ No newline at end of file + diff --git a/examples/async-demo/src/lib.rs b/examples/async-demo/src/lib.rs index eb551950b..6743dacb1 100644 --- a/examples/async-demo/src/lib.rs +++ b/examples/async-demo/src/lib.rs @@ -1,16 +1,12 @@ -use futures::StreamExt; -use gloo_timers::{ - callback::Interval, - future::{IntervalStream, TimeoutFuture}, -}; +use gloo_timers::future::TimeoutFuture; use leptos::{ prelude::*, - reactive_graph::{computed::AsyncDerived, owner::Stored, signal::RwSignal}, + reactive_graph::{computed::AsyncDerived, signal::RwSignal}, tachys::log, - view, Executor, IntoView, + view, IntoView, }; use send_wrapper::SendWrapper; -use std::future::Future; +use std::future::{Future, IntoFuture}; fn wait( id: char, @@ -24,21 +20,90 @@ fn wait( }) } +fn sleep(seconds: u32) -> impl Future + Send + Sync { + SendWrapper::new(async move { + TimeoutFuture::new(seconds * 1000).await; + }) +} + pub fn async_example() -> impl IntoView { let a = RwSignal::new(0); let b = RwSignal::new(1); + let a2 = create_resource(a, |a| wait('A', 1, a)); + let b2 = create_resource(b, |a| wait('A', 1, b)); + + view! { + + + + {move || a2().map(move |a2| { + b2().map(move |b2| { + view! { +

{a2} + {b2}

+ } + }) + })} +
+

+ //{times} +

+ } +} + +/* + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + */ +pub fn notes() -> impl IntoView { + let a = RwSignal::new(0); + let b = RwSignal::new(1); + let a2 = AsyncDerived::new(move || wait('A', 1, a.get())); let b2 = AsyncDerived::new(move || wait('B', 3, b.get())); - let c = AsyncDerived::new(move || async move { a2.await + b2.await }); + let c = AsyncDerived::new(move || async move { + sleep(1).await; + a2.await + b2.await + }); - let times = move || { - //let a2 = wait('A', 1, a.get()); - //let b2 = wait('B', 3, b.get()); + let a_and_b = move || { async move { (a2.await, " + ", b2.await) } - //async move { (a2.await, " + ", b2.await, " = ", c.await) } .suspend() - .with_fallback("Loading...") + .with_fallback("Loading A and B...") + .track() + }; + + let c = move || { + c.into_future() + .suspend() + .with_fallback("Loading C...") .track() }; @@ -53,8 +118,7 @@ pub fn async_example() -> impl IntoView { }> {b} -

- {times} -

+

{a_and_b}

+

{c}

} } diff --git a/routing/Cargo.toml b/routing/Cargo.toml index c14009cff..73bb393a4 100644 --- a/routing/Cargo.toml +++ b/routing/Cargo.toml @@ -18,6 +18,9 @@ tracing = { version = "0.1", optional = true } [dependencies.web-sys] version = "0.3" features = [ + "Document", + "Window", + "console", # History/Routing "History", "HtmlAnchorElement", @@ -38,7 +41,6 @@ features = [ "RequestInit", "RequestMode", "Response", - "Window", ] [features] diff --git a/routing/src/lib.rs b/routing/src/lib.rs index be84ee4d1..9cdb52ae2 100644 --- a/routing/src/lib.rs +++ b/routing/src/lib.rs @@ -1,6 +1,14 @@ +#![no_std] + +#[macro_use] +extern crate alloc; + //mod generate_route_list; pub mod location; +pub mod matching; pub mod params; +mod path_segment; +pub use path_segment::*; //pub mod matching; //cfg(feature = "reaccy")] //pub mod reactive; diff --git a/routing/src/location/browser.rs b/routing/src/location/browser.rs new file mode 100644 index 000000000..f12c26054 --- /dev/null +++ b/routing/src/location/browser.rs @@ -0,0 +1,279 @@ +use super::{Location, LocationChange, State, Url, BASE}; +use crate::params::Params; +use alloc::{borrow::Cow, boxed::Box, rc::Rc, string::String}; +use core::fmt; +use js_sys::{try_iter, Array, JsString, Reflect}; +use wasm_bindgen::{closure::Closure, JsCast, JsValue}; +use web_sys::{ + Document, Event, HtmlAnchorElement, MouseEvent, UrlSearchParams, Window, +}; + +fn document() -> Document { + window().document().expect( + "router cannot be used in a JS environment without a `document`", + ) +} + +fn window() -> Window { + web_sys::window() + .expect("router cannot be used in a JS environment without a `window`") +} + +#[derive(Clone, Default)] +pub struct BrowserUrl { + navigation_hook: Option>, +} + +impl fmt::Debug for BrowserUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BrowserUrl").finish_non_exhaustive() + } +} + +impl BrowserUrl { + pub fn new() -> Self { + Self::default() + } + + fn try_current() -> Result { + let location = window().location(); + Ok(Url { + origin: location.origin()?, + path: location.pathname()?, + search: location + .search()? + .strip_prefix('?') + .map(String::from) + .unwrap_or_default(), + search_params: search_params_from_web_url( + &UrlSearchParams::new_with_str(&location.search()?)?, + )?, + hash: location.hash()?, + }) + } + + fn unescape(s: &str) -> String { + js_sys::decode_uri(s).unwrap().into() + } + + fn scroll_to_el(loc_scroll: bool) { + if let Ok(hash) = window().location().hash() { + if !hash.is_empty() { + let hash = js_sys::decode_uri(&hash[1..]) + .ok() + .and_then(|decoded| decoded.as_string()) + .unwrap_or(hash); + let el = document().get_element_by_id(&hash); + if let Some(el) = el { + el.scroll_into_view(); + return; + } + } + } + + // scroll to top + if loc_scroll { + window().scroll_to_with_x_and_y(0.0, 0.0); + } + } +} + +impl Location for BrowserUrl { + type Error = JsValue; + + fn init(&self) { + let this = self.clone(); + let handle_anchor_click = move |ev: Event| { + let ev = ev.unchecked_into::(); + if ev.default_prevented() + || ev.button() != 0 + || ev.meta_key() + || ev.alt_key() + || ev.ctrl_key() + || ev.shift_key() + { + return; + } + + let composed_path = ev.composed_path(); + let mut a: Option = None; + for i in 0..composed_path.length() { + if let Ok(el) = + composed_path.get(i).dyn_into::() + { + a = Some(el); + } + } + if let Some(a) = a { + let href = a.href(); + let target = a.target(); + + // let browser handle this event if link has target, + // or if it doesn't have href or state + // TODO "state" is set as a prop, not an attribute + if !target.is_empty() + || (href.is_empty() && !a.has_attribute("state")) + { + return; + } + + let rel = a.get_attribute("rel").unwrap_or_default(); + let mut rel = rel.split([' ', '\t']); + + // let browser handle event if it has rel=external or download + if a.has_attribute("download") || rel.any(|p| p == "external") { + return; + } + + let base = window() + .location() + .origin() + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(BASE)); + let url = Self::parse_with_base(href.as_str(), &base).unwrap(); + let path_name = Self::unescape(&url.path); + + // let browser handle this event if it leaves our domain + // or our base path + if url.origin + != window().location().origin().unwrap_or_default() + // TODO base path for router + /* || (true // TODO base_path //!self.base_path.is_empty() + && !path_name.is_empty() + && !path_name + .to_lowercase() + .starts_with(&self.base_path.to_lowercase())) */ + { + return; + } + + let to = path_name + + if url.search.is_empty() { "" } else { "?" } + + &Self::unescape(&url.search) + + &Self::unescape(&url.hash); + let state = Reflect::get(&a, &JsValue::from_str("state")) + .ok() + .and_then(|value| { + if value == JsValue::UNDEFINED { + None + } else { + Some(value) + } + }); + + ev.prevent_default(); + + let replace = Reflect::get(&a, &JsValue::from_str("replace")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let change = LocationChange { + value: to, + replace, + scroll: true, + state: State(state), + }; + + // run any router-specific hook + if let Some(navigate_hook) = &this.navigation_hook { + navigate_hook(url); + } + + // complete navigation + this.navigate(&change); + } + }; + + let closure = Closure::wrap( + Box::new(handle_anchor_click) as Box + ) + .into_js_value(); + window() + .add_event_listener_with_callback( + "click", + closure.as_ref().unchecked_ref(), + ) + .expect( + "couldn't add `click` listener to `window` to handle `` \ + clicks", + ); + + // handle popstate event (forward/back navigation) + if let Some(navigation_hook) = self.navigation_hook.clone() { + let cb = move || match Self::try_current() { + Ok(url) => navigation_hook(url), + Err(e) => { + #[cfg(debug_assertions)] + web_sys::console::error_1(&e); + _ = e; + } + }; + let closure = + Closure::wrap(Box::new(cb) as Box).into_js_value(); + window() + .add_event_listener_with_callback( + "popstate", + closure.as_ref().unchecked_ref(), + ) + .expect("couldn't add `popstate` listener to `window`"); + } + } + + fn set_navigation_hook(&mut self, cb: impl Fn(Url) + 'static) { + self.navigation_hook = Some(Rc::new(cb)); + } + + fn navigate(&self, loc: &LocationChange) { + let history = window().history().unwrap(); + + if loc.replace { + history + .replace_state_with_url( + &loc.state.to_js_value(), + "", + Some(&loc.value), + ) + .unwrap(); + } else { + // push the "forward direction" marker + let state = &loc.state.to_js_value(); + history + .push_state_with_url(state, "", Some(&loc.value)) + .unwrap(); + } + // scroll to el + Self::scroll_to_el(loc.scroll); + } + + fn parse_with_base(url: &str, base: &str) -> Result { + let location = web_sys::Url::new_with_base(url, base)?; + Ok(Url { + origin: location.origin(), + path: location.pathname(), + search: location + .search() + .strip_prefix('?') + .map(String::from) + .unwrap_or_default(), + search_params: search_params_from_web_url( + &location.search_params(), + )?, + hash: location.hash(), + }) + } +} + +fn search_params_from_web_url( + params: &web_sys::UrlSearchParams, +) -> Result, JsValue> { + let mut search_params = Params::new(); + for pair in try_iter(params)?.into_iter().flatten() { + let row = pair?.unchecked_into::(); + search_params.push(( + row.get(0).unchecked_into::().into(), + row.get(1).unchecked_into::().into(), + )); + } + Ok(search_params) +} diff --git a/routing/src/location/mod.rs b/routing/src/location/mod.rs index fdd7dd638..cbe7f4092 100644 --- a/routing/src/location/mod.rs +++ b/routing/src/location/mod.rs @@ -1,19 +1,44 @@ use crate::params::Params; -use std::fmt::Debug; +use alloc::string::String; +use core::fmt::Debug; use wasm_bindgen::JsValue; +mod browser; mod server; +pub use browser::*; pub use server::*; pub(crate) const BASE: &str = "https://leptos.dev"; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Url { - pub origin: String, - pub pathname: String, - pub search: String, - pub search_params: Params, - pub hash: String, + origin: String, + path: String, + search: String, + search_params: Params, + hash: String, +} + +impl Url { + pub fn origin(&self) -> &str { + &self.origin + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn search(&self) -> &str { + &self.search + } + + pub fn search_params(&self) -> &Params { + &self.search_params + } + + pub fn hash(&self) -> &str { + &self.hash + } } /// A description of a navigation. @@ -47,13 +72,16 @@ pub trait Location { /// Sets up any global event listeners or other initialization needed. fn init(&self); - /// Returns the current URL. - fn try_to_url(&self) -> Result; - fn set_navigation_hook(&mut self, cb: impl Fn(Url) + 'static); /// Navigate to a new location. fn navigate(&self, loc: &LocationChange); + + fn parse(url: &str) -> Result { + Self::parse_with_base(url, BASE) + } + + fn parse_with_base(url: &str, base: &str) -> Result; } #[derive(Debug, Clone, Default, PartialEq)] diff --git a/routing/src/location/server.rs b/routing/src/location/server.rs index 2645c099d..aebda7cce 100644 --- a/routing/src/location/server.rs +++ b/routing/src/location/server.rs @@ -1,15 +1,14 @@ use super::{Location, LocationChange, Url}; +use alloc::string::{String, ToString}; +use core::fmt::Display; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestUrl(String); impl RequestUrl { - /// Creates a server-side request URL from a path, with an optional initial slash. - pub fn from_path(path: impl AsRef) -> Self { - let path = path.as_ref().trim_start_matches('/'); - let mut string = String::with_capacity(path.len()); - string.push_str(path); - Self(string) + /// Creates a server-side request URL from a path. + pub fn new(path: impl Display) -> Self { + Self(path.to_string()) } } @@ -24,25 +23,27 @@ impl Location for RequestUrl { fn init(&self) {} - fn try_to_url_with_base(&self, base: &str) -> Result { - let url = String::with_capacity(self.0.len() + 1 + base); - let url = url::Url::parse(&self.0)?; + fn set_navigation_hook(&mut self, _cb: impl FnMut(Url) + 'static) {} + + fn navigate(&self, _loc: &LocationChange) {} + + fn parse_with_base(url: &str, base: &str) -> Result { + let base = url::Url::parse(base)?; + let url = url::Url::options().base_url(Some(&base)).parse(&url)?; + let search_params = url .query_pairs() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); + Ok(Url { origin: url.origin().unicode_serialization(), - pathname: url.path().to_string(), + path: url.path().to_string(), search: url.query().unwrap_or_default().to_string(), search_params, hash: Default::default(), }) } - - fn set_navigation_hook(&mut self, _cb: impl FnMut(Url) + 'static) {} - - fn navigate(&self, _loc: &LocationChange) {} } #[cfg(test)] @@ -50,8 +51,22 @@ mod tests { use super::RequestUrl; use crate::location::Location; + #[test] pub fn should_parse_url_without_origin() { - let req = RequestUrl::from_path("/foo/bar"); - let url = req.try_to_url().expect("could not parse URL"); + let url = RequestUrl::parse("/foo/bar").unwrap(); + assert_eq!(url.path(), "/foo/bar"); + } + + #[test] + pub fn should_not_parse_url_without_slash() { + let url = RequestUrl::parse("foo/bar").unwrap(); + assert_eq!(url.path(), "/foo/bar"); + } + + #[test] + pub fn should_parse_with_base() { + let url = RequestUrl::parse("https://www.example.com/foo/bar").unwrap(); + assert_eq!(url.origin(), "https://www.example.com"); + assert_eq!(url.path(), "/foo/bar"); } } diff --git a/routing/src/matching/horizontal/mod.rs b/routing/src/matching/horizontal/mod.rs new file mode 100644 index 000000000..9c80e7da4 --- /dev/null +++ b/routing/src/matching/horizontal/mod.rs @@ -0,0 +1,28 @@ +use crate::PathSegment; +use alloc::vec::Vec; +use core::str::Chars; + +mod param_segments; +mod static_segment; +mod tuples; +use super::PartialPathMatch; +pub use param_segments::*; +pub use static_segment::*; + +/// Defines a route which may or may not be matched by any given URL, +/// or URL segment. +/// +/// This is a "horizontal" matching: i.e., it treats a tuple of route segments +/// as subsequent segments of the URL and tries to match them all. For a "vertical" +/// matching that sees a tuple as alternatives to one another, see [`RouteChild`](super::RouteChild). +pub trait PossibleRouteMatch { + fn matches(&self, path: &str) -> bool { + self.matches_iter(&mut path.chars()) + } + + fn matches_iter(&self, path: &mut Chars) -> bool; + + fn test<'a>(&self, path: &'a str) -> Option>; + + fn generate_path(&self, path: &mut Vec); +} diff --git a/routing/src/matching/horizontal/param_segments.rs b/routing/src/matching/horizontal/param_segments.rs new file mode 100644 index 000000000..be87f4078 --- /dev/null +++ b/routing/src/matching/horizontal/param_segments.rs @@ -0,0 +1,148 @@ +use super::{PartialPathMatch, PossibleRouteMatch}; +use crate::PathSegment; +use alloc::{string::String, vec::Vec}; +use core::str::Chars; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct ParamSegment(pub &'static str); + +impl PossibleRouteMatch for ParamSegment { + fn matches_iter(&self, test: &mut Chars) -> bool { + let mut test = test.peekable(); + // match an initial / + if test.peek() == Some(&'/') { + test.next(); + } + for char in test { + // when we get a closing /, stop matching + if char == '/' { + break; + } + } + true + } + + fn test<'a>(&self, path: &'a str) -> Option> { + let mut matched = String::new(); + let mut param_value = String::new(); + let mut test = path.chars(); + + // match an initial / + if let Some('/') = test.next() { + matched.push('/'); + } + for char in test { + // when we get a closing /, stop matching + if char == '/' { + break; + } + // otherwise, push into the matched param + else { + matched.push(char); + param_value.push(char); + } + } + + let next_index = matched.len(); + Some(PartialPathMatch::new( + &path[next_index..], + vec![(self.0, param_value)], + matched, + )) + } + + fn generate_path(&self, path: &mut Vec) { + path.push(PathSegment::Param(self.0.into())); + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct WildcardSegment(pub &'static str); + +impl PossibleRouteMatch for WildcardSegment { + fn matches_iter(&self, _path: &mut Chars) -> bool { + true + } + + fn test<'a>(&self, path: &'a str) -> Option> { + let mut matched = String::new(); + let mut param_value = String::new(); + let mut test = path.chars(); + + // match an initial / + if let Some('/') = test.next() { + matched.push('/'); + } + for char in test { + matched.push(char); + param_value.push(char); + } + + let next_index = matched.len(); + Some(PartialPathMatch::new( + &path[next_index..], + vec![(self.0, param_value)], + matched, + )) + } + + fn generate_path(&self, path: &mut Vec) { + path.push(PathSegment::Splat(self.0.into())); + } +} + +#[cfg(test)] +mod tests { + use super::PossibleRouteMatch; + use crate::matching::{ParamSegment, StaticSegment, WildcardSegment}; + use alloc::string::ToString; + + #[test] + fn single_param_match() { + let path = "/foo"; + let def = ParamSegment("a"); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), ""); + assert_eq!(matched.params()[0], ("a", "foo".to_string())); + } + + #[test] + fn single_param_match_with_trailing_slash() { + let path = "/foo/"; + let def = ParamSegment("a"); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), "/"); + assert_eq!(matched.params()[0], ("a", "foo".to_string())); + } + + #[test] + fn tuple_of_param_matches() { + let path = "/foo/bar"; + let def = (ParamSegment("a"), ParamSegment("b")); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar"); + assert_eq!(matched.remaining(), ""); + assert_eq!(matched.params()[0], ("a", "foo".to_string())); + assert_eq!(matched.params()[1], ("b", "bar".to_string())); + } + + #[test] + fn splat_should_match_all() { + let path = "/foo/bar/////"; + let def = ( + StaticSegment("foo"), + StaticSegment("bar"), + WildcardSegment("rest"), + ); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar/////"); + assert_eq!(matched.remaining(), ""); + assert_eq!(matched.params()[0], ("rest", "////".to_string())); + } +} diff --git a/routing/src/matching/horizontal/static_segment.rs b/routing/src/matching/horizontal/static_segment.rs new file mode 100644 index 000000000..1a30ecabe --- /dev/null +++ b/routing/src/matching/horizontal/static_segment.rs @@ -0,0 +1,159 @@ +use super::{PartialPathMatch, PossibleRouteMatch}; +use crate::PathSegment; +use alloc::{string::String, vec::Vec}; +use core::str::Chars; + +impl PossibleRouteMatch for () { + fn test<'a>(&self, path: &'a str) -> Option> { + Some(PartialPathMatch::new(path, [], "")) + } + + fn matches_iter(&self, _path: &mut Chars) -> bool { + true + } + + fn generate_path(&self, _path: &mut Vec) {} +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct StaticSegment(pub &'static str); + +impl PossibleRouteMatch for StaticSegment { + fn matches_iter(&self, test: &mut Chars) -> bool { + let mut this = self.0.chars(); + let mut test = test.peekable(); + if test.peek() == Some(&'/') { + test.next(); + } + + // unless this segment is empty, we start by + // assuming that it has not actually matched + let mut has_matched = self.0.is_empty(); + for char in test { + // when we get a closing /, stop matching + if char == '/' { + break; + } + // if the next character in the path doesn't match the + // next character in the segment, we don't match + else if this.next() != Some(char) { + return false; + } else { + has_matched = true; + } + } + + has_matched + } + + fn test<'a>(&self, path: &'a str) -> Option> { + let mut matched = String::new(); + let mut test = path.chars(); + let mut this = self.0.chars(); + + // match an initial / + if let Some('/') = test.next() { + matched.push('/'); + } + for char in test { + // when we get a closing /, stop matching + if char == '/' { + break; + } + // if the next character in the path matches the + // next character in the segment, add it to the match + else if Some(char) == this.next() { + matched.push(char); + } + // otherwise, this route doesn't match and we should + // return None + else { + return None; + } + } + + // build the match object + // the remaining is built from the path in, with the slice moved + // by the length of this match + let next_index = matched.len(); + Some(PartialPathMatch::new( + &path[next_index..], + Vec::new(), + matched, + )) + } + + fn generate_path(&self, path: &mut Vec) { + path.push(PathSegment::Static(self.0.into())) + } +} + +#[cfg(test)] +mod tests { + use super::{PossibleRouteMatch, StaticSegment}; + + #[test] + fn single_static_match() { + let path = "/foo"; + let def = StaticSegment("foo"); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), ""); + assert!(matched.params().is_empty()); + } + + #[test] + fn single_static_mismatch() { + let path = "/foo"; + let def = StaticSegment("bar"); + assert!(def.test(path).is_none()); + } + + #[test] + fn single_static_match_with_trailing_slash() { + let path = "/foo/"; + let def = StaticSegment("foo"); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), "/"); + assert!(matched.params().is_empty()); + } + + #[test] + fn tuple_of_static_matches() { + let path = "/foo/bar"; + let def = (StaticSegment("foo"), StaticSegment("bar")); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar"); + assert_eq!(matched.remaining(), ""); + assert!(matched.params().is_empty()); + } + + #[test] + fn tuple_static_mismatch() { + let path = "/foo/baz"; + let def = (StaticSegment("foo"), StaticSegment("bar")); + assert!(!def.matches(path)); + assert!(def.test(path).is_none()); + } + + #[test] + fn arbitrary_nesting_of_tuples_has_no_effect_on_matching() { + let path = "/foo/bar"; + let def = ( + (), + (StaticSegment("foo")), + (), + ((), ()), + StaticSegment("bar"), + (), + ); + assert!(def.matches(path)); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar"); + assert_eq!(matched.remaining(), ""); + assert!(matched.params().is_empty()); + } +} diff --git a/routing/src/matching/horizontal/tuples.rs b/routing/src/matching/horizontal/tuples.rs new file mode 100644 index 000000000..fc081e666 --- /dev/null +++ b/routing/src/matching/horizontal/tuples.rs @@ -0,0 +1,81 @@ +use super::{PartialPathMatch, PathSegment, PossibleRouteMatch}; +use alloc::{string::String, vec::Vec}; +use core::str::Chars; + +macro_rules! tuples { + ($($ty:ident),*) => { + impl<$($ty),*> PossibleRouteMatch for ($($ty,)*) + where + $($ty: PossibleRouteMatch),*, + { + fn matches_iter(&self, path: &mut Chars) -> bool + { + #[allow(non_snake_case)] + let ($($ty,)*) = &self; + $($ty.matches_iter(path) &&)* true + } + + fn test<'a>(&self, path: &'a str) -> Option> + { + let mut full_params = Vec::new(); + let mut full_matched = String::new(); + #[allow(non_snake_case)] + let ($($ty,)*) = &self; + $( + let PartialPathMatch { + remaining, + matched, + params + } = $ty.test(path)?; + let path = remaining; + full_matched.push_str(&matched); + full_params.extend(params); + )* + Some(PartialPathMatch { + remaining: path, + matched: full_matched, + params: full_params + }) + } + + fn generate_path(&self, path: &mut Vec) { + #[allow(non_snake_case)] + let ($($ty,)*) = &self; + $( + $ty.generate_path(path); + )* + } + } + }; +} + +tuples!(A, B); +tuples!(A, B, C); +tuples!(A, B, C, D); +tuples!(A, B, C, D, E); +tuples!(A, B, C, D, E, F); +tuples!(A, B, C, D, E, F, G); +tuples!(A, B, C, D, E, F, G, H); +tuples!(A, B, C, D, E, F, G, H, I); +tuples!(A, B, C, D, E, F, G, H, I, J); +tuples!(A, B, C, D, E, F, G, H, I, J, K); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W); +tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X); +tuples!( + A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y +); +tuples!( + A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, + Z +); diff --git a/routing/src/matching/mod.rs b/routing/src/matching/mod.rs new file mode 100644 index 000000000..7c268bacd --- /dev/null +++ b/routing/src/matching/mod.rs @@ -0,0 +1,65 @@ +mod horizontal; +mod vertical; +use crate::params::Params; +use alloc::{borrow::Cow, string::String, vec::Vec}; +pub use horizontal::*; +pub use vertical::*; + +pub struct Routes { + base: Cow<'static, str>, + children: Children, +} + +pub struct RouteMatch<'a> { + matched_nested_routes: Vec>, +} + +pub struct NestedRouteMatch<'a> { + /// The portion of the full path matched by this nested route. + matched_path: String, + /// The map of params matched by this route. + params: Params<&'static str>, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct NestedRoute { + segments: Segments, + children: Children, +} + +#[derive(Debug)] +pub struct PartialPathMatch<'a> { + pub(crate) remaining: &'a str, + pub(crate) params: Params<&'static str>, + pub(crate) matched: String, +} + +impl<'a> PartialPathMatch<'a> { + pub fn new( + remaining: &'a str, + params: impl Into>, + matched: impl Into, + ) -> Self { + Self { + remaining, + params: params.into(), + matched: matched.into(), + } + } + + pub fn is_complete(&self) -> bool { + self.remaining.is_empty() || self.remaining == "/" + } + + pub fn remaining(&self) -> &str { + self.remaining + } + + pub fn params(&self) -> &[(&'static str, String)] { + &self.params + } + + pub fn matched(&self) -> &str { + self.matched.as_str() + } +} diff --git a/routing/src/matching/vertical/mod.rs b/routing/src/matching/vertical/mod.rs new file mode 100644 index 000000000..4f4da11dd --- /dev/null +++ b/routing/src/matching/vertical/mod.rs @@ -0,0 +1,5 @@ +use super::PartialPathMatch; + +pub trait ChooseRoute { + fn choose_route<'a>(&self, path: &'a str) -> Option>; +} diff --git a/routing/src/params.rs b/routing/src/params.rs index b032a3ae2..e4183e606 100644 --- a/routing/src/params.rs +++ b/routing/src/params.rs @@ -1 +1,4 @@ +extern crate alloc; +use alloc::{string::String, vec::Vec}; + pub(crate) type Params = Vec<(K, String)>; diff --git a/routing/src/path_segment.rs b/routing/src/path_segment.rs new file mode 100644 index 000000000..0029e13d8 --- /dev/null +++ b/routing/src/path_segment.rs @@ -0,0 +1,8 @@ +use alloc::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathSegment { + Static(Cow<'static, str>), + Param(Cow<'static, str>), + Splat(Cow<'static, str>), +}