mdbook_summary/
lib.rs

1//! Summary parser for mdBook.
2//!
3//! This is used to parse the
4//! [`SUMMARY.md`](https://rust-lang.github.io/mdBook/format/summary.html)
5//! file structure for [mdBook](https://rust-lang.github.io/mdBook/).
6
7use anyhow::{Context, Error, Result, bail};
8pub use mdbook_core::book::SectionNumber;
9use memchr::Memchr;
10use pulldown_cmark::{DefaultBrokenLinkCallback, Event, HeadingLevel, Tag, TagEnd};
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13use std::fmt::Display;
14use std::path::{Path, PathBuf};
15use tracing::{debug, trace, warn};
16
17/// Parse the text from a `SUMMARY.md` file into a sort of "recipe" to be
18/// used when loading a book from disk.
19///
20/// # Summary Format
21///
22/// **Title:** It's common practice to begin with a title, generally
23/// "# Summary". It's not mandatory and the parser (currently) ignores it, so
24/// you can too if you feel like it.
25///
26/// **Prefix Chapter:** Before the main numbered chapters you can add a couple
27/// of elements that will not be numbered. This is useful for forewords,
28/// introductions, etc. There are however some constraints. You can not nest
29/// prefix chapters, they should all be on the root level. And you can not add
30/// prefix chapters once you have added numbered chapters.
31///
32/// ```markdown
33/// [Title of prefix element](relative/path/to/markdown.md)
34/// ```
35///
36/// **Part Title:** An optional title for the next collect of numbered chapters. The numbered
37/// chapters can be broken into as many parts as desired.
38///
39/// **Numbered Chapter:** Numbered chapters are the main content of the book,
40/// they
41/// will be numbered and can be nested, resulting in a nice hierarchy (chapters,
42/// sub-chapters, etc.)
43///
44/// ```markdown
45/// # Title of Part
46///
47/// - [Title of the Chapter](relative/path/to/markdown.md)
48/// ```
49///
50/// You can either use - or * to indicate a numbered chapter, the parser doesn't
51/// care but you'll probably want to stay consistent.
52///
53/// **Suffix Chapter:** After the numbered chapters you can add a couple of
54/// non-numbered chapters. They are the same as prefix chapters but come after
55/// the numbered chapters instead of before.
56///
57/// All other elements are unsupported and will be ignored at best or result in
58/// an error.
59pub fn parse_summary(summary: &str) -> Result<Summary> {
60    let parser = SummaryParser::new(summary);
61    parser.parse()
62}
63
64/// The parsed `SUMMARY.md`, specifying how the book should be laid out.
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66#[non_exhaustive]
67pub struct Summary {
68    /// An optional title for the `SUMMARY.md`, currently just ignored.
69    pub title: Option<String>,
70    /// Chapters before the main text (e.g. an introduction).
71    pub prefix_chapters: Vec<SummaryItem>,
72    /// The main numbered chapters of the book, broken into one or more possibly named parts.
73    pub numbered_chapters: Vec<SummaryItem>,
74    /// Items which come after the main document (e.g. a conclusion).
75    pub suffix_chapters: Vec<SummaryItem>,
76}
77
78/// A struct representing an entry in the `SUMMARY.md`, possibly with nested
79/// entries.
80///
81/// This is roughly the equivalent of `[Some section](./path/to/file.md)`.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct Link {
85    /// The name of the chapter.
86    pub name: String,
87    /// The location of the chapter's source file, taking the book's `src`
88    /// directory as the root.
89    pub location: Option<PathBuf>,
90    /// The section number, if this chapter is in the numbered section.
91    pub number: Option<SectionNumber>,
92    /// Any nested items this chapter may contain.
93    pub nested_items: Vec<SummaryItem>,
94}
95
96impl Link {
97    /// Create a new link with no nested items.
98    pub fn new<S: Into<String>, P: AsRef<Path>>(name: S, location: P) -> Link {
99        Link {
100            name: name.into(),
101            location: Some(location.as_ref().to_path_buf()),
102            number: None,
103            nested_items: Vec::new(),
104        }
105    }
106}
107
108impl Default for Link {
109    fn default() -> Self {
110        Link {
111            name: String::new(),
112            location: Some(PathBuf::new()),
113            number: None,
114            nested_items: Vec::new(),
115        }
116    }
117}
118
119/// An item in `SUMMARY.md`.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[non_exhaustive]
122pub enum SummaryItem {
123    /// A link to a chapter.
124    Link(Link),
125    /// A separator (`---`).
126    Separator,
127    /// A part title.
128    PartTitle(String),
129}
130
131impl SummaryItem {
132    fn maybe_link_mut(&mut self) -> Option<&mut Link> {
133        match *self {
134            SummaryItem::Link(ref mut l) => Some(l),
135            _ => None,
136        }
137    }
138}
139
140impl From<Link> for SummaryItem {
141    fn from(other: Link) -> SummaryItem {
142        SummaryItem::Link(other)
143    }
144}
145
146/// A recursive descent (-ish) parser for a `SUMMARY.md`.
147///
148///
149/// # Grammar
150///
151/// The `SUMMARY.md` file has a grammar which looks something like this:
152///
153/// ```text
154/// summary           ::= title prefix_chapters numbered_chapters
155///                         suffix_chapters
156/// title             ::= "# " TEXT
157///                     | EPSILON
158/// prefix_chapters   ::= item*
159/// suffix_chapters   ::= item*
160/// numbered_chapters ::= part+
161/// part              ::= title dotted_item+
162/// dotted_item       ::= INDENT* DOT_POINT item
163/// item              ::= link
164///                     | separator
165/// separator         ::= "---"
166/// link              ::= "[" TEXT "]" "(" TEXT ")"
167/// DOT_POINT         ::= "-"
168///                     | "*"
169/// ```
170///
171/// > **Note:** the `TEXT` terminal is "normal" text, and should (roughly)
172/// > match the following regex: "[^<>\n[]]+".
173struct SummaryParser<'a> {
174    src: &'a str,
175    stream: pulldown_cmark::OffsetIter<'a, DefaultBrokenLinkCallback>,
176    offset: usize,
177
178    /// We can't actually put an event back into the `OffsetIter` stream, so instead we store it
179    /// here until somebody calls `next_event` again.
180    back: Option<Event<'a>>,
181}
182
183/// Reads `Events` from the provided stream until the corresponding
184/// `Event::End` is encountered which matches the `$delimiter` pattern.
185///
186/// This is the equivalent of doing
187/// `$stream.take_while(|e| e != $delimiter).collect()` but it allows you to
188/// use pattern matching and you won't get errors because `take_while()`
189/// moves `$stream` out of self.
190macro_rules! collect_events {
191    ($stream:expr,start $delimiter:pat) => {
192        collect_events!($stream, Event::Start($delimiter))
193    };
194    ($stream:expr,end $delimiter:pat) => {
195        collect_events!($stream, Event::End($delimiter))
196    };
197    ($stream:expr, $delimiter:pat) => {{
198        let mut events = Vec::new();
199
200        loop {
201            let event = $stream.next().map(|(ev, _range)| ev);
202            trace!("Next event: {:?}", event);
203
204            match event {
205                Some($delimiter) => break,
206                Some(other) => events.push(other),
207                None => {
208                    debug!(
209                        "Reached end of stream without finding the closing pattern, {}",
210                        stringify!($delimiter)
211                    );
212                    break;
213                }
214            }
215        }
216
217        events
218    }};
219}
220
221impl<'a> SummaryParser<'a> {
222    fn new(text: &'a str) -> SummaryParser<'a> {
223        let pulldown_parser = pulldown_cmark::Parser::new(text).into_offset_iter();
224
225        SummaryParser {
226            src: text,
227            stream: pulldown_parser,
228            offset: 0,
229            back: None,
230        }
231    }
232
233    /// Get the current line and column to give the user more useful error
234    /// messages.
235    fn current_location(&self) -> (usize, usize) {
236        let previous_text = self.src[..self.offset].as_bytes();
237        let line = Memchr::new(b'\n', previous_text).count() + 1;
238        let start_of_line = memchr::memrchr(b'\n', previous_text).unwrap_or(0);
239        let col = self.src[start_of_line..self.offset].chars().count();
240
241        (line, col)
242    }
243
244    /// Parse the text the `SummaryParser` was created with.
245    fn parse(mut self) -> Result<Summary> {
246        let title = self.parse_title();
247
248        let prefix_chapters = self
249            .parse_affix(true)
250            .with_context(|| "There was an error parsing the prefix chapters")?;
251        let numbered_chapters = self
252            .parse_parts()
253            .with_context(|| "There was an error parsing the numbered chapters")?;
254        let suffix_chapters = self
255            .parse_affix(false)
256            .with_context(|| "There was an error parsing the suffix chapters")?;
257
258        let mut files = HashSet::new();
259        for part in [&prefix_chapters, &numbered_chapters, &suffix_chapters] {
260            Self::check_for_duplicates(&part, &mut files)?;
261        }
262
263        Ok(Summary {
264            title,
265            prefix_chapters,
266            numbered_chapters,
267            suffix_chapters,
268        })
269    }
270
271    /// Recursively check for duplicate files in the summary items.
272    fn check_for_duplicates<'b>(
273        items: &'b [SummaryItem],
274        files: &mut HashSet<&'b PathBuf>,
275    ) -> Result<()> {
276        for item in items {
277            if let SummaryItem::Link(link) = item {
278                if let Some(location) = &link.location {
279                    if !files.insert(location) {
280                        bail!(anyhow::anyhow!(
281                            "Duplicate file in SUMMARY.md: {:?}",
282                            location
283                        ));
284                    }
285                }
286                // Recursively check nested items
287                Self::check_for_duplicates(&link.nested_items, files)?;
288            }
289        }
290        Ok(())
291    }
292
293    /// Parse the affix chapters.
294    fn parse_affix(&mut self, is_prefix: bool) -> Result<Vec<SummaryItem>> {
295        let mut items = Vec::new();
296        debug!(
297            "Parsing {} items",
298            if is_prefix { "prefix" } else { "suffix" }
299        );
300
301        loop {
302            match self.next_event() {
303                Some(ev @ Event::Start(Tag::List(..)))
304                | Some(
305                    ev @ Event::Start(Tag::Heading {
306                        level: HeadingLevel::H1,
307                        ..
308                    }),
309                ) => {
310                    if is_prefix {
311                        // we've finished prefix chapters and are at the start
312                        // of the numbered section.
313                        self.back(ev);
314                        break;
315                    } else {
316                        bail!(self.parse_error("Suffix chapters cannot be followed by a list"));
317                    }
318                }
319                Some(Event::Start(Tag::Link { dest_url, .. })) => {
320                    let link = self.parse_link(dest_url.to_string());
321                    items.push(SummaryItem::Link(link));
322                }
323                Some(Event::Rule) => items.push(SummaryItem::Separator),
324                Some(_) => {}
325                None => break,
326            }
327        }
328
329        Ok(items)
330    }
331
332    fn parse_parts(&mut self) -> Result<Vec<SummaryItem>> {
333        let mut parts = vec![];
334
335        // We want the section numbers to be continues through all parts.
336        let mut root_number = SectionNumber::default();
337        let mut root_items = 0;
338
339        loop {
340            // Possibly match a title or the end of the "numbered chapters part".
341            let title = match self.next_event() {
342                Some(ev @ Event::Start(Tag::Paragraph)) => {
343                    // we're starting the suffix chapters
344                    self.back(ev);
345                    break;
346                }
347
348                Some(Event::Start(Tag::Heading {
349                    level: HeadingLevel::H1,
350                    ..
351                })) => {
352                    debug!("Found a h1 in the SUMMARY");
353
354                    let tags = collect_events!(self.stream, end TagEnd::Heading(HeadingLevel::H1));
355                    Some(stringify_events(tags))
356                }
357
358                Some(ev) => {
359                    self.back(ev);
360                    None
361                }
362
363                None => break, // EOF, bail...
364            };
365
366            // Parse the rest of the part.
367            let numbered_chapters = self
368                .parse_numbered(&mut root_items, &mut root_number)
369                .with_context(|| "There was an error parsing the numbered chapters")?;
370
371            if let Some(title) = title {
372                parts.push(SummaryItem::PartTitle(title));
373            }
374            parts.extend(numbered_chapters);
375        }
376
377        Ok(parts)
378    }
379
380    /// Finishes parsing a link once the `Event::Start(Tag::Link(..))` has been opened.
381    fn parse_link(&mut self, href: String) -> Link {
382        let href = href.replace("%20", " ");
383        let link_content = collect_events!(self.stream, end TagEnd::Link);
384        let name = stringify_events(link_content);
385
386        let path = if href.is_empty() {
387            None
388        } else {
389            Some(PathBuf::from(href))
390        };
391
392        Link {
393            name,
394            location: path,
395            number: None,
396            nested_items: Vec::new(),
397        }
398    }
399
400    /// Parse the numbered chapters.
401    fn parse_numbered(
402        &mut self,
403        root_items: &mut u32,
404        root_number: &mut SectionNumber,
405    ) -> Result<Vec<SummaryItem>> {
406        let mut items = Vec::new();
407
408        // For the first iteration, we want to just skip any opening paragraph tags, as that just
409        // marks the start of the list. But after that, another opening paragraph indicates that we
410        // have started a new part or the suffix chapters.
411        let mut first = true;
412
413        loop {
414            match self.next_event() {
415                Some(ev @ Event::Start(Tag::Paragraph)) => {
416                    if !first {
417                        // we're starting the suffix chapters
418                        self.back(ev);
419                        break;
420                    }
421                }
422                // The expectation is that pulldown cmark will terminate a paragraph before a new
423                // heading, so we can always count on this to return without skipping headings.
424                Some(
425                    ev @ Event::Start(Tag::Heading {
426                        level: HeadingLevel::H1,
427                        ..
428                    }),
429                ) => {
430                    // we're starting a new part
431                    self.back(ev);
432                    break;
433                }
434                Some(ev @ Event::Start(Tag::List(..))) => {
435                    self.back(ev);
436                    let mut bunch_of_items = self.parse_nested_numbered(root_number)?;
437
438                    // if we've resumed after something like a rule the root sections
439                    // will be numbered from 1. We need to manually go back and update
440                    // them
441                    update_section_numbers(&mut bunch_of_items, 0, *root_items);
442                    *root_items += bunch_of_items.len() as u32;
443                    items.extend(bunch_of_items);
444                }
445                Some(Event::Start(other_tag)) => {
446                    trace!("Skipping contents of {:?}", other_tag);
447
448                    // Skip over the contents of this tag
449                    while let Some(event) = self.next_event() {
450                        if event == Event::End(other_tag.clone().into()) {
451                            break;
452                        }
453                    }
454                }
455                Some(Event::Rule) => {
456                    items.push(SummaryItem::Separator);
457                }
458
459                // something else... ignore
460                Some(_) => {}
461
462                // EOF, bail...
463                None => {
464                    break;
465                }
466            }
467
468            // From now on, we cannot accept any new paragraph opening tags.
469            first = false;
470        }
471
472        Ok(items)
473    }
474
475    /// Push an event back to the tail of the stream.
476    fn back(&mut self, ev: Event<'a>) {
477        assert!(self.back.is_none());
478        trace!("Back: {:?}", ev);
479        self.back = Some(ev);
480    }
481
482    fn next_event(&mut self) -> Option<Event<'a>> {
483        let next = self.back.take().or_else(|| {
484            self.stream.next().map(|(ev, range)| {
485                self.offset = range.start;
486                ev
487            })
488        });
489
490        trace!("Next event: {:?}", next);
491
492        next
493    }
494
495    fn parse_nested_numbered(&mut self, parent: &SectionNumber) -> Result<Vec<SummaryItem>> {
496        debug!("Parsing numbered chapters at level {}", parent);
497        let mut items = Vec::new();
498
499        loop {
500            match self.next_event() {
501                Some(Event::Start(Tag::Item)) => {
502                    let item = self.parse_nested_item(parent, items.len())?;
503                    items.push(item);
504                }
505                Some(Event::Start(Tag::List(..))) => {
506                    // Skip this tag after comment because it is not nested.
507                    if items.is_empty() {
508                        continue;
509                    }
510                    // recurse to parse the nested list
511                    let (_, last_item) = get_last_link(&mut items)?;
512                    let last_item_number = last_item
513                        .number
514                        .as_ref()
515                        .expect("All numbered chapters have numbers");
516
517                    let sub_items = self.parse_nested_numbered(last_item_number)?;
518
519                    last_item.nested_items = sub_items;
520                }
521                Some(Event::End(TagEnd::List(..))) => break,
522                Some(_) => {}
523                None => break,
524            }
525        }
526
527        Ok(items)
528    }
529
530    fn parse_nested_item(
531        &mut self,
532        parent: &SectionNumber,
533        num_existing_items: usize,
534    ) -> Result<SummaryItem> {
535        loop {
536            match self.next_event() {
537                Some(Event::Start(Tag::Paragraph)) => continue,
538                Some(Event::Start(Tag::Link { dest_url, .. })) => {
539                    let mut link = self.parse_link(dest_url.to_string());
540
541                    let mut number = parent.clone();
542                    number.push(num_existing_items as u32 + 1);
543                    trace!(
544                        "Found chapter: {} {} ({})",
545                        number,
546                        link.name,
547                        link.location
548                            .as_ref()
549                            .map(|p| p.to_str().unwrap_or(""))
550                            .unwrap_or("[draft]")
551                    );
552
553                    link.number = Some(number);
554
555                    return Ok(SummaryItem::Link(link));
556                }
557                other => {
558                    warn!("Expected a start of a link, actually got {:?}", other);
559                    bail!(self.parse_error(
560                        "The link items for nested chapters must only contain a hyperlink"
561                    ));
562                }
563            }
564        }
565    }
566
567    fn parse_error<D: Display>(&self, msg: D) -> Error {
568        let (line, col) = self.current_location();
569        anyhow::anyhow!(
570            "failed to parse SUMMARY.md line {}, column {}: {}",
571            line,
572            col,
573            msg
574        )
575    }
576
577    /// Try to parse the title line.
578    fn parse_title(&mut self) -> Option<String> {
579        loop {
580            match self.next_event() {
581                Some(Event::Start(Tag::Heading {
582                    level: HeadingLevel::H1,
583                    ..
584                })) => {
585                    debug!("Found a h1 in the SUMMARY");
586
587                    let tags = collect_events!(self.stream, end TagEnd::Heading(HeadingLevel::H1));
588                    return Some(stringify_events(tags));
589                }
590                // Skip a HTML element such as a comment line.
591                Some(Event::Html(_) | Event::InlineHtml(_))
592                | Some(Event::Start(Tag::HtmlBlock) | Event::End(TagEnd::HtmlBlock)) => {}
593                // Otherwise, no title.
594                Some(ev) => {
595                    self.back(ev);
596                    return None;
597                }
598                _ => return None,
599            }
600        }
601    }
602}
603
604fn update_section_numbers(items: &mut [SummaryItem], level: usize, by: u32) {
605    for item in items {
606        if let SummaryItem::Link(ref mut link) = *item {
607            if let Some(ref mut number) = link.number {
608                number[level] += by;
609            }
610
611            update_section_numbers(&mut link.nested_items, level, by);
612        }
613    }
614}
615
616/// Gets a pointer to the last `Link` in a list of `SummaryItem`s, and its
617/// index.
618fn get_last_link(links: &mut [SummaryItem]) -> Result<(usize, &mut Link)> {
619    links
620        .iter_mut()
621        .enumerate()
622        .filter_map(|(i, item)| item.maybe_link_mut().map(|l| (i, l)))
623        .next_back()
624        .ok_or_else(|| {
625            anyhow::anyhow!(
626                "Unable to get last link because the list of SummaryItems \
627                 doesn't contain any Links"
628            )
629        })
630}
631
632/// Removes the styling from a list of Markdown events and returns just the
633/// plain text.
634fn stringify_events(events: Vec<Event<'_>>) -> String {
635    events
636        .into_iter()
637        .filter_map(|t| match t {
638            Event::Text(text) | Event::Code(text) => Some(text.into_string()),
639            Event::SoftBreak => Some(String::from(" ")),
640            _ => None,
641        })
642        .collect()
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn parse_initial_title() {
651        let src = "# Summary";
652        let should_be = String::from("Summary");
653
654        let mut parser = SummaryParser::new(src);
655        let got = parser.parse_title().unwrap();
656
657        assert_eq!(got, should_be);
658    }
659
660    #[test]
661    fn no_initial_title() {
662        let src = "[Link]()";
663        let mut parser = SummaryParser::new(src);
664
665        assert!(parser.parse_title().is_none());
666        assert!(matches!(
667            parser.next_event(),
668            Some(Event::Start(Tag::Paragraph))
669        ));
670    }
671
672    #[test]
673    fn parse_title_with_styling() {
674        let src = "# My **Awesome** Summary";
675        let should_be = String::from("My Awesome Summary");
676
677        let mut parser = SummaryParser::new(src);
678        let got = parser.parse_title().unwrap();
679
680        assert_eq!(got, should_be);
681    }
682
683    #[test]
684    fn convert_markdown_events_to_a_string() {
685        let src = "Hello *World*, `this` is some text [and a link](./path/to/link)";
686        let should_be = "Hello World, this is some text and a link";
687
688        let events = pulldown_cmark::Parser::new(src).collect();
689        let got = stringify_events(events);
690
691        assert_eq!(got, should_be);
692    }
693
694    #[test]
695    fn parse_some_prefix_items() {
696        let src = "[First](./first.md)\n[Second](./second.md)\n";
697        let mut parser = SummaryParser::new(src);
698
699        let should_be = vec![
700            SummaryItem::Link(Link {
701                name: String::from("First"),
702                location: Some(PathBuf::from("./first.md")),
703                ..Default::default()
704            }),
705            SummaryItem::Link(Link {
706                name: String::from("Second"),
707                location: Some(PathBuf::from("./second.md")),
708                ..Default::default()
709            }),
710        ];
711
712        let got = parser.parse_affix(true).unwrap();
713
714        assert_eq!(got, should_be);
715    }
716
717    #[test]
718    fn parse_prefix_items_with_a_separator() {
719        let src = "[First](./first.md)\n\n---\n\n[Second](./second.md)\n";
720        let mut parser = SummaryParser::new(src);
721
722        let got = parser.parse_affix(true).unwrap();
723
724        assert_eq!(got.len(), 3);
725        assert_eq!(got[1], SummaryItem::Separator);
726    }
727
728    #[test]
729    fn suffix_items_cannot_be_followed_by_a_list() {
730        let src = "[First](./first.md)\n- [Second](./second.md)\n";
731        let mut parser = SummaryParser::new(src);
732
733        let got = parser.parse_affix(false);
734
735        assert!(got.is_err());
736        let error_message = got.err().unwrap().to_string();
737        assert_eq!(
738            error_message,
739            "failed to parse SUMMARY.md line 2, column 1: Suffix chapters cannot be followed by a list"
740        );
741    }
742
743    #[test]
744    fn expected_a_start_of_a_link() {
745        let src = "- Title\n";
746        let mut parser = SummaryParser::new(src);
747
748        let got = parser.parse_affix(false);
749
750        assert!(got.is_err());
751        let error_message = got.err().unwrap().to_string();
752        assert_eq!(
753            error_message,
754            "failed to parse SUMMARY.md line 1, column 0: Suffix chapters cannot be followed by a list"
755        );
756    }
757
758    #[test]
759    fn parse_a_link() {
760        let src = "[First](./first.md)";
761        let should_be = Link {
762            name: String::from("First"),
763            location: Some(PathBuf::from("./first.md")),
764            ..Default::default()
765        };
766
767        let mut parser = SummaryParser::new(src);
768        let _ = parser.stream.next(); // Discard opening paragraph
769
770        let href = match parser.stream.next() {
771            Some((Event::Start(Tag::Link { dest_url, .. }), _range)) => dest_url.to_string(),
772            other => panic!("Unreachable, {other:?}"),
773        };
774
775        let got = parser.parse_link(href);
776        assert_eq!(got, should_be);
777    }
778
779    #[test]
780    fn parse_a_numbered_chapter() {
781        let src = "- [First](./first.md)\n";
782        let link = Link {
783            name: String::from("First"),
784            location: Some(PathBuf::from("./first.md")),
785            number: Some(SectionNumber::new([1])),
786            ..Default::default()
787        };
788        let should_be = vec![SummaryItem::Link(link)];
789
790        let mut parser = SummaryParser::new(src);
791        let got = parser
792            .parse_numbered(&mut 0, &mut SectionNumber::default())
793            .unwrap();
794
795        assert_eq!(got, should_be);
796    }
797
798    #[test]
799    fn parse_nested_numbered_chapters() {
800        let src = "- [First](./first.md)\n  - [Nested](./nested.md)\n- [Second](./second.md)";
801
802        let should_be = vec![
803            SummaryItem::Link(Link {
804                name: String::from("First"),
805                location: Some(PathBuf::from("./first.md")),
806                number: Some(SectionNumber::new([1])),
807                nested_items: vec![SummaryItem::Link(Link {
808                    name: String::from("Nested"),
809                    location: Some(PathBuf::from("./nested.md")),
810                    number: Some(SectionNumber::new([1, 1])),
811                    nested_items: Vec::new(),
812                })],
813            }),
814            SummaryItem::Link(Link {
815                name: String::from("Second"),
816                location: Some(PathBuf::from("./second.md")),
817                number: Some(SectionNumber::new([2])),
818                nested_items: Vec::new(),
819            }),
820        ];
821
822        let mut parser = SummaryParser::new(src);
823        let got = parser
824            .parse_numbered(&mut 0, &mut SectionNumber::default())
825            .unwrap();
826
827        assert_eq!(got, should_be);
828    }
829
830    #[test]
831    fn parse_numbered_chapters_separated_by_comment() {
832        let src = "- [First](./first.md)\n<!-- this is a comment -->\n- [Second](./second.md)";
833
834        let should_be = vec![
835            SummaryItem::Link(Link {
836                name: String::from("First"),
837                location: Some(PathBuf::from("./first.md")),
838                number: Some(SectionNumber::new([1])),
839                nested_items: Vec::new(),
840            }),
841            SummaryItem::Link(Link {
842                name: String::from("Second"),
843                location: Some(PathBuf::from("./second.md")),
844                number: Some(SectionNumber::new([2])),
845                nested_items: Vec::new(),
846            }),
847        ];
848
849        let mut parser = SummaryParser::new(src);
850        let got = parser
851            .parse_numbered(&mut 0, &mut SectionNumber::default())
852            .unwrap();
853
854        assert_eq!(got, should_be);
855    }
856
857    #[test]
858    fn parse_titled_parts() {
859        let src = "- [First](./first.md)\n- [Second](./second.md)\n\
860                   # Title 2\n- [Third](./third.md)\n\t- [Fourth](./fourth.md)";
861
862        let should_be = vec![
863            SummaryItem::Link(Link {
864                name: String::from("First"),
865                location: Some(PathBuf::from("./first.md")),
866                number: Some(SectionNumber::new([1])),
867                nested_items: Vec::new(),
868            }),
869            SummaryItem::Link(Link {
870                name: String::from("Second"),
871                location: Some(PathBuf::from("./second.md")),
872                number: Some(SectionNumber::new([2])),
873                nested_items: Vec::new(),
874            }),
875            SummaryItem::PartTitle(String::from("Title 2")),
876            SummaryItem::Link(Link {
877                name: String::from("Third"),
878                location: Some(PathBuf::from("./third.md")),
879                number: Some(SectionNumber::new([3])),
880                nested_items: vec![SummaryItem::Link(Link {
881                    name: String::from("Fourth"),
882                    location: Some(PathBuf::from("./fourth.md")),
883                    number: Some(SectionNumber::new([3, 1])),
884                    nested_items: Vec::new(),
885                })],
886            }),
887        ];
888
889        let mut parser = SummaryParser::new(src);
890        let got = parser.parse_parts().unwrap();
891
892        assert_eq!(got, should_be);
893    }
894
895    /// This test ensures the book will continue to pass because it breaks the
896    /// `SUMMARY.md` up using level 2 headers ([example]).
897    ///
898    /// [example]: https://github.com/rust-lang/book/blob/2c942dc094f4ddcdc7aba7564f80782801197c99/second-edition/src/SUMMARY.md#basic-rust-literacy
899    #[test]
900    fn can_have_a_subheader_between_nested_items() {
901        let src = "- [First](./first.md)\n\n## Subheading\n\n- [Second](./second.md)\n";
902        let should_be = vec![
903            SummaryItem::Link(Link {
904                name: String::from("First"),
905                location: Some(PathBuf::from("./first.md")),
906                number: Some(SectionNumber::new([1])),
907                nested_items: Vec::new(),
908            }),
909            SummaryItem::Link(Link {
910                name: String::from("Second"),
911                location: Some(PathBuf::from("./second.md")),
912                number: Some(SectionNumber::new([2])),
913                nested_items: Vec::new(),
914            }),
915        ];
916
917        let mut parser = SummaryParser::new(src);
918        let got = parser
919            .parse_numbered(&mut 0, &mut SectionNumber::default())
920            .unwrap();
921
922        assert_eq!(got, should_be);
923    }
924
925    #[test]
926    fn an_empty_link_location_is_a_draft_chapter() {
927        let src = "- [Empty]()\n";
928        let mut parser = SummaryParser::new(src);
929
930        let got = parser.parse_numbered(&mut 0, &mut SectionNumber::default());
931        let should_be = vec![SummaryItem::Link(Link {
932            name: String::from("Empty"),
933            location: None,
934            number: Some(SectionNumber::new([1])),
935            nested_items: Vec::new(),
936        })];
937
938        assert!(got.is_ok());
939        assert_eq!(got.unwrap(), should_be);
940    }
941
942    /// Regression test for https://github.com/rust-lang/mdBook/issues/779
943    /// Ensure section numbers are correctly incremented after a horizontal separator.
944    #[test]
945    fn keep_numbering_after_separator() {
946        let src =
947            "- [First](./first.md)\n---\n- [Second](./second.md)\n---\n- [Third](./third.md)\n";
948        let should_be = vec![
949            SummaryItem::Link(Link {
950                name: String::from("First"),
951                location: Some(PathBuf::from("./first.md")),
952                number: Some(SectionNumber::new([1])),
953                nested_items: Vec::new(),
954            }),
955            SummaryItem::Separator,
956            SummaryItem::Link(Link {
957                name: String::from("Second"),
958                location: Some(PathBuf::from("./second.md")),
959                number: Some(SectionNumber::new([2])),
960                nested_items: Vec::new(),
961            }),
962            SummaryItem::Separator,
963            SummaryItem::Link(Link {
964                name: String::from("Third"),
965                location: Some(PathBuf::from("./third.md")),
966                number: Some(SectionNumber::new([3])),
967                nested_items: Vec::new(),
968            }),
969        ];
970
971        let mut parser = SummaryParser::new(src);
972        let got = parser
973            .parse_numbered(&mut 0, &mut SectionNumber::default())
974            .unwrap();
975
976        assert_eq!(got, should_be);
977    }
978
979    /// Regression test for https://github.com/rust-lang/mdBook/issues/1218
980    /// Ensure chapter names spread across multiple lines have spaces between all the words.
981    #[test]
982    fn add_space_for_multi_line_chapter_names() {
983        let src = "- [Chapter\ntitle](./chapter.md)";
984        let should_be = vec![SummaryItem::Link(Link {
985            name: String::from("Chapter title"),
986            location: Some(PathBuf::from("./chapter.md")),
987            number: Some(SectionNumber::new([1])),
988            nested_items: Vec::new(),
989        })];
990
991        let mut parser = SummaryParser::new(src);
992        let got = parser
993            .parse_numbered(&mut 0, &mut SectionNumber::default())
994            .unwrap();
995
996        assert_eq!(got, should_be);
997    }
998
999    #[test]
1000    fn allow_space_in_link_destination() {
1001        let src = "- [test1](./test%20link1.md)\n- [test2](<./test link2.md>)";
1002        let should_be = vec![
1003            SummaryItem::Link(Link {
1004                name: String::from("test1"),
1005                location: Some(PathBuf::from("./test link1.md")),
1006                number: Some(SectionNumber::new([1])),
1007                nested_items: Vec::new(),
1008            }),
1009            SummaryItem::Link(Link {
1010                name: String::from("test2"),
1011                location: Some(PathBuf::from("./test link2.md")),
1012                number: Some(SectionNumber::new([2])),
1013                nested_items: Vec::new(),
1014            }),
1015        ];
1016        let mut parser = SummaryParser::new(src);
1017        let got = parser
1018            .parse_numbered(&mut 0, &mut SectionNumber::default())
1019            .unwrap();
1020
1021        assert_eq!(got, should_be);
1022    }
1023
1024    #[test]
1025    fn skip_html_comments() {
1026        let src = r#"<!--
1027# Title - En
1028-->
1029# Title - Local
1030
1031<!--
1032[Prefix 00-01 - En](ch00-01.md)
1033[Prefix 00-02 - En](ch00-02.md)
1034-->
1035[Prefix 00-01 - Local](ch00-01.md)
1036[Prefix 00-02 - Local](ch00-02.md)
1037
1038<!--
1039## Section Title - En
1040-->
1041## Section Title - Localized
1042
1043<!--
1044- [Ch 01-00 - En](ch01-00.md)
1045    - [Ch 01-01 - En](ch01-01.md)
1046    - [Ch 01-02 - En](ch01-02.md)
1047-->
1048- [Ch 01-00 - Local](ch01-00.md)
1049    - [Ch 01-01 - Local](ch01-01.md)
1050    - [Ch 01-02 - Local](ch01-02.md)
1051
1052<!--
1053- [Ch 02-00 - En](ch02-00.md)
1054-->
1055- [Ch 02-00 - Local](ch02-00.md)
1056
1057<!--
1058[Appendix A - En](appendix-01.md)
1059[Appendix B - En](appendix-02.md)
1060-->`
1061[Appendix A - Local](appendix-01.md)
1062[Appendix B - Local](appendix-02.md)
1063"#;
1064
1065        let mut parser = SummaryParser::new(src);
1066
1067        // ---- Title ----
1068        let title = parser.parse_title();
1069        assert_eq!(title, Some(String::from("Title - Local")));
1070
1071        // ---- Prefix Chapters ----
1072
1073        let new_affix_item = |name, location| {
1074            SummaryItem::Link(Link {
1075                name: String::from(name),
1076                location: Some(PathBuf::from(location)),
1077                ..Default::default()
1078            })
1079        };
1080
1081        let should_be = vec![
1082            new_affix_item("Prefix 00-01 - Local", "ch00-01.md"),
1083            new_affix_item("Prefix 00-02 - Local", "ch00-02.md"),
1084        ];
1085
1086        let got = parser.parse_affix(true).unwrap();
1087        assert_eq!(got, should_be);
1088
1089        // ---- Numbered Chapters ----
1090
1091        let new_numbered_item = |name, location, numbers: &[u32], nested_items| {
1092            SummaryItem::Link(Link {
1093                name: String::from(name),
1094                location: Some(PathBuf::from(location)),
1095                number: Some(SectionNumber::new(numbers)),
1096                nested_items,
1097            })
1098        };
1099
1100        let ch01_nested = vec![
1101            new_numbered_item("Ch 01-01 - Local", "ch01-01.md", &[1, 1], vec![]),
1102            new_numbered_item("Ch 01-02 - Local", "ch01-02.md", &[1, 2], vec![]),
1103        ];
1104
1105        let should_be = vec![
1106            new_numbered_item("Ch 01-00 - Local", "ch01-00.md", &[1], ch01_nested),
1107            new_numbered_item("Ch 02-00 - Local", "ch02-00.md", &[2], vec![]),
1108        ];
1109        let got = parser.parse_parts().unwrap();
1110        assert_eq!(got, should_be);
1111
1112        // ---- Suffix Chapters ----
1113
1114        let should_be = vec![
1115            new_affix_item("Appendix A - Local", "appendix-01.md"),
1116            new_affix_item("Appendix B - Local", "appendix-02.md"),
1117        ];
1118
1119        let got = parser.parse_affix(false).unwrap();
1120        assert_eq!(got, should_be);
1121    }
1122
1123    #[test]
1124    fn duplicate_entries_1() {
1125        let src = r#"
1126# Summary
1127- [A](./a.md)
1128- [A](./a.md)
1129"#;
1130
1131        let res = parse_summary(src);
1132        assert!(res.is_err());
1133        let error_message = res.err().unwrap().to_string();
1134        assert_eq!(error_message, r#"Duplicate file in SUMMARY.md: "./a.md""#);
1135    }
1136
1137    #[test]
1138    fn duplicate_entries_2() {
1139        let src = r#"
1140# Summary
1141- [A](./a.md)
1142  - [A](./a.md)
1143"#;
1144
1145        let res = parse_summary(src);
1146        assert!(res.is_err());
1147        let error_message = res.err().unwrap().to_string();
1148        assert_eq!(error_message, r#"Duplicate file in SUMMARY.md: "./a.md""#);
1149    }
1150    #[test]
1151    fn duplicate_entries_3() {
1152        let src = r#"
1153# Summary
1154- [A](./a.md)
1155- [B](./b.md)
1156  - [A](./a.md)
1157"#;
1158
1159        let res = parse_summary(src);
1160        assert!(res.is_err());
1161        let error_message = res.err().unwrap().to_string();
1162        assert_eq!(error_message, r#"Duplicate file in SUMMARY.md: "./a.md""#);
1163    }
1164
1165    #[test]
1166    fn duplicate_entries_4() {
1167        let src = r#"
1168# Summary
1169[A](./a.md)
1170- [B](./b.md)
1171- [A](./a.md)
1172"#;
1173
1174        let res = parse_summary(src);
1175        assert!(res.is_err());
1176        let error_message = res.err().unwrap().to_string();
1177        assert_eq!(error_message, r#"Duplicate file in SUMMARY.md: "./a.md""#);
1178    }
1179
1180    #[test]
1181    fn duplicate_entries_5() {
1182        let src = r#"
1183# Summary
1184[A](./a.md)
1185
1186# hi
1187- [B](./b.md)
1188
1189# bye
1190
1191---
1192
1193[A](./a.md)
1194"#;
1195
1196        let res = parse_summary(src);
1197        assert!(res.is_err());
1198        let error_message = res.err().unwrap().to_string();
1199        assert_eq!(error_message, r#"Duplicate file in SUMMARY.md: "./a.md""#);
1200    }
1201}