Created a Loader for loading a book using the Summary

This is a squashed commit. It roughly encompasses the following changes.

---

\# Book

- Created another private submodule, mdbook::loader::book

- This submodule contains the data types representing a Book

- For now the Book just contains a list of BookItems (either chapters or
separators)

- A Chapter contains its name, contents (as one long string), an
optional section number (only numbered chapters have numbers,
obviously), and any nested chapters

- There's a function for loading a single Chapter from disk using it's
associated Link entry from the SUMMARY.md

- Another function builds up the Book by recursively visiting all Links
and separators in the Summary and joining them into a single
Vec<SummaryItem>. This is the only non-dumb-data-type item which is
actually exported from the book module

\# Loader

- Made the loader use the book::load_book_from_disk function for
loading a book in the loader's directory.

\# Tests

- Made sure you can load from disk by writing some files to a temporary
directory

- Made sure the Loader can load the entire example-book from disk and
doesn't crash or hit an error

- Increased test coverage from 34.4% to 47.7% (as reported by cargo
kcov)
This commit is contained in:
Michael Bryan 2017-07-03 07:34:03 +08:00
parent 0a035e3a49
commit bce3297bbb
5 changed files with 302 additions and 36 deletions

View File

@ -3,8 +3,7 @@
//! **mdBook** is similar to Gitbook but implemented in Rust. //! **mdBook** is similar to Gitbook but implemented in Rust.
//! It offers a command line interface, but can also be used as a regular crate. //! It offers a command line interface, but can also be used as a regular crate.
//! //!
//! This is the API doc, but you can find a [less "low-level" documentation //! This is the API doc, but you can find a [less "low-level" documentation here](../index.html) that
//! here](../index.html) that
//! contains information about the command line tool, format, structure etc. //! contains information about the command line tool, format, structure etc.
//! It is also rendered with mdBook to showcase the features and default theme. //! It is also rendered with mdBook to showcase the features and default theme.
//! //!
@ -26,23 +25,17 @@
//! # #[allow(unused_variables)] //! # #[allow(unused_variables)]
//! fn main() { //! fn main() {
//! let mut book = MDBook::new("my-book") // Path to root //! let mut book = MDBook::new("my-book") // Path to root
//! .with_source("src") // Path from root to //! .with_source("src") // Path from root to source directory
//! source directory //! .with_destination("book") // Path from root to output directory
//! .with_destination("book") // Path from root to //! .read_config() // Parse book.toml configuration file
//! output directory //! .expect("I don't handle configuration file errors, but you should!");
//! .read_config() // Parse book.toml
//! configuration file
//! .expect("I don't handle configuration file errors,
//! but you should!");
//!
//! book.build().unwrap(); // Render the book //! book.build().unwrap(); // Render the book
//! } //! }
//! ``` //! ```
//! //!
//! ## Implementing a new Renderer //! ## Implementing a new Renderer
//! //!
//! If you want to create a new renderer for mdBook, the only thing you have to //! If you want to create a new renderer for mdBook, the only thing you have to do is to implement
//! do is to implement
//! the [Renderer trait](renderer/renderer/trait.Renderer.html) //! the [Renderer trait](renderer/renderer/trait.Renderer.html)
//! //!
//! And then you can swap in your renderer like this: //! And then you can swap in your renderer like this:
@ -57,21 +50,18 @@
//! # fn main() { //! # fn main() {
//! # let your_renderer = HtmlHandlebars::new(); //! # let your_renderer = HtmlHandlebars::new();
//! # //! #
//! let book = //! let book = MDBook::new("my-book").set_renderer(Box::new(your_renderer));
//! MDBook::new("my-book").set_renderer(Box::new(your_renderer));
//! # } //! # }
//! ``` //! ```
//! If you make a renderer, you get the book constructed in form of //! If you make a renderer, you get the book constructed in form of `Vec<BookItems>` and you get
//! `Vec<BookItems>` and you get
//! the book config in a `BookConfig` struct. //! the book config in a `BookConfig` struct.
//! //!
//! It's your responsability to create the necessary files in the correct //! It's your responsibility to create the necessary files in the correct
//! directories. //! directories.
//! //!
//! ## utils //! ## utils
//! //!
//! I have regrouped some useful functions in the [utils](utils/index.html) //! I have regrouped some useful functions in the [utils](utils/index.html) module, like the
//! module, like the
//! following function [`utils::fs::create_file(path: //! following function [`utils::fs::create_file(path:
//! &Path)`](utils/fs/fn.create_file.html) //! &Path)`](utils/fs/fn.create_file.html)
//! //!
@ -99,6 +89,8 @@ extern crate serde_json;
#[cfg(test)] #[cfg(test)]
#[macro_use] #[macro_use]
extern crate pretty_assertions; extern crate pretty_assertions;
#[cfg(test)]
extern crate tempdir;
mod parse; mod parse;
mod preprocess; mod preprocess;

