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::from_r::{SexpError, TryFromSexp, charsxp_to_str};
31use crate::gc_protect::ProtectScope;
32use crate::{R_xlen_t, SEXP, SEXPTYPE, SexpExt};
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 scope = ProtectScope::new();
137 // Preserve the R_BlankString short-circuit: skips hash-lookup on the
138 // interned empty CHARSXP for any empty choice strings.
139 let vec = scope.alloc_strsxp(choices.len()).into_raw();
140 for (i, s) in choices.iter().enumerate() {
141 let charsxp = if s.is_empty() {
142 SEXP::blank_string()
143 } else {
144 SEXP::charsxp(s)
145 };
146 vec.set_string_elt(i as R_xlen_t, charsxp);
147 }
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 R_xlen_t;
179 if level_idx < 0 || level_idx >= levels.len() as 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 [`MatchArg`]→[`IntoRVecElement`](crate::newtype::IntoRVecElement)
249/// bridge below, which backs `IntoR for Vec<MyEnum>`.
250pub fn match_arg_vec_into_sexp<T: MatchArg>(values: Vec<T>) -> SEXP {
251 unsafe {
252 let scope = ProtectScope::new();
253 // Preserve the R_BlankString short-circuit: skips hash-lookup on the
254 // interned empty CHARSXP for any empty choice strings.
255 let vec = scope.alloc_strsxp(values.len()).into_raw();
256 for (i, v) in values.into_iter().enumerate() {
257 let s = v.to_choice();
258 let charsxp = if s.is_empty() {
259 SEXP::blank_string()
260 } else {
261 SEXP::charsxp(s)
262 };
263 vec.set_string_elt(i as R_xlen_t, charsxp);
264 }
265 vec
266 }
267}
268
269/// Bridge: every [`MatchArg`] type is an [`IntoRVecElement`](crate::newtype::IntoRVecElement),
270/// so `Vec<MyEnum>` converts to an R character vector (STRSXP) via
271/// [`match_arg_vec_into_sexp`].
272///
273/// `IntoR for Vec<T>` has a single blanket slot (see [`crate::newtype`]). Routing
274/// `MatchArg` through `IntoRVecElement` lets `#[derive(IntoR)]` newtypes share
275/// that slot — a newtype implements `IntoRVecElement` concretely in its own crate,
276/// which coexists with this bridge because a local newtype is provably not
277/// `MatchArg` (deriving both `MatchArg` and `IntoR` on one type is an E0119
278/// coherence error, by design). Stable Rust has no negative trait bounds, so a
279/// *second* `impl<T: …> IntoR for Vec<T>` blanket would conflict directly; this
280/// indirection is what avoids that.
281impl<T: MatchArg> crate::newtype::IntoRVecElement for T {
282 fn elements_into_sexp(values: Vec<Self>) -> SEXP {
283 match_arg_vec_into_sexp(values)
284 }
285}
286
287/// Extract multiple strings from an R SEXP (STRSXP) and match each against
288/// the choices of a `MatchArg` type.
289///
290/// Used by the generated C wrapper for `match_arg + several_ok` parameters
291/// (`match.arg` with `several.ok = TRUE`).
292///
293/// NULL input returns all variants (matching R's `match.arg` default with `several.ok = TRUE`).
294///
295/// Note: factors (INTSXP) are not handled here — the R wrapper coerces factors
296/// to character before the `.Call()` boundary.
297pub fn match_arg_vec_from_sexp<T: MatchArg>(sexp: SEXP) -> Result<Vec<T>, MatchArgError> {
298 // NIL → all choices (match.arg default with several.ok = TRUE).
299 if sexp.type_of() == SEXPTYPE::NILSXP {
300 return <T as MatchArg>::CHOICES
301 .iter()
302 .map(|c| match_choice::<T>(c))
303 .collect();
304 }
305
306 // STRSXP path — delegate type check + per-element NA handling
307 // to Vec<Option<&str>>. `None` means NA_character_ (IsNa error).
308 <Vec<Option<&'static str>> as TryFromSexp>::try_from_sexp(sexp)
309 .map_err(sexp_err_to_match_arg_err)?
310 .into_iter()
311 .map(|opt| opt.ok_or(MatchArgError::IsNa).and_then(match_choice::<T>))
312 .collect()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::escape_r_string;
318
319 #[test]
320 fn escapes_backslash_and_quote() {
321 assert_eq!(escape_r_string(r#"say "hi""#), r#"say \"hi\""#);
322 assert_eq!(escape_r_string(r"c:\path"), r"c:\\path");
323 }
324
325 #[test]
326 fn escapes_control_characters() {
327 assert_eq!(escape_r_string("line1\nline2"), r"line1\nline2");
328 assert_eq!(escape_r_string("tab\there"), r"tab\there");
329 assert_eq!(escape_r_string("cr\rlf"), r"cr\rlf");
330 }
331
332 #[test]
333 fn passes_through_plain_strings() {
334 assert_eq!(escape_r_string("Fast"), "Fast");
335 assert_eq!(escape_r_string("it's"), "it's");
336 assert_eq!(escape_r_string(""), "");
337 }
338}