use std::{
collections::{BTreeMap, HashMap},
path::Path,
};
use rd_rds::{RObject, RValue, file::ReadOptions};
use crate::{Error, rds::map_file_error, util::rstr_to_string};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HelpTopicText {
Missing,
Na,
Invalid(String),
Text(String),
}
impl HelpTopicText {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Text(value) => Some(value),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HelpTopicEntry {
pub aliases: Vec<Option<String>>,
pub name: HelpTopicText,
pub title: HelpTopicText,
pub file: HelpTopicText,
}
impl HelpTopicEntry {
pub fn topic_key(&self) -> Option<&str> {
self.file.as_str().map(|file| {
let basename = file
.trim_end_matches(std::path::is_separator)
.rsplit(std::path::is_separator)
.next()
.unwrap_or(file);
basename
.strip_suffix(".Rd")
.or_else(|| basename.strip_suffix(".rd"))
.unwrap_or(basename)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelpTopicIndex {
entries: Vec<HelpTopicEntry>,
alias_lookup: HashMap<String, usize>,
}
impl HelpTopicIndex {
pub fn read_installed(package_dir: impl AsRef<Path>) -> Result<Option<Self>, Error> {
Self::read_installed_with_options(package_dir, &ReadOptions::default())
}
pub fn read_installed_with_options(
package_dir: impl AsRef<Path>,
options: &ReadOptions,
) -> Result<Option<Self>, Error> {
let path = package_dir.as_ref().join("Meta/Rd.rds");
match rd_rds::file::read_with_options(&path, options) {
Ok(root) => Self::from_object(&root).map(Some),
Err(rd_rds::file::ReadError::Io { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
Ok(None)
}
Err(error) => Err(map_file_error(&path, error)),
}
}
pub fn from_object(root: &RObject) -> Result<Self, Error> {
let RValue::List(columns) = root.value() else {
return Err(malformed("root is not a list"));
};
if !root.class().is_some_and(|classes| {
classes
.iter()
.any(|class| matches!(class.as_str(), Some(Ok(value)) if value == "data.frame"))
}) {
return Err(malformed("class does not include \"data.frame\""));
}
let names = root
.names()
.ok_or_else(|| malformed("missing character names attribute"))?;
if names.len() != columns.len() {
return Err(malformed("column names and columns have different lengths"));
}
let mut positions = BTreeMap::new();
for (position, name) in names.iter().enumerate() {
let name = rstr_to_string(name).map_err(|error| {
malformed(format!("invalid column name at {position}: {error}"))
})?;
if positions.insert(name.clone(), position).is_some() {
return Err(malformed(format!("duplicate column name {name:?}")));
}
}
let column = |name: &str| positions.get(name).map(|&index| &columns[index]);
let aliases =
column("Aliases").ok_or_else(|| malformed("missing required column \"Aliases\""))?;
let RValue::List(aliases) = aliases.value() else {
return Err(malformed("column \"Aliases\" is not a list"));
};
let nrow = aliases.len();
validate_row_count(root, nrow)?;
let mut entries = Vec::with_capacity(nrow);
for (row, cell) in aliases.iter().enumerate() {
let RValue::Character(values) = cell.value() else {
return Err(malformed(format!(
"Aliases at row {row} is not a character vector"
)));
};
let aliases = values
.iter()
.enumerate()
.map(|(element, value)| {
value
.as_str()
.map(|result| result.map(|text| text.into_owned()))
.transpose()
.map_err(|error| {
malformed(format!(
"invalid Aliases at row {row}, element {element}: {error}"
))
})
})
.collect::<Result<_, _>>()?;
entries.push(HelpTopicEntry {
aliases,
name: optional_text(column("Name"), "Name", row, nrow),
title: optional_text(column("Title"), "Title", row, nrow),
file: optional_text(column("File"), "File", row, nrow),
});
}
let mut alias_lookup = HashMap::new();
for (row, entry) in entries.iter().enumerate() {
for alias in entry.aliases.iter().flatten() {
alias_lookup.entry(alias.clone()).or_insert(row);
}
}
Ok(Self {
entries,
alias_lookup,
})
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &HelpTopicEntry> {
self.entries.iter()
}
pub fn find_alias(&self, alias: &str) -> Option<&HelpTopicEntry> {
self.alias_lookup.get(alias).map(|&row| &self.entries[row])
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl TryFrom<&RObject> for HelpTopicIndex {
type Error = Error;
fn try_from(value: &RObject) -> Result<Self, Self::Error> {
Self::from_object(value)
}
}
fn optional_text(column: Option<&RObject>, name: &str, row: usize, nrow: usize) -> HelpTopicText {
let Some(column) = column else {
return HelpTopicText::Missing;
};
let RValue::Character(values) = column.value() else {
return HelpTopicText::Invalid(format!("{name} is not a character vector"));
};
if values.len() > nrow {
return HelpTopicText::Invalid(format!(
"{name} has {} values for {nrow} rows",
values.len()
));
}
let Some(value) = values.get(row) else {
return HelpTopicText::Invalid(format!("{name} has no value at row {row}"));
};
match value.as_str() {
None => HelpTopicText::Na,
Some(Ok(value)) => HelpTopicText::Text(value.into_owned()),
Some(Err(error)) => HelpTopicText::Invalid(format!("{name} at row {row}: {error}")),
}
}
fn validate_row_count(root: &RObject, nrow: usize) -> Result<(), Error> {
let row_names = root
.attributes()
.get("row.names")
.ok_or_else(|| malformed("missing row.names attribute"))?;
let count = match row_names.value() {
RValue::Integer(values) if values.len() == 2 && values[0].is_none() => values[1]
.ok_or_else(|| malformed("NA row count in row.names"))?
.unsigned_abs()
as usize,
RValue::Integer(values) => values.len(),
RValue::Character(values) => values.len(),
_ => return Err(malformed("row.names is not an integer or character vector")),
};
if count != nrow {
return Err(malformed(format!(
"row.names implies {count} rows but Aliases has {nrow}"
)));
}
Ok(())
}
fn malformed(message: impl Into<String>) -> Error {
Error::MalformedIndex(format!("invalid Meta/Rd.rds: {}", message.into()))
}