From fdc79b41cff18cfc1587c433bf9bd738b946d925 Mon Sep 17 00:00:00 2001 From: Tristan Druyen Date: Tue, 15 Apr 2025 16:12:00 +0200 Subject: [PATCH] Split into seperate files --- CHANGELOG.md | 2 +- src/generate.rs | 233 ++++++++++++++++++++++++++++++ src/lib.rs | 366 ++---------------------------------------------- src/parse.rs | 98 +++++++++++++ 4 files changed, 343 insertions(+), 356 deletions(-) create mode 100644 src/generate.rs create mode 100644 src/parse.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c5e9a..77faec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- No changes yet +- Refactored huge lib.rs into 3 seperate files. ## [0.3.3] - 2025-04-15 diff --git a/src/generate.rs b/src/generate.rs new file mode 100644 index 0000000..bc53f38 --- /dev/null +++ b/src/generate.rs @@ -0,0 +1,233 @@ +use std::{collections::BTreeMap, fmt::Write, path::Path}; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::LitStr; + +use crate::parse::methods_for_route; + +// A struct representing a directory in the module tree +#[derive(Debug)] +struct ModuleDir { + name: String, + has_route: bool, + children: BTreeMap, +} + +impl ModuleDir { + fn new(name: &str) -> Self { + ModuleDir { + name: name.to_string(), + has_route: false, + children: BTreeMap::new(), + } + } + + fn add_to_module_tree(&mut self, rel_path: &Path, _route_path: &Path) { + let components: Vec<_> = rel_path + .components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect(); + + if components.is_empty() { + self.has_route = true; + return; + } + + let mut root = self; + + for (i, segment) in components.iter().enumerate() { + if i == components.len() - 1 && segment == "route.rs" { + root.has_route = true; + break; + } + + root = root + .children + .entry(segment.clone()) + .or_insert_with(|| ModuleDir::new(segment)); + } + } +} + +// Add a route to the module tree + +// Normalize a path segment for use as a module name +fn normalize_module_name(name: &str) -> String { + if name.starts_with('[') && name.ends_with(']') { + let inner = &name[1..name.len() - 1]; + if let Some(stripped) = inner.strip_prefix("...") { + format!("___{stripped}") + } else { + format!("__{inner}") + } + } else { + name.replace(['-', '.'], "_") + } +} + +// Convert a relative path to module path segments and axum route path +fn path_to_module_path(rel_path: &Path) -> (String, Vec) { + let mut axum_path = String::new(); + let mut mod_path = Vec::new(); + + let components: Vec<_> = rel_path + .components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect(); + + // Handle root route + if components.is_empty() { + return ("/".to_string(), vec!["route".to_string()]); + } + + for (i, segment) in components.iter().enumerate() { + if i == components.len() - 1 && segment == "route.rs" { + mod_path.push("route".to_string()); + } else { + // Process directory name + let normalized = normalize_module_name(segment); + mod_path.push(normalized); + + // Process URL path + if segment.starts_with('[') && segment.ends_with(']') { + let param = &segment[1..segment.len() - 1]; + if let Some(stripped) = param.strip_prefix("...") { + write!(&mut axum_path, "/{{*{stripped}}}").unwrap(); + } else { + write!(&mut axum_path, "/{{:{param}}}").unwrap(); + } + } else { + write!(&mut axum_path, "/{segment}").unwrap(); + } + } + } + + if axum_path.is_empty() { + axum_path = "/".to_string(); + } + + (axum_path, mod_path) +} + +// Generate tokens for a module path +fn generate_mod_path_tokens(mod_path: &[String]) -> TokenStream { + let mut result = TokenStream::new(); + + for (i, segment) in mod_path.iter().enumerate() { + let segment_ident = format_ident!("{}", segment); + + if i == 0 { + result = quote! { #segment_ident }; + } else { + result = quote! { #result::#segment_ident }; + } + } + + result +} + +// Generate module hierarchy code +fn generate_module_hierarchy(dir: &ModuleDir) -> TokenStream { + let mut result = TokenStream::new(); + + // Add route.rs module if this directory has one + if dir.has_route { + let route_mod = quote! { + #[path = "route.rs"] + pub mod route; + }; + result.extend(route_mod); + } + + // Add subdirectories + for child in dir.children.values() { + let child_name = format_ident!("{}", normalize_module_name(&child.name)); + let child_path_lit = LitStr::new(&child.name, proc_macro2::Span::call_site()); + let child_content = generate_module_hierarchy(child); + + let child_mod = quote! { + #[path = #child_path_lit] + pub mod #child_name { + #child_content + } + }; + + result.extend(child_mod); + } + + result +} + +pub fn route_registrations( + root_namespace_str: &str, + routes: &Vec<(std::path::PathBuf, std::path::PathBuf)>, +) -> TokenStream { + let root_namespace_ident = format_ident!("{}", root_namespace_str); + + let mut route_registrations = Vec::new(); + for (route_path, rel_path) in routes { + // Generate module path and axum path + let (axum_path, mod_path) = path_to_module_path(rel_path); + + let method_registrations = methods_for_route(route_path); + + if method_registrations.is_empty() { + return quote! { + compile_error!(concat!( + "No routes defined in your route.rs's !\n", + "ensure that at least one `pub async fn` named after an HTTP verb is defined. (e.g. get, post, put, delete)" + )); + }; + } + + let first_method = &method_registrations[0]; + let first_method_ident = format_ident!("{}", first_method); + + let mod_path_tokens = generate_mod_path_tokens(&mod_path); + + let mut builder = quote! { + axum::routing::#first_method_ident(#root_namespace_ident::#mod_path_tokens::#first_method_ident) + }; + + for method in &method_registrations[1..] { + let method_ident = format_ident!("{}", method); + + builder = quote! { + #builder.#method_ident(#root_namespace_ident::#mod_path_tokens::#method_ident) + }; + } + + let registration = quote! { + router = router.route(#axum_path, #builder); + }; + route_registrations.push(registration); + } + + TokenStream::from_iter(route_registrations) +} + +pub fn module_tree( + root_namespace_str: &str, + base_dir: &Path, + routes: &Vec<(std::path::PathBuf, std::path::PathBuf)>, +) -> TokenStream { + let root_namespace_ident = format_ident!("{}", root_namespace_str); + let base_path_lit = LitStr::new( + base_dir.to_str().unwrap_or("./"), + proc_macro2::Span::call_site(), + ); + + let mut root = ModuleDir::new(root_namespace_str); + for (route_path, rel_path) in routes { + root.add_to_module_tree(rel_path, route_path); + } + + let mod_hierarchy = generate_module_hierarchy(&root); + quote! { + #[path = #base_path_lit] + mod #root_namespace_ident { + #mod_hierarchy + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e4282b8..992ae7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,77 +127,28 @@ //! - **Compile-time Only**: The routing is determined at compile time, so dynamic route registration isn't supported. //! - **Expects seperate directory**: To make rust-analyzer & co work correctly the macro imports all route.rs files inside the given directory tree. //! It is highly recommended to keep the route directory seperate from the rest of your module-tree. -use std::{ - collections::BTreeMap, - fmt::Write, - fs, - path::{Path, PathBuf}, -}; +use std::path::Path; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{ - Ident, - Item, - LitStr, - Result, - Token, - Visibility, - parse::{Parse, ParseStream}, - parse_file, - parse_macro_input, -}; +use quote::quote; +use syn::parse_macro_input; -#[derive(Debug)] -struct FolderRouterArgs { - path: String, - state_type: Ident, -} - -impl Parse for FolderRouterArgs { - fn parse(input: ParseStream) -> Result { - let path_lit = input.parse::()?; - input.parse::()?; - let state_type = input.parse::()?; - - Ok(FolderRouterArgs { - path: path_lit.value(), - state_type, - }) - } -} - -// A struct representing a directory in the module tree -#[derive(Debug)] -struct ModuleDir { - name: String, - has_route: bool, - children: BTreeMap, -} - -impl ModuleDir { - fn new(name: &str) -> Self { - ModuleDir { - name: name.to_string(), - has_route: false, - children: BTreeMap::new(), - } - } -} +mod generate; +mod parse; /// Creates an Axum router module tree & creation function /// by scanning a directory for `route.rs` files. /// /// # Parameters /// -/// * `path` - A string literal pointing to the API directory, relative to the +/// * `path` - A string literal pointing to the route directory, relative to the /// Cargo manifest directory /// * `state_type` - The type name of your application state that will be shared /// across all routes #[allow(clippy::missing_panics_doc)] #[proc_macro_attribute] pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr as FolderRouterArgs); + let args = parse_macro_input!(attr as parse::FolderRouterArgs); let input_item = parse_macro_input!(item as syn::ItemStruct); let struct_name = &input_item.ident; @@ -221,12 +172,7 @@ pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream { .collect::() ); - // Collect route files - let mut routes = Vec::new(); - collect_route_files(&base_dir, &base_dir, &mut routes); - - // ensures deterministic macro output - routes.sort(); + let routes = parse::collect_route_files(&base_dir, &base_dir); if routes.is_empty() { return TokenStream::from(quote! { @@ -237,8 +183,8 @@ pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream { }); } - let module_tree = module_tree(&mod_namespace, &base_dir, &routes); - let route_registrations = route_registrations(&mod_namespace, &routes); + let module_tree = generate::module_tree(&mod_namespace, &base_dir, &routes); + let route_registrations = generate::route_registrations(&mod_namespace, &routes); quote! { #module_tree @@ -256,9 +202,7 @@ pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -// this is a workaround for macrotest behaviour -// which makes it impossible to get deterministic macro results -// without this weird stuff +// This is a workaround for macrotest behaviour #[cfg(debug_assertions)] fn get_manifest_dir() -> String { use regex::Regex; @@ -276,291 +220,3 @@ fn get_manifest_dir() -> String { fn get_manifest_dir() -> String { std::env::var("CARGO_MANIFEST_DIR").unwrap_or("./".to_string()) } - -/// parses the file at the specified location using syn -/// and returns a Vec<&'static str> of all used http verb fns -/// e.g. for the file -/// -/// ```rust -/// pub async fn get() {} // ✅ => "get" be added to vec -/// pub fn post() {} // not async -/// async fn delete() {} // not pub -/// fn patch() {} // not pub nor async -/// pub fn non_verb() {} // not a http verb -/// ``` -/// -/// it returns: `vec!["get"]` -fn methods_for_route(route_path: &PathBuf) -> Vec<&'static str> { - // Read the file content - let Ok(file_content) = fs::read_to_string(route_path) else { - return Vec::new(); - }; - - // Parse the file content into a syn syntax tree - let Ok(file) = parse_file(&file_content) else { - return Vec::new(); - }; - - // Define HTTP methods we're looking for - let allowed_methods = [ - "any", "get", "post", "put", "delete", "patch", "head", "options", "trace", "connect", - ]; - let mut found_methods = Vec::new(); - - // Collect all pub & async fn's - for item in &file.items { - if let Item::Fn(fn_item) = item { - let fn_name = fn_item.sig.ident.to_string(); - - // Check if the function name is one of our HTTP methods - // if let Some(&method) = methods.iter().find(|&&m| m == fn_name) { - // Check if the function is public - let is_public = matches!(fn_item.vis, Visibility::Public(_)); - - // Check if the function is async - let is_async = fn_item.sig.asyncness.is_some(); - - if is_public && is_async { - found_methods.push(fn_name); - } - // } - } - } - - // Iterate through methods to ensure consistent order - allowed_methods - .into_iter() - .filter(|elem| { - found_methods - .clone() - .into_iter() - .any(|method| method == *elem) - }) - .collect() -} - -fn route_registrations( - // root_namespace_str: String, - // base_dir: String, - root_namespace_str: &str, - routes: &Vec<(PathBuf, PathBuf)>, -) -> proc_macro2::TokenStream { - let root_namespace_ident = format_ident!("{}", root_namespace_str); - - let mut route_registrations = Vec::new(); - for (route_path, rel_path) in routes { - // Generate module path and axum path - let (axum_path, mod_path) = path_to_module_path(rel_path); - - let method_registrations = methods_for_route(route_path); - - if method_registrations.is_empty() { - return quote! { - compile_error!(concat!( - "No routes defined in your route.rs's !\n", - "ensure that at least one `pub async fn` named after an HTTP verb is defined. (e.g. get, post, put, delete)" - )); - }; - } - - let first_method = &method_registrations[0]; - let first_method_ident = format_ident!("{}", first_method); - - let mod_path_tokens = generate_mod_path_tokens(&mod_path); - - let mut builder = quote! { - axum::routing::#first_method_ident(#root_namespace_ident::#mod_path_tokens::#first_method_ident) - }; - - for method in &method_registrations[1..] { - let method_ident = format_ident!("{}", method); - - builder = quote! { - #builder.#method_ident(#root_namespace_ident::#mod_path_tokens::#method_ident) - }; - } - - let registration = quote! { - router = router.route(#axum_path, #builder); - }; - route_registrations.push(registration); - } - - proc_macro2::TokenStream::from_iter(route_registrations) -} - -fn module_tree( - root_namespace_str: &str, - base_dir: &Path, - routes: &Vec<(PathBuf, PathBuf)>, -) -> proc_macro2::TokenStream { - let root_namespace_ident = format_ident!("{}", root_namespace_str); - let base_path_lit = LitStr::new( - base_dir.to_str().unwrap_or("./"), - proc_macro2::Span::call_site(), - ); - - let mut root = ModuleDir::new(root_namespace_str); - for (route_path, rel_path) in routes { - add_to_module_tree(&mut root, rel_path, route_path); - } - - let mod_hierarchy = generate_module_hierarchy(&root); - quote! { - #[path = #base_path_lit] - mod #root_namespace_ident { - #mod_hierarchy - } - } -} - -// Add a route to the module tree -fn add_to_module_tree(root: &mut ModuleDir, rel_path: &Path, _route_path: &Path) { - let components: Vec<_> = rel_path - .components() - .map(|c| c.as_os_str().to_string_lossy().to_string()) - .collect(); - - if components.is_empty() { - root.has_route = true; - return; - } - - let mut root = root; - - for (i, segment) in components.iter().enumerate() { - if i == components.len() - 1 && segment == "route.rs" { - root.has_route = true; - break; - } - - root = root - .children - .entry(segment.clone()) - .or_insert_with(|| ModuleDir::new(segment)); - } -} - -// Generate module hierarchy code -fn generate_module_hierarchy(dir: &ModuleDir) -> proc_macro2::TokenStream { - let mut result = proc_macro2::TokenStream::new(); - - // Add route.rs module if this directory has one - if dir.has_route { - let route_mod = quote! { - #[path = "route.rs"] - pub mod route; - }; - result.extend(route_mod); - } - - // Add subdirectories - for child in dir.children.values() { - let child_name = format_ident!("{}", normalize_module_name(&child.name)); - let child_path_lit = LitStr::new(&child.name, proc_macro2::Span::call_site()); - let child_content = generate_module_hierarchy(child); - - let child_mod = quote! { - #[path = #child_path_lit] - pub mod #child_name { - #child_content - } - }; - - result.extend(child_mod); - } - - result -} - -// Generate tokens for a module path -fn generate_mod_path_tokens(mod_path: &[String]) -> proc_macro2::TokenStream { - let mut result = proc_macro2::TokenStream::new(); - - for (i, segment) in mod_path.iter().enumerate() { - let segment_ident = format_ident!("{}", segment); - - if i == 0 { - result = quote! { #segment_ident }; - } else { - result = quote! { #result::#segment_ident }; - } - } - - result -} - -// Normalize a path segment for use as a module name -fn normalize_module_name(name: &str) -> String { - if name.starts_with('[') && name.ends_with(']') { - let inner = &name[1..name.len() - 1]; - if let Some(stripped) = inner.strip_prefix("...") { - format!("___{stripped}") - } else { - format!("__{inner}") - } - } else { - name.replace(['-', '.'], "_") - } -} - -// Convert a relative path to module path segments and axum route path -fn path_to_module_path(rel_path: &Path) -> (String, Vec) { - let mut axum_path = String::new(); - let mut mod_path = Vec::new(); - - let components: Vec<_> = rel_path - .components() - .map(|c| c.as_os_str().to_string_lossy().to_string()) - .collect(); - - // Handle root route - if components.is_empty() { - return ("/".to_string(), vec!["route".to_string()]); - } - - for (i, segment) in components.iter().enumerate() { - if i == components.len() - 1 && segment == "route.rs" { - mod_path.push("route".to_string()); - } else { - // Process directory name - let normalized = normalize_module_name(segment); - mod_path.push(normalized); - - // Process URL path - if segment.starts_with('[') && segment.ends_with(']') { - let param = &segment[1..segment.len() - 1]; - if let Some(stripped) = param.strip_prefix("...") { - write!(&mut axum_path, "/{{*{stripped}}}").unwrap(); - } else { - write!(&mut axum_path, "/{{:{param}}}").unwrap(); - } - } else { - write!(&mut axum_path, "/{segment}").unwrap(); - } - } - } - - if axum_path.is_empty() { - axum_path = "/".to_string(); - } - - (axum_path, mod_path) -} - -// Collect route.rs files recursively -fn collect_route_files(base_dir: &Path, dir: &Path, routes: &mut Vec<(PathBuf, PathBuf)>) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.filter_map(std::result::Result::ok) { - let path = entry.path(); - - if path.is_dir() { - collect_route_files(base_dir, &path, routes); - } else if path.file_name().unwrap_or_default() == "route.rs" { - if let Ok(rel_dir) = path.strip_prefix(base_dir) { - routes.push((path.clone(), rel_dir.to_path_buf())); - } - } - } - } -} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..5295aff --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,98 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use syn::{ + Ident, + Item, + LitStr, + Result, + Token, + Visibility, + parse::{Parse, ParseStream}, + parse_file, +}; + +#[derive(Debug)] +pub struct FolderRouterArgs { + pub path: String, + pub state_type: Ident, +} + +impl Parse for FolderRouterArgs { + fn parse(input: ParseStream) -> Result { + let path_lit = input.parse::()?; + input.parse::()?; + let state_type = input.parse::()?; + + Ok(FolderRouterArgs { + path: path_lit.value(), + state_type, + }) + } +} + +/// Parses the file at the specified location and returns HTTP verb functions +pub fn methods_for_route(route_path: &PathBuf) -> Vec<&'static str> { + // Read the file content + let Ok(file_content) = fs::read_to_string(route_path) else { + return Vec::new(); + }; + + // Parse the file content into a syn syntax tree + let Ok(file) = parse_file(&file_content) else { + return Vec::new(); + }; + + // Define HTTP methods we're looking for + let allowed_methods = [ + "any", "get", "post", "put", "delete", "patch", "head", "options", "trace", "connect", + ]; + let mut found_methods = Vec::new(); + + // Collect all pub & async fn's + for item in &file.items { + if let Item::Fn(fn_item) = item { + let fn_name = fn_item.sig.ident.to_string(); + let is_public = matches!(fn_item.vis, Visibility::Public(_)); + let is_async = fn_item.sig.asyncness.is_some(); + + if is_public && is_async { + found_methods.push(fn_name); + } + } + } + + // Iterate through methods to ensure consistent order + allowed_methods + .into_iter() + .filter(|elem| { + found_methods + .clone() + .into_iter() + .any(|method| method == *elem) + }) + .collect() +} + +// Collect route.rs files recursively +pub fn collect_route_files(base_dir: &Path, dir: &Path) -> Vec<(PathBuf, PathBuf)> { + let mut routes = Vec::new(); + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.filter_map(std::result::Result::ok) { + let path = entry.path(); + + if path.is_dir() { + let mut nested_routes = collect_route_files(base_dir, &path); + routes.append(&mut nested_routes); + } else if path.file_name().unwrap_or_default() == "route.rs" { + if let Ok(rel_dir) = path.strip_prefix(base_dir) { + routes.push((path.clone(), rel_dir.to_path_buf())); + } + } + } + } + routes.sort(); + routes +}