dterror-derive 0.2.0

Proc-macros for the dterror CtxError and FromContext traits
Documentation
//! This module classifies field-level markers and parses the `#[context(...)]` attribute.
//! The struct and enum expansion paths both use this code.

use syn::visit::Visit;
use syn::visit_mut::VisitMut;
use syn::{Attribute, Field, Ident, Lifetime, Type, parse_quote};

/// This enum defines the role of a field when the macro constructs the error from the context.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldRole {
    /// The macro assigns this field the `source` argument of `from_context`.
    Source,
    /// The macro assigns this field the `location` argument of `from_context`.
    Location,
    /// This field is in the generated `{Error}Ctx` struct.
    Context,
}

impl FieldRole {
    pub(crate) fn classify(field: &Field) -> syn::Result<FieldRole> {
        let is_source = field
            .attrs
            .iter()
            .any(|attr| attr.path().is_ident("source") || attr.path().is_ident("from"));
        let is_location = field
            .attrs
            .iter()
            .any(|attr| attr.path().is_ident("location"));

        match (is_source, is_location) {
            (true, true) => Err(syn::Error::new_spanned(
                field,
                "a field cannot carry both a `#[source]`/`#[from]` marker and a `#[location]` marker",
            )),
            (true, false) => Ok(FieldRole::Source),
            (false, true) => Ok(FieldRole::Location),
            (false, false) => Ok(FieldRole::Context),
        }
    }
}

/// This enum defines how the generated `{Error}Ctx` stores a context field. It also defines
/// when the code converts the stored value to the error's own type with `.into()`.
pub(crate) enum CtxTarget {
    /// The macro stores the field's declared type without change.
    /// `new(...)` takes `impl Into<FieldType>` and applies `.into()` inside `new`.
    /// This is the default storage target. The `.into()` call might allocate.
    Field,
    /// The macro stores a borrowed reference (`&'ctx TargetType`) instead of an owned value.
    /// The generated type requires a `'ctx` lifetime parameter.
    /// The code converts the stored reference to an owned value with `.into()` inside `from_context`.
    /// This target has no cost on the Ok path.
    Borrow(Type),
    /// The macro stores an owned value of a different type than the field's own type.
    /// For example, the target can be a surrogate type that uses less memory.
    /// `new(...)` takes `impl Into<TargetType>` and `from_context` converts the stored value
    /// to the field's owned type with `.into()`.
    /// Unlike [`CtxTarget::Borrow`], this variant does not add a `&'ctx` reference on the
    /// generated type. If the target mentions lifetimes (for example, `KindCtx<'a>`), the
    /// macro binds them to `'ctx`. This requires a `'ctx` parameter on the generated type.
    From(Type),
}

/// This struct holds a context field and its storage target. The code parses the storage
/// target from an optional `#[context(...)]` attribute.
pub(crate) struct CtxField<'a> {
    /// This field stores the original syn field definition.
    field: &'a Field,
    /// This field shows how the generated `{Error}Ctx` stores it. See [`CtxTarget`].
    pub(crate) target: CtxTarget,
}

impl<'a> CtxField<'a> {
    pub(crate) fn parse(field: &'a Field) -> syn::Result<Self> {
        let context_attrs: Vec<&Attribute> = field
            .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 field may carry at most one `#[context(...)]` attribute",
            ));
        }

        // At most one helper (`borrow` or `from`) may appear. The value stays `None` until
        // the code sees one. The code rejects a second appearance below with a specific message.
        let mut target: Option<CtxTarget> = None;

        for attr in &context_attrs {
            attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("borrow") || meta.path.is_ident("from") {
                    match (target.as_ref(), meta.path.is_ident("borrow")) {
                        // This case handles a second helper of the same kind inside one attribute.
                        (Some(CtxTarget::Borrow(_)), true) => {
                            Err(meta
                                .error("duplicate `#[context(borrow = ...)]` on the same field"))
                        }
                        (Some(CtxTarget::From(_)), false) => {
                            Err(meta.error("duplicate `#[context(from = ...)]` on the same field"))
                        }
                        // The derive treats the two kinds as mutually exclusive within a single attribute.
                        (Some(_), _) => Err(meta.error(
                            "`#[context(borrow = ...)]` and `#[context(from = ...)]` cannot be \
                             combined on the same field",
                        )),
                        // This is the first helper. The code requires an explicit target type and stores it.
                        (None, is_borrow) => {
                            let value_stream = meta.value().map_err(|_| {
                                meta.error(if is_borrow {
                                    "`#[context(borrow)]` requires an explicit target type, e.g. \
                                     `#[context(borrow = Path)]`"
                                } else {
                                    "`#[context(from)]` requires an explicit target type, e.g. \
                                     `#[context(from = String)]`"
                                })
                            })?;
                            let ty: Type = value_stream.parse()?;
                            target = Some(if is_borrow {
                                CtxTarget::Borrow(ty)
                            } else {
                                CtxTarget::From(ty)
                            });
                            Ok(())
                        }
                    }
                } else if meta.path.is_ident("option") {
                    Err(meta.error(
                        "`#[context(option)]` is only valid on the `#[source]`/`#[from]` field",
                    ))
                } else {
                    Err(meta.error("unknown `#[context(...)]` helper; expected `borrow` or `from`"))
                }
            })?;
        }

        let target = target.unwrap_or(CtxTarget::Field);

        Ok(Self { field, target })
    }

    pub(crate) fn ident(&self) -> Option<&Ident> {
        self.field.ident.as_ref()
    }

    pub(crate) fn ty(&self) -> &Type {
        &self.field.ty
    }

    pub(crate) fn vis(&self) -> &syn::Visibility {
        &self.field.vis
    }
}