234
src/loader/book.rs Normal file
View File

@ -0,0 +1,234 @@
#![allow(missing_docs, unused_variables, unused_imports, dead_code)]
use std::path::Path;
use std::fs::File;
use std::io::Read;
use loader::summary::{Summary, Link, SummaryItem, SectionNumber};
use errors::*;
/// A dumb tree structure representing a book.
///
/// For the moment a book is just a collection of `BookItems`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Book {
/// The sections in this book.
pub sections: Vec<BookItem>,
}
impl Book {
/// Create an empty book.
pub fn new() -> Self {
Default::default()
}
}
/// Enum representing any type of item which can be added to a book.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BookItem {
/// A nested chapter.
Chapter(Chapter),
/// A section separator.
Separator,
}
/// The representation of a "chapter", usually mapping to a single file on
/// disk however it may contain multiple sub-chapters.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Chapter {
/// The chapter's name.
pub name: String,
/// The chapter's contents.
pub content: String,
/// The chapter's section number, if it has one.
pub number: Option<SectionNumber>,
/// Nested items.
pub sub_items: Vec<BookItem>,
}
impl Chapter {
/// Create a new chapter with the provided content.
pub fn new(name: &str, content: String) -> Chapter {
Chapter {
name: name.to_string(),
content: content,
..Default::default()
}
}
}
/// Use the provided `Summary` to load a `Book` from disk.
///
/// You need to pass in the book's source directory because all the links in
/// `SUMMARY.md` give the chapter locations relative to it.
pub fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<Book> {
debug!("[*] Loading the book from disk");
let src_dir = src_dir.as_ref();
let prefix = summary.prefix_chapters.iter();
let numbered = summary.numbered_chapters.iter();
let suffix = summary.suffix_chapters.iter();
let summary_items = prefix.chain(numbered).chain(suffix);
let chapters = summary_items
.map(|i| load_summary_item(i, src_dir))
.collect::<Result<_>>()
.chain_err(|| "Couldn't load chapters from disk")?;
Ok(Book { sections: chapters })
}
fn load_summary_item<P: AsRef<Path>>(item: &SummaryItem, src_dir: P) -> Result<BookItem> {
match *item {
SummaryItem::Separator => Ok(BookItem::Separator),
SummaryItem::Link(ref link) => load_chapter(link, src_dir).map(|c| BookItem::Chapter(c)),
}
}
fn load_chapter<P: AsRef<Path>>(link: &Link, src_dir: P) -> Result<Chapter> {
debug!("[*] Loading {} ({})", link.name, link.location.display());
let src_dir = src_dir.as_ref();
let location = if link.location.is_absolute() {
link.location.clone()
} else {
src_dir.join(&link.location)
};
let mut f = File::open(location).chain_err(|| {
format!("Chapter file not found, {}", link.location.display())
})?;
let mut content = String::new();
f.read_to_string(&mut content)?;
let mut ch = Chapter::new(&link.name, content);
ch.number = link.number.clone();
let sub_items = link.nested_items
.iter()
.map(|i| load_summary_item(i, src_dir))
.collect::<Result<Vec<_>>>()?;
ch.sub_items = sub_items;
Ok(ch)
}
#[cfg(test)]
mod tests {
use super::*;
use tempdir::TempDir;
use std::io::Write;
const DUMMY_SRC: &'static str = "
# Dummy Chapter
this is some dummy text.
And here is some more text.
";
/// Create a dummy `Link` in a temporary directory.
fn dummy_link() -> (Link, TempDir) {
let temp = TempDir::new("book").unwrap();
let chapter_path = temp.path().join("chapter_1.md");
File::create(&chapter_path)
.unwrap()
.write(DUMMY_SRC.as_bytes())
.unwrap();
let link = Link::new("Chapter 1", chapter_path);
(link, temp)
}
/// Create a nested `Link` written to a temporary directory.
fn nested_links() -> (Link, TempDir) {
let (mut root, temp_dir) = dummy_link();
let second_path = temp_dir.path().join("second.md");
File::create(&second_path)
.unwrap()
.write_all("Hello World!".as_bytes())
.unwrap();
let mut second = Link::new("Nested Chapter 1", &second_path);
second.number = Some(SectionNumber(vec![1, 2]));
root.push_item(second.clone());
root.push_item(SummaryItem::Separator);
root.push_item(second.clone());
(root, temp_dir)
}
#[test]
fn load_a_single_chapter_from_disk() {
let (link, temp_dir) = dummy_link();
let should_be = Chapter::new("Chapter 1", DUMMY_SRC.to_string());
let got = load_chapter(&link, temp_dir.path()).unwrap();
assert_eq!(got, should_be);
}
#[test]
fn cant_load_a_nonexistent_chapter() {
let link = Link::new("Chapter 1", "/foo/bar/baz.md");
let got = load_chapter(&link, "");
assert!(got.is_err());
}
#[test]
fn load_recursive_link_with_separators() {
let (root, _temp) = nested_links();
let nested = Chapter {
name: String::from("Nested Chapter 1"),
content: String::from("Hello World!"),
number: Some(SectionNumber(vec![1, 2])),
sub_items: Vec::new(),
};
let should_be = BookItem::Chapter(Chapter {
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
number: None,
sub_items: vec![
BookItem::Chapter(nested.clone()),
BookItem::Separator,
BookItem::Chapter(nested.clone()),
],
});
let got = load_summary_item(&SummaryItem::Link(root), "").unwrap();
assert_eq!(got, should_be);
}
#[test]
fn load_a_book_with_a_single_chapter() {
let (link, temp) = dummy_link();
let summary = Summary {
numbered_chapters: vec![SummaryItem::Link(link)],
..Default::default()
};
let should_be = Book {
sections: vec![
BookItem::Chapter(Chapter {
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
..Default::default()
}),
],
};
let got = load_book_from_disk(&summary, "").unwrap();
assert_eq!(got, should_be);
}
}

