dterror-derive 0.1.0

Derive macro for the dterror FromContext trait
Documentation
//! Expansion of the enum path: the `{Error}Ctx` enum, its per-variant constructors, and the
//! `FromContext` impl.

use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use std::collections::HashMap;
use syn::{Attribute, DataEnum, DeriveInput, Fields, Ident, LitStr, Variant, parse_quote};

use super::field::ScopeFields;
use super::naming::to_snek_case;
use super::validate::validate_generics;

/// A constructible enum variant: the original variant, its classified fields, and the resolved
/// constructor name (default `snake_case`, or a `#[context(constructor = "...")]` override).
struct CtxVariant<'a> {
    /// The original syn variant definition.
    variant: &'a Variant,
    /// The classified fields of this variant.
    scope: ScopeFields<'a>,
    /// The name of the generated constructor on `{Error}Ctx`.
    ctor_name: Ident,
}

/// Expand the enum path: classify every variant, validate names and generics, and emit the Ctx
/// enum, its per-variant constructors, and the `FromContext` impl.
pub(crate) fn expand_enum_item(
    input: &DeriveInput,
    data: &DataEnum,
    ctx_ident: &Ident,
) -> syn::Result<TokenStream2> {
    let mut ctx_variants: Vec<CtxVariant> = Vec::new();

    for variant in &data.variants {
        match &variant.fields {
            // Unit variants are valid but never constructible: no Ctx variant, no constructor.
            Fields::Unit => {}
            Fields::Unnamed(_) => {
                return Err(syn::Error::new_spanned(
                    variant,
                    "`#[derive(FromContext)]` only supports variants with named fields",
                ));
            }
            Fields::Named(fields) => {
                let scope = ScopeFields::classify(&fields.named)?;
                // A variant is constructible iff it has a context field AND a source: without
                // a source there is no error to attach context to. Unit variants, marker-only
                // variants, and source-less variants are all excluded: no Ctx variant, no
                // constructor.
                if scope.context_fields.is_empty() || scope.source_field.is_none() {
                    continue;
                }
                let ctor_name = parse_constructor_override(variant)?
                    .unwrap_or_else(|| to_snek_case(&variant.ident));
                ctx_variants.push(CtxVariant {
                    variant,
                    scope,
                    ctor_name,
                });
            }
        }
    }

    if ctx_variants.is_empty() {
        return Err(syn::Error::new_spanned(
            input,
            "`#[derive(FromContext)]` requires at least one variant with a context field (a field not marked `#[source]`/`#[from]` or `#[location]`) and a `#[source]`/`#[from]` field",
        ));
    }

    check_unique_constructor_names(&ctx_variants)?;

    // The Ctx enum declares the item's full type-parameter list, so validate across all variants.
    let context_fields = ctx_variants
        .iter()
        .flat_map(|cv| cv.scope.context_fields.iter());
    validate_generics(input, context_fields)?;

    let vis = &input.vis;

    // `'ctx` is global to the Ctx enum: any borrowed field in any variant requires it.
    let has_borrowed = ctx_variants
        .iter()
        .flat_map(|cv| cv.scope.context_fields.iter())
        .any(|cf| cf.borrow_target.is_some());

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

    // Build a second Generics set that inserts `'ctx` as the first lifetime parameter.
    let mut ctx_generics_lifetime = input.generics.clone();
    if has_borrowed {
        ctx_generics_lifetime.params.insert(0, parse_quote!('ctx));
    }
    let (ctx_impl_generics, ctx_type_generics, _) = ctx_generics_lifetime.split_for_impl();

    let ctx_enum = expand_enum(
        ctx_ident,
        &ctx_variants,
        vis,
        has_borrowed,
        &impl_generics,
        where_clause,
        &ctx_impl_generics,
    );

    let constructors = expand_enum_constructors(
        ctx_ident,
        &ctx_variants,
        has_borrowed,
        &impl_generics,
        &error_type_generics,
        where_clause,
        &ctx_impl_generics,
        &ctx_type_generics,
    );

    let from_context_impl = expand_enum_from_context(
        &input.ident,
        ctx_ident,
        &ctx_variants,
        has_borrowed,
        &impl_generics,
        &error_type_generics,
        where_clause,
        &ctx_type_generics,
    );

    Ok(quote! {
        #ctx_enum
        #constructors
        #from_context_impl
    })
}

/// Parse the optional variant-level `#[context(constructor = "name")]` attribute.
///
/// At most one `#[context(...)]` per variant; inside it only the `constructor` helper is
/// accepted, with a quoted string value that must parse as an identifier.
fn parse_constructor_override(variant: &Variant) -> syn::Result<Option<Ident>> {
    let context_attrs: Vec<&Attribute> = variant
        .attrs
        .iter()
        .filter(|attr| attr.path().is_ident("context"))
        .collect();

    if let Some(duplicate) = context_attrs.get(1) {
        return Err(syn::Error::new_spanned(
            duplicate,
            "a variant may carry at most one `#[context(...)]` attribute",
        ));
    }

    let mut ctor_name: Option<Ident> = None;

    for attr in &context_attrs {
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("constructor") {
                if ctor_name.is_some() {
                    return Err(
                        meta.error("duplicate `#[context(constructor = ...)]` on the same variant")
                    );
                }
                let Ok(value) = meta.value() else {
                    return Err(meta.error(
                        "`#[context(constructor)]` requires a quoted string name, e.g. \
                             `#[context(constructor = \"io\")]`",
                    ));
                };
                let lit: LitStr = match value.parse() {
                    Ok(lit) => lit,
                    Err(_) => {
                        return Err(meta.error(
                            "`#[context(constructor)]` requires a quoted string name, e.g. \
                             `#[context(constructor = \"io\")]`",
                        ));
                    }
                };
                ctor_name = Some(match lit.parse::<Ident>() {
                    Ok(ident) => ident,
                    Err(_) => {
                        return Err(meta.error(
                            "constructor name must be a valid identifier, e.g. \
                             `#[context(constructor = \"io\")]`",
                        ));
                    }
                });
            } else {
                return Err(meta.error(
                    "unknown `#[context(...)]` helper on a variant; expected `constructor`",
                ));
            }
            Ok(())
        })?;
    }

    Ok(ctor_name)
}

