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))]
142pub fn iri_enum_derive(input: TokenStream) -> TokenStream {
143    let ast = parse_macro_input!(input as DeriveInput);
144    let core = core_path();
145
146    let mut prefixes: HashMap<String, IriBuf> = HashMap::new();
147    for attr in &ast.attrs {
148        let Some(tokens) = meta_list_tokens(attr, "iri_prefix") else {
149            continue;
150        };
151        let args: PrefixArgs = match parse2(tokens.clone()) {
152            Ok(a) => a,
153            Err(e) => return e.to_compile_error().into(),
154        };
155        match IriBuf::new(args.iri.value()) {
156            Ok(iri) => {
157                prefixes.insert(args.name.value(), iri);
158            }
159            Err(e) => bail!(args.iri.span(), "invalid IRI `{}` for prefix", e.0),
160        }
161    }
162
163    let Data::Enum(data) = ast.data else {
164        bail!(ast.ident.span(), "IriEnum can only be derived for enums");
165    };
166
167    let type_id = ast.ident;
168    let mut try_from_arms = TokenStream2::new();
169    let mut try_from_default = quote! { ::core::result::Result::Err(()) };
170    let mut into_arms = TokenStream2::new();
171
172    for variant in data.variants {
173        let variant_ident = variant.ident;
174        let mut variant_iri: Option<IriBuf> = None;
175
176        for attr in &variant.attrs {
177            let Some(tokens) = meta_list_tokens(attr, "iri") else {
178                continue;
179            };
180            let IriArg(lit) = match parse2(tokens.clone()) {
181                Ok(a) => a,
182                Err(e) => return e.to_compile_error().into(),
183            };
184            match expand_iri(&lit.value(), &prefixes) {
185                Ok(iri) => variant_iri = Some(iri),
186                Err(msg) => bail!(lit.span(), "{} for variant `{}`", msg, variant_ident),
187            }
188        }
189
190        match variant.fields {
191            Fields::Unit => {
192                let Some(iri) = variant_iri else {
193                    bail!(variant_ident.span(), "missing `#[iri(...)]` attribute for unit variant `{}`", variant_ident);
194                };
195                let raw = iri.as_str();
196                let iri_expr = iri_const_tokens(&core, &iri);
197                try_from_arms.extend(quote! {
198                    #raw => ::core::result::Result::Ok(#type_id::#variant_ident),
199                });
200                into_arms.extend(quote! {
201                    #type_id::#variant_ident => #iri_expr,
202                });
203            }
204            Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
205                let ty = fields.unnamed.into_iter().next().unwrap().ty;
206                try_from_default = quote! {
207                    match <#ty as ::core::convert::TryFrom<&#core::Iri<__T>>>::try_from(iri) {
208                        ::core::result::Result::Ok(value) => ::core::result::Result::Ok(#type_id::#variant_ident(value)),
209                        ::core::result::Result::Err(_) => { #try_from_default }
210                    }
211                };
212                into_arms.extend(quote! {
213                    #type_id::#variant_ident(v) => <&#ty as ::core::convert::Into<#core::Iri<&'static str>>>::into(v),
214                });
215            }
216            Fields::Named(_) => bail!(variant_ident.span(), "variants with named fields are unsupported"),
217            Fields::Unnamed(_) => bail!(variant_ident.span(), "variants with more than one field are unsupported"),
218        }
219    }
220
221    let output = quote! {
222        impl<__T> ::core::convert::TryFrom<&#core::Iri<__T>> for #type_id
223        where
224            __T: ::core::ops::Deref<Target = str>,
225        {
226            type Error = ();
227
228            #[inline]
229            fn try_from(iri: &#core::Iri<__T>) -> ::core::result::Result<Self, ()> {
230                match iri.as_str() {
231                    #try_from_arms
232                    _ => #try_from_default,
233                }
234            }
235        }
236
237        impl ::core::convert::From<&#type_id> for #core::Iri<&'static str> {
238            #[inline]
239            fn from(value: &#type_id) -> Self {
240                match value {
241                    #into_arms
242                }
243            }
244        }
245
246        impl ::core::convert::From<#type_id> for #core::Iri<&'static str> {
247            #[inline]
248            fn from(value: #type_id) -> Self {
249                <&#type_id as ::core::convert::Into<#core::Iri<&'static str>>>::into(&value)
250            }
251        }
252    };
253
254    output.into()
255}