dterror-derive 0.1.0

Derive macro for the dterror FromContext trait
Documentation
//! Expansion of the struct path: the `{Error}Ctx` struct, its `new` constructor, and the
//! `FromContext` impl.

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

use super::field::{CtxField, ScopeFields};
use super::validate::validate_generics;

/// Expand the struct path: classify the fields, validate the generics, and emit the Ctx
/// struct, its `new` constructor, and the `FromContext` impl.
pub(crate) fn expand_struct_item(
    input: &DeriveInput,
    data: &DataStruct,
    ctx_ident: &Ident,
) -> syn::Result<TokenStream2> {
    let Fields::Named(fields) = &data.fields else {
        return Err(syn::Error::new_spanned(
            input,
            "`#[derive(FromContext)]` 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(FromContext)]` requires at least one context field (a field not marked `#[source]`/`#[from]` or `#[location]`)",
        ));
    }

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

    let vis = &input.vis;

    // Determine whether any context field uses #[context(borrow)]. When at least one does, the
    // generated `{Error}Ctx` needs a `'ctx` lifetime parameter so it can hold borrowed references.
    // When none do, we omit `'ctx` entirely (avoids E0392: unused lifetime on struct definitions).
    let has_borrowed = 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_struct = expand_struct(
        ctx_ident,
        &scope.context_fields,
        vis,
        has_borrowed,
        &impl_generics,
        where_clause,
        &ctx_impl_generics,
    );

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

    let from_context_impl = expand_struct_from_context(
        &input.ident,
        ctx_ident,
        scope.source_field,
        scope.location_field,
        &scope.context_fields,
        has_borrowed,
        &impl_generics,
        &error_type_generics,
        where_clause,
        &ctx_type_generics,
    );

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

/// The `{Error}Ctx<'ctx>` struct: one field per context field, in declaration order.
/// Borrowed fields store `&'ctx TargetType`.
/// Owned fields store the type as-is.
fn expand_struct(
    ctx_ident: &Ident,
    context_fields: &Vec<CtxField<'_>>,
    vis: &syn::Visibility,
    has_borrowed: bool,
    impl_generics: &syn::ImplGenerics<'_>,
    where_clause: Option<&syn::WhereClause>,
    ctx_impl_generics: &syn::ImplGenerics<'_>,
) -> TokenStream2 {
    let ctx_fields = 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 }
        }
    });

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

    quote! {
        #[derive(Clone, Debug, PartialEq)]
        #vis struct #ctx_ident #struct_generics #where_clause {
            #(#ctx_fields,)*
        }
    }
}

/// `pub fn new(...)`: one argument per context field in declaration order.
/// Borrowed fields take `&'ctx TargetType` directly to avoid cloning / allocating.
/// Owned fields use `impl Into<FieldType>`.
#[allow(clippy::too_many_arguments)]
fn expand_struct_new(
    ctx_ident: &Ident,
    context_fields: &Vec<CtxField<'_>>,
    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 new_args = 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 new_body = 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 impl_generics_for_new = if has_borrowed {
            quote! { #ctx_impl_generics }
        } else {
            quote! { #impl_generics }
        };
        let type_generics_for_new = if has_borrowed {
            quote! { #ctx_type_generics }
        } else {
            quote! { #error_type_generics }
        };

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

/// `impl ::dterror::FromContext for {Error}`: moves the context fields into the owned type,
/// converting borrowed references back to owned via `.into()`, and assigns location/source.
#[allow(clippy::too_many_arguments)]
fn expand_struct_from_context(
    error_ident: &Ident,
    ctx_ident: &Ident,
    source_field: Option<&Field>,
    location_field: Option<&Field>,
    context_fields: &[CtxField<'_>],
    has_borrowed: 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 cf.borrow_target.is_some() {
                quote! { #ident: ctx.#ident.into(), }
            } else {
                quote! { #ident: ctx.#ident, }
            }
        });
        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;
            quote! { #ident: source, }
        });

        // 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 {
                    Self {
                        #(#ctx_field_inits)*
                        #location_init
                        #source_init
                    }
                }
            }
        }
    }
}