Skip to main content

miniextendr_macros/
match_arg_derive.rs

1//! # `#[derive(MatchArg)]` - Enum ↔ R String with `match.arg` Support
2//!
3//! This module implements the `#[derive(MatchArg)]` macro which generates
4//! the `MatchArg` trait implementation for C-style enums, enabling automatic
5//! conversion between Rust enums and R character strings with partial matching.
6//!
7//! ## Tradeoff
8//!
9//! Without `#[derive(MatchArg)]`, an enum-shaped argument lands in the Rust
10//! function as a raw `String` that you match on by hand — invalid values
11//! surface as Rust `Err` deep in the body, with no R-side defaulting and no
12//! partial matching. `#[derive(MatchArg)]` shifts that work to the wrapper
13//! boundary: the generated R wrapper picks up `match.arg`-style **partial
14//! string matching** against the enum's variant list, fills in the first
15//! variant as the **R-visible default**, and rejects unknown values before
16//! the Rust function is even called. The variant list reaches the R side
17//! via a write-time placeholder (`MX_MATCH_ARG_CHOICES`) rather than a
18//! string baked into the proc macro, so adding a variant only requires
19//! re-running the build.
20//!
21//! ## Usage
22//!
23//! ```ignore
24//! #[derive(Copy, Clone, MatchArg)]
25//! enum Mode {
26//!     Fast,
27//!     Safe,
28//!     Debug,
29//! }
30//!
31//! // Generates impl MatchArg for Mode, TryFromSexp for Mode, IntoR for Mode.
32//! ```
33//!
34//! ## Attributes
35//!
36//! - `#[match_arg(rename = "name")]` - Rename a variant's choice string
37//! - `#[match_arg(rename_all = "snake_case")]` - Rename all variants (snake_case, kebab-case, lower, upper)
38
39use proc_macro2::TokenStream;
40use quote::quote;
41use syn::{Data, DeriveInput, Fields};
42
43use crate::naming::apply_rename_all;
44
45/// Parsed `#[match_arg(...)]` attributes from an enum or variant.
46#[derive(Default)]
47struct MatchArgAttrs {
48    /// Per-variant rename: `#[match_arg(rename = "custom")]`.
49    rename: Option<String>,
50    /// Enum-level rename-all: `#[match_arg(rename_all = "snake_case")]`.
51    /// Applied to all variants that don't have an explicit `rename`.
52    rename_all: Option<String>,
53}
54
55/// Parse `#[match_arg(...)]` attributes from a list of `syn::Attribute`.
56///
57/// Extracts `rename` and `rename_all` keys. Validates that `rename_all` uses
58/// one of the supported modes: `snake_case`, `kebab-case`, `lower`, `upper`.
59/// Returns `Err` for unknown attribute keys or unsupported `rename_all` values.
60fn parse_match_arg_attrs(attrs: &[syn::Attribute]) -> syn::Result<MatchArgAttrs> {
61    let mut result = MatchArgAttrs::default();
62
63    for attr in attrs {
64        if attr.path().is_ident("match_arg") {
65            attr.parse_nested_meta(|meta| {
66                if meta.path.is_ident("rename") {
67                    let value: syn::LitStr = meta.value()?.parse()?;
68                    result.rename = Some(value.value());
69                } else if meta.path.is_ident("rename_all") {
70                    let value: syn::LitStr = meta.value()?.parse()?;
71                    let val = value.value();
72                    match val.as_str() {
73                        "snake_case" | "kebab-case" | "lower" | "upper" => {}
74                        _ => {
75                            return Err(meta.error(
76                                "unsupported rename_all value; expected one of: \
77                                 snake_case, kebab-case, lower, upper",
78                            ));
79                        }
80                    }
81                    result.rename_all = Some(val);
82                } else {
83                    return Err(meta
84                        .error("unknown match_arg attribute; expected `rename` or `rename_all`"));
85                }
86                Ok(())
87            })?;
88        }
89    }
90
91    Ok(result)
92}
93
94/// Main entry point for `#[derive(MatchArg)]`.
95///
96/// Generates three trait implementations:
97/// - `impl MatchArg` -- provides `CHOICES` (static string slice), `from_choice`, `to_choice`
98/// - `impl TryFromSexp` -- converts R character scalar to enum variant via `match_arg_from_sexp`
99/// - `impl IntoR` -- converts enum variant to R character scalar via `to_choice().into_sexp()`
100///
101/// `impl IntoR for Vec<Self>` is provided automatically by the blanket
102/// `impl<T: MatchArg> IntoR for Vec<T>` in `miniextendr-api::match_arg`,
103/// so returning `Vec<EnumName>` from a `#[miniextendr]` function works
104/// without any extra code in the user's crate.
105///
106/// Validates:
107/// - Only enums are accepted (not structs or unions)
108/// - Generic enums are rejected
109/// - At least one variant is required
110/// - Only fieldless (C-style) variants are allowed
111/// - No duplicate choice names after renaming
112///
113/// Choice names default to variant identifiers, optionally transformed by
114/// `#[match_arg(rename_all = "...")]` or overridden per-variant with
115/// `#[match_arg(rename = "...")]`.
116pub fn derive_match_arg(input: DeriveInput) -> syn::Result<TokenStream> {
117    let name = &input.ident;
118    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
119
120    // Reject generics for v1
121    if !input.generics.params.is_empty() {
122        return Err(syn::Error::new_spanned(
123            &input.generics,
124            "#[derive(MatchArg)] does not support generic enums",
125        ));
126    }
127
128    // Parse enum-level attributes
129    let attrs = parse_match_arg_attrs(&input.attrs)?;
130
131    // Get enum variants
132    let variants = match &input.data {
133        Data::Enum(data) => &data.variants,
134        Data::Struct(_) => {
135            return Err(syn::Error::new_spanned(
136                &input,
137                "#[derive(MatchArg)] can only be applied to enums",
138            ));
139        }
140        Data::Union(_) => {
141            return Err(syn::Error::new_spanned(
142                &input,
143                "#[derive(MatchArg)] can only be applied to enums",
144            ));
145        }
146    };
147
148    if variants.is_empty() {
149        return Err(syn::Error::new_spanned(
150            &input,
151            "#[derive(MatchArg)] requires at least one variant",
152        ));
153    }
154
155    let mut choice_names = Vec::new();
156    let mut variant_idents = Vec::new();
157
158    for variant in variants {
159        // Only allow unit variants (fieldless)
160        if !matches!(variant.fields, Fields::Unit) {
161            return Err(syn::Error::new_spanned(
162                variant,
163                "#[derive(MatchArg)] only supports fieldless (C-style) enum variants",
164            ));
165        }
166
167        // Parse variant-level attributes
168        let var_attrs = parse_match_arg_attrs(&variant.attrs)?;
169
170        // Determine choice name
171        let choice_name = if let Some(r) = var_attrs.rename {
172            r
173        } else {
174            apply_rename_all(&variant.ident.to_string(), attrs.rename_all.as_deref())
175        };
176
177        choice_names.push(choice_name);
178        variant_idents.push(&variant.ident);
179    }
180
181    // Check for duplicate choice names
182    {
183        let mut seen = std::collections::HashSet::new();
184        for (i, name) in choice_names.iter().enumerate() {
185            if !seen.insert(name.as_str()) {
186                return Err(syn::Error::new_spanned(
187                    &variants.iter().nth(i).unwrap().ident,
188                    format!("duplicate choice name {:?} in #[derive(MatchArg)]", name),
189                ));
190            }
191        }
192    }
193
194    let choice_strs: Vec<&str> = choice_names.iter().map(|s| s.as_str()).collect();
195
196    Ok(quote! {
197        impl #impl_generics ::miniextendr_api::match_arg::MatchArg for #name #ty_generics #where_clause {
198            const CHOICES: &'static [&'static str] = &[#(#choice_strs),*];
199
200            fn from_choice(choice: &str) -> Option<Self> {
201                match choice {
202                    #(#choice_strs => Some(Self::#variant_idents),)*
203                    _ => None,
204                }
205            }
206
207            fn to_choice(self) -> &'static str {
208                match self {
209                    #(Self::#variant_idents => #choice_strs,)*
210                }
211            }
212        }
213
214        impl #impl_generics ::miniextendr_api::TryFromSexp for #name #ty_generics #where_clause {
215            type Error = ::miniextendr_api::SexpError;
216
217            fn try_from_sexp(sexp: ::miniextendr_api::SEXP) -> Result<Self, Self::Error> {
218                ::miniextendr_api::match_arg_from_sexp(sexp).map_err(Into::into)
219            }
220        }
221
222        impl #impl_generics ::miniextendr_api::IntoR for #name #ty_generics #where_clause {
223            type Error = std::convert::Infallible;
224
225            fn try_into_sexp(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
226                Ok(self.into_sexp())
227            }
228
229            unsafe fn try_into_sexp_unchecked(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
230                self.try_into_sexp()
231            }
232
233            fn into_sexp(self) -> ::miniextendr_api::SEXP {
234                use ::miniextendr_api::match_arg::MatchArg;
235                self.to_choice().into_sexp()
236            }
237        }
238
239
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_simple_derive() {
249        let input: DeriveInput = syn::parse_quote! {
250            enum Mode {
251                Fast,
252                Safe,
253                Debug,
254            }
255        };
256
257        let result = derive_match_arg(input).unwrap();
258        let code = result.to_string();
259        assert!(code.contains("Fast"));
260        assert!(code.contains("Safe"));
261        assert!(code.contains("Debug"));
262        assert!(code.contains("CHOICES"));
263        assert!(code.contains("from_choice"));
264        assert!(code.contains("to_choice"));
265    }
266
267    #[test]
268    fn test_rename_all() {
269        let input: DeriveInput = syn::parse_quote! {
270            #[match_arg(rename_all = "snake_case")]
271            enum Mode {
272                FastMode,
273                SafeMode,
274            }
275        };
276
277        let result = derive_match_arg(input).unwrap();
278        let code = result.to_string();
279        assert!(code.contains("fast_mode"));
280        assert!(code.contains("safe_mode"));
281    }
282
283    #[test]
284    fn test_rename_variant() {
285        let input: DeriveInput = syn::parse_quote! {
286            enum Priority {
287                #[match_arg(rename = "lo")]
288                Low,
289                #[match_arg(rename = "hi")]
290                High,
291            }
292        };
293
294        let result = derive_match_arg(input).unwrap();
295        let code = result.to_string();
296        assert!(code.contains("\"lo\""));
297        assert!(code.contains("\"hi\""));
298    }
299
300    #[test]
301    fn test_reject_fields() {
302        let input: DeriveInput = syn::parse_quote! {
303            enum Bad {
304                A(i32),
305            }
306        };
307
308        let result = derive_match_arg(input);
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn test_reject_struct() {
314        let input: DeriveInput = syn::parse_quote! {
315            struct Bad;
316        };
317
318        let result = derive_match_arg(input);
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_reject_empty() {
324        let input: DeriveInput = syn::parse_quote! {
325            enum Empty {}
326        };
327
328        let result = derive_match_arg(input);
329        assert!(result.is_err());
330    }
331
332    #[test]
333    fn test_into_r_impl_present() {
334        // The derive emits IntoR for the scalar EnumName.
335        // Vec<EnumName> IntoR is covered by the blanket impl<T: MatchArg> IntoR for Vec<T>
336        // in miniextendr-api — it is NOT emitted by the derive.
337        let input: DeriveInput = syn::parse_quote! {
338            enum Mode {
339                Fast,
340                Safe,
341                Debug,
342            }
343        };
344
345        let result = derive_match_arg(input).unwrap();
346        let code = result.to_string();
347        // Scalar IntoR impl must be present
348        assert!(code.contains("IntoR for Mode"));
349        // Vec<Mode> IntoR must NOT be emitted by the derive (covered by blanket in miniextendr-api)
350        assert!(!code.contains("IntoR for :: std :: vec :: Vec < Mode >"));
351        assert!(!code.contains("match_arg_vec_into_sexp"));
352    }
353
354    #[test]
355    fn test_duplicate_choice_names() {
356        let input: DeriveInput = syn::parse_quote! {
357            enum Dup {
358                #[match_arg(rename = "same")]
359                A,
360                #[match_arg(rename = "same")]
361                B,
362            }
363        };
364
365        let result = derive_match_arg(input);
366        assert!(result.is_err());
367    }
368}