iri-rs-enum 3.4.1

Derive macro that maps vocabulary IRIs onto enum variants, with compile-time prefix resolution and const-friendly output.
Documentation
//! Derive macro for IRI-valued enum types.
//!
//! Storage and comparison of full IRIs can be costly. For known vocabularies a
//! plain enum is cheaper. The [`IriEnum`] derive generates the glue: conversion
//! from a borrowed [`iri_rs_core::Iri`] into the enum, and back into a
//! `'static`-backed [`iri_rs_core::Iri`].
//!
//! ```rust,ignore
//! use iri_rs_enum::IriEnum;
//! use iri_rs_static::iri;
//!
//! #[derive(IriEnum, PartialEq, Debug)]
//! #[iri_prefix("schema" = "https://schema.org/")]
//! pub enum Vocab {
//!     #[iri("schema:name")] Name,
//!     #[iri("schema:knows")] Knows,
//! }
//!
//! let term: Vocab = Vocab::try_from(&iri!("https://schema.org/name")).unwrap();
//! assert_eq!(term, Vocab::Name);
//! ```
//!
//! Variants with a single unnamed field act as fallbacks; the inner type must
//! implement `TryFrom<&Iri<T>>` and `From<&Inner>` for `Iri<&'static str>`
//! (both generated by this derive on nested enums).
//!
//! Path resolution uses [`proc_macro_crate`]: downstream crates can depend on
//! either `iri-rs-core` directly or on the `iri-rs` umbrella crate (which
//! re-exports the runtime types under a hidden `__private` module).

use iri_rs_core::{IriBuf, Positions};
use proc_macro::TokenStream;
use proc_macro_crate::{FoundCrate, crate_name};
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use std::collections::HashMap;
use syn::{
    Attribute,
    Data,
    DeriveInput,
    Fields,
    LitStr,
    Meta,
    Token,
    parse::{Parse, ParseStream},
    parse_macro_input,
    parse2,
};

macro_rules! bail {
    ($span:expr, $($arg:tt)*) => {
        return syn::Error::new($span, format!($($arg)*)).to_compile_error().into()
    };
}

fn core_path() -> TokenStream2 {
    match crate_name("iri-rs") {
        Ok(FoundCrate::Itself) => quote!(::iri_rs::__private),
        Ok(FoundCrate::Name(n)) => {
            let id = format_ident!("{}", n);
            quote!(::#id::__private)
        }
        Err(_) => match crate_name("iri-rs-core").expect("expected `iri-rs` or `iri-rs-core` in dependencies") {
            FoundCrate::Itself => quote!(crate),
            FoundCrate::Name(n) => {
                let id = format_ident!("{}", n);
                quote!(::#id)
            }
        },
    }
}

struct PrefixArgs {
    name: LitStr,
    iri: LitStr,
}

impl Parse for PrefixArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let name: LitStr = input.parse()?;
        let _: Token![=] = input.parse()?;
        let iri: LitStr = input.parse()?;
        Ok(Self { name, iri })
    }
}

struct IriArg(LitStr);

impl Parse for IriArg {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        Ok(Self(input.parse()?))
    }
}

fn meta_list_tokens<'a>(attr: &'a Attribute, name: &str) -> Option<&'a TokenStream2> {
    match &attr.meta {
        Meta::List(list) if list.path.is_ident(name) => Some(&list.tokens),
        _ => None,
    }
}

fn expand_iri(value: &str, prefixes: &HashMap<String, IriBuf>) -> Result<IriBuf, String> {
    if let Some(index) = value.find(':') {
        if index > 0 {
            let (prefix, rest) = value.split_at(index);
            let suffix = &rest[1..];
            if !suffix.starts_with("//") {
                if let Some(base) = prefixes.get(prefix) {
                    let concat = format!("{}{}", base.as_str(), suffix);
                    return IriBuf::new(concat.clone()).map_err(|_| format!("invalid IRI `{concat}`"));
                }
            }
        }
    }
    IriBuf::new(value.to_owned()).map_err(|_| format!("invalid IRI `{value}`"))
}

fn positions_tokens(core: &TokenStream2, p: Positions) -> TokenStream2 {
    let s = p.scheme_end;
    let a = p.authority_end;
    let pe = p.path_end;
    let q = p.query_end;
    quote! {
        #core::Positions {
            scheme_end: #s,
            authority_end: #a,
            path_end: #pe,
            query_end: #q,
        }
    }
}