View File

@ -46,8 +46,10 @@ use std::io::Read;
use errors::*; use errors::*;
mod summary; mod summary;
mod book;
pub use self::summary::{Summary, Link, SummaryItem, parse_summary, SectionNumber}; pub use self::summary::{Summary, Link, SummaryItem, parse_summary, SectionNumber};
pub use self::book::{Book, load_book_from_disk, BookItem, Chapter};
/// The object in charge of parsing the source directory into a usable /// The object in charge of parsing the source directory into a usable
@ -64,21 +66,33 @@ impl Loader {
} }
/// Parse the summary file and use it to load a book from disk. /// Parse the summary file and use it to load a book from disk.
pub fn load(&self) -> Result<()> { pub fn load(&self) -> Result<Book> {
let summary = self.parse_summary().chain_err( let summary_md = self.find_summary().chain_err(
|| "Couldn't find `SUMMARY.md`",
)?;
let summary = self.parse_summary(&summary_md).chain_err(
|| "Couldn't parse `SUMMARY.md`", || "Couldn't parse `SUMMARY.md`",
)?; )?;
unimplemented!() let src_dir = match summary_md.parent() {
Some(parent) => parent,
None => bail!("SUMMARY.md doesn't have a parent... wtf?"),
};
load_book_from_disk(&summary, src_dir)
} }
/// Parse the `SUMMARY.md` file. /// Parse a `SUMMARY.md` file.
pub fn parse_summary(&self) -> Result<Summary> { pub fn parse_summary<P: AsRef<Path>>(&self, summary_md: P) -> Result<Summary> {
let path = self.source_directory.join("SUMMARY.md");
let mut summary_content = String::new(); let mut summary_content = String::new();
File::open(&path)?.read_to_string(&mut summary_content)?; File::open(summary_md)?.read_to_string(&mut summary_content)?;
summary::parse_summary(&summary_content) summary::parse_summary(&summary_content)
} }
fn find_summary(&self) -> Result<PathBuf> {
// TODO: use Piston's find_folder to make locating SUMMARY.md easier.
// https://github.com/PistonDevelopers/find_folder
Ok(self.source_directory.join("SUMMARY.md"))
}
} }

View File

