arcature-macros 0.1.0

Proc-macro crate for Arcature: #[model], #[request], #[controller], #[derive(Job)], #[derive(Event)].
Documentation
//! `#[page("name")]` -- declares a browser-safe Inertia page prop struct.
//!
//! Emits five things from the annotated struct:
//!
//! 1. The struct unchanged, with a `#[derive(::arcature::Serialize)]` that
//!    satisfies the `ClientData: Serialize` supertrait.
//! 2. `impl ::arcature::inertia::ClientData`, whose `exposure_schema()` is
//!    built from the struct's named fields through the shared
//!    [`crate::schema`] type-to-schema mapping.
//! 3. An associated `PAGE_CONTRACT` const -- the typed registry path,
//!    registered with `PageContracts::register`.
//! 4. `impl ::arcature::inertia::PageType`, the same identity reachable
//!    from generic code -- `Page<T>` in a handler return type needs a
//!    bound, and an inherent const cannot be named through one.
//! 5. An associated `PAGE_CONTRACT_ENTRY` const -- the non-generic
//!    aggregation path. `module!`'s `pages:` section collects these into a
//!    `&'static [PageContractEntry]` slice so `application!` can build the
//!    `PageContracts` registry from the graph with no hand-written
//!    registration chain.
//!
//! ## Syntax
//!
//! ```ignore
//! #[page("users/show")]
//! pub struct ShowUserPage {
//!     pub user: UserResource,
//!     pub can_edit: bool,
//! }
//! ```
//!
//! A missing or non-string page name produces `error[ARC-M002]`; a
//! non-struct item produces `error[ARC-M001]`; a tuple or unit struct
//! produces `error[ARC-M002]`.
//!
//! ## Client Exposure Firewall
//!
//! The generated `exposure_schema()` maps a named (non-primitive) field type
//! to `PropsSchema::nested::<FieldType>`, which requires
//! `FieldType: ClientData`. An internal domain model that merely derives
//! `Serialize` therefore cannot appear in a page's props -- the program does
//! not compile.

use proc_macro2::TokenStream;
use quote::quote;
use syn::spanned::Spanned;

use crate::diagnostic::{MacroError, MacroErrorCode, MacroResult};
use crate::schema::map_field;

/// The implementation of `#[page("name")]`. Called by the thin `lib.rs`
/// entrypoint. Returns a [`MacroError`] (converted to `compile_error!` by
/// the entrypoint) on failure -- never panics.
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 {
            /// The stable page identity for this page type, generated by
            /// `#[page("name")]`. Register it with
            /// `PageContracts::register`.
            pub const PAGE_CONTRACT: ::arcature::inertia::PageContract<Self> =
                ::arcature::inertia::PageContract::new(#page_name);

            /// The non-generic page-contract descriptor, generated by
            /// `#[page("name")]`. `module!`'s `pages:` section collects it
            /// into a `&'static [PageContractEntry]` slice so
            /// `application!` can build `PageContracts` from the graph with
            /// no hand-written registration chain. The `ClientData`
            /// firewall holds: this const only exists for types that
            /// implement `ClientData`, which the same macro generates
            /// above.
            pub const PAGE_CONTRACT_ENTRY: ::arcature::inertia::PageContractEntry =
                ::arcature::inertia::PageContractEntry::new(
                    #page_name,
                    <Self as ::arcature::inertia::ClientData>::exposure_schema,
                );
        }
    })
}

/// Parses the attribute argument as a single non-empty string literal.
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);
    }
}