pulldown_cmark_toc/
lib.rs

1//! Generate a table of contents from a Markdown document.
2//!
3//! By default the heading anchor calculation (aka the "slugification")
4//! is done in a way that attempts to mimic GitHub's (undocumented) behavior.
5//! (Though you can customize this with your own [`slug::Slugify`] implementation).
6//!
7//! # Examples
8//!
9//! ```
10//! use pulldown_cmark_toc::TableOfContents;
11//!
12//! let text = "# Heading\n\n## Subheading\n\n## Subheading with `code`\n";
13//!
14//! let toc = TableOfContents::new(text);
15//! assert_eq!(
16//!     toc.to_cmark(),
17//!     r#"- [Heading](#heading)
18//!   - [Subheading](#subheading)
19//!   - [Subheading with `code`](#subheading-with-code)
20//! "#
21//! );
22//! ```
23
24mod render;
25mod slug;
26
27use std::borrow::Borrow;
28use std::fmt::Write;
29use std::slice::Iter;
30
31pub use pulldown_cmark::HeadingLevel;
32use pulldown_cmark::{Event, Options as CmarkOptions, Parser, Tag, TagEnd};
33
34pub use render::{ItemSymbol, Options};
35pub use slug::{GitHubSlugifier, Slugify};
36
37/////////////////////////////////////////////////////////////////////////
38// Definitions
39/////////////////////////////////////////////////////////////////////////
40
41/// Represents a heading.
42#[derive(Debug, Clone)]
43pub struct Heading<'a> {
44    /// The Markdown events between the heading tags.
45    events: Vec<Event<'a>>,
46    /// The heading level.
47    level: HeadingLevel,
48}
49
50/// Represents a Table of Contents.
51#[derive(Debug)]
52pub struct TableOfContents<'a> {
53    headings: Vec<Heading<'a>>,
54}
55
56/////////////////////////////////////////////////////////////////////////
57// Implementations
58/////////////////////////////////////////////////////////////////////////
59
60impl Heading<'_> {
61    /// The raw events contained between the heading tags.
62    pub fn events(&self) -> Iter<Event> {
63        self.events.iter()
64    }
65
66    /// The heading level.
67    pub fn level(&self) -> HeadingLevel {
68        self.level
69    }
70
71    /// The heading text with all Markdown code stripped out.
72    ///
73    /// The output of this this function can be used to generate an anchor.
74    pub fn text(&self) -> String {
75        let mut buf = String::new();
76        for event in self.events() {
77            if let Event::Text(s) | Event::Code(s) = event {
78                buf.push_str(s);
79            }
80        }
81        buf
82    }
83}
84
85impl<'a> TableOfContents<'a> {
86    /// Construct a new table of contents from Markdown text.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// # use pulldown_cmark_toc::TableOfContents;
92    /// let toc = TableOfContents::new("# Heading\n");
93    /// ```
94    pub fn new(text: &'a str) -> Self {
95        // We are not enabling all options since we want to mimic
96        // GitHub's behavior as closely as possible by default.
97        // And e.g. enabling heading attributes could result in wrong anchors
98        // or enabling smart punctuation would result in inconsistent rendering.
99        let mut options = CmarkOptions::empty();
100        options.insert(CmarkOptions::ENABLE_STRIKETHROUGH);
101        options.insert(CmarkOptions::ENABLE_FOOTNOTES);
102        // Not enabling tables and tasklists since they cannot have any
103        // effect on headings (which are the only events we care about).
104        let events = Parser::new_ext(text, options);
105        Self::new_with_events(events)
106    }
107
108    /// Construct a new table of contents from parsed Markdown events.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// # use pulldown_cmark_toc::TableOfContents;
114    /// use pulldown_cmark::Parser;
115    ///
116    /// let parser = Parser::new("# Heading\n");
117    /// let toc = TableOfContents::new_with_events(parser);;
118    /// ```
119    pub fn new_with_events<I, E>(events: I) -> Self
120    where
121        I: Iterator<Item = E>,
122        E: Borrow<Event<'a>>,
123    {
124        let mut headings = Vec::new();
125        let mut current: Option<Heading> = None;
126
127        for event in events {
128            let event = event.borrow();
129            match event {
130                Event::Start(Tag::Heading { level, .. }) => {
131                    current = Some(Heading {
132                        events: Vec::new(),
133                        level: *level,
134                    });
135                }
136                Event::End(TagEnd::Heading(level)) => {
137                    let heading = current.take().unwrap();
138                    assert_eq!(heading.level, *level);
139                    headings.push(heading);
140                }
141                event => {
142                    if let Some(heading) = current.as_mut() {
143                        heading.events.push(event.clone());
144                    }
145                }
146            }
147        }
148        Self { headings }
149    }
150
151    /// Iterate over the headings in this table of contents.
152    ///
153    /// # Examples
154    ///
155    /// Simple iteration over each heading.
156    /// ```
157    /// # use pulldown_cmark_toc::TableOfContents;
158    /// let toc = TableOfContents::new("# Heading\n");
159    ///
160    /// for heading in toc.headings() {
161    ///     // use heading
162    /// }
163    /// ```
164    ///
165    /// Filtering out certain heading levels.
166    /// ```
167    /// # use pulldown_cmark_toc::{HeadingLevel, TableOfContents};
168    /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
169    ///
170    /// for heading in toc.headings().filter(|h| h.level() >= HeadingLevel::H2) {
171    ///     // use heading
172    /// }
173    /// ```
174    pub fn headings(&self) -> Iter<Heading> {
175        self.headings.iter()
176    }
177
178    /// Render the table of contents as Markdown.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// # use pulldown_cmark_toc::TableOfContents;
184    /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
185    /// assert_eq!(
186    ///     toc.to_cmark(),
187    ///     "- [Heading](#heading)\n  - [Subheading](#subheading)\n"
188    /// );
189    /// ```
190    #[must_use]
191    pub fn to_cmark(&self) -> String {
192        self.to_cmark_with_options(Options::default())
193    }
194
195    /// Render the table of contents as Markdown with extra options.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// # use pulldown_cmark_toc::{HeadingLevel, ItemSymbol, Options, TableOfContents};
201    ///
202    /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
203    /// let options = Options::default()
204    ///     .item_symbol(ItemSymbol::Asterisk)
205    ///     .levels(HeadingLevel::H2..=HeadingLevel::H6)
206    ///     .indent(4);
207    /// assert_eq!(
208    ///     toc.to_cmark_with_options(options),
209    ///     "* [Subheading](#subheading)\n"
210    /// );
211    /// ```
212    #[must_use]
213    pub fn to_cmark_with_options(&self, options: Options) -> String {
214        let Options {
215            item_symbol,
216            levels,
217            indent,
218            slugifier: mut slugger,
219        } = options;
220
221        let mut buf = String::new();
222        for heading in self.headings().filter(|h| levels.contains(&h.level())) {
223            let title = crate::render::to_cmark(heading.events());
224            let indent = indent * (heading.level() as usize - *levels.start() as usize);
225
226            // make sure the anchor is unique
227
228            writeln!(
229                buf,
230                "{:indent$}{} [{}](#{})",
231                "",
232                item_symbol,
233                title,
234                slugger.slugify(&heading.text()),
235                indent = indent,
236            )
237            .unwrap();
238        }
239        buf
240    }
241}
242
243/////////////////////////////////////////////////////////////////////////
244// Unit tests
245/////////////////////////////////////////////////////////////////////////
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    use pulldown_cmark::CowStr::Borrowed;
252    use pulldown_cmark::Event::{Code, Text};
253
254    #[test]
255    fn heading_text_with_code() {
256        let heading = Heading {
257            events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
258            level: HeadingLevel::H1,
259        };
260        assert_eq!(heading.text(), "Another heading");
261    }
262
263    #[test]
264    fn heading_text_with_links() {
265        let events = Parser::new("Here [TOML](https://toml.io)").collect();
266        let heading = Heading {
267            events,
268            level: HeadingLevel::H1,
269        };
270        assert_eq!(heading.text(), "Here TOML");
271    }
272
273    #[test]
274    fn toc_new() {
275        let toc = TableOfContents::new("# Heading\n\n## `Another` heading\n");
276        assert_eq!(toc.headings[0].events, [Text(Borrowed("Heading"))]);
277        assert_eq!(toc.headings[0].level, HeadingLevel::H1);
278        assert_eq!(
279            toc.headings[1].events,
280            [Code(Borrowed("Another")), Text(Borrowed(" heading"))]
281        );
282        assert_eq!(toc.headings[1].level, HeadingLevel::H2);
283        assert_eq!(toc.headings.len(), 2);
284    }
285
286    #[test]
287    fn toc_new_does_not_enable_smart_punctuation() {
288        let toc = TableOfContents::new("# What's the deal with ellipsis ...?\n");
289        assert_eq!(toc.headings[0].text(), "What's the deal with ellipsis ...?");
290    }
291
292    #[test]
293    fn toc_new_does_not_enable_heading_attributes() {
294        let toc = TableOfContents::new("# text { #id .class1 .class2 }\n");
295        assert_eq!(toc.headings[0].text(), "text { #id .class1 .class2 }");
296    }
297
298    #[test]
299    fn toc_to_cmark_unique_anchors() {
300        let toc = TableOfContents::new("# Heading\n\n# Heading\n\n# `Heading`");
301        assert_eq!(
302            toc.to_cmark(),
303            "- [Heading](#heading)\n- [Heading](#heading-1)\n- [`Heading`](#heading-2)\n"
304        )
305    }
306}