elastic-mapping-macro 0.1.0

Procedural macros for elastic-mapping - generate Elasticsearch mappings from Rust types
Documentation
//! Procedural macros for the `elastic-mapping` crate.
//!
//! This crate provides the `#[derive(Document)]` macro that automatically generates
//! Elasticsearch mapping implementations for Rust structs and enums.
//!
//! You typically don't need to depend on this crate directly. Instead, use the
//! main `elastic-mapping` crate which re-exports the `Document` derive macro.
//!
//! # Example
//!
//! ```rust,ignore
//! use elastic_mapping::Document;
//!
//! #[derive(Document)]
//! struct MyDocument {
//!     title: String,
//!     count: i64,
//! }
//! ```

mod codegen;
mod enums;
mod error;
mod rename;
mod structs;

use darling::{FromDeriveInput, FromField, FromMeta, FromVariant, ast::Data};
use proc_macro::TokenStream;
use syn::{DeriveInput, Generics, Ident, parse_macro_input};

use codegen::gen_mapping_impl;
use enums::{classify_enum, gen_enum_impl};
use error::GeneratorError;
use structs::gen_struct;

use crate::rename::{RenameCasing, apply_rename_casing};

/// Elasticsearch mapping field type constants
const TYPE_FIELD: &str = "type";
const PROPERTIES_FIELD: &str = "properties";
const FIELDS_FIELD: &str = "fields";
const ANALYZER_FIELD: &str = "analyzer";
const INDEX_FIELD: &str = "index";
const IGNORE_ABOVE_FIELD: &str = "ignore_above";

/// Elasticsearch data type constants
const OBJECT_TYPE: &str = "object";
const KEYWORD_TYPE: &str = "keyword";

/// Derives Elasticsearch mapping generation for structs and enums.
///
/// This macro implements the `MappingType` and `MappingObject` traits for your type,
/// allowing you to call `document_mapping()` to generate Elasticsearch mappings.
///
/// # Supported Attributes
///
/// ## Container-level (Struct/Enum) Attributes
///
/// - `#[serde(rename_all = "...")]` - Rename all fields/variants according to the given case convention
///   - Supported cases: `camelCase`, `PascalCase`, `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case`, etc.
/// - `#[serde(tag = "...")]` - Use internally tagged enum representation
/// - `#[serde(tag = "...", content = "...")]` - Use adjacently tagged enum representation
///
/// ## Field-level Attributes
///
/// - `#[document(analyzer = "analyzer_name")]` - Sets the analyzer for a text field
/// - `#[document(keyword(index = bool))]` - Adds a keyword subfield with index configuration
/// - `#[document(keyword(ignore_above = u32))]` - Adds a keyword subfield with ignore_above setting
/// - `#[serde(rename = "name")]` - Rename this field/variant
/// - `#[serde(flatten)]` - Flatten nested structure fields into the parent
///
/// # Examples
///
/// ## Basic Struct
///
/// ```ignore
/// use elastic_mapping::Document;
///
/// #[derive(Document)]
/// struct Product {
///     name: String,
///     price: f64,
///     in_stock: bool,
/// }
/// ```
///
/// ## With Field Annotations
///
/// ```ignore
/// use elastic_mapping::Document;
///
/// #[derive(Document)]
/// struct Article {
///     #[document(analyzer = "english")]
///     title: String,
///     
///     #[document(keyword(index = false))]
///     internal_id: String,
/// }
/// ```
///
/// ## With Serde Attributes
///
/// ```ignore
/// use elastic_mapping::Document;
/// use serde::Serialize;
///
/// #[derive(Serialize, Document)]
/// #[serde(rename_all = "camelCase")]
/// struct UserProfile {
///     first_name: String,
///     last_name: String,
/// }
/// ```
///
/// ## Enum Support
///
/// ```ignore
/// use elastic_mapping::Document;
/// use serde::Serialize;
///
/// // Adjacently tagged enum
/// #[derive(Serialize, Document)]
/// #[serde(tag = "type", content = "data")]
/// enum Event {
///     Created { id: String, timestamp: i64 },
///     Updated { id: String, changes: String },
///     Deleted { id: String },
/// }
/// ```
#[proc_macro_derive(Document, attributes(document))]
pub fn derive_response(input: TokenStream) -> TokenStream {
    let args = parse_macro_input!(input as DeriveInput);

    match generate(args) {
        Ok(stream) => stream,
        Err(err) => err.write_errors().into(),
    }
}

fn generate(input: DeriveInput) -> Result<TokenStream, GeneratorError> {
    let args = DocumentArgs::from_derive_input(&input)?;

    let ident = &args.ident;
    let generics = &args.generics;

    let output = match &args.data {
        Data::Enum(variants) => {
            let representation = classify_enum(&args, variants)?;
            gen_enum_impl(ident, generics, representation)?
        }
        Data::Struct(fields) => {
            let (properties, iterable) = gen_struct(fields.fields.as_slice(), args.rename_all)?;
            gen_mapping_impl(ident, generics, "object", properties, iterable)
        }
    };

    Ok(output.into())
}