/// This struct holds the classified fields of one scope. A scope is a struct or an enum variant.
pub(crate) struct ScopeFields<'a> {
    /// This option stores the field that has the `#[source]` or `#[from]` marker, if one exists.
    pub(crate) source_field: Option<&'a Field>,
    /// This value shows whether `from_context` wraps the captured source in `Some(...)` before
    /// it assigns the source to `source_field`. The value is true when the field carries
    /// `#[context(option)]` or when its type is `Option<T>`.
    pub(crate) source_is_option: bool,
    /// This option stores the field that has the `#[location]` marker, if one exists.
    pub(crate) location_field: Option<&'a Field>,
    /// This vector holds the context fields in the declaration order.
    pub(crate) context_fields: Vec<CtxField<'a>>,
}

impl<'a> ScopeFields<'a> {
    /// This function classifies every named field of the scope. It enforces the per-scope
    /// marker rules.
    pub(crate) fn classify(fields: impl IntoIterator<Item = &'a Field>) -> syn::Result<Self> {
        let mut source_field: Option<&Field> = None;
        let mut source_is_option = false;
        let mut location_field: Option<&Field> = None;
        let mut context_fields: Vec<CtxField> = Vec::new();

        for field in fields {
            match FieldRole::classify(field)? {
                FieldRole::Source => {
                    if source_field.is_some() {
                        return Err(syn::Error::new_spanned(
                            field,
                            "at most one field may be marked `#[source]`/`#[from]`",
                        ));
                    }
                    let explicit_option = parse_source_option(field)?;
                    let is_option_ty = is_option_type(&field.ty);
                    if explicit_option && !is_option_ty {
                        return Err(syn::Error::new_spanned(
                            field,
                            "`#[context(option)]` requires the `#[source]` field's type to be `Option<T>`",
                        ));
                    }
                    source_field = Some(field);
                    source_is_option = explicit_option || is_option_ty;
                }
                FieldRole::Location => {
                    if location_field.is_some() {
                        return Err(syn::Error::new_spanned(
                            field,
                            "at most one field may be marked `#[location]`",
                        ));
                    }
                    // A `#[location]` field accepts no `#[context(...)]` helper.
                    // For `option`, the code uses the standard helper-placement error instead.
                    // The code rejects `borrow` and `from` with a location-specific message.
                    // A location field is never a context field, so the code must reject these
                    // helpers explicitly.
                    for attr in field
                        .attrs
                        .iter()
                        .filter(|attr| attr.path().is_ident("context"))
                    {
                        attr.parse_nested_meta(|meta| {
                            if meta.path.is_ident("option") {
                                Err(meta.error(
                                    "`#[context(option)]` is only valid on the `#[source]`/`#[from]` field",
                                ))
                            } else {
                                Err(meta.error(
                                    "`#[context(...)]` cannot be applied to a `#[location]` field",
                                ))
                            }
                        })?;
                    }
                    location_field = Some(field);
                }
                FieldRole::Context => context_fields.push(CtxField::parse(field)?),
            }
        }

        Ok(Self {
            source_field,
            source_is_option,
            location_field,
            context_fields,
        })
    }
}

/// This function parses `#[context(option)]` on the `#[source]` or `#[from]` field.
/// `option` is the only `#[context(...)]` helper that a source field accepts.
fn parse_source_option(field: &Field) -> syn::Result<bool> {
    let context_attrs: Vec<&Attribute> = field
        .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 field may carry at most one `#[context(...)]` attribute",
        ));
    }

    let mut option = false;
    for attr in &context_attrs {
        attr.parse_nested_meta(|meta| {
            if !meta.path.is_ident("option") {
                return Err(meta.error(
                    "`#[context(...)]` on a `#[source]`/`#[from]` field only supports the `option` helper",
                ));
            }
            if option {
                return Err(meta.error("duplicate `#[context(option)]` on the same field"));
            }
            option = true;
            Ok(())
        })?;
    }

    Ok(option)
}

/// This function returns whether `ty` is an `Option<...>` type whose path has `Option` as its
/// last segment.
pub(crate) fn is_option_type(ty: &Type) -> bool {
    let Type::Path(type_path) = ty else {
        return false;
    };
    type_path
        .path
        .segments
        .last()
        .is_some_and(|segment| segment.ident == "Option")
}

/// This function returns whether `ty` mentions a lifetime (for example, `KindCtx<'a>` or
/// `&'a str`).
pub(crate) fn contains_lifetime(ty: &Type) -> bool {
    let mut checker = LifetimeChecker { found: false };
    checker.visit_type(ty);
    checker.found
}

struct LifetimeChecker {
    found: bool,
}

impl Visit<'_> for LifetimeChecker {
    fn visit_lifetime(&mut self, _lifetime: &Lifetime) {
        self.found = true;
    }
}

/// This function rewrites every lifetime in `ty` to `'ctx`. The derive injects `'ctx` as a
/// lifetime parameter on `{Error}Ctx` when a from-target mentions lifetimes.
pub(crate) fn bind_lifetimes_to_ctx(ty: &Type) -> Type {
    let mut bound = ty.clone();
    BindLifetimesToCtx.visit_type_mut(&mut bound);
    bound
}

struct BindLifetimesToCtx;

impl VisitMut for BindLifetimesToCtx {
    fn visit_lifetime_mut(&mut self, lifetime: &mut Lifetime) {
        *lifetime = parse_quote!('ctx);
    }
}