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}