Skip to main content

rd_helpdb/
topics.rs

1//! Typed access to `Meta/Rd.rds`, independent of the compiled help database.
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    path::Path,
6};
7
8use rd_rds::{RObject, RValue, file::ReadOptions};
9
10use crate::{Error, rds::map_file_error, util::rstr_to_string};
11
12/// A topic name, title, or source file name in help-topic metadata.
13///
14/// Missing columns, R `NA`, and malformed values remain distinct. An invalid
15/// optional field does not prevent reading the other fields or rows.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum HelpTopicText {
19    /// The column is absent.
20    Missing,
21    /// The stored character value is R `NA`.
22    Na,
23    /// The column has the wrong type, this row is absent, the column exceeds
24    /// the row count, or the string cannot be decoded. The explanation is
25    /// diagnostic text, not a stable machine-readable format.
26    Invalid(String),
27    /// A decoded value, including an empty string.
28    Text(String),
29}
30
31impl HelpTopicText {
32    /// Returns usable text, collapsing missing, NA, and invalid fields to `None`.
33    pub fn as_str(&self) -> Option<&str> {
34        match self {
35            Self::Text(value) => Some(value),
36            _ => None,
37        }
38    }
39}
40
41/// One row of `Meta/Rd.rds`, in stored order.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct HelpTopicEntry {
45    /// Aliases in stored order, including duplicates and R `NA` as `None`.
46    pub aliases: Vec<Option<String>>,
47    /// The optional `Name` value, preserved exactly as decoded.
48    pub name: HelpTopicText,
49    /// The optional `Title` value. No whitespace normalization is applied.
50    pub title: HelpTopicText,
51    /// The optional `File` value, preserved exactly as decoded.
52    pub file: HelpTopicText,
53}
54
55impl HelpTopicEntry {
56    /// Derives the help-database key from the basename of [`Self::file`],
57    /// removing one trailing `.Rd` or `.rd` suffix as R does. Trailing path
58    /// separators are ignored. The stored file value is unchanged, and an
59    /// empty basename yields an empty key. This does not establish that a
60    /// corresponding topic exists in the help database.
61    pub fn topic_key(&self) -> Option<&str> {
62        self.file.as_str().map(|file| {
63            let basename = file
64                .trim_end_matches(std::path::is_separator)
65                .rsplit(std::path::is_separator)
66                .next()
67                .unwrap_or(file);
68            basename
69                .strip_suffix(".Rd")
70                .or_else(|| basename.strip_suffix(".rd"))
71                .unwrap_or(basename)
72        })
73    }
74}
75
76/// An owned view of an installed package's `Meta/Rd.rds` topic metadata.
77///
78/// This source is separate from `help/aliases.rds`. Rows and alias groups are
79/// preserved, and [`Self::find_alias`] selects the **first** matching row.
80/// [`crate::PackageHelpDb::resolve_alias`] instead uses the **last** occurrence
81/// in `aliases.rds`. Neither lookup consults or overrides the other source.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct HelpTopicIndex {
84    entries: Vec<HelpTopicEntry>,
85    alias_lookup: HashMap<String, usize>,
86}
87
88impl HelpTopicIndex {
89    /// Reads `Meta/Rd.rds` below an explicitly named installed-package directory.
90    ///
91    /// Returns `Ok(None)` only for a missing path, `Ok(Some(index))` for
92    /// present metadata (including a valid empty index), and an error for
93    /// other I/O failures, decoding failures, or an invalid required schema.
94    /// No help `.rdx`, `.rdb`, or `aliases.rds` file is opened.
95    pub fn read_installed(package_dir: impl AsRef<Path>) -> Result<Option<Self>, Error> {
96        Self::read_installed_with_options(package_dir, &ReadOptions::default())
97    }
98
99    /// Reads installed metadata with explicit file, decompression, and decoder
100    /// bounds and encoding policy supplied to [`rd_rds::file`].
101    pub fn read_installed_with_options(
102        package_dir: impl AsRef<Path>,
103        options: &ReadOptions,
104    ) -> Result<Option<Self>, Error> {
105        let path = package_dir.as_ref().join("Meta/Rd.rds");
106        match rd_rds::file::read_with_options(&path, options) {
107            Ok(root) => Self::from_object(&root).map(Some),
108            Err(rd_rds::file::ReadError::Io { source, .. })
109                if source.kind() == std::io::ErrorKind::NotFound =>
110            {
111                Ok(None)
112            }
113            Err(error) => Err(map_file_error(&path, error)),
114        }
115    }
116
117    /// Validates and copies a decoded topic metadata data frame.
118    ///
119    /// The root must be a named list with class `data.frame`, unique non-NA
120    /// column names, and `row.names` agreeing with the required `Aliases`
121    /// list column. Each alias cell must be a character vector; NA aliases
122    /// are retained but never match a lookup. Invalid alias strings are
123    /// errors. Additional columns are ignored.
124    ///
125    /// `Name`, `Title`, and `File` are optional character columns. Missing columns
126    /// and NA values are retained explicitly. Wrong column types, excess
127    /// values, missing row values in short columns, and undecodable strings
128    /// become [`HelpTopicText::Invalid`], preserving usable neighboring
129    /// fields and rows. RDS decoding errors remain fatal before this view
130    /// can recover individual fields.
131    pub fn from_object(root: &RObject) -> Result<Self, Error> {
132        let RValue::List(columns) = root.value() else {
133            return Err(malformed("root is not a list"));
134        };
135        if !root.class().is_some_and(|classes| {
136            classes
137                .iter()
138                .any(|class| matches!(class.as_str(), Some(Ok(value)) if value == "data.frame"))
139        }) {
140            return Err(malformed("class does not include \"data.frame\""));
141        }
142        let names = root
143            .names()
144            .ok_or_else(|| malformed("missing character names attribute"))?;
145        if names.len() != columns.len() {
146            return Err(malformed("column names and columns have different lengths"));
147        }
148        let mut positions = BTreeMap::new();
149        for (position, name) in names.iter().enumerate() {
150            let name = rstr_to_string(name).map_err(|error| {
151                malformed(format!("invalid column name at {position}: {error}"))
152            })?;
153            if positions.insert(name.clone(), position).is_some() {
154                return Err(malformed(format!("duplicate column name {name:?}")));
155            }
156        }
157        let column = |name: &str| positions.get(name).map(|&index| &columns[index]);
158        let aliases =
159            column("Aliases").ok_or_else(|| malformed("missing required column \"Aliases\""))?;
160        let RValue::List(aliases) = aliases.value() else {
161            return Err(malformed("column \"Aliases\" is not a list"));
162        };
163        let nrow = aliases.len();
164        validate_row_count(root, nrow)?;
165        let mut entries = Vec::with_capacity(nrow);
166        for (row, cell) in aliases.iter().enumerate() {
167            let RValue::Character(values) = cell.value() else {
168                return Err(malformed(format!(
169                    "Aliases at row {row} is not a character vector"
170                )));
171            };
172            let aliases = values
173                .iter()
174                .enumerate()
175                .map(|(element, value)| {
176                    value
177                        .as_str()
178                        .map(|result| result.map(|text| text.into_owned()))
179                        .transpose()
180                        .map_err(|error| {
181                            malformed(format!(
182                                "invalid Aliases at row {row}, element {element}: {error}"
183                            ))
184                        })
185                })
186                .collect::<Result<_, _>>()?;
187            entries.push(HelpTopicEntry {
188                aliases,
189                name: optional_text(column("Name"), "Name", row, nrow),
190                title: optional_text(column("Title"), "Title", row, nrow),
191                file: optional_text(column("File"), "File", row, nrow),
192            });
193        }
194        let mut alias_lookup = HashMap::new();
195        for (row, entry) in entries.iter().enumerate() {
196            for alias in entry.aliases.iter().flatten() {
197                alias_lookup.entry(alias.clone()).or_insert(row);
198            }
199        }
200        Ok(Self {
201            entries,
202            alias_lookup,
203        })
204    }
205
206    /// Iterates over entries in stored row order.
207    pub fn entries(&self) -> impl ExactSizeIterator<Item = &HelpTopicEntry> {
208        self.entries.iter()
209    }
210
211    /// Finds the first row containing this alias, ignoring NA alias values.
212    /// Lookup is case-sensitive and does not normalize text. A first match
213    /// with unavailable optional fields still wins over later matches.
214    pub fn find_alias(&self, alias: &str) -> Option<&HelpTopicEntry> {
215        self.alias_lookup.get(alias).map(|&row| &self.entries[row])
216    }
217
218    /// Returns the number of stored rows, including rows with no aliases.
219    pub fn len(&self) -> usize {
220        self.entries.len()
221    }
222
223    /// Returns whether the metadata contains no rows.
224    pub fn is_empty(&self) -> bool {
225        self.entries.is_empty()
226    }
227}
228
229impl TryFrom<&RObject> for HelpTopicIndex {
230    type Error = Error;
231
232    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
233        Self::from_object(value)
234    }
235}
236
237fn optional_text(column: Option<&RObject>, name: &str, row: usize, nrow: usize) -> HelpTopicText {
238    let Some(column) = column else {
239        return HelpTopicText::Missing;
240    };
241    let RValue::Character(values) = column.value() else {
242        return HelpTopicText::Invalid(format!("{name} is not a character vector"));
243    };
244    if values.len() > nrow {
245        return HelpTopicText::Invalid(format!(
246            "{name} has {} values for {nrow} rows",
247            values.len()
248        ));
249    }
250    let Some(value) = values.get(row) else {
251        return HelpTopicText::Invalid(format!("{name} has no value at row {row}"));
252    };
253    match value.as_str() {
254        None => HelpTopicText::Na,
255        Some(Ok(value)) => HelpTopicText::Text(value.into_owned()),
256        Some(Err(error)) => HelpTopicText::Invalid(format!("{name} at row {row}: {error}")),
257    }
258}
259
260fn validate_row_count(root: &RObject, nrow: usize) -> Result<(), Error> {
261    let row_names = root
262        .attributes()
263        .get("row.names")
264        .ok_or_else(|| malformed("missing row.names attribute"))?;
265    let count = match row_names.value() {
266        // R's compact row names encode the count in the second element.
267        RValue::Integer(values) if values.len() == 2 && values[0].is_none() => values[1]
268            .ok_or_else(|| malformed("NA row count in row.names"))?
269            .unsigned_abs()
270            as usize,
271        RValue::Integer(values) => values.len(),
272        RValue::Character(values) => values.len(),
273        _ => return Err(malformed("row.names is not an integer or character vector")),
274    };
275    if count != nrow {
276        return Err(malformed(format!(
277            "row.names implies {count} rows but Aliases has {nrow}"
278        )));
279    }
280    Ok(())
281}
282
283fn malformed(message: impl Into<String>) -> Error {
284    Error::MalformedIndex(format!("invalid Meta/Rd.rds: {}", message.into()))
285}