fieldmasker_derive 0.0.1

Proc-macro derive for fieldmasker::MaskSpec
Documentation
//! Derive macro for `fieldmasker::MaskSpec`.
//!
//! # Features
//! - Structs with **named fields**: builds an `Object` schema using field names,
//!   honoring Serde field attributes.
//! - **Newtype tuple structs**: delegate to the inner type’s schema.
//! - **Other tuple structs**, **enums**, and **unions**: treated as scalar.
//!
//! # Serde attributes honored
//! - `#[serde(rename = "...")]`
//! - `#[serde(rename(serialize = "..."))]` (preferred when present)
//! - `#[serde(rename_all = "...")]` on the container
//! - `#[serde(skip)]` / `#[serde(skip_serializing)]`
//!
//! # Unsupported (for now)
//! - `#[serde(flatten)]` — emits a compile error. Support can be added once the
//!   core crate exposes object-schema accessors at runtime.
//!

use heck::{ToKebabCase, ToLowerCamelCase, ToPascalCase, ToShoutySnakeCase, ToSnakeCase};
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::quote;
use syn::spanned::Spanned;
use syn::{
    Attribute, Data, DataStruct, DeriveInput, Field, Fields, FieldsNamed, LitStr, Path,
    WhereClause, parse_macro_input, parse_quote,
};

/// Derive `fieldmasker::MaskSpec` for a type.
///
/// See crate-level docs for supported shapes and attributes.
#[proc_macro_derive(MaskSpec, attributes(serde, fieldmasker))]
pub fn derive_mask_spec(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let ident = &input.ident;

    let krate = syn::parse_quote!(::fieldmasker);

    // Carry generics and where-clauses.
    let (impl_generics, ty_generics, mut where_clause) = {
        let (ig, tg, wc) = input.generics.split_for_impl();
        (ig, tg, wc.cloned())
    };

    // Parse container-level serde rename_all.
    let rename_all = container_rename_all(&input.attrs);

    // Expand based on data shape.
    let expanded: TokenStream2 = match &input.data {
        Data::Struct(DataStruct {
            fields: Fields::Named(fields),
            ..
        }) => {
            // Reject unsupported attributes early with helpful diagnostics.
            if let Err(e) = check_named_fields_supported(fields) {
                return e.to_compile_error().into();
            }

            // Add per-field MaskSpec bounds into the where-clause.
            where_clause = add_bounds_for_named_fields(where_clause, fields);

            // Build entries (name, Node) for Node::object([ .. ]).
            let entries: Vec<TokenStream2> = fields
                .named
                .iter()
                .filter(|f| !should_skip(f))
                .map(|f| field_entry(f, rename_all.as_deref(), &krate))
                .collect();

            let build_node = quote! { #krate::spec::Node::object([#(#entries),*]) };

            quote! {
                impl #impl_generics #krate::MaskSpec for #ident #ty_generics #where_clause {
                    fn mask_spec() -> &'static #krate::spec::Node {
                        static __NODE: ::std::sync::OnceLock<#krate::spec::Node> = ::std::sync::OnceLock::new();
                        __NODE.get_or_init(|| { #build_node })
                    }
                }
            }
        }

        // Unit structs -> scalar node
        Data::Struct(DataStruct {
            fields: Fields::Unit,
            ..
        }) => {
            quote! {
                impl #impl_generics #krate::MaskSpec for #ident #ty_generics #where_clause {
                    fn mask_spec() -> &'static #krate::spec::Node {
                        static __NODE: #krate::spec::Node = #krate::spec::Node::scalar();
                        &__NODE
                    }
                }
            }
        }

        // Tuple structs: newtype delegates; others are scalar.
        Data::Struct(DataStruct {
            fields: Fields::Unnamed(tup),
            ..
        }) => {
            if tup.unnamed.len() == 1 {
                let inner = &tup.unnamed[0].ty;
                quote! {
                    impl #impl_generics #krate::MaskSpec for #ident #ty_generics #where_clause {
                        fn mask_spec() -> &'static #krate::spec::Node {
                            <#inner as #krate::MaskSpec>::mask_spec()
                        }
                    }
                }
            } else {
                quote! {
                    impl #impl_generics #krate::MaskSpec for #ident #ty_generics #where_clause {
                        fn mask_spec() -> &'static #krate::spec::Node {
                            static __NODE: #krate::spec::Node = #krate::spec::Node::scalar();
                            &__NODE
                        }
                    }
                }
            }
        }

        // Enums and unions -> scalar node (conservative)
        Data::Enum(_) | Data::Union(_) => {
            quote! {
                impl #impl_generics #krate::MaskSpec for #ident #ty_generics #where_clause {
                    fn mask_spec() -> &'static #krate::spec::Node {
                        static __NODE: #krate::spec::Node = #krate::spec::Node::scalar();
                        &__NODE
                    }
                }
            }
        }
    };

    expanded.into()
}

/// Ensure unsupported attributes on named fields are rejected with a clear error.
fn check_named_fields_supported(fields: &FieldsNamed) -> syn::Result<()> {
    for f in &fields.named {
        if has_flatten(f) {
            return Err(syn::Error::new(
                f.span(),
                "`#[serde(flatten)]` is not supported by `fieldmasker::MaskSpec` derive yet",
            ));
        }
    }
    Ok(())
}

