use std::collections::HashMap;
use std::path::Path;
use syn::Item;
mod path_scan;
pub use path_scan::{fingerprints_from_scan, scan_route_folder};
use crate::{
error::{MacroResult, err_call_site},
file_utils::{file_to_segments, normalize_display_path, normalize_path_key},
metadata::{CollectedMetadata, RouteMetadata},
route::{extract_doc_comment, extract_route_info},
route_impl::StoredRouteInfo,
};
fn kebab_case_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let mut in_param = false;
for ch in path.chars() {
match ch {
'{' => {
in_param = true;
out.push(ch);
}
'}' => {
in_param = false;
out.push(ch);
}
'_' if !in_param => out.push('-'),
other => out.push(other),
}
}
out
}
fn build_route_path(base_path: &str, custom_path: Option<&str>) -> String {
let joined = custom_path.map_or_else(
|| base_path.to_owned(),
|custom| {
let trimmed_base = base_path.trim_end_matches('/');
format!("{trimmed_base}/{}", custom.trim_start_matches('/'))
},
);
kebab_case_path(&joined)
}
#[inline]
fn take_or_clone(
module_path: &mut String,
file_path: &mut String,
is_last: bool,
) -> (String, String) {
if is_last {
(std::mem::take(module_path), std::mem::take(file_path))
} else {
(module_path.clone(), file_path.clone())
}
}
fn push_stored_routes(
metadata: &mut CollectedMetadata,
base_path: &str,
module_path: &mut String,
file_path: &mut String,
stored_routes: &[&StoredRouteInfo],
) {
let n = stored_routes.len();
for (i, stored) in stored_routes.iter().enumerate() {
let route_path = build_route_path(base_path, stored.custom_path.as_deref());
let description = stored.description.clone();
let (mp, fp) = take_or_clone(module_path, file_path, i + 1 == n);
metadata.routes.push(RouteMetadata {
method: stored.method.clone().unwrap_or_else(|| "get".to_string()),
path: route_path,
function_name: stored.fn_name.clone(),
module_path: mp,
file_path: fp,
success_status: stored.success_status,
error_status: stored.error_status.clone(),
typed_responses: stored.typed_responses.clone(),
tags: stored.tags.clone(),
security: stored.security.clone(),
headers: stored.headers.clone(),
operation_id: stored.operation_id.clone(),
summary: stored.summary.clone(),
request_example: stored.request_example.clone(),
response_example: stored.response_example.clone(),
deprecated: stored.deprecated,
description,
});
}
}
fn push_parsed_routes(
metadata: &mut CollectedMetadata,
base_path: &str,
module_path: &mut String,
file_path: &mut String,
file_ast: &syn::File,
) {
let mut route_entries: Vec<(&syn::ItemFn, crate::route::RouteInfo)> = Vec::new();
for item in &file_ast.items {
if let Item::Fn(fn_item) = item
&& let Some(route_info) = extract_route_info(&fn_item.attrs)
{
route_entries.push((fn_item, route_info));
}
}
let n = route_entries.len();
for (i, (fn_item, route_info)) in route_entries.into_iter().enumerate() {
let route_path = build_route_path(base_path, route_info.path.as_deref());
let description = route_info
.description
.or_else(|| extract_doc_comment(&fn_item.attrs));
let (mp, fp) = take_or_clone(module_path, file_path, i + 1 == n);
metadata.routes.push(RouteMetadata {
method: route_info.method,
path: route_path,
function_name: fn_item.sig.ident.to_string(),
module_path: mp,
file_path: fp,
success_status: route_info.success_status,
error_status: route_info.error_status,
typed_responses: route_info.typed_responses,
tags: route_info.tags,
security: route_info.security,
headers: route_info.headers,
operation_id: route_info.operation_id,
summary: route_info.summary,
request_example: route_info.request_example,
response_example: route_info.response_example,
deprecated: route_info.deprecated,
description,
});
}
}
#[cfg(test)]
pub fn collect_metadata(
folder_path: &Path,
folder_name: &str,
route_storage: &[StoredRouteInfo],
) -> MacroResult<(CollectedMetadata, HashMap<String, syn::File>)> {
let files = crate::file_utils::collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?;
collect_metadata_from_files(
files.iter().map(std::path::PathBuf::as_path),
folder_path,
folder_name,
route_storage,
)
}
pub fn collect_metadata_from_files<'a>(
files: impl IntoIterator<Item = &'a Path>,
folder_path: &Path,
folder_name: &str,
route_storage: &[StoredRouteInfo],
) -> MacroResult<(CollectedMetadata, HashMap<String, syn::File>)> {
let mut metadata = CollectedMetadata::new();
let mut file_asts = HashMap::new();
let cwd = std::env::current_dir().unwrap_or_default();
let storage_by_file: HashMap<String, Vec<&StoredRouteInfo>> = {
let mut map: HashMap<String, Vec<&StoredRouteInfo>> = HashMap::new();
for stored in route_storage {
if let Some(ref fp) = stored.file_path {
map.entry(normalize_path_key(fp, &cwd))
.or_default()
.push(stored);
}
}
map
};
for file in files {
if file.extension().is_none_or(|e| e != "rs") {
continue;
}
let mut file_path = normalize_display_path(file);
let file_key = normalize_path_key(&file_path, &cwd);
let segments = file
.strip_prefix(folder_path)
.map(|file_stem| file_to_segments(file_stem, folder_path))
.map_err(|e| {
err_call_site(format!(
"Failed to strip prefix from file: {} (base: {}): {}",
file.display(),
folder_path.display(),
e
))
})?;
let mut module_path = if folder_name.is_empty() {
segments.join("::")
} else {
format!("{}::{}", folder_name, segments.join("::"))
};
let base_path = format!("/{}", segments.join("/"));
if let Some(stored_routes) = storage_by_file.get(&file_key) {
push_stored_routes(
&mut metadata,
&base_path,
&mut module_path,
&mut file_path,
stored_routes,
);
} else {
let file_ast = crate::schema_macro::file_cache::get_parsed_file(file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?;
let file_ast: &syn::File = file_asts.entry(file_path.clone()).or_insert(file_ast);
push_parsed_routes(
&mut metadata,
&base_path,
&mut module_path,
&mut file_path,
file_ast,
);
}
}
Ok((metadata, file_asts))
}
#[cfg(test)]
mod tests;