const-router-macros 0.1.0

Procedural macros for const-router.
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
    Error, Expr, LitStr, Result, Token,
    parse::{Parse, ParseStream},
    punctuated::Punctuated,
};

pub(crate) fn expand(input: TokenStream) -> Result<TokenStream> {
    let mut input = syn::parse2::<RouterInput>(input)?;
    input.sort_and_validate_routes()?;

    Ok(input.into_tokens())
}

struct RouterInput {
    fallback: Expr,
    routes: Vec<RouteInput>,
}

impl RouterInput {
    fn sort_and_validate_routes(&mut self) -> Result<()> {
        self.routes
            .sort_by(|left, right| left.key_value.cmp(&right.key_value));

        if let Some(pair) = self
            .routes
            .windows(2)
            .find(|pair| pair[0].key_value == pair[1].key_value)
        {
            return Err(Error::new(
                pair[1].key.span(),
                format!("duplicate route key {:?}", pair[1].key_value),
            ));
        }

        Ok(())
    }

    fn into_tokens(self) -> TokenStream {
        let Self { fallback, routes } = self;
        let routes = routes.iter().map(RouteInput::to_tokens);

        quote! {
            ::const_router::__internal::new_router(#fallback(), &[#(#routes),*])
        }
    }
}

impl Parse for RouterInput {
    fn parse(input: ParseStream<'_>) -> Result<Self> {
        let fallback = input.parse::<Expr>()?;
        let routes = if input.is_empty() {
            Vec::new()
        } else {
            input.parse::<Token![,]>()?;
            Punctuated::<RouteInput, Token![,]>::parse_terminated(input)?
                .into_iter()
                .collect()
        };

        Ok(Self { fallback, routes })
    }
}

struct RouteInput {
    handler: Expr,
    key: LitStr,
    key_value: String,
}

impl RouteInput {
    fn to_tokens(&self) -> TokenStream {
        let key = &self.key;
        let handler = &self.handler;

        quote! {
            ::const_router::__internal::new_route(#key, #handler())
        }
    }
}

impl Parse for RouteInput {
    fn parse(input: ParseStream<'_>) -> Result<Self> {
        let key = input.parse::<LitStr>()?;
        input.parse::<Token![=>]>()?;
        let handler = input.parse::<Expr>()?;
        let key_value = key.value();

        Ok(Self {
            handler,
            key,
            key_value,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use quote::quote;

    fn expand_to_string(input: TokenStream) -> String {
        expand(input)
            .expect("router input should expand")
            .to_string()
    }

    #[test]
    fn expands_fallback_only_router() {
        let expanded = expand_to_string(quote! { fallback_handler });

        assert!(expanded.contains("new_router"));
        assert!(expanded.contains("fallback_handler ()"));
        assert!(!expanded.contains("new_route ("));
    }

    #[test]
    fn sorts_routes_by_key_before_expanding() {
        let expanded = expand_to_string(quote! {
            fallback_handler,
            "/zeta" => zeta_handler,
            "/alpha" => alpha_handler,
            "/middle" => middle_handler,
        });

        let alpha = expanded.find("\"/alpha\"").expect("alpha route exists");
        let middle = expanded.find("\"/middle\"").expect("middle route exists");
        let zeta = expanded.find("\"/zeta\"").expect("zeta route exists");

        assert!(alpha < middle);
        assert!(middle < zeta);
    }

    #[test]
    fn accepts_trailing_route_comma() {
        let expanded = expand_to_string(quote! {
            fallback_handler,
            "/alpha" => alpha_handler,
        });

        assert!(expanded.contains("\"/alpha\""));
        assert!(expanded.contains("alpha_handler ()"));
    }

    #[test]
    fn rejects_duplicate_route_keys_after_sorting() {
        let error = expand(quote! {
            fallback_handler,
            "/same" => first_handler,
            "/different" => different_handler,
            "/same" => second_handler,
        })
        .expect_err("duplicate keys should be rejected");

        assert_eq!(error.to_string(), "duplicate route key \"/same\"");
    }
}