Skip to main content

rd_helpdb/
db.rs

1//! [`PackageHelpDb`], the main entry point for reading an installed
2//! package's compiled help database.
3
4use std::{
5    collections::HashMap,
6    fs::File,
7    io::{Read, Seek, SeekFrom},
8    path::{Path, PathBuf},
9    sync::OnceLock,
10};
11
12use rd_rds::RObject;
13
14use crate::{
15    DemoIndex, Error, VignetteIndex,
16    index::{Index, Record},
17    rds::{decode_rdb_record, read_rds_file},
18    util::rstr_to_string,
19};
20
21/// A reader over an installed R package's compiled help database:
22/// `help/<pkg>.rdx` + `help/<pkg>.rdb`, plus the sibling `help/aliases.rds`
23/// and `Meta/hsearch.rds`, `Meta/vignette.rds`, and `Meta/demo.rds` files.
24///
25/// The `.rdx` index is parsed eagerly at [`PackageHelpDb::open`] (it's
26/// small); `.rdb` records are read on demand by seeking into the `.rdb`
27/// file, since records are independent and there's no need to hold the
28/// whole file in memory. `help/aliases.rds` is parsed lazily, on the first
29/// call to [`PackageHelpDb::aliases`] or [`PackageHelpDb::resolve_alias`],
30/// and the resulting index is cached for the lifetime of this reader -- a
31/// failed parse is NOT cached, so a transient failure (a missing or
32/// momentarily corrupt file) is retried on the next call rather than
33/// poisoning every subsequent lookup. A duplicated alias resolves to its
34/// LAST occurrence, matching the last-wins semantics of R's own
35/// `list2env()`-based alias loader; [`PackageHelpDb::aliases`] still
36/// returns every entry, including duplicates, in on-disk order, since
37/// that's what oracle comparisons against R's `readRDS()` expect.
38/// `search_index()` re-reads `Meta/hsearch.rds` on every call rather than
39/// caching -- it's read infrequently relative to alias/topic lookups, and
40/// re-reading keeps the API simple and always up to date.
41#[derive(Debug)]
42pub struct PackageHelpDb {
43    package: String,
44    pkg_dir: PathBuf,
45    help_dir: PathBuf,
46    rdb_path: PathBuf,
47    index: Index,
48    /// Lazily-built `help/aliases.rds` index, cached after the first
49    /// successful parse. See the struct-level docs above for the caching
50    /// and duplicate-alias semantics.
51    alias_index: OnceLock<AliasIndex>,
52}
53
54/// A parsed `help/aliases.rds` index: the alias -> topic pairs in on-disk
55/// order (`entries`, duplicates included), plus a `lookup` map from alias
56/// to its index into `entries`.
57///
58/// `lookup` is built by inserting `entries` in file order, so a later
59/// duplicate alias overwrites an earlier one -- i.e. it always resolves to
60/// the LAST occurrence, matching R's own `list2env()`-based alias loader.
61#[derive(Debug)]
62struct AliasIndex {
63    entries: Vec<(String, String)>,
64    lookup: HashMap<String, usize>,
65}
66
67impl AliasIndex {
68    /// Builds an [`AliasIndex`] from the parsed `aliases.rds` root object,
69    /// a named character vector mapping alias -> topic.
70    fn from_root(root: &RObject) -> Result<Self, Error> {
71        let rd_rds::RValue::Character(values) = &root.value() else {
72            return Err(Error::MalformedIndex(
73                "aliases.rds root is not a character vector".into(),
74            ));
75        };
76        let names = root.names().ok_or_else(|| {
77            Error::MalformedIndex("aliases.rds is missing a names attribute".into())
78        })?;
79        if names.len() != values.len() {
80            return Err(Error::MalformedIndex(format!(
81                "aliases.rds has {} names but {} values",
82                names.len(),
83                values.len()
84            )));
85        }
86
87        let entries = names
88            .iter()
89            .zip(values.iter())
90            .map(|(alias, topic)| Ok((rstr_to_string(alias)?, rstr_to_string(topic)?)))
91            .collect::<Result<Vec<(String, String)>, Error>>()?;
92
93        // Insert in file order so a later duplicate alias overwrites an
94        // earlier one's index -- last-wins.
95        let mut lookup = HashMap::with_capacity(entries.len());
96        for (position, (alias, _)) in entries.iter().enumerate() {
97            lookup.insert(alias.clone(), position);
98        }
99
100        Ok(Self { entries, lookup })
101    }
102}
103
104impl PackageHelpDb {
105    /// Opens `<pkg_dir>/help/<pkg>.rdx` (+ `.rdb`), where `<pkg>` is the
106    /// basename of `pkg_dir`, e.g. `/opt/R/4.6.1/lib/R/library/utils`.
107    ///
108    /// Library-path discovery (R's `.libPaths()`) is out of scope: callers
109    /// must supply the installed package directory explicitly.
110    pub fn open(pkg_dir: impl AsRef<Path>) -> Result<Self, Error> {
111        let pkg_dir = pkg_dir.as_ref();
112        let package = pkg_dir
113            .file_name()
114            .and_then(|name| name.to_str())
115            .ok_or_else(|| {
116                Error::MalformedIndex(format!(
117                    "cannot determine a package name from directory {}",
118                    pkg_dir.display()
119                ))
120            })?
121            .to_string();
122
123        let help_dir = pkg_dir.join("help");
124        let rdx_path = help_dir.join(format!("{package}.rdx"));
125        let rdb_path = help_dir.join(format!("{package}.rdb"));
126
127        let rdx_root = read_rds_file(&rdx_path)?;
128        let index = Index::from_rdx(&rdx_root)?;
129
130        Ok(Self {
131            package,
132            pkg_dir: pkg_dir.to_path_buf(),
133            help_dir,
134            rdb_path,
135            index,
136            alias_index: OnceLock::new(),
137        })
138    }
139
140    /// The package name (the basename of the directory passed to
141    /// [`PackageHelpDb::open`]).
142    pub fn package(&self) -> &str {
143        &self.package
144    }
145
146    /// Topic names present in the `.rdx` `variables` map, in index order.
147    pub fn topics(&self) -> impl Iterator<Item = &str> {
148        self.index.topics()
149    }
150
151    /// Persistence keys present in the `.rdx` `references` map (e.g.
152    /// `"env::0"`), in index order.
153    pub fn reference_keys(&self) -> impl Iterator<Item = &str> {
154        self.index.reference_keys()
155    }
156
157    /// Raw decoded Rd object for `topic`: a `.rdx` `variables` lookup, a
158    /// `.rdb` record fetch, zlib decompression, and `rd_rds::parse`.
159    pub fn raw_topic(&self, topic: &str) -> Result<RObject, Error> {
160        let record = self
161            .index
162            .variable(topic)
163            .ok_or_else(|| Error::UnknownTopic {
164                topic: topic.to_string(),
165            })?;
166        self.fetch_record(record)
167    }
168
169    /// Fetches a record via the `.rdx` `references` map (persistence keys
170    /// like `"env::0"`). Same mechanics as [`PackageHelpDb::raw_topic`],
171    /// different key space.
172    ///
173    /// `rd-helpdb` does not attempt to resolve `PERSISTSXP` nodes into
174    /// environments; this only exposes the raw record a reference key
175    /// points at.
176    pub fn reference(&self, key: &str) -> Result<RObject, Error> {
177        let record = self
178            .index
179            .reference(key)
180            .ok_or_else(|| Error::UnknownReference {
181                key: key.to_string(),
182            })?;
183        self.fetch_record(record)
184    }
185
186    /// alias -> topic map from `help/aliases.rds`, in on-disk order,
187    /// including duplicate aliases if the file has any (see the
188    /// struct-level docs for how [`PackageHelpDb::resolve_alias`] handles
189    /// duplicates).
190    pub fn aliases(&self) -> Result<Vec<(String, String)>, Error> {
191        Ok(self.alias_index()?.entries.clone())
192    }
193
194    /// Resolves `alias` to its topic name via `help/aliases.rds`. If
195    /// `alias` occurs more than once in the file, resolves to the topic of
196    /// its LAST occurrence, matching R's own `list2env()`-based alias
197    /// loader.
198    pub fn resolve_alias(&self, alias: &str) -> Result<Option<&str>, Error> {
199        let index = self.alias_index()?;
200        Ok(index
201            .lookup
202            .get(alias)
203            .map(|&position| index.entries[position].1.as_str()))
204    }
205
206    /// Returns the cached `help/aliases.rds` index, parsing and caching it
207    /// on the first call. Parse failures are not cached: on error, the
208    /// `OnceLock` is left unset so the next call retries from scratch.
209    fn alias_index(&self) -> Result<&AliasIndex, Error> {
210        if let Some(index) = self.alias_index.get() {
211            return Ok(index);
212        }
213
214        let path = self.help_dir.join("aliases.rds");
215        let root = read_rds_file(&path)?;
216        let index = AliasIndex::from_root(&root)?;
217
218        // If another thread won the race and set the index first, that's
219        // fine -- both builds are equivalent, so just fetch whichever one
220        // landed.
221        let _ = self.alias_index.set(index);
222        Ok(self
223            .alias_index
224            .get()
225            .expect("alias_index was just set above"))
226    }
227
228    /// Decoded `Meta/hsearch.rds` as a raw [`RObject`] (no typed model yet).
229    pub fn search_index(&self) -> Result<RObject, Error> {
230        let path = self.pkg_dir.join("Meta").join("hsearch.rds");
231        read_rds_file(&path)
232    }
233
234    /// Reads and validates `Meta/vignette.rds`.
235    ///
236    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
237    /// a missing file returns `Ok(None)`: packages without vignettes normally
238    /// omit this file. An existing zero-row data frame returns a present,
239    /// empty index, while malformed files and non-`NotFound` I/O failures are
240    /// errors.
241    pub fn vignettes(&self) -> Result<Option<VignetteIndex>, Error> {
242        let path = self.pkg_dir.join("Meta").join("vignette.rds");
243        let Some(root) = read_optional_rds_file(&path)? else {
244            return Ok(None);
245        };
246        VignetteIndex::from_object(&root).map(Some)
247    }
248
249    /// Reads and validates `Meta/demo.rds`.
250    ///
251    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
252    /// a missing file returns `Ok(None)`: packages without demos normally omit
253    /// this file. An existing zero-row matrix returns a present, empty index,
254    /// while malformed files and non-`NotFound` I/O failures are errors.
255    pub fn demos(&self) -> Result<Option<DemoIndex>, Error> {
256        let path = self.pkg_dir.join("Meta").join("demo.rds");
257        let Some(root) = read_optional_rds_file(&path)? else {
258            return Ok(None);
259        };
260        DemoIndex::from_object(&root).map(Some)
261    }
262
263    fn fetch_record(&self, record: Record) -> Result<RObject, Error> {
264        let mut file = File::open(&self.rdb_path).map_err(|err| Error::io(&self.rdb_path, err))?;
265        file.seek(SeekFrom::Start(record.offset as u64))
266            .map_err(|err| Error::io(&self.rdb_path, err))?;
267
268        let mut buf = vec![0u8; record.length];
269        file.read_exact(&mut buf)
270            .map_err(|err| Error::io(&self.rdb_path, err))?;
271
272        decode_rdb_record(&buf)
273    }
274}
275
276fn read_optional_rds_file(path: &Path) -> Result<Option<RObject>, Error> {
277    match read_rds_file(path) {
278        Ok(root) => Ok(Some(root)),
279        Err(Error::Io { ref source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
280            Ok(None)
281        }
282        Err(error) => Err(error),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use std::path::PathBuf;
289
290    use super::*;
291
292    /// `tests/fixtures/data/aliases_vector_dup_v3.rds`: a named character
293    /// vector `c(shared = "first-topic", unique = "unique-topic", shared =
294    /// "second-topic")`, generated by `tests/fixtures/generate_fixtures.R`
295    /// (section 9b) -- `RObject`/`RValue` can't be hand-built outside
296    /// `rd-rds` (its `Attributes` constructor is crate-private), so the
297    /// duplicate-alias input has to come from a real parsed `.rds` file
298    /// rather than a struct literal.
299    fn dup_aliases_root() -> RObject {
300        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
301            .join("tests/fixtures/data/aliases_vector_dup_v3.rds");
302        read_rds_file(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
303    }
304
305    #[test]
306    fn alias_index_resolves_duplicates_last_wins() {
307        let root = dup_aliases_root();
308        let index = AliasIndex::from_root(&root).expect("AliasIndex::from_root");
309
310        // `entries` keeps both occurrences of "shared", in on-disk order.
311        assert_eq!(
312            index.entries,
313            vec![
314                ("shared".to_string(), "first-topic".to_string()),
315                ("unique".to_string(), "unique-topic".to_string()),
316                ("shared".to_string(), "second-topic".to_string()),
317            ]
318        );
319
320        // `lookup` resolves the duplicate to the LAST occurrence.
321        let shared_position = *index.lookup.get("shared").expect("'shared' in lookup");
322        assert_eq!(index.entries[shared_position].1, "second-topic");
323        let unique_position = *index.lookup.get("unique").expect("'unique' in lookup");
324        assert_eq!(index.entries[unique_position].1, "unique-topic");
325    }
326}