use super::text::inlines_to_text;
use crate::ast::parser::ParseConfig;
use crate::ast::{parse_with_config, Block};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingInfo {
pub text: String,
pub slug: String,
pub level: u8,
}
pub fn extract_headings(markdown: &str) -> Vec<HeadingInfo> {
extract_headings_with_config(markdown, &ParseConfig::default())
}
pub fn extract_headings_with_config(markdown: &str, config: &ParseConfig) -> Vec<HeadingInfo> {
let doc = parse_with_config(markdown, config);
let mut out = Vec::new();
for block in &doc.blocks {
if let Block::Heading {
level,
children,
id,
} = block
{
let mut text = String::new();
inlines_to_text(children, &mut text);
out.push(HeadingInfo {
text: text.trim().to_string(),
slug: id.clone().unwrap_or_default(),
level: *level,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_text_slug_level() {
let md = "# Title\n\n## Getting Started\n\ntext\n\n### Sub *em*\n";
let hs = extract_headings(md);
assert_eq!(hs.len(), 3);
assert_eq!(hs[0], HeadingInfo { text: "Title".into(), slug: "title".into(), level: 1 });
assert_eq!(hs[1], HeadingInfo { text: "Getting Started".into(), slug: "getting-started".into(), level: 2 });
assert_eq!(hs[2], HeadingInfo { text: "Sub em".into(), slug: "sub-em".into(), level: 3 });
}
#[test]
fn dedups_duplicate_slugs() {
let md = "## Setup\n\n## Setup\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, "setup");
assert_eq!(hs[1].slug, "setup-1");
}
#[test]
fn preserves_cjk() {
let md = "## 中文标题\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, "中文标题");
assert_eq!(hs[0].text, "中文标题");
}
#[test]
fn empty_doc_no_headings() {
assert!(extract_headings("just a paragraph\n").is_empty());
}
#[test]
fn slug_matches_obsidian_anchor_for_punctuation() {
let md = "## Step 1: Install\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, crate::heading::anchor::obsidian_heading_anchor("Step 1: Install"));
}
#[test]
fn math_heading_slug_is_identical_across_every_surface() {
let md = "# Euler $e^{i\\pi}=-1$ identity\n";
let math_on = ParseConfig { math: true, ..Default::default() };
let extracted = extract_headings_with_config(md, &math_on);
assert_eq!(extracted.len(), 1);
let doc = crate::ast::parse_with_config(md, &math_on);
let Block::Heading { id, .. } = &doc.blocks[0] else {
panic!("expected a heading, got {:?}", doc.blocks[0]);
};
assert_eq!(extracted[0].slug, *id.as_ref().expect("heading must have an id"));
assert_eq!(
extracted[0].slug,
crate::heading::anchor::obsidian_heading_anchor("Euler $e^{i\\pi}=-1$ identity")
);
let off = extract_headings_with_config(md, &ParseConfig::default());
assert_eq!(
extracted[0].slug, off[0].slug,
"TeX with no markdown-active characters must slug identically either way"
);
assert_eq!(extracted[0].text, "Euler $e^{i\\pi}=-1$ identity");
assert_eq!(extracted[0].text, off[0].text);
}
#[test]
fn markdown_active_chars_in_tex_move_the_anchor_but_keep_graph_agreement() {
let math_on = ParseConfig { math: true, ..Default::default() };
for (md, raw, expect_off) in [
(
"# Convolution $f*g$ and $h*k$ end\n",
"Convolution $f*g$ and $h*k$ end",
"convolution-$fg$-and-$hk$-end",
),
("# Dual $V^*$ and $W^*$ end\n", "Dual $V^*$ and $W^*$ end", "dual-$v$-and-$w$-end"),
] {
let on = extract_headings_with_config(md, &math_on);
let off = extract_headings_with_config(md, &ParseConfig::default());
assert_eq!(
on[0].slug,
crate::heading::anchor::obsidian_heading_anchor(raw),
"math-ON slug diverged from the raw-line slug the wikilink graph computes"
);
assert_eq!(off[0].slug, expect_off, "math-OFF slug drifted from what ADR-030 records");
assert_ne!(
on[0].slug, off[0].slug,
"expected this heading's anchor to MOVE when math is enabled"
);
}
}
#[test]
fn display_math_in_a_heading_keeps_both_delimiters() {
let math_on = ParseConfig { math: true, ..Default::default() };
let hs = extract_headings_with_config("# Case $$a+b$$ tail\n", &math_on);
assert_eq!(hs[0].text, "Case $$a+b$$ tail");
assert_eq!(
hs[0].slug,
crate::heading::anchor::obsidian_heading_anchor("Case $$a+b$$ tail"),
"graph agreement — the invariant that always holds"
);
assert_eq!(
hs[0].slug,
extract_headings_with_config("# Case $$a+b$$ tail\n", &ParseConfig::default())[0].slug
);
}
#[test]
fn headings_differing_only_inside_math_do_not_collide() {
let math_on = ParseConfig { math: true, ..Default::default() };
let hs = extract_headings_with_config("## Case $a$\n\n## Case $b$\n", &math_on);
assert_ne!(hs[0].slug, hs[1].slug);
assert!(!hs[1].slug.ends_with("-1"), "slug {:?} collided", hs[1].slug);
}
}