diff --git a/examples/hackernews/src/lib.rs b/examples/hackernews/src/lib.rs index 4d391ea825..17d3c24402 100644 --- a/examples/hackernews/src/lib.rs +++ b/examples/hackernews/src/lib.rs @@ -4,7 +4,7 @@ mod routes; use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet}; use leptos_router::{ components::{FlatRoutes, Route, Router, RoutingProgress}, - ParamSegment, StaticSegment, + OptionalParamSegment, ParamSegment, StaticSegment, }; use routes::{nav::*, stories::*, story::*, users::*}; use std::time::Duration; @@ -28,9 +28,7 @@ pub fn App() -> impl IntoView { - - // TODO allow optional params without duplication - + diff --git a/examples/hackernews_axum/src/lib.rs b/examples/hackernews_axum/src/lib.rs index 6e5b11b996..04c2efbb8c 100644 --- a/examples/hackernews_axum/src/lib.rs +++ b/examples/hackernews_axum/src/lib.rs @@ -4,7 +4,7 @@ mod routes; use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet}; use leptos_router::{ components::{FlatRoutes, Route, Router, RoutingProgress}, - ParamSegment, StaticSegment, + OptionalParamSegment, ParamSegment, StaticSegment, }; use routes::{nav::*, stories::*, story::*, users::*}; use std::time::Duration; @@ -46,9 +46,7 @@ pub fn App() -> impl IntoView { - - // TODO allow optional params without duplication - + diff --git a/examples/hackernews_islands_axum/src/lib.rs b/examples/hackernews_islands_axum/src/lib.rs index 260e4849a4..4558a78d67 100644 --- a/examples/hackernews_islands_axum/src/lib.rs +++ b/examples/hackernews_islands_axum/src/lib.rs @@ -4,7 +4,7 @@ mod routes; use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet}; use leptos_router::{ components::{FlatRoutes, Route, Router}, - ParamSegment, StaticSegment, + OptionalParamSegment, ParamSegment, StaticSegment, }; use routes::{nav::*, stories::*, story::*, users::*}; #[cfg(feature = "ssr")] @@ -42,9 +42,7 @@ pub fn App() -> impl IntoView { - - // TODO allow optional params without duplication - + diff --git a/examples/hackernews_js_fetch/src/lib.rs b/examples/hackernews_js_fetch/src/lib.rs index c365687e7e..f12c6f31a6 100644 --- a/examples/hackernews_js_fetch/src/lib.rs +++ b/examples/hackernews_js_fetch/src/lib.rs @@ -4,7 +4,7 @@ mod routes; use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet}; use leptos_router::{ components::{FlatRoutes, Route, Router, RoutingProgress}, - ParamSegment, StaticSegment, + OptionalParamSegment, ParamSegment, StaticSegment, }; use routes::{nav::*, stories::*, story::*, users::*}; use std::time::Duration; @@ -46,9 +46,7 @@ pub fn App() -> impl IntoView { - - // TODO allow optional params without duplication - + diff --git a/integrations/actix/src/lib.rs b/integrations/actix/src/lib.rs index c2d06cc268..4eae047e4c 100644 --- a/integrations/actix/src/lib.rs +++ b/integrations/actix/src/lib.rs @@ -35,7 +35,7 @@ use leptos_router::{ components::provide_server_redirect, location::RequestUrl, static_routes::{RegenerationFn, ResolvedStaticPath}, - Method, PathSegment, RouteList, RouteListing, SsrMode, + ExpandOptionals, Method, PathSegment, RouteList, RouteListing, SsrMode, }; use once_cell::sync::Lazy; use parking_lot::RwLock; @@ -901,7 +901,7 @@ trait ActixPath { fn to_actix_path(&self) -> String; } -impl ActixPath for &[PathSegment] { +impl ActixPath for Vec { fn to_actix_path(&self) -> String { let mut path = String::new(); for segment in self.iter() { @@ -923,6 +923,14 @@ impl ActixPath for &[PathSegment] { path.push_str(":.*}"); } PathSegment::Unit => {} + PathSegment::OptionalParam(_) => { + #[cfg(feature = "tracing")] + tracing::error!( + "to_axum_path should only be called on expanded \ + paths, which do not have OptionalParam any longer" + ); + Default::default() + } } } path @@ -938,23 +946,34 @@ pub struct ActixRouteListing { regenerate: Vec, } -impl From for ActixRouteListing { - fn from(value: RouteListing) -> Self { - let path = value.path().to_actix_path(); - let path = if path.is_empty() { - "/".to_string() - } else { - path - }; - let mode = value.mode(); - let methods = value.methods().collect(); - let regenerate = value.regenerate().into(); - Self { - path, - mode: mode.clone(), - methods, - regenerate, - } +trait IntoRouteListing: Sized { + fn into_route_listing(self) -> Vec; +} + +impl IntoRouteListing for RouteListing { + fn into_route_listing(self) -> Vec { + self.path() + .to_vec() + .expand_optionals() + .into_iter() + .map(|path| { + let path = path.to_actix_path(); + let path = if path.is_empty() { + "/".to_string() + } else { + path + }; + let mode = self.mode(); + let methods = self.methods().collect(); + let regenerate = self.regenerate().into(); + ActixRouteListing { + path, + mode: mode.clone(), + methods, + regenerate, + } + }) + .collect() } } @@ -1033,7 +1052,7 @@ where let mut routes = routes .into_inner() .into_iter() - .map(ActixRouteListing::from) + .flat_map(IntoRouteListing::into_route_listing) .collect::>(); ( diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs index 74cf7c82a4..f0450bcfb0 100644 --- a/integrations/axum/src/lib.rs +++ b/integrations/axum/src/lib.rs @@ -66,7 +66,7 @@ use leptos_router::{ components::provide_server_redirect, location::RequestUrl, static_routes::{RegenerationFn, StaticParamsMap}, - PathSegment, RouteList, RouteListing, SsrMode, + ExpandOptionals, PathSegment, RouteList, RouteListing, SsrMode, }; #[cfg(feature = "default")] use once_cell::sync::Lazy; @@ -1267,23 +1267,34 @@ pub struct AxumRouteListing { regenerate: Vec, } -impl From for AxumRouteListing { - fn from(value: RouteListing) -> Self { - let path = value.path().to_axum_path(); - let path = if path.is_empty() { - "/".to_string() - } else { - path - }; - let mode = value.mode(); - let methods = value.methods().collect(); - let regenerate = value.regenerate().into(); - Self { - path, - mode: mode.clone(), - methods, - regenerate, - } +trait IntoRouteListing: Sized { + fn into_route_listing(self) -> Vec; +} + +impl IntoRouteListing for RouteListing { + fn into_route_listing(self) -> Vec { + self.path() + .to_vec() + .expand_optionals() + .into_iter() + .map(|path| { + let path = path.to_axum_path(); + let path = if path.is_empty() { + "/".to_string() + } else { + path + }; + let mode = self.mode(); + let methods = self.methods().collect(); + let regenerate = self.regenerate().into(); + AxumRouteListing { + path, + mode: mode.clone(), + methods, + regenerate, + } + }) + .collect() } } @@ -1367,7 +1378,7 @@ where let mut routes = routes .into_inner() .into_iter() - .map(AxumRouteListing::from) + .flat_map(IntoRouteListing::into_route_listing) .collect::>(); ( @@ -1700,7 +1711,7 @@ trait AxumPath { fn to_axum_path(&self) -> String; } -impl AxumPath for &[PathSegment] { +impl AxumPath for Vec { fn to_axum_path(&self) -> String { let mut path = String::new(); for segment in self.iter() { @@ -1720,6 +1731,14 @@ impl AxumPath for &[PathSegment] { path.push_str(s); } PathSegment::Unit => {} + PathSegment::OptionalParam(_) => { + #[cfg(feature = "tracing")] + tracing::error!( + "to_axum_path should only be called on expanded \ + paths, which do not have OptionalParam any longer" + ); + Default::default() + } } } path diff --git a/router/src/flat_router.rs b/router/src/flat_router.rs index 2308886fc6..f80a4bd6a4 100644 --- a/router/src/flat_router.rs +++ b/router/src/flat_router.rs @@ -1,11 +1,11 @@ use crate::{ hooks::Matched, location::{LocationProvider, Url}, - matching::Routes, + matching::{MatchParams, Routes}, params::ParamsMap, view_transition::start_view_transition, - ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, PathSegment, - RouteList, RouteListing, RouteMatchId, + ChooseView, MatchInterface, MatchNestedRoutes, PathSegment, RouteList, + RouteListing, RouteMatchId, }; use any_spawner::Executor; use either_of::Either; diff --git a/router/src/matching/horizontal/mod.rs b/router/src/matching/horizontal/mod.rs index 2c9684dd5a..2b60cc4b1e 100644 --- a/router/src/matching/horizontal/mod.rs +++ b/router/src/matching/horizontal/mod.rs @@ -1,5 +1,4 @@ use super::{PartialPathMatch, PathSegment}; -use std::borrow::Cow; mod param_segments; mod static_segment; mod tuples; @@ -13,12 +12,9 @@ pub use static_segment::*; /// 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 { - type ParamsIter: IntoIterator, String)>; + const OPTIONAL: bool = false; - fn test<'a>( - &self, - path: &'a str, - ) -> Option>; + fn test<'a>(&self, path: &'a str) -> Option>; fn generate_path(&self, path: &mut Vec); } diff --git a/router/src/matching/horizontal/param_segments.rs b/router/src/matching/horizontal/param_segments.rs index c00947f168..35ead7c048 100644 --- a/router/src/matching/horizontal/param_segments.rs +++ b/router/src/matching/horizontal/param_segments.rs @@ -14,14 +14,16 @@ use std::borrow::Cow; /// /// // Manual definition /// let manual = (ParamSegment("message"),); -/// let (key, value) = manual.test(path)?.params().last()?; +/// let params = manual.test(path)?.params(); +/// let (key, value) = params.last()?; /// /// assert_eq!(key, "message"); /// assert_eq!(value, "hello"); /// /// // Macro definition /// let using_macro = path!("/:message"); -/// let (key, value) = using_macro.test(path)?.params().last()?; +/// let params = using_macro.test(path)?.params(); +/// let (key, value) = params.last()?; /// /// assert_eq!(key, "message"); /// assert_eq!(value, "hello"); @@ -33,12 +35,7 @@ use std::borrow::Cow; pub struct ParamSegment(pub &'static str); impl PossibleRouteMatch for ParamSegment { - type ParamsIter = iter::Once<(Cow<'static, str>, String)>; - - fn test<'a>( - &self, - path: &'a str, - ) -> Option> { + fn test<'a>(&self, path: &'a str) -> Option> { let mut matched_len = 0; let mut param_offset = 0; let mut param_len = 0; @@ -66,10 +63,10 @@ impl PossibleRouteMatch for ParamSegment { } let (matched, remaining) = path.split_at(matched_len); - let param_value = iter::once(( + let param_value = vec![( Cow::Borrowed(self.0), path[param_offset..param_len + param_offset].to_string(), - )); + )]; Some(PartialPathMatch::new(remaining, param_value, matched)) } @@ -93,14 +90,16 @@ impl PossibleRouteMatch for ParamSegment { /// /// // Manual definition /// let manual = (StaticSegment("echo"), WildcardSegment("kitchen_sink")); -/// let (key, value) = manual.test(path)?.params().last()?; +/// let params = manual.test(path)?.params(); +/// let (key, value) = params.last()?; /// /// assert_eq!(key, "kitchen_sink"); /// assert_eq!(value, "send/sync/and/static"); /// /// // Macro definition /// let using_macro = path!("/echo/*else"); -/// let (key, value) = using_macro.test(path)?.params().last()?; +/// let params = using_macro.test(path)?.params(); +/// let (key, value) = params.last()?; /// /// assert_eq!(key, "else"); /// assert_eq!(value, "send/sync/and/static"); @@ -122,12 +121,7 @@ impl PossibleRouteMatch for ParamSegment { pub struct WildcardSegment(pub &'static str); impl PossibleRouteMatch for WildcardSegment { - type ParamsIter = iter::Once<(Cow<'static, str>, String)>; - - fn test<'a>( - &self, - path: &'a str, - ) -> Option> { + fn test<'a>(&self, path: &'a str) -> Option> { let mut matched_len = 0; let mut param_offset = 0; let mut param_len = 0; @@ -148,7 +142,11 @@ impl PossibleRouteMatch for WildcardSegment { Cow::Borrowed(self.0), path[param_offset..param_len + param_offset].to_string(), )); - Some(PartialPathMatch::new(remaining, param_value, matched)) + Some(PartialPathMatch::new( + remaining, + param_value.into_iter().collect(), + matched, + )) } fn generate_path(&self, path: &mut Vec) { @@ -156,10 +154,64 @@ impl PossibleRouteMatch for WildcardSegment { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct OptionalParamSegment(pub &'static str); + +impl PossibleRouteMatch for OptionalParamSegment { + const OPTIONAL: bool = true; + + fn test<'a>(&self, path: &'a str) -> Option> { + let mut matched_len = 0; + let mut param_offset = 0; + let mut param_len = 0; + let mut test = path.chars(); + + // match an initial / + if let Some('/') = test.next() { + matched_len += 1; + param_offset = 1; + } + for char in test { + // when we get a closing /, stop matching + if char == '/' { + break; + } + // otherwise, push into the matched param + else { + matched_len += char.len_utf8(); + param_len += char.len_utf8(); + } + } + + let matched_len = if matched_len == 1 && path.starts_with('/') { + 0 + } else { + matched_len + }; + let (matched, remaining) = path.split_at(matched_len); + let param_value = (matched_len > 0) + .then(|| { + ( + Cow::Borrowed(self.0), + path[param_offset..param_len + param_offset].to_string(), + ) + }) + .into_iter() + .collect(); + Some(PartialPathMatch::new(remaining, param_value, matched)) + } + + fn generate_path(&self, path: &mut Vec) { + path.push(PathSegment::OptionalParam(self.0.into())); + } +} + #[cfg(test)] mod tests { use super::PossibleRouteMatch; - use crate::{ParamSegment, StaticSegment, WildcardSegment}; + use crate::{ + OptionalParamSegment, ParamSegment, StaticSegment, WildcardSegment, + }; #[test] fn single_param_match() { @@ -168,7 +220,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert_eq!(params[0], ("a".into(), "foo".into())); } @@ -179,7 +231,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), "/"); - let params = matched.params().collect::>(); + let params = matched.params(); assert_eq!(params[0], ("a".into(), "foo".into())); } @@ -190,7 +242,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert_eq!(params[0], ("a".into(), "foo".into())); assert_eq!(params[1], ("b".into(), "bar".into())); } @@ -206,7 +258,94 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar/////"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert_eq!(params[0], ("rest".into(), "////".into())); } + + #[test] + fn optional_param_can_match() { + let path = "/foo"; + let def = OptionalParamSegment("a"); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert_eq!(params[0], ("a".into(), "foo".into())); + } + + #[test] + fn optional_param_can_not_match() { + let path = "/"; + let def = OptionalParamSegment("a"); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), ""); + assert_eq!(matched.remaining(), "/"); + let params = matched.params(); + assert_eq!(params.first(), None); + } + + #[test] + fn optional_params_match_first() { + let path = "/foo"; + let def = (OptionalParamSegment("a"), OptionalParamSegment("b")); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert_eq!(params[0], ("a".into(), "foo".into())); + } + + #[test] + fn optional_params_can_match_both() { + let path = "/foo/bar"; + let def = (OptionalParamSegment("a"), OptionalParamSegment("b")); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert_eq!(params[0], ("a".into(), "foo".into())); + assert_eq!(params[1], ("b".into(), "bar".into())); + } + + #[test] + fn matching_after_optional_param() { + let path = "/bar"; + let def = (OptionalParamSegment("a"), StaticSegment("bar")); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/bar"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert!(params.is_empty()); + } + + #[test] + fn multiple_optional_params_match_first() { + let path = "/foo/bar"; + let def = ( + OptionalParamSegment("a"), + OptionalParamSegment("b"), + StaticSegment("bar"), + ); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/bar"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert_eq!(params[0], ("a".into(), "foo".into())); + } + + #[test] + fn multiple_optionals_can_match_both() { + let path = "/foo/qux/bar"; + let def = ( + OptionalParamSegment("a"), + OptionalParamSegment("b"), + StaticSegment("bar"), + ); + let matched = def.test(path).expect("couldn't match route"); + assert_eq!(matched.matched(), "/foo/qux/bar"); + assert_eq!(matched.remaining(), ""); + let params = matched.params(); + assert_eq!(params[0], ("a".into(), "foo".into())); + assert_eq!(params[1], ("b".into(), "qux".into())); + } } diff --git a/router/src/matching/horizontal/static_segment.rs b/router/src/matching/horizontal/static_segment.rs index c9c22e8bae..5179efecfd 100644 --- a/router/src/matching/horizontal/static_segment.rs +++ b/router/src/matching/horizontal/static_segment.rs @@ -1,15 +1,9 @@ use super::{PartialPathMatch, PathSegment, PossibleRouteMatch}; -use core::iter; -use std::{borrow::Cow, fmt::Debug}; +use std::fmt::Debug; impl PossibleRouteMatch for () { - type ParamsIter = iter::Empty<(Cow<'static, str>, String)>; - - fn test<'a>( - &self, - path: &'a str, - ) -> Option> { - Some(PartialPathMatch::new(path, iter::empty(), "")) + fn test<'a>(&self, path: &'a str) -> Option> { + Some(PartialPathMatch::new(path, vec![], "")) } fn generate_path(&self, _path: &mut Vec) {} @@ -44,14 +38,14 @@ impl AsPath for &'static str { /// /// // Params are empty as we had no `ParamSegement`s or `WildcardSegment`s /// // If you did have additional dynamic segments, this would not be empty. -/// assert_eq!(matched.params().count(), 0); +/// assert_eq!(matched.params().len(), 0); /// /// // Macro definition /// let using_macro = path!("/users"); /// let matched = manual.test(path)?; /// assert_eq!(matched.matched(), "/users"); /// -/// assert_eq!(matched.params().count(), 0); +/// assert_eq!(matched.params().len(), 0); /// /// # Some(()) /// # })().unwrap(); @@ -60,12 +54,7 @@ impl AsPath for &'static str { pub struct StaticSegment(pub T); impl PossibleRouteMatch for StaticSegment { - type ParamsIter = iter::Empty<(Cow<'static, str>, String)>; - - fn test<'a>( - &self, - path: &'a str, - ) -> Option> { + fn test<'a>(&self, path: &'a str) -> Option> { let mut matched_len = 0; let mut test = path.chars().peekable(); let mut this = self.0.as_path().chars(); @@ -113,8 +102,7 @@ impl PossibleRouteMatch for StaticSegment { // the remaining is built from the path in, with the slice moved // by the length of this match let (matched, remaining) = path.split_at(matched_len); - has_matched - .then(|| PartialPathMatch::new(remaining, iter::empty(), matched)) + has_matched.then(|| PartialPathMatch::new(remaining, vec![], matched)) } fn generate_path(&self, path: &mut Vec) { @@ -151,7 +139,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -162,7 +150,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -187,7 +175,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), "/"); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -198,7 +186,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo"); assert_eq!(matched.remaining(), "/"); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -209,7 +197,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -220,7 +208,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -252,7 +240,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } @@ -270,7 +258,7 @@ mod tests { let matched = def.test(path).expect("couldn't match route"); assert_eq!(matched.matched(), "/foo/bar"); assert_eq!(matched.remaining(), ""); - let params = matched.params().collect::>(); + let params = matched.params(); assert!(params.is_empty()); } } diff --git a/router/src/matching/horizontal/tuples.rs b/router/src/matching/horizontal/tuples.rs index 1af93db1f0..a4879fb1f0 100644 --- a/router/src/matching/horizontal/tuples.rs +++ b/router/src/matching/horizontal/tuples.rs @@ -1,23 +1,4 @@ use super::{PartialPathMatch, PathSegment, PossibleRouteMatch}; -use core::iter::Chain; - -macro_rules! chain_types { - ($first:ty, $second:ty, ) => { - Chain< - $first, - <<$second as PossibleRouteMatch>::ParamsIter as IntoIterator>::IntoIter - > - }; - ($first:ty, $second:ty, $($rest:ty,)+) => { - chain_types!( - Chain< - $first, - <<$second as PossibleRouteMatch>::ParamsIter as IntoIterator>::IntoIter, - >, - $($rest,)+ - ) - } -} macro_rules! tuples { ($first:ident => $($ty:ident),*) => { @@ -27,34 +8,69 @@ macro_rules! tuples { $first: PossibleRouteMatch, $($ty: PossibleRouteMatch),*, { - type ParamsIter = chain_types!(<<$first>::ParamsIter as IntoIterator>::IntoIter, $($ty,)*); + fn test<'a>(&self, path: &'a str) -> Option> { + // on the first run, include all optionals + let mut include_optionals = { + [$first::OPTIONAL, $($ty::OPTIONAL),*].into_iter().filter(|n| *n).count() + }; - fn test<'a>(&self, path: &'a str) -> Option> { - let mut matched_len = 0; #[allow(non_snake_case)] let ($first, $($ty,)*) = &self; - let remaining = path; - let PartialPathMatch { - remaining, - matched, - params - } = $first.test(remaining)?; - matched_len += matched.len(); - let params_iter = params.into_iter(); - $( - let PartialPathMatch { - remaining, - matched, - params - } = $ty.test(remaining)?; - matched_len += matched.len(); - let params_iter = params_iter.chain(params); - )* - Some(PartialPathMatch { - remaining, - matched: &path[0..matched_len], - params: params_iter - }) + + loop { + let mut nth_field = 0; + let mut matched_len = 0; + let mut r = path; + + let mut p = Vec::new(); + let mut m = String::new(); + + if !$first::OPTIONAL || nth_field < include_optionals { + match $first.test(r) { + None => { + return None; + }, + Some(PartialPathMatch { remaining, matched, params }) => { + p.extend(params.into_iter()); + m.push_str(matched); + r = remaining; + }, + } + } + + matched_len += m.len(); + $( + if $ty::OPTIONAL { + nth_field += 1; + } + if !$ty::OPTIONAL || nth_field < include_optionals { + let PartialPathMatch { + remaining, + matched, + params + } = match $ty.test(r) { + None => if $ty::OPTIONAL { + return None; + } else { + if include_optionals == 0 { + return None; + } + include_optionals -= 1; + continue; + }, + Some(v) => v, + }; + r = remaining; + matched_len += matched.len(); + p.extend(params); + } + )* + return Some(PartialPathMatch { + remaining: r, + matched: &path[0..matched_len], + params: p + }); + } } fn generate_path(&self, path: &mut Vec) { @@ -74,12 +90,7 @@ where Self: core::fmt::Debug, A: PossibleRouteMatch, { - type ParamsIter = A::ParamsIter; - - fn test<'a>( - &self, - path: &'a str, - ) -> Option> { + fn test<'a>(&self, path: &'a str) -> Option> { let remaining = path; let PartialPathMatch { remaining, diff --git a/router/src/matching/mod.rs b/router/src/matching/mod.rs index e37764c7aa..7e61d335a1 100644 --- a/router/src/matching/mod.rs +++ b/router/src/matching/mod.rs @@ -103,9 +103,7 @@ pub trait MatchInterface { } pub trait MatchParams { - type Params: IntoIterator, String)>; - - fn to_params(&self) -> Self::Params; + fn to_params(&self) -> Vec<(Cow<'static, str>, String)>; } pub trait MatchNestedRoutes { @@ -255,13 +253,13 @@ mod tests { ); let matched = routes.match_route("/about").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert!(params.is_empty()); let matched = routes.match_route("/blog").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert!(params.is_empty()); let matched = routes.match_route("/blog/post/42").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert_eq!(params, vec![("id".into(), "42".into())]); } @@ -297,34 +295,34 @@ mod tests { assert!(matched.is_none()); let matched = routes.match_route("/portfolio/about").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert!(params.is_empty()); let matched = routes.match_route("/portfolio/blog/post/42").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert_eq!(params, vec![("id".into(), "42".into())]); let matched = routes.match_route("/portfolio/contact").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert_eq!(params, vec![("any".into(), "".into())]); let matched = routes.match_route("/portfolio/contact/foobar").unwrap(); - let params = matched.to_params().collect::>(); + let params = matched.to_params(); assert_eq!(params, vec![("any".into(), "foobar".into())]); } } #[derive(Debug)] -pub struct PartialPathMatch<'a, ParamsIter> { +pub struct PartialPathMatch<'a> { pub(crate) remaining: &'a str, - pub(crate) params: ParamsIter, + pub(crate) params: Vec<(Cow<'static, str>, String)>, pub(crate) matched: &'a str, } -impl<'a, ParamsIter> PartialPathMatch<'a, ParamsIter> { +impl<'a> PartialPathMatch<'a> { pub fn new( remaining: &'a str, - params: ParamsIter, + params: Vec<(Cow<'static, str>, String)>, matched: &'a str, ) -> Self { Self { @@ -342,7 +340,7 @@ impl<'a, ParamsIter> PartialPathMatch<'a, ParamsIter> { self.remaining } - pub fn params(self) -> ParamsIter { + pub fn params(self) -> Vec<(Cow<'static, str>, String)> { self.params } diff --git a/router/src/matching/nested/mod.rs b/router/src/matching/nested/mod.rs index 83028d5da7..4277d6142d 100644 --- a/router/src/matching/nested/mod.rs +++ b/router/src/matching/nested/mod.rs @@ -96,21 +96,19 @@ impl NestedRoute { } #[derive(PartialEq, Eq)] -pub struct NestedMatch { +pub struct NestedMatch { id: RouteMatchId, /// The portion of the full path matched only by this nested route. matched: String, /// The map of params matched only by this nested route. - params: ParamsIter, + params: Vec<(Cow<'static, str>, String)>, /// The nested route. child: Option, view_fn: View, } -impl fmt::Debug - for NestedMatch +impl fmt::Debug for NestedMatch where - ParamsIter: fmt::Debug, Child: fmt::Debug, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -122,21 +120,14 @@ where } } -impl MatchParams - for NestedMatch -where - ParamsIter: IntoIterator, String)> + Clone, -{ - type Params = ParamsIter; - +impl MatchParams for NestedMatch { #[inline(always)] - fn to_params(&self) -> Self::Params { + fn to_params(&self) -> Vec<(Cow<'static, str>, String)> { self.params.clone() } } -impl MatchInterface - for NestedMatch +impl MatchInterface for NestedMatch where Child: MatchInterface + MatchParams + 'static, View: ChooseView, @@ -161,21 +152,13 @@ impl MatchNestedRoutes where Self: 'static, Segments: PossibleRouteMatch + std::fmt::Debug, - <::ParamsIter as IntoIterator>::IntoIter: Clone, Children: MatchNestedRoutes, - <<::Match as MatchParams>::Params as IntoIterator>::IntoIter: Clone, - Children::Match: MatchParams, - Children: 'static, - ::Params: Clone, + Children::Match: MatchParams, + Children: 'static, View: ChooseView + Clone, { type Data = Data; - type Match = NestedMatch::IntoIter, - Either, String) - >, <::Params as IntoIterator>::IntoIter> - >, Children::Match, View>; + type Match = NestedMatch; fn match_nested<'a>( &'a self, @@ -186,33 +169,34 @@ where .and_then( |PartialPathMatch { remaining, - params, + mut params, matched, }| { let (_, inner, remaining) = match &self.children { None => (None, None, remaining), Some(children) => { - let (inner, remaining) = children.match_nested(remaining); + let (inner, remaining) = + children.match_nested(remaining); let (id, inner) = inner?; - (Some(id), Some(inner), remaining) + (Some(id), Some(inner), remaining) } }; - let params = params.into_iter(); - let inner_params = match &inner { - None => Either::Left(iter::empty()), - Some(inner) => Either::Right(inner.to_params().into_iter()) - }; + let inner_params = inner + .as_ref() + .map(|inner| inner.to_params()) + .unwrap_or_default(); let id = RouteMatchId(self.id); if remaining.is_empty() || remaining == "/" { + params.extend(inner_params); Some(( Some(( id, NestedMatch { id, matched: matched.to_string(), - params: params.chain(inner_params), + params, child: inner, view_fn: self.view.clone(), }, @@ -238,9 +222,9 @@ where let regenerate = match &ssr_mode { SsrMode::Static(data) => match data.regenerate.as_ref() { None => vec![], - Some(regenerate) => vec![regenerate.clone()] - } - _ => vec![] + Some(regenerate) => vec![regenerate.clone()], + }, + _ => vec![], }; match children { @@ -248,32 +232,41 @@ where segments: segment_routes, ssr_mode, methods, - regenerate + regenerate, })), Some(children) => { - Either::Right(children.generate_routes().into_iter().map(move |child| { - // extend this route's segments with child segments - let segments = segment_routes.clone().into_iter().chain(child.segments).collect(); + Either::Right(children.generate_routes().into_iter().map( + move |child| { + // extend this route's segments with child segments + let segments = segment_routes + .clone() + .into_iter() + .chain(child.segments) + .collect(); - let mut methods = methods.clone(); - methods.extend(child.methods); + let mut methods = methods.clone(); + methods.extend(child.methods); - let mut regenerate = regenerate.clone(); - regenerate.extend(child.regenerate); + let mut regenerate = regenerate.clone(); + regenerate.extend(child.regenerate); - if child.ssr_mode > ssr_mode { - GeneratedRouteData { - segments, - ssr_mode: child.ssr_mode, - methods, regenerate - } - } else { - GeneratedRouteData { - segments, - ssr_mode: ssr_mode.clone(), methods, regenerate + if child.ssr_mode > ssr_mode { + GeneratedRouteData { + segments, + ssr_mode: child.ssr_mode, + methods, + regenerate, + } + } else { + GeneratedRouteData { + segments, + ssr_mode: ssr_mode.clone(), + methods, + regenerate, + } } - } - })) + }, + )) } } } diff --git a/router/src/matching/nested/tuples.rs b/router/src/matching/nested/tuples.rs index ea6378efaf..cdce0a47ce 100644 --- a/router/src/matching/nested/tuples.rs +++ b/router/src/matching/nested/tuples.rs @@ -5,10 +5,8 @@ use either_of::*; use std::borrow::Cow; impl MatchParams for () { - type Params = iter::Empty<(Cow<'static, str>, String)>; - - fn to_params(&self) -> Self::Params { - iter::empty() + fn to_params(&self) -> Vec<(Cow<'static, str>, String)> { + Vec::new() } } @@ -53,9 +51,7 @@ impl MatchParams for (A,) where A: MatchParams, { - type Params = A::Params; - - fn to_params(&self) -> Self::Params { + fn to_params(&self) -> Vec<(Cow<'static, str>, String)> { self.0.to_params() } } @@ -105,15 +101,10 @@ where A: MatchParams, B: MatchParams, { - type Params = Either< - ::IntoIter, - ::IntoIter, - >; - - fn to_params(&self) -> Self::Params { + fn to_params(&self) -> Vec<(Cow<'static, str>, String)> { match self { - Either::Left(i) => Either::Left(i.to_params().into_iter()), - Either::Right(i) => Either::Right(i.to_params().into_iter()), + Either::Left(i) => i.to_params(), + Either::Right(i) => i.to_params(), } } } @@ -208,13 +199,9 @@ macro_rules! tuples { where $($ty: MatchParams),*, { - type Params = $either<$( - <$ty::Params as IntoIterator>::IntoIter, - )*>; - - fn to_params(&self) -> Self::Params { + fn to_params(&self) -> Vec<(Cow<'static, str>, String)> { match self { - $($either::$ty(i) => $either::$ty(i.to_params().into_iter()),)* + $($either::$ty(i) => i.to_params(),)* } } } diff --git a/router/src/matching/path_segment.rs b/router/src/matching/path_segment.rs index 20f4ca7093..02c695a30b 100644 --- a/router/src/matching/path_segment.rs +++ b/router/src/matching/path_segment.rs @@ -5,6 +5,7 @@ pub enum PathSegment { Unit, Static(Cow<'static, str>), Param(Cow<'static, str>), + OptionalParam(Cow<'static, str>), Splat(Cow<'static, str>), } @@ -14,7 +15,98 @@ impl PathSegment { PathSegment::Unit => "", PathSegment::Static(i) => i, PathSegment::Param(i) => i, + PathSegment::OptionalParam(i) => i, PathSegment::Splat(i) => i, } } } + +pub trait ExpandOptionals { + fn expand_optionals(&self) -> Vec>; +} + +impl ExpandOptionals for Vec { + fn expand_optionals(&self) -> Vec> { + let mut segments = vec![self.to_vec()]; + let mut checked = Vec::new(); + while let Some(next_to_check) = segments.pop() { + let mut had_optional = false; + for (idx, segment) in next_to_check.iter().enumerate() { + if let PathSegment::OptionalParam(name) = segment { + had_optional = true; + let mut unit_variant = next_to_check.to_vec(); + unit_variant.remove(idx); + let mut param_variant = next_to_check.to_vec(); + param_variant[idx] = PathSegment::Param(name.clone()); + segments.push(unit_variant); + segments.push(param_variant); + break; + } + } + if !had_optional { + checked.push(next_to_check.to_vec()); + } + } + checked + } +} + +#[cfg(test)] +mod tests { + use crate::{ExpandOptionals, PathSegment}; + + #[test] + fn expand_optionals_on_plain() { + let plain = vec![ + PathSegment::Static("a".into()), + PathSegment::Param("b".into()), + ]; + assert_eq!(plain.expand_optionals(), vec![plain]); + } + + #[test] + fn expand_optionals_once() { + let plain = vec![ + PathSegment::OptionalParam("a".into()), + PathSegment::Static("b".into()), + ]; + assert_eq!( + plain.expand_optionals(), + vec![ + vec![ + PathSegment::Param("a".into()), + PathSegment::Static("b".into()) + ], + vec![PathSegment::Static("b".into())] + ] + ); + } + + #[test] + fn expand_optionals_twice() { + let plain = vec![ + PathSegment::OptionalParam("a".into()), + PathSegment::OptionalParam("b".into()), + PathSegment::Static("c".into()), + ]; + assert_eq!( + plain.expand_optionals(), + vec![ + vec![ + PathSegment::Param("a".into()), + PathSegment::Param("b".into()), + PathSegment::Static("c".into()), + ], + vec![ + PathSegment::Param("a".into()), + PathSegment::Static("c".into()), + ], + vec![ + PathSegment::Param("b".into()), + PathSegment::Static("c".into()), + ], + vec![PathSegment::Static("c".into())] + ] + ); + } +} diff --git a/router/src/matching/vertical/mod.rs b/router/src/matching/vertical/mod.rs index 2ea88b0ecd..4f4da11dd1 100644 --- a/router/src/matching/vertical/mod.rs +++ b/router/src/matching/vertical/mod.rs @@ -1,10 +1,5 @@ use super::PartialPathMatch; pub trait ChooseRoute { - fn choose_route<'a>( - &self, - path: &'a str, - ) -> Option< - PartialPathMatch<'a, impl IntoIterator>, - >; + fn choose_route<'a>(&self, path: &'a str) -> Option>; } diff --git a/router/src/static_routes.rs b/router/src/static_routes.rs index 8d363180f0..8329a4f3d3 100644 --- a/router/src/static_routes.rs +++ b/router/src/static_routes.rs @@ -247,6 +247,7 @@ impl StaticPath { } paths = new_paths; } + OptionalParam(_) => todo!(), } } paths diff --git a/router_macro/Cargo.toml b/router_macro/Cargo.toml index 118e225e51..f36eeed9ac 100644 --- a/router_macro/Cargo.toml +++ b/router_macro/Cargo.toml @@ -18,4 +18,4 @@ proc-macro2 = "1.0" quote = "1.0" [dev-dependencies] -leptos_router = { version = "0.7.0-beta" } +leptos_router = { path = "../router" } diff --git a/router_macro/src/lib.rs b/router_macro/src/lib.rs index 03451ffad4..3863f3b430 100644 --- a/router_macro/src/lib.rs +++ b/router_macro/src/lib.rs @@ -14,12 +14,16 @@ const RFC3986_PCHAR_OTHER: [char; 1] = ['@']; /// # Examples /// /// ```rust -/// use leptos_router::{path, ParamSegment, StaticSegment, WildcardSegment}; +/// use leptos_router::{ +/// path, OptionalParamSegment, ParamSegment, StaticSegment, +/// WildcardSegment, +/// }; /// -/// let path = path!("/foo/:bar/*any"); +/// let path = path!("/foo/:bar/:baz?/*any"); /// let output = ( /// StaticSegment("foo"), /// ParamSegment("bar"), +/// OptionalParamSegment("baz"), /// WildcardSegment("any"), /// ); /// @@ -41,6 +45,7 @@ struct Segments(pub Vec); enum Segment { Static(String), Param(String), + OptionalParam(String), Wildcard(String), } @@ -93,7 +98,11 @@ impl SegmentParser { for segment in current_str.split('/') { if let Some(segment) = segment.strip_prefix(':') { - segments.push(Segment::Param(segment.to_string())); + if let Some(segment) = segment.strip_suffix('?') { + segments.push(Segment::OptionalParam(segment.to_string())); + } else { + segments.push(Segment::Param(segment.to_string())); + } } else if let Some(segment) = segment.strip_prefix('*') { segments.push(Segment::Wildcard(segment.to_string())); } else { @@ -156,6 +165,10 @@ impl ToTokens for Segment { Segment::Param(p) => { tokens.extend(quote! { leptos_router::ParamSegment(#p) }); } + Segment::OptionalParam(p) => { + tokens + .extend(quote! { leptos_router::OptionalParamSegment(#p) }); + } } } } diff --git a/router_macro/tests/path.rs b/router_macro/tests/path.rs index 2110629f8b..c88b95a534 100644 --- a/router_macro/tests/path.rs +++ b/router_macro/tests/path.rs @@ -1,4 +1,6 @@ -use leptos_router::{ParamSegment, StaticSegment, WildcardSegment}; +use leptos_router::{ + OptionalParamSegment, ParamSegment, StaticSegment, WildcardSegment, +}; use leptos_router_macro::path; #[test] @@ -86,6 +88,12 @@ fn parses_single_param() { assert_eq!(output, (ParamSegment("id"),)); } +#[test] +fn parses_optional_param() { + let output = path!("/:id?"); + assert_eq!(output, (OptionalParamSegment("id"),)); +} + #[test] fn parses_static_and_param() { let output = path!("/home/:id"); @@ -144,9 +152,22 @@ fn parses_consecutive_param() { ); } +#[test] +fn parses_consecutive_optional_param() { + let output = path!("/:foo?/:bar?/:baz?"); + assert_eq!( + output, + ( + OptionalParamSegment("foo"), + OptionalParamSegment("bar"), + OptionalParamSegment("baz") + ) + ); +} + #[test] fn parses_complex() { - let output = path!("/home/:id/foo/:bar/*any"); + let output = path!("/home/:id/foo/:bar/:baz?/*any"); assert_eq!( output, ( @@ -154,6 +175,7 @@ fn parses_complex() { ParamSegment("id"), StaticSegment("foo"), ParamSegment("bar"), + OptionalParamSegment("baz"), WildcardSegment("any"), ) );