apollo-errors-derive 0.5.0

Proc macro for deriving apollo-errors::Error trait
Documentation
//! Field parsing

use syn::{Attribute, Result, Type};

use super::http_header::parse_http_header;
use crate::ir::{FieldDefinition, TransparentFieldDefinition};

/// Parse a field
pub(crate) fn parse_field(field: syn::Field) -> Result<FieldDefinition> {
    // Check if field has #[extension] or #[extension(rename = "...")] attribute
    let (is_extension, rename) = parse_extension(&field.attrs)?;

    // Check if field has #[from] attribute (implies #[source])
    let is_from = has_attribute(&field.attrs, "from");

    // Check if field has #[source] attribute (or #[from] which implies #[source])
    let is_source = has_attribute(&field.attrs, "source") || is_from;

    // Parse #[http_header("...")] attribute
    let http_header = parse_http_header(&field.attrs)?;

    let rust_name = field
        .ident
        .clone()
        .ok_or_else(|| syn::Error::new_spanned(&field, "fields must be named"))?;

    let output_name = rename.unwrap_or_else(|| rust_name.to_string());
    let is_option = is_option_type(&field.ty);

    Ok(FieldDefinition {
        rust_name,
        output_name,
        ty: field.ty,
        is_extension,
        is_source,
        is_from,
        is_option,
        http_header,
    })
}

pub(crate) fn parse_transparent_field(field: syn::Field) -> Result<TransparentFieldDefinition> {
    if field.ident.is_some() {
        unreachable!("parse_transparent_field() can only be called on unnamed fields");
    }

    if let Some(attr) = field
        .attrs
        .iter()
        .find(|attr| attr.path().is_ident("source"))
    {
        return Err(syn::Error::new_spanned(
            attr,
            "#[source] attribute is not necessary on transparent variants",
        ));
    }

    Ok(TransparentFieldDefinition {
        ty: field.ty,
        is_from: has_attribute(&field.attrs, "from"),
    })
}

/// Check if an attribute list contains a specific attribute
fn has_attribute(attrs: &[Attribute], name: &str) -> bool {
    attrs.iter().any(|attr| attr.path().is_ident(name))
}

/// Check if a type is `Option<T>`.
/// Handles: `Option<T>`, `std::option::Option<T>`, `core::option::Option<T>`
fn is_option_type(ty: &Type) -> bool {
    if let Type::Path(type_path) = ty {
        let path = &type_path.path;

        // Check for Option, std::option::Option, or core::option::Option
        path.segments
            .last()
            .is_some_and(|seg| seg.ident == "Option")
            && (path.segments.len() == 1
                || (path.segments.len() == 3
                    && (path.segments[0].ident == "std" || path.segments[0].ident == "core")
                    && path.segments[1].ident == "option"))
    } else {
        false
    }
}

/// Parse `#[extension]` or `#[extension(rename = "...")]`.
///
/// Returns `(is_extension, rename)`.
fn parse_extension(attrs: &[Attribute]) -> Result<(bool, Option<String>)> {
    let mut is_extension = false;
    let mut rename: Option<String> = None;

    for attr in attrs {
        if !attr.path().is_ident("extension") {
            continue;
        }

        // Check for duplicate #[extension] attributes
        if is_extension {
            return Err(syn::Error::new_spanned(
                attr,
                "duplicate #[extension] attribute",
            ));
        }
        is_extension = true;

        match &attr.meta {
            syn::Meta::Path(_) => {}
            // `#[extension(rename = "...")]`
            syn::Meta::List(_) => {
                attr.parse_nested_meta(|meta| {
                    if meta.path.is_ident("rename") {
                        let value = meta.value()?;
                        let s: syn::LitStr = value.parse()?;
                        rename = Some(s.value());
                        Ok(())
                    } else {
                        Err(meta.error("unknown extension attribute, expected `rename = \"...\"`"))
                    }
                })?;
            }
            // `#[extension = "..."]` is not valid here
            syn::Meta::NameValue(_) => {
                return Err(syn::Error::new_spanned(
                    attr,
                    "expected #[extension] or #[extension(rename = \"...\")]",
                ));
            }
        }
    }

    Ok((is_extension, rename))
}

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

    #[test]
    fn bare_extension_is_detected() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[extension])];
        let (is_extension, rename) = parse_extension(&attrs).unwrap();
        assert!(is_extension);
        assert_eq!(rename, None);
    }

    #[test]
    fn extension_with_rename() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[extension(rename = "MY_FIELD")])];
        let (is_extension, rename) = parse_extension(&attrs).unwrap();
        assert!(is_extension);
        assert_eq!(rename, Some("MY_FIELD".to_string()));
    }

    #[test]
    fn no_extension_attribute() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[source])];
        let (is_extension, rename) = parse_extension(&attrs).unwrap();
        assert!(!is_extension);
        assert_eq!(rename, None);
    }

    #[test]
    fn reject_duplicate_extension() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[extension]), parse_quote!(#[extension])];
        let err = parse_extension(&attrs).unwrap_err();
        assert!(err.to_string().contains("duplicate #[extension] attribute"));
    }

    #[test]
    fn reject_unknown_extension_key() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[extension(unknown = "value")])];
        let err = parse_extension(&attrs).unwrap_err();
        assert!(err.to_string().contains("unknown extension attribute"));
    }

    #[test]
    fn reject_extension_with_no_parens() {
        let attrs: Vec<Attribute> = vec![parse_quote!(#[extension = "MyField"])];
        let err = parse_extension(&attrs).unwrap_err();
        assert!(
            err.to_string()
                .contains("expected #[extension] or #[extension(rename = \"...\")]")
        );
    }
}