use proc_macro2::TokenStream;
use quote::quote;
use syn::spanned::Spanned;
use crate::diagnostic::{MacroError, MacroErrorCode, MacroResult};
use crate::schema::map_field;
pub fn page(attr: TokenStream, item: TokenStream) -> MacroResult {
let page_name = parse_page_name(attr)?;
let item_struct: syn::ItemStruct =
syn::parse2(item).map_err(|e| MacroError::from_syn(MacroErrorCode::ArcM001, e))?;
let syn::Fields::Named(named) = &item_struct.fields else {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
item_struct.fields.span(),
"#[page] requires a struct with named fields \
(e.g. `struct Foo { field: Type }`)",
));
};
let field_chains = named
.named
.iter()
.map(|field| {
let name = field
.ident
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
map_field(&name, &field.ty)
})
.collect::<Result<Vec<_>, _>>()?;
let struct_name = &item_struct.ident;
let (impl_generics, ty_generics, where_clause) = item_struct.generics.split_for_impl();
Ok(quote! {
#[derive(::arcature::Serialize)]
#item_struct
impl #impl_generics ::arcature::inertia::ClientData for #struct_name #ty_generics
#where_clause
{
fn exposure_schema() -> ::arcature::inertia::PropsSchema {
::arcature::inertia::PropsSchema::new()
#( #field_chains )*
}
}
impl #impl_generics ::arcature::inertia::PageType for #struct_name #ty_generics
#where_clause
{
const CONTRACT: ::arcature::inertia::PageContract<Self> =
::arcature::inertia::PageContract::new(#page_name);
}
impl #impl_generics #struct_name #ty_generics #where_clause {
pub const PAGE_CONTRACT: ::arcature::inertia::PageContract<Self> =
::arcature::inertia::PageContract::new(#page_name);
pub const PAGE_CONTRACT_ENTRY: ::arcature::inertia::PageContractEntry =
::arcature::inertia::PageContractEntry::new(
#page_name,
<Self as ::arcature::inertia::ClientData>::exposure_schema,
);
}
})
}
fn parse_page_name(attr: TokenStream) -> Result<String, MacroError> {
let lit: syn::LitStr = syn::parse2(attr).map_err(|_| {
MacroError::new(
MacroErrorCode::ArcM002,
proc_macro2::Span::call_site(),
"#[page] requires a string literal page name, e.g. #[page(\"users/show\")]",
)
})?;
let name = lit.value();
if name.is_empty() {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
lit.span(),
"#[page(\"...\")] name must not be empty",
));
}
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
fn expand(attr: TokenStream, item: TokenStream) -> String {
page(attr, item).unwrap().to_string()
}
#[test]
fn generates_a_client_data_impl() {
let s = expand(
quote! { "home" },
quote! { pub struct HomePage { pub title: String } },
);
assert!(
s.contains(":: arcature :: inertia :: ClientData"),
"got: {s}"
);
assert!(s.contains("exposure_schema"), "got: {s}");
}
#[test]
fn generates_the_page_contract_const() {
let s = expand(
quote! { "users/show" },
quote! { pub struct ShowUserPage { pub user: UserResource } },
);
assert!(s.contains("PAGE_CONTRACT :"), "got: {s}");
assert!(s.contains("\"users/show\""), "got: {s}");
}
#[test]
fn generates_the_page_contract_entry_const() {
let s = expand(
quote! { "home" },
quote! { pub struct HomePage { pub title: String } },
);
assert!(s.contains("PAGE_CONTRACT_ENTRY"), "got: {s}");
assert!(s.contains("PageContractEntry :: new"), "got: {s}");
}
#[test]
fn adds_the_serialize_derive() {
let s = expand(
quote! { "home" },
quote! { pub struct HomePage { pub title: String } },
);
assert!(s.contains("Serialize"), "got: {s}");
}
#[test]
fn nested_named_field_types_go_through_the_firewall() {
let s = expand(
quote! { "users/show" },
quote! {
pub struct ShowUserPage {
pub user: UserResource,
pub can_edit: bool,
}
},
);
assert!(s.contains("nested :: < UserResource >"), "got: {s}");
assert!(s.contains("boolean ()"), "got: {s}");
}
#[test]
fn a_page_with_no_fields_gets_an_empty_schema() {
let s = expand(quote! { "blank" }, quote! { pub struct BlankPage {} });
assert!(s.contains("PropsSchema :: new ()"), "got: {s}");
}
#[test]
fn rejects_a_missing_page_name() {
let err = page(
TokenStream::new(),
quote! { pub struct P { pub a: String } },
)
.unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn rejects_a_non_string_page_name() {
let err = page(quote! { 42 }, quote! { pub struct P { pub a: String } }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn rejects_an_empty_page_name() {
let err = page(quote! { "" }, quote! { pub struct P { pub a: String } }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn rejects_an_enum_item() {
let err = page(quote! { "home" }, quote! { enum Status { Active } }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM001);
}
#[test]
fn rejects_a_tuple_struct() {
let err = page(quote! { "home" }, quote! { struct P(String); }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
}