Skip to main content

haste_fhir_operation_error_derive/
lib.rs

1//! `#[derive(OperationOutcomeError)]` for error enums. Each variant lists the
2//! issues it answers with; the enum then converts into an
3//! `OperationOutcomeError`, and a `#[from]` field makes it convert from the
4//! error it wraps.
5
6use haste_fhir_model::r4::generated::terminology::{IssueType, ValueSetDef};
7use proc_macro::TokenStream;
8use proc_macro2::Span;
9use quote::{format_ident, quote};
10use syn::{
11    Attribute, Data, DeriveInput, Fields, Ident, LitStr, Type, Variant, parse_macro_input,
12    spanned::Spanned,
13};
14
15const SEVERITIES: [&str; 4] = ["fatal", "error", "warning", "information"];
16
17/// One issue attribute: `#[error(code = "not-found", diagnostic = "...")]`.
18/// `severity` and `code` are the names of the model's constructors.
19struct Issue {
20    severity: Ident,
21    code: Ident,
22    diagnostic: Option<LitStr>,
23}
24
25/// Parse an attribute as an issue, or `None` when it is some other attribute.
26fn parse_issue(attr: &Attribute) -> syn::Result<Option<Issue>> {
27    let Some(severity) = attr
28        .path()
29        .get_ident()
30        .filter(|ident| SEVERITIES.contains(&ident.to_string().as_str()))
31    else {
32        return Ok(None);
33    };
34
35    let mut code = None;
36    let mut diagnostic = None;
37    attr.parse_nested_meta(|meta| {
38        if meta.path.is_ident("code") {
39            let literal: LitStr = meta.value()?.parse()?;
40            let value = literal.value();
41            if IssueType::CODES.binary_search(&value.as_str()).is_err() {
42                return Err(syn::Error::new(
43                    literal.span(),
44                    format!(
45                        "'{value}' is not a FHIR issue type; expected one of {:?}",
46                        IssueType::CODES
47                    ),
48                ));
49            }
50            code = Some(Ident::new(&value.replace('-', "_"), literal.span()));
51        } else if meta.path.is_ident("diagnostic") {
52            diagnostic = Some(meta.value()?.parse()?);
53        } else {
54            return Err(meta.error("expected `code` or `diagnostic`"));
55        }
56        Ok(())
57    })?;
58
59    let Some(code) = code else {
60        return Err(syn::Error::new(attr.span(), "an issue needs a `code`"));
61    };
62    Ok(Some(Issue {
63        severity: severity.clone(),
64        code,
65        diagnostic,
66    }))
67}
68
69/// The `OperationOutcomeIssue` expression for an issue. A diagnostic is a
70/// format string over the variant's fields as `arg0`, `arg1`, ...
71fn issue_expr(issue: &Issue) -> proc_macro2::TokenStream {
72    let Issue {
73        severity,
74        code,
75        diagnostic,
76    } = issue;
77    let diagnostics = diagnostic.as_ref().map_or_else(
78        || quote! { None },
79        |text| {
80            quote! {
81                Some(Box::new(haste_fhir_model::r4::generated::types::FHIRString {
82                    value: Some(format!(#text)),
83                    ..Default::default()
84                }))
85            }
86        },
87    );
88    quote! {
89        haste_fhir_model::r4::generated::resources::OperationOutcomeIssue {
90            severity: haste_fhir_model::r4::generated::terminology::IssueSeverity::#severity(),
91            code: haste_fhir_model::r4::generated::terminology::IssueType::#code(),
92            diagnostics: #diagnostics,
93            ..Default::default()
94        }
95    }
96}
97
98/// A variant's match arm, plus the type of its `#[from]` field if it has one.
99fn variant_arm(
100    name: &Ident,
101    variant: &Variant,
102) -> syn::Result<(proc_macro2::TokenStream, Option<Type>)> {
103    let issues = variant
104        .attrs
105        .iter()
106        .map(parse_issue)
107        .filter_map(Result::transpose)
108        .collect::<syn::Result<Vec<_>>>()?;
109    if issues.is_empty() {
110        return Err(syn::Error::new(
111            variant.span(),
112            "a variant needs a `fatal`, `error`, `warning` or `information` attribute",
113        ));
114    }
115
116    let fields: Vec<_> = match &variant.fields {
117        Fields::Unit => Vec::new(),
118        Fields::Unnamed(unnamed) => unnamed.unnamed.iter().collect(),
119        Fields::Named(named) => {
120            return Err(syn::Error::new(
121                named.span(),
122                "named fields are not supported; use a tuple variant",
123            ));
124        }
125    };
126    let args: Vec<Ident> = (0..fields.len()).map(|i| format_ident!("arg{i}")).collect();
127
128    let from_positions: Vec<usize> = fields
129        .iter()
130        .enumerate()
131        .filter(|(_, field)| field.attrs.iter().any(|attr| attr.path().is_ident("from")))
132        .map(|(i, _)| i)
133        .collect();
134    if from_positions.len() > 1 {
135        return Err(syn::Error::new(
136            variant.span(),
137            "only one field can be `#[from]`",
138        ));
139    }
140    if !from_positions.is_empty() && fields.len() != 1 {
141        return Err(syn::Error::new(
142            variant.span(),
143            "a variant with a `#[from]` field has no other fields",
144        ));
145    }
146    let from = from_positions.first().map(|&i| fields[i].ty.clone());
147    let source = from_positions.first().map_or_else(
148        || quote! { None },
149        |&i| {
150            let arg = &args[i];
151            quote! { Some(#arg.into()) }
152        },
153    );
154
155    let ident = &variant.ident;
156    let pattern = if args.is_empty() {
157        quote! { #name::#ident }
158    } else {
159        quote! { #name::#ident(#(#args),*) }
160    };
161    let issues = issues.iter().map(issue_expr);
162    // The diagnostics format the fields before the `#[from]` field moves into the source.
163    let arm = quote! {
164        #pattern => {
165            let outcome = haste_fhir_model::r4::generated::resources::OperationOutcome {
166                issue: vec![#(#issues),*],
167                ..Default::default()
168            };
169            haste_fhir_operation_error::OperationOutcomeError::new(#source, outcome)
170        }
171    };
172    Ok((arm, from))
173}
174
175fn expand(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
176    let Data::Enum(data) = &input.data else {
177        return Err(syn::Error::new(
178            Span::call_site(),
179            "OperationOutcomeError can only be derived for an enum",
180        ));
181    };
182    let name = &input.ident;
183
184    let mut arms = Vec::with_capacity(data.variants.len());
185    let mut from_impls = Vec::new();
186    for variant in &data.variants {
187        let (arm, from) = variant_arm(name, variant)?;
188        arms.push(arm);
189        if let Some(ty) = from {
190            let ident = &variant.ident;
191            from_impls.push(quote! {
192                impl From<#ty> for #name {
193                    fn from(error: #ty) -> Self {
194                        #name::#ident(error)
195                    }
196                }
197            });
198        }
199    }
200
201    // `track_caller` makes the error's origin the `?` that converted it.
202    Ok(quote! {
203        impl From<#name> for haste_fhir_operation_error::OperationOutcomeError {
204            #[track_caller]
205            fn from(value: #name) -> Self {
206                match value {
207                    #(#arms),*
208                }
209            }
210        }
211        #(#from_impls)*
212    })
213}
214
215/// Derives `From<YourEnum> for OperationOutcomeError`.
216///
217/// Each variant carries one or more of `#[fatal(...)]`, `#[error(...)]`,
218/// `#[warning(...)]` and `#[information(...)]`, with a `code` from the FHIR
219/// `IssueType` value set and an optional `diagnostic` format string over the
220/// variant's fields as `{arg0}`, `{arg1}`, ... A `#[from]` field becomes the
221/// error's source and also generates `From<ThatError> for YourEnum`.
222#[proc_macro_derive(
223    OperationOutcomeError,
224    attributes(fatal, error, warning, information, from)
225)]
226pub fn operation_error(input: TokenStream) -> TokenStream {
227    let input = parse_macro_input!(input as DeriveInput);
228    expand(&input)
229        .unwrap_or_else(syn::Error::into_compile_error)
230        .into()
231}