mod render;
mod slug;
use std::borrow::Borrow;
use std::fmt::Write;
use std::slice::Iter;
pub use pulldown_cmark::HeadingLevel;
use pulldown_cmark::{Event, Options as CmarkOptions, Parser, Tag, TagEnd};
pub use render::{ItemSymbol, Options};
pub use slug::{GitHubSlugifier, Slugify};
#[derive(Debug, Clone)]
pub struct Heading<'a> {
events: Vec<Event<'a>>,
level: HeadingLevel,
}
#[derive(Debug)]
pub struct TableOfContents<'a> {
headings: Vec<Heading<'a>>,
}
impl Heading<'_> {
pub fn events(&self) -> Iter<Event> {
self.events.iter()
}
pub fn level(&self) -> HeadingLevel {
self.level
}
pub fn text(&self) -> String {
let mut buf = String::new();
for event in self.events() {
if let Event::Text(s) | Event::Code(s) = event {
buf.push_str(s);
}
}
buf
}
}
impl<'a> TableOfContents<'a> {
pub fn new(text: &'a str) -> Self {
let mut options = CmarkOptions::empty();
options.insert(CmarkOptions::ENABLE_STRIKETHROUGH);
options.insert(CmarkOptions::ENABLE_FOOTNOTES);
let events = Parser::new_ext(text, options);
Self::new_with_events(events)
}
pub fn new_with_events<I, E>(events: I) -> Self
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
{
let mut headings = Vec::new();
let mut current: Option<Heading> = None;
for event in events {
let event = event.borrow();
match event {
Event::Start(Tag::Heading { level, .. }) => {
current = Some(Heading {
events: Vec::new(),
level: *level,
});
}
Event::End(TagEnd::Heading(level)) => {
let heading = current.take().unwrap();
assert_eq!(heading.level, *level);
headings.push(heading);
}
event => {
if let Some(heading) = current.as_mut() {
heading.events.push(event.clone());
}
}
}
}
Self { headings }
}
pub fn headings(&self) -> Iter<Heading> {
self.headings.iter()
}
#[must_use]
pub fn to_cmark(&self) -> String {
self.to_cmark_with_options(Options::default())
}
#[must_use]
pub fn to_cmark_with_options(&self, options: Options) -> String {
let Options {
item_symbol,
levels,
indent,
slugifier: mut slugger,
} = options;
let mut buf = String::new();
for heading in self.headings().filter(|h| levels.contains(&h.level())) {
let title = crate::render::to_cmark(heading.events());
let indent = indent * (heading.level() as usize - *levels.start() as usize);
writeln!(
buf,
"{:indent$}{} [{}](#{})",
"",
item_symbol,
title,
slugger.slugify(&heading.text()),
indent = indent,
)
.unwrap();
}
buf
}
}
#[cfg(test)]
mod tests {
use super::*;
use pulldown_cmark::CowStr::Borrowed;
use pulldown_cmark::Event::{Code, Text};
#[test]
fn heading_text_with_code() {
let heading = Heading {
events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
level: HeadingLevel::H1,
};
assert_eq!(heading.text(), "Another heading");
}
#[test]
fn heading_text_with_links() {
let events = Parser::new("Here [TOML](https://toml.io)").collect();
let heading = Heading {
events,
level: HeadingLevel::H1,
};
assert_eq!(heading.text(), "Here TOML");
}
#[test]
fn toc_new() {
let toc = TableOfContents::new("# Heading\n\n## `Another` heading\n");
assert_eq!(toc.headings[0].events, [Text(Borrowed("Heading"))]);
assert_eq!(toc.headings[0].level, HeadingLevel::H1);
assert_eq!(
toc.headings[1].events,
[Code(Borrowed("Another")), Text(Borrowed(" heading"))]
);
assert_eq!(toc.headings[1].level, HeadingLevel::H2);
assert_eq!(toc.headings.len(), 2);
}
#[test]
fn toc_new_does_not_enable_smart_punctuation() {
let toc = TableOfContents::new("# What's the deal with ellipsis ...?\n");
assert_eq!(toc.headings[0].text(), "What's the deal with ellipsis ...?");
}
#[test]
fn toc_new_does_not_enable_heading_attributes() {
let toc = TableOfContents::new("# text { #id .class1 .class2 }\n");
assert_eq!(toc.headings[0].text(), "text { #id .class1 .class2 }");
}
#[test]
fn toc_to_cmark_unique_anchors() {
let toc = TableOfContents::new("# Heading\n\n# Heading\n\n# `Heading`");
assert_eq!(
toc.to_cmark(),
"- [Heading](#heading)\n- [Heading](#heading-1)\n- [`Heading`](#heading-2)\n"
)
}
}