rok-search-macros 0.6.0

Derive macro for the rok-orm search Searchable trait
Documentation
//! Proc-macro crate for `rok-orm` search abstraction.
//!
//! Provides `#[derive(Searchable)]` which generates an `impl rok_orm::search::Searchable`
//! for any struct, reading `#[searchable(rank = N)]` field attributes.
//!
//! This crate is re-exported by `rok-orm` when `features = ["search", "search-macros"]`.
//!
//! # Example
//! ```rust,ignore
//! #[derive(serde::Serialize, serde::Deserialize, Searchable)]
//! #[searchable(index = "posts")]
//! pub struct Post {
//!     pub id: i64,
//!     #[searchable(rank = 10)]
//!     pub title: String,
//!     #[searchable(rank = 5)]
//!     pub body: String,
//!     pub published: bool,
//! }
//! ```

use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields, LitInt, LitStr};

/// Derive macro that implements `rok_orm::search::Searchable` for a struct.
///
/// Struct-level attribute `#[searchable(index = "table")]` sets the index name.
/// Field-level attribute `#[searchable(rank = N)]` marks a field as searchable with
/// the given rank weight (10 → A, 7–9 → B, 5–6 → C, < 5 → D).
#[proc_macro_derive(Searchable, attributes(searchable))]
pub fn derive_searchable(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let ident = &input.ident;

    let mut index_name = ident.to_string().to_lowercase() + "s";

    for attr in &input.attrs {
        if attr.path().is_ident("searchable") {
            let _ = attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("index") {
                    let value: LitStr = meta.value()?.parse()?;
                    index_name = value.value();
                }
                Ok(())
            });
        }
    }

    let fields = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(f) => &f.named,
            _ => {
                return syn::Error::new(
                    Span::call_site(),
                    "#[derive(Searchable)] requires named fields",
                )
                .to_compile_error()
                .into();
            }
        },
        _ => {
            return syn::Error::new(
                Span::call_site(),
                "#[derive(Searchable)] only supports structs",
            )
            .to_compile_error()
            .into();
        }
    };

    let mut field_entries = Vec::new();

    for field in fields {
        let field_name = field.ident.as_ref().unwrap().to_string();
        let mut rank: Option<u8> = None;

        for attr in &field.attrs {
            if attr.path().is_ident("searchable") {
                let _ = attr.parse_nested_meta(|meta| {
                    if meta.path.is_ident("rank") {
                        let value: LitInt = meta.value()?.parse()?;
                        rank = Some(value.base10_parse::<u8>().map_err(|_| {
                            syn::Error::new(Span::call_site(), "rank must be a u8")
                        })?);
                    }
                    Ok(())
                });
            }
        }

        if let Some(r) = rank {
            field_entries.push((field_name, r));
        }
    }

    let searchable_fields_tokens: Vec<_> = field_entries
        .iter()
        .map(|(name, rank)| {
            let rank_val = *rank;
            quote! {
                ::rok_orm::search::SearchField {
                    name: #name.into(),
                    weight: ::rok_orm::search::RankWeight::from_rank(#rank_val),
                }
            }
        })
        .collect();

    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();

    let expanded = quote! {
        impl #impl_generics ::rok_orm::search::Searchable for #ident #ty_generics #where_clause {
            fn index_name() -> &'static str {
                #index_name
            }
            fn searchable_fields() -> ::std::vec::Vec<::rok_orm::search::SearchField> {
                vec![ #(#searchable_fields_tokens),* ]
            }
        }
    };

    TokenStream::from(expanded)
}