rusty-man 0.3.0

Command-line viewer for rustdoc documentation
// SPDX-FileCopyrightText: 2020 Robin Krahl <robin.krahl@ireas.org>
// SPDX-License-Identifier: MIT

//! Search index for a documentation source.
//!
//! The search index is read from the `search-index.js` file generated by rustdoc.  It contains a
//! list of items groupd by their crate.
//!
//! For details on the format of the search index, see the `html/render.rs` file in `librustdoc`.
//! Note that the format of the search index changed in April 2020 (Rust 1.44.0) with commit
//! b4fb3069ce82f61f84a9487d17fb96389d55126a.  We only support the new format as the old format is
//! much harder to parse.
//!
//! For details on the generation of the search index, see the `html/render/cache.rs` file in
//! `librustdoc`.

use std::collections;
use std::fmt;
use std::fs;
use std::io;
use std::path;

use crate::doc;

#[derive(Debug)]
pub struct Index {
    path: path::PathBuf,
    data: Data,
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct IndexItem {
    pub name: doc::Fqn,
    pub ty: doc::ItemType,
    pub description: String,
}

impl fmt::Display for IndexItem {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.description.is_empty() {
            write!(f, "{} ({})", &self.name, self.ty.name())
        } else {
            write!(
                f,
                "{} ({}): {}",
                &self.name,
                self.ty.name(),
                &self.description
            )
        }
    }
}

#[derive(Debug, Default, PartialEq, serde::Deserialize)]
#[serde(transparent)]
struct Data {
    crates: collections::HashMap<String, CrateData>,
}

#[derive(Debug, Default, PartialEq, serde::Deserialize)]
struct CrateData {
    #[serde(rename = "i")]
    items: Vec<ItemData>,
    #[serde(rename = "p")]
    paths: Vec<(usize, String)>,
}

#[derive(Debug, PartialEq, serde_tuple::Deserialize_tuple)]
struct ItemData {
    #[serde(deserialize_with = "deserialize_item_type")]
    ty: doc::ItemType,
    name: String,
    path: String,
    desc: String,
    parent: Option<usize>,
    _ignored: serde_json::Value,
}

fn deserialize_item_type<'de, D>(d: D) -> Result<doc::ItemType, D::Error>
where
    D: serde::de::Deserializer<'de>,
{
    use doc::ItemType;
    use serde::de::{Deserialize, Error};

    match u8::deserialize(d)? {
        0 => Ok(ItemType::Module),
        1 => Ok(ItemType::ExternCrate),
        2 => Ok(ItemType::Import),
        3 => Ok(ItemType::Struct),
        4 => Ok(ItemType::Enum),
        5 => Ok(ItemType::Function),
        6 => Ok(ItemType::Typedef),
        7 => Ok(ItemType::Static),
        8 => Ok(ItemType::Trait),
        9 => Ok(ItemType::Impl),
        10 => Ok(ItemType::TyMethod),
        11 => Ok(ItemType::Method),
        12 => Ok(ItemType::StructField),
        13 => Ok(ItemType::Variant),
        14 => Ok(ItemType::Macro),
        15 => Ok(ItemType::Primitive),
        16 => Ok(ItemType::AssocType),
        17 => Ok(ItemType::Constant),
        18 => Ok(ItemType::AssocConst),
        19 => Ok(ItemType::Union),
        20 => Ok(ItemType::ForeignType),
        21 => Ok(ItemType::Keyword),
        22 => Ok(ItemType::OpaqueTy),
        23 => Ok(ItemType::ProcAttribute),
        24 => Ok(ItemType::ProcDerive),
        25 => Ok(ItemType::TraitAlias),
        _ => Err(D::Error::custom("Unexpected item type")),
    }
}

impl Index {
    pub fn load(path: impl AsRef<path::Path>) -> anyhow::Result<Option<Self>> {
        use std::io::BufRead;

        anyhow::ensure!(
            path.as_ref().is_file(),
            "Search index '{}' must be a file",
            path.as_ref().display()
        );

        let mut json: Option<String> = None;
        let mut finished = false;

        for line in io::BufReader::new(fs::File::open(path.as_ref())?).lines() {
            let line = line?;
            if let Some(json) = &mut json {
                if line == "}');" {
                    json.push_str("}");
                    finished = true;
                    break;
                } else {
                    json.push_str(line.trim_end_matches('\\'));
                }
            } else if line == "var searchIndex = JSON.parse('{\\" {
                json = Some(String::from("{"));
            }
        }

        if let Some(json) = json {
            if finished {
                use anyhow::Context;
                let json = json.replace("\\'", "'");
                let data: Data =
                    serde_json::from_str(&json).context("Could not parse search index")?;

                Ok(Some(Index {
                    data,
                    path: path.as_ref().to_owned(),
                }))
            } else {
                log::info!(
                    "Did not find JSON end line in search index '{}'",
                    path.as_ref().display()
                );
                Ok(None)
            }
        } else {
            log::info!(
                "Did not find JSON start line in search index '{}'",
                path.as_ref().display()
            );
            Ok(None)
        }
    }