/// Add `T: fieldmasker::MaskSpec` bounds for each named field type.
fn add_bounds_for_named_fields(
    where_clause: Option<WhereClause>,
    fields: &FieldsNamed,
) -> Option<WhereClause> {
    let mut wc = where_clause.unwrap_or_else(|| WhereClause {
        where_token: Default::default(),
        predicates: Default::default(),
    });

    for f in &fields.named {
        if should_skip(f) {
            continue;
        }
        let ty = &f.ty;
        wc.predicates
            .push(parse_quote!(#ty : fieldmasker::MaskSpec));
    }

    Some(wc)
}

/// Build one `(name, Node)` entry for a named field.
fn field_entry(field: &Field, container_rename_all: Option<&str>, krate: &Path) -> TokenStream2 {
    let name_lit = resolve_field_name(field, container_rename_all);
    let ty = &field.ty;
    quote! {
        (#name_lit, <#ty as #krate::MaskSpec>::mask_spec().clone())
    }
}

/// Should this field be skipped for serialization according to Serde?
fn should_skip(field: &Field) -> bool {
    field.attrs.iter().any(|attr| {
        if !is_serde_attr(attr) {
            return false;
        }
        let mut skip = false;
        let _ = attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("skip") || meta.path.is_ident("skip_serializing") {
                skip = true;
            }
            Ok(())
        });
        skip
    })
}

/// Detect `#[serde(flatten)]` on the field.
fn has_flatten(field: &Field) -> bool {
    field.attrs.iter().any(|attr| {
        if !is_serde_attr(attr) {
            return false;
        }
        let mut yes = false;
        let _ = attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("flatten") {
                yes = true;
            }
            Ok(())
        });
        yes
    })
}

/// Extract container-level `rename_all`, if present.
fn container_rename_all(attrs: &[Attribute]) -> Option<String> {
    for attr in attrs {
        if !is_serde_attr(attr) {
            continue;
        }
        let mut out: Option<String> = None;
        let _ = attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("rename_all") {
                let lit: LitStr = meta.value()?.parse()?;
                out = Some(lit.value());
            }
            Ok(())
        });
        if out.is_some() {
            return out;
        }
    }
    None
}

/// True if the attribute is a `serde` attribute.
fn is_serde_attr(attr: &Attribute) -> bool {
    attr.path().is_ident("serde")
}

/// Resolve the *serialization* field name:
/// 1) `#[serde(rename(serialize = "..."))]`
/// 2) `#[serde(rename = "...")]`
/// 3) `rename_all` applied to the Rust identifier
fn resolve_field_name(field: &Field, container_rename_all: Option<&str>) -> LitStr {
    // Prefer explicit serialization rename.
    for attr in &field.attrs {
        if let Some(lit) = serde_serialize_name(attr, field) {
            return lit;
        }
    }

    // Fallback: apply rename_all to the identifier.
    let ident = field.ident.as_ref().expect("named fields only");
    let base = ident.to_string();
    let named = apply_rename_all(&base, container_rename_all);
    LitStr::new(&named, ident.span())
}

/// Parse `#[serde(rename = "...")]` or `#[serde(rename(serialize = "..."))]`.
fn serde_serialize_name(attr: &Attribute, field: &Field) -> Option<LitStr> {
    if !is_serde_attr(attr) {
        return None;
    }
    let mut out: Option<LitStr> = None;

    let _ = attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("rename") {
            // Either `rename = "..."` or nested `rename(serialize = "...", ..)`
            if meta.input.peek(syn::Token![=]) {
                let lit: LitStr = meta.value()?.parse()?;
                out = Some(with_span_from(&lit, field));
            } else {
                let mut ser: Option<LitStr> = None;
                meta.parse_nested_meta(|inner| {
                    if inner.path.is_ident("serialize") {
                        let lit: LitStr = inner.value()?.parse()?;
                        ser = Some(with_span_from(&lit, field));
                    }
                    Ok(())
                })?;
                if let Some(l) = ser {
                    out = Some(l);
                }
            }
        }
        Ok(())
    });

    out
}

/// Apply Serde's `rename_all` variants we support.
fn apply_rename_all(base: &str, ra: Option<&str>) -> String {
    match ra {
        Some("lowercase") => base.to_lowercase(),
        Some("UPPERCASE") => base.to_uppercase(),
        Some("snake_case") => base.to_snake_case(),
        Some("SCREAMING_SNAKE_CASE") => base.to_shouty_snake_case(),
        Some("kebab-case") => base.to_kebab_case(),
        Some("SCREAMING-KEBAB-CASE") => base.to_kebab_case().to_uppercase(),
        Some("camelCase") => base.to_lower_camel_case(),
        Some("PascalCase") => base.to_pascal_case(),
        _ => base.to_owned(),
    }
}

/// Create a `LitStr` with the field's ident span (for nicer diagnostics).
fn with_span_from(lit: &LitStr, field: &Field) -> LitStr {
    let span = field
        .ident
        .as_ref()
        .map(|id| id.span())
        .unwrap_or_else(Span::call_site);
    LitStr::new(&lit.value(), span)
}