use crate::code_fence::{FenceDelimiter, is_closing_fence, parse_opening_fence};
use crate::frontmatter::frontmatter_line_count;
use crate::heading_to_anchor;
use crate::plain_text::inline_markdown_to_text;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarkdownHeading {
pub level: u8,
pub text: String,
pub line: usize,
pub anchor: String,
}
pub fn extract_headings(markdown: &str) -> Vec<MarkdownHeading> {
let frontmatter_lines = frontmatter_line_count(markdown);
let mut headings = Vec::new();
let mut active_fence: Option<FenceDelimiter> = None;
for (index, line) in markdown.lines().enumerate() {
if index < frontmatter_lines {
continue;
}
if let Some(delimiter) = active_fence {
if is_closing_fence(line, delimiter) {
active_fence = None;
}
continue;
}
if let Some(opening) = parse_opening_fence(line) {
active_fence = Some(opening.delimiter);
continue;
}
if let Some((level, text)) = parse_heading_line(line) {
let text = inline_markdown_to_text(text);
headings.push(MarkdownHeading {
level,
anchor: heading_to_anchor(&text),
text,
line: index + 1,
});
}
}
headings
}
pub(crate) fn parse_heading_line(line: &str) -> Option<(u8, &str)> {
let candidate = crate::strip_up_to_three_leading_spaces(line);
let level = candidate
.chars()
.take_while(|character| *character == '#')
.count();
if !(1..=6).contains(&level) {
return None;
}
let remainder = &candidate[level..];
if !remainder.is_empty() && !remainder.chars().next().is_some_and(char::is_whitespace) {
return None;
}
Some((level as u8, trim_closing_hashes(remainder.trim())))
}
fn trim_closing_hashes(text: &str) -> &str {
let trimmed = text.trim();
let mut end = trimmed.len();
while end > 0 && trimmed.as_bytes()[end - 1] == b'#' {
end -= 1;
}
if end == trimmed.len() {
return trimmed;
}
let prefix = &trimmed[..end];
if prefix.chars().last().is_some_and(char::is_whitespace) {
prefix.trim_end()
} else {
trimmed
}
}