dterror-derive 0.2.0

Proc-macros for the dterror CtxError and FromContext traits
Documentation
//! This module expands the struct path. It generates the `{Error}Ctx` struct, its `new`
//! constructor, and the `CtxError` impl.

use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{DataStruct, DeriveInput, Field, Fields, Ident, parse_quote};

use super::derive_attr::{CtxDerives, parse_ctx_derives};
use super::field::{CtxField, CtxTarget, ScopeFields, bind_lifetimes_to_ctx, contains_lifetime};
use super::validate::validate_generics;

/// This function expands the struct path. It classifies the fields and validates the generics.
/// It emits the Ctx struct, its `new` constructor, and the `CtxError` impl.
pub(crate) fn expand_struct_item(
    input: &DeriveInput,
    data: &DataStruct,
    ctx_ident: &Ident,
) -> syn::Result<TokenStream2> {
    let derives = parse_ctx_derives(input)?;

    let Fields::Named(fields) = &data.fields else {
        return Err(syn::Error::new_spanned(
            input,
            "`#[derive(CtxError)]` only supports structs with named fields",
        ));
    };

    let scope = ScopeFields::classify(&fields.named)?;

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

    validate_generics(input, "CtxError", scope.context_fields.iter())?;

    let vis = &input.vis;

    // The code determines whether the generated `{Error}Ctx` needs a `'ctx` lifetime parameter.
    // A borrowed field stores `&'ctx Target`. The macro binds any lifetimes that a from-target
    // mentions (for example `KindCtx<'a>`) to `'ctx`. When neither applies, the macro omits
    // `'ctx` entirely (avoids E0392: unused lifetime on struct definitions).
    let has_ctx_lifetime = scope.context_fields.iter().any(|cf| match &cf.target {
        CtxTarget::Borrow(_) => true,
        CtxTarget::From(target) => contains_lifetime(target),
        CtxTarget::Field => false,
    });

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

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

    let ctx_struct = expand_struct(
        ctx_ident,
        &scope.context_fields,
        vis,
        has_ctx_lifetime,
        &impl_generics,
        where_clause,
        &ctx_impl_generics,
        &derives,
    );

    let new_impl = expand_struct_new(
        ctx_ident,
        &scope.context_fields,
        has_ctx_lifetime,
        &impl_generics,
        &error_type_generics,
        where_clause,
        &ctx_impl_generics,
        &ctx_type_generics,
    );

    let ctx_error_impl = expand_struct_ctx_error(
        &input.ident,
        ctx_ident,
        scope.source_field,
        scope.source_is_option,
        scope.location_field,
        &scope.context_fields,
        has_ctx_lifetime,
        &impl_generics,
        &error_type_generics,
        where_clause,
        &ctx_type_generics,
    );

    Ok(quote! {
        #ctx_struct
        #new_impl
        #ctx_error_impl
    })
}

