Skip to main content

miniextendr_api/
r_coerce.rs

1//! Traits for R's `as.<class>()` coercion functions.
2//!
3//! This module provides traits that enable Rust types wrapped in `ExternalPtr<T>`
4//! to define how they convert to R base types. When used with the `#[miniextendr(as = "...")]`
5//! attribute, these generate proper S3 method wrappers for R's coercion generics.
6//!
7//! # Tradeoff
8//!
9//! These traits are about exposing user-controlled conversions to R callers
10//! via S3 method dispatch — they're **not** the inbound/outbound conversion
11//! path for `#[miniextendr]` arguments and return values. For that, see
12//! [`crate::from_r::TryFromSexp`] / [`crate::into_r::IntoR`] (and
13//! [`crate::coerce::Coerce`] / [`crate::strict`] for the lax/strict pairs).
14//! Failure mode of confusing the two: an `RCoerceList` impl will not satisfy a
15//! `fn foo(_: MyType)` argument coming from R — that needs `TryFromSexp`.
16//!
17//! # Supported Conversions
18//!
19//! | R Generic | Rust Trait | Method |
20//! |-----------|------------|--------|
21//! | `as.data.frame` | [`RCoerceDataFrame`] | `as_data_frame(&self)` |
22//! | `as.list` | [`RCoerceList`] | `as_list(&self)` |
23//! | `as.character` | [`RCoerceCharacter`] | `as_character(&self)` |
24//! | `as.numeric` / `as.double` | [`RCoerceNumeric`] | `as_numeric(&self)` |
25//! | `as.integer` | [`RCoerceInteger`] | `as_integer(&self)` |
26//! | `as.logical` | [`RCoerceLogical`] | `as_logical(&self)` |
27//! | `as.matrix` | [`RCoerceMatrix`] | `as_matrix(&self)` |
28//! | `as.vector` | [`RCoerceVector`] | `as_vector(&self)` |
29//! | `as.factor` | [`RCoerceFactor`] | `as_factor(&self)` |
30//! | `as.Date` | [`RCoerceDate`] | `as_date(&self)` |
31//! | `as.POSIXct` | [`RCoercePOSIXct`] | `as_posixct(&self)` |
32//! | `as.complex` | [`RCoerceComplex`] | `as_complex(&self)` |
33//! | `as.raw` | [`RCoerceRaw`] | `as_raw(&self)` |
34//! | `as.environment` | [`RCoerceEnvironment`] | `as_environment(&self)` |
35//! | `as.function` | [`RCoerceFunction`] | `as_function(&self)` |
36//!
37//! # Usage with `#[miniextendr]`
38//!
39//! Use `#[miniextendr(as = "...")]` on impl methods to generate S3 method wrappers:
40//!
41//! ```ignore
42//! use miniextendr_api::{miniextendr, ExternalPtr, List};
43//! use miniextendr_api::r_coerce::RCoerceError;
44//!
45//! pub struct MyData {
46//!     names: Vec<String>,
47//!     values: Vec<f64>,
48//! }
49//!
50//! #[miniextendr(s3)]
51//! impl MyData {
52//!     pub fn new(names: Vec<String>, values: Vec<f64>) -> Self {
53//!         Self { names, values }
54//!     }
55//!
56//!     /// Convert to data.frame
57//!     #[miniextendr(as = "data.frame")]
58//!     pub fn as_data_frame(&self) -> Result<List, RCoerceError> {
59//!         if self.names.len() != self.values.len() {
60//!             return Err(RCoerceError::InvalidData {
61//!                 message: "names and values must have same length".into(),
62//!             });
63//!         }
64//!         Ok(List::from_pairs(vec![
65//!             ("name", self.names.clone()),
66//!             ("value", self.values.clone()),
67//!         ])
68//!         .set_class_str(&["data.frame"])
69//!         .set_row_names_int(self.names.len()))
70//!     }
71//!
72//!     /// Convert to character representation
73//!     #[miniextendr(as = "character")]
74//!     pub fn as_character(&self) -> Result<String, RCoerceError> {
75//!         Ok(format!("MyData({} items)", self.values.len()))
76//!     }
77//! }
78//! ```
79//!
80//! This generates R S3 methods:
81//!
82//! ```r
83//! # Generated automatically:
84//! as.data.frame.MyData <- function(x, ...) {
85//!     .Call(C_MyData__as_data_frame, .call = match.call(), x)
86//! }
87//!
88//! as.character.MyData <- function(x, ...) {
89//!     .Call(C_MyData__as_character, .call = match.call(), x)
90//! }
91//! ```
92
93use std::fmt;
94
95// region: Error Types
96
97/// Error type for `as.<class>()` coercion failures.
98///
99/// This error type provides structured information about why a coercion failed,
100/// allowing for meaningful error messages in R.
101#[derive(Debug, Clone)]
102pub enum RCoerceError {
103    /// The conversion is not supported for this type combination.
104    ///
105    /// Use this when a type fundamentally cannot be converted to the target type
106    /// (e.g., trying to convert a non-numeric type to numeric).
107    NotSupported {
108        /// The source type name
109        from: &'static str,
110        /// The target type name
111        to: &'static str,
112    },
113
114    /// The conversion failed due to invalid or malformed data.
115    ///
116    /// Use this when the data itself prevents conversion (e.g., mismatched
117    /// lengths for data.frame columns, invalid format strings).
118    InvalidData {
119        /// Description of what's invalid
120        message: String,
121    },
122
123    /// The conversion would result in unacceptable precision loss.
124    ///
125    /// Use this when numeric conversion would truncate or lose significant
126    /// digits beyond acceptable limits.
127    PrecisionLoss {
128        /// Description of the precision loss
129        message: String,
130    },
131
132    /// A custom error message.
133    ///
134    /// Use this for domain-specific errors that don't fit the other categories.
135    Custom(String),
136}
137
138impl fmt::Display for RCoerceError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::NotSupported { from, to } => {
142                write!(f, "cannot coerce {} to {}", from, to)
143            }
144            Self::InvalidData { message } => {
145                write!(f, "invalid data: {}", message)
146            }
147            Self::PrecisionLoss { message } => {
148                write!(f, "precision loss: {}", message)
149            }
150            Self::Custom(msg) => write!(f, "{}", msg),
151        }
152    }
153}
154
155impl std::error::Error for RCoerceError {}
156
157// Implement From<String> for convenient error creation
158impl From<String> for RCoerceError {
159    fn from(s: String) -> Self {
160        RCoerceError::Custom(s)
161    }
162}
163
164impl From<&str> for RCoerceError {
165    fn from(s: &str) -> Self {
166        RCoerceError::Custom(s.to_string())
167    }
168}
169// endregion
170
171// region: Coercion Traits
172
173/// Trait for types that can be coerced to `data.frame` via `as.data.frame()`.
174///
175/// # Example
176///
177/// ```ignore
178/// use miniextendr_api::r_coerce::{RCoerceDataFrame, RCoerceError};
179/// use miniextendr_api::List;
180///
181/// impl RCoerceDataFrame for MyStruct {
182///     fn as_data_frame(&self) -> Result<List, RCoerceError> {
183///         Ok(List::from_pairs(vec![
184///             ("col1", self.field1.clone()),
185///             ("col2", self.field2.clone()),
186///         ])
187///         .set_class_str(&["data.frame"])
188///         .set_row_names_int(self.field1.len()))
189///     }
190/// }
191/// ```
192pub trait RCoerceDataFrame {
193    /// Convert to an R data.frame.
194    ///
195    /// The returned List should have:
196    /// - Named columns of equal length
197    /// - Class attribute set to "data.frame"
198    /// - row.names attribute set appropriately
199    fn as_data_frame(&self) -> Result<crate::List, RCoerceError>;
200}
201
202/// Trait for types that can be coerced to `list` via `as.list()`.
203///
204/// # Example
205///
206/// ```ignore
207/// impl RCoerceList for MyStruct {
208///     fn as_list(&self) -> Result<List, RCoerceError> {
209///         Ok(List::from_pairs(vec![
210///             ("field1", self.field1.clone()),
211///             ("field2", self.field2.clone()),
212///         ]))
213///     }
214/// }
215/// ```
216pub trait RCoerceList {
217    /// Convert to an R list.
218    fn as_list(&self) -> Result<crate::List, RCoerceError>;
219}
220
221/// Trait for types that can be coerced to `character` via `as.character()`.
222///
223/// This typically produces a string representation of the object.
224/// For single values, return a single-element vector; for collections,
225/// return a vector with one element per item.
226pub trait RCoerceCharacter {
227    /// Convert to an R character vector.
228    fn as_character(&self) -> Result<crate::SEXP, RCoerceError>;
229}
230
231/// Trait for types that can be coerced to `numeric`/`double` via `as.numeric()`.
232///
233/// The result should be an R numeric vector (REALSXP).
234pub trait RCoerceNumeric {
235    /// Convert to an R numeric vector.
236    fn as_numeric(&self) -> Result<crate::SEXP, RCoerceError>;
237}
238
239/// Trait for types that can be coerced to `integer` via `as.integer()`.
240///
241/// The result should be an R integer vector (INTSXP).
242pub trait RCoerceInteger {
243    /// Convert to an R integer vector.
244    fn as_integer(&self) -> Result<crate::SEXP, RCoerceError>;
245}
246
247/// Trait for types that can be coerced to `logical` via `as.logical()`.
248///
249/// The result should be an R logical vector (LGLSXP).
250pub trait RCoerceLogical {
251    /// Convert to an R logical vector.
252    fn as_logical(&self) -> Result<crate::SEXP, RCoerceError>;
253}
254
255/// Trait for types that can be coerced to `matrix` via `as.matrix()`.
256///
257/// The result should be an R matrix with appropriate dimensions.
258pub trait RCoerceMatrix {
259    /// Convert to an R matrix.
260    fn as_matrix(&self) -> Result<crate::SEXP, RCoerceError>;
261}
262
263/// Trait for types that can be coerced to a generic `vector` via `as.vector()`.
264///
265/// This is the most general vector coercion, typically stripping attributes.
266pub trait RCoerceVector {
267    /// Convert to an R vector.
268    fn as_vector(&self) -> Result<crate::SEXP, RCoerceError>;
269}
270
271/// Trait for types that can be coerced to `factor` via `as.factor()`.
272///
273/// The result should be an R factor (integer vector with levels attribute).
274pub trait RCoerceFactor {
275    /// Convert to an R factor.
276    fn as_factor(&self) -> Result<crate::SEXP, RCoerceError>;
277}
278
279/// Trait for types that can be coerced to `Date` via `as.Date()`.
280///
281/// The result should be an R Date object (numeric with "Date" class).
282pub trait RCoerceDate {
283    /// Convert to an R Date.
284    fn as_date(&self) -> Result<crate::SEXP, RCoerceError>;
285}
286
287/// Trait for types that can be coerced to `POSIXct` via `as.POSIXct()`.
288///
289/// The result should be an R POSIXct object (numeric with "POSIXct", "POSIXt" class).
290pub trait RCoercePOSIXct {
291    /// Convert to an R POSIXct.
292    fn as_posixct(&self) -> Result<crate::SEXP, RCoerceError>;
293}
294
295/// Trait for types that can be coerced to `complex` via `as.complex()`.
296///
297/// The result should be an R complex vector (CPLXSXP).
298pub trait RCoerceComplex {
299    /// Convert to an R complex vector.
300    fn as_complex(&self) -> Result<crate::SEXP, RCoerceError>;
301}
302
303/// Trait for types that can be coerced to `raw` via `as.raw()`.
304///
305/// The result should be an R raw vector (RAWSXP).
306pub trait RCoerceRaw {
307    /// Convert to an R raw vector.
308    fn as_raw(&self) -> Result<crate::SEXP, RCoerceError>;
309}
310
311/// Trait for types that can be coerced to `environment` via `as.environment()`.
312///
313/// The result should be an R environment.
314pub trait RCoerceEnvironment {
315    /// Convert to an R environment.
316    fn as_environment(&self) -> Result<crate::SEXP, RCoerceError>;
317}
318
319/// Trait for types that can be coerced to `function` via `as.function()`.
320///
321/// The result should be an R function (closure).
322pub trait RCoerceFunction {
323    /// Convert to an R function.
324    fn as_function(&self) -> Result<crate::SEXP, RCoerceError>;
325}
326// endregion
327
328// region: Helper Functions
329
330/// Maps an R generic name to the corresponding trait method name.
331///
332/// This is used by the proc-macro to validate `#[miniextendr(as = "...")]` attributes.
333///
334/// # Returns
335///
336/// The Rust method name that corresponds to the R generic, or `None` if the
337/// generic is not supported.
338pub const fn r_generic_to_method(generic: &str) -> Option<&'static str> {
339    // Use a match on string slices. We can't use HashMap in const fn.
340    // This is a compile-time lookup table.
341    Some(match generic.as_bytes() {
342        b"data.frame" => "as_data_frame",
343        b"list" => "as_list",
344        b"character" => "as_character",
345        b"numeric" | b"double" => "as_numeric",
346        b"integer" => "as_integer",
347        b"logical" => "as_logical",
348        b"matrix" => "as_matrix",
349        b"vector" => "as_vector",
350        b"factor" => "as_factor",
351        b"Date" => "as_date",
352        b"POSIXct" => "as_posixct",
353        b"complex" => "as_complex",
354        b"raw" => "as_raw",
355        b"environment" => "as_environment",
356        b"function" => "as_function",
357        _ => return None,
358    })
359}
360
361/// All supported R coercion generics.
362///
363/// This list can be used to validate user input or generate documentation.
364pub const SUPPORTED_AS_GENERICS: &[&str] = &[
365    "data.frame",
366    "list",
367    "character",
368    "numeric",
369    "double",
370    "integer",
371    "logical",
372    "matrix",
373    "vector",
374    "factor",
375    "Date",
376    "POSIXct",
377    "complex",
378    "raw",
379    "environment",
380    "function",
381];
382
383/// Check if a generic name is a supported `as.<class>()` generic.
384#[inline]
385pub fn is_supported_as_generic(generic: &str) -> bool {
386    SUPPORTED_AS_GENERICS.contains(&generic)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_error_display() {
395        let err = RCoerceError::NotSupported {
396            from: "MyType",
397            to: "data.frame",
398        };
399        assert_eq!(err.to_string(), "cannot coerce MyType to data.frame");
400
401        let err = RCoerceError::InvalidData {
402            message: "column lengths differ".to_string(),
403        };
404        assert_eq!(err.to_string(), "invalid data: column lengths differ");
405
406        let err = RCoerceError::Custom("something went wrong".to_string());
407        assert_eq!(err.to_string(), "something went wrong");
408    }
409
410    #[test]
411    fn test_supported_generics() {
412        assert!(is_supported_as_generic("data.frame"));
413        assert!(is_supported_as_generic("list"));
414        assert!(is_supported_as_generic("character"));
415        assert!(is_supported_as_generic("numeric"));
416        assert!(is_supported_as_generic("double"));
417        assert!(!is_supported_as_generic("foo"));
418        assert!(!is_supported_as_generic(""));
419    }
420
421    #[test]
422    fn test_generic_to_method() {
423        assert_eq!(r_generic_to_method("data.frame"), Some("as_data_frame"));
424        assert_eq!(r_generic_to_method("list"), Some("as_list"));
425        assert_eq!(r_generic_to_method("numeric"), Some("as_numeric"));
426        assert_eq!(r_generic_to_method("double"), Some("as_numeric"));
427        assert_eq!(r_generic_to_method("foo"), None);
428    }
429}
430// endregion