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