/// This function generates the `{Error}Ctx<'ctx>` struct. It has one field per context field,
/// in the declaration order. A borrowed field stores `&'ctx TargetType`. The macro binds every
/// lifetime that a from-target mentions to `'ctx`. A plain field stores its own type. The
/// `#[context(derive(...))]` attribute selects which traits the struct derives. By default,
/// the struct derives nothing.
#[allow(clippy::too_many_arguments)]
fn expand_struct(
    ctx_ident: &Ident,
    context_fields: &Vec<CtxField<'_>>,
    vis: &syn::Visibility,
    has_ctx_lifetime: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_impl_generics: &syn::ImplGenerics<'_>,
    derives: &CtxDerives,
) -> TokenStream2 {
    let ctx_fields = context_fields.iter().map(|cf| {
        let fvis = cf.vis();
        let ident = cf.ident();
        match &cf.target {
            CtxTarget::Borrow(target) => quote! { #fvis #ident: &'ctx #target },
            CtxTarget::From(target) => {
                let target = bind_lifetimes_to_ctx(target);
                quote! { #fvis #ident: #target }
            }
            CtxTarget::Field => {
                let ty = cf.ty();
                quote! { #fvis #ident: #ty }
            }
        }
    });

    // If the Ctx needs a 'ctx lifetime parameter, the code uses the injected generic.
    let struct_generics = if has_ctx_lifetime {
        quote! { #ctx_impl_generics }
    } else {
        quote! { #impl_generics }
    };

    let derive_attr = derives.derive_attr();

    quote! {
        #[allow(dead_code)]
        #derive_attr
        #vis struct #ctx_ident #struct_generics #where_clause {
            #(#ctx_fields,)*
        }
    }
}

/// This function generates `pub fn new(...)`. It takes one argument per context field, in the
/// declaration order. Borrowed fields take `&'ctx TargetType` directly so that the macro does not
/// clone or allocate. Owned fields use `impl Into<FieldType>`.
#[allow(clippy::too_many_arguments)]
fn expand_struct_new(
    ctx_ident: &Ident,
    context_fields: &Vec<CtxField<'_>>,
    has_ctx_lifetime: 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 new_args = context_fields.iter().map(|cf| {
            let ident = cf.ident();
            match &cf.target {
                CtxTarget::Borrow(target) => quote! { #ident: &'ctx #target },
                CtxTarget::From(target) => {
                    let target = bind_lifetimes_to_ctx(target);
                    quote! { #ident: impl Into<#target> }
                }
                CtxTarget::Field => {
                    let ty = cf.ty();
                    quote! { #ident: impl Into<#ty> }
                }
            }
        });
        let new_body = context_fields.iter().map(|cf| {
            let ident = cf.ident();
            if let CtxTarget::Borrow(_) = &cf.target {
                quote! { #ident: #ident, }
            } else {
                quote! { #ident: #ident.into(), }
            }
        });

        let impl_generics_for_new = if has_ctx_lifetime {
            quote! { #ctx_impl_generics }
        } else {
            quote! { #impl_generics }
        };
        let type_generics_for_new = if has_ctx_lifetime {
            quote! { #ctx_type_generics }
        } else {
            quote! { #error_type_generics }
        };

        quote! {
            impl #impl_generics_for_new #ctx_ident #type_generics_for_new #where_clause {
                /// The constructor creates the context from one argument per context field, in
                /// the declaration order.
                pub fn new(#(#new_args),*) -> Self {
                    Self {
                        #(#new_body)*
                    }
                }
            }
        }
    }
}

/// This function generates `impl ::dterror::CtxError for {Error}`. It moves the context fields
/// into the owned type. The macro converts borrow and from-marked targets to owned values with
/// `.into()` where needed. The macro assigns location and source (an optional source wrapped
/// in `Some(...)`).
#[allow(clippy::too_many_arguments)]
fn expand_struct_ctx_error(
    error_ident: &Ident,
    ctx_ident: &Ident,
    source_field: Option<&Field>,
    source_is_option: bool,
    location_field: Option<&Field>,
    context_fields: &[CtxField<'_>],
    has_ctx_lifetime: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    error_type_generics: &syn::TypeGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_type_generics: &syn::TypeGenerics<'_>,
) -> TokenStream2 {
    {
        let ctx_field_inits = context_fields.iter().map(|cf| {
            let ident = cf.ident();
            if let CtxTarget::Field = &cf.target {
                quote! { #ident: ctx.#ident, }
            } else {
                quote! { #ident: ctx.#ident.into(), }
            }
        });
        let location_init = location_field.map(|field| {
            let ident = &field.ident;
            quote! { #ident: location, }
        });
        let source_init = source_field.map(|field| {
            let ident = &field.ident;
            if source_is_option {
                quote! { #ident: ::std::option::Option::Some(source), }
            } else {
                quote! { #ident: source, }
            }
        });

        // The generic associated 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_ctx_lifetime {
            quote! { #ctx_ident #ctx_type_generics }
        } else {
            quote! { #ctx_ident #error_type_generics }
        };

        quote! {
            #[automatically_derived]
            impl #impl_generics ::dterror::CtxError 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 {
                    Self {
                        #(#ctx_field_inits)*
                        #location_init
                        #source_init
                    }
                }
            }
        }
    }
}