use proc_macro2::TokenStream;
use quote::quote;
use syn::spanned::Spanned;
use crate::diagnostic::{MacroError, MacroErrorCode, MacroResult};
use crate::field_shape::{collect_field_shapes, emit_field_shape_slice};
use crate::schema::map_field;
pub fn resource(attr: TokenStream, item: TokenStream) -> MacroResult {
if !attr.is_empty() {
return Err(MacroError::new(
MacroErrorCode::ArcM009,
proc_macro2::Span::call_site(),
format!("#[resource] takes no arguments (got: {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(),
"#[resource] 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 fields_slice = emit_field_shape_slice(&collect_field_shapes(&item_struct)?);
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::ResourceMetadata for #struct_name #ty_generics
#where_clause
{
const FIELDS: &'static [::arcature::FieldShape] = #fields_slice;
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn expand(item: TokenStream) -> String {
resource(TokenStream::new(), item).unwrap().to_string()
}
#[test]
fn generates_a_client_data_impl() {
let s = expand(quote! {
pub struct UserResource {
pub id: String,
pub name: String,
}
});
assert!(
s.contains(":: arcature :: inertia :: ClientData"),
"got: {s}"
);
assert!(s.contains("exposure_schema"), "got: {s}");
}
#[test]
fn adds_the_serialize_derive() {
let s = expand(quote! { pub struct UserResource { pub id: String } });
assert!(s.contains("Serialize"), "got: {s}");
}
#[test]
fn does_not_generate_a_page_contract() {
let s = expand(quote! { pub struct UserResource { pub id: String } });
assert!(!s.contains("PAGE_CONTRACT"), "got: {s}");
}
#[test]
fn generates_a_resource_metadata_impl() {
let s = expand(quote! {
pub struct LinkResource {
pub id: String,
pub tags: Vec<String>,
}
});
assert!(s.contains(":: arcature :: ResourceMetadata"), "got: {s}");
assert!(s.contains("FieldShape"), "got: {s}");
assert!(s.contains("\"id\""), "got: {s}");
assert!(s.contains("\"Vec<String>\""), "got: {s}");
}
#[test]
fn resource_fields_carry_no_validate_rules() {
let s = expand(quote! { pub struct UserResource { pub id: String } });
assert!(s.contains("validates : & []"), "got: {s}");
}
#[test]
fn nested_named_field_types_go_through_the_firewall() {
let s = expand(quote! {
pub struct PostResource {
pub author: UserResource,
pub tags: Vec<TagResource>,
}
});
assert!(s.contains("nested :: < UserResource >"), "got: {s}");
assert!(s.contains("nested_array :: < TagResource >"), "got: {s}");
}
#[test]
fn rejects_attribute_arguments() {
let err = resource(
quote! { name = "x" },
quote! { pub struct R { pub id: String } },
)
.unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM009);
}
#[test]
fn rejects_enum_item() {
let err = resource(TokenStream::new(), quote! { enum Status { Active } }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM001);
}
#[test]
fn rejects_tuple_struct() {
let err = resource(TokenStream::new(), quote! { struct Tuple(String); }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn rejects_unit_struct() {
let err = resource(TokenStream::new(), quote! { struct Unit; }).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
}