Skip to main content

rumdl_lib/rules/
heading_utils.rs

1use regex::Regex;
2use std::fmt;
3use std::str::FromStr;
4use std::sync::LazyLock;
5
6static ATX_PATTERN: LazyLock<Regex> =
7    LazyLock::new(|| Regex::new(r"^(\s*)(#{1,6})(\s*)([^#\n]*?)(?:\s+(#{1,6}))?\s*$").unwrap());
8static SETEXT_HEADING_1: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(=+)(\s*)$").unwrap());
9static SETEXT_HEADING_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(-+)(\s*)$").unwrap());
10static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").unwrap());
11
12/// Represents different styles of Markdown headings
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)]
14pub enum HeadingStyle {
15    Atx,       // # Heading
16    AtxClosed, // # Heading #
17    Setext1,   // Heading
18    // =======
19    Setext2, // Heading
20    // -------
21    Consistent,          // For maintaining consistency with the first found header style
22    SetextWithAtx,       // Setext for h1/h2, ATX for h3-h6
23    SetextWithAtxClosed, // Setext for h1/h2, ATX closed for h3-h6
24}
25
26impl fmt::Display for HeadingStyle {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        let s = match self {
29            HeadingStyle::Atx => "atx",
30            HeadingStyle::AtxClosed => "atx-closed",
31            HeadingStyle::Setext1 => "setext1",
32            HeadingStyle::Setext2 => "setext2",
33            HeadingStyle::Consistent => "consistent",
34            HeadingStyle::SetextWithAtx => "setext-with-atx",
35            HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed",
36        };
37        write!(f, "{s}")
38    }
39}
40
41impl FromStr for HeadingStyle {
42    type Err = ();
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        let normalized = s.trim().to_ascii_lowercase().replace('-', "_");
45        match normalized.as_str() {
46            "atx" => Ok(HeadingStyle::Atx),
47            "atx_closed" => Ok(HeadingStyle::AtxClosed),
48            "setext1" | "setext" => Ok(HeadingStyle::Setext1),
49            "setext2" => Ok(HeadingStyle::Setext2),
50            "consistent" => Ok(HeadingStyle::Consistent),
51            "setext_with_atx" => Ok(HeadingStyle::SetextWithAtx),
52            "setext_with_atx_closed" => Ok(HeadingStyle::SetextWithAtxClosed),
53            _ => Err(()),
54        }
55    }
56}
57
58/// Utility functions for working with Markdown headings
59pub struct HeadingUtils;
60
61impl HeadingUtils {
62    /// Convert a heading to a different style
63    pub fn convert_heading_style(text_content: &str, level: u32, style: HeadingStyle) -> String {
64        // Validate heading level
65        let level = level.clamp(1, 6);
66
67        if text_content.trim().is_empty() {
68            // Empty headings: ATX can be just `##`, Setext requires text so return empty
69            return match style {
70                HeadingStyle::Atx => "#".repeat(level as usize),
71                HeadingStyle::AtxClosed => {
72                    let hashes = "#".repeat(level as usize);
73                    format!("{hashes} {hashes}")
74                }
75                HeadingStyle::Setext1 | HeadingStyle::Setext2 => String::new(),
76                // These are meta-styles resolved before calling this function
77                HeadingStyle::Consistent | HeadingStyle::SetextWithAtx | HeadingStyle::SetextWithAtxClosed => {
78                    "#".repeat(level as usize)
79                }
80            };
81        }
82
83        let indentation = text_content
84            .chars()
85            .take_while(|c| c.is_whitespace())
86            .collect::<String>();
87        let text_content = text_content.trim();
88
89        match style {
90            HeadingStyle::Atx => {
91                format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
92            }
93            HeadingStyle::AtxClosed => {
94                format!(
95                    "{}{} {} {}",
96                    indentation,
97                    "#".repeat(level as usize),
98                    text_content,
99                    "#".repeat(level as usize)
100                )
101            }
102            HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
103                if level > 2 {
104                    // Fall back to ATX style for levels > 2
105                    format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
106                } else {
107                    let underline_char = if level == 1 || style == HeadingStyle::Setext1 {
108                        '='
109                    } else {
110                        '-'
111                    };
112                    let visible_length = text_content.chars().count();
113                    let underline_length = visible_length.max(1); // Ensure at least 1 underline char
114                    format!(
115                        "{}{}\n{}{}",
116                        indentation,
117                        text_content,
118                        indentation,
119                        underline_char.to_string().repeat(underline_length)
120                    )
121                }
122            }
123            HeadingStyle::Consistent => {
124                // For Consistent style, default to ATX as it's the most commonly used
125                format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
126            }
127            HeadingStyle::SetextWithAtx => {
128                if level <= 2 {
129                    // Use Setext for h1/h2
130                    let underline_char = if level == 1 { '=' } else { '-' };
131                    let visible_length = text_content.chars().count();
132                    let underline_length = visible_length.max(1);
133                    format!(
134                        "{}{}\n{}{}",
135                        indentation,
136                        text_content,
137                        indentation,
138                        underline_char.to_string().repeat(underline_length)
139                    )
140                } else {
141                    // Use ATX for h3-h6
142                    format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
143                }
144            }
145            HeadingStyle::SetextWithAtxClosed => {
146                if level <= 2 {
147                    // Use Setext for h1/h2
148                    let underline_char = if level == 1 { '=' } else { '-' };
149                    let visible_length = text_content.chars().count();
150                    let underline_length = visible_length.max(1);
151                    format!(
152                        "{}{}\n{}{}",
153                        indentation,
154                        text_content,
155                        indentation,
156                        underline_char.to_string().repeat(underline_length)
157                    )
158                } else {
159                    // Use ATX closed for h3-h6
160                    format!(
161                        "{}{} {} {}",
162                        indentation,
163                        "#".repeat(level as usize),
164                        text_content,
165                        "#".repeat(level as usize)
166                    )
167                }
168            }
169        }
170    }
171
172    /// Convert a heading text to a valid ID for fragment links
173    pub fn heading_to_fragment(text: &str) -> String {
174        // Remove any HTML tags
175        let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
176
177        // Convert to lowercase and trim
178        let text_lower = text_no_html.trim().to_lowercase();
179
180        // Replace spaces and punctuation with hyphens
181        let text_with_hyphens = text_lower
182            .chars()
183            .map(|c| if c.is_alphanumeric() { c } else { '-' })
184            .collect::<String>();
185
186        // Replace multiple consecutive hyphens with a single hyphen
187        let text_clean = text_with_hyphens
188            .split('-')
189            .filter(|s| !s.is_empty())
190            .collect::<Vec<_>>()
191            .join("-");
192
193        // Remove leading and trailing hyphens
194        text_clean.trim_matches('-').to_string()
195    }
196}
197
198/// Checks if a line is a heading
199#[inline]
200pub fn is_heading(line: &str) -> bool {
201    // Fast path checks first
202    let trimmed = line.trim();
203    if trimmed.is_empty() {
204        return false;
205    }
206
207    if trimmed.starts_with('#') {
208        // Check for ATX heading
209        ATX_PATTERN.is_match(line)
210    } else {
211        // We can't tell for setext headings without looking at the next line
212        false
213    }
214}
215
216/// Checks if a line is a setext heading marker
217#[inline]
218pub fn is_setext_heading_marker(line: &str) -> bool {
219    SETEXT_HEADING_1.is_match(line) || SETEXT_HEADING_2.is_match(line)
220}
221
222/// Get the heading level for a line
223#[inline]
224pub fn get_heading_level(lines: &[&str], index: usize) -> u32 {
225    if index >= lines.len() {
226        return 0;
227    }
228
229    let line = lines[index];
230
231    // Check for ATX style heading
232    if let Some(captures) = ATX_PATTERN.captures(line) {
233        let hashes = captures.get(2).map_or("", |m| m.as_str());
234        return hashes.len() as u32;
235    }
236
237    // Check for setext style heading
238    if index < lines.len() - 1 {
239        let next_line = lines[index + 1];
240
241        if SETEXT_HEADING_1.is_match(next_line) {
242            return 1;
243        }
244
245        if SETEXT_HEADING_2.is_match(next_line) {
246            return 2;
247        }
248    }
249
250    0
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_heading_style_conversion() {
259        assert_eq!(
260            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Atx),
261            "# Heading 1"
262        );
263        assert_eq!(
264            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::AtxClosed),
265            "## Heading 2 ##"
266        );
267        assert_eq!(
268            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Setext1),
269            "Heading 1\n========="
270        );
271        assert_eq!(
272            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::Setext2),
273            "Heading 2\n---------"
274        );
275    }
276
277    #[test]
278    fn test_convert_heading_style_edge_cases() {
279        // Empty text: ATX headings produce just the hash marks (valid markdown)
280        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Atx), "#");
281        assert_eq!(HeadingUtils::convert_heading_style("   ", 1, HeadingStyle::Atx), "#");
282        assert_eq!(HeadingUtils::convert_heading_style("", 2, HeadingStyle::Atx), "##");
283        assert_eq!(
284            HeadingUtils::convert_heading_style("", 1, HeadingStyle::AtxClosed),
285            "# #"
286        );
287        // Setext cannot represent empty headings, returns empty
288        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
289
290        // Level clamping
291        assert_eq!(
292            HeadingUtils::convert_heading_style("Text", 0, HeadingStyle::Atx),
293            "# Text"
294        );
295        assert_eq!(
296            HeadingUtils::convert_heading_style("Text", 10, HeadingStyle::Atx),
297            "###### Text"
298        );
299
300        // Setext with level > 2 falls back to ATX
301        assert_eq!(
302            HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
303            "### Text"
304        );
305
306        // Preserve indentation
307        assert_eq!(
308            HeadingUtils::convert_heading_style("  Text", 1, HeadingStyle::Atx),
309            "  # Text"
310        );
311
312        // Very short text for setext
313        assert_eq!(
314            HeadingUtils::convert_heading_style("Hi", 1, HeadingStyle::Setext1),
315            "Hi\n=="
316        );
317    }
318
319    #[test]
320    fn test_heading_to_fragment() {
321        assert_eq!(HeadingUtils::heading_to_fragment("Simple Heading"), "simple-heading");
322        assert_eq!(
323            HeadingUtils::heading_to_fragment("Heading with Numbers 123"),
324            "heading-with-numbers-123"
325        );
326        assert_eq!(
327            HeadingUtils::heading_to_fragment("Special!@#$%Characters"),
328            "special-characters"
329        );
330        assert_eq!(HeadingUtils::heading_to_fragment("  Trimmed  "), "trimmed");
331        assert_eq!(
332            HeadingUtils::heading_to_fragment("Multiple   Spaces"),
333            "multiple-spaces"
334        );
335        assert_eq!(
336            HeadingUtils::heading_to_fragment("Heading <em>with HTML</em>"),
337            "heading-with-html"
338        );
339        assert_eq!(
340            HeadingUtils::heading_to_fragment("---Leading-Dashes---"),
341            "leading-dashes"
342        );
343        assert_eq!(HeadingUtils::heading_to_fragment(""), "");
344    }
345
346    #[test]
347    fn test_module_level_functions() {
348        // Test is_heading
349        assert!(is_heading("# Heading"));
350        assert!(is_heading("  ## Indented"));
351        assert!(!is_heading("Not a heading"));
352        assert!(!is_heading(""));
353
354        // Test is_setext_heading_marker
355        assert!(is_setext_heading_marker("========"));
356        assert!(is_setext_heading_marker("--------"));
357        assert!(is_setext_heading_marker("  ======"));
358        assert!(!is_setext_heading_marker("# Heading"));
359        assert!(is_setext_heading_marker("---")); // Three dashes is valid
360
361        // Test get_heading_level
362        let lines = vec!["# H1", "## H2", "### H3"];
363        assert_eq!(get_heading_level(&lines, 0), 1);
364        assert_eq!(get_heading_level(&lines, 1), 2);
365        assert_eq!(get_heading_level(&lines, 2), 3);
366        assert_eq!(get_heading_level(&lines, 10), 0);
367    }
368
369    #[test]
370    fn test_heading_style_from_str() {
371        assert_eq!(HeadingStyle::from_str("atx"), Ok(HeadingStyle::Atx));
372        assert_eq!(HeadingStyle::from_str("ATX"), Ok(HeadingStyle::Atx));
373        assert_eq!(HeadingStyle::from_str("atx_closed"), Ok(HeadingStyle::AtxClosed));
374        assert_eq!(HeadingStyle::from_str("atx-closed"), Ok(HeadingStyle::AtxClosed));
375        assert_eq!(HeadingStyle::from_str("ATX-CLOSED"), Ok(HeadingStyle::AtxClosed));
376        assert_eq!(HeadingStyle::from_str("setext1"), Ok(HeadingStyle::Setext1));
377        assert_eq!(HeadingStyle::from_str("setext"), Ok(HeadingStyle::Setext1));
378        assert_eq!(HeadingStyle::from_str("setext2"), Ok(HeadingStyle::Setext2));
379        assert_eq!(HeadingStyle::from_str("consistent"), Ok(HeadingStyle::Consistent));
380        assert_eq!(
381            HeadingStyle::from_str("setext_with_atx"),
382            Ok(HeadingStyle::SetextWithAtx)
383        );
384        assert_eq!(
385            HeadingStyle::from_str("setext-with-atx"),
386            Ok(HeadingStyle::SetextWithAtx)
387        );
388        assert_eq!(
389            HeadingStyle::from_str("setext_with_atx_closed"),
390            Ok(HeadingStyle::SetextWithAtxClosed)
391        );
392        assert_eq!(
393            HeadingStyle::from_str("setext-with-atx-closed"),
394            Ok(HeadingStyle::SetextWithAtxClosed)
395        );
396        assert_eq!(HeadingStyle::from_str("invalid"), Err(()));
397    }
398
399    #[test]
400    fn test_heading_style_display() {
401        assert_eq!(HeadingStyle::Atx.to_string(), "atx");
402        assert_eq!(HeadingStyle::AtxClosed.to_string(), "atx-closed");
403        assert_eq!(HeadingStyle::Setext1.to_string(), "setext1");
404        assert_eq!(HeadingStyle::Setext2.to_string(), "setext2");
405        assert_eq!(HeadingStyle::Consistent.to_string(), "consistent");
406    }
407
408    #[test]
409    fn test_unicode_heading_fragments() {
410        assert_eq!(HeadingUtils::heading_to_fragment("你好世界"), "你好世界");
411        assert_eq!(HeadingUtils::heading_to_fragment("Café René"), "café-rené");
412    }
413}