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