rocdoc 0.1.2

Command line rust documentation searching in the style of godoc
Documentation
/*!
 * Locate the generated docs that we have available within the current workspace
 */
use crate::pprint::{header, pprint_as_columns, CRATE_LIST_HEADING_COLOR};
use std::fs;
use std::{env, ffi, path, process};

/// Determine the known crates under this path
pub fn list_known_crates() -> std::io::Result<()> {
    let mut dirs = vec![String::from("std")];

    if let Some(root) = get_doc_root(&CrateType::Cargo) {
        for res in root.read_dir()? {
            let entry = res?;
            let meta = entry.metadata()?;
            if meta.is_dir() && entry.file_name() != "src" {
                dirs.push(String::from(entry.file_name().to_str().unwrap()));
            }
        }
    }

    dirs.sort();
    let title = header("known crates", CRATE_LIST_HEADING_COLOR);
    println!("{}\n{}", title, pprint_as_columns(dirs));
    Ok(())
}

/// Each of the various documentation types we can be asked to locate
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum Tag {
    /// A public constant
    Constant,
    /// An enum and its variants
    Enum,
    /// A top level function
    Function,
    /// An exported macro
    Macro,
    /// A child module under the current search path
    Module,
    /// A stdlib primative data type
    Primative,
    /// A struct (we wont see hidden fields)
    Struct,
    /// A trait definition
    Trait,
    /// Methods on a struct if that's what the current path points to
    Method,
    /// Something we don't know how to handle yet
    Unknown,
}

impl From<path::PathBuf> for Tag {
    fn from(path_buf: path::PathBuf) -> Tag {
        let file_name = path_buf.file_name().unwrap();
        if file_name == ffi::OsString::from("index.html") {
            return Tag::Module;
        }

        match file_name.to_os_string().into_string() {
            Err(_) => Tag::Unknown,
            Ok(s) => match s.split('.').collect::<Vec<&str>>()[0] {
                "constant" => Tag::Constant,
                "enum" => Tag::Enum,
                "fn" => Tag::Function,
                "macro" => Tag::Macro,
                "primative" => Tag::Primative,
                "struct" => Tag::Struct,
                "trait" => Tag::Trait,
                _ => Tag::Unknown,
            },
        }
    }
}

/// A filesystem path generated by the search query issued by the user
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct TaggedPath {
    path_buf: path::PathBuf,
    without_prefix: Option<ffi::OsString>,
    /// The file name that was located
    pub file_name: String,
    /// A method name within this file if the file itself is for the parent struct
    pub method_name: Option<String>,
    /// A Tag indicating what type of file this is
    pub tag: Tag,
}

impl TaggedPath {
    /// The underlying abs path as a String
    pub fn path(&self) -> String {
        String::from(self.path_buf.to_str().unwrap())
    }

    fn dir(&self) -> String {
        let mut dir = self.path_buf.clone();
        dir.pop();
        String::from(dir.to_str().unwrap())
    }

    /// Returns a vec of child directory leaf names next to this path
    pub fn sibling_dirs(&self) -> std::io::Result<Vec<String>> {
        let mut dirs = Vec::new();
        for res in fs::read_dir(self.dir())? {
            let entry = res?;
            let meta = entry.metadata()?;
            if meta.is_dir() {
                dirs.push(String::from(entry.file_name().to_str().unwrap()));
            }
        }

        Ok(dirs)
    }
}

impl From<path::PathBuf> for TaggedPath {
    fn from(path_buf: path::PathBuf) -> TaggedPath {
        let file_name = match path_buf.file_name().unwrap().to_str() {
            Some(s) => String::from(s),
            None => panic!("file path is not valid utf8"),
        };
        let tag = Tag::from(path_buf.clone());
        let method_name = None;
        let without_prefix = match tag {
            Tag::Module | Tag::Unknown => None,
            _ => Some(ffi::OsString::from(
                file_name.splitn(2, '.').collect::<Vec<&str>>()[1],
            )),
        };

        TaggedPath {
            path_buf,
            file_name,
            without_prefix,
            method_name,
            tag,
        }
    }
}

/// Local documentation is stored in different locations depending on whether or
/// not the query resolves to something that is from the standard library or a
/// third party crate that the user is pulling in via Cargo.
#[derive(PartialEq, Eq, Debug)]
enum CrateType {
    StdLib,
    Cargo,
}

/// We can't resolve all cases when we parse the Query but it is useful to know
/// if the query is for a method or a concrete symbol that will have its own
/// documentation file
#[derive(PartialEq, Eq, Debug)]
enum QueryType {
    InstanceMethod,
    Unknown,
}

/// A Locator handles mapping a user query string from the command line to a file
/// location on disk. It also provides information about what kind of documentation
/// file it has found so that the appropriate parsing of the file contents can be
/// carried out.
#[derive(PartialEq, Eq, Debug)]
pub struct Locator {
    root: path::PathBuf,
    crate_type: CrateType,
    query_type: QueryType,
    components: Vec<String>,
}

impl Locator {
    /// Create a new Locator based on the given user query path entered at the command line
    pub fn new(query: String) -> Self {
        let components: Vec<String> = query
            .split("::")
            .flat_map(|s| s.split('.'))
            .filter(|s| !s.is_empty())
            .map(String::from)
            .collect();

        let crate_type = if components[0] == "std" {
            CrateType::StdLib
        } else {
            CrateType::Cargo
        };

        let query_type = if query.contains('.') {
            QueryType::InstanceMethod
        } else {
            QueryType::Unknown
        };

        let root = get_doc_root(&crate_type).expect("unable to locate documentation root");

        return Locator {
            root,
            crate_type,
            query_type,
            components,
        };
    }

