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, NativeEncodingSource, 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(
320                    value.as_bytes(),
321                    REncoding::Utf8,
322                    NativeEncodingSource::Unknown,
323                ),
324                None => RStr::Na,
325            })
326            .collect()
327    }
328
329    fn character(values: &[Option<&str>]) -> RObject {
330        RObject::from_parts(RValue::Character(strings(values)), Attributes::default())
331    }
332
333    fn null() -> RObject {
334        RObject::from_parts(RValue::Null, Attributes::default())
335    }
336
337    fn matrix(
338        dim: Option<Vec<Option<i32>>>,
339        dimnames: Option<Vec<RObject>>,
340        values: &[Option<&str>],
341    ) -> RObject {
342        let mut attributes = Vec::new();
343        if let Some(dim) = dim {
344            attributes.push(Attribute::new(
345                Symbol::new("dim"),
346                RObject::from_parts(RValue::Integer(dim), Attributes::default()),
347            ));
348        }
349        if let Some(dimnames) = dimnames {
350            attributes.push(Attribute::new(
351                Symbol::new("dimnames"),
352                RObject::from_parts(RValue::List(dimnames), Attributes::default()),
353            ));
354        }
355        RObject::from_parts(
356            RValue::Character(strings(values)),
357            Attributes::new(attributes),
358        )
359    }
360
361    #[test]
362    fn requires_valid_dimensions_and_matching_cell_count() {
363        let missing_dim = matrix(None, None, &[]);
364        assert!(matches!(
365            CharacterMatrix::from_object(&missing_dim),
366            Err(ViewError::Missing { ref path }) if path == "CharacterMatrix.attributes.dim"
367        ));
368
369        let short_dim = matrix(Some(vec![Some(1)]), None, &[Some("x")]);
370        assert!(matches!(
371            CharacterMatrix::from_object(&short_dim),
372            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dim"
373        ));
374
375        let negative_dim = matrix(Some(vec![Some(-1), Some(0)]), None, &[]);
376        assert!(matches!(
377            CharacterMatrix::from_object(&negative_dim),
378            Err(ViewError::InvalidDimensions { ref path, .. }) if path == "CharacterMatrix.attributes.dim"
379        ));
380
381        let wrong_cell_count = matrix(Some(vec![Some(2), Some(2)]), None, &[Some("x")]);
382        assert!(matches!(
383            CharacterMatrix::from_object(&wrong_cell_count),
384            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix"
385        ));
386    }
387
388    #[test]
389    fn converts_to_row_major_and_preserves_na_and_empty_strings() {
390        let object = matrix(
391            Some(vec![Some(2), Some(2)]),
392            None,
393            &[Some("a"), None, Some(""), Some("d")],
394        );
395        let matrix = CharacterMatrix::try_from(&object).expect("valid matrix");
396
397        assert_eq!(matrix.nrow(), 2);
398        assert_eq!(matrix.ncol(), 2);
399        assert_eq!(matrix.get(0, 0), Some(Some("a")));
400        assert_eq!(matrix.get(0, 1), Some(Some("")));
401        assert_eq!(matrix.get(1, 0), Some(None));
402        assert_eq!(matrix.get(1, 1), Some(Some("d")));
403        assert_eq!(matrix.get(2, 0), None);
404        assert_eq!(matrix.get(0, 2), None);
405        assert_eq!(matrix.row_name(0), None);
406        assert_eq!(matrix.column_name(0), None);
407    }
408
409    #[test]
410    fn accepts_optional_well_shaped_dimnames() {
411        let object = matrix(
412            Some(vec![Some(2), Some(1)]),
413            Some(vec![character(&[Some("one"), None]), null()]),
414            &[Some("a"), Some("b")],
415        );
416        let matrix = CharacterMatrix::from_object(&object).expect("valid dimnames");
417
418        assert_eq!(matrix.row_name(0), Some("one"));
419        assert_eq!(matrix.row_name(1), None);
420        assert_eq!(matrix.row_name(2), None);
421        assert_eq!(matrix.column_name(0), None);
422    }
423
424    #[test]
425    fn rejects_malformed_dimnames_shapes() {
426        let short_list = matrix(
427            Some(vec![Some(1), Some(1)]),
428            Some(vec![null()]),
429            &[Some("x")],
430        );
431        assert!(matches!(
432            CharacterMatrix::from_object(&short_list),
433            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dimnames"
434        ));
435
436        let wrong_axis_length = matrix(
437            Some(vec![Some(1), Some(1)]),
438            Some(vec![character(&[Some("one"), Some("two")]), null()]),
439            &[Some("x")],
440        );
441        assert!(matches!(
442            CharacterMatrix::from_object(&wrong_axis_length),
443            Err(ViewError::UnexpectedLength { ref path, .. }) if path == "CharacterMatrix.attributes.dimnames[0]"
444        ));
445    }
446}