use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
#[must_use]
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}
#[must_use]
pub fn markdown_dialect() -> Options {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts
}
#[must_use]
pub fn first_h1(md: &str) -> Option<String> {
let mut text: Option<String> = None;
for event in Parser::new_ext(md, markdown_dialect()) {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => text = Some(String::new()),
Event::Text(t) | Event::Code(t) => {
if let Some(text) = text.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
_ => {}
}
}
text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
#[must_use]
pub fn heading_text(source: &str) -> String {
first_h1(&format!("# {source}")).unwrap_or_default()
}
#[must_use]
pub fn heading_id_from(explicit: Option<&str>, text: &str) -> String {
explicit
.map(str::trim)
.filter(|e| !e.is_empty())
.map_or_else(|| slugify(text), ToOwned::to_owned)
}
#[must_use]
pub fn heading_id(source: &str) -> String {
let md = format!("# {source}");
let (mut explicit, mut text) = (None, String::new());
let mut open = false;
for event in Parser::new_ext(&md, markdown_dialect()) {
match event {
Event::Start(Tag::Heading { id, .. }) => {
explicit = id.map(|i| i.to_string());
open = true;
}
Event::Text(t) | Event::Code(t) if open => text.push_str(&t),
Event::End(TagEnd::Heading(_)) => break,
_ => {}
}
}
heading_id_from(explicit.as_deref(), text.trim())
}
#[cfg(test)]
mod tests {
use super::{first_h1, heading_id, heading_text, headings, slugify};
#[test]
fn a_heading_is_more_than_a_line_starting_with_two_hashes() {
let md = "# Title\n\n\
> ## Quoted\n\n\
\u{20}\u{20}## Indented\n\n\
Setext\n---\n\n\
```\n## Not a heading\n```\n\n\
## Plain\n";
let hs = headings(md);
let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
["title", "quoted", "indented", "setext", "plain"],
"blockquoted, indented and setext headings are headings; a fenced \
`## ` is not"
);
}
#[test]
fn heading_offsets_ascend_and_point_at_the_heading_not_its_container() {
let md = "## First\n\ntext\n\n> ## Quoted\n\nmore\n";
let hs = headings(md);
assert_eq!(hs.len(), 2);
assert!(hs[0].start < hs[1].start, "document order: {hs:?}");
assert!(
md[hs[1].start..].starts_with("## Quoted"),
"offset points at the heading, not its container: {:?}",
&md[hs[1].start..]
);
}
#[test]
fn a_heading_carries_its_level_text_and_declared_id() {
let hs = headings("### Design *notes* {#arch}\n");
assert_eq!(hs.len(), 1);
assert_eq!(hs[0].level, 3);
assert_eq!(hs[0].text, "Design notes", "markup reduced");
assert_eq!(hs[0].id, "arch", "the declared anchor, not the slug");
}
#[test]
fn a_reference_style_link_cannot_resolve_without_its_document() {
assert_eq!(heading_id("See [the plan][plan]"), "see-the-plan-plan");
assert_eq!(heading_id("See [the plan](plan.md)"), "see-the-plan");
assert_eq!(heading_id("See [the plan]"), "see-the-plan");
}
#[test]
fn collapses_punctuation_and_trims() {
assert_eq!(slugify("Install & build"), "install-build");
assert_eq!(
slugify("The five ways to run it"),
"the-five-ways-to-run-it"
);
assert_eq!(slugify(" §2 — Context! "), "2-context");
assert_eq!(
slugify("Cross-repo: a hub and its spokes"),
"cross-repo-a-hub-and-its-spokes"
);
assert_eq!(slugify("!!!"), "");
}
#[test]
fn an_attribute_block_is_markup_not_part_of_the_title() {
let title = first_h1("# The five ways to run it {#modes}\n").expect("an h1");
assert_eq!(title, "The five ways to run it");
assert!(
!title.contains("{#"),
"an attribute block must not survive into a title: {title:?}"
);
}
#[test]
fn both_entry_points_agree_on_where_a_heading_ends() {
for source in [
"The five ways to run it {#modes}",
"Sets like {#1, #2}",
"The `--json` flag",
"See [the docs](x.md)",
"A ~~retracted~~ claim",
"Install & build",
"",
] {
assert_eq!(
first_h1(&format!("# {source}")).unwrap_or_default(),
heading_text(source),
"the two entry points disagreed about {source:?}"
);
}
}
#[test]
fn a_heading_inside_a_fence_is_a_code_sample() {
let md = "```\n# Widget — Technical Implementation Plan\n```\n\n# Real title\n";
assert_eq!(first_h1(md).as_deref(), Some("Real title"));
assert_eq!(
first_h1("```\n# Fenced only\n```\n"),
None,
"a fenced `#` is not a heading at all"
);
}
#[test]
fn a_setext_heading_is_an_h1() {
assert_eq!(
first_h1("Underlined title\n===\n").as_deref(),
Some("Underlined title")
);
}
#[test]
fn an_empty_heading_names_nothing() {
assert_eq!(first_h1("#\n\n# Second\n"), None);
assert_eq!(heading_text(""), "");
}
#[test]
fn heading_text_feeds_slugify_the_text_a_reader_sees() {
assert_eq!(
slugify(&heading_text("1 · Offline mode — the default {#offline}")),
"1-offline-mode-the-default"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heading {
pub level: u8,
pub id: String,
pub text: String,
pub start: usize,
}
#[must_use]
pub fn headings(md: &str) -> Vec<Heading> {
let mut out: Vec<Heading> = Vec::new();
let mut open: Option<(u8, Option<String>, String, usize)> = None;
for (event, range) in Parser::new_ext(md, markdown_dialect()).into_offset_iter() {
match event {
Event::Start(Tag::Heading { level, id, .. }) => {
let level = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
open = Some((level, id.map(|i| i.to_string()), String::new(), range.start));
}
Event::Text(t) | Event::Code(t) => {
if let Some((_, _, text, _)) = open.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(_)) => {
if let Some((level, explicit, text, start)) = open.take() {
let text = text.trim().to_owned();
out.push(Heading {
level,
id: heading_id_from(explicit.as_deref(), &text),
text,
start,
});
}
}
_ => {}
}
}
out
}