1use std::{collections::BTreeMap, path::Path};
4
5use rd_rds::{RObject, RValue, file::ReadOptions, matrix::CharacterMatrix};
6
7use crate::{Error, rds::map_file_error};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct HelpSearchBaseEntry {
17 pub package: Option<String>,
18 pub lib_path: Option<String>,
19 pub id: Option<String>,
20 pub name: Option<String>,
21 pub title: Option<String>,
22 pub topic: Option<String>,
23 pub encoding: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct HelpSearchAliasEntry {
30 pub alias: Option<String>,
31 pub id: Option<String>,
32 pub package: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct HelpSearchKeywordEntry {
39 pub keyword: Option<String>,
40 pub id: Option<String>,
41 pub package: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct HelpSearchConceptEntry {
48 pub concept: Option<String>,
49 pub id: Option<String>,
50 pub package: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HelpSearchIndex {
61 base: Vec<HelpSearchBaseEntry>,
62 aliases: Vec<HelpSearchAliasEntry>,
63 keywords: Vec<HelpSearchKeywordEntry>,
64 concepts: Vec<HelpSearchConceptEntry>,
65}
66
67impl HelpSearchIndex {
68 pub fn read_installed(package_dir: impl AsRef<Path>) -> Result<Option<Self>, Error> {
75 Self::read_installed_with_options(package_dir, &ReadOptions::default())
76 }
77
78 pub fn read_installed_with_options(
80 package_dir: impl AsRef<Path>,
81 options: &ReadOptions,
82 ) -> Result<Option<Self>, Error> {
83 let path = package_dir.as_ref().join("Meta/hsearch.rds");
84 match rd_rds::file::read_with_options(&path, options) {
85 Ok(root) => Self::from_object(&root).map(Some),
86 Err(rd_rds::file::ReadError::Io { source, .. })
87 if source.kind() == std::io::ErrorKind::NotFound =>
88 {
89 Ok(None)
90 }
91 Err(error) => Err(map_file_error(&path, error)),
92 }
93 }
94
95 pub fn from_object(root: &RObject) -> Result<Self, Error> {
111 let RValue::List(matrices) = root.value() else {
112 return Err(malformed("root is not a list"));
113 };
114 if root.attributes().get("names").is_some() {
115 return Err(malformed("root list must be unnamed"));
116 }
117 if matrices.len() != 4 {
118 return Err(malformed(format!(
119 "root has {} matrices, expected 4",
120 matrices.len()
121 )));
122 }
123
124 let base = CharacterMatrix::from_object(&matrices[0])
125 .map_err(|error| malformed(format!("invalid Base matrix: {error}")))?;
126 let aliases = CharacterMatrix::from_object(&matrices[1])
127 .map_err(|error| malformed(format!("invalid Aliases matrix: {error}")))?;
128 let keywords = CharacterMatrix::from_object(&matrices[2])
129 .map_err(|error| malformed(format!("invalid Keywords matrix: {error}")))?;
130 let concepts = CharacterMatrix::from_object(&matrices[3])
131 .map_err(|error| malformed(format!("invalid Concepts matrix: {error}")))?;
132
133 let base_columns = columns(
134 &base,
135 "Base",
136 &[
137 "Package", "LibPath", "ID", "Name", "Title", "Topic", "Encoding",
138 ],
139 &["Package", "LibPath", "ID", "Name", "Title", "Topic"],
140 &["Package", "LibPath", "ID", "name", "title", "topic"],
141 &[
142 "Package", "LibPath", "ID", "name", "title", "topic", "Encoding",
143 ],
144 )?;
145 let alias_columns = relation_columns(&aliases, "Aliases", "Alias", "Aliases")?;
146 let keyword_columns = relation_columns(&keywords, "Keywords", "Keyword", "Keywords")?;
147 let concept_columns = relation_columns(&concepts, "Concepts", "Concept", "Concepts")?;
148
149 let mut base_entries = Vec::with_capacity(base.nrow());
150 for row in 0..base.nrow() {
151 base_entries.push(HelpSearchBaseEntry {
152 package: cell(&base, row, base_columns[0]),
153 lib_path: cell(&base, row, base_columns[1]),
154 id: cell(&base, row, base_columns[2]),
155 name: cell(&base, row, base_columns[3]),
156 title: cell(&base, row, base_columns[4]),
157 topic: cell(&base, row, base_columns[5]),
158 encoding: base_columns
159 .get(6)
160 .map(|&column| cell(&base, row, column))
161 .unwrap_or_else(|| Some(String::new())),
162 });
163 }
164
165 let mut alias_entries = Vec::with_capacity(aliases.nrow());
166 for row in 0..aliases.nrow() {
167 alias_entries.push(HelpSearchAliasEntry {
168 alias: cell(&aliases, row, alias_columns[0]),
169 id: cell(&aliases, row, alias_columns[1]),
170 package: cell(&aliases, row, alias_columns[2]),
171 });
172 }
173 let mut keyword_entries = Vec::with_capacity(keywords.nrow());
174 for row in 0..keywords.nrow() {
175 keyword_entries.push(HelpSearchKeywordEntry {
176 keyword: cell(&keywords, row, keyword_columns[0]),
177 id: cell(&keywords, row, keyword_columns[1]),
178 package: cell(&keywords, row, keyword_columns[2]),
179 });
180 }
181 let mut concept_entries = Vec::with_capacity(concepts.nrow());
182 for row in 0..concepts.nrow() {
183 concept_entries.push(HelpSearchConceptEntry {
184 concept: cell(&concepts, row, concept_columns[0]),
185 id: cell(&concepts, row, concept_columns[1]),
186 package: cell(&concepts, row, concept_columns[2]),
187 });
188 }
189
190 Ok(Self {
191 base: base_entries,
192 aliases: alias_entries,
193 keywords: keyword_entries,
194 concepts: concept_entries,
195 })
196 }
197
198 pub fn base_entries(&self) -> impl ExactSizeIterator<Item = &HelpSearchBaseEntry> {
200 self.base.iter()
201 }
202
203 pub fn aliases(&self) -> impl ExactSizeIterator<Item = &HelpSearchAliasEntry> {
205 self.aliases.iter()
206 }
207
208 pub fn keywords(&self) -> impl ExactSizeIterator<Item = &HelpSearchKeywordEntry> {
210 self.keywords.iter()
211 }
212
213 pub fn concepts(&self) -> impl ExactSizeIterator<Item = &HelpSearchConceptEntry> {
215 self.concepts.iter()
216 }
217
218 pub fn base_len(&self) -> usize {
220 self.base.len()
221 }
222
223 pub fn aliases_len(&self) -> usize {
225 self.aliases.len()
226 }
227
228 pub fn keywords_len(&self) -> usize {
230 self.keywords.len()
231 }
232
233 pub fn concepts_len(&self) -> usize {
235 self.concepts.len()
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.base.is_empty()
241 && self.aliases.is_empty()
242 && self.keywords.is_empty()
243 && self.concepts.is_empty()
244 }
245}
246
247impl TryFrom<&RObject> for HelpSearchIndex {
248 type Error = Error;
249
250 fn try_from(value: &RObject) -> Result<Self, Self::Error> {
251 Self::from_object(value)
252 }
253}
254
255fn columns(
256 matrix: &CharacterMatrix,
257 label: &str,
258 modern: &[&str],
259 modern_without_encoding: &[&str],
260 legacy_without_encoding: &[&str],
261 legacy: &[&str],
262) -> Result<Vec<usize>, Error> {
263 let names = matrix_column_positions(matrix, label)?;
264 let matched = if names.len() == modern.len() && same_names(&names, modern) {
265 modern
266 } else if names.len() == modern_without_encoding.len()
267 && same_names(&names, modern_without_encoding)
268 {
269 modern_without_encoding
270 } else if names.len() == legacy_without_encoding.len()
271 && same_names(&names, legacy_without_encoding)
272 {
273 legacy_without_encoding
274 } else if names.len() == legacy.len() && same_names(&names, legacy) {
275 legacy
276 } else {
277 return Err(malformed(format!(
278 "{label} matrix has unsupported columns: {:?}",
279 names.keys().collect::<Vec<_>>()
280 )));
281 };
282 Ok(matched
283 .iter()
284 .filter_map(|name| names.get(*name).copied())
285 .collect())
286}
287
288fn relation_columns(
289 matrix: &CharacterMatrix,
290 label: &str,
291 current_value: &str,
292 legacy_value: &str,
293) -> Result<[usize; 3], Error> {
294 let names = matrix_column_positions(matrix, label)?;
295 let current = [current_value, "ID", "Package"];
296 let legacy = [legacy_value, "ID", "Package"];
297 let expected = if names.len() == 3 && same_names(&names, ¤t) {
298 current
299 } else if names.len() == 3 && same_names(&names, &legacy) {
300 legacy
301 } else {
302 return Err(malformed(format!(
303 "{label} matrix has unsupported columns: {:?}",
304 names.keys().collect::<Vec<_>>()
305 )));
306 };
307 Ok([names[expected[0]], names[expected[1]], names[expected[2]]])
308}
309
310fn matrix_column_positions<'a>(
311 matrix: &'a CharacterMatrix,
312 label: &str,
313) -> Result<BTreeMap<&'a str, usize>, Error> {
314 let mut positions = BTreeMap::new();
315 for column in 0..matrix.ncol() {
316 let Some(name) = matrix.column_name(column) else {
317 return Err(malformed(format!(
318 "{label} matrix has missing or NA column name at position {column}"
319 )));
320 };
321 if positions.insert(name, column).is_some() {
322 return Err(malformed(format!(
323 "{label} matrix has duplicate column name {name:?}"
324 )));
325 }
326 }
327 Ok(positions)
328}
329
330fn same_names(names: &BTreeMap<&str, usize>, expected: &[&str]) -> bool {
331 expected.iter().all(|name| names.contains_key(name))
332}
333
334fn cell(matrix: &CharacterMatrix, row: usize, column: usize) -> Option<String> {
335 matrix.get(row, column).flatten().map(str::to_owned)
336}
337
338fn malformed(message: impl Into<String>) -> Error {
339 Error::MalformedIndex(format!("invalid Meta/hsearch.rds: {}", message.into()))
340}