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    Error,
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` 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    fn fetch_record(&self, record: Record) -> Result<RObject, Error> {
235        let mut file = File::open(&self.rdb_path).map_err(|err| Error::io(&self.rdb_path, err))?;
236        file.seek(SeekFrom::Start(record.offset as u64))
237            .map_err(|err| Error::io(&self.rdb_path, err))?;
238
239        let mut buf = vec![0u8; record.length];
240        file.read_exact(&mut buf)
241            .map_err(|err| Error::io(&self.rdb_path, err))?;
242
243        decode_rdb_record(&buf)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::path::PathBuf;
250
251    use super::*;
252
253    /// `tests/fixtures/data/aliases_vector_dup_v3.rds`: a named character
254    /// vector `c(shared = "first-topic", unique = "unique-topic", shared =
255    /// "second-topic")`, generated by `tests/fixtures/generate_fixtures.R`
256    /// (section 9b) -- `RObject`/`RValue` can't be hand-built outside
257    /// `rd-rds` (its `Attributes` constructor is crate-private), so the
258    /// duplicate-alias input has to come from a real parsed `.rds` file
259    /// rather than a struct literal.
260    fn dup_aliases_root() -> RObject {
261        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
262            .join("tests/fixtures/data/aliases_vector_dup_v3.rds");
263        read_rds_file(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
264    }
265
266    #[test]
267    fn alias_index_resolves_duplicates_last_wins() {
268        let root = dup_aliases_root();
269        let index = AliasIndex::from_root(&root).expect("AliasIndex::from_root");
270
271        // `entries` keeps both occurrences of "shared", in on-disk order.
272        assert_eq!(
273            index.entries,
274            vec![
275                ("shared".to_string(), "first-topic".to_string()),
276                ("unique".to_string(), "unique-topic".to_string()),
277                ("shared".to_string(), "second-topic".to_string()),
278            ]
279        );
280
281        // `lookup` resolves the duplicate to the LAST occurrence.
282        let shared_position = *index.lookup.get("shared").expect("'shared' in lookup");
283        assert_eq!(index.entries[shared_position].1, "second-topic");
284        let unique_position = *index.lookup.get("unique").expect("'unique' in lookup");
285        assert_eq!(index.entries[unique_position].1, "unique-topic");
286    }
287}