Skip to main content

iri_rs_enum/
lib.rs

1//! Derive macro for IRI-valued enum types.
2//!
3//! Storage and comparison of full IRIs can be costly. For known vocabularies a
4//! plain enum is cheaper. The [`IriEnum`] derive generates the glue: conversion
5//! from a borrowed [`iri_rs_core::Iri`] into the enum, and back into a
6//! `'static`-backed [`iri_rs_core::Iri`].
7//!
8//! ```rust,ignore
9//! use iri_rs_enum::IriEnum;
10//! use iri_rs_static::iri;
11//!
12//! #[derive(IriEnum, PartialEq, Debug)]
13//! #[iri_prefix("schema" = "https://schema.org/")]
14//! pub enum Vocab {
15//!     #[iri("schema:name")] Name,
16//!     #[iri("schema:knows")] Knows,
17//! }
18//!
19//! let term: Vocab = Vocab::try_from(&iri!("https://schema.org/name")).unwrap();
20//! assert_eq!(term, Vocab::Name);
21//! ```
22//!
23//! Variants with a single unnamed field act as fallbacks; the inner type must
24//! implement `TryFrom<&Iri<T>>` and `From<&Inner>` for `Iri<&'static str>`
25//! (both generated by this derive on nested enums).
26//!
27//! Path resolution uses [`proc_macro_crate`]: downstream crates can depend on
28//! either `iri-rs-core` directly or on the `iri-rs` umbrella crate (which
29//! re-exports the runtime types under a hidden `__private` module).
30
31use iri_rs_core::{IriBuf, Positions};
32use proc_macro::TokenStream;
33use proc_macro_crate::{FoundCrate, crate_name};
34use proc_macro2::TokenStream as TokenStream2;
35use quote::{format_ident, quote};
36use std::collections::HashMap;
37use syn::{
38    Attribute,
39    Data,
40    DeriveInput,
41    Fields,
42    LitStr,
43    Meta,
44    Token,
45    parse::{Parse, ParseStream},
46    parse_macro_input,
47    parse2,
48};
49
50macro_rules! bail {
51    ($span:expr, $($arg:tt)*) => {
52        return syn::Error::new($span, format!($($arg)*)).to_compile_error().into()
53    };
54}
55
56fn core_path() -> TokenStream2 {
57    match crate_name("iri-rs") {
58        Ok(FoundCrate::Itself) => quote!(::iri_rs::__private),
59        Ok(FoundCrate::Name(n)) => {
60            let id = format_ident!("{}", n);
61            quote!(::#id::__private)
62        }
63        Err(_) => match crate_name("iri-rs-core").expect("expected `iri-rs` or `iri-rs-core` in dependencies") {
64            FoundCrate::Itself => quote!(crate),
65            FoundCrate::Name(n) => {
66                let id = format_ident!("{}", n);
67                quote!(::#id)
68            }
69        },
70    }
71}
72
73struct PrefixArgs {
74    name: LitStr,
75    iri: LitStr,
76}
77
78impl Parse for PrefixArgs {
79    fn parse(input: ParseStream) -> syn::Result<Self> {
80        let name: LitStr = input.parse()?;
81        let _: Token![=] = input.parse()?;
82        let iri: LitStr = input.parse()?;
83        Ok(Self { name, iri })
84    }
85}
86
87struct IriArg(LitStr);
88
89impl Parse for IriArg {
90    fn parse(input: ParseStream) -> syn::Result<Self> {
91        Ok(Self(input.parse()?))
92    }
93}
94
95fn meta_list_tokens<'a>(attr: &'a Attribute, name: &str) -> Option<&'a TokenStream2> {
96    match &attr.meta {
97        Meta::List(list) if list.path.is_ident(name) => Some(&list.tokens),
98        _ => None,
99    }
100}
101
102fn expand_iri(value: &str, prefixes: &HashMap<String, IriBuf>) -> Result<IriBuf, String> {
103    if let Some(index) = value.find(':') {
104        if index > 0 {
105            let (prefix, rest) = value.split_at(index);
106            let suffix = &rest[1..];
107            if !suffix.starts_with("//") {
108                if let Some(base) = prefixes.get(prefix) {
109                    let concat = format!("{}{}", base.as_str(), suffix);
110                    return IriBuf::new(concat.clone()).map_err(|_| format!("invalid IRI `{concat}`"));
111                }
112            }
113        }
114    }
115    IriBuf::new(value.to_owned()).map_err(|_| format!("invalid IRI `{value}`"))
116}
117
118fn positions_tokens(core: &TokenStream2, p: Positions) -> TokenStream2 {
119    let s = p.scheme_end;
120    let a = p.authority_end;
121    let pe = p.path_end;
122    let q = p.query_end;
123    quote! {
124        #core::Positions {
125            scheme_end: #s,
126            authority_end: #a,
127            path_end: #pe,
128            query_end: #q,
129        }
130    }
131}
132
133fn iri_const_tokens(core: &TokenStream2, iri: &IriBuf) -> TokenStream2 {
134    let s = iri.as_str();
135    let p = positions_tokens(core, iri.positions());
136    quote! {
137        #core::Iri::<&'static str>::from_raw_parts(#s, #p)
138    }
139}
140
141#[proc_macro_derive(IriEnum, attributes(iri_prefix, iri))]
142// `too_many_lines`: one line over the threshold, and the body is a single
143// straight-line lowering of the input enum; splitting it would only move code.
144// `missing_panics_doc`: the two `unwrap`s here sit behind guards that prove them
145// (`len() == 1` before taking the single field), and a "# Panics" section on a
146// derive's rustdoc documents an entry point nobody calls directly.
147#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
148pub fn iri_enum_derive(input: TokenStream) -> TokenStream {
149    let ast = parse_macro_input!(input as DeriveInput);
150    let core = core_path();
151
152    let mut prefixes: HashMap<String, IriBuf> = HashMap::new();
153    for attr in &ast.attrs {
154        let Some(tokens) = meta_list_tokens(attr, "iri_prefix") else {
155            continue;
156        };
157        let args: PrefixArgs = match parse2(tokens.clone()) {
158            Ok(a) => a,
159            Err(e) => return e.to_compile_error().into(),
160        };
161        match IriBuf::new(args.iri.value()) {
162            Ok(iri) => {
163                prefixes.insert(args.name.value(), iri);
164            }
165            Err(e) => bail!(args.iri.span(), "invalid IRI `{}` for prefix", e.0),
166        }
167    }
168
169    let Data::Enum(data) = ast.data else {
170        bail!(ast.ident.span(), "IriEnum can only be derived for enums");
171    };
172
173    let type_id = ast.ident;
174    let mut try_from_arms = TokenStream2::new();
175    let mut try_from_default = quote! { ::core::result::Result::Err(()) };
176    let mut into_arms = TokenStream2::new();
177
178    for variant in data.variants {
179        let variant_ident = variant.ident;
180        let mut variant_iri: Option<IriBuf> = None;
181
182        for attr in &variant.attrs {
183            let Some(tokens) = meta_list_tokens(attr, "iri") else {
184                continue;
185            };
186            let IriArg(lit) = match parse2(tokens.clone()) {
187                Ok(a) => a,
188                Err(e) => return e.to_compile_error().into(),
189            };
190            match expand_iri(&lit.value(), &prefixes) {
191                Ok(iri) => variant_iri = Some(iri),
192                Err(msg) => bail!(lit.span(), "{} for variant `{}`", msg, variant_ident),
193            }
194        }
195
196        match variant.fields {
197            Fields::Unit => {
198                let Some(iri) = variant_iri else {
199                    bail!(variant_ident.span(), "missing `#[iri(...)]` attribute for unit variant `{}`", variant_ident);
200                };
201                let raw = iri.as_str();
202                let iri_expr = iri_const_tokens(&core, &iri);
203                try_from_arms.extend(quote! {
204                    #raw => ::core::result::Result::Ok(#type_id::#variant_ident),
205                });
206                into_arms.extend(quote! {
207                    #type_id::#variant_ident => #iri_expr,
208                });
209            }
210            Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
211                let ty = fields.unnamed.into_iter().next().unwrap().ty;
212                try_from_default = quote! {
213                    match <#ty as ::core::convert::TryFrom<&#core::Iri<__T>>>::try_from(iri) {
214                        ::core::result::Result::Ok(value) => ::core::result::Result::Ok(#type_id::#variant_ident(value)),
215                        ::core::result::Result::Err(_) => { #try_from_default }
216                    }
217                };
218                into_arms.extend(quote! {
219                    #type_id::#variant_ident(v) => <&#ty as ::core::convert::Into<#core::Iri<&'static str>>>::into(v),
220                });
221            }
222            Fields::Named(_) => bail!(variant_ident.span(), "variants with named fields are unsupported"),
223            Fields::Unnamed(_) => bail!(variant_ident.span(), "variants with more than one field are unsupported"),
224        }
225    }
226
227    let output = quote! {
228        impl<__T> ::core::convert::TryFrom<&#core::Iri<__T>> for #type_id
229        where
230            __T: ::core::ops::Deref<Target = str>,
231        {
232            type Error = ();
233
234            #[inline]
235            fn try_from(iri: &#core::Iri<__T>) -> ::core::result::Result<Self, ()> {
236                match iri.as_str() {
237                    #try_from_arms
238                    _ => #try_from_default,
239                }
240            }
241        }
242
243        impl ::core::convert::From<&#type_id> for #core::Iri<&'static str> {
244            #[inline]
245            fn from(value: &#type_id) -> Self {
246                match value {
247                    #into_arms
248                }
249            }
250        }
251
252        impl ::core::convert::From<#type_id> for #core::Iri<&'static str> {
253            #[inline]
254            fn from(value: #type_id) -> Self {
255                <&#type_id as ::core::convert::Into<#core::Iri<&'static str>>>::into(&value)
256            }
257        }
258    };
259
260    output.into()
261}