aro-macros 1.0.0

Procedural macros for the Aro web framework
Documentation
//! Route attribute macro implementation.

use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{ItemFn, LitStr, Token, parse::Parse, parse::ParseStream};

use crate::crate_path;

/// Arguments to a route attribute: `("/path")`.
struct RouteArgs {
    path: LitStr,
}

impl Parse for RouteArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let path: LitStr = input.parse()?;
        // Allow optional trailing comma
        if input.peek(Token![,]) {
            let _: Token![,] = input.parse()?;
        }
        Ok(Self { path })
    }
}

/// Validate a route path string, returning an error if invalid.
///
/// Valid paths:
/// - Must start with `/`
/// - Segments can be literal (`/users`) or parameters (`/{id}` or `/:id`)
/// - Braced params must have a non-empty name and matching braces
/// - Colon params must have a non-empty name
fn validate_path(path: &LitStr) -> syn::Result<()> {
    let value = path.value();

    if value.is_empty() || !value.starts_with('/') {
        return Err(syn::Error::new_spanned(
            path,
            "route path must start with '/'",
        ));
    }

    // Validate each segment
    for segment in value.split('/').skip(1) {
        if segment.is_empty() {
            // Allow trailing slash and root path
            continue;
        }

        if segment.starts_with('{') {
            // Braced parameter: {name}
            if !segment.ends_with('}') {
                return Err(syn::Error::new_spanned(
                    path,
                    format!("unclosed brace in path segment '{segment}'"),
                ));
            }
            let name = &segment[1..segment.len() - 1];
            if name.is_empty() {
                return Err(syn::Error::new_spanned(
                    path,
                    "path parameter name cannot be empty: '{}'",
                ));
            }
            if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
                return Err(syn::Error::new_spanned(
                    path,
                    format!("path parameter name must be alphanumeric or underscore, got '{name}'"),
                ));
            }
        } else if let Some(name) = segment.strip_prefix(':') {
            // Colon parameter: :name
            if name.is_empty() {
                return Err(syn::Error::new_spanned(
                    path,
                    "path parameter name cannot be empty after ':'",
                ));
            }
            if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
                return Err(syn::Error::new_spanned(
                    path,
                    format!("path parameter name must be alphanumeric or underscore, got '{name}'"),
                ));
            }
        } else if segment.contains('{') || segment.contains('}') {
            return Err(syn::Error::new_spanned(
                path,
                format!("misplaced brace in path segment '{segment}'"),
            ));
        }
    }

    Ok(())
}

/// Normalize route path parameters to Axum 0.8's `{capture}` syntax.
///
/// Converts colon-style parameters (`:id`) to brace-style (`{id}`).
/// Brace-style parameters are passed through unchanged.
fn to_axum_path(path: &str) -> String {
    let segments: Vec<&str> = path.split('/').collect();
    let mut result = String::with_capacity(path.len());
    for (i, segment) in segments.iter().enumerate() {
        if i > 0 {
            result.push('/');
        }
        if let Some(name) = segment.strip_prefix(':') {
            result.push('{');
            result.push_str(name);
            result.push('}');
        } else {
            result.push_str(segment);
        }
    }
    result
}

/// Generate the attribute macro expansion for a given HTTP method.
pub fn expand_route(method: &str, args: TokenStream, input: TokenStream) -> TokenStream {
    let result = expand_route_inner(method, args, input);
    match result {
        Ok(tokens) => tokens,
        Err(err) => err.to_compile_error(),
    }
}

