Skip to main content

use_markdown/
heading.rs

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/// A Markdown ATX heading.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct MarkdownHeading {
9    /// The heading level from 1 to 6.
10    pub level: u8,
11    /// The cleaned heading text.
12    pub text: String,
13    /// The 1-based line where the heading was found.
14    pub line: usize,
15    /// A practical anchor derived from the heading text.
16    pub anchor: String,
17}
18
19/// Extracts ATX headings while ignoring content inside fenced code blocks.
20pub 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}