Skip to main content

miniextendr_api/
match_arg.rs

1//! `match.arg`-style enum conversion for R string arguments.
2//!
3//! This module provides the [`MatchArg`] trait for converting between Rust
4//! fieldless enums and R character strings with `match.arg` semantics
5//! (exact match or unique partial matching).
6//!
7//! # Usage
8//!
9//! ```ignore
10//! use miniextendr_api::MatchArg;
11//!
12//! #[derive(Copy, Clone, MatchArg)]
13//! #[match_arg(rename_all = "snake_case")]
14//! enum Mode {
15//!     Fast,
16//!     Safe,
17//!     Debug,
18//! }
19//!
20//! #[miniextendr]
21//! fn run(#[miniextendr(match_arg)] mode: Mode) -> String {
22//!     format!("{mode:?}")
23//! }
24//! ```
25//!
26//! The generated R wrapper uses `base::match.arg()` for validation before
27//! the main `.Call()`, giving users familiar R error messages and partial
28//! matching.
29
30use crate::ffi::{self, SEXP, SEXPTYPE, SexpExt};
31use crate::from_r::{SexpError, TryFromSexp, charsxp_to_str};
32use crate::into_r::IntoR;
33
34/// Trait for enum types that support `match.arg`-style string conversion.
35///
36/// Implementors provide a fixed set of choice strings and bidirectional
37/// conversion between enum variants and their string representations.
38///
39/// Use `#[derive(MatchArg)]` to auto-generate this implementation.
40pub trait MatchArg: Sized + Copy + 'static {
41    /// The canonical choice strings, in variant declaration order.
42    ///
43    /// The first choice is the default when the R argument is `NULL`.
44    const CHOICES: &'static [&'static str];
45
46    /// Convert a choice string to the corresponding enum variant.
47    ///
48    /// Returns `None` if the string doesn't match any choice exactly.
49    fn from_choice(choice: &str) -> Option<Self>;
50
51    /// Convert the enum variant to its canonical choice string.
52    fn to_choice(self) -> &'static str;
53}
54
55/// Error type for `MatchArg` conversion failures.
56#[derive(Debug, Clone)]
57pub enum MatchArgError {
58    /// The SEXP was not a character or factor type.
59    InvalidType(SEXPTYPE),
60    /// The input had length != 1.
61    InvalidLength(usize),
62    /// The input was NA.
63    IsNa,
64    /// No choice matched the input.
65    NoMatch {
66        /// The input string that didn't match.
67        input: String,
68        /// The valid choices.
69        choices: &'static [&'static str],
70    },
71}
72
73impl std::fmt::Display for MatchArgError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            MatchArgError::InvalidType(ty) => {
77                write!(f, "match.arg: expected character or factor, got {:?}", ty)
78            }
79            MatchArgError::InvalidLength(len) => {
80                write!(f, "match.arg: expected length 1, got {}", len)
81            }
82            MatchArgError::IsNa => write!(f, "match.arg: input is NA"),
83            MatchArgError::NoMatch { input, choices } => {
84                write!(
85                    f,
86                    "'arg' should be one of {}, got {:?}",
87                    choices
88                        .iter()
89                        .map(|c| format!("{:?}", c))
90                        .collect::<Vec<_>>()
91                        .join(", "),
92                    input,
93                )
94            }
95        }
96    }
97}
98
99impl std::error::Error for MatchArgError {}
100
101impl From<MatchArgError> for crate::from_r::SexpError {
102    fn from(e: MatchArgError) -> Self {
103        crate::from_r::SexpError::InvalidValue(e.to_string())
104    }
105}
106
107/// Escape a Rust `&str` for embedding inside an R double-quoted string literal.
108///
109/// Handles `\`, `"`, newline, carriage return, and tab — the characters R
110/// recognises as escape sequences inside `"..."`. Used when formatting
111/// `MatchArg::CHOICES` into the default of a generated R wrapper formal, so
112/// that a choice like `say "hi"` or `c:\path` cannot produce syntactically
113/// invalid R code.
114pub fn escape_r_string(s: &str) -> String {
115    let mut out = String::with_capacity(s.len());
116    for ch in s.chars() {
117        match ch {
118            '\\' => out.push_str(r"\\"),
119            '"' => out.push_str(r#"\""#),
120            '\n' => out.push_str(r"\n"),
121            '\r' => out.push_str(r"\r"),
122            '\t' => out.push_str(r"\t"),
123            c => out.push(c),
124        }
125    }
126    out
127}
128
129/// Build an R character vector (STRSXP) from the choices of a `MatchArg` type.
130///
131/// This is called by generated choices-helper C wrappers to provide the
132/// choice list to `base::match.arg()` in the R wrapper.
133pub fn choices_sexp<T: MatchArg>() -> SEXP {
134    let choices = <T as MatchArg>::CHOICES;
135    unsafe {
136        let n = choices.len();
137        let vec = ffi::Rf_allocVector(SEXPTYPE::STRSXP, n as ffi::R_xlen_t);
138        ffi::Rf_protect(vec);
139        for (i, s) in choices.iter().enumerate() {
140            let charsxp = if s.is_empty() {
141                SEXP::blank_string()
142            } else {
143                SEXP::charsxp(s)
144            };
145            vec.set_string_elt(i as ffi::R_xlen_t, charsxp);
146        }
147        ffi::Rf_unprotect(1);
148        vec
149    }
150}
151
152/// Map a `SexpError` from a string `TryFromSexp` conversion into a `MatchArgError`.
153///
154/// Only `Type` and `Length` are produced by the `&str`/`Option<&str>`/
155/// `Vec<Option<&str>>` conversions we delegate to — other variants are
156/// unreachable in this context.
157fn sexp_err_to_match_arg_err(e: SexpError) -> MatchArgError {
158    match e {
159        SexpError::Type(t) => MatchArgError::InvalidType(t.actual),
160        SexpError::Length(l) => MatchArgError::InvalidLength(l.actual),
161        other => unreachable!("unexpected SexpError from string conversion: {other}"),
162    }
163}
164
165/// Extract a single choice from a factor SEXP (INTSXP with `levels` attribute).
166fn factor_elt_to_choice<T: MatchArg>(sexp: SEXP) -> Result<T, MatchArgError> {
167    let len = sexp.len();
168    if len != 1 {
169        return Err(MatchArgError::InvalidLength(len));
170    }
171    let idx = sexp.integer_elt(0);
172    if idx == i32::MIN {
173        // NA_integer_
174        return Err(MatchArgError::IsNa);
175    }
176    let levels = sexp.get_levels();
177    // R factor indices are 1-based.
178    let level_idx = (idx - 1) as ffi::R_xlen_t;
179    if level_idx < 0 || level_idx >= levels.len() as ffi::R_xlen_t {
180        return Err(MatchArgError::NoMatch {
181            input: format!("<factor index {}>", idx),
182            choices: <T as MatchArg>::CHOICES,
183        });
184    }
185    let charsxp = levels.string_elt(level_idx);
186    // UTF-8 locale asserted at package init — charsxp_to_str is safe.
187    match_choice::<T>(unsafe { charsxp_to_str(charsxp) })
188}
189
190/// Extract a single string from an R SEXP and match it against a `MatchArg` type.
191///
192/// Used by the generated `TryFromSexp for T` implementation (single-value `match.arg`).
193pub fn match_arg_from_sexp<T: MatchArg>(sexp: SEXP) -> Result<T, MatchArgError> {
194    // NIL → default (first choice), matching `base::match.arg()`.
195    if sexp.type_of() == SEXPTYPE::NILSXP {
196        return T::from_choice(<T as MatchArg>::CHOICES[0]).ok_or_else(|| MatchArgError::NoMatch {
197            input: String::new(),
198            choices: <T as MatchArg>::CHOICES,
199        });
200    }
201
202    // Factors (INTSXP with STRSXP levels attribute): extract the level label.
203    if sexp.is_factor() {
204        return factor_elt_to_choice::<T>(sexp);
205    }
206
207    // STRSXP length-1 path — delegate type/length checks to Option<&str>.
208    // NIL was handled above, so `None` here means NA_character_.
209    let input = <Option<&'static str> as TryFromSexp>::try_from_sexp(sexp)
210        .map_err(sexp_err_to_match_arg_err)?
211        .ok_or(MatchArgError::IsNa)?;
212
213    match_choice::<T>(input)
214}
215
216/// Match a string against the choices of a `MatchArg` type (exact or partial).
217fn match_choice<T: MatchArg>(input: &str) -> Result<T, MatchArgError> {
218    // Exact match
219    if let Some(val) = T::from_choice(input) {
220        return Ok(val);
221    }
222
223    // Unique partial match (like R's match.arg)
224    let mut matches: Vec<(usize, &'static str)> = Vec::new();
225    for (i, choice) in <T as MatchArg>::CHOICES.iter().enumerate() {
226        if choice.starts_with(input) {
227            matches.push((i, choice));
228        }
229    }
230
231    match matches.len() {
232        1 => T::from_choice(matches[0].1).ok_or(MatchArgError::NoMatch {
233            input: input.to_string(),
234            choices: <T as MatchArg>::CHOICES,
235        }),
236        _ => Err(MatchArgError::NoMatch {
237            input: input.to_string(),
238            choices: <T as MatchArg>::CHOICES,
239        }),
240    }
241}
242
243/// Convert a `Vec<T: MatchArg>` to an R character vector (STRSXP).
244///
245/// Each element is written as its canonical choice string via [`MatchArg::to_choice`].
246/// Empty choice strings are stored as `R_BlankString` (parity with [`choices_sexp`]).
247///
248/// Called by the `impl IntoR for Vec<T>` block emitted by `#[derive(MatchArg)]`.
249pub fn match_arg_vec_into_sexp<T: MatchArg>(values: Vec<T>) -> SEXP {
250    unsafe {
251        let n = values.len();
252        let vec = ffi::Rf_allocVector(SEXPTYPE::STRSXP, n as ffi::R_xlen_t);
253        ffi::Rf_protect(vec);
254        for (i, v) in values.into_iter().enumerate() {
255            let s = v.to_choice();
256            let charsxp = if s.is_empty() {
257                SEXP::blank_string()
258            } else {
259                SEXP::charsxp(s)
260            };
261            vec.set_string_elt(i as ffi::R_xlen_t, charsxp);
262        }
263        ffi::Rf_unprotect(1);
264        vec
265    }
266}
267
268/// Blanket [`IntoR`] for any vector of `MatchArg` values.
269///
270/// Converts to an R character vector (STRSXP) via [`match_arg_vec_into_sexp`].
271/// This blanket impl lives in `miniextendr-api` (not in a derive macro) because
272/// `impl<T: MatchArg> IntoR for Vec<T>` in the user's crate would conflict
273/// with `impl<T: RNativeType> IntoR for Vec<T>` (E0119: coherence); stable
274/// Rust has no negative trait bounds to prove the two constraints are disjoint.
275impl<T: MatchArg> IntoR for Vec<T> {
276    type Error = std::convert::Infallible;
277
278    fn try_into_sexp(self) -> Result<SEXP, Self::Error> {
279        Ok(self.into_sexp())
280    }
281
282    unsafe fn try_into_sexp_unchecked(self) -> Result<SEXP, Self::Error> {
283        self.try_into_sexp()
284    }
285
286    fn into_sexp(self) -> SEXP {
287        match_arg_vec_into_sexp(self)
288    }
289}
290
291/// Extract multiple strings from an R SEXP (STRSXP) and match each against
292/// the choices of a `MatchArg` type.
293///
294/// Used by the generated C wrapper for `match_arg + several_ok` parameters
295/// (`match.arg` with `several.ok = TRUE`).
296///
297/// NULL input returns all variants (matching R's `match.arg` default with `several.ok = TRUE`).
298///
299/// Note: factors (INTSXP) are not handled here — the R wrapper coerces factors
300/// to character before the `.Call()` boundary.
301pub fn match_arg_vec_from_sexp<T: MatchArg>(sexp: SEXP) -> Result<Vec<T>, MatchArgError> {
302    // NIL → all choices (match.arg default with several.ok = TRUE).
303    if sexp.type_of() == SEXPTYPE::NILSXP {
304        return <T as MatchArg>::CHOICES
305            .iter()
306            .map(|c| match_choice::<T>(c))
307            .collect();
308    }
309
310    // STRSXP path — delegate type check + per-element NA handling
311    // to Vec<Option<&str>>. `None` means NA_character_ (IsNa error).
312    <Vec<Option<&'static str>> as TryFromSexp>::try_from_sexp(sexp)
313        .map_err(sexp_err_to_match_arg_err)?
314        .into_iter()
315        .map(|opt| opt.ok_or(MatchArgError::IsNa).and_then(match_choice::<T>))
316        .collect()
317}
318
319#[cfg(test)]
320mod tests {
321    use super::escape_r_string;
322
323    #[test]
324    fn escapes_backslash_and_quote() {
325        assert_eq!(escape_r_string(r#"say "hi""#), r#"say \"hi\""#);
326        assert_eq!(escape_r_string(r"c:\path"), r"c:\\path");
327    }
328
329    #[test]
330    fn escapes_control_characters() {
331        assert_eq!(escape_r_string("line1\nline2"), r"line1\nline2");
332        assert_eq!(escape_r_string("tab\there"), r"tab\there");
333        assert_eq!(escape_r_string("cr\rlf"), r"cr\rlf");
334    }
335
336    #[test]
337    fn passes_through_plain_strings() {
338        assert_eq!(escape_r_string("Fast"), "Fast");
339        assert_eq!(escape_r_string("it's"), "it's");
340        assert_eq!(escape_r_string(""), "");
341    }
342}