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/// Protect a literal terminal hash run when serializing plain ATX text.
59/// Closed ATX adds its own marker and does not need this escape.
60pub(crate) fn escape_atx_closing_hashes(text: &str) -> std::borrow::Cow<'_, str> {
61    let first_hash = text.len() - text.bytes().rev().take_while(|&byte| byte == b'#').count();
62    if first_hash == text.len() || (first_hash > 0 && !matches!(text.as_bytes()[first_hash - 1], b' ' | b'\t')) {
63        return std::borrow::Cow::Borrowed(text);
64    }
65    let mut escaped = String::with_capacity(text.len() + 1);
66    escaped.push_str(&text[..first_hash]);
67    escaped.push('\\');
68    escaped.push_str(&text[first_hash..]);
69    std::borrow::Cow::Owned(escaped)
70}
71
72/// Utility functions for working with Markdown headings
73pub struct HeadingUtils;
74
75impl HeadingUtils {
76    /// Convert a heading to a different style
77    pub fn convert_heading_style(text_content: &str, level: u32, style: HeadingStyle) -> String {
78        // Validate heading level
79        let level = level.clamp(1, 6);
80
81        if text_content.trim().is_empty() {
82            // Empty headings: ATX can be just `##`, Setext requires text so return empty
83            return match style {
84                HeadingStyle::Atx => "#".repeat(level as usize),
85                HeadingStyle::AtxClosed => {
86                    let hashes = "#".repeat(level as usize);
87                    format!("{hashes} {hashes}")
88                }
89                HeadingStyle::Setext1 | HeadingStyle::Setext2 => String::new(),
90                // These are meta-styles resolved before calling this function
91                HeadingStyle::Consistent | HeadingStyle::SetextWithAtx | HeadingStyle::SetextWithAtxClosed => {
92                    "#".repeat(level as usize)
93                }
94            };
95        }
96
97        let indentation = text_content
98            .chars()
99            .take_while(|c| c.is_whitespace())
100            .collect::<String>();
101        let text_content = text_content.trim();
102
103        match style {
104            HeadingStyle::Atx => {
105                format!(
106                    "{}{} {}",
107                    indentation,
108                    "#".repeat(level as usize),
109                    escape_atx_closing_hashes(text_content)
110                )
111            }
112            HeadingStyle::AtxClosed => {
113                format!(
114                    "{}{} {} {}",
115                    indentation,
116                    "#".repeat(level as usize),
117                    text_content,
118                    "#".repeat(level as usize)
119                )
120            }
121            HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
122                if level > 2 {
123                    // Fall back to ATX style for levels > 2
124                    format!(
125                        "{}{} {}",
126                        indentation,
127                        "#".repeat(level as usize),
128                        escape_atx_closing_hashes(text_content)
129                    )
130                } else {
131                    let underline_char = if level == 1 || style == HeadingStyle::Setext1 {
132                        '='
133                    } else {
134                        '-'
135                    };
136                    let visible_length = text_content.chars().count();
137                    let underline_length = visible_length.max(1); // Ensure at least 1 underline char
138                    format!(
139                        "{}{}\n{}{}",
140                        indentation,
141                        text_content,
142                        indentation,
143                        underline_char.to_string().repeat(underline_length)
144                    )
145                }
146            }
147            HeadingStyle::Consistent => {
148                // For Consistent style, default to ATX as it's the most commonly used
149                format!(
150                    "{}{} {}",
151                    indentation,
152                    "#".repeat(level as usize),
153                    escape_atx_closing_hashes(text_content)
154                )
155            }
156            HeadingStyle::SetextWithAtx => {
157                if level <= 2 {
158                    // Use Setext for h1/h2
159                    let underline_char = if level == 1 { '=' } else { '-' };
160                    let visible_length = text_content.chars().count();
161                    let underline_length = visible_length.max(1);
162                    format!(
163                        "{}{}\n{}{}",
164                        indentation,
165                        text_content,
166                        indentation,
167                        underline_char.to_string().repeat(underline_length)
168                    )
169                } else {
170                    // Use ATX for h3-h6
171                    format!(
172                        "{}{} {}",
173                        indentation,
174                        "#".repeat(level as usize),
175                        escape_atx_closing_hashes(text_content)
176                    )
177                }
178            }
179            HeadingStyle::SetextWithAtxClosed => {
180                if level <= 2 {
181                    // Use Setext for h1/h2
182                    let underline_char = if level == 1 { '=' } else { '-' };
183                    let visible_length = text_content.chars().count();
184                    let underline_length = visible_length.max(1);
185                    format!(
186                        "{}{}\n{}{}",
187                        indentation,
188                        text_content,
189                        indentation,
190                        underline_char.to_string().repeat(underline_length)
191                    )
192                } else {
193                    // Use ATX closed for h3-h6
194                    format!(
195                        "{}{} {} {}",
196                        indentation,
197                        "#".repeat(level as usize),
198                        text_content,
199                        "#".repeat(level as usize)
200                    )
201                }
202            }
203        }
204    }
205
206    /// Convert a heading text to a valid ID for fragment links
207    pub fn heading_to_fragment(text: &str) -> String {
208        // Remove any HTML tags
209        let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
210
211        // Convert to lowercase and trim
212        let text_lower = text_no_html.trim().to_lowercase();
213
214        // Replace spaces and punctuation with hyphens
215        let text_with_hyphens = text_lower
216            .chars()
217            .map(|c| if c.is_alphanumeric() { c } else { '-' })
218            .collect::<String>();
219
220        // Replace multiple consecutive hyphens with a single hyphen
221        let text_clean = text_with_hyphens
222            .split('-')
223            .filter(|s| !s.is_empty())
224            .collect::<Vec<_>>()
225            .join("-");
226
227        // Remove leading and trailing hyphens
228        text_clean.trim_matches('-').to_string()
229    }
230}
231
232/// Setext-to-ATX conversion joins text lines. That must not alter literal
233/// values or discard a hard break that belongs to the rendered heading.
234pub(crate) fn setext_to_atx_is_safe(
235    ctx: &crate::lint_context::LintContext,
236    heading_idx: usize,
237    heading: &crate::lint_context::HeadingInfo,
238) -> bool {
239    if !matches!(
240        heading.style,
241        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
242    ) {
243        return true;
244    }
245    let first_idx = heading_idx + 1 - heading.text_lines;
246    let tags = ctx.html_tags();
247    let inside_literal = |pos| {
248        let idx = tags.partition_point(|tag| tag.byte_offset < pos);
249        ctx.is_inside_template_code(pos) || ctx.is_inside_mdx_code(pos) || (idx > 0 && tags[idx - 1].byte_end > pos)
250    };
251    let first = &ctx.lines[first_idx];
252    if inside_literal(first.byte_offset + first.indent) {
253        return false;
254    }
255    let joined_lines = &ctx.lines[first_idx..heading_idx];
256    let title_crosses_join = |start: usize, end: usize, title: Option<&str>| {
257        if !title.is_some_and(|title| title.contains(['\n', '\r'])) {
258            return false;
259        }
260        let idx = joined_lines.partition_point(|line| line.byte_offset + line.byte_len <= start);
261        joined_lines
262            .get(idx)
263            .is_some_and(|line| line.byte_offset + line.byte_len < end)
264    };
265    // Inline title newlines are attribute data. Reference titles live in an
266    // untouched definition, and ordinary label soft breaks remain joinable.
267    if !joined_lines.is_empty()
268        && (ctx.links().iter().any(|link| {
269            link.link_type == pulldown_cmark::LinkType::Inline
270                && title_crosses_join(link.byte_offset, link.byte_end, link.title.as_deref())
271        }) || ctx.images().iter().any(|image| {
272            image.link_type == pulldown_cmark::LinkType::Inline
273                && title_crosses_join(image.byte_offset, image.byte_end, image.title.as_deref())
274        }))
275    {
276        // Decoded newlines can also come from entities in an unchanged title.
277        // Reparse only these rare candidates with the same Markdown flavor.
278        let converted = HeadingUtils::convert_heading_style(&heading.raw_text, heading.level as u32, HeadingStyle::Atx);
279        let converted_ctx = crate::lint_context::LintContext::new(&converted, ctx.flavor, None);
280        if converted_ctx.valid_headings().count() != 1 {
281            return false;
282        }
283        let text_start = first.byte_offset;
284        let text_end = ctx.lines[heading_idx].byte_offset + ctx.lines[heading_idx].byte_len;
285        let expected_links = ctx
286            .links()
287            .iter()
288            .filter(|link| {
289                link.link_type == pulldown_cmark::LinkType::Inline
290                    && link.byte_offset >= text_start
291                    && link.byte_end <= text_end
292            })
293            .map(|link| link.title.as_deref().unwrap_or(""));
294        let actual_links = converted_ctx
295            .links()
296            .iter()
297            .filter(|link| link.link_type == pulldown_cmark::LinkType::Inline)
298            .map(|link| link.title.as_deref().unwrap_or(""));
299        let expected_images = ctx
300            .images()
301            .iter()
302            .filter(|image| {
303                image.link_type == pulldown_cmark::LinkType::Inline
304                    && image.byte_offset >= text_start
305                    && image.byte_end <= text_end
306            })
307            .map(|image| image.title.as_deref().unwrap_or(""));
308        let actual_images = converted_ctx
309            .images()
310            .iter()
311            .filter(|image| image.link_type == pulldown_cmark::LinkType::Inline)
312            .map(|image| image.title.as_deref().unwrap_or(""));
313        if !expected_links.eq(actual_links) || !expected_images.eq(actual_images) {
314            return false;
315        }
316    }
317    (first_idx..heading_idx).all(|idx| {
318        let line = &ctx.lines[idx];
319        let content = line.content(ctx.content);
320        let boundary = line.byte_offset + line.byte_len;
321        !inside_literal(boundary)
322            && !ctx.line_ends_with_hard_break(idx + 1)
323            && !content.ends_with("  ")
324            && !(ctx.is_in_code_span_byte(boundary)
325                && (content.ends_with([' ', '\t']) || ctx.lines[idx + 1].content(ctx.content).starts_with([' ', '\t'])))
326    })
327}
328
329/// Checks if a line is a heading
330#[inline]
331pub fn is_heading(line: &str) -> bool {
332    // Fast path checks first
333    let trimmed = line.trim();
334    if trimmed.is_empty() {
335        return false;
336    }
337
338    if trimmed.starts_with('#') {
339        // Check for ATX heading
340        ATX_PATTERN.is_match(line)
341    } else {
342        // We can't tell for setext headings without looking at the next line
343        false
344    }
345}
346
347/// Checks if a line is a setext heading marker
348#[inline]
349pub fn is_setext_heading_marker(line: &str) -> bool {
350    SETEXT_HEADING_1.is_match(line) || SETEXT_HEADING_2.is_match(line)
351}
352
353/// Get the heading level for a line
354#[inline]
355pub fn get_heading_level(lines: &[&str], index: usize) -> u32 {
356    if index >= lines.len() {
357        return 0;
358    }
359
360    let line = lines[index];
361
362    // Check for ATX style heading
363    if let Some(captures) = ATX_PATTERN.captures(line) {
364        let hashes = captures.get(2).map_or("", |m| m.as_str());
365        return hashes.len() as u32;
366    }
367
368    // Check for setext style heading
369    if index < lines.len() - 1 {
370        let next_line = lines[index + 1];
371
372        if SETEXT_HEADING_1.is_match(next_line) {
373            return 1;
374        }
375
376        if SETEXT_HEADING_2.is_match(next_line) {
377            return 2;
378        }
379    }
380
381    0
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_heading_style_conversion() {
390        assert_eq!(
391            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Atx),
392            "# Heading 1"
393        );
394        assert_eq!(
395            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::AtxClosed),
396            "## Heading 2 ##"
397        );
398        assert_eq!(
399            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Setext1),
400            "Heading 1\n========="
401        );
402        assert_eq!(
403            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::Setext2),
404            "Heading 2\n---------"
405        );
406    }
407
408    #[test]
409    fn test_convert_heading_style_edge_cases() {
410        // Empty text: ATX headings produce just the hash marks (valid markdown)
411        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Atx), "#");
412        assert_eq!(HeadingUtils::convert_heading_style("   ", 1, HeadingStyle::Atx), "#");
413        assert_eq!(HeadingUtils::convert_heading_style("", 2, HeadingStyle::Atx), "##");
414        assert_eq!(
415            HeadingUtils::convert_heading_style("", 1, HeadingStyle::AtxClosed),
416            "# #"
417        );
418        // Setext cannot represent empty headings, returns empty
419        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
420
421        // Level clamping
422        assert_eq!(
423            HeadingUtils::convert_heading_style("Text", 0, HeadingStyle::Atx),
424            "# Text"
425        );
426        assert_eq!(
427            HeadingUtils::convert_heading_style("Text", 10, HeadingStyle::Atx),
428            "###### Text"
429        );
430
431        // Setext with level > 2 falls back to ATX
432        assert_eq!(
433            HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
434            "### Text"
435        );
436
437        // Preserve indentation
438        assert_eq!(
439            HeadingUtils::convert_heading_style("  Text", 1, HeadingStyle::Atx),
440            "  # Text"
441        );
442
443        // Very short text for setext
444        assert_eq!(
445            HeadingUtils::convert_heading_style("Hi", 1, HeadingStyle::Setext1),
446            "Hi\n=="
447        );
448    }
449
450    #[test]
451    fn test_heading_to_fragment() {
452        assert_eq!(HeadingUtils::heading_to_fragment("Simple Heading"), "simple-heading");
453        assert_eq!(
454            HeadingUtils::heading_to_fragment("Heading with Numbers 123"),
455            "heading-with-numbers-123"
456        );
457        assert_eq!(
458            HeadingUtils::heading_to_fragment("Special!@#$%Characters"),
459            "special-characters"
460        );
461        assert_eq!(HeadingUtils::heading_to_fragment("  Trimmed  "), "trimmed");
462        assert_eq!(
463            HeadingUtils::heading_to_fragment("Multiple   Spaces"),
464            "multiple-spaces"
465        );
466        assert_eq!(
467            HeadingUtils::heading_to_fragment("Heading <em>with HTML</em>"),
468            "heading-with-html"
469        );
470        assert_eq!(
471            HeadingUtils::heading_to_fragment("---Leading-Dashes---"),
472            "leading-dashes"
473        );
474        assert_eq!(HeadingUtils::heading_to_fragment(""), "");
475    }
476
477    #[test]
478    fn test_module_level_functions() {
479        // Test is_heading
480        assert!(is_heading("# Heading"));
481        assert!(is_heading("  ## Indented"));
482        assert!(!is_heading("Not a heading"));
483        assert!(!is_heading(""));
484
485        // Test is_setext_heading_marker
486        assert!(is_setext_heading_marker("========"));
487        assert!(is_setext_heading_marker("--------"));
488        assert!(is_setext_heading_marker("  ======"));
489        assert!(!is_setext_heading_marker("# Heading"));
490        assert!(is_setext_heading_marker("---")); // Three dashes is valid
491
492        // Test get_heading_level
493        let lines = vec!["# H1", "## H2", "### H3"];
494        assert_eq!(get_heading_level(&lines, 0), 1);
495        assert_eq!(get_heading_level(&lines, 1), 2);
496        assert_eq!(get_heading_level(&lines, 2), 3);
497        assert_eq!(get_heading_level(&lines, 10), 0);
498    }
499
500    #[test]
501    fn test_heading_style_from_str() {
502        assert_eq!(HeadingStyle::from_str("atx"), Ok(HeadingStyle::Atx));
503        assert_eq!(HeadingStyle::from_str("ATX"), Ok(HeadingStyle::Atx));
504        assert_eq!(HeadingStyle::from_str("atx_closed"), Ok(HeadingStyle::AtxClosed));
505        assert_eq!(HeadingStyle::from_str("atx-closed"), Ok(HeadingStyle::AtxClosed));
506        assert_eq!(HeadingStyle::from_str("ATX-CLOSED"), Ok(HeadingStyle::AtxClosed));
507        assert_eq!(HeadingStyle::from_str("setext1"), Ok(HeadingStyle::Setext1));
508        assert_eq!(HeadingStyle::from_str("setext"), Ok(HeadingStyle::Setext1));
509        assert_eq!(HeadingStyle::from_str("setext2"), Ok(HeadingStyle::Setext2));
510        assert_eq!(HeadingStyle::from_str("consistent"), Ok(HeadingStyle::Consistent));
511        assert_eq!(
512            HeadingStyle::from_str("setext_with_atx"),
513            Ok(HeadingStyle::SetextWithAtx)
514        );
515        assert_eq!(
516            HeadingStyle::from_str("setext-with-atx"),
517            Ok(HeadingStyle::SetextWithAtx)
518        );
519        assert_eq!(
520            HeadingStyle::from_str("setext_with_atx_closed"),
521            Ok(HeadingStyle::SetextWithAtxClosed)
522        );
523        assert_eq!(
524            HeadingStyle::from_str("setext-with-atx-closed"),
525            Ok(HeadingStyle::SetextWithAtxClosed)
526        );
527        assert_eq!(HeadingStyle::from_str("invalid"), Err(()));
528    }
529
530    #[test]
531    fn test_heading_style_display() {
532        assert_eq!(HeadingStyle::Atx.to_string(), "atx");
533        assert_eq!(HeadingStyle::AtxClosed.to_string(), "atx-closed");
534        assert_eq!(HeadingStyle::Setext1.to_string(), "setext1");
535        assert_eq!(HeadingStyle::Setext2.to_string(), "setext2");
536        assert_eq!(HeadingStyle::Consistent.to_string(), "consistent");
537    }
538
539    #[test]
540    fn test_unicode_heading_fragments() {
541        assert_eq!(HeadingUtils::heading_to_fragment("你好世界"), "你好世界");
542        assert_eq!(HeadingUtils::heading_to_fragment("Café René"), "café-rené");
543    }
544    #[test]
545    fn plain_atx_serialization_preserves_literal_terminal_hashes() {
546        for style in [
547            HeadingStyle::Atx,
548            HeadingStyle::Consistent,
549            HeadingStyle::Setext1,
550            HeadingStyle::Setext2,
551            HeadingStyle::SetextWithAtx,
552        ] {
553            for (title, expected) in [
554                ("Title ###", "Title \\###"),
555                ("Title\t#", "Title\t\\#"),
556                ("#######", "\\#######"),
557                ("Title###", "Title###"),
558                ("Title \\###", "Title \\###"),
559                ("Title ### {#id}", "Title ### {#id}"),
560            ] {
561                assert_eq!(
562                    HeadingUtils::convert_heading_style(title, 3, style),
563                    format!("### {expected}"),
564                    "{style:?}: {title}"
565                );
566            }
567        }
568        assert_eq!(
569            HeadingUtils::convert_heading_style("Title ###", 1, HeadingStyle::AtxClosed),
570            "# Title ### #"
571        );
572        assert_eq!(
573            HeadingUtils::convert_heading_style("Title ###", 1, HeadingStyle::Setext1),
574            "Title ###\n========="
575        );
576    }
577}