1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum HelpTopicText {
19 Missing,
21 Na,
23 Invalid(String),
27 Text(String),
29}
30
31impl HelpTopicText {
32 pub fn as_str(&self) -> Option<&str> {
34 match self {
35 Self::Text(value) => Some(value),
36 _ => None,
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct HelpTopicEntry {
45 pub aliases: Vec<Option<String>>,
47 pub name: HelpTopicText,
49 pub title: HelpTopicText,
51 pub file: HelpTopicText,
53}
54
55impl HelpTopicEntry {
56 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#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct HelpTopicIndex {
84 entries: Vec<HelpTopicEntry>,
85 alias_lookup: HashMap<String, usize>,
86}
87
88impl HelpTopicIndex {
89 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 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 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 pub fn entries(&self) -> impl ExactSizeIterator<Item = &HelpTopicEntry> {
208 self.entries.iter()
209 }
210
211 pub fn find_alias(&self, alias: &str) -> Option<&HelpTopicEntry> {
215 self.alias_lookup.get(alias).map(|&row| &self.entries[row])
216 }
217
218 pub fn len(&self) -> usize {
220 self.entries.len()
221 }
222
223 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 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}