use std::collections::BTreeMap;
use proc_macro2::{Span, TokenStream};
use quote::quote;
use super::declaration::RoutesDeclaration;
use super::flatten::ExpandedRoute;
use super::path;
pub fn expand(decl: &RoutesDeclaration, flat: &[ExpandedRoute]) -> TokenStream {
let vis = &decl.visibility;
let mod_ident = &decl.route_mod_ident;
let mut root: Vec<Helper> = Vec::new();
let mut nested: BTreeMap<String, Vec<Helper>> = BTreeMap::new();
for route in flat {
if route.name.is_empty() {
continue;
}
match route.name.split_once('.') {
Some((first, rest)) => nested
.entry(first.to_string())
.or_default()
.push(Helper::new(&rest.replace('.', "_"), &route.path)),
None => root.push(Helper::new(&route.name, &route.path)),
}
}
let root_fns = root.iter().map(Helper::expand);
let nested_mods = nested.iter().map(|(name, helpers)| {
let ident = syn::Ident::new(name, Span::call_site());
let fns = helpers.iter().map(Helper::expand);
quote! {
pub mod #ident {
#(#fns)*
}
}
});
quote! {
#vis mod #mod_ident {
#(#root_fns)*
#(#nested_mods)*
}
}
}
struct Helper {
func_name: String,
path: String,
params: Vec<String>,
}
impl Helper {
fn new(func_name: &str, route_path: &str) -> Self {
Helper {
func_name: func_name.to_string(),
path: route_path.to_string(),
params: path::params(route_path),
}
}
fn expand(&self) -> TokenStream {
let ident = syn::Ident::new(&self.func_name, Span::call_site());
let path = &self.path;
if self.params.is_empty() {
return quote! {
pub fn #ident() -> &'static str { #path }
};
}
let params: Vec<syn::Ident> = self
.params
.iter()
.map(|p| syn::Ident::new(p, Span::call_site()))
.collect();
let declarations = params
.iter()
.map(|p| quote! { #p: impl ::std::fmt::Display });
quote! {
pub fn #ident( #(#declarations),* ) -> ::std::string::String {
::std::format!(#path #(, #params = #params)*)
}
}
}
}
#[cfg(test)]
mod tests {
use super::expand;
use crate::routes::declaration::RoutesDeclaration;
use crate::routes::flatten;
use quote::quote;
fn generated(tokens: proc_macro2::TokenStream) -> String {
let decl = syn::parse2::<RoutesDeclaration>(tokens).expect("parses");
let flat = flatten::entries(&decl.entries, "");
expand(&decl, &flat).to_string()
}
#[test]
fn the_module_is_named_after_the_declaration() {
let out = generated(quote! { pub app { get "/" => home { name: home } } });
assert!(out.contains("pub mod app_route"));
}
#[test]
fn a_parameterless_route_returns_a_static_str() {
let out = generated(quote! { app { get "/" => home { name: home } } });
assert!(out.contains("pub fn home () -> & 'static str { \"/\" }"));
}
#[test]
fn a_dotted_name_nests_into_a_module() {
let out = generated(quote! {
app { get "/login" => login { name: auth.login } }
});
assert!(out.contains("pub mod auth"));
assert!(out.contains("pub fn login ()"));
}
#[test]
fn a_deeply_dotted_name_flattens_below_the_first_segment() {
let out = generated(quote! {
app { get "/l" => index { name: api.v1.links } }
});
assert!(out.contains("pub mod api"));
assert!(out.contains("pub fn v1_links ()"));
}
#[test]
fn a_parameterized_route_formats_its_path() {
let out = generated(quote! {
app { get "/links/{link}" => show { name: links.show } }
});
assert!(out.contains("link : impl :: std :: fmt :: Display"));
assert!(out.contains(":: std :: string :: String"));
assert!(out.contains("format ! (\"/links/{link}\" , link = link)"));
}
#[test]
fn multiple_parameters_are_all_arguments() {
let out = generated(quote! {
app { get "/teams/{team}/links/{link}" => show { name: links.show } }
});
assert!(out.contains("team : impl :: std :: fmt :: Display"));
assert!(out.contains("link : impl :: std :: fmt :: Display"));
}
#[test]
fn unnamed_routes_get_no_helper() {
let out = generated(quote! { app { get "/" => home } });
assert!(!out.contains("pub fn "));
}
#[test]
fn resource_helpers_land_under_the_resource_name() {
let out = generated(quote! {
app { resource "/links" => LinksController { name: links } }
});
assert!(out.contains("pub mod links"));
assert!(out.contains("pub fn index ()"));
assert!(out.contains("pub fn edit ("));
}
}