Skip to main content

hl7_2_derive/
lib.rs

1//! Derive macros for [`hl7-2`](https://crates.io/crates/hl7-2): map a
2//! struct's fields to HL7 v2 message paths once, in the type definition,
3//! instead of writing the same accessor calls at every call site.
4//!
5//! This crate is not used directly. `hl7-2` re-exports both macros behind
6//! its `derive` feature, so the dependency to add is:
7//!
8//! ```toml
9//! hl7-2 = { version = "0.2", features = ["derive"] }
10//! ```
11//!
12//! Keeping the macros in a crate of their own is what lets the default
13//! build of `hl7-2` keep exactly one dependency: `syn` and `quote` are
14//! compiled only for callers who ask for the macros.
15//!
16//! ```ignore
17//! use hl7_2::{FromHl7, ToHl7, Raw};
18//!
19//! #[derive(FromHl7, ToHl7)]
20//! struct Result {
21//!     #[hl7("OBX-3.1")]   code: String,
22//!     #[hl7("OBX-5")]     value: Option<String>,
23//!     #[hl7("OBX-6.1")]   units: Option<String>,
24//!     #[hl7(nested)]      patient: Patient,   // its own FromHl7
25//!     #[hl7(raw)]         raw: Raw,           // the escape hatch
26//! }
27//! ```
28//!
29//! One attribute per field, and a field with none is skipped on read
30//! (it must implement [`Default`]) and on write:
31//!
32//! | attribute | on read | on write |
33//! |---|---|---|
34//! | `#[hl7("PID-5.1")]` | read the path | write the path |
35//! | `#[hl7(nested)]` | the field's own `FromHl7` | the field's own `ToHl7` |
36//! | `#[hl7(raw)]` | the whole message | skipped |
37//! | none | `Default::default()` | skipped |
38//!
39//! ## When `hl7-2` is not called `hl7_2`
40//!
41//! The generated code names the crate absolutely, as `::hl7_2`, so that it
42//! works wherever the type is defined without the caller importing
43//! anything. A caller who renames the dependency —
44//! `hl7 = { package = "hl7-2" }` in `Cargo.toml`, or a workspace that
45//! aliases it — has no `::hl7_2` for the macro to reach, and the generated
46//! code stops compiling. Say where it is instead, once, on the struct:
47//!
48//! ```ignore
49//! #[derive(FromHl7)]
50//! #[hl7(crate = hl7)]        // or `crate = "::some::path::to::hl7_2"`
51//! struct Patient {
52//!     #[hl7("PID-5.1")] family: String,
53//! }
54//! ```
55
56#![warn(missing_docs, clippy::pedantic)]
57
58use proc_macro::TokenStream;
59use quote::quote;
60use syn::spanned::Spanned;
61use syn::{Data, DeriveInput, Field, Fields, LitStr, Path, Token, parse_macro_input};
62
63/// Derive `FromHl7`: read each annotated field from its path.
64///
65/// See the crate documentation for the attributes. Only structs with named
66/// fields are supported; a tuple struct has no field names to map, and an
67/// enum has no single shape to read.
68#[proc_macro_derive(FromHl7, attributes(hl7))]
69pub fn derive_from_hl7(input: TokenStream) -> TokenStream {
70    let input = parse_macro_input!(input as DeriveInput);
71    match from_hl7(&input) {
72        Ok(tokens) => tokens.into(),
73        Err(error) => error.to_compile_error().into(),
74    }
75}
76
77/// Derive `ToHl7`: write each annotated field back to its path.
78///
79/// Writing needs the segments to exist already; build the message with
80/// `hl7_2::Builder` (whose `encode` method takes a `ToHl7`) or add them
81/// with `Message::append_segment`.
82#[proc_macro_derive(ToHl7, attributes(hl7))]
83pub fn derive_to_hl7(input: TokenStream) -> TokenStream {
84    let input = parse_macro_input!(input as DeriveInput);
85    match to_hl7(&input) {
86        Ok(tokens) => tokens.into(),
87        Err(error) => error.to_compile_error().into(),
88    }
89}
90
91/// What one field's `#[hl7(...)]` attribute asked for.
92enum Mapping {
93    /// `#[hl7("PID-5.1")]`
94    Path(LitStr),
95    /// `#[hl7(nested)]`
96    Nested,
97    /// `#[hl7(raw)]`
98    Raw,
99    /// No attribute at all.
100    None,
101}
102
103fn from_hl7(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
104    let name = &input.ident;
105    let krate = crate_path(input)?;
106    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
107    let mut reads = Vec::new();
108    for field in named_fields(input)? {
109        let ident = field.ident.as_ref().expect("named fields");
110        let ty = &field.ty;
111        reads.push(match mapping(field)? {
112            Mapping::Path(path) => quote! {
113                #ident: <#ty as #krate::FromHl7Value>::from_hl7_value(message, #path)?
114            },
115            Mapping::Nested => quote! {
116                #ident: <#ty as #krate::FromHl7>::from_hl7(message)?
117            },
118            Mapping::Raw => quote! {
119                #ident: #krate::Raw::new(::core::clone::Clone::clone(message)).into()
120            },
121            Mapping::None => quote! {
122                #ident: ::core::default::Default::default()
123            },
124        });
125    }
126    Ok(quote! {
127        #[automatically_derived]
128        impl #impl_generics #krate::FromHl7 for #name #type_generics #where_clause {
129            fn from_hl7(message: &#krate::Message) -> ::core::result::Result<Self, #krate::Error> {
130                ::core::result::Result::Ok(#name { #(#reads),* })
131            }
132        }
133    })
134}
135
136fn to_hl7(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
137    let name = &input.ident;
138    let krate = crate_path(input)?;
139    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
140    let mut writes = Vec::new();
141    for field in named_fields(input)? {
142        let ident = field.ident.as_ref().expect("named fields");
143        let ty = &field.ty;
144        match mapping(field)? {
145            Mapping::Path(path) => writes.push(quote! {
146                <#ty as #krate::ToHl7Value>::to_hl7_value(&self.#ident, message, #path)?;
147            }),
148            Mapping::Nested => writes.push(quote! {
149                <#ty as #krate::ToHl7>::to_hl7(&self.#ident, message)?;
150            }),
151            // The raw message is where the struct came from, not something
152            // to write back over it.
153            Mapping::Raw | Mapping::None => {}
154        }
155    }
156    Ok(quote! {
157        #[automatically_derived]
158        impl #impl_generics #krate::ToHl7 for #name #type_generics #where_clause {
159            fn to_hl7(
160                &self,
161                message: &mut #krate::Message,
162            ) -> ::core::result::Result<(), #krate::Error> {
163                #(#writes)*
164                ::core::result::Result::Ok(())
165            }
166        }
167    })
168}
169
170/// The struct's named fields, or an error explaining what this macro maps.
171fn named_fields(input: &DeriveInput) -> syn::Result<impl Iterator<Item = &Field>> {
172    match &input.data {
173        Data::Struct(data) => match &data.fields {
174            Fields::Named(named) => Ok(named.named.iter()),
175            other => Err(syn::Error::new(
176                other.span(),
177                "hl7-2 derives map field names to HL7 paths, so the struct needs named fields",
178            )),
179        },
180        Data::Enum(_) | Data::Union(_) => Err(syn::Error::new(
181            input.ident.span(),
182            "hl7-2 derives apply to structs; an enum or union has no single message shape",
183        )),
184    }
185}
186
187/// Read one field's `#[hl7(...)]` attribute.
188/// Where the generated code should look for `hl7-2`.
189///
190/// `::hl7_2` unless the struct says otherwise with `#[hl7(crate = ...)]`,
191/// which is what a caller who renamed the dependency needs: the generated
192/// code names the crate absolutely so that it compiles wherever the type is
193/// defined, and an absolute name that does not exist is a compile error the
194/// caller cannot work around from their side.
195///
196/// The value is a path, written bare (`crate = hl7`) or quoted
197/// (`crate = "::vendor::hl7_2"`); the quoted form is there because that is
198/// how the rest of the ecosystem spells it.
199fn crate_path(input: &DeriveInput) -> syn::Result<Path> {
200    for attribute in &input.attrs {
201        if !attribute.path().is_ident("hl7") {
202            continue;
203        }
204        return attribute.parse_args_with(|stream: syn::parse::ParseStream| {
205            stream.parse::<Token![crate]>().map_err(|_| {
206                syn::Error::new(
207                    attribute.span(),
208                    "the only #[hl7(...)] option on a struct is `crate = ...`; \
209                     path attributes belong on fields",
210                )
211            })?;
212            stream.parse::<Token![=]>()?;
213            if stream.peek(LitStr) {
214                return stream.parse::<LitStr>()?.parse();
215            }
216            stream.parse()
217        });
218    }
219    Ok(syn::parse_quote!(::hl7_2))
220}
221
222fn mapping(field: &Field) -> syn::Result<Mapping> {
223    let mut found = Mapping::None;
224    for attribute in &field.attrs {
225        if !attribute.path().is_ident("hl7") {
226            continue;
227        }
228        if !matches!(found, Mapping::None) {
229            return Err(syn::Error::new(
230                attribute.span(),
231                "a field takes one #[hl7(...)] attribute",
232            ));
233        }
234        // Three spellings: a path literal, `nested`, or `raw`.
235        found = attribute.parse_args_with(|input: syn::parse::ParseStream| {
236            if input.peek(LitStr) {
237                return Ok(Mapping::Path(input.parse()?));
238            }
239            let word: syn::Ident = input.parse()?;
240            match word.to_string().as_str() {
241                "nested" => Ok(Mapping::Nested),
242                "raw" => Ok(Mapping::Raw),
243                other => Err(syn::Error::new(
244                    word.span(),
245                    format!(
246                        "unknown #[hl7(...)] option {other:?}; expected a path such as \
247                         #[hl7(\"PID-5.1\")], or `nested`, or `raw`"
248                    ),
249                )),
250            }
251        })?;
252    }
253    Ok(found)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use quote::ToTokens;
260
261    fn resolved(attributes: &str) -> String {
262        let input: DeriveInput = syn::parse_str(&format!("{attributes} struct S {{ f: u32 }}"))
263            .expect("test input parses");
264        crate_path(&input)
265            .expect("crate path resolves")
266            .to_token_stream()
267            .to_string()
268            .replace(' ', "")
269    }
270
271    #[test]
272    fn defaults_to_the_absolute_crate_name() {
273        assert_eq!(resolved(""), "::hl7_2");
274    }
275
276    #[test]
277    fn a_bare_path_is_taken_as_written() {
278        assert_eq!(resolved("#[hl7(crate = hl7)]"), "hl7");
279        assert_eq!(
280            resolved("#[hl7(crate = ::vendor::hl7_2)]"),
281            "::vendor::hl7_2"
282        );
283        assert_eq!(resolved("#[hl7(crate = crate::renamed)]"), "crate::renamed");
284    }
285
286    #[test]
287    fn a_quoted_path_is_the_same_thing() {
288        assert_eq!(
289            resolved(r#"#[hl7(crate = "::vendor::hl7_2")]"#),
290            "::vendor::hl7_2"
291        );
292    }
293
294    #[test]
295    fn a_struct_attribute_that_is_not_crate_says_so() {
296        let input: DeriveInput =
297            syn::parse_str("#[hl7(\"PID-5\")] struct S { f: u32 }").expect("test input parses");
298        let Err(error) = crate_path(&input) else {
299            panic!("a path literal is not a struct option");
300        };
301        assert!(error.to_string().contains("belong on fields"), "{error}");
302    }
303}