1use crate::code_fence::{FenceDelimiter, is_closing_fence, parse_opening_fence};
2use crate::frontmatter::frontmatter_line_count;
3use crate::heading_to_anchor;
4use crate::plain_text::inline_markdown_to_text;
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct MarkdownHeading {
9 pub level: u8,
11 pub text: String,
13 pub line: usize,
15 pub anchor: String,
17}
18
19pub fn extract_headings(markdown: &str) -> Vec<MarkdownHeading> {
21 let frontmatter_lines = frontmatter_line_count(markdown);
22 let mut headings = Vec::new();
23 let mut active_fence: Option<FenceDelimiter> = None;
24
25 for (index, line) in markdown.lines().enumerate() {
26 if index < frontmatter_lines {
27 continue;
28 }
29
30 if let Some(delimiter) = active_fence {
31 if is_closing_fence(line, delimiter) {
32 active_fence = None;
33 }
34 continue;
35 }
36
37 if let Some(opening) = parse_opening_fence(line) {
38 active_fence = Some(opening.delimiter);
39 continue;
40 }
41
42 if let Some((level, text)) = parse_heading_line(line) {
43 let text = inline_markdown_to_text(text);
44 headings.push(MarkdownHeading {
45 level,
46 anchor: heading_to_anchor(&text),
47 text,
48 line: index + 1,
49 });
50 }
51 }
52
53 headings
54}
55
56pub(crate) fn parse_heading_line(line: &str) -> Option<(u8, &str)> {
57 let candidate = crate::strip_up_to_three_leading_spaces(line);
58 let level = candidate
59 .chars()
60 .take_while(|character| *character == '#')
61 .count();
62 if !(1..=6).contains(&level) {
63 return None;
64 }
65
66 let remainder = &candidate[level..];
67 if !remainder.is_empty() && !remainder.chars().next().is_some_and(char::is_whitespace) {
68 return None;
69 }
70
71 Some((level as u8, trim_closing_hashes(remainder.trim())))
72}
73
74fn trim_closing_hashes(text: &str) -> &str {
75 let trimmed = text.trim();
76 let mut end = trimmed.len();
77
78 while end > 0 && trimmed.as_bytes()[end - 1] == b'#' {
79 end -= 1;
80 }
81
82 if end == trimmed.len() {
83 return trimmed;
84 }
85
86 let prefix = &trimmed[..end];
87 if prefix.chars().last().is_some_and(char::is_whitespace) {
88 prefix.trim_end()
89 } else {
90 trimmed
91 }
92}