/// Reject two variants that would generate constructors with the same name.
fn check_unique_constructor_names(ctx_variants: &[CtxVariant<'_>]) -> syn::Result<()> {
    let mut seen: HashMap<Ident, &Variant> = HashMap::new();
    for cv in ctx_variants {
        if let Some(first) = seen.get(&cv.ctor_name) {
            return Err(syn::Error::new_spanned(
                &cv.variant.ident,
                format!(
                    "duplicate constructor name `{}`; variants `{}` and `{}` both generate it",
                    cv.ctor_name, first.ident, cv.variant.ident
                ),
            ));
        }
        seen.insert(cv.ctor_name.clone(), cv.variant);
    }
    Ok(())
}

/// The `{Error}Ctx<'ctx>` enum: one same-named variant per constructible variant, holding that
/// variant's context fields in declaration order.
/// Borrowed fields store `&'ctx TargetType`.
/// Owned fields store the type as-is.
fn expand_enum(
    ctx_ident: &Ident,
    ctx_variants: &[CtxVariant<'_>],
    vis: &syn::Visibility,
    has_borrowed: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_impl_generics: &syn::ImplGenerics<'_>,
) -> TokenStream2 {
    let variants = ctx_variants.iter().map(|cv| {
        let vident = &cv.variant.ident;
        let ctx_fields = cv.scope.context_fields.iter().map(|cf| {
            let fvis = cf.vis();
            let ident = cf.ident();
            if let Some(target) = &cf.borrow_target {
                quote! { #fvis #ident: &'ctx #target }
            } else {
                let ty = cf.ty();
                quote! { #fvis #ident: #ty }
            }
        });
        quote! { #vident { #(#ctx_fields,)* } }
    });

    // If any field is marked as borrowed, we use the injected 'ctx generic.
    let enum_generics = if has_borrowed {
        quote! { #ctx_impl_generics }
    } else {
        quote! { #impl_generics }
    };

    quote! {
        #[derive(Clone, Debug, PartialEq)]
        #vis enum #ctx_ident #enum_generics #where_clause {
            #(#variants,)*
        }
    }
}

/// One `pub fn` per constructible variant on `{Error}Ctx`, in a single impl block. Borrowed
/// fields take `&'ctx TargetType` directly to avoid cloning / allocating; owned fields use
/// `impl Into<FieldType>`.
#[allow(clippy::too_many_arguments)]
fn expand_enum_constructors(
    ctx_ident: &Ident,
    ctx_variants: &[CtxVariant<'_>],
    has_borrowed: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    error_type_generics: &syn::TypeGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_impl_generics: &syn::ImplGenerics<'_>,
    ctx_type_generics: &syn::TypeGenerics<'_>,
) -> TokenStream2 {
    let fns = ctx_variants.iter().map(|cv| {
        let vident = &cv.variant.ident;
        let name = &cv.ctor_name;
        let args = cv.scope.context_fields.iter().map(|cf| {
            let ident = cf.ident();
            if let Some(target) = &cf.borrow_target {
                quote! { #ident: &'ctx #target }
            } else {
                let ty = cf.ty();
                quote! { #ident: impl Into<#ty> }
            }
        });
        let body = cv.scope.context_fields.iter().map(|cf| {
            let ident = cf.ident();
            if cf.borrow_target.is_some() {
                // Borrowed fields are stored as-is, zero modification, zero allocation.
                quote! { #ident: #ident, }
            } else {
                // `From<T> for T` takes ownership, doesn't act like Clone.
                quote! { #ident: #ident.into(), }
            }
        });
        let doc = format!("Construct the `{vident}` context variant from one argument per context field, in declaration order.");
        quote! {
            #[doc = #doc]
            pub fn #name(#(#args),*) -> Self {
                Self::#vident {
                    #(#body)*
                }
            }
        }
    });

    let impl_generics_for_ctor = if has_borrowed {
        quote! { #ctx_impl_generics }
    } else {
        quote! { #impl_generics }
    };
    let type_generics_for_ctor = if has_borrowed {
        quote! { #ctx_type_generics }
    } else {
        quote! { #error_type_generics }
    };

    quote! {
        impl #impl_generics_for_ctor #ctx_ident #type_generics_for_ctor #where_clause {
            #(#fns)*
        }
    }
}

/// `impl ::dterror::FromContext for {Error}`: matches over the Ctx value to build the
/// corresponding error variant, converting borrowed references back to owned via `.into()` and
/// assigning each variant's `location`/`source` fields.
#[allow(clippy::too_many_arguments)]
fn expand_enum_from_context(
    error_ident: &Ident,
    ctx_ident: &Ident,
    ctx_variants: &[CtxVariant<'_>],
    has_borrowed: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    error_type_generics: &syn::TypeGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_type_generics: &syn::TypeGenerics<'_>,
) -> TokenStream2 {
    let arms = ctx_variants.iter().map(|cv| {
        let vident = &cv.variant.ident;
        let bindings = cv
            .scope
            .context_fields
            .iter()
            .map(crate::field::CtxField::ident);
        let inits = cv.scope.context_fields.iter().map(|cf| {
            let ident = cf.ident();
            if cf.borrow_target.is_some() {
                quote! { #ident: #ident.into(), }
            } else {
                quote! { #ident: #ident, }
            }
        });
        let location_init = cv.scope.location_field.map(|field| {
            let ident = &field.ident;
            quote! { #ident: location, }
        });
        let source_init = cv.scope.source_field.map(|field| {
            let ident = &field.ident;
            quote! { #ident: source, }
        });
        quote! {
            #ctx_ident::#vident { #(#bindings),* } => #error_ident::#vident {
                #(#inits)*
                #location_init
                #source_init
            },
        }
    });

    // The GAT type alias references ctx_ident with 'ctx (if borrowed) or without.
    // When no fields are borrowed, `type Ctx<'ctx> = {Error}Ctx<T>;` is still valid.
    let gat_type_alias = if has_borrowed {
        quote! { #ctx_ident #ctx_type_generics }
    } else {
        quote! { #ctx_ident #error_type_generics }
    };

    quote! {
        impl #impl_generics ::dterror::FromContext for #error_ident #error_type_generics #where_clause {
            type Ctx<'ctx> = #gat_type_alias;

            fn from_context(
                ctx: Self::Ctx<'_>,
                location: &'static std::panic::Location<'static>,
                source: Box<dyn std::error::Error + Send + Sync + 'static>,
            ) -> Self {
                match ctx {
                    #(#arms)*
                }
            }
        }
    }
}