df-derive-macros 0.3.0

Procedural derive macro implementation for df-derive.
Documentation
use crate::ir::DateTimeUnit;
use proc_macro2::Span;
use syn::spanned::Spanned as SynSpanned;

use super::Spanned;
use super::decimal::parse_decimal_attr;
use super::field_conflicts::{FieldAttr, set_override};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LeafOverride {
    AsStr,
    AsString,
    Decimal { precision: u8, scale: u8 },
    TimeUnit(DateTimeUnit),
}

#[derive(Clone, Debug)]
pub enum FieldDisposition {
    Skip,
    Include(FieldConversion),
}

#[derive(Clone, Debug)]
pub enum FieldConversion {
    Default,
    LeafOverride(Spanned<LeafOverride>),
    Binary { span: Span },
}

fn parse_time_unit_attr(meta: &syn::meta::ParseNestedMeta<'_>) -> Result<DateTimeUnit, syn::Error> {
    let lit: syn::LitStr = meta.value()?.parse()?;
    match lit.value().as_str() {
        "ms" => Ok(DateTimeUnit::Milliseconds),
        "us" => Ok(DateTimeUnit::Microseconds),
        "ns" => Ok(DateTimeUnit::Nanoseconds),
        other => Err(syn::Error::new_spanned(
            &lit,
            format!("invalid `time_unit` value `{other}`; expected one of \"ms\", \"us\", \"ns\""),
        )),
    }
}

pub fn parse_field_disposition(
    field: &syn::Field,
    field_display_name: &str,
) -> Result<FieldDisposition, syn::Error> {
    let mut override_: Option<(FieldAttr, Span)> = None;
    for attr in &field.attrs {
        if attr.path().is_ident("df_derive") {
            attr.parse_nested_meta(|meta| {
                let incoming_span = meta.path.span();
                if meta.path.is_ident("skip") {
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Skip,
                        incoming_span,
                    )
                } else if meta.path.is_ident("as_string") {
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Leaf(LeafOverride::AsString),
                        incoming_span,
                    )
                } else if meta.path.is_ident("as_str") {
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Leaf(LeafOverride::AsStr),
                        incoming_span,
                    )
                } else if meta.path.is_ident("as_binary") {
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Binary,
                        incoming_span,
                    )
                } else if meta.path.is_ident("decimal") {
                    let (precision, scale) = parse_decimal_attr(&meta)?;
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Leaf(LeafOverride::Decimal { precision, scale }),
                        incoming_span,
                    )
                } else if meta.path.is_ident("time_unit") {
                    let unit = parse_time_unit_attr(&meta)?;
                    set_override(
                        field_display_name,
                        &mut override_,
                        FieldAttr::Leaf(LeafOverride::TimeUnit(unit)),
                        incoming_span,
                    )
                } else {
                    Err(meta.error(
                        "unknown key in #[df_derive(...)] field attribute; expected `skip`, `as_str`, `as_string`, `as_binary`, `decimal(precision = N, scale = N)`, or `time_unit = \"ms\"|\"us\"|\"ns\"`",
                    ))
                }
            })?;
        }
    }
    Ok(override_.map_or(
        FieldDisposition::Include(FieldConversion::Default),
        |(value, span)| value.into_disposition(span),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_disposition(field: &syn::Field) -> syn::Result<FieldDisposition> {
        parse_field_disposition(field, "value")
    }

    fn leaf_override_value(field: &syn::Field) -> LeafOverride {
        let disposition = parse_disposition(field).expect("field disposition should parse");
        let FieldDisposition::Include(FieldConversion::LeafOverride(leaf_override)) = disposition
        else {
            panic!("field leaf override should be present");
        };
        leaf_override.value
    }

    #[test]
    fn parses_string_and_decimal_field_overrides() {
        let as_str = leaf_override_value(&syn::parse_quote! {
            #[df_derive(as_str)]
            value: String
        });
        assert!(matches!(as_str, LeafOverride::AsStr));

        let as_string = leaf_override_value(&syn::parse_quote! {
            #[df_derive(as_string)]
            value: DisplayType
        });
        assert!(matches!(as_string, LeafOverride::AsString));

        let decimal = leaf_override_value(&syn::parse_quote! {
            #[df_derive(decimal(precision = 10, scale = 2))]
            value: Decimal
        });
        assert!(matches!(
            decimal,
            LeafOverride::Decimal {
                precision: 10,
                scale: 2,
            }
        ));
    }

    #[test]
    fn rejects_duplicate_decimal_keys_and_bad_time_units() {
        let duplicate_decimal = parse_disposition(&syn::parse_quote! {
            #[df_derive(decimal(precision = 10, precision = 11, scale = 2))]
            value: Decimal
        });
        assert!(duplicate_decimal.is_err());

        let bad_time_unit = parse_disposition(&syn::parse_quote! {
            #[df_derive(time_unit = "bad")]
            value: chrono::NaiveDateTime
        });
        assert!(bad_time_unit.is_err());
    }

    #[test]
    fn rejects_conflicting_string_overrides() {
        let result = parse_disposition(&syn::parse_quote! {
            #[df_derive(as_str, as_string)]
            value: String
        });
        assert!(result.is_err());
    }

    #[test]
    fn conflicting_overrides_report_the_first_override() {
        let error = parse_disposition(&syn::parse_quote! {
            #[df_derive(as_str, as_string)]
            value: String
        })
        .expect_err("conflicting field overrides should fail");
        let rendered = error.into_compile_error().to_string();

        assert!(rendered.contains("has both `as_str` and `as_string`"));
        assert!(rendered.contains("first `as_str` override declared here"));
    }
}