From 698f8c2f01ea549d77d7dc3338a12e04c11057b9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 17 Apr 2024 14:02:58 +0200 Subject: Adding upstream version 1.64.0+dfsg1. Signed-off-by: Daniel Baumann --- vendor/mdbook/src/book/book.rs | 640 ++++++++++++++++++++++ vendor/mdbook/src/book/init.rs | 207 +++++++ vendor/mdbook/src/book/mod.rs | 836 ++++++++++++++++++++++++++++ vendor/mdbook/src/book/summary.rs | 1097 +++++++++++++++++++++++++++++++++++++ 4 files changed, 2780 insertions(+) create mode 100644 vendor/mdbook/src/book/book.rs create mode 100644 vendor/mdbook/src/book/init.rs create mode 100644 vendor/mdbook/src/book/mod.rs create mode 100644 vendor/mdbook/src/book/summary.rs (limited to 'vendor/mdbook/src/book') diff --git a/vendor/mdbook/src/book/book.rs b/vendor/mdbook/src/book/book.rs new file mode 100644 index 000000000..d28c22dad --- /dev/null +++ b/vendor/mdbook/src/book/book.rs @@ -0,0 +1,640 @@ +use std::collections::VecDeque; +use std::fmt::{self, Display, Formatter}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use super::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; +use crate::config::BuildConfig; +use crate::errors::*; +use crate::utils::bracket_escape; + +use serde::{Deserialize, Serialize}; + +/// Load a book into memory from its `src/` directory. +pub fn load_book>(src_dir: P, cfg: &BuildConfig) -> 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) + .with_context(|| format!("Couldn't open SUMMARY.md in {:?} directory", src_dir))? + .read_to_string(&mut summary_content)?; + + let summary = parse_summary(&summary_content) + .with_context(|| format!("Summary parsing failed for file={:?}", summary_md))?; + + if cfg.create_missing { + create_missing(src_dir, &summary).with_context(|| "Unable to create missing chapters")?; + } + + load_book_from_disk(&summary, src_dir) +} + +fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> { + let mut items: Vec<_> = summary + .prefix_chapters + .iter() + .chain(summary.numbered_chapters.iter()) + .chain(summary.suffix_chapters.iter()) + .collect(); + + while !items.is_empty() { + let next = items.pop().expect("already checked"); + + if let SummaryItem::Link(ref link) = *next { + if let Some(ref location) = link.location { + let filename = src_dir.join(location); + if !filename.exists() { + if let Some(parent) = filename.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + debug!("Creating missing file {}", filename.display()); + + let mut f = File::create(&filename).with_context(|| { + format!("Unable to create missing file: {}", filename.display()) + })?; + writeln!(f, "# {}", bracket_escape(&link.name))?; + } + } + + items.extend(&link.nested_items); + } + } + + Ok(()) +} + +/// A dumb tree structure representing a book. +/// +/// For the moment a book is just a collection of [`BookItems`] which are +/// accessible by either iterating (immutably) over the book with [`iter()`], or +/// recursively applying a closure to each section to mutate the chapters, using +/// [`for_each_mut()`]. +/// +/// [`iter()`]: #method.iter +/// [`for_each_mut()`]: #method.for_each_mut +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Book { + /// The sections in this book. + pub sections: Vec, + __non_exhaustive: (), +} + +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(), + } + } + + /// Recursively apply a closure to each item in the book, allowing you to + /// mutate them. + /// + /// # Note + /// + /// Unlike the `iter()` method, this requires a closure instead of returning + /// an iterator. This is because using iterators can possibly allow you + /// to have iterator invalidation errors. + pub fn for_each_mut(&mut self, mut func: F) + where + F: FnMut(&mut BookItem), + { + for_each_mut(&mut func, &mut self.sections); + } + + /// Append a `BookItem` to the `Book`. + pub fn push_item>(&mut self, item: I) -> &mut Self { + self.sections.push(item.into()); + self + } +} + +pub fn for_each_mut<'a, F, I>(func: &mut F, items: I) +where + F: FnMut(&mut BookItem), + I: IntoIterator, +{ + for item in items { + if let BookItem::Chapter(ch) = item { + for_each_mut(func, &mut ch.sub_items); + } + + func(item); + } +} + +/// 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, + /// A part title. + PartTitle(String), +} + +impl From for BookItem { + fn from(other: Chapter) -> BookItem { + BookItem::Chapter(other) + } +} + +/// 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: Option, + /// The chapter's source file, relative to the `SUMMARY.md` file. + pub source_path: Option, + /// An ordered list of the names of each chapter above this one in the hierarchy. + pub parent_names: Vec, +} + +impl Chapter { + /// Create a new chapter with the provided content. + pub fn new>( + name: &str, + content: String, + p: P, + parent_names: Vec, + ) -> Chapter { + let path: PathBuf = p.into(); + Chapter { + name: name.to_string(), + content, + path: Some(path.clone()), + source_path: Some(path), + parent_names, + ..Default::default() + } + } + + /// Create a new draft chapter that is not attached to a source markdown file (and thus + /// has no content). + pub fn new_draft(name: &str, parent_names: Vec) -> Self { + Chapter { + name: name.to_string(), + content: String::new(), + path: None, + source_path: None, + parent_names, + ..Default::default() + } + } + + /// Check if the chapter is a draft chapter, meaning it has no path to a source markdown file. + pub fn is_draft_chapter(&self) -> bool { + self.path.is_none() + } +} + +/// Use the provided `Summary` to load a `Book` from disk. +/// +/// You need to pass in the book's source directory because all the links in +/// `SUMMARY.md` give the chapter locations relative to it. +pub(crate) fn load_book_from_disk>(summary: &Summary, src_dir: P) -> 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, Vec::new())?; + chapters.push(chapter); + } + + Ok(Book { + sections: chapters, + __non_exhaustive: (), + }) +} + +fn load_summary_item + Clone>( + item: &SummaryItem, + src_dir: P, + parent_names: Vec, +) -> Result { + match item { + SummaryItem::Separator => Ok(BookItem::Separator), + SummaryItem::Link(ref link) => { + load_chapter(link, src_dir, parent_names).map(BookItem::Chapter) + } + SummaryItem::PartTitle(title) => Ok(BookItem::PartTitle(title.clone())), + } +} + +fn load_chapter>( + link: &Link, + src_dir: P, + parent_names: Vec, +) -> Result { + let src_dir = src_dir.as_ref(); + + let mut ch = if let Some(ref link_location) = link.location { + debug!("Loading {} ({})", link.name, link_location.display()); + + let location = if link_location.is_absolute() { + link_location.clone() + } else { + src_dir.join(link_location) + }; + + let mut f = File::open(&location) + .with_context(|| format!("Chapter file not found, {}", link_location.display()))?; + + let mut content = String::new(); + f.read_to_string(&mut content).with_context(|| { + format!("Unable to read \"{}\" ({})", link.name, location.display()) + })?; + + if content.as_bytes().starts_with(b"\xef\xbb\xbf") { + content.replace_range(..3, ""); + } + + let stripped = location + .strip_prefix(&src_dir) + .expect("Chapters are always inside a book"); + + Chapter::new(&link.name, content, stripped, parent_names.clone()) + } else { + Chapter::new_draft(&link.name, parent_names.clone()) + }; + + let mut sub_item_parents = parent_names; + + ch.number = link.number.clone(); + + sub_item_parents.push(link.name.clone()); + let sub_items = link + .nested_items + .iter() + .map(|i| load_summary_item(i, src_dir, sub_item_parents.clone())) + .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. +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 std::io::Write; + use tempfile::{Builder as TempFileBuilder, TempDir}; + + const DUMMY_SRC: &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 = TempFileBuilder::new().prefix("book").tempdir().unwrap(); + + let chapter_path = temp.path().join("chapter_1.md"); + File::create(&chapter_path) + .unwrap() + .write_all(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(b"Hello World!") + .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.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", + Vec::new(), + ); + + let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap(); + assert_eq!(got, should_be); + } + + #[test] + fn load_a_single_chapter_with_utf8_bom_from_disk() { + let temp_dir = TempFileBuilder::new().prefix("book").tempdir().unwrap(); + + let chapter_path = temp_dir.path().join("chapter_1.md"); + File::create(&chapter_path) + .unwrap() + .write_all(("\u{feff}".to_owned() + DUMMY_SRC).as_bytes()) + .unwrap(); + + let link = Link::new("Chapter 1", chapter_path); + + let should_be = Chapter::new( + "Chapter 1", + DUMMY_SRC.to_string(), + "chapter_1.md", + Vec::new(), + ); + + let got = load_chapter(&link, temp_dir.path(), Vec::new()).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, "", Vec::new()); + 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: Some(PathBuf::from("second.md")), + source_path: Some(PathBuf::from("second.md")), + parent_names: vec![String::from("Chapter 1")], + sub_items: Vec::new(), + }; + let should_be = BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: Some(PathBuf::from("chapter_1.md")), + source_path: Some(PathBuf::from("chapter_1.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(nested.clone()), + BookItem::Separator, + BookItem::Chapter(nested), + ], + }); + + let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).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: Some(PathBuf::from("chapter_1.md")), + source_path: Some(PathBuf::from("chapter_1.md")), + ..Default::default() + })], + ..Default::default() + }; + + let got = load_book_from_disk(&summary, temp.path()).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, + ], + ..Default::default() + }; + + 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: Some(PathBuf::from("Chapter_1/index.md")), + source_path: Some(PathBuf::from("Chapter_1/index.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(Chapter::new( + "Hello World", + String::new(), + "Chapter_1/hello.md", + Vec::new(), + )), + BookItem::Separator, + BookItem::Chapter(Chapter::new( + "Goodbye World", + String::new(), + "Chapter_1/goodbye.md", + Vec::new(), + )), + ], + }), + BookItem::Separator, + ], + ..Default::default() + }; + + 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 for_each_mut_visits_all_items() { + let mut book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: Some(PathBuf::from("Chapter_1/index.md")), + source_path: Some(PathBuf::from("Chapter_1/index.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(Chapter::new( + "Hello World", + String::new(), + "Chapter_1/hello.md", + Vec::new(), + )), + BookItem::Separator, + BookItem::Chapter(Chapter::new( + "Goodbye World", + String::new(), + "Chapter_1/goodbye.md", + Vec::new(), + )), + ], + }), + BookItem::Separator, + ], + ..Default::default() + }; + + let num_items = book.iter().count(); + let mut visited = 0; + + book.for_each_mut(|_| visited += 1); + + assert_eq!(visited, num_items); + } + + #[test] + fn cant_load_chapters_with_an_empty_path() { + let (_, temp) = dummy_link(); + let summary = Summary { + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("Empty"), + location: Some(PathBuf::from("")), + ..Default::default() + })], + + ..Default::default() + }; + + let got = load_book_from_disk(&summary, temp.path()); + assert!(got.is_err()); + } + + #[test] + fn cant_load_chapters_when_the_link_is_a_directory() { + let (_, temp) = dummy_link(); + let dir = temp.path().join("nested"); + fs::create_dir(&dir).unwrap(); + + let summary = Summary { + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("nested"), + location: Some(dir), + ..Default::default() + })], + ..Default::default() + }; + + let got = load_book_from_disk(&summary, temp.path()); + assert!(got.is_err()); + } +} diff --git a/vendor/mdbook/src/book/init.rs b/vendor/mdbook/src/book/init.rs new file mode 100644 index 000000000..264c113d3 --- /dev/null +++ b/vendor/mdbook/src/book/init.rs @@ -0,0 +1,207 @@ +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; + +use super::MDBook; +use crate::config::Config; +use crate::errors::*; +use crate::theme; + +/// A helper for setting up a new book and its directory structure. +#[derive(Debug, Clone, PartialEq)] +pub struct BookBuilder { + root: PathBuf, + create_gitignore: bool, + config: Config, + copy_theme: bool, +} + +impl BookBuilder { + /// Create a new `BookBuilder` which will generate a book in the provided + /// root directory. + pub fn new>(root: P) -> BookBuilder { + BookBuilder { + root: root.into(), + create_gitignore: false, + config: Config::default(), + copy_theme: false, + } + } + + /// Set the [`Config`] to be used. + pub fn with_config(&mut self, cfg: Config) -> &mut BookBuilder { + self.config = cfg; + self + } + + /// Get the config used by the `BookBuilder`. + pub fn config(&self) -> &Config { + &self.config + } + + /// Should the theme be copied into the generated book (so users can tweak + /// it)? + pub fn copy_theme(&mut self, copy: bool) -> &mut BookBuilder { + self.copy_theme = copy; + self + } + + /// Should we create a `.gitignore` file? + pub fn create_gitignore(&mut self, create: bool) -> &mut BookBuilder { + self.create_gitignore = create; + self + } + + /// Generate the actual book. This will: + /// + /// - Create the directory structure. + /// - Stub out some dummy chapters and the `SUMMARY.md`. + /// - Create a `.gitignore` (if applicable) + /// - Create a themes directory and populate it (if applicable) + /// - Generate a `book.toml` file, + /// - Then load the book so we can build it or run tests. + pub fn build(&self) -> Result { + info!("Creating a new book with stub content"); + + self.create_directory_structure() + .with_context(|| "Unable to create directory structure")?; + + self.create_stub_files() + .with_context(|| "Unable to create stub files")?; + + if self.create_gitignore { + self.build_gitignore() + .with_context(|| "Unable to create .gitignore")?; + } + + if self.copy_theme { + self.copy_across_theme() + .with_context(|| "Unable to copy across the theme")?; + } + + self.write_book_toml()?; + + match MDBook::load(&self.root) { + Ok(book) => Ok(book), + Err(e) => { + error!("{}", e); + + panic!( + "The BookBuilder should always create a valid book. If you are seeing this it \ + is a bug and should be reported." + ); + } + } + } + + fn write_book_toml(&self) -> Result<()> { + debug!("Writing book.toml"); + let book_toml = self.root.join("book.toml"); + let cfg = toml::to_vec(&self.config).with_context(|| "Unable to serialize the config")?; + + File::create(book_toml) + .with_context(|| "Couldn't create book.toml")? + .write_all(&cfg) + .with_context(|| "Unable to write config to book.toml")?; + Ok(()) + } + + fn copy_across_theme(&self) -> Result<()> { + debug!("Copying theme"); + + let html_config = self.config.html_config().unwrap_or_default(); + let themedir = html_config.theme_dir(&self.root); + + if !themedir.exists() { + debug!( + "{} does not exist, creating the directory", + themedir.display() + ); + fs::create_dir(&themedir)?; + } + + let mut index = File::create(themedir.join("index.hbs"))?; + index.write_all(theme::INDEX)?; + + let cssdir = themedir.join("css"); + if !cssdir.exists() { + fs::create_dir(&cssdir)?; + } + + let mut general_css = File::create(cssdir.join("general.css"))?; + general_css.write_all(theme::GENERAL_CSS)?; + + let mut chrome_css = File::create(cssdir.join("chrome.css"))?; + chrome_css.write_all(theme::CHROME_CSS)?; + + if html_config.print.enable { + let mut print_css = File::create(cssdir.join("print.css"))?; + print_css.write_all(theme::PRINT_CSS)?; + } + + let mut variables_css = File::create(cssdir.join("variables.css"))?; + variables_css.write_all(theme::VARIABLES_CSS)?; + + let mut favicon = File::create(themedir.join("favicon.png"))?; + favicon.write_all(theme::FAVICON_PNG)?; + + let mut favicon = File::create(themedir.join("favicon.svg"))?; + favicon.write_all(theme::FAVICON_SVG)?; + + let mut js = File::create(themedir.join("book.js"))?; + js.write_all(theme::JS)?; + + let mut highlight_css = File::create(themedir.join("highlight.css"))?; + highlight_css.write_all(theme::HIGHLIGHT_CSS)?; + + let mut highlight_js = File::create(themedir.join("highlight.js"))?; + highlight_js.write_all(theme::HIGHLIGHT_JS)?; + + Ok(()) + } + + fn build_gitignore(&self) -> Result<()> { + debug!("Creating .gitignore"); + + let mut f = File::create(self.root.join(".gitignore"))?; + + writeln!(f, "{}", self.config.build.build_dir.display())?; + + Ok(()) + } + + fn create_stub_files(&self) -> Result<()> { + debug!("Creating example book contents"); + let src_dir = self.root.join(&self.config.book.src); + + let summary = src_dir.join("SUMMARY.md"); + if !summary.exists() { + trace!("No summary found creating stub summary and chapter_1.md."); + let mut f = File::create(&summary).with_context(|| "Unable to create SUMMARY.md")?; + writeln!(f, "# Summary")?; + writeln!(f)?; + writeln!(f, "- [Chapter 1](./chapter_1.md)")?; + + let chapter_1 = src_dir.join("chapter_1.md"); + let mut f = + File::create(&chapter_1).with_context(|| "Unable to create chapter_1.md")?; + writeln!(f, "# Chapter 1")?; + } else { + trace!("Existing summary found, no need to create stub files."); + } + Ok(()) + } + + fn create_directory_structure(&self) -> Result<()> { + debug!("Creating directory tree"); + fs::create_dir_all(&self.root)?; + + let src = self.root.join(&self.config.book.src); + fs::create_dir_all(&src)?; + + let build = self.root.join(&self.config.build.build_dir); + fs::create_dir_all(&build)?; + + Ok(()) + } +} diff --git a/vendor/mdbook/src/book/mod.rs b/vendor/mdbook/src/book/mod.rs new file mode 100644 index 000000000..9745d2b7e --- /dev/null +++ b/vendor/mdbook/src/book/mod.rs @@ -0,0 +1,836 @@ +//! The internal representation of a book and infrastructure for loading it from +//! disk and building it. +//! +//! For examples on using `MDBook`, consult the [top-level documentation][1]. +//! +//! [1]: ../index.html + +#[allow(clippy::module_inception)] +mod book; +mod init; +mod summary; + +pub use self::book::{load_book, Book, BookItem, BookItems, Chapter}; +pub use self::init::BookBuilder; +pub use self::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; + +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::string::ToString; +use tempfile::Builder as TempFileBuilder; +use toml::Value; +use topological_sort::TopologicalSort; + +use crate::errors::*; +use crate::preprocess::{ + CmdPreprocessor, IndexPreprocessor, LinkPreprocessor, Preprocessor, PreprocessorContext, +}; +use crate::renderer::{CmdRenderer, HtmlHandlebars, MarkdownRenderer, RenderContext, Renderer}; +use crate::utils; + +use crate::config::{Config, RustEdition}; + +/// The object used to manage and build a book. +pub struct MDBook { + /// The book's root directory. + pub root: PathBuf, + /// The configuration used to tweak now a book is built. + pub config: Config, + /// A representation of the book's contents in memory. + pub book: Book, + renderers: Vec>, + + /// List of pre-processors to be run on the book. + preprocessors: Vec>, +} + +impl MDBook { + /// Load a book from its root directory on disk. + pub fn load>(book_root: P) -> Result { + let book_root = book_root.into(); + let config_location = book_root.join("book.toml"); + + // the book.json file is no longer used, so we should emit a warning to + // let people know to migrate to book.toml + if book_root.join("book.json").exists() { + warn!("It appears you are still using book.json for configuration."); + warn!("This format is no longer used, so you should migrate to the"); + warn!("book.toml format."); + warn!("Check the user guide for migration information:"); + warn!("\thttps://rust-lang.github.io/mdBook/format/config.html"); + } + + let mut config = if config_location.exists() { + debug!("Loading config from {}", config_location.display()); + Config::from_disk(&config_location)? + } else { + Config::default() + }; + + config.update_from_env(); + + if config + .html_config() + .map_or(false, |html| html.google_analytics.is_some()) + { + warn!( + "The output.html.google-analytics field has been deprecated; \ + it will be removed in a future release.\n\ + Consider placing the appropriate site tag code into the \ + theme/head.hbs file instead.\n\ + The tracking code may be found in the Google Analytics Admin page.\n\ + " + ); + } + + if log_enabled!(log::Level::Trace) { + for line in format!("Config: {:#?}", config).lines() { + trace!("{}", line); + } + } + + MDBook::load_with_config(book_root, config) + } + + /// Load a book from its root directory using a custom `Config`. + pub fn load_with_config>(book_root: P, config: Config) -> Result { + let root = book_root.into(); + + let src_dir = root.join(&config.book.src); + let book = book::load_book(&src_dir, &config.build)?; + + let renderers = determine_renderers(&config); + let preprocessors = determine_preprocessors(&config)?; + + Ok(MDBook { + root, + config, + book, + renderers, + preprocessors, + }) + } + + /// Load a book from its root directory using a custom `Config` and a custom summary. + pub fn load_with_config_and_summary>( + book_root: P, + config: Config, + summary: Summary, + ) -> Result { + let root = book_root.into(); + + let src_dir = root.join(&config.book.src); + let book = book::load_book_from_disk(&summary, &src_dir)?; + + let renderers = determine_renderers(&config); + let preprocessors = determine_preprocessors(&config)?; + + Ok(MDBook { + root, + config, + book, + renderers, + preprocessors, + }) + } + + /// Returns a flat depth-first iterator over the elements of the book, + /// it returns a [`BookItem`] enum: + /// `(section: String, bookitem: &BookItem)` + /// + /// ```no_run + /// # use mdbook::MDBook; + /// # use mdbook::book::BookItem; + /// # let book = MDBook::load("mybook").unwrap(); + /// for item in book.iter() { + /// match *item { + /// BookItem::Chapter(ref chapter) => {}, + /// BookItem::Separator => {}, + /// BookItem::PartTitle(ref title) => {} + /// } + /// } + /// + /// // would print something like this: + /// // 1. Chapter 1 + /// // 1.1 Sub Chapter + /// // 1.2 Sub Chapter + /// // 2. Chapter 2 + /// // + /// // etc. + /// ``` + pub fn iter(&self) -> BookItems<'_> { + self.book.iter() + } + + /// `init()` gives you a `BookBuilder` which you can use to setup a new book + /// and its accompanying directory structure. + /// + /// The `BookBuilder` creates some boilerplate files and directories to get + /// you started with your book. + /// + /// ```text + /// book-test/ + /// ├── book + /// └── src + /// ├── chapter_1.md + /// └── SUMMARY.md + /// ``` + /// + /// It uses the path provided as the root directory for your book, then adds + /// in a `src/` directory containing a `SUMMARY.md` and `chapter_1.md` file + /// to get you started. + pub fn init>(book_root: P) -> BookBuilder { + BookBuilder::new(book_root) + } + + /// Tells the renderer to build our book and put it in the build directory. + pub fn build(&self) -> Result<()> { + info!("Book building has started"); + + for renderer in &self.renderers { + self.execute_build_process(&**renderer)?; + } + + Ok(()) + } + + /// Run the entire build process for a particular [`Renderer`]. + pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> { + let mut preprocessed_book = self.book.clone(); + let preprocess_ctx = PreprocessorContext::new( + self.root.clone(), + self.config.clone(), + renderer.name().to_string(), + ); + + for preprocessor in &self.preprocessors { + if preprocessor_should_run(&**preprocessor, renderer, &self.config) { + debug!("Running the {} preprocessor.", preprocessor.name()); + preprocessed_book = preprocessor.run(&preprocess_ctx, preprocessed_book)?; + } + } + + let name = renderer.name(); + let build_dir = self.build_dir_for(name); + + let mut render_context = RenderContext::new( + self.root.clone(), + preprocessed_book, + self.config.clone(), + build_dir, + ); + render_context + .chapter_titles + .extend(preprocess_ctx.chapter_titles.borrow_mut().drain()); + + info!("Running the {} backend", renderer.name()); + renderer + .render(&render_context) + .with_context(|| "Rendering failed") + } + + /// You can change the default renderer to another one by using this method. + /// The only requirement is that your renderer implement the [`Renderer`] + /// trait. + pub fn with_renderer(&mut self, renderer: R) -> &mut Self { + self.renderers.push(Box::new(renderer)); + self + } + + /// Register a [`Preprocessor`] to be used when rendering the book. + pub fn with_preprocessor(&mut self, preprocessor: P) -> &mut Self { + self.preprocessors.push(Box::new(preprocessor)); + self + } + + /// Run `rustdoc` tests on the book, linking against the provided libraries. + pub fn test(&mut self, library_paths: Vec<&str>) -> Result<()> { + 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 = TempFileBuilder::new().prefix("mdbook-").tempdir()?; + + // FIXME: Is "test" the proper renderer name to use here? + let preprocess_context = + PreprocessorContext::new(self.root.clone(), self.config.clone(), "test".to_string()); + + let book = LinkPreprocessor::new().run(&preprocess_context, self.book.clone())?; + // Index Preprocessor is disabled so that chapter paths continue to point to the + // actual markdown files. + + let mut failed = false; + for item in book.iter() { + if let BookItem::Chapter(ref ch) = *item { + let chapter_path = match ch.path { + Some(ref path) if !path.as_os_str().is_empty() => path, + _ => continue, + }; + + let path = self.source_dir().join(&chapter_path); + info!("Testing file: {:?}", path); + + // write preprocessed file to tempdir + let path = temp_dir.path().join(&chapter_path); + let mut tmpf = utils::fs::create_file(&path)?; + tmpf.write_all(ch.content.as_bytes())?; + + let mut cmd = Command::new("rustdoc"); + cmd.arg(&path).arg("--test").args(&library_args); + + if let Some(edition) = self.config.rust.edition { + match edition { + RustEdition::E2015 => { + cmd.args(&["--edition", "2015"]); + } + RustEdition::E2018 => { + cmd.args(&["--edition", "2018"]); + } + RustEdition::E2021 => { + cmd.args(&["--edition", "2021"]); + } + } + } + + let output = cmd.output()?; + + if !output.status.success() { + failed = true; + error!( + "rustdoc returned an error:\n\ + \n--- stdout\n{}\n--- stderr\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + } + if failed { + bail!("One or more tests failed"); + } + Ok(()) + } + + /// The logic for determining where a backend should put its build + /// artefacts. + /// + /// If there is only 1 renderer, put it in the directory pointed to by the + /// `build.build_dir` key in [`Config`]. If there is more than one then the + /// renderer gets its own directory within the main build dir. + /// + /// i.e. If there were only one renderer (in this case, the HTML renderer): + /// + /// - build/ + /// - index.html + /// - ... + /// + /// Otherwise if there are multiple: + /// + /// - build/ + /// - epub/ + /// - my_awesome_book.epub + /// - html/ + /// - index.html + /// - ... + /// - latex/ + /// - my_awesome_book.tex + /// + pub fn build_dir_for(&self, backend_name: &str) -> PathBuf { + let build_dir = self.root.join(&self.config.build.build_dir); + + if self.renderers.len() <= 1 { + build_dir + } else { + build_dir.join(backend_name) + } + } + + /// Get the directory containing this book's source files. + pub fn source_dir(&self) -> PathBuf { + self.root.join(&self.config.book.src) + } + + /// Get the directory containing the theme resources for the book. + pub fn theme_dir(&self) -> PathBuf { + self.config + .html_config() + .unwrap_or_default() + .theme_dir(&self.root) + } +} + +/// Look at the `Config` and try to figure out what renderers to use. +fn determine_renderers(config: &Config) -> Vec> { + let mut renderers = Vec::new(); + + if let Some(output_table) = config.get("output").and_then(Value::as_table) { + renderers.extend(output_table.iter().map(|(key, table)| { + if key == "html" { + Box::new(HtmlHandlebars::new()) as Box + } else if key == "markdown" { + Box::new(MarkdownRenderer::new()) as Box + } else { + interpret_custom_renderer(key, table) + } + })); + } + + // if we couldn't find anything, add the HTML renderer as a default + if renderers.is_empty() { + renderers.push(Box::new(HtmlHandlebars::new())); + } + + renderers +} + +const DEFAULT_PREPROCESSORS: &[&str] = &["links", "index"]; + +fn is_default_preprocessor(pre: &dyn Preprocessor) -> bool { + let name = pre.name(); + name == LinkPreprocessor::NAME || name == IndexPreprocessor::NAME +} + +/// Look at the `MDBook` and try to figure out what preprocessors to run. +fn determine_preprocessors(config: &Config) -> Result>> { + // Collect the names of all preprocessors intended to be run, and the order + // in which they should be run. + let mut preprocessor_names = TopologicalSort::::new(); + + if config.build.use_default_preprocessors { + for name in DEFAULT_PREPROCESSORS { + preprocessor_names.insert(name.to_string()); + } + } + + if let Some(preprocessor_table) = config.get("preprocessor").and_then(Value::as_table) { + for (name, table) in preprocessor_table.iter() { + preprocessor_names.insert(name.to_string()); + + let exists = |name| { + (config.build.use_default_preprocessors && DEFAULT_PREPROCESSORS.contains(&name)) + || preprocessor_table.contains_key(name) + }; + + if let Some(before) = table.get("before") { + let before = before.as_array().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.before to be an array", + name + )) + })?; + for after in before { + let after = after.as_str().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.before to contain strings", + name + )) + })?; + + if !exists(after) { + // Only warn so that preprocessors can be toggled on and off (e.g. for + // troubleshooting) without having to worry about order too much. + warn!( + "preprocessor.{}.after contains \"{}\", which was not found", + name, after + ); + } else { + preprocessor_names.add_dependency(name, after); + } + } + } + + if let Some(after) = table.get("after") { + let after = after.as_array().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.after to be an array", + name + )) + })?; + for before in after { + let before = before.as_str().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.after to contain strings", + name + )) + })?; + + if !exists(before) { + // See equivalent warning above for rationale + warn!( + "preprocessor.{}.before contains \"{}\", which was not found", + name, before + ); + } else { + preprocessor_names.add_dependency(before, name); + } + } + } + } + } + + // Now that all links have been established, queue preprocessors in a suitable order + let mut preprocessors = Vec::with_capacity(preprocessor_names.len()); + // `pop_all()` returns an empty vector when no more items are not being depended upon + for mut names in std::iter::repeat_with(|| preprocessor_names.pop_all()) + .take_while(|names| !names.is_empty()) + { + // The `topological_sort` crate does not guarantee a stable order for ties, even across + // runs of the same program. Thus, we break ties manually by sorting. + // Careful: `str`'s default sorting, which we are implicitly invoking here, uses code point + // values ([1]), which may not be an alphabetical sort. + // As mentioned in [1], doing so depends on locale, which is not desirable for deciding + // preprocessor execution order. + // [1]: https://doc.rust-lang.org/stable/std/cmp/trait.Ord.html#impl-Ord-14 + names.sort(); + for name in names { + let preprocessor: Box = match name.as_str() { + "links" => Box::new(LinkPreprocessor::new()), + "index" => Box::new(IndexPreprocessor::new()), + _ => { + // The only way to request a custom preprocessor is through the `preprocessor` + // table, so it must exist, be a table, and contain the key. + let table = &config.get("preprocessor").unwrap().as_table().unwrap()[&name]; + let command = get_custom_preprocessor_cmd(&name, table); + Box::new(CmdPreprocessor::new(name, command)) + } + }; + preprocessors.push(preprocessor); + } + } + + // "If `pop_all` returns an empty vector and `len` is not 0, there are cyclic dependencies." + // Normally, `len() == 0` is equivalent to `is_empty()`, so we'll use that. + if preprocessor_names.is_empty() { + Ok(preprocessors) + } else { + Err(Error::msg("Cyclic dependency detected in preprocessors")) + } +} + +fn get_custom_preprocessor_cmd(key: &str, table: &Value) -> String { + table + .get("command") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("mdbook-{}", key)) +} + +fn interpret_custom_renderer(key: &str, table: &Value) -> Box { + // look for the `command` field, falling back to using the key + // prepended by "mdbook-" + let table_dot_command = table + .get("command") + .and_then(Value::as_str) + .map(ToString::to_string); + + let command = table_dot_command.unwrap_or_else(|| format!("mdbook-{}", key)); + + Box::new(CmdRenderer::new(key.to_string(), command)) +} + +/// Check whether we should run a particular `Preprocessor` in combination +/// with the renderer, falling back to `Preprocessor::supports_renderer()` +/// method if the user doesn't say anything. +/// +/// The `build.use-default-preprocessors` config option can be used to ensure +/// default preprocessors always run if they support the renderer. +fn preprocessor_should_run( + preprocessor: &dyn Preprocessor, + renderer: &dyn Renderer, + cfg: &Config, +) -> bool { + // default preprocessors should be run by default (if supported) + if cfg.build.use_default_preprocessors && is_default_preprocessor(preprocessor) { + return preprocessor.supports_renderer(renderer.name()); + } + + let key = format!("preprocessor.{}.renderers", preprocessor.name()); + let renderer_name = renderer.name(); + + if let Some(Value::Array(ref explicit_renderers)) = cfg.get(&key) { + return explicit_renderers + .iter() + .filter_map(Value::as_str) + .any(|name| name == renderer_name); + } + + preprocessor.supports_renderer(renderer_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use toml::value::{Table, Value}; + + #[test] + fn config_defaults_to_html_renderer_if_empty() { + let cfg = Config::default(); + + // make sure we haven't got anything in the `output` table + assert!(cfg.get("output").is_none()); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "html"); + } + + #[test] + fn add_a_random_renderer_to_the_config() { + let mut cfg = Config::default(); + cfg.set("output.random", Table::new()).unwrap(); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "random"); + } + + #[test] + fn add_a_random_renderer_with_custom_command_to_the_config() { + let mut cfg = Config::default(); + + let mut table = Table::new(); + table.insert("command".to_string(), Value::String("false".to_string())); + cfg.set("output.random", table).unwrap(); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "random"); + } + + #[test] + fn config_defaults_to_link_and_index_preprocessor_if_not_set() { + let cfg = Config::default(); + + // make sure we haven't got anything in the `preprocessor` table + assert!(cfg.get("preprocessor").is_none()); + + let got = determine_preprocessors(&cfg); + + assert!(got.is_ok()); + assert_eq!(got.as_ref().unwrap().len(), 2); + assert_eq!(got.as_ref().unwrap()[0].name(), "index"); + assert_eq!(got.as_ref().unwrap()[1].name(), "links"); + } + + #[test] + fn use_default_preprocessors_works() { + let mut cfg = Config::default(); + cfg.build.use_default_preprocessors = false; + + let got = determine_preprocessors(&cfg).unwrap(); + + assert_eq!(got.len(), 0); + } + + #[test] + fn can_determine_third_party_preprocessors() { + let cfg_str = r#" + [book] + title = "Some Book" + + [preprocessor.random] + + [build] + build-dir = "outputs" + create-missing = false + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // make sure the `preprocessor.random` table exists + assert!(cfg.get_preprocessor("random").is_some()); + + let got = determine_preprocessors(&cfg).unwrap(); + + assert!(got.into_iter().any(|p| p.name() == "random")); + } + + #[test] + fn preprocessors_can_provide_their_own_commands() { + let cfg_str = r#" + [preprocessor.random] + command = "python random.py" + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // make sure the `preprocessor.random` table exists + let random = cfg.get_preprocessor("random").unwrap(); + let random = get_custom_preprocessor_cmd("random", &Value::Table(random.clone())); + + assert_eq!(random, "python random.py"); + } + + #[test] + fn preprocessor_before_must_be_array() { + let cfg_str = r#" + [preprocessor.random] + before = 0 + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn preprocessor_after_must_be_array() { + let cfg_str = r#" + [preprocessor.random] + after = 0 + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn preprocessor_order_is_honored() { + let cfg_str = r#" + [preprocessor.random] + before = [ "last" ] + after = [ "index" ] + + [preprocessor.last] + after = [ "links", "index" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + let index = |name| { + preprocessors + .iter() + .enumerate() + .find(|(_, preprocessor)| preprocessor.name() == name) + .unwrap() + .0 + }; + let assert_before = |before, after| { + if index(before) >= index(after) { + eprintln!("Preprocessor order:"); + for preprocessor in &preprocessors { + eprintln!(" {}", preprocessor.name()); + } + panic!("{} should come before {}", before, after); + } + }; + + assert_before("index", "random"); + assert_before("index", "last"); + assert_before("random", "last"); + assert_before("links", "last"); + } + + #[test] + fn cyclic_dependencies_are_detected() { + let cfg_str = r#" + [preprocessor.links] + before = [ "index" ] + + [preprocessor.index] + before = [ "links" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn dependencies_dont_register_undefined_preprocessors() { + let cfg_str = r#" + [preprocessor.links] + before = [ "random" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + + assert!(!preprocessors + .iter() + .any(|preprocessor| preprocessor.name() == "random")); + } + + #[test] + fn dependencies_dont_register_builtin_preprocessors_if_disabled() { + let cfg_str = r#" + [preprocessor.random] + before = [ "links" ] + + [build] + use-default-preprocessors = false + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + + assert!(!preprocessors + .iter() + .any(|preprocessor| preprocessor.name() == "links")); + } + + #[test] + fn config_respects_preprocessor_selection() { + let cfg_str = r#" + [preprocessor.links] + renderers = ["html"] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // double-check that we can access preprocessor.links.renderers[0] + let html = cfg + .get_preprocessor("links") + .and_then(|links| links.get("renderers")) + .and_then(Value::as_array) + .and_then(|renderers| renderers.get(0)) + .and_then(Value::as_str) + .unwrap(); + assert_eq!(html, "html"); + let html_renderer = HtmlHandlebars::default(); + let pre = LinkPreprocessor::new(); + + let should_run = preprocessor_should_run(&pre, &html_renderer, &cfg); + assert!(should_run); + } + + struct BoolPreprocessor(bool); + impl Preprocessor for BoolPreprocessor { + fn name(&self) -> &str { + "bool-preprocessor" + } + + fn run(&self, _ctx: &PreprocessorContext, _book: Book) -> Result { + unimplemented!() + } + + fn supports_renderer(&self, _renderer: &str) -> bool { + self.0 + } + } + + #[test] + fn preprocessor_should_run_falls_back_to_supports_renderer_method() { + let cfg = Config::default(); + let html = HtmlHandlebars::new(); + + let should_be = true; + let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg); + assert_eq!(got, should_be); + + let should_be = false; + let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg); + assert_eq!(got, should_be); + } +} diff --git a/vendor/mdbook/src/book/summary.rs b/vendor/mdbook/src/book/summary.rs new file mode 100644 index 000000000..2bd81580f --- /dev/null +++ b/vendor/mdbook/src/book/summary.rs @@ -0,0 +1,1097 @@ +use crate::errors::*; +use memchr::{self, Memchr}; +use pulldown_cmark::{self, Event, HeadingLevel, Tag}; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display, Formatter}; +use std::iter::FromIterator; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; + +/// 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". It's not mandatory and the parser (currently) 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) +/// ``` +/// +/// **Part Title:** An optional title for the next collect of numbered chapters. The numbered +/// chapters can be broken into as many parts as desired. +/// +/// **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 Part +/// +/// - [Title of the Chapter](relative/path/to/markdown.md) +/// ``` +/// +/// You can either use - or * to indicate a numbered chapter, the parser doesn't +/// care but you'll probably want to stay consistent. +/// +/// **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 numbered chapters of the book, broken into one or more possibly named parts. + 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: Option, + /// 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: Some(location.as_ref().to_path_buf()), + number: None, + nested_items: Vec::new(), + } + } +} + +impl Default for Link { + fn default() -> Self { + Link { + name: String::new(), + location: Some(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, + /// A part title. + PartTitle(String), +} + +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) + } +} + +/// A recursive descent (-ish) parser for a `SUMMARY.md`. +/// +/// +/// # 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 ::= part+ +/// part ::= title 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> { + src: &'a str, + stream: pulldown_cmark::OffsetIter<'a, 'a>, + offset: usize, + + /// We can't actually put an event back into the `OffsetIter` stream, so instead we store it + /// here until somebody calls `next_event` again. + back: Option>, +} + +/// 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 != $delimiter).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,start $delimiter:pat) => { + collect_events!($stream, Event::Start($delimiter)) + }; + ($stream:expr,end $delimiter:pat) => { + collect_events!($stream, Event::End($delimiter)) + }; + ($stream:expr, $delimiter:pat) => {{ + let mut events = Vec::new(); + + loop { + let event = $stream.next().map(|(ev, _range)| ev); + trace!("Next event: {:?}", event); + + match event { + Some($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).into_offset_iter(); + + SummaryParser { + src: text, + stream: pulldown_parser, + offset: 0, + back: None, + } + } + + /// Get the current line and column to give the user more useful error + /// messages. + fn current_location(&self) -> (usize, usize) { + let previous_text = self.src[..self.offset].as_bytes(); + let line = Memchr::new(b'\n', previous_text).count() + 1; + let start_of_line = memchr::memrchr(b'\n', previous_text).unwrap_or(0); + let col = self.src[start_of_line..self.offset].chars().count(); + + (line, col) + } + + /// Parse the text the `SummaryParser` was created with. + fn parse(mut self) -> Result { + let title = self.parse_title(); + + let prefix_chapters = self + .parse_affix(true) + .with_context(|| "There was an error parsing the prefix chapters")?; + let numbered_chapters = self + .parse_parts() + .with_context(|| "There was an error parsing the numbered chapters")?; + let suffix_chapters = self + .parse_affix(false) + .with_context(|| "There was an error parsing the suffix chapters")?; + + Ok(Summary { + title, + prefix_chapters, + numbered_chapters, + suffix_chapters, + }) + } + + /// Parse the affix chapters. + fn parse_affix(&mut self, is_prefix: bool) -> Result> { + let mut items = Vec::new(); + debug!( + "Parsing {} items", + if is_prefix { "prefix" } else { "suffix" } + ); + + loop { + match self.next_event() { + Some(ev @ Event::Start(Tag::List(..))) + | Some(ev @ Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + if is_prefix { + // we've finished prefix chapters and are at the start + // of the numbered section. + self.back(ev); + break; + } else { + bail!(self.parse_error("Suffix chapters cannot be followed by a list")); + } + } + Some(Event::Start(Tag::Link(_type, href, _title))) => { + let link = self.parse_link(href.to_string()); + items.push(SummaryItem::Link(link)); + } + Some(Event::Rule) => items.push(SummaryItem::Separator), + Some(_) => {} + None => break, + } + } + + Ok(items) + } + + fn parse_parts(&mut self) -> Result> { + let mut parts = vec![]; + + // We want the section numbers to be continues through all parts. + let mut root_number = SectionNumber::default(); + let mut root_items = 0; + + loop { + // Possibly match a title or the end of the "numbered chapters part". + let title = match self.next_event() { + Some(ev @ Event::Start(Tag::Paragraph)) => { + // we're starting the suffix chapters + self.back(ev); + break; + } + + Some(Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + debug!("Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); + Some(stringify_events(tags)) + } + + Some(ev) => { + self.back(ev); + None + } + + None => break, // EOF, bail... + }; + + // Parse the rest of the part. + let numbered_chapters = self + .parse_numbered(&mut root_items, &mut root_number) + .with_context(|| "There was an error parsing the numbered chapters")?; + + if let Some(title) = title { + parts.push(SummaryItem::PartTitle(title)); + } + parts.extend(numbered_chapters); + } + + Ok(parts) + } + + /// Finishes parsing a link once the `Event::Start(Tag::Link(..))` has been opened. + fn parse_link(&mut self, href: String) -> Link { + let href = href.replace("%20", " "); + let link_content = collect_events!(self.stream, end Tag::Link(..)); + let name = stringify_events(link_content); + + let path = if href.is_empty() { + None + } else { + Some(PathBuf::from(href)) + }; + + Link { + name, + location: path, + number: None, + nested_items: Vec::new(), + } + } + + /// Parse the numbered chapters. + fn parse_numbered( + &mut self, + root_items: &mut u32, + root_number: &mut SectionNumber, + ) -> Result> { + let mut items = Vec::new(); + + // For the first iteration, we want to just skip any opening paragraph tags, as that just + // marks the start of the list. But after that, another opening paragraph indicates that we + // have started a new part or the suffix chapters. + let mut first = true; + + loop { + match self.next_event() { + Some(ev @ Event::Start(Tag::Paragraph)) => { + if !first { + // we're starting the suffix chapters + self.back(ev); + break; + } + } + // The expectation is that pulldown cmark will terminate a paragraph before a new + // heading, so we can always count on this to return without skipping headings. + Some(ev @ Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + // we're starting a new part + self.back(ev); + break; + } + Some(ev @ Event::Start(Tag::List(..))) => { + self.back(ev); + let mut bunch_of_items = self.parse_nested_numbered(root_number)?; + + // if we've resumed after something like a rule the root sections + // will be numbered from 1. We need to manually go back and update + // them + update_section_numbers(&mut bunch_of_items, 0, *root_items); + *root_items += bunch_of_items.len() as u32; + items.extend(bunch_of_items); + } + Some(Event::Start(other_tag)) => { + trace!("Skipping contents of {:?}", other_tag); + + // Skip over the contents of this tag + while let Some(event) = self.next_event() { + if event == Event::End(other_tag.clone()) { + break; + } + } + } + Some(Event::Rule) => { + items.push(SummaryItem::Separator); + } + + // something else... ignore + Some(_) => {} + + // EOF, bail... + None => { + break; + } + } + + // From now on, we cannot accept any new paragraph opening tags. + first = false; + } + + Ok(items) + } + + /// Push an event back to the tail of the stream. + fn back(&mut self, ev: Event<'a>) { + assert!(self.back.is_none()); + trace!("Back: {:?}", ev); + self.back = Some(ev); + } + + fn next_event(&mut self) -> Option> { + let next = self.back.take().or_else(|| { + self.stream.next().map(|(ev, range)| { + self.offset = range.start; + ev + }) + }); + + trace!("Next event: {:?}", next); + + next + } + + fn parse_nested_numbered(&mut self, parent: &SectionNumber) -> Result> { + debug!("Parsing numbered chapters at level {}", parent); + let mut items = Vec::new(); + + loop { + match self.next_event() { + Some(Event::Start(Tag::Item)) => { + let item = self.parse_nested_item(parent, items.len())?; + items.push(item); + } + Some(Event::Start(Tag::List(..))) => { + // Skip this tag after comment bacause it is not nested. + if items.is_empty() { + continue; + } + // recurse to parse the nested list + let (_, last_item) = get_last_link(&mut items)?; + let last_item_number = last_item + .number + .as_ref() + .expect("All numbered chapters have numbers"); + + let sub_items = self.parse_nested_numbered(last_item_number)?; + + last_item.nested_items = sub_items; + } + Some(Event::End(Tag::List(..))) => break, + Some(_) => {} + None => break, + } + } + + Ok(items) + } + + fn parse_nested_item( + &mut self, + parent: &SectionNumber, + num_existing_items: usize, + ) -> Result { + loop { + match self.next_event() { + Some(Event::Start(Tag::Paragraph)) => continue, + Some(Event::Start(Tag::Link(_type, href, _title))) => { + let mut link = self.parse_link(href.to_string()); + + let mut number = parent.clone(); + number.0.push(num_existing_items as u32 + 1); + trace!( + "Found chapter: {} {} ({})", + number, + link.name, + link.location + .as_ref() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("[draft]") + ); + + link.number = Some(number); + + return Ok(SummaryItem::Link(link)); + } + other => { + warn!("Expected a start of a link, actually got {:?}", other); + bail!(self.parse_error( + "The link items for nested chapters must only contain a hyperlink" + )); + } + } + } + } + + fn parse_error(&self, msg: D) -> Error { + let (line, col) = self.current_location(); + anyhow::anyhow!( + "failed to parse SUMMARY.md line {}, column {}: {}", + line, + col, + msg + ) + } + + /// Try to parse the title line. + fn parse_title(&mut self) -> Option { + loop { + match self.next_event() { + Some(Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + debug!("Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); + return Some(stringify_events(tags)); + } + // Skip a HTML element such as a comment line. + Some(Event::Html(_)) => {} + // Otherwise, no title. + Some(ev) => { + self.back(ev); + return None; + } + _ => return None, + } + } + } +} + +fn update_section_numbers(sections: &mut [SummaryItem], level: usize, by: u32) { + for section in sections { + if let SummaryItem::Link(ref mut link) = *section { + if let Some(ref mut number) = link.number { + number.0[level] += by; + } + + update_section_numbers(&mut link.nested_items, level, by); + } + } +} + +/// 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(|| + anyhow::anyhow!("Unable to get last link because the list of SummaryItems doesn't contain any Links") + ) +} + +/// 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) | Event::Code(text) => Some(text.into_string()), + Event::SoftBreak => Some(String::from(" ")), + _ => 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 { + if self.0.is_empty() { + write!(f, "0") + } else { + 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 + } +} + +impl FromIterator for SectionNumber { + fn from_iter>(it: I) -> Self { + SectionNumber(it.into_iter().collect()) + } +} + +#[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 no_initial_title() { + let src = "[Link]()"; + let mut parser = SummaryParser::new(src); + + assert!(parser.parse_title().is_none()); + assert!(matches!( + parser.next_event(), + Some(Event::Start(Tag::Paragraph)) + )); + } + + #[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 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 parse_some_prefix_items() { + let src = "[First](./first.md)\n[Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + ..Default::default() + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + ..Default::default() + }), + ]; + + let got = parser.parse_affix(true).unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_prefix_items_with_a_separator() { + let src = "[First](./first.md)\n\n---\n\n[Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_affix(true).unwrap(); + + assert_eq!(got.len(), 3); + assert_eq!(got[1], SummaryItem::Separator); + } + + #[test] + fn suffix_items_cannot_be_followed_by_a_list() { + let src = "[First](./first.md)\n- [Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_affix(false); + + assert!(got.is_err()); + } + + #[test] + fn parse_a_link() { + let src = "[First](./first.md)"; + let should_be = Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + ..Default::default() + }; + + let mut parser = SummaryParser::new(src); + let _ = parser.stream.next(); // Discard opening paragraph + + let href = match parser.stream.next() { + Some((Event::Start(Tag::Link(_type, href, _title)), _range)) => href.to_string(), + other => panic!("Unreachable, {:?}", other), + }; + + let got = parser.parse_link(href); + assert_eq!(got, should_be); + } + + #[test] + fn parse_a_numbered_chapter() { + let src = "- [First](./first.md)\n"; + let link = Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + ..Default::default() + }; + let should_be = vec![SummaryItem::Link(link)]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_nested_numbered_chapters() { + let src = "- [First](./first.md)\n - [Nested](./nested.md)\n- [Second](./second.md)"; + + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: vec![SummaryItem::Link(Link { + name: String::from("Nested"), + location: Some(PathBuf::from("./nested.md")), + number: Some(SectionNumber(vec![1, 1])), + nested_items: Vec::new(), + })], + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_numbered_chapters_separated_by_comment() { + let src = "- [First](./first.md)\n\n- [Second](./second.md)"; + + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_titled_parts() { + let src = "- [First](./first.md)\n- [Second](./second.md)\n\ + # Title 2\n- [Third](./third.md)\n\t- [Fourth](./fourth.md)"; + + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + SummaryItem::PartTitle(String::from("Title 2")), + SummaryItem::Link(Link { + name: String::from("Third"), + location: Some(PathBuf::from("./third.md")), + number: Some(SectionNumber(vec![3])), + nested_items: vec![SummaryItem::Link(Link { + name: String::from("Fourth"), + location: Some(PathBuf::from("./fourth.md")), + number: Some(SectionNumber(vec![3, 1])), + nested_items: Vec::new(), + })], + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser.parse_parts().unwrap(); + + assert_eq!(got, should_be); + } + + /// This test ensures the book will continue to pass because it breaks the + /// `SUMMARY.md` up using level 2 headers ([example]). + /// + /// [example]: https://github.com/rust-lang/book/blob/2c942dc094f4ddcdc7aba7564f80782801197c99/second-edition/src/SUMMARY.md#basic-rust-literacy + #[test] + fn can_have_a_subheader_between_nested_items() { + let src = "- [First](./first.md)\n\n## Subheading\n\n- [Second](./second.md)\n"; + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn an_empty_link_location_is_a_draft_chapter() { + let src = "- [Empty]()\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_numbered(&mut 0, &mut SectionNumber::default()); + let should_be = vec![SummaryItem::Link(Link { + name: String::from("Empty"), + location: None, + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + })]; + + assert!(got.is_ok()); + assert_eq!(got.unwrap(), should_be); + } + + /// Regression test for https://github.com/rust-lang/mdBook/issues/779 + /// Ensure section numbers are correctly incremented after a horizontal separator. + #[test] + fn keep_numbering_after_separator() { + let src = + "- [First](./first.md)\n---\n- [Second](./second.md)\n---\n- [Third](./third.md)\n"; + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Separator, + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + SummaryItem::Separator, + SummaryItem::Link(Link { + name: String::from("Third"), + location: Some(PathBuf::from("./third.md")), + number: Some(SectionNumber(vec![3])), + nested_items: Vec::new(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + /// Regression test for https://github.com/rust-lang/mdBook/issues/1218 + /// Ensure chapter names spread across multiple lines have spaces between all the words. + #[test] + fn add_space_for_multi_line_chapter_names() { + let src = "- [Chapter\ntitle](./chapter.md)"; + let should_be = vec![SummaryItem::Link(Link { + name: String::from("Chapter title"), + location: Some(PathBuf::from("./chapter.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + })]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn allow_space_in_link_destination() { + let src = "- [test1](./test%20link1.md)\n- [test2](<./test link2.md>)"; + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("test1"), + location: Some(PathBuf::from("./test link1.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("test2"), + location: Some(PathBuf::from("./test link2.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ]; + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn skip_html_comments() { + let src = r#" +# Title - Local + + +[Prefix 00-01 - Local](ch00-01.md) +[Prefix 00-02 - Local](ch00-02.md) + + +## Section Title - Localized + + +- [Ch 01-00 - Local](ch01-00.md) + - [Ch 01-01 - Local](ch01-01.md) + - [Ch 01-02 - Local](ch01-02.md) + + +- [Ch 02-00 - Local](ch02-00.md) + +` +[Appendix A - Local](appendix-01.md) +[Appendix B - Local](appendix-02.md) +"#; + + let mut parser = SummaryParser::new(src); + + // ---- Title ---- + let title = parser.parse_title(); + assert_eq!(title, Some(String::from("Title - Local"))); + + // ---- Prefix Chapters ---- + + let new_affix_item = |name, location| { + SummaryItem::Link(Link { + name: String::from(name), + location: Some(PathBuf::from(location)), + ..Default::default() + }) + }; + + let should_be = vec![ + new_affix_item("Prefix 00-01 - Local", "ch00-01.md"), + new_affix_item("Prefix 00-02 - Local", "ch00-02.md"), + ]; + + let got = parser.parse_affix(true).unwrap(); + assert_eq!(got, should_be); + + // ---- Numbered Chapters ---- + + let new_numbered_item = |name, location, numbers: &[u32], nested_items| { + SummaryItem::Link(Link { + name: String::from(name), + location: Some(PathBuf::from(location)), + number: Some(SectionNumber(numbers.to_vec())), + nested_items, + }) + }; + + let ch01_nested = vec![ + new_numbered_item("Ch 01-01 - Local", "ch01-01.md", &[1, 1], vec![]), + new_numbered_item("Ch 01-02 - Local", "ch01-02.md", &[1, 2], vec![]), + ]; + + let should_be = vec![ + new_numbered_item("Ch 01-00 - Local", "ch01-00.md", &[1], ch01_nested), + new_numbered_item("Ch 02-00 - Local", "ch02-00.md", &[2], vec![]), + ]; + let got = parser.parse_parts().unwrap(); + assert_eq!(got, should_be); + + // ---- Suffix Chapters ---- + + let should_be = vec![ + new_affix_item("Appendix A - Local", "appendix-01.md"), + new_affix_item("Appendix B - Local", "appendix-02.md"), + ]; + + let got = parser.parse_affix(false).unwrap(); + assert_eq!(got, should_be); + } +} -- cgit v1.2.3