rusty-man 0.4.1

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

//! rusty-man is a command-line viewer for documentation generated by `rustdoc`.
//!
//! rusty-man opens the documentation for a given keyword.  It performs these steps to find the
//! documentation for an item:
//! 1. The sources, currently only local directories, are loaded, see the `load_sources` function
//!    and the `source` module.  Per default, we look for documentation in the directory
//!    `share/doc/rust{,-doc}/html` relative to the Rust installation path (`rustc --print sysroot`
//!    or `usr`) and in `./target/doc`.
//! 2. We try to look up the given keyword in all acailable sources, see the `find_doc` function
//!    and the `source` module for the lookup logic and the `doc` module for the loaded
//!    documentation.
//! 3. If we didn’t find a match in the previous step, we load the search index from the
//!    `search-index.js` file for all sources and try to find a matching item.  If we find one, we
//!    open the documentation for that item as in step 2.  See the `search_doc` function and the
//!    `index` module.
//!
//! If we found a documentation item, we use a viewer to open it – see the `viewer` module.
//! Currently, there are three viewer implementations:  `plain` converts the documentaion to plain
//! text, `rich` adds some formatting to it.  Both viewers pipe their output through a pager, if
//! available.  The third viewer, `tui`, provides an interactive interface for browsing the
//! documentation.
//!
//! The documentation is scraped from the HTML files generated by `rustdoc`.  See the `parser`
//! module for the scraping and the `doc::Doc` struct for the structure of the documentation items.
//! For details on the structure of the HTML files and the search index, you have to look at the
//! `html::render` module in the `librustdoc` source code.
//!
//! Note that the format of the search index changed in a recent Rust version (> 1.40 and <= 1.44).
//! We don’t support the old index format.  As the format of the HTML files is not specified,
//! rusty-man might not work with new Rust versions that change the documentation format.

// The matches! macro was added in Rust 1.42, but our MSRV is 1.40.
#![allow(clippy::match_like_matches_macro)]

mod args;
mod doc;
mod index;
mod parser;
mod source;
#[cfg(test)]
mod test_utils;
mod viewer;

use std::io;
use std::path;

fn main() -> anyhow::Result<()> {
    env_logger::init();

    let args = args::Args::load()?;
    let sources = load_sources(&args.source_paths, !args.no_default_sources)?;
    let doc = if let Some(doc) = find_doc(&sources, &args.keyword, None)? {
        Some(doc)
    } else if !args.no_search {
        search_doc(&sources, &args.keyword)?
    } else {
        anyhow::bail!("Could not find documentation for {}", &args.keyword);
    };

    if let Some(doc) = doc {
        let viewer = args.viewer.unwrap_or_else(viewer::get_default);
        if args.examples {
            let examples = doc.find_examples()?;
            anyhow::ensure!(
                !examples.is_empty(),
                "Could not find examples for {}",
                &args.keyword
            );
            viewer.open_examples(sources, args.viewer_args, &doc, examples)
        } else {
            viewer.open(sources, args.viewer_args, &doc)
        }
    } else {
        // item selection cancelled by user
        Ok(())
    }
}

/// Load all sources given as a command-line argument and, if enabled, the default sources.
fn load_sources(
    sources: &[String],
    load_default_sources: bool,
) -> anyhow::Result<Vec<Box<dyn source::Source>>> {
    let mut vec: Vec<Box<dyn source::Source>> = Vec::new();

    if load_default_sources {
        for path in get_default_sources() {
            if path.is_dir() {
                vec.push(source::get_source(&path)?);
            } else {
                log::info!(
                    "Ignoring default source '{}' because it does not exist",
                    path.display()
                );
            }
        }
    }

    for s in sources {
        vec.push(source::get_source(s)?);
    }

    // The last source should be searched first --> reverse source vector
    vec.reverse();

    Ok(vec)
}

fn get_default_sources() -> Vec<path::PathBuf> {
    let mut default_sources: Vec<path::PathBuf> = Vec::new();

    let sysroot = get_sysroot().unwrap_or_else(|| path::PathBuf::from("/usr"));
    default_sources.push(sysroot.join("share/doc/rust/html"));
    default_sources.push(sysroot.join("share/doc/rust-doc/html"));

    default_sources.push(path::PathBuf::from("./target/doc"));

    default_sources
}

