From 6d0d4bf37942c9a5eaf57ca2d6e52d096a27314c Mon Sep 17 00:00:00 2001 From: Michael Bryan Date: Sat, 24 Jun 2017 22:47:03 +0800 Subject: [PATCH] Created a SUMMARY.md parser and basic Loader From the [pull request comment][pr], here's a rough summary of what was done in the squashed commits. --- \# Summary Parser - Added a private submodule called `mdbook::loader::summary` which contains all the code for parsing `SUMMARY.md` - A `Summary` contains a title (optional), then some prefix, numbered, and suffix chapters (technically `Vec`) - A `SummaryItem` is either a `Link` (i.e. link to a chapter), or a separator - A `Link` contains the chapter name, its location relative to the book's `src/` directory, and a list of nested `SummaryItems` - The `SummaryParser` (a state machine-based parser) uses `pulldown_cmark` to turn the `SUMMARY.md` string into a stream of `Events`, it then iterates over those events changing its behaviour depending on the current state, - The states are `Start`, `PrefixChapters`, `NestedChapters(u32)` (the `u32` represents your nesting level, because lists can contain lists), `SuffixChapters`, and `End` - Each state will read the appropriate link and build up the `Summary`, skipping any events which aren't a link, horizontal rule (separator), or a list \# Loader - Created a basic loader which can be used to load the `SUMMARY.md` in a directory. \# Tests - Added a couple unit tests for each state in the parser's state machine - Added integration tests for parsing a dummy SUMMARY.md then asserting the result is exactly what we expected [pr]: https://github.com/azerupi/mdBook/pull/371#issuecomment-312636102 --- Cargo.toml | 5 + src/lib.rs | 32 +- src/loader/mod.rs | 84 +++++ src/loader/summary.rs | 714 ++++++++++++++++++++++++++++++++++++++++++ tests/loading.rs | 109 +++++++ 5 files changed, 935 insertions(+), 9 deletions(-) create mode 100644 src/loader/mod.rs create mode 100644 src/loader/summary.rs create mode 100644 tests/loading.rs diff --git a/Cargo.toml b/Cargo.toml index ca9e9d3d..c1be46be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,11 @@ iron = { version = "0.5", optional = true } staticfile = { version = "0.4", optional = true } ws = { version = "0.7", optional = true} +# Tests +[dev-dependencies] +tempdir = "0.3.4" +pretty_assertions = "0.2.1" + [build-dependencies] error-chain = "0.11" diff --git a/src/lib.rs b/src/lib.rs index fff77bbd..9a01bb93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ //! **mdBook** is similar to Gitbook but implemented in Rust. //! 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 here](../index.html) that +//! This is the API doc, but you can find a [less "low-level" documentation +//! here](../index.html) that //! contains information about the command line tool, format, structure etc. //! It is also rendered with mdBook to showcase the features and default theme. //! @@ -25,10 +26,14 @@ //! # #[allow(unused_variables)] //! fn main() { //! let mut book = MDBook::new("my-book") // Path to root -//! .with_source("src") // Path from root to source directory -//! .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!"); +//! .with_source("src") // Path from root to +//! source directory +//! .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 //! } @@ -36,7 +41,8 @@ //! //! ## Implementing a new Renderer //! -//! If you want to create a new renderer for mdBook, the only thing you have to do is to implement +//! If you want to create a new renderer for mdBook, the only thing you have to +//! do is to implement //! the [Renderer trait](renderer/renderer/trait.Renderer.html) //! //! And then you can swap in your renderer like this: @@ -54,14 +60,17 @@ //! let book = MDBook::new("my-book").set_renderer(Box::new(your_renderer)); //! # } //! ``` -//! If you make a renderer, you get the book constructed in form of `Vec` and you get +//! 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 responsability to create the necessary files in the correct +//! directories. //! //! ## utils //! -//! I have regrouped some useful functions in the [utils](utils/index.html) module, like the +//! I have regrouped some useful functions in the [utils](utils/index.html) +//! module, like the //! following function [`utils::fs::create_file(path: //! &Path)`](utils/fs/fn.create_file.html) //! @@ -87,6 +96,10 @@ extern crate serde; extern crate serde_json; extern crate tempdir; +#[cfg(test)] +#[macro_use] +extern crate pretty_assertions; + mod parse; mod preprocess; pub mod book; @@ -94,6 +107,7 @@ pub mod config; pub mod renderer; pub mod theme; pub mod utils; +pub mod loader; pub use book::MDBook; pub use book::BookItem; diff --git a/src/loader/mod.rs b/src/loader/mod.rs new file mode 100644 index 00000000..3c597d4b --- /dev/null +++ b/src/loader/mod.rs @@ -0,0 +1,84 @@ +//! Functionality for loading the internal book representation from disk. +//! +//! The typical use case is to create a `Loader` pointing at the correct +//! source directory then call the `load()` method. Internally this will +//! search for the `SUMMARY.md` file, parse it, then use the parsed +//! `Summary` to construct an in-memory representation of the entire book. +//! +//! # Examples +//! +//! ```rust,no_run +//! # fn run() -> mdbook::errors::Result<()> { +//! use mdbook::loader::Loader; +//! let loader = Loader::new("./src/"); +//! let book = loader.load()?; +//! # Ok(()) +//! # } +//! # fn main() { run().unwrap() } +//! ``` +//! +//! Alternatively, if you are using the `mdbook` crate as a library and +//! only want to read the `SUMMARY.md` file without having to load the +//! entire book from disk, you can use the `parse_summary()` function. +//! +//! ```rust +//! # fn run() -> mdbook::errors::Result<()> { +//! use mdbook::loader::parse_summary; +//! let src = "# Book Summary +//! +//! [Introduction](./index.md) +//! - [First Chapter](./first/index.md) +//! - [Sub-Section](./first/subsection.md) +//! - [Second Chapter](./second/index.md) +//! "; +//! let summary = parse_summary(src)?; +//! println!("{:#?}", summary); +//! # Ok(()) +//! # } +//! # fn main() { run().unwrap() } +//! ``` + +#![deny(missing_docs)] + +use std::path::{Path, PathBuf}; +use std::fs::File; +use std::io::Read; +use errors::*; + +mod summary; + +pub use self::summary::{Summary, Link, SummaryItem, parse_summary, SectionNumber}; + + +/// The object in charge of parsing the source directory into a usable +/// `Book` struct. +#[derive(Debug, Clone, PartialEq)] +pub struct Loader { + source_directory: PathBuf, +} + +impl Loader { + /// Create a new loader which uses the provided source directory. + pub fn new>(source_directory: P) -> Loader { + Loader { source_directory: source_directory.as_ref().to_path_buf() } + } + + /// Parse the summary file and use it to load a book from disk. + pub fn load(&self) -> Result<()> { + let summary = self.parse_summary().chain_err( + || "Couldn't parse `SUMMARY.md`", + )?; + + unimplemented!() + } + + /// Parse the `SUMMARY.md` file. + pub fn parse_summary(&self) -> Result { + let path = self.source_directory.join("SUMMARY.md"); + + let mut summary_content = String::new(); + File::open(&path)?.read_to_string(&mut summary_content)?; + + summary::parse_summary(&summary_content) + } +} diff --git a/src/loader/summary.rs b/src/loader/summary.rs new file mode 100644 index 00000000..6510fe97 --- /dev/null +++ b/src/loader/summary.rs @@ -0,0 +1,714 @@ +#![allow(dead_code, unused_variables)] + +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)] +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)] +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)] +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, + } + } +} + +#[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 => self.step_prefix(next_event)?, + State::NumberedChapters(n) => self.step_numbered(next_event, n)?, + State::SuffixChapters => self.step_suffix(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, + other => bail!("Expected a start of paragraph but got {:?}", other), + } + + Ok(()) + } + + /// In the second step we look out for links and horizontal rules to add + /// to the prefix. + /// + /// This state should only progress when it encounters a list. All other + /// events will either be separators (horizontal rule), prefix chapters + /// (the links), or skipped. + fn step_prefix(&mut self, event: Event<'a>) -> Result<()> { + match event { + Event::Start(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 a prefix chapter: {:?}", link.name); + self.summary.prefix_chapters.push(SummaryItem::Link(link)); + }, + Event::End(Tag::Rule) => { + debug!("[*] Found a prefix chapter separator"); + self.summary.prefix_chapters.push(SummaryItem::Separator); + }, + Event::Start(Tag::List(_)) => { + debug!("[*] Changing from prefix chapters to numbered chapters"); + self.state = State::NumberedChapters(0); + }, + + other => { + trace!("[*] Skipping unexpected token in summary: {:?}", 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, nesting: u32) -> 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::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(()) + } + + fn step_suffix(&mut self, event: Event<'a>) -> Result<()> { + // FIXME: This has been copy/pasted from step_prefix. make DRY. + match event { + Event::Start(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 a suffix chapter: {:?}", link.name); + self.summary.suffix_chapters.push(SummaryItem::Link(link)); + }, + Event::End(Tag::Rule) => { + debug!("[*] Found a suffix chapter separator"); + self.summary.suffix_chapters.push(SummaryItem::Separator); + }, + other => { + trace!("[*] Skipping unexpected token in summary: {:?}", 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 next_level = level - 1; + let index_for_item = links.len() + 1; + + // FIXME: This bit needs simplifying! + 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)> { + 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)] +pub struct SectionNumber(pub Vec); + +impl Display for SectionNumber { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let dotted_number: String = self.0 + .iter() + .map(|i| format!("{}", i)) + .collect::>() + .join("."); + + write!(f, "{}", dotted_number) + } +} + +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); + let string_repr = format!("{}", section_number); + + assert_eq!(string_repr, 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/tests/loading.rs b/tests/loading.rs new file mode 100644 index 00000000..581478b4 --- /dev/null +++ b/tests/loading.rs @@ -0,0 +1,109 @@ +//! Integration tests for loading a book into memory + +#[macro_use] +extern crate pretty_assertions; +extern crate mdbook; +extern crate env_logger; +extern crate tempdir; + +use std::path::PathBuf; +use std::fs::File; +use std::io::Write; + +use mdbook::loader::{parse_summary, Link, SummaryItem, SectionNumber, Summary, Loader}; +use tempdir::TempDir; + + +const SUMMARY: &'static str = " +# Summary + +[Introduction](/intro.md) + +--- + +[A Prefix Chapter](/some_prefix.md) + +- [First Chapter](/chapter_1/index.md) + - [Some Subsection](/chapter_1/subsection.md) + +--- + +[Conclusion](/conclusion.md) +"; + +#[test] +fn parse_summary_md() { + env_logger::init().ok(); + + let should_be = expected_summary(); + let got = parse_summary(SUMMARY).unwrap(); + + println!("{:#?}", got); + assert_eq!(got, should_be); +} + +#[test] +fn parse_summary_using_loader() { + env_logger::init().ok(); + + let temp = TempDir::new("book").unwrap(); + let summary_md = temp.path().join("SUMMARY.md"); + + File::create(&summary_md).unwrap().write_all(SUMMARY.as_bytes()).unwrap(); + + let loader = Loader::new(temp.path()); + + let got = loader.parse_summary().unwrap(); + let should_be = expected_summary(); + + assert_eq!(got, should_be); +} + +/// This is what the SUMMARY should be parsed as +fn expected_summary() -> Summary { + Summary { + title: Some(String::from("Summary")), + + prefix_chapters: vec![ + SummaryItem::Link(Link { + name: String::from("Introduction"), + location: PathBuf::from("/intro.md"), + number: None, + nested_items: vec![], + }), + SummaryItem::Separator, + SummaryItem::Link(Link { + name: String::from("A Prefix Chapter"), + location: PathBuf::from("/some_prefix.md"), + number: None, + nested_items: vec![], + }), + ], + + numbered_chapters: vec![ + SummaryItem::Link(Link { + name: String::from("First Chapter"), + location: PathBuf::from("/chapter_1/index.md"), + number: Some(SectionNumber(vec![1])), + nested_items: vec![ + SummaryItem::Link(Link { + name: String::from("Some Subsection"), + location: PathBuf::from("/chapter_1/subsection.md"), + number: Some(SectionNumber(vec![1, 1])), + nested_items: vec![], + }), + ], + }), + ], + + suffix_chapters: vec![ + SummaryItem::Separator, + SummaryItem::Link(Link { + name: String::from("Conclusion"), + location: PathBuf::from("/conclusion.md"), + number: None, + nested_items: vec![], + }), + ], + } +}