    pub fn find(&self, name: &doc::Name) -> Vec<IndexItem> {
        log::info!(
            "Looking up '{}' in search index '{}'",
            name,
            self.path.display()
        );
        let mut matches: Vec<IndexItem> = Vec::new();
        for (krate, data) in &self.data.crates {
            let mut path = krate;
            for item in &data.items {
                path = if item.path.is_empty() {
                    path
                } else {
                    &item.path
                };

                if item.ty == doc::ItemType::AssocType {
                    continue;
                }

                let full_path = match item.parent {
                    Some(idx) => {
                        let parent = &data.paths[idx].1;
                        format!("{}::{}", path, parent)
                    }
                    None => path.to_owned(),
                };
                let full_name: doc::Fqn = format!("{}::{}", &full_path, &item.name).into();
                if full_name.ends_with(&name) {
                    log::info!("Found index match '{}'", full_name);
                    matches.push(IndexItem {
                        name: full_name,
                        ty: item.ty,
                        description: item.desc.clone(),
                    });
                }
            }
        }
        matches.sort_unstable();
        matches.dedup();
        matches
    }
}

#[cfg(test)]
mod tests {
    use super::{CrateData, Data, Index, IndexItem, ItemData};
    use crate::doc::ItemType;
    use crate::test_utils::with_rustdoc;

    #[test]
    fn test_empty() {
        let expected: Data = Default::default();
        let actual: Data = serde_json::from_str("{}").unwrap();
        assert_eq!(expected, actual);
    }

    #[test]
    fn test_empty_crate() {
        let mut expected: Data = Default::default();
        expected
            .crates
            .insert("test".to_owned(), Default::default());
        let actual: Data = serde_json::from_str("{\"test\": {\"i\": [], \"p\": []}}").unwrap();
        assert_eq!(expected, actual);
    }

    #[test]
    fn test_one_item() {
        let mut expected: Data = Default::default();
        let mut krate: CrateData = Default::default();
        krate.items.push(ItemData {
            ty: ItemType::Module,
            name: "name".to_owned(),
            path: "path".to_owned(),
            desc: "desc".to_owned(),
            parent: None,
            _ignored: Default::default(),
        });
        expected.crates.insert("test".to_owned(), krate);
        let actual: Data = serde_json::from_str(
            "{\"test\": {\"i\": [[0, \"name\", \"path\", \"desc\", null, null]], \"p\": []}}",
        )
        .unwrap();
        assert_eq!(expected, actual);
    }

    #[test]
    fn test_index() {
        with_rustdoc(">=1.44.0", |_, path| {
            let index = Index::load(path.join("search-index.js")).unwrap().unwrap();

            let empty: Vec<IndexItem> = Vec::new();

            let node_data_ref = vec![IndexItem {
                name: "kuchiki::NodeDataRef".to_owned().into(),
                ty: ItemType::Struct,
                description: "Holds a strong reference to a node, but dereferences to…".to_owned(),
            }];
            assert_eq!(node_data_ref, index.find(&"NodeDataRef".to_owned().into()));
            assert_eq!(
                node_data_ref,
                index.find(&"kuchiki::NodeDataRef".to_owned().into())
            );
            assert_eq!(empty, index.find(&"DataRef".to_owned().into()));
            assert_eq!(empty, index.find(&"NodeDataReff".to_owned().into()));

            let as_node = vec![IndexItem {
                name: "kuchiki::NodeDataRef::as_node".to_owned().into(),
                ty: ItemType::Method,
                description: "Access the corresponding node.".to_owned(),
            }];
            assert_eq!(as_node, index.find(&"as_node".to_owned().into()));
            assert_eq!(
                as_node,
                index.find(&"NodeDataRef::as_node".to_owned().into())
            );
            assert_eq!(
                as_node,
                index.find(&"kuchiki::NodeDataRef::as_node".to_owned().into())
            );
            assert_eq!(empty, index.find(&"DataRef::as_node".to_owned().into()));
        });
    }
}