Skip to main content

rd_rds/
matrix.rs

1//! Validated, owned views of R character matrices.
2//!
3//! [`CharacterMatrix`] accepts the ordinary R matrix shape: a character
4//! vector with a required two-element integer `dim` attribute and an optional
5//! `dimnames` attribute. R stores matrix cells in column-major order; the view
6//! copies them into row-major order for Rust callers.
7
8use thiserror::Error;
9
10use crate::{RObject, RStr, RValue};
11
12/// A construction error from [`CharacterMatrix`].
13#[derive(Debug, Error, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum ViewError {
16    #[error("missing value at {path}")]
17    Missing { path: String },
18    #[error("unexpected type at {path}: expected {expected}, got {actual}")]
19    UnexpectedType {
20        path: String,
21        expected: &'static str,
22        actual: &'static str,
23    },
24    #[error("unexpected length at {path}: expected {expected}, got {actual}")]
25    UnexpectedLength {
26        path: String,
27        expected: String,
28        actual: usize,
29    },
30    #[error("invalid dimensions at {path}: {reason}")]
31    InvalidDimensions { path: String, reason: String },
32    #[error("invalid string encoding at {path}")]
33    InvalidStringEncoding { path: String },
34}
35
36impl ViewError {
37    /// Returns the logical location of the invalid value.
38    pub fn path(&self) -> &str {
39        match self {
40            Self::Missing { path }
41            | Self::UnexpectedType { path, .. }
42            | Self::UnexpectedLength { path, .. }
43            | Self::InvalidDimensions { path, .. }
44            | Self::InvalidStringEncoding { path } => path,
45        }
46    }
47}
48
49/// A validated, owned view of an R character matrix.
50///
51/// Matrix cells are stored in row-major order. An R `NA` cell is represented
52/// by the inner `None` returned from [`CharacterMatrix::get`], while an
53/// out-of-bounds lookup is represented by the outer `None`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CharacterMatrix {
56    nrow: usize,
57    ncol: usize,
58    cells: Vec<Option<String>>,
59    row_names: Option<Vec<Option<String>>>,
60    column_names: Option<Vec<Option<String>>>,
61}
62
63impl CharacterMatrix {
64    /// Validates and copies an R character matrix.
65    pub fn from_object(object: &RObject) -> Result<Self, ViewError> {
66        let values = match object.value() {
67            RValue::Character(values) => values,
68            value => {
69                return Err(unexpected_type(
70                    "CharacterMatrix",
71                    "character vector",
72                    kind_name(value),
73                ));
74            }
75        };
76
77        let dimensions = object
78            .attributes()
79            .get("dim")
80            .ok_or_else(|| missing("CharacterMatrix.attributes.dim"))?;
81        let dimensions = match dimensions.value() {
82            RValue::Integer(values) => values,
83            value => {
84                return Err(unexpected_type(
85                    "CharacterMatrix.attributes.dim",
86                    "integer vector",
87                    kind_name(value),
88                ));
89            }
90        };
91        if dimensions.len() != 2 {
92            return Err(unexpected_length(
93                "CharacterMatrix.attributes.dim",
94                "2",
95                dimensions.len(),
96            ));
97        }
98
99        let mut shape = [0usize; 2];
100        for (index, value) in dimensions.iter().enumerate() {
101            let Some(value) = value else {
102                return Err(invalid_dimensions(
103                    "CharacterMatrix.attributes.dim",
104                    "dimensions must not contain NA",
105                ));
106            };
107            if *value < 0 {
108                return Err(invalid_dimensions(
109                    "CharacterMatrix.attributes.dim",
110                    "dimensions must not be negative",
111                ));
112            }
113            shape[index] = *value as usize;
114        }
115
116        let element_count = shape[0].checked_mul(shape[1]).ok_or_else(|| {
117            invalid_dimensions(
118                "CharacterMatrix.attributes.dim",
119                "dimension product overflows",
120            )
121        })?;
122        if values.len() != element_count {
123            return Err(unexpected_length(
124                "CharacterMatrix",
125                &element_count.to_string(),
126                values.len(),
127            ));
128        }
129
130        let (row_names, column_names) = match object.attributes().get("dimnames") {
131            None => (None, None),
132            Some(dimnames) => {
133                let dimnames = match dimnames.value() {
134                    RValue::List(values) => values,
135                    value => {
136                        return Err(unexpected_type(
137                            "CharacterMatrix.attributes.dimnames",
138                            "list",
139                            kind_name(value),
140                        ));
141                    }
142                };
143                if dimnames.len() != 2 {
144                    return Err(unexpected_length(
145                        "CharacterMatrix.attributes.dimnames",
146                        "2",
147                        dimnames.len(),
148                    ));
149                }
150                (
151                    decode_names(&dimnames[0], shape[0], 0)?,
152                    decode_names(&dimnames[1], shape[1], 1)?,
153                )
154            }
155        };
156
157        let mut cells = Vec::with_capacity(element_count);
158        for row in 0..shape[0] {
159            for column in 0..shape[1] {
160                cells.push(decode_string(&values[row + column * shape[0]], || {
161                    format!("CharacterMatrix[row={row},column={column}]")
162                })?);
163            }
164        }
165
166        Ok(Self {
167            nrow: shape[0],
168            ncol: shape[1],
169            cells,
170            row_names,
171            column_names,
172        })
173    }
174
175    /// Returns the number of rows.
176    pub fn nrow(&self) -> usize {
177        self.nrow
178    }
179
180    /// Returns the number of columns.
181    pub fn ncol(&self) -> usize {
182        self.ncol
183    }
184
185    /// Returns a cell, distinguishing an out-of-bounds lookup from an R `NA`.
186    pub fn get(&self, row: usize, column: usize) -> Option<Option<&str>> {
187        if row >= self.nrow || column >= self.ncol {
188            return None;
189        }
190        Some(self.cells[row * self.ncol + column].as_deref())
191    }
192
193    /// Returns a row name, or `None` if it is absent, `NA`, or out of bounds.
194    pub fn row_name(&self, row: usize) -> Option<&str> {
195        self.row_names
196            .as_ref()?
197            .get(row)?
198            .as_ref()
199            .map(String::as_str)
200    }
201
202    /// Returns a column name, or `None` if it is absent, `NA`, or out of bounds.
203    pub fn column_name(&self, column: usize) -> Option<&str> {
204        self.column_names
205            .as_ref()?
206            .get(column)?
207            .as_ref()
208            .map(String::as_str)
209    }
210}
211
212impl TryFrom<&RObject> for CharacterMatrix {
213    type Error = ViewError;
214
215    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
216        Self::from_object(value)
217    }
218}
219
220fn decode_names(
221    object: &RObject,
222    expected: usize,
223    axis: usize,
224) -> Result<Option<Vec<Option<String>>>, ViewError> {
225    let values = match object.value() {
226        RValue::Null => return Ok(None),
227        RValue::Character(values) => values,
228        value => {
229            return Err(unexpected_type(
230                &format!("CharacterMatrix.attributes.dimnames[{axis}]"),
231                "NULL or character vector",
232                kind_name(value),
233            ));
234        }
235    };
236    if values.len() != expected {
237        return Err(unexpected_length(
238            &format!("CharacterMatrix.attributes.dimnames[{axis}]"),
239            &expected.to_string(),
240            values.len(),
241        ));
242    }
243
244    values
245        .iter()
246        .enumerate()
247        .map(|(index, value)| {
248            decode_string(value, || {
249                format!("CharacterMatrix.attributes.dimnames[{axis}][{index}]")
250            })
251        })
252        .collect::<Result<Vec<_>, _>>()
253        .map(Some)
254}
255
256/// Decodes a single matrix or dimnames cell. `path` is only evaluated on the
257/// (rare) invalid-encoding error path, so callers can pass a closure that
258/// builds the path string instead of formatting it eagerly for every cell.
259fn decode_string(value: &RStr, path: impl FnOnce() -> String) -> Result<Option<String>, ViewError> {
260    match value.as_str() {
261        None => Ok(None),
262        Some(Ok(value)) => Ok(Some(value.into_owned())),
263        Some(Err(_)) => Err(ViewError::InvalidStringEncoding { path: path() }),
264    }
265}
266
267fn missing(path: &str) -> ViewError {
268    ViewError::Missing {
269        path: path.to_owned(),
270    }
271}
272
273fn unexpected_type(path: &str, expected: &'static str, actual: &'static str) -> ViewError {
274    ViewError::UnexpectedType {
275        path: path.to_owned(),
276        expected,
277        actual,
278    }
279}
280
281fn unexpected_length(path: &str, expected: &str, actual: usize) -> ViewError {
282    ViewError::UnexpectedLength {
283        path: path.to_owned(),
284        expected: expected.to_owned(),
285        actual,
286    }
287}
288
289fn invalid_dimensions(path: &str, reason: &str) -> ViewError {
290    ViewError::InvalidDimensions {
291        path: path.to_owned(),
292        reason: reason.to_owned(),
293    }
294}
295
296fn kind_name(value: &RValue) -> &'static str {
297    match value {
298        RValue::Null => "NULL",
299        RValue::Logical(_) => "logical vector",
300        RValue::Integer(_) => "integer vector",
301        RValue::Real(_) => "real vector",
302        RValue::Character(_) => "character vector",
303        RValue::List(_) => "list",
304        RValue::Symbol(_) => "symbol",
305        RValue::Persisted(_) => "persisted reference",
306        RValue::Environment(_) => "environment",
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::{Attribute, Attributes, REncoding, Symbol};
314
315    fn strings(values: &[Option<&str>]) -> Vec<RStr> {
316        values
317            .iter()
318            .map(|value| match value {
319                Some(value) => RStr::new(value.as_bytes(), REncoding::Utf8, None),
320                None => RStr::Na,
321            })
322            .collect()
323    }
324
325    fn character(values: &[Option<&str>]) -> RObject {
326        RObject::from_parts(RValue::Character(strings(values)), Attributes::default())
327    }
328
329    fn null() -> RObject {
330        RObject::from_parts(RValue::Null, Attributes::default())
331    }
332
333    fn matrix(
334        dim: Option<Vec<Option<i32>>>,
335        dimnames: Option<Vec<RObject>>,
336        values: &[Option<&str>],
337    ) -> RObject {
338        let mut attributes = Vec::new();
339        if let Some(dim) = dim {
340            attributes.push(Attribute::new(
341                Symbol::new("dim"),
342                RObject::from_parts(RValue::Integer(dim), Attributes::default()),
343            ));
344        }
345        if let Some(dimnames) = dimnames {
346            attributes.push(Attribute::new(
347                Symbol::new("dimnames"),
348                RObject::from_parts(RValue::List(dimnames), Attributes::default()),
349            ));
350        }
351        RObject::from_parts(
352            RValue::Character(strings(values)),
353            Attributes::new(attributes),
354        )
355    }
356
357    #[test]
358    fn requires_valid_dimensions_and_matching_cell_count() {
359        let missing_dim = matrix(None, None, &[]);
360        assert!(matches!(
361            CharacterMatrix::from_object(&missing_dim),
362            Err(ViewError::Missing { ref path }) if path == "CharacterMatrix.attributes.dim"
363        ));
364
365        let short_dim = matrix(Some(vec![Some(1)]), None, &[Some("x")]);
366        assert!(matches!(
367            CharacterMatrix::from_object(&short_dim),
368            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dim"
369        ));
370
371        let negative_dim = matrix(Some(vec![Some(-1), Some(0)]), None, &[]);
372        assert!(matches!(
373            CharacterMatrix::from_object(&negative_dim),
374            Err(ViewError::InvalidDimensions { ref path, .. }) if path == "CharacterMatrix.attributes.dim"
375        ));
376
377        let wrong_cell_count = matrix(Some(vec![Some(2), Some(2)]), None, &[Some("x")]);
378        assert!(matches!(
379            CharacterMatrix::from_object(&wrong_cell_count),
380            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix"
381        ));
382    }
383
384    #[test]
385    fn converts_to_row_major_and_preserves_na_and_empty_strings() {
386        let object = matrix(
387            Some(vec![Some(2), Some(2)]),
388            None,
389            &[Some("a"), None, Some(""), Some("d")],
390        );
391        let matrix = CharacterMatrix::try_from(&object).expect("valid matrix");
392
393        assert_eq!(matrix.nrow(), 2);
394        assert_eq!(matrix.ncol(), 2);
395        assert_eq!(matrix.get(0, 0), Some(Some("a")));
396        assert_eq!(matrix.get(0, 1), Some(Some("")));
397        assert_eq!(matrix.get(1, 0), Some(None));
398        assert_eq!(matrix.get(1, 1), Some(Some("d")));
399        assert_eq!(matrix.get(2, 0), None);
400        assert_eq!(matrix.get(0, 2), None);
401        assert_eq!(matrix.row_name(0), None);
402        assert_eq!(matrix.column_name(0), None);
403    }
404
405    #[test]
406    fn accepts_optional_well_shaped_dimnames() {
407        let object = matrix(
408            Some(vec![Some(2), Some(1)]),
409            Some(vec![character(&[Some("one"), None]), null()]),
410            &[Some("a"), Some("b")],
411        );
412        let matrix = CharacterMatrix::from_object(&object).expect("valid dimnames");
413
414        assert_eq!(matrix.row_name(0), Some("one"));
415        assert_eq!(matrix.row_name(1), None);
416        assert_eq!(matrix.row_name(2), None);
417        assert_eq!(matrix.column_name(0), None);
418    }
419
420    #[test]
421    fn rejects_malformed_dimnames_shapes() {
422        let short_list = matrix(
423            Some(vec![Some(1), Some(1)]),
424            Some(vec![null()]),
425            &[Some("x")],
426        );
427        assert!(matches!(
428            CharacterMatrix::from_object(&short_list),
429            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dimnames"
430        ));
431
432        let wrong_axis_length = matrix(
433            Some(vec![Some(1), Some(1)]),
434            Some(vec![character(&[Some("one"), Some("two")]), null()]),
435            &[Some("x")],
436        );
437        assert!(matches!(
438            CharacterMatrix::from_object(&wrong_axis_length),
439            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dimnames[0]"
440        ));
441    }
442}