    /// The resolved local file path if we were able to determine one
    pub fn target_file_path(&self) -> Option<String> {
        self.determine_tagged_path().map(|p| p.path())
    }

    /// The resolved local TaggedPath (path with added metadata)
    pub fn determine_tagged_path(&self) -> Option<TaggedPath> {
        let mut search_path = self.root.clone();
        search_path.extend(self.query_dir_as_path_buf().iter());

        // Check to see if we are targeting a module and grab index.html
        // if we are.
        search_path.push(self.last_component().clone());
        if search_path.is_dir() {
            search_path.push("index.html");
            return Some(TaggedPath::from(search_path));
        } else {
            search_path.pop();
        }

        let target_filename = self.query_filename();
        let target_filename_for_method = self.query_filename_for_method();

        while search_path != self.root {
            if let Ok(entries) = search_path.read_dir() {
                for entry in entries.filter_map(|p| p.ok()) {
                    let mut tagged = TaggedPath::from(entry.path());
                    if let Some(without_prefix) = &tagged.without_prefix {
                        if without_prefix == &target_filename {
                            return Some(tagged);
                        }

                        if let Some(ref target) = target_filename_for_method {
                            if without_prefix == target {
                                tagged.tag = Tag::Method;
                                tagged.method_name =
                                    Some(String::from(self.last_component().to_str().unwrap()));
                                return Some(tagged);
                            }
                        }
                    }
                }
            };

            if let Some(p) = search_path.file_name() {
                if p == target_filename {
                    search_path.push("index.html");
                    return Some(TaggedPath::from(search_path));
                }
            }

            search_path.pop();
        }
        return None;
    }

    fn query_dir_as_path_buf(&self) -> path::PathBuf {
        let mut buf = path::PathBuf::new();
        buf.extend(self.components.iter());
        buf.pop();

        return buf;
    }

    fn query_filename_for_method(&self) -> Option<ffi::OsString> {
        if self.components.len() > 2 {
            let comp = self.components[self.components.len() - 2].clone();
            Some(ffi::OsString::from(comp + ".html"))
        } else {
            None
        }
    }

    fn query_filename(&self) -> ffi::OsString {
        if let Some(s) = self.components.last() {
            ffi::OsString::from(String::from(s) + ".html")
        } else {
            panic!("no last component in method query")
        }
    }

    fn last_component(&self) -> ffi::OsString {
        if let Some(s) = self.components.last() {
            ffi::OsString::from(s)
        } else {
            panic!("no last component in method query")
        }
    }
}

fn get_doc_root(crate_type: &CrateType) -> Option<path::PathBuf> {
    match crate_type {
        CrateType::StdLib => get_sys_root().map(|r| r.join(path::Path::new("share/doc/rust/html"))),
        CrateType::Cargo => get_crate_root().map(|r| r.join(path::Path::new("target/doc"))),
    }
}

fn get_sys_root() -> Option<path::PathBuf> {
    process::Command::new("rustc")
        .arg("--print")
        .arg("sysroot")
        .output()
        .ok()
        .and_then(|out| String::from_utf8(out.stdout).ok())
        .map(|s| path::Path::new(s.trim()).to_path_buf())
}

fn get_crate_root() -> Option<path::PathBuf> {
    let mut cur_dir = env::current_dir().ok().unwrap();
    let cargo_toml = ffi::OsStr::new("Cargo.toml");
    let file_system_root = path::Path::new("/");

    while cur_dir != file_system_root {
        if let Ok(paths) = cur_dir.read_dir() {
            for entry in paths {
                if let Ok(entry) = entry {
                    if let Some(fname) = entry.path().as_path().file_name() {
                        if fname == cargo_toml {
                            return Some(cur_dir);
                        }
                    }
                };
            }
        };
        cur_dir.pop();
    }
    return None;
}

#[cfg(test)]
mod tests {
    use super::*;
    use test_case::test_case;

    #[test_case("test_resources/foo/enum.elon.html", Tag::Enum)]
    #[test_case("test_resources/foo/fn.foo.html", Tag::Function)]
    #[test_case("test_resources/foo/macro.makrow.html", Tag::Macro)]
    #[test_case("test_resources/foo/index.html", Tag::Module)]
    #[test_case("test_resources/foo/primative.ug.html", Tag::Primative)]
    #[test_case("test_resources/foo/struct.structural.html", Tag::Struct)]
    #[test_case("test_resources/foo/trait.fooable.html", Tag::Trait)]
    #[test_case("test_resources/foo/some_other_unknown.html", Tag::Unknown)]
    fn path_buf_into_symbol_type(path: &str, expected: Tag) {
        let path_buf = path::PathBuf::from(path);
        let symbol_type = Tag::from(path_buf);

        assert_eq!(symbol_type, expected);
    }

    #[test_case("std::fs::File", CrateType::StdLib, QueryType::Unknown, vec!["std", "fs", "File"])]
    #[test_case("std::path::PathBuf.file_name", CrateType::StdLib, QueryType::InstanceMethod, vec!["std", "path", "PathBuf", "file_name"])]
    #[test_case("foo::Foo.bar", CrateType::Cargo, QueryType::InstanceMethod, vec!["foo", "Foo", "bar"])]
    fn locator_from_input(
        path: &str,
        crate_type: CrateType,
        query_type: QueryType,
        comps: Vec<&str>,
    ) {
        let root = get_doc_root(&crate_type).unwrap();
        assert_eq!(
            Locator::new(String::from(path)),
            Locator {
                root: root,
                crate_type: crate_type,
                query_type: query_type,
                components: comps.iter().map(|c| String::from(*c)).collect()
            }
        )
    }
}