@ -50,7 +50,7 @@ pub fn parse_summary(summary: &str) -> Result<Summary> {
} }
/// The parsed `SUMMARY.md`, specifying how the book should be laid out. /// The parsed `SUMMARY.md`, specifying how the book should be laid out.
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Summary { pub struct Summary {
/// An optional title for the `SUMMARY.md`, currently just ignored. /// An optional title for the `SUMMARY.md`, currently just ignored.
pub title: Option<String>, pub title: Option<String>,
@ -66,7 +66,7 @@ pub struct Summary {
/// entries. /// entries.
/// ///
/// This is roughly the equivalent of `[Some section](./path/to/file.md)`. /// This is roughly the equivalent of `[Some section](./path/to/file.md)`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Link { pub struct Link {
/// The name of the chapter. /// The name of the chapter.
pub name: String, pub name: String,
@ -89,6 +89,11 @@ impl Link {
nested_items: Vec::new(), nested_items: Vec::new(),
} }
} }
/// Add an item to this link's `nested_items`.
pub fn push_item<I: Into<SummaryItem>>(&mut self, item: I) {
self.nested_items.push(item.into());
}
} }
impl Default for Link { impl Default for Link {
@ -103,7 +108,7 @@ impl Default for Link {
} }
/// An item in `SUMMARY.md` which could be either a separator or a `Link`. /// An item in `SUMMARY.md` which could be either a separator or a `Link`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SummaryItem { pub enum SummaryItem {
/// A link to a chapter. /// A link to a chapter.
Link(Link), Link(Link),
@ -120,6 +125,12 @@ impl SummaryItem {
} }
} }
impl From<Link> for SummaryItem {
fn from(other: Link) -> SummaryItem {
SummaryItem::Link(other)
}
}
#[derive(Debug, Copy, Clone, PartialEq)] #[derive(Debug, Copy, Clone, PartialEq)]
enum State { enum State {
Begin, Begin,
@ -310,8 +321,9 @@ impl<'a> SummaryParser<'a> {
fn step_numbered(&mut self, event: Event, nesting: u32) -> Result<()> { fn step_numbered(&mut self, event: Event, nesting: u32) -> Result<()> {
match event { match event {
Event::Start(Tag::Item) => { Event::Start(Tag::Item) => {
let it = self.parse_item() let it = self.parse_item().chain_err(
.chain_err(|| "List items should only contain links")?; || "List items should only contain links",
)?;
debug!("[*] Found a chapter: {:?} ({})", it.name, it.location.display()); debug!("[*] Found a chapter: {:?} ({})", it.name, it.location.display());
let section_number = self.push_numbered_section(SummaryItem::Link(it)); let section_number = self.push_numbered_section(SummaryItem::Link(it));
@ -479,7 +491,7 @@ fn stringify_events(events: Vec<Event>) -> String {
/// A section number like "1.2.3", basically just a newtype'd `Vec<u32>` with /// A section number like "1.2.3", basically just a newtype'd `Vec<u32>` with
/// a pretty `Display` impl. /// a pretty `Display` impl.
#[derive(Debug, PartialEq, Clone, Default)] #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
pub struct SectionNumber(pub Vec<u32>); pub struct SectionNumber(pub Vec<u32>);
impl Display for SectionNumber { impl Display for SectionNumber {

View File

@ -49,11 +49,14 @@ fn parse_summary_using_loader() {
let temp = TempDir::new("book").unwrap(); let temp = TempDir::new("book").unwrap();
let summary_md = temp.path().join("SUMMARY.md"); let summary_md = temp.path().join("SUMMARY.md");
File::create(&summary_md).unwrap().write_all(SUMMARY.as_bytes()).unwrap(); File::create(&summary_md)
.unwrap()
.write_all(SUMMARY.as_bytes())
.unwrap();
let loader = Loader::new(temp.path()); let loader = Loader::new(temp.path());
let got = loader.parse_summary().unwrap(); let got = loader.parse_summary(&summary_md).unwrap();
let should_be = expected_summary(); let should_be = expected_summary();
assert_eq!(got, should_be); assert_eq!(got, should_be);
@ -107,3 +110,14 @@ fn expected_summary() -> Summary {
], ],
} }
} }
#[test]
fn load_the_example_book() {
let example_src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("book-example")
.join("src");
let loader = Loader::new(example_src_dir);
let book = loader.load().unwrap();
println!("{:#?}", book);
}