use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use std::path::{Path, PathBuf};
use syn::{parse::Parse, parse::ParseStream, parse_macro_input, LitStr};
use crate::utils::levenshtein_distance;
pub struct RedirectInput {
pub route_name: LitStr,
}
impl Parse for RedirectInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(RedirectInput {
route_name: input.parse()?,
})
}
}
pub fn redirect_impl(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as RedirectInput);
let ferro = quote!(::ferro);
let route_name = input.route_name.value();
let route_lit = &input.route_name;
if route_name.starts_with('/') {
let expanded = quote! {
#ferro::Redirect::to(#route_lit)
};
return expanded.into();
}
if let Err(err) = validate_route_exists(&route_name, route_lit.span()) {
return err.to_compile_error().into();
}
let expanded = quote! {
#ferro::Redirect::route(#route_lit)
};
expanded.into()
}
fn validate_route_exists(route_name: &str, span: Span) -> Result<(), syn::Error> {
let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
Ok(dir) => dir,
Err(_) => return Ok(()), };
let project_root = PathBuf::from(&manifest_dir);
let available_routes = extract_route_names(&project_root);
if available_routes.is_empty() {
return Ok(());
}
if !available_routes.contains(&route_name.to_string()) {
let mut error_msg = format!("Route '{route_name}' not found.");
error_msg.push_str("\n\nAvailable routes:");
for route in &available_routes {
error_msg.push_str(&format!("\n - {route}"));
}
if let Some(suggestion) = find_similar_route(route_name, &available_routes) {
error_msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
}
return Err(syn::Error::new(span, error_msg));
}
Ok(())
}
fn extract_route_names(project_root: &Path) -> Vec<String> {
let routes_rs = project_root.join("src").join("routes.rs");
let main_rs = project_root.join("src").join("main.rs");
let content = std::fs::read_to_string(&routes_rs)
.or_else(|_| std::fs::read_to_string(&main_rs))
.unwrap_or_default();
if content.is_empty() {
return Vec::new();
}
let mut routes = Vec::new();
let name_re = regex::Regex::new(r#"\.name\s*\(\s*"([^"]+)"\s*\)"#).unwrap();
for cap in name_re.captures_iter(&content) {
if let Some(m) = cap.get(1) {
routes.push(m.as_str().to_string());
}
}
let resource_re = regex::Regex::new(r#"resource!\s*\(\s*"(/[^"]*)"#).unwrap();
let actions_re = regex::Regex::new(r"\[([^\]]+)\]").unwrap();
for cap in resource_re.captures_iter(&content) {
if let Some(m) = cap.get(1) {
let path = m.as_str();
let name_prefix = path.trim_start_matches('/').replace('/', ".");
let start = m.start();
let remaining = &content[start..];
if let Some(resource_call_end) = remaining.find("),") {
let resource_call = &remaining[..resource_call_end + 1];
if let Some(only_start) = resource_call.find("only:") {
let only_section = &resource_call[only_start..];
if let Some(actions_cap) = actions_re.captures(only_section) {
if let Some(actions_str) = actions_cap.get(1) {
for action in actions_str.as_str().split(',') {
let action = action.trim();
if !action.is_empty() {
routes.push(format!("{name_prefix}.{action}"));
}
}
}
}
} else {
for action in &[
"index", "create", "store", "show", "edit", "update", "destroy",
] {
routes.push(format!("{name_prefix}.{action}"));
}
}
} else {
for action in &[
"index", "create", "store", "show", "edit", "update", "destroy",
] {
routes.push(format!("{name_prefix}.{action}"));
}
}
}
}
routes
}
fn find_similar_route(target: &str, available: &[String]) -> Option<String> {
let target_lower = target.to_lowercase();
for route in available {
if route.to_lowercase() == target_lower {
return Some(route.clone());
}
}
let mut best_match: Option<(String, usize)> = None;
let threshold = std::cmp::max(2, target.len() / 3);
for route in available {
let distance = levenshtein_distance(&target_lower, &route.to_lowercase());
if distance <= threshold
&& (best_match.is_none() || distance < best_match.as_ref().unwrap().1)
{
best_match = Some((route.clone(), distance));
}
}
best_match.map(|(name, _)| name)
}