fn iri_const_tokens(core: &TokenStream2, iri: &IriBuf) -> TokenStream2 {
    let s = iri.as_str();
    let p = positions_tokens(core, iri.positions());
    quote! {
        #core::Iri::<&'static str>::from_raw_parts(#s, #p)
    }
}

#[proc_macro_derive(IriEnum, attributes(iri_prefix, iri))]
// `too_many_lines`: one line over the threshold, and the body is a single
// straight-line lowering of the input enum; splitting it would only move code.
// `missing_panics_doc`: the two `unwrap`s here sit behind guards that prove them
// (`len() == 1` before taking the single field), and a "# Panics" section on a
// derive's rustdoc documents an entry point nobody calls directly.
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
pub fn iri_enum_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let core = core_path();

    let mut prefixes: HashMap<String, IriBuf> = HashMap::new();
    for attr in &ast.attrs {
        let Some(tokens) = meta_list_tokens(attr, "iri_prefix") else {
            continue;
        };
        let args: PrefixArgs = match parse2(tokens.clone()) {
            Ok(a) => a,
            Err(e) => return e.to_compile_error().into(),
        };
        match IriBuf::new(args.iri.value()) {
            Ok(iri) => {
                prefixes.insert(args.name.value(), iri);
            }
            Err(e) => bail!(args.iri.span(), "invalid IRI `{}` for prefix", e.0),
        }
    }

    let Data::Enum(data) = ast.data else {
        bail!(ast.ident.span(), "IriEnum can only be derived for enums");
    };

    let type_id = ast.ident;
    let mut try_from_arms = TokenStream2::new();
    let mut try_from_default = quote! { ::core::result::Result::Err(()) };
    let mut into_arms = TokenStream2::new();

    for variant in data.variants {
        let variant_ident = variant.ident;
        let mut variant_iri: Option<IriBuf> = None;

        for attr in &variant.attrs {
            let Some(tokens) = meta_list_tokens(attr, "iri") else {
                continue;
            };
            let IriArg(lit) = match parse2(tokens.clone()) {
                Ok(a) => a,
                Err(e) => return e.to_compile_error().into(),
            };
            match expand_iri(&lit.value(), &prefixes) {
                Ok(iri) => variant_iri = Some(iri),
                Err(msg) => bail!(lit.span(), "{} for variant `{}`", msg, variant_ident),
            }
        }

        match variant.fields {
            Fields::Unit => {
                let Some(iri) = variant_iri else {
                    bail!(variant_ident.span(), "missing `#[iri(...)]` attribute for unit variant `{}`", variant_ident);
                };
                let raw = iri.as_str();
                let iri_expr = iri_const_tokens(&core, &iri);
                try_from_arms.extend(quote! {
                    #raw => ::core::result::Result::Ok(#type_id::#variant_ident),
                });
                into_arms.extend(quote! {
                    #type_id::#variant_ident => #iri_expr,
                });
            }
            Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
                let ty = fields.unnamed.into_iter().next().unwrap().ty;
                try_from_default = quote! {
                    match <#ty as ::core::convert::TryFrom<&#core::Iri<__T>>>::try_from(iri) {
                        ::core::result::Result::Ok(value) => ::core::result::Result::Ok(#type_id::#variant_ident(value)),
                        ::core::result::Result::Err(_) => { #try_from_default }
                    }
                };
                into_arms.extend(quote! {
                    #type_id::#variant_ident(v) => <&#ty as ::core::convert::Into<#core::Iri<&'static str>>>::into(v),
                });
            }
            Fields::Named(_) => bail!(variant_ident.span(), "variants with named fields are unsupported"),
            Fields::Unnamed(_) => bail!(variant_ident.span(), "variants with more than one field are unsupported"),
        }
    }

    let output = quote! {
        impl<__T> ::core::convert::TryFrom<&#core::Iri<__T>> for #type_id
        where
            __T: ::core::ops::Deref<Target = str>,
        {
            type Error = ();

            #[inline]
            fn try_from(iri: &#core::Iri<__T>) -> ::core::result::Result<Self, ()> {
                match iri.as_str() {
                    #try_from_arms
                    _ => #try_from_default,
                }
            }
        }

        impl ::core::convert::From<&#type_id> for #core::Iri<&'static str> {
            #[inline]
            fn from(value: &#type_id) -> Self {
                match value {
                    #into_arms
                }
            }
        }

        impl ::core::convert::From<#type_id> for #core::Iri<&'static str> {
            #[inline]
            fn from(value: #type_id) -> Self {
                <&#type_id as ::core::convert::Into<#core::Iri<&'static str>>>::into(&value)
            }
        }
    };

    output.into()
}