/// Parsed arguments from the Document derive macro
#[derive(Debug, FromDeriveInput)]
#[darling(attributes(document, serde), allow_unknown_fields)]
struct DocumentArgs {
    pub(crate) ident: Ident,
    pub(crate) generics: Generics,
    pub(crate) data: Data<DocumentVariant, DocumentField>,
    /// Serde - Rename all the fields (if this is a struct) or variants (if this is an enum) according to the given case convention.
    pub(crate) rename_all: Option<RenameCasing>,
    /// Serde - On an enum: Use the internally tagged enum representation, with the given tag.
    pub(crate) tag: Option<String>,
    /// Serde - On an enum: Use the adjacently tagged enum representation, with the given content field.
    pub(crate) content: Option<String>,
}

/// Parsed variant from an enum
#[derive(Debug, FromVariant)]
#[darling(attributes(document, serde), allow_unknown_fields)]
struct DocumentVariant {
    pub(crate) ident: Ident,
    pub(crate) fields: darling::ast::Fields<DocumentField>,
    /// Serde - Serialize and deserialize this variant with the given name instead of its Rust name.
    pub(crate) rename: Option<String>,
}

impl DocumentVariant {
    /// Resolves the final variant name, accounting for rename and rename_all
    pub(crate) fn resolve_name(&self, rename_all: Option<RenameCasing>) -> String {
        self.rename.clone().unwrap_or_else(|| {
            let ident_str = self.ident.to_string();
            apply_rename_casing(&ident_str, rename_all)
        })
    }
}

/// Parsed field from a struct or enum variant
#[derive(Debug, FromField)]
#[darling(attributes(document, serde), allow_unknown_fields)]
struct DocumentField {
    pub(crate) ident: Option<Ident>,
    pub(crate) ty: syn::Type,
    /// Sets an analyzer to be used in the mapping
    pub(crate) analyzer: Option<String>,
    /// Keyword field configuration
    pub(crate) keyword: Option<DocumentFieldKeyword>,
    /// Serde - Flatten the contents of this field into the container it is defined in.
    #[darling(default)]
    pub(crate) flatten: bool,
    /// Serde - Serialize and deserialize this field with the given name instead of its Rust name.
    pub(crate) rename: Option<String>,
}

/// Configuration for keyword field mapping
#[derive(Debug, FromMeta)]
struct DocumentFieldKeyword {
    pub(crate) index: Option<bool>,
    pub(crate) ignore_above: Option<u32>,
}

/// Represents a property in the generated mapping
struct MappingProperty {
    pub(crate) name: String,
    pub(crate) mapping: proc_macro2::TokenStream,
}

impl DocumentField {
    /// Resolves the final field name, accounting for rename and rename_all
    pub(crate) fn resolve_name(&self, rename_all: Option<RenameCasing>) -> String {
        self.rename.clone().unwrap_or_else(|| {
            let ident_str = self
                .ident
                .as_ref()
                .map(|i| i.to_string())
                .unwrap_or_default();

            apply_rename_casing(&ident_str, rename_all)
        })
    }

    /// Generates the mapping for this field
    pub(crate) fn gen_mapping(&self, rename_all: Option<RenameCasing>) -> MappingProperty {
        let ty = &self.ty;
        let name = self.resolve_name(rename_all);

        let analyzer = self.analyzer.as_ref().map(|analyzer| {
            quote::quote! {
                m.insert(#ANALYZER_FIELD.into(), #analyzer.into());
            }
        });

        let keyword = self.keyword.as_ref().map(|keyword| {
            let ignore_above = keyword.ignore_above.map(|ignore_above| {
                quote::quote! {
                    field_m.insert(#IGNORE_ABOVE_FIELD.into(), #ignore_above.into());
                }
            });

            let index = keyword.index.map(|index| {
                quote::quote! {
                    field_m.insert(#INDEX_FIELD.into(), #index.into());
                }
            });

            quote::quote! {
                m.insert(#FIELDS_FIELD.into(), {
                    let mut km = elastic_mapping::serde_json::Map::default();
                    km.insert(#KEYWORD_TYPE.into(), {
                        let mut field_m = elastic_mapping::serde_json::Map::default();
                        field_m.insert(#TYPE_FIELD.into(), #KEYWORD_TYPE.into());
                        #ignore_above
                        #index
                        field_m.into()
                    });
                    km.into()
                });
            }
        });

        let mapping = quote::quote! {
            {
                let mut m = <#ty as elastic_mapping::MappingType>::mapping();
                #analyzer
                #keyword
                m.into()
            }
        };

        MappingProperty { name, mapping }
    }
}