use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use crate::markdown::types::{RenderOptions, RenderedMarkdown, TocItem};
#[must_use]
pub fn render(body: &str, options: RenderOptions) -> RenderedMarkdown {
let mut pulldown_opts = Options::empty();
if options.enable_tables {
pulldown_opts.insert(Options::ENABLE_TABLES);
}
if options.enable_strikethrough {
pulldown_opts.insert(Options::ENABLE_STRIKETHROUGH);
}
if options.enable_tasklists {
pulldown_opts.insert(Options::ENABLE_TASKLISTS);
}
let parser = Parser::new_ext(body, pulldown_opts);
let raw: Vec<Event<'_>> = parser.collect();
let mut toc: Vec<TocItem> = Vec::new();
let mut output: Vec<Event<'_>> = Vec::with_capacity(raw.len());
let mut anchors = AnchorAllocator::with_reserved(&raw);
let mut i = 0;
while i < raw.len() {
match &raw[i] {
Event::Start(Tag::Heading { level, .. }) => {
let level_u8 = heading_level_to_u8(*level);
let text = heading_text(&raw, i);
let id = anchors.allocate(&heading_id(&text));
if id.is_empty() {
output.push(Event::Html(CowStr::from(format!("<h{level_u8}>"))));
} else {
toc.push(TocItem {
level: level_u8,
id: id.clone(),
text,
});
output.push(Event::Html(CowStr::from(format!(
"<h{level_u8} id=\"{id}\">"
))));
}
i += 1;
}
Event::End(TagEnd::Heading(level)) => {
let level_u8 = heading_level_to_u8(*level);
output.push(Event::Html(CowStr::from(format!("</h{level_u8}>"))));
i += 1;
}
_ => {
match &raw[i] {
Event::Html(s) | Event::InlineHtml(s) => {
output.push(Event::Text(s.clone()));
}
other => {
output.push(other.clone());
}
}
i += 1;
}
}
}
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, output.into_iter());
RenderedMarkdown { html, toc }
}
fn heading_text(events: &[Event<'_>], start: usize) -> String {
let mut text = String::with_capacity(128);
for event in &events[start + 1..] {
match event {
Event::Text(t) | Event::Code(t) => text.push_str(t),
Event::SoftBreak | Event::HardBreak => text.push(' '),
Event::End(TagEnd::Heading(_)) => break,
_ => {}
}
}
text
}
struct AnchorAllocator {
used: std::collections::HashSet<String>,
reserved: std::collections::HashSet<String>,
next_suffix: std::collections::HashMap<String, usize>,
}
impl AnchorAllocator {
fn with_reserved(events: &[Event<'_>]) -> Self {
let mut reserved = std::collections::HashSet::new();
for (i, event) in events.iter().enumerate() {
if matches!(event, Event::Start(Tag::Heading { .. })) {
let slug = heading_id(&heading_text(events, i));
if !slug.is_empty() {
reserved.insert(slug);
}
}
}
Self {
used: std::collections::HashSet::new(),
reserved,
next_suffix: std::collections::HashMap::new(),
}
}
fn allocate(&mut self, base: &str) -> String {
if base.is_empty() {
return String::new();
}
if self.used.insert(base.to_owned()) {
return base.to_owned();
}
let mut counter = self.next_suffix.get(base).copied().unwrap_or(0);
let id = loop {
counter += 1;
let candidate = format!("{base}-{counter}");
if !self.reserved.contains(&candidate) && self.used.insert(candidate.clone()) {
break candidate;
}
};
self.next_suffix.insert(base.to_owned(), counter);
id
}
}
const fn heading_level_to_u8(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
#[must_use]
pub fn heading_id(text: &str) -> String {
let words: Vec<String> = text
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(str::to_lowercase)
.collect();
words.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_simple_paragraph() {
let result = render("Hello **world**!", RenderOptions::default());
assert!(result.html.contains("<strong>world</strong>"));
assert!(result.toc.is_empty());
}
#[test]
fn generates_stable_heading_ids() {
let result = render("# Hello World\n\nSome text.", RenderOptions::default());
assert!(result.html.contains(r#"id="hello-world""#));
assert_eq!(result.toc.len(), 1);
assert_eq!(result.toc[0].id, "hello-world");
assert_eq!(result.toc[0].text, "Hello World");
assert_eq!(result.toc[0].level, 1);
}
#[test]
fn extracts_ordered_toc() {
let md = "# Title\n\n## Section 1\n\nText.\n\n### Subsection\n\n## Section 2\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc.len(), 4);
assert_eq!(result.toc[0].level, 1);
assert_eq!(result.toc[0].id, "title");
assert_eq!(result.toc[1].level, 2);
assert_eq!(result.toc[1].id, "section-1");
assert_eq!(result.toc[2].level, 3);
assert_eq!(result.toc[2].id, "subsection");
assert_eq!(result.toc[3].level, 2);
assert_eq!(result.toc[3].id, "section-2");
}
#[test]
fn preserves_fenced_code_language() {
let md = "```rust\nfn main() {}\n```";
let result = render(md, RenderOptions::default());
assert!(result.html.contains("language-rust"));
}
#[test]
fn renders_tables_when_enabled() {
let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let result = render(
md,
RenderOptions {
enable_tables: true,
..Default::default()
},
);
assert!(result.html.contains("<table>"));
}
#[test]
fn suppresses_tables_when_disabled() {
let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let result = render(
md,
RenderOptions {
enable_tables: false,
..Default::default()
},
);
assert!(!result.html.contains("<table>"));
}
#[test]
fn renders_strikethrough_when_enabled() {
let result = render(
"~~strike~~",
RenderOptions {
enable_strikethrough: true,
..Default::default()
},
);
assert!(result.html.contains("<del>"));
}
#[test]
fn suppresses_strikethrough_when_disabled() {
let result = render(
"~~strike~~",
RenderOptions {
enable_strikethrough: false,
..Default::default()
},
);
assert!(!result.html.contains("<del>"));
}
#[test]
fn empty_body_renders_empty_html() {
let result = render("", RenderOptions::default());
assert_eq!(result.html.trim(), "");
assert!(result.toc.is_empty());
}
#[test]
fn heading_id_strips_special_chars() {
assert_eq!(heading_id("Hello, World!"), "hello-world");
assert_eq!(heading_id("Getting Started"), "getting-started");
assert_eq!(heading_id(" Leading Spaces "), "leading-spaces");
}
#[test]
fn heading_id_unique_for_different_texts() {
assert_ne!(heading_id("Section 1"), heading_id("Section 2"));
}
#[test]
fn heading_id_level6() {
let result = render("###### Deep\n", RenderOptions::default());
assert!(result.html.contains(r#"<h6 id="deep">"#));
assert_eq!(result.toc[0].level, 6);
}
#[test]
fn multiple_headings_all_in_toc() {
let md = "# One\n## Two\n### Three\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc.len(), 3);
assert!(result.html.contains(r#"id="one""#));
assert!(result.html.contains(r#"id="two""#));
assert!(result.html.contains(r#"id="three""#));
}
#[test]
fn heading_id_apostrophe_handled() {
assert_eq!(heading_id("What's New"), "what-s-new");
}
#[test]
fn heading_id_all_special_chars() {
assert_eq!(heading_id("!!!"), "");
}
#[test]
fn heading_id_unicode_preserved() {
assert_eq!(heading_id("Über uns"), "über-uns");
assert_eq!(heading_id("日本語"), "日本語");
}
#[test]
fn soft_break_in_heading_preserved_as_space() {
let md = "Hello\nWorld\n=====\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc[0].text, "Hello World");
assert!(result.html.contains(r#"id="hello-world""#));
}
#[test]
fn hard_break_in_heading_preserved_as_space() {
let md = "Hello\\\nWorld\n=====\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc[0].text, "Hello World");
assert!(result.html.contains(r#"id="hello-world""#));
}
#[test]
fn punctuation_only_heading_emits_no_id_and_no_toc_entry() {
let result = render("# !!!\n\nText.", RenderOptions::default());
assert!(!result.html.contains("id="));
assert!(result.toc.is_empty());
assert!(result.html.contains("<h1>"));
}
#[test]
fn duplicate_headings_get_unique_ids() {
let md = "## Example\n\nFirst.\n\n## Example\n\nSecond.\n\n## Example\n\nThird.\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["example", "example-1", "example-2"]);
assert!(result.html.contains(r#"<h2 id="example">"#));
assert!(result.html.contains(r#"<h2 id="example-1">"#));
assert!(result.html.contains(r#"<h2 id="example-2">"#));
}
#[test]
fn anchors_do_not_leak_between_documents() {
let md = "## Example\n\n## Example\n";
let first = render(md, RenderOptions::default());
let second = render(md, RenderOptions::default());
let ids_a: Vec<&str> = first.toc.iter().map(|t| t.id.as_str()).collect();
let ids_b: Vec<&str> = second.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids_a, ids_b, "anchor state leaked across render() calls");
assert_eq!(ids_a, vec!["example", "example-1"]);
}
#[test]
fn first_occurrence_keeps_unsuffixed_id() {
let md = "# Intro\n\n## Setup\n\n## Setup\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc[0].id, "intro");
assert_eq!(result.toc[1].id, "setup");
assert_eq!(result.toc[2].id, "setup-1");
}
#[test]
fn dedup_suffix_skips_ids_already_taken_by_another_heading() {
let md = "## Example\n\n## Example 1\n\n## Example\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["example", "example-1", "example-2"]);
}
#[test]
fn duplicate_never_steals_another_headings_natural_slug() {
let md = "## Example\n\n## Example\n\n## Example 1\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["example", "example-2", "example-1"]);
assert_eq!(result.toc[2].text, "Example 1");
}
#[test]
fn every_heading_keeps_its_own_natural_slug() {
let md = "# x\n# x\n# x\n# x-1\n# x-2\n# x\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids[3], "x-1");
assert_eq!(ids[4], "x-2");
assert_eq!(ids[0], "x");
assert_eq!(ids[1], "x-3");
assert_eq!(ids[2], "x-4");
assert_eq!(ids[5], "x-5");
}
#[test]
fn repeat_of_a_numeric_looking_slug_nests_its_suffix() {
let md = "# x-1\n# x-1\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["x-1", "x-1-1"]);
}
#[test]
fn dedup_is_case_insensitive_via_slug() {
let md = "## Setup\n\n## SETUP\n";
let result = render(md, RenderOptions::default());
assert_eq!(result.toc[0].id, "setup");
assert_eq!(result.toc[1].id, "setup-1");
}
#[test]
fn duplicate_punctuation_only_headings_stay_id_free() {
let result = render("# !!!\n\n# ???\n", RenderOptions::default());
assert!(!result.html.contains("id="));
assert!(result.toc.is_empty());
}
#[test]
fn toc_ids_match_emitted_heading_ids() {
let md = "# Guide\n## Example\n### Example\n## Example\n";
let result = render(md, RenderOptions::default());
let ids: Vec<&str> = result.toc.iter().map(|t| t.id.as_str()).collect();
let mut unique = ids.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), ids.len(), "TOC ids must be distinct: {ids:?}");
for id in &ids {
let needle = format!(r#" id="{id}">"#);
assert_eq!(
result.html.matches(&needle).count(),
1,
"TOC id {id:?} must match exactly one heading in:\n{}",
result.html
);
}
assert_eq!(
result.html.matches(r#" id=""#).count(),
ids.len(),
"every emitted id must be represented in the TOC:\n{}",
result.html
);
}
#[test]
fn escapes_raw_html() {
let md = "<script>alert('xss')</script>\n\nAn <img src=x onerror=alert(1)> image.";
let result = render(md, RenderOptions::default());
assert!(!result.html.contains("<script>"));
assert!(!result.html.contains("<img"));
assert!(result.html.contains("<script>"));
assert!(result.html.contains("<img"));
}
}