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, DeriveInput, Expr, LitStr, Token};
use crate::utils::levenshtein_distance;
pub enum PropsKind {
Typed(Expr),
Json(proc_macro2::TokenStream),
}
pub struct InertiaResponseInput {
pub component: LitStr,
pub _comma: Token![,],
pub props: PropsKind,
pub config: Option<ConfigArg>,
}
pub struct ConfigArg {
pub _comma: Token![,],
pub expr: Expr,
}
impl Parse for InertiaResponseInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let component: LitStr = input.parse()?;
let comma: Token![,] = input.parse()?;
let props = if input.peek(syn::Ident) {
let expr: Expr = input.parse()?;
PropsKind::Typed(expr)
} else {
let props_content;
syn::braced!(props_content in input);
let props_tokens: proc_macro2::TokenStream = props_content.parse()?;
PropsKind::Json(props_tokens)
};
let config = if input.peek(Token![,]) {
let config_comma: Token![,] = input.parse()?;
let config_expr: Expr = input.parse()?;
Some(ConfigArg {
_comma: config_comma,
expr: config_expr,
})
} else {
None
};
Ok(InertiaResponseInput {
component,
_comma: comma,
props,
config,
})
}
}
#[derive(Clone, Copy, PartialEq)]
enum RenameAll {
None,
CamelCase,
}
fn to_camel_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = false;
for c in s.chars() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.extend(c.to_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
fn parse_rename_all(attrs: &[syn::Attribute]) -> RenameAll {
for attr in attrs {
if attr.path().is_ident("serde") || attr.path().is_ident("inertia") {
if let Ok(nested) = attr.parse_args::<syn::MetaNameValue>() {
if nested.path.is_ident("rename_all") {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = &nested.value
{
if lit_str.value().as_str() == "camelCase" {
return RenameAll::CamelCase;
}
}
}
}
}
}
RenameAll::None
}
pub fn derive_inertia_props_impl(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let ferro = quote!(::ferro);
let name = &input.ident;
let generics = &input.generics;
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let rename_all = parse_rename_all(&input.attrs);
let fields = match &input.data {
syn::Data::Struct(data) => match &data.fields {
syn::Fields::Named(fields) => &fields.named,
_ => {
return syn::Error::new_spanned(
&input,
"InertiaProps only supports structs with named fields",
)
.to_compile_error()
.into();
}
},
_ => {
return syn::Error::new_spanned(&input, "InertiaProps can only be derived for structs")
.to_compile_error()
.into();
}
};
let field_count = fields.len();
let field_names: Vec<_> = fields.iter().map(|f| &f.ident).collect();
let field_name_strings: Vec<_> = fields
.iter()
.map(|f| {
let name = f.ident.as_ref().unwrap().to_string();
match rename_all {
RenameAll::CamelCase => to_camel_case(&name),
RenameAll::None => name,
}
})
.collect();
let expanded = quote! {
impl #impl_generics #ferro::serde::Serialize for #name #ty_generics #where_clause {
fn serialize<S>(&self, serializer: S) -> ::core::result::Result<S::Ok, S::Error>
where
S: #ferro::serde::Serializer,
{
use #ferro::serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct(stringify!(#name), #field_count)?;
#(
state.serialize_field(#field_name_strings, &self.#field_names)?;
)*
state.end()
}
}
};
expanded.into()
}
pub fn inertia_response_impl(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as InertiaResponseInput);
let ferro = quote!(::ferro);
let component_name = input.component.value();
let component_lit = &input.component;
if let Err(err) = validate_component_exists(&component_name, component_lit.span()) {
return err.to_compile_error().into();
}
let props_expr = match &input.props {
PropsKind::Typed(expr) => {
quote! {
#ferro::serde_json::to_value(&#expr)
.expect("Failed to serialize InertiaProps")
}
}
PropsKind::Json(tokens) => {
quote! {
#ferro::serde_json::json!({#tokens})
}
}
};
let expanded = if let Some(config) = input.config {
let config_expr = config.expr;
quote! {{
let props = #props_expr;
let url = #ferro::InertiaContext::current_path();
let response = #ferro::InertiaResponse::new(#component_lit, props, url)
.with_config(#config_expr);
if #ferro::InertiaContext::is_inertia_request() {
Ok(response.to_json_response())
} else {
Ok(response.to_html_response())
}
}}
} else {
quote! {{
let props = #props_expr;
let url = #ferro::InertiaContext::current_path();
let response = #ferro::InertiaResponse::new(#component_lit, props, url);
if #ferro::InertiaContext::is_inertia_request() {
Ok(response.to_json_response())
} else {
Ok(response.to_html_response())
}
}}
};
expanded.into()
}
fn validate_component_exists(component_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 component_path = project_root
.join("frontend")
.join("src")
.join("pages")
.join(format!("{component_name}.tsx"));
if !component_path.exists() {
let available = list_available_components(&project_root);
let mut error_msg = format!(
"Inertia component '{component_name}' not found.\nExpected file: frontend/src/pages/{component_name}.tsx"
);
if !available.is_empty() {
error_msg.push_str("\n\nAvailable components:");
for comp in &available {
error_msg.push_str(&format!("\n - {comp}"));
}
if let Some(suggestion) = find_similar_component(component_name, &available) {
error_msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
}
} else {
error_msg.push_str("\n\nNo components found in frontend/src/pages/");
error_msg
.push_str("\nMake sure your frontend directory structure is set up correctly.");
}
return Err(syn::Error::new(span, error_msg));
}
Ok(())
}
fn list_available_components(project_root: &Path) -> Vec<String> {
let pages_dir = project_root.join("frontend").join("src").join("pages");
let mut components = Vec::new();
collect_components_recursive(&pages_dir, &pages_dir, &mut components);
components.sort();
components
}
fn collect_components_recursive(base_dir: &Path, current_dir: &Path, components: &mut Vec<String>) {
if let Ok(entries) = std::fs::read_dir(current_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
collect_components_recursive(base_dir, &path, components);
} else if path.extension().map(|e| e == "tsx").unwrap_or(false) {
if let Ok(relative) = path.strip_prefix(base_dir) {
if let Some(stem) = relative.with_extension("").to_str() {
let component_name = stem.replace(std::path::MAIN_SEPARATOR, "/");
components.push(component_name);
}
}
}
}
}
}
fn find_similar_component(target: &str, available: &[String]) -> Option<String> {
let target_lower = target.to_lowercase();
for comp in available {
if comp.to_lowercase() == target_lower {
return Some(comp.clone());
}
}
let mut best_match: Option<(String, usize)> = None;
for comp in available {
let distance = levenshtein_distance(&target_lower, &comp.to_lowercase());
let threshold = std::cmp::max(2, target.len() / 3);
if distance <= threshold
&& (best_match.is_none() || distance < best_match.as_ref().unwrap().1)
{
best_match = Some((comp.clone(), distance));
}
}
best_match.map(|(name, _)| name)
}