fn get_sysroot() -> Option<path::PathBuf> {
    std::process::Command::new("rustc")
        .arg("--print")
        .arg("sysroot")
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().into())
}

/// Find the documentation for an item with the given name (exact matches only).
fn find_doc(
    sources: &[Box<dyn source::Source>],
    name: &doc::Name,
    ty: Option<doc::ItemType>,
) -> anyhow::Result<Option<doc::Doc>> {
    let fqn = name.clone().into();
    for source in sources {
        if let Some(doc) = source.find_doc(&fqn, ty)? {
            return Ok(Some(doc));
        }
    }
    log::info!("Could not find item '{}'", fqn);
    Ok(None)
}

/// Use the search index to find the documentation for an item that partially matches the given
/// keyword.
fn search_doc(
    sources: &[Box<dyn source::Source>],
    name: &doc::Name,
) -> anyhow::Result<Option<doc::Doc>> {
    if let Some(item) = search_item(sources, name)? {
        use anyhow::Context;

        let doc = find_doc(sources, &item.name, Some(item.ty))?
            .with_context(|| format!("Could not find documentation for {}", &item.name))?;
        Ok(Some(doc))
    } else {
        log::info!(
            "Could not find documentation for '{}' in the search index",
            name
        );
        Ok(None)
    }
}

/// Use the search index to find an item that partially matches the given keyword.
fn search_item(
    sources: &[Box<dyn source::Source>],
    name: &doc::Name,
) -> anyhow::Result<Option<index::IndexItem>> {
    let indexes = sources
        .iter()
        .filter_map(|s| s.load_index().transpose())
        .collect::<anyhow::Result<Vec<_>>>()?;
    let mut items = indexes
        .iter()
        .map(|i| i.find(name))
        .collect::<Vec<_>>()
        .concat();
    items.sort_unstable();
    items.dedup();

    if items.is_empty() {
        Err(anyhow::anyhow!(
            "Could not find documentation for {}",
            &name
        ))
    } else if items.len() == 1 {
        log::info!("Search returned a single item: '{}'", &items[0].name);
        Ok(Some(items[0].clone()))
    } else {
        select_item(&items, name)
    }
}

/// Let the user select an item from the given list of matches.
fn select_item(
    items: &[index::IndexItem],
    name: &doc::Name,
) -> anyhow::Result<Option<index::IndexItem>> {
    use std::io::Write;

    // If we are not on a TTY, we can’t ask the user to select an item --> abort
    anyhow::ensure!(
        termion::is_tty(&io::stdin()),
        "Found multiple matches for {}",
        name
    );

    println!("Found mulitple matches for {} – select one of:", name);
    println!();
    let width = (items.len() + 1).to_string().len();
    for (i, item) in items.iter().enumerate() {
        println!("[ {:width$} ] {}", i, &item, width = width);
    }
    println!();
    print!("> ");
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    if let Ok(i) = usize::from_str_radix(input.trim(), 10) {
        Ok(items.get(i).map(Clone::clone))
    } else {
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use crate::source;
    use crate::test_utils::with_rustdoc;

    #[test]
    fn test_find_doc() {
        with_rustdoc("*", |_, path| {
            let sources = vec![source::get_source(path).unwrap()];

            assert!(
                super::find_doc(&sources, &"kuchiki".to_owned().into(), None)
                    .unwrap()
                    .is_some()
            );
            assert!(
                super::find_doc(&sources, &"kuchiki::NodeRef".to_owned().into(), None)
                    .unwrap()
                    .is_some()
            );
            assert!(super::find_doc(
                &sources,
                &"kuchiki::NodeDataRef::as_node".to_owned().into(),
                None
            )
            .unwrap()
            .is_some());
            assert!(
                super::find_doc(&sources, &"kuchiki::traits".to_owned().into(), None)
                    .unwrap()
                    .is_some()
            );
            assert!(
                super::find_doc(&sources, &"kachiki".to_owned().into(), None)
                    .unwrap()
                    .is_none()
            );
        });
    }
}