fn expand_route_inner(
    method: &str,
    args: TokenStream,
    input: TokenStream,
) -> syn::Result<TokenStream> {
    let route_args: RouteArgs = syn::parse2(args)?;
    let func: ItemFn = syn::parse2(input)?;

    validate_path(&route_args.path)?;

    let path_str = &route_args.path;
    let fn_name = &func.sig.ident;
    let vis = &func.vis;

    // Build the hidden const name: __ARO_ROUTE_<FN_NAME>
    let const_name = syn::Ident::new(
        &format!("__ARO_ROUTE_{}", fn_name.to_string().to_uppercase()),
        fn_name.span(),
    );

    let method_variant = syn::Ident::new(
        &format!(
            "{}{}",
            &method[..1].to_uppercase(),
            &method[1..].to_lowercase()
        ),
        proc_macro2::Span::call_site(),
    );

    // Build the registration function name: __aro_register_<fn_name>
    let register_fn_name = format_ident!("__aro_register_{}", fn_name);

    // Axum routing function: axum::routing::get, axum::routing::post, etc.
    let axum_method = syn::Ident::new(&method.to_lowercase(), proc_macro2::Span::call_site());

    // Normalize path to Axum 0.8's {capture} syntax
    let axum_path = to_axum_path(&path_str.value());

    // Resolve crate paths dynamically
    let core_path = crate_path::aro_core_path();
    let axum = crate_path::axum_path();

    Ok(quote! {
        #func

        #[doc(hidden)]
        #[allow(non_upper_case_globals, reason = "generated by aro route macro")]
        #vis const #const_name: #core_path::routing::RouteDef = #core_path::routing::RouteDef {
            method: #core_path::routing::Method::#method_variant,
            path: #path_str,
        };

        #[doc(hidden)]
        #[allow(non_snake_case, reason = "generated by aro route macro")]
        #vis fn #register_fn_name(
            router: #axum::Router<#core_path::state::AroState>,
        ) -> #axum::Router<#core_path::state::AroState> {
            router.route(#axum_path, #axum::routing::#axum_method(#fn_name))
        }
    })
}

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

    fn parse_and_validate(path: &str) -> syn::Result<()> {
        let lit: LitStr = syn::parse_str(&format!("\"{path}\""))?;
        validate_path(&lit)
    }

    #[test]
    fn valid_paths() {
        assert!(parse_and_validate("/").is_ok());
        assert!(parse_and_validate("/users").is_ok());
        assert!(parse_and_validate("/users/{id}").is_ok());
        assert!(parse_and_validate("/users/:id").is_ok());
        assert!(parse_and_validate("/users/{id}/posts/{post_id}").is_ok());
        assert!(parse_and_validate("/api/v1/items").is_ok());
        assert!(parse_and_validate("/trailing/").is_ok());
    }

    #[test]
    fn invalid_empty_path() {
        assert!(parse_and_validate("").is_err());
    }

    #[test]
    fn invalid_no_leading_slash() {
        assert!(parse_and_validate("users").is_err());
    }

    #[test]
    fn invalid_unclosed_brace() {
        assert!(parse_and_validate("/users/{id").is_err());
    }

    #[test]
    fn invalid_empty_brace_param() {
        assert!(parse_and_validate("/users/{}").is_err());
    }

    #[test]
    fn invalid_empty_colon_param() {
        assert!(parse_and_validate("/users/:").is_err());
    }

    #[test]
    fn invalid_misplaced_brace() {
        assert!(parse_and_validate("/users/a{id}").is_err());
    }

    #[test]
    fn invalid_param_name_characters() {
        assert!(parse_and_validate("/users/{id-name}").is_err());
        assert!(parse_and_validate("/users/:id-name").is_err());
    }

    #[test]
    fn to_axum_path_no_params() {
        assert_eq!(to_axum_path("/"), "/");
        assert_eq!(to_axum_path("/users"), "/users");
        assert_eq!(to_axum_path("/api/v1/items"), "/api/v1/items");
    }

    #[test]
    fn to_axum_path_brace_params_unchanged() {
        assert_eq!(to_axum_path("/users/{id}"), "/users/{id}");
        assert_eq!(
            to_axum_path("/users/{id}/posts/{post_id}"),
            "/users/{id}/posts/{post_id}"
        );
    }

    #[test]
    fn to_axum_path_colon_params_converted() {
        assert_eq!(to_axum_path("/users/:id"), "/users/{id}");
        assert_eq!(
            to_axum_path("/users/:id/posts/:post_id"),
            "/users/{id}/posts/{post_id}"
        );
    }
}