diff --git a/Cargo.toml b/Cargo.toml index ca9e9d3df3..1c74008a13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,9 @@ ws = { version = "0.7", optional = true} [build-dependencies] error-chain = "0.11" +[dev-dependencies] +pretty_assertions = "0.2" + [features] default = ["output", "watch", "serve"] debug = [] diff --git a/book-example/src/misc/contributors.md b/book-example/src/misc/contributors.md index 36b50fad24..6c91f660fd 100644 --- a/book-example/src/misc/contributors.md +++ b/book-example/src/misc/contributors.md @@ -11,3 +11,4 @@ If you have contributed to mdBook and I forgot to add you, don't hesitate to add - Wayne Nilsen ([waynenilsen](https://github.com/waynenilsen)) - [funnkill](https://github.com/funkill) - Fu Gangqiang ([FuGangqiang](https://github.com/FuGangqiang)) +- Michael Bryan ([Michael-F-Bryan](https://github.com/Michael-F-Bryan)) diff --git a/src/bin/build.rs b/src/bin/build.rs index 1a296ee543..123fccc1b6 100644 --- a/src/bin/build.rs +++ b/src/bin/build.rs @@ -9,7 +9,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { .about("Build the book from the markdown files") .arg_from_usage("-o, --open 'Open the compiled book in a web browser'") .arg_from_usage("-d, --dest-dir=[dest-dir] 'The output directory for your book{n}(Defaults to ./book when omitted)'") - .arg_from_usage("--no-create 'Will not create non-existent files linked from SUMMARY.md'") + .arg_from_usage("--create 'Will create non-existent files linked from SUMMARY.md'") .arg_from_usage("--curly-quotes 'Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans'") .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'") } @@ -17,21 +17,20 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { // Build command implementation pub fn execute(args: &ArgMatches) -> Result<()> { let book_dir = get_book_dir(args); - let book = MDBook::new(&book_dir).read_config()?; + let book = MDBook::new(&book_dir); let mut book = match args.value_of("dest-dir") { Some(dest_dir) => book.with_destination(dest_dir), None => book, }; - if args.is_present("no-create") { - book.create_missing = false; - } + book.create_missing = args.is_present("create"); if args.is_present("curly-quotes") { book = book.with_curly_quotes(true); } + book = book.read_config()?; book.build()?; if args.is_present("open") { diff --git a/src/book/book.rs b/src/book/book.rs new file mode 100644 index 0000000000..0991427193 --- /dev/null +++ b/src/book/book.rs @@ -0,0 +1,392 @@ +use std::fmt::{self, Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::collections::VecDeque; +use std::fs::File; +use std::io::{Read, Write}; + +use super::summary::{parse_summary, Summary, Link, SummaryItem, SectionNumber}; +use errors::*; + + +/// Load a book into memory from its `src/` directory. +pub fn load_book>(src_dir: P, create_if_not_present: bool) -> Result { + let src_dir = src_dir.as_ref(); + let summary_md = src_dir.join("SUMMARY.md"); + + let mut summary_content = String::new(); + File::open(summary_md) + .chain_err(|| "Couldn't open SUMMARY.md")? + .read_to_string(&mut summary_content)?; + + let summary = parse_summary(&summary_content).chain_err(|| "Summary parsing failed")?; + + load_book_from_disk(&summary, src_dir, create_if_not_present) +} + + +/// 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, +} + +impl Book { + /// Create an empty book. + pub fn new() -> Self { + Default::default() + } + + /// Get a depth-first iterator over the items in the book. + pub fn iter(&self) -> BookItems { + BookItems { items: self.sections.iter().collect() } + } +} + +/// 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, + /// Nested items. + pub sub_items: Vec, + /// The chapter's location, relative to the `SUMMARY.md` file. + pub path: PathBuf, +} + +impl Chapter { + /// Create a new chapter with the provided content. + pub fn new>(name: &str, content: String, path: P) -> Chapter { + Chapter { + name: name.to_string(), + content: content, + path: path.into(), + ..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. +fn load_book_from_disk>(summary: &Summary, src_dir: P, create_if_not_present: bool) -> Result { + 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 mut chapters = Vec::new(); + + for summary_item in summary_items { + let chapter = load_summary_item(summary_item, src_dir, create_if_not_present)?; + chapters.push(chapter); + } + + Ok(Book { sections: chapters }) +} + +fn load_summary_item>(item: &SummaryItem, src_dir: P, create_if_not_present: bool) -> Result { + match *item { + SummaryItem::Separator => Ok(BookItem::Separator), + SummaryItem::Link(ref link) => { + let file = src_dir.as_ref().join(&link.location); + + if create_if_not_present && !file.exists() { + let text = format!("# {}", link.name); + File::create(&file)?.write_all(text.as_bytes())?; + } + + load_chapter(link, src_dir).map(|c| BookItem::Chapter(c)) + }, + } +} + +fn load_chapter>(link: &Link, src_dir: P) -> Result { + 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 stripped = location.strip_prefix(&src_dir).expect("Chapters are always inside a book"); + + let mut ch = Chapter::new(&link.name, content, stripped); + ch.number = link.number.clone(); + + let sub_items = link.nested_items + .iter() + .map(|i| load_summary_item(i, src_dir, false)) + .collect::>>()?; + + ch.sub_items = sub_items; + + Ok(ch) +} + +/// A depth-first iterator over the items in a book. +/// +/// # Note +/// +/// This struct shouldn't be created directly, instead prefer the +/// [`Book::iter()`] method. +/// +/// [`Book::iter()`]: struct.Book.html#method.iter +pub struct BookItems<'a> { + items: VecDeque<&'a BookItem>, +} + +impl<'a> Iterator for BookItems<'a> { + type Item = &'a BookItem; + + fn next(&mut self) -> Option { + let item = self.items.pop_front(); + + if let Some(&BookItem::Chapter(ref ch)) = item { + // if we wanted a breadth-first iterator we'd `extend()` here + for sub_item in ch.sub_items.iter().rev() { + self.items.push_front(sub_item); + } + } + + item + } +} + +impl Display for Chapter { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + if let Some(ref section_number) = self.number { + write!(f, "{} ", section_number)?; + } + + write!(f, "{}", self.name) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use tempdir::TempDir; + use std::io::Write; + use std::fs; + + 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.nested_items.push(second.clone().into()); + root.nested_items.push(SummaryItem::Separator); + root.nested_items.push(second.clone().into()); + + (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(), "chapter_1.md"); + + 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])), + path: PathBuf::from("second.md"), + sub_items: Vec::new(), + }; + let should_be = BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: PathBuf::from("chapter_1.md"), + sub_items: vec![ + BookItem::Chapter(nested.clone()), + BookItem::Separator, + BookItem::Chapter(nested.clone()), + ], + }); + + let got = load_summary_item(&SummaryItem::Link(root), temp.path(), false).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), + path: PathBuf::from("chapter_1.md"), + ..Default::default() + }), + ], + }; + + let got = load_book_from_disk(&summary, temp.path(), false).unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn book_iter_iterates_over_sequential_items() { + let book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + ..Default::default() + }), + BookItem::Separator, + ], + }; + + let should_be: Vec<_> = book.sections.iter().collect(); + + let got: Vec<_> = book.iter().collect(); + + assert_eq!(got, should_be); + } + + #[test] + fn iterate_over_nested_book_items() { + let book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: PathBuf::from("Chapter_1/index.md"), + sub_items: vec![ + BookItem::Chapter(Chapter::new("Hello World", String::new(), "Chapter_1/hello.md")), + BookItem::Separator, + BookItem::Chapter(Chapter::new("Goodbye World", String::new(), "Chapter_1/goodbye.md")), + ], + }), + BookItem::Separator, + ], + }; + + + let got: Vec<_> = book.iter().collect(); + + assert_eq!(got.len(), 5); + + // checking the chapter names are in the order should be sufficient here... + let chapter_names: Vec = got.into_iter() + .filter_map(|i| match *i { + BookItem::Chapter(ref ch) => Some(ch.name.clone()), + _ => None, + }) + .collect(); + let should_be: Vec<_> = vec![ + String::from("Chapter 1"), + String::from("Hello World"), + String::from("Goodbye World"), + ]; + + assert_eq!(chapter_names, should_be); + } + + #[test] + fn create_missing_book_items() { + let (link, temp) = dummy_link(); + let summary = Summary { + numbered_chapters: vec![SummaryItem::Link(link)], + ..Default::default() + }; + + let chapter_1 = temp.path().join("chapter_1.md"); + fs::remove_file(&chapter_1).unwrap(); + assert!(!chapter_1.exists()); + + load_book_from_disk(&summary, temp.path(), true).unwrap(); + + assert!(chapter_1.exists()); + } +} diff --git a/src/book/bookitem.rs b/src/book/bookitem.rs deleted file mode 100644 index 7fe7ab5528..0000000000 --- a/src/book/bookitem.rs +++ /dev/null @@ -1,87 +0,0 @@ -use serde::{Serialize, Serializer}; -use serde::ser::SerializeStruct; -use std::path::PathBuf; - - -#[derive(Debug, Clone)] -pub enum BookItem { - Chapter(String, Chapter), // String = section - Affix(Chapter), - Spacer, -} - -#[derive(Debug, Clone)] -pub struct Chapter { - pub name: String, - pub path: PathBuf, - pub sub_items: Vec, -} - -#[derive(Debug, Clone)] -pub struct BookItems<'a> { - pub items: &'a [BookItem], - pub current_index: usize, - pub stack: Vec<(&'a [BookItem], usize)>, -} - - -impl Chapter { - pub fn new(name: String, path: PathBuf) -> Self { - - Chapter { - name: name, - path: path, - sub_items: vec![], - } - } -} - - -impl Serialize for Chapter { - fn serialize(&self, serializer: S) -> ::std::result::Result - where S: Serializer - { - let mut struct_ = serializer.serialize_struct("Chapter", 2)?; - struct_.serialize_field("name", &self.name)?; - struct_.serialize_field("path", &self.path)?; - struct_.end() - } -} - - - -// Shamelessly copied from Rustbook -// (https://github.com/rust-lang/rust/blob/master/src/rustbook/book.rs) -impl<'a> Iterator for BookItems<'a> { - type Item = &'a BookItem; - - fn next(&mut self) -> Option<&'a BookItem> { - loop { - if self.current_index >= self.items.len() { - match self.stack.pop() { - None => return None, - Some((parent_items, parent_idx)) => { - self.items = parent_items; - self.current_index = parent_idx + 1; - }, - } - } else { - let cur = &self.items[self.current_index]; - - match *cur { - BookItem::Chapter(_, ref ch) | - BookItem::Affix(ref ch) => { - self.stack.push((self.items, self.current_index)); - self.items = &ch.sub_items[..]; - self.current_index = 0; - }, - BookItem::Spacer => { - self.current_index += 1; - }, - } - - return Some(cur); - } - } - } -} diff --git a/src/book/mod.rs b/src/book/mod.rs index e8896e0aca..77bd6ed188 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -1,6 +1,10 @@ -pub mod bookitem; +//! The internal representation of a `Book`. -pub use self::bookitem::{BookItem, BookItems}; +mod book; +mod summary; + +pub use self::book::{load_book, Book, BookItem, BookItems, Chapter}; +pub use self::summary::SectionNumber; use std::path::{Path, PathBuf}; use std::fs::{self, File}; @@ -8,8 +12,9 @@ use std::io::{Read, Write}; use std::process::Command; use tempdir::TempDir; -use {theme, parse, utils}; -use renderer::{Renderer, HtmlHandlebars}; +use {theme, utils}; +use renderer::{HtmlHandlebars, Renderer}; +use self::summary::SummaryItem; use preprocess; use errors::*; @@ -18,10 +23,16 @@ use config::tomlconfig::TomlConfig; use config::htmlconfig::HtmlConfig; use config::jsonconfig::JsonConfig; + +const STUB_SUMMARY_CONTENTS: &'static str = "# Summary\n\n- [Chapter 1](./chapter_1.md)]"; +const STUB_CHAPTER_1: &'static str = "# Chapter 1\n"; + +/// A helper for managing the `Book`, its configuration, and the rendering +/// process. pub struct MDBook { config: BookConfig, - pub content: Vec, + pub content: Option, renderer: Box, livereload: Option, @@ -60,7 +71,6 @@ impl MDBook { /// [`set_dest()`](#method.set_dest) pub fn new>(root: P) -> MDBook { - let root = root.into(); if !root.exists() || !root.is_dir() { warn!("{:?} No directory with that name", root); @@ -69,11 +79,11 @@ impl MDBook { MDBook { config: BookConfig::new(root), - content: vec![], + content: None, renderer: Box::new(HtmlHandlebars::new()), livereload: None, - create_missing: true, + create_missing: false, } } @@ -84,17 +94,17 @@ impl MDBook { /// ```no_run /// # extern crate mdbook; /// # use mdbook::MDBook; - /// # use mdbook::BookItem; + /// # use mdbook::book::BookItem; /// # #[allow(unused_variables)] /// # fn main() { /// # let book = MDBook::new("mybook"); /// for item in book.iter() { - /// match item { - /// &BookItem::Chapter(ref section, ref chapter) => {}, - /// &BookItem::Affix(ref chapter) => {}, - /// &BookItem::Spacer => {}, + /// match *item { + /// BookItem::Chapter(ref chapter) => println!("{}", chapter), + /// BookItem::Separator => {}, /// } /// } + /// panic!(); /// /// // would print something like this: /// // 1. Chapter 1 @@ -107,11 +117,10 @@ impl MDBook { /// ``` pub fn iter(&self) -> BookItems { - BookItems { - items: &self.content[..], - current_index: 0, - stack: Vec::new(), - } + self.content + .as_ref() + .expect("Trying to iterate over a book before it is loaded. This is a bug") + .iter() } /// `init()` creates some boilerplate files and directories @@ -128,89 +137,83 @@ impl MDBook { /// It uses the paths given as source and output directories /// and adds a `SUMMARY.md` and a /// `chapter_1.md` to the source directory. - pub fn init(&mut self) -> Result<()> { - debug!("[fn]: init"); - if !self.config.get_root().exists() { - fs::create_dir_all(&self.config.get_root()).unwrap(); - info!("{:?} created", &self.config.get_root()); - } + self.create_book_directories()?; + self.create_stub_files()?; - { + self.parse_summary() + .chain_err(|| "Couldn't parse the SUMMARY.md file")?; - if !self.get_destination().exists() { - debug!("[*]: {:?} does not exist, trying to create directory", self.get_destination()); - fs::create_dir_all(self.get_destination())?; - } + debug!("[*]: init done"); + Ok(()) + } + fn create_book_directories(&self) -> Result<()> { + debug!("[*] Creating directories"); - if !self.config.get_source().exists() { - debug!("[*]: {:?} does not exist, trying to create directory", self.config.get_source()); - fs::create_dir_all(self.config.get_source())?; - } + let root = self.config.get_root(); + let dest = self.get_destination(); + let src = self.config.get_source(); + + let necessary_folders = &[root, dest, src]; - let summary = self.config.get_source().join("SUMMARY.md"); + for folder in necessary_folders { + if !folder.exists() { + fs::create_dir_all(folder)?; + debug!("{} created", folder.display()); + } + } - if !summary.exists() { + Ok(()) + } - // Summary does not exist, create it - debug!("[*]: {:?} does not exist, trying to create SUMMARY.md", &summary); - let mut f = File::create(&summary)?; + fn create_stub_files(&self) -> Result<()> { + debug!("[*] Creating stub files"); - debug!("[*]: Writing to SUMMARY.md"); + let src = self.config.get_source(); + let summary = src.join("SUMMARY.md"); - writeln!(f, "# Summary")?; - writeln!(f, "")?; - writeln!(f, "- [Chapter 1](./chapter_1.md)")?; + if summary.exists() { + if self.create_missing { + // As a special case, if we run "mdbook init" on a book which + // already has a summary we'll read that summary and create + // stubs for any files which don't already exist (@azerupi likes + // having access to this shortcut). + return create_files_from_summary(&src, &summary); + } else { + return Ok(()); } } - // parse SUMMARY.md, and create the missing item related file - self.parse_summary()?; + debug!("[*]: Creating SUMMARY.md"); + let mut f = File::create(&summary)?; + writeln!(f, "{}", STUB_SUMMARY_CONTENTS)?; - debug!("[*]: constructing paths for missing files"); - for item in self.iter() { - debug!("[*]: item: {:?}", item); - let ch = match *item { - BookItem::Spacer => continue, - BookItem::Chapter(_, ref ch) | - BookItem::Affix(ref ch) => ch, - }; - if !ch.path.as_os_str().is_empty() { - let path = self.config.get_source().join(&ch.path); - - if !path.exists() { - if !self.create_missing { - return Err(format!("'{}' referenced from SUMMARY.md does not exist.", path.to_string_lossy()) - .into()); - } - debug!("[*]: {:?} does not exist, trying to create file", path); - ::std::fs::create_dir_all(path.parent().unwrap())?; - let mut f = File::create(path)?; + let ch_1 = src.join("chapter_1.md"); + if !ch_1.exists() { + debug!("[*] Creating {}", ch_1.display()); - // debug!("[*]: Writing to {:?}", path); - writeln!(f, "# {}", ch.name)?; - } - } + let mut f = File::create(&ch_1)?; + writeln!(f, "{}", STUB_CHAPTER_1)?; } - debug!("[*]: init done"); Ok(()) } pub fn create_gitignore(&self) { let gitignore = self.get_gitignore(); - let destination = self.config.get_html_config() - .get_destination(); + let destination = self.config.get_html_config().get_destination(); - // Check that the gitignore does not extist and that the destination path begins with the root path - // We assume tha if it does begin with the root path it is contained within. This assumption - // will not hold true for paths containing double dots to go back up e.g. `root/../destination` + // Check that the gitignore does not exist and that the destination path + // begins with the root path + // We assume tha if it does begin with the root path it is contained within. + // This assumption + // will not hold true for paths containing double dots to go back up e.g. + // `root/../destination` if !gitignore.exists() && destination.starts_with(self.config.get_root()) { - let relative = destination .strip_prefix(self.config.get_root()) .expect("Could not strip the root prefix, path is not relative to root") @@ -244,7 +247,6 @@ impl MDBook { self.renderer.render(self) } - pub fn get_gitignore(&self) -> PathBuf { self.config.get_root().join(".gitignore") } @@ -286,8 +288,7 @@ impl MDBook { } pub fn write_file>(&self, filename: P, content: &[u8]) -> Result<()> { - let path = self.get_destination() - .join(filename); + let path = self.get_destination().join(filename); utils::fs::create_file(&path)? .write_all(content) @@ -300,7 +301,6 @@ impl MDBook { /// The root directory is the one specified when creating a new `MDBook` pub fn read_config(mut self) -> Result { - let toml = self.get_root().join("book.toml"); let json = self.get_root().join("book.json"); @@ -356,31 +356,33 @@ impl MDBook { pub fn test(&mut self, library_paths: Vec<&str>) -> Result<()> { // read in the chapters self.parse_summary().chain_err(|| "Couldn't parse summary")?; - let library_args: Vec<&str> = (0..library_paths.len()).map(|_| "-L") - .zip(library_paths.into_iter()) - .flat_map(|x| vec![x.0, x.1]) - .collect(); + let library_args: Vec<&str> = (0..library_paths.len()) + .map(|_| "-L") + .zip(library_paths.into_iter()) + .flat_map(|x| vec![x.0, x.1]) + .collect(); let temp_dir = TempDir::new("mdbook")?; for item in self.iter() { - - if let BookItem::Chapter(_, ref ch) = *item { - if !ch.path.as_os_str().is_empty() { - + if let BookItem::Chapter(ref ch) = *item { + if ch.path != PathBuf::new() { let path = self.get_source().join(&ch.path); - let base = path.parent().ok_or_else( - || String::from("Invalid bookitem path!"), - )?; + let base = path.parent() + .ok_or_else(|| String::from("Invalid bookitem path!"))?; let content = utils::fs::file_to_string(&path)?; // Parse and expand links let content = preprocess::links::replace_all(&content, base)?; println!("[*]: Testing file: {:?}", path); - //write preprocessed file to tempdir + // write preprocessed file to tempdir let path = temp_dir.path().join(&ch.path); let mut tmpf = utils::fs::create_file(&path)?; tmpf.write_all(content.as_bytes())?; - let output = Command::new("rustdoc").arg(&path).arg("--test").args(&library_args).output()?; + let output = Command::new("rustdoc") + .arg(&path) + .arg("--test") + .args(&library_args) + .output()?; if !output.status.success() { bail!(ErrorKind::Subprocess("Rustdoc returned an error".to_string(), output)); @@ -398,15 +400,15 @@ impl MDBook { pub fn with_destination>(mut self, destination: T) -> Self { let root = self.config.get_root().to_owned(); - self.config.get_mut_html_config() + self.config + .get_mut_html_config() .set_destination(&root, &destination.into()); self } pub fn get_destination(&self) -> &Path { - self.config.get_html_config() - .get_destination() + self.config.get_html_config().get_destination() } pub fn with_source>(mut self, source: T) -> Self { @@ -452,61 +454,56 @@ impl MDBook { pub fn with_theme_path>(mut self, theme_path: T) -> Self { let root = self.config.get_root().to_owned(); - self.config.get_mut_html_config() + self.config + .get_mut_html_config() .set_theme(&root, &theme_path.into()); self } pub fn get_theme_path(&self) -> &Path { - self.config.get_html_config() - .get_theme() + self.config.get_html_config().get_theme() } pub fn with_curly_quotes(mut self, curly_quotes: bool) -> Self { - self.config.get_mut_html_config() + self.config + .get_mut_html_config() .set_curly_quotes(curly_quotes); self } pub fn get_curly_quotes(&self) -> bool { - self.config.get_html_config() - .get_curly_quotes() + self.config.get_html_config().get_curly_quotes() } pub fn with_mathjax_support(mut self, mathjax_support: bool) -> Self { - self.config.get_mut_html_config() + self.config + .get_mut_html_config() .set_mathjax_support(mathjax_support); self } pub fn get_mathjax_support(&self) -> bool { - self.config.get_html_config() - .get_mathjax_support() + self.config.get_html_config().get_mathjax_support() } pub fn get_google_analytics_id(&self) -> Option { - self.config.get_html_config() - .get_google_analytics_id() + self.config.get_html_config().get_google_analytics_id() } pub fn has_additional_js(&self) -> bool { - self.config.get_html_config() - .has_additional_js() + self.config.get_html_config().has_additional_js() } pub fn get_additional_js(&self) -> &[PathBuf] { - self.config.get_html_config() - .get_additional_js() + self.config.get_html_config().get_additional_js() } pub fn has_additional_css(&self) -> bool { - self.config.get_html_config() - .has_additional_css() + self.config.get_html_config().has_additional_css() } pub fn get_additional_css(&self) -> &[PathBuf] { - self.config.get_html_config() - .get_additional_css() + self.config.get_html_config().get_additional_css() } pub fn get_html_config(&self) -> &HtmlConfig { @@ -515,8 +512,54 @@ impl MDBook { // Construct book fn parse_summary(&mut self) -> Result<()> { - // When append becomes stable, use self.content.append() ... - self.content = parse::construct_bookitems(&self.get_source().join("SUMMARY.md"))?; + let src = self.config.get_source(); + let book = load_book(&src, self.create_missing)?; + + self.content = Some(book); Ok(()) } } + +fn create_files_from_summary(src: &Path, summary_path: &Path) -> Result<()> { + debug!("[fn]: create_files_from_summary"); + let summary = summary::parse_summary(&utils::fs::file_to_string(summary_path)?)?; + debug!("[*]: parsed existing summary"); + trace!("[*]: {:#?}", summary); + + create_list_of_stub_chapters(&summary.prefix_chapters, src)?; + create_list_of_stub_chapters(&summary.numbered_chapters, src)?; + create_list_of_stub_chapters(&summary.suffix_chapters, src)?; + + debug!("[*]: Finished creating stub chapters using for a SUMMARY.md"); + Ok(()) +} + +fn create_list_of_stub_chapters(chapters: &[SummaryItem], src_dir: &Path) -> Result<()> { + for summary_item in chapters { + if let SummaryItem::Link(ref ch) = *summary_item { + let location = src_dir.join(&ch.location); + debug!("{} + {} => {}", src_dir.display(), ch.location.display(), location.display()); + create_stub_chapter(&ch.name, &location)?; + create_list_of_stub_chapters(&ch.nested_items, src_dir)?; + } + } + + Ok(()) +} + +fn create_stub_chapter(name: &str, path: &Path) -> Result<()> { + debug!("[*]: Creating stub for \"{}\" at {}", name, path.display()); + + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + if !path.exists() { + let mut f = File::create(path)?; + writeln!(f, "# {}", name)?; + } + + Ok(()) +} diff --git a/src/book/summary.rs b/src/book/summary.rs new file mode 100644 index 0000000000..46f477e527 --- /dev/null +++ b/src/book/summary.rs @@ -0,0 +1,718 @@ +use std::fmt::{self, Formatter, Display}; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use pulldown_cmark::{self, Event, Tag}; +use errors::*; + + +/// Parse the text from a `SUMMARY.md` file into a sort of "recipe" to be +/// used when loading a book from disk. +/// +/// # Summary Format +/// +/// **Title:** It's common practice to begin with a title, generally +/// "# Summary". But it is not mandatory, the parser just ignores it. So you +/// can too if you feel like it. +/// +/// **Prefix Chapter:** Before the main numbered chapters you can add a couple +/// of elements that will not be numbered. This is useful for forewords, +/// introductions, etc. There are however some constraints. You can not nest +/// prefix chapters, they should all be on the root level. And you can not add +/// prefix chapters once you have added numbered chapters. +/// +/// ```markdown +/// [Title of prefix element](relative/path/to/markdown.md) +/// ``` +/// +/// **Numbered Chapter:** Numbered chapters are the main content of the book, +/// they +/// will be numbered and can be nested, resulting in a nice hierarchy (chapters, +/// sub-chapters, etc.) +/// +/// ```markdown +/// - [Title of the Chapter](relative/path/to/markdown.md) +/// ``` +/// +/// You can either use - or * to indicate a numbered chapter. +/// +/// **Suffix Chapter:** After the numbered chapters you can add a couple of +/// non-numbered chapters. They are the same as prefix chapters but come after +/// the numbered chapters instead of before. +/// +/// All other elements are unsupported and will be ignored at best or result in +/// an error. +pub fn parse_summary(summary: &str) -> Result { + let parser = SummaryParser::new(summary); + parser.parse() +} + +/// The parsed `SUMMARY.md`, specifying how the book should be laid out. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Summary { + /// An optional title for the `SUMMARY.md`, currently just ignored. + pub title: Option, + /// Chapters before the main text (e.g. an introduction). + pub prefix_chapters: Vec, + /// The main chapters in the document. + pub numbered_chapters: Vec, + /// Items which come after the main document (e.g. a conclusion). + pub suffix_chapters: Vec, +} + +/// A struct representing an entry in the `SUMMARY.md`, possibly with nested +/// entries. +/// +/// This is roughly the equivalent of `[Some section](./path/to/file.md)`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Link { + /// The name of the chapter. + pub name: String, + /// The location of the chapter's source file, taking the book's `src` + /// directory as the root. + pub location: PathBuf, + /// The section number, if this chapter is in the numbered section. + pub number: Option, + /// Any nested items this chapter may contain. + pub nested_items: Vec, +} + +impl Link { + /// Create a new link with no nested items. + pub fn new, P: AsRef>(name: S, location: P) -> Link { + Link { + name: name.into(), + location: location.as_ref().to_path_buf(), + number: None, + nested_items: Vec::new(), + } + } +} + +impl Default for Link { + fn default() -> Self { + Link { + name: String::new(), + location: PathBuf::new(), + number: None, + nested_items: Vec::new(), + } + } +} + +/// An item in `SUMMARY.md` which could be either a separator or a `Link`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SummaryItem { + /// A link to a chapter. + Link(Link), + /// A separator (`---`). + Separator, +} + +impl SummaryItem { + fn maybe_link_mut(&mut self) -> Option<&mut Link> { + match *self { + SummaryItem::Link(ref mut l) => Some(l), + _ => None, + } + } +} + +impl From for SummaryItem { + fn from(other: Link) -> SummaryItem { + SummaryItem::Link(other) + } +} + +#[derive(Debug, Copy, Clone, PartialEq)] +enum State { + Begin, + PrefixChapters, + /// Numbered chapters, including the nesting level. + NumberedChapters(u32), + SuffixChapters, + End, +} + +/// A state machine parser for parsing a `SUMMARY.md` file. +/// +/// The parser has roughly 5 states, +/// +/// - **Begin:** the initial state +/// - **Prefix Chapters:** Parsing the prefix chapters +/// - **Numbered Chapters:** Parsing the numbered chapters, using a `usize` to +/// indicate the nesting level (because chapters can have sub-chapters) +/// - **Suffix Chapters:** pretty much identical to the Prefix Chapters +/// - **End:** The final state +/// +/// The `parse()` method then continually invokes `step()` until it reaches the +/// `End` state. Parsing is guaranteed to (eventually) finish because the next +/// `Event` is read from the underlying `pulldown_cmark::Parser` and passed +/// into the current state's associated method. +/// +/// +/// # Grammar +/// +/// The `SUMMARY.md` file has a grammar which looks something like this: +/// +/// ```text +/// summary ::= title prefix_chapters numbered_chapters +/// suffix_chapters +/// title ::= "# " TEXT +/// | EPSILON +/// prefix_chapters ::= item* +/// suffix_chapters ::= item* +/// numbered_chapters ::= dotted_item+ +/// dotted_item ::= INDENT* DOT_POINT item +/// item ::= link +/// | separator +/// separator ::= "---" +/// link ::= "[" TEXT "]" "(" TEXT ")" +/// DOT_POINT ::= "-" +/// | "*" +/// ``` +/// +/// > **Note:** the `TEXT` terminal is "normal" text, and should (roughly) +/// > match the following regex: "[^<>\n[]]+". +struct SummaryParser<'a> { + stream: pulldown_cmark::Parser<'a>, + summary: Summary, + state: State, +} + +/// Reads `Events` from the provided stream until the corresponding +/// `Event::End` is encountered which matches the `$delimiter` pattern. +/// +/// This is the equivalent of doing +/// `$stream.take_while(|e| e != $delimeter).collect()` but it allows you to +/// use pattern matching and you won't get errors because `take_while()` +/// moves `$stream` out of self. +macro_rules! collect_events { + ($stream:expr, $delimiter:pat) => { + { + let mut events = Vec::new(); + + loop { + let event = $stream.next(); + match event { + Some(Event::End($delimiter)) => break, + Some(other) => events.push(other), + None => { + debug!("Reached end of stream without finding the closing pattern, {}", stringify!($delimiter)); + break; + } + } + } + + events + } + } +} + +impl<'a> SummaryParser<'a> { + fn new(text: &str) -> SummaryParser { + let pulldown_parser = pulldown_cmark::Parser::new(text); + let intermediate_summary = Summary::default(); + + SummaryParser { + stream: pulldown_parser, + summary: intermediate_summary, + state: State::Begin, + } + } + + /// Parse the text the `SummaryParser` was created with. + fn parse(mut self) -> Result { + self.summary.title = self.parse_title(); + + if let Some(ref title) = self.summary.title { + debug!("[*] Title is {:?}", title); + } + + while self.state != State::End { + self.step()?; + } + + Ok(self.summary) + } + + fn step(&mut self) -> Result<()> { + if let Some(next_event) = self.stream.next() { + trace!("[*] Current state: {:?}, next event: {:?}", self.state, next_event); + + match self.state { + State::Begin => self.step_start(next_event)?, + State::PrefixChapters | State::SuffixChapters => self.step_affix(next_event)?, + State::NumberedChapters(_) => self.step_numbered(next_event)?, + State::End => {}, + } + } else { + trace!("[*] Reached end of SUMMARY.md"); + self.state = State::End; + } + + Ok(()) + } + + /// The very first state, we should see a `Begin Paragraph` token or + /// it's an error... + fn step_start(&mut self, event: Event<'a>) -> Result<()> { + match event { + Event::Start(Tag::Paragraph) => self.state = State::PrefixChapters, + Event::Start(Tag::List(_)) => self.state = State::NumberedChapters(0), + other => bail!("Expected a start of paragraph but got {:?}", other), + } + + Ok(()) + } + + /// Try to step through an "affix" section (recognising prefix and suffix + /// chapters). + /// + /// If we encounter a link or horizontal line, it'll get added to the + /// section. If we encounter a list, we'll either change to + /// `State::NumberedChapter` (for prefix) or throw an error (suffix chapters). + /// + /// Anything else will be ignored. + fn step_affix(&mut self, event: Event<'a>) -> Result<()> { + + match event { + Event::Start(tag) => self.handle_start_tag_in_affix_chapter(tag)?, + Event::End(Tag::Rule) => { + debug!("[*] Found an affix chapter separator"); + self.affix_chapter_list().push(SummaryItem::Separator); + }, + other => { + trace!("[*] Skipping unexpected token in summary: {:?}", other); + }, + } + + Ok(()) + } + + /// A helper function to get the `SummaryItem` list we should add items to + /// when parsing an affix chapter (i.e. prefix or suffix chapters). + fn affix_chapter_list(&mut self) -> &mut Vec { + match self.state { + State::PrefixChapters => &mut self.summary.prefix_chapters, + State::SuffixChapters => &mut self.summary.suffix_chapters, + other => panic!("affix_chapter_list() called with invalid state: {:?}", other), + } + } + + fn handle_start_tag_in_affix_chapter(&mut self, tag: Tag) -> Result<()> { + match tag { + Tag::Link(location, _) => { + let content = collect_events!(self.stream, Tag::Link(_, _)); + let text = stringify_events(content); + let link = Link::new(text, location.as_ref()); + + debug!("[*] Found an affix chapter: {:?}", link.name); + self.affix_chapter_list().push(SummaryItem::Link(link)); + }, + Tag::List(_) => { + match self.state { + State::PrefixChapters => { + debug!("[*] Changing from prefix chapters to numbered chapters"); + self.state = State::NumberedChapters(0); + }, + State::SuffixChapters => bail!("Suffix chapters can't be followed by a list"), + _ => unreachable!(), + } + }, + other => trace!("[*] Skipping unknown start tag while parsing affix chapters: {:?}", other), + } + + Ok(()) + } + + /// Parse the numbered chapters. + /// + /// If the event is the start of a list item, consume the entire item and + /// add a new link to the summary with `push_numbered_section`. + /// + /// If the event is the start of a new list, bump the nesting level. + /// + /// If the event is the end of a list, decrement the nesting level. When + /// the nesting level would go negative, we've finished the numbered + /// section and need to parse the suffix section. + /// + /// Otherwise, ignore the event. + fn step_numbered(&mut self, event: Event) -> Result<()> { + match event { + Event::Start(Tag::Item) => { + let it = self.parse_item().chain_err( + || "List items should only contain links", + )?; + + debug!("[*] Found a chapter: {:?} ({})", it.name, it.location.display()); + let section_number = self.push_numbered_section(SummaryItem::Link(it)); + trace!("[*] Section number is {}", section_number); + }, + Event::End(Tag::Rule) => { + debug!("[*] Found a numbered chapter separator"); + self.summary.numbered_chapters.push(SummaryItem::Separator); + self.state = State::NumberedChapters(0); + }, + Event::Start(Tag::List(_)) => { + if let State::NumberedChapters(n) = self.state { + self.state = State::NumberedChapters(n + 1); + trace!("[*] Nesting level increased to {}", n + 1); + } + }, + Event::End(Tag::List(_)) => { + if let State::NumberedChapters(n) = self.state { + if n == 0 { + trace!("[*] Finished parsing the numbered chapters"); + self.state = State::SuffixChapters; + } else { + trace!("[*] Nesting level decreased to {}", n - 1); + self.state = State::NumberedChapters(n - 1); + } + } + }, + other => { + trace!("[*] skipping unexpected token: {:?}", other); + }, + } + + Ok(()) + } + + /// Parse a single item (`[Some Chapter Name](./path/to/chapter.md)`). + fn parse_item(&mut self) -> Result { + let next = self.stream.next(); + + if let Some(Event::Start(Tag::Link(dest, _))) = next { + let content = collect_events!(self.stream, Tag::Link(..)); + + Ok(Link::new(stringify_events(content), dest.as_ref())) + } else { + bail!("Expected a link, got {:?}", next) + } + } + + /// Try to parse the title line. + fn parse_title(&mut self) -> Option { + if let Some(Event::Start(Tag::Header(1))) = self.stream.next() { + debug!("[*] Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, Tag::Header(1)); + + // TODO: How do we deal with headings like "# My **awesome** summary"? + // for now, I'm just going to scan through and concatenate the + // Event::Text tags, skipping any styling. + Some(stringify_events(tags)) + } else { + None + } + } + + /// Push a new section at the end of the current nesting level. + fn push_numbered_section(&mut self, item: SummaryItem) -> SectionNumber { + if let State::NumberedChapters(level) = self.state { + push_item_at_nesting_level( + &mut self.summary.numbered_chapters, + item, + level as usize, + SectionNumber::default(), + ).chain_err(|| { + format!("The parser should always ensure we add the next \ + item at the correct level ({}:{})", module_path!(), line!()) + }) + .unwrap() + } else { + // this method should only ever be called when parsing a numbered + // section, therefore if we ever get here something has gone + // hideously wrong... + error!("Calling push_numbered_section() when not in a numbered section"); + error!("Current state: {:?}", self.state); + error!("Item: {:?}", item); + error!("Summary:"); + error!("{:#?}", self.summary); + panic!("Entered unreachable code, this is a bug"); + } + } +} + +/// Given a particular level (e.g. 3), go that many levels down the `Link`'s +/// nested items then append the provided item to the last `Link` in the +/// list. +fn push_item_at_nesting_level(links: &mut Vec, mut item: SummaryItem, level: usize, mut section_number: SectionNumber) + -> Result { + if level == 0 { + // set the section number, if applicable + section_number.push(links.len() as u32 + 1); + + if let SummaryItem::Link(ref mut l) = item { + l.number = Some(section_number.clone()); + } + + links.push(item); + Ok(section_number) + } else { + let (index, last_link) = get_last_link(links).chain_err(|| { + format!("The list of links needs to be {} levels deeper (current position {})", + level, section_number) + })?; + + section_number.push(index as u32 + 1); + push_item_at_nesting_level(&mut last_link.nested_items, item, level - 1, section_number) + } +} + +/// Gets a pointer to the last `Link` in a list of `SummaryItem`s, and its +/// index. +fn get_last_link(links: &mut [SummaryItem]) -> Result<(usize, &mut Link)> { + // TODO: This should probably be integrated into `Link::push_item()` + links + .iter_mut() + .enumerate() + .filter_map(|(i, item)| item.maybe_link_mut().map(|l| (i, l))) + .rev() + .next() + .ok_or_else(|| "The list of SummaryItems doesn't contain any Links".into()) +} + + +/// Removes the styling from a list of Markdown events and returns just the +/// plain text. +fn stringify_events(events: Vec) -> String { + events + .into_iter() + .filter_map(|t| match t { + Event::Text(text) => Some(text.into_owned()), + _ => None, + }) + .collect() +} + +/// A section number like "1.2.3", basically just a newtype'd `Vec` with +/// a pretty `Display` impl. +#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] +pub struct SectionNumber(pub Vec); + +impl Display for SectionNumber { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + for item in &self.0 { + write!(f, "{}.", item)?; + } + Ok(()) + } +} + +impl Deref for SectionNumber { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for SectionNumber { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn section_number_has_correct_dotted_representation() { + let inputs = vec![ + (vec![0], "0."), + (vec![1, 3], "1.3."), + (vec![1, 2, 3], "1.2.3."), + ]; + + for (input, should_be) in inputs { + let section_number = SectionNumber(input).to_string(); + assert_eq!(section_number, should_be); + } + } + + #[test] + fn parse_initial_title() { + let src = "# Summary"; + let should_be = String::from("Summary"); + + let mut parser = SummaryParser::new(src); + let got = parser.parse_title().unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_title_with_styling() { + let src = "# My **Awesome** Summary"; + let should_be = String::from("My Awesome Summary"); + + let mut parser = SummaryParser::new(src); + let got = parser.parse_title().unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_a_single_item() { + let src = "[A Chapter](./path/to/chapter)"; + let should_be = Link { + name: String::from("A Chapter"), + location: PathBuf::from("./path/to/chapter"), + number: None, + nested_items: Vec::new(), + }; + + let mut parser = SummaryParser::new(src); + let _ = parser.stream.next(); // skip the opening paragraph tag + let got = parser.parse_item().unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn convert_markdown_events_to_a_string() { + let src = "Hello *World*, `this` is some text [and a link](./path/to/link)"; + let should_be = "Hello World, this is some text and a link"; + + let events = pulldown_cmark::Parser::new(src).collect(); + let got = stringify_events(events); + + assert_eq!(got, should_be); + + } + + #[test] + fn can_step_past_first_token() { + let src = "hello world"; + let should_be = State::PrefixChapters; + + let mut parser = SummaryParser::new(src); + assert_eq!(parser.state, State::Begin); + parser.step().unwrap(); + assert_eq!(parser.state, should_be); + } + + #[test] + fn first_token_must_be_open_paragraph() { + let src = "hello world"; + + let mut parser = SummaryParser::new(src); + let _ = parser.stream.next(); // manually step past the Start Paragraph + assert!(parser.step().is_err()); + } + + #[test] + fn can_parse_prefix_chapter_links() { + let src = "[Hello World](./foo/bar/baz)"; + let should_be = Link { + name: String::from("Hello World"), + location: PathBuf::from("./foo/bar/baz"), + number: None, + nested_items: Vec::new(), + }; + + let mut parser = SummaryParser::new(src); + parser.state = State::PrefixChapters; + assert!(parser.summary.prefix_chapters.is_empty()); + + let _ = parser.stream.next(); // manually step past the Start Paragraph + parser.step().unwrap(); + + assert_eq!(parser.summary.prefix_chapters.len(), 1); + assert_eq!(parser.summary.prefix_chapters[0], SummaryItem::Link(should_be)); + assert_eq!(parser.state, State::PrefixChapters); + } + + #[test] + fn can_parse_prefix_chapter_horizontal_rules() { + let src = "---"; + let should_be = SummaryItem::Separator; + + let mut parser = SummaryParser::new(src); + parser.state = State::PrefixChapters; + assert!(parser.summary.prefix_chapters.is_empty()); + + let _ = parser.stream.next(); // manually step past the Start Paragraph + parser.step().unwrap(); + + assert_eq!(parser.summary.prefix_chapters.len(), 1); + assert_eq!(parser.summary.prefix_chapters[0], should_be); + assert_eq!(parser.state, State::PrefixChapters); + } + + #[test] + fn step_from_prefix_chapters_to_numbered() { + let src = "- foo"; + + let mut parser = SummaryParser::new(src); + parser.state = State::PrefixChapters; + + // let _ = parser.stream.next(); // manually step past the Start Paragraph + parser.step().unwrap(); + + assert_eq!(parser.state, State::NumberedChapters(0)); + } + + #[test] + fn push_item_onto_empty_link() { + let root = Link::new("First", "/"); + let mut links = vec![SummaryItem::Link(root)]; + + assert_eq!(links[0].maybe_link_mut().unwrap().nested_items.len(), 0); + let got = push_item_at_nesting_level(&mut links, SummaryItem::Separator, 1, SectionNumber::default()).unwrap(); + assert_eq!(links[0].maybe_link_mut().unwrap().nested_items.len(), 1); + assert_eq!(*got, vec![1, 1]); + } + + #[test] + fn push_item_onto_complex_link() { + let mut root = Link::new("First", "/first"); + root.nested_items.push(SummaryItem::Separator); + + let mut child = Link::new("Second", "/first/second"); + child.nested_items.push(SummaryItem::Link( + Link::new("Third", "/first/second/third"), + )); + root.nested_items.push(SummaryItem::Link(child)); + root.nested_items.push(SummaryItem::Separator); + + let mut links = vec![SummaryItem::Link(root)]; + + // FIXME: This crap for getting a deeply nested member is just plain ugly :( + assert_eq!(links[0].maybe_link_mut().unwrap() + .nested_items[1].maybe_link_mut() + .unwrap() + .nested_items[0].maybe_link_mut() + .unwrap() + .nested_items.len() , 0); + let got = push_item_at_nesting_level( + &mut links, + SummaryItem::Link(Link::new("Dummy", "")), + 3, + SectionNumber::default(), + ).unwrap(); + assert_eq!(links[0].maybe_link_mut().unwrap() + .nested_items[1].maybe_link_mut() + .unwrap() + .nested_items[0].maybe_link_mut() + .unwrap() + .nested_items.len() , 1); + println!("{:#?}", links); + assert_eq!(*got, vec![1, 2, 1, 1]); + } + + #[test] + fn parse_a_numbered_chapter() { + let src = "- [First](./second)"; + let mut parser = SummaryParser::new(src); + let _ = parser.stream.next(); + + assert_eq!(parser.summary.numbered_chapters.len(), 0); + + parser.state = State::NumberedChapters(0); + parser.step().unwrap(); + + assert_eq!(parser.summary.numbered_chapters.len(), 1); + } +} diff --git a/src/lib.rs b/src/lib.rs index fff77bbd72..242437d0ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,6 @@ //! .with_destination("book") // Path from root to output directory //! .read_config() // Parse book.toml configuration file //! .expect("I don't handle configuration file errors, but you should!"); -//! //! book.build().unwrap(); // Render the book //! } //! ``` @@ -57,7 +56,8 @@ //! If you make a renderer, you get the book constructed in form of `Vec` and you get //! the book config in a `BookConfig` struct. //! -//! It's your responsability to create the necessary files in the correct directories. +//! It's your responsibility to create the necessary files in the correct +//! directories. //! //! ## utils //! @@ -87,7 +87,10 @@ extern crate serde; extern crate serde_json; extern crate tempdir; -mod parse; +#[cfg(test)] +#[macro_use] +extern crate pretty_assertions; + mod preprocess; pub mod book; pub mod config; @@ -96,11 +99,14 @@ pub mod theme; pub mod utils; pub use book::MDBook; -pub use book::BookItem; +pub use book::Book; pub use renderer::Renderer; /// The error types used through out this crate. pub mod errors { + // needed temporarily because of https://github.com/rust-lang-nursery/error-chain/issues/208 + #![allow(unknown_lints, unused_doc_comment)] + error_chain!{ foreign_links { Io(::std::io::Error); diff --git a/src/parse/mod.rs b/src/parse/mod.rs deleted file mode 100644 index c8c8aab7d4..0000000000 --- a/src/parse/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub use self::summary::construct_bookitems; - -pub mod summary; diff --git a/src/parse/summary.rs b/src/parse/summary.rs deleted file mode 100644 index cc8452d441..0000000000 --- a/src/parse/summary.rs +++ /dev/null @@ -1,231 +0,0 @@ -use std::path::PathBuf; -use std::fs::File; -use std::io::{Read, Result, Error, ErrorKind}; -use book::bookitem::{BookItem, Chapter}; - -pub fn construct_bookitems(path: &PathBuf) -> Result> { - debug!("[fn]: construct_bookitems"); - let mut summary = String::new(); - File::open(path)?.read_to_string(&mut summary)?; - - debug!("[*]: Parse SUMMARY.md"); - let top_items = parse_level(&mut summary.split('\n').collect(), 0, vec![0])?; - debug!("[*]: Done parsing SUMMARY.md"); - Ok(top_items) -} - -fn parse_level(summary: &mut Vec<&str>, current_level: i32, mut section: Vec) -> Result> { - debug!("[fn]: parse_level"); - let mut items: Vec = vec![]; - - // Construct the book recursively - while !summary.is_empty() { - let item: BookItem; - // Indentation level of the line to parse - let level = level(summary[0], 4)?; - - // if level < current_level we remove the last digit of section, - // exit the current function, - // and return the parsed level to the calling function. - if level < current_level { - break; - } - - // if level > current_level we call ourselves to go one level deeper - if level > current_level { - // Level can not be root level !! - // Add a sub-number to section - section.push(0); - let last = items - .pop() - .expect("There should be at least one item since this can't be the root level"); - - if let BookItem::Chapter(ref s, ref ch) = last { - let mut ch = ch.clone(); - ch.sub_items = parse_level(summary, level, section.clone())?; - items.push(BookItem::Chapter(s.clone(), ch)); - - // Remove the last number from the section, because we got back to our level.. - section.pop(); - continue; - } else { - return Err(Error::new(ErrorKind::Other, - "Your summary.md is messed up\n\n - Prefix, \ - Suffix and Spacer elements can only exist on the root level.\n - \ - Prefix elements can only exist before any chapter and there can be \ - no chapters after suffix elements.")); - }; - - } else { - // level and current_level are the same, parse the line - item = if let Some(parsed_item) = parse_line(summary[0]) { - - // Eliminate possible errors and set section to -1 after suffix - match parsed_item { - // error if level != 0 and BookItem is != Chapter - BookItem::Affix(_) | - BookItem::Spacer if level > 0 => { - return Err(Error::new(ErrorKind::Other, - "Your summary.md is messed up\n\n - \ - Prefix, Suffix and Spacer elements can only exist on the \ - root level.\n - Prefix \ - elements can only exist before any chapter and there can be \ - no chapters after suffix elements.")) - }, - - // error if BookItem == Chapter and section == -1 - BookItem::Chapter(_, _) if section[0] == -1 => { - return Err(Error::new(ErrorKind::Other, - "Your summary.md is messed up\n\n - \ - Prefix, Suffix and Spacer elements can only exist on the \ - root level.\n - Prefix \ - elements can only exist before any chapter and there can be \ - no chapters after suffix elements.")) - }, - - // Set section = -1 after suffix - BookItem::Affix(_) if section[0] > 0 => { - section[0] = -1; - }, - - _ => {}, - } - - match parsed_item { - BookItem::Chapter(_, ch) => { - // Increment section - let len = section.len() - 1; - section[len] += 1; - let s = section - .iter() - .fold("".to_owned(), |s, i| s + &i.to_string() + "."); - BookItem::Chapter(s, ch) - }, - _ => parsed_item, - } - - } else { - // If parse_line does not return Some(_) continue... - summary.remove(0); - continue; - }; - } - - summary.remove(0); - items.push(item) - } - debug!("[*]: Level: {:?}", items); - Ok(items) -} - - -fn level(line: &str, spaces_in_tab: i32) -> Result { - debug!("[fn]: level"); - let mut spaces = 0; - let mut level = 0; - - for ch in line.chars() { - match ch { - ' ' => spaces += 1, - '\t' => level += 1, - _ => break, - } - if spaces >= spaces_in_tab { - level += 1; - spaces = 0; - } - } - - // If there are spaces left, there is an indentation error - if spaces > 0 { - debug!("[SUMMARY.md]:"); - debug!("\t[line]: {}", line); - debug!("[*]: There is an indentation error on this line. Indentation should be {} spaces", spaces_in_tab); - return Err(Error::new(ErrorKind::Other, format!("Indentation error on line:\n\n{}", line))); - } - - Ok(level) -} - - -fn parse_line(l: &str) -> Option { - debug!("[fn]: parse_line"); - - // Remove leading and trailing spaces or tabs - let line = l.trim_matches(|c: char| c == ' ' || c == '\t'); - - // Spacers are "------" - if line.starts_with("--") { - debug!("[*]: Line is spacer"); - return Some(BookItem::Spacer); - } - - if let Some(c) = line.chars().nth(0) { - match c { - // List item - '-' | '*' => { - debug!("[*]: Line is list element"); - - if let Some((name, path)) = read_link(line) { - return Some(BookItem::Chapter("0".to_owned(), Chapter::new(name, path))); - } else { - return None; - } - }, - // Non-list element - '[' => { - debug!("[*]: Line is a link element"); - - if let Some((name, path)) = read_link(line) { - return Some(BookItem::Affix(Chapter::new(name, path))); - } else { - return None; - } - }, - _ => {}, - } - } - - None -} - -fn read_link(line: &str) -> Option<(String, PathBuf)> { - let mut start_delimitor; - let mut end_delimitor; - - // In the future, support for list item that is not a link - // Not sure if I should error on line I can't parse or just ignore them... - if let Some(i) = line.find('[') { - start_delimitor = i; - } else { - debug!("[*]: '[' not found, this line is not a link. Ignoring..."); - return None; - } - - if let Some(i) = line[start_delimitor..].find("](") { - end_delimitor = start_delimitor + i; - } else { - debug!("[*]: '](' not found, this line is not a link. Ignoring..."); - return None; - } - - let name = line[start_delimitor + 1..end_delimitor].to_owned(); - - start_delimitor = end_delimitor + 1; - if let Some(i) = line[start_delimitor..].find(')') { - end_delimitor = start_delimitor + i; - } else { - debug!("[*]: ')' not found, this line is not a link. Ignoring..."); - return None; - } - - let path = PathBuf::from(line[start_delimitor + 1..end_delimitor].to_owned()); - - Some((name, path)) -} diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index d2b4677921..d90ad75458 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -2,17 +2,17 @@ use renderer::html_handlebars::helpers; use preprocess; use renderer::Renderer; use book::MDBook; -use book::bookitem::{BookItem, Chapter}; use config::PlaypenConfig; -use {utils, theme}; -use theme::{Theme, playpen_editor}; +use theme::{self, Theme, playpen_editor}; +use book::{BookItem, Chapter}; +use utils; use errors::*; use regex::{Regex, Captures}; use std::ascii::AsciiExt; use std::path::{Path, PathBuf}; use std::fs::{self, File}; -use std::io::{self, Read}; +use std::io::Read; use std::collections::BTreeMap; use std::collections::HashMap; @@ -32,11 +32,9 @@ impl HtmlHandlebars { -> Result<()> { // FIXME: This should be made DRY-er and rely less on mutable state match *item { - BookItem::Chapter(_, ref ch) | - BookItem::Affix(ref ch) if !ch.path.as_os_str().is_empty() => { - + BookItem::Chapter(ref ch) => { let path = ctx.book.get_source().join(&ch.path); - let content = utils::fs::file_to_string(&path)?; + let content = ch.content.clone(); let base = path.parent().ok_or_else( || String::from("Invalid bookitem path!"), )?; @@ -47,9 +45,7 @@ impl HtmlHandlebars { print_content.push_str(&content); // Update the context with data for this file - let path = ch.path.to_str().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "Could not convert path to str") - })?; + let path = ch.path.to_str().ok_or_else(|| Error::from("Could not convert path to str"))?; // Non-lexical lifetimes needed :'( let title: String; @@ -392,31 +388,26 @@ fn make_data(book: &MDBook) -> Result for item in book.iter() { // Create the data to inject in the template - let mut chapter = BTreeMap::new(); + let mut chapter_data = BTreeMap::new(); match *item { - BookItem::Affix(ref ch) => { - chapter.insert("name".to_owned(), json!(ch.name)); - let path = ch.path.to_str().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "Could not convert path to str") - })?; - chapter.insert("path".to_owned(), json!(path)); - }, - BookItem::Chapter(ref s, ref ch) => { - chapter.insert("section".to_owned(), json!(s)); - chapter.insert("name".to_owned(), json!(ch.name)); - let path = ch.path.to_str().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "Could not convert path to str") - })?; - chapter.insert("path".to_owned(), json!(path)); + BookItem::Chapter(ref ch) => { + if let Some(ref section_number) = ch.number { + chapter_data.insert("section".to_owned(), json!(section_number.to_string())); + } + + chapter_data.insert("name".to_owned(), json!(ch.name)); + let path = ch.path.to_str() + .ok_or_else(|| Error::from("Could not convert path to str"))?; + chapter_data.insert("path".to_owned(), json!(path)); }, - BookItem::Spacer => { - chapter.insert("spacer".to_owned(), json!("_spacer_")); + BookItem::Separator => { + chapter_data.insert("spacer".to_owned(), json!("_spacer_")); }, } - chapters.push(chapter); + chapters.push(chapter_data); } data.insert("chapters".to_owned(), json!(chapters)); diff --git a/tests/loading.rs b/tests/loading.rs new file mode 100644 index 0000000000..beaa219774 --- /dev/null +++ b/tests/loading.rs @@ -0,0 +1,21 @@ +//! Integration tests for loading a book into memory + +extern crate mdbook; +extern crate env_logger; + +use std::path::PathBuf; + +use mdbook::book::load_book; + + +#[test] +fn load_the_example_book() { + env_logger::init().ok(); + + let example_src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("book-example") + .join("src"); + + let book = load_book(example_src_dir, false).unwrap(); + println!("{:#?}", book); +} diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index 7de0838fff..83d2a0e220 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -1,3 +1,4 @@ +extern crate env_logger; extern crate mdbook; extern crate tempdir; @@ -39,10 +40,10 @@ fn make_sure_bottom_level_files_contain_links_to_chapters() { let dest = temp.path().join("book"); let links = vec![ r#"href="intro.html""#, - r#"href="./first/index.html""#, - r#"href="./first/nested.html""#, - r#"href="./second.html""#, - r#"href="./conclusion.html""#, + r#"href="first/index.html""#, + r#"href="first/nested.html""#, + r#"href="second.html""#, + r#"href="conclusion.html""#, ]; let files_in_bottom_dir = vec!["index.html", "intro.html", "second.html", "conclusion.html"]; @@ -62,10 +63,10 @@ fn check_correct_cross_links_in_nested_dir() { let links = vec![ r#""#, r#"href="intro.html""#, - r#"href="./first/index.html""#, - r#"href="./first/nested.html""#, - r#"href="./second.html""#, - r#"href="./conclusion.html""#, + r#"href="first/index.html""#, + r#"href="first/nested.html""#, + r#"href="second.html""#, + r#"href="conclusion.html""#, ]; let files_in_nested_dir = vec!["index.html", "nested.html"]; @@ -77,14 +78,14 @@ fn check_correct_cross_links_in_nested_dir() { assert_contains_strings( first.join("index.html"), &[ - r##"href="./first/index.html#some-section" id="some-section""## + r##"href="first/index.html#some-section" id="some-section""##, ], ); assert_contains_strings( first.join("nested.html"), &[ - r##"href="./first/nested.html#some-section" id="some-section""## + r##"href="first/nested.html#some-section" id="some-section""##, ], ); } @@ -106,6 +107,8 @@ fn rendered_code_has_playpen_stuff() { #[test] fn chapter_content_appears_in_rendered_document() { + env_logger::init().ok(); + let content = vec![ ("index.html", "Here's some interesting text"), ("second.html", "Second Chapter"), @@ -125,3 +128,20 @@ fn chapter_content_appears_in_rendered_document() { assert_contains_strings(path, &[text]); } } + +#[test] +fn chapter_1_file_not_created_if_summary_already_exists() { + let temp = DummyBook::default().build(); + + let src = temp.path().join("src"); + let summary = src.join("SUMMARY.md"); + let chapter_1 = src.join("chapter_1.md"); + + assert!(summary.exists()); + assert!(!chapter_1.exists()); + + let mut md = MDBook::new(temp.path()); + md.build().unwrap(); + + assert!(!chapter_1.exists()); +}