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        // Only spaces and tabs are blank: a heading of a no-break space has text.
82        if text_content.trim_matches([' ', '\t']).is_empty() {
83            // Empty headings: ATX can be just `##`, Setext requires text so return empty
84            return match style {
85                HeadingStyle::Atx => "#".repeat(level as usize),
86                HeadingStyle::AtxClosed => {
87                    let hashes = "#".repeat(level as usize);
88                    format!("{hashes} {hashes}")
89                }
90                HeadingStyle::Setext1 | HeadingStyle::Setext2 => String::new(),
91                // These are meta-styles resolved before calling this function
92                HeadingStyle::Consistent | HeadingStyle::SetextWithAtx | HeadingStyle::SetextWithAtxClosed => {
93                    "#".repeat(level as usize)
94                }
95            };
96        }
97
98        let indentation = text_content
99            .chars()
100            .take_while(|c| matches!(c, ' ' | '\t'))
101            .collect::<String>();
102        // Only a space or tab is indentation; other whitespace is part of the text.
103        let text_content = text_content.trim_matches([' ', '\t']);
104
105        match style {
106            HeadingStyle::Atx => {
107                format!(
108                    "{}{} {}",
109                    indentation,
110                    "#".repeat(level as usize),
111                    escape_atx_closing_hashes(text_content)
112                )
113            }
114            HeadingStyle::AtxClosed => {
115                format!(
116                    "{}{} {} {}",
117                    indentation,
118                    "#".repeat(level as usize),
119                    text_content,
120                    "#".repeat(level as usize)
121                )
122            }
123            HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
124                if level > 2 {
125                    // Fall back to ATX style for levels > 2
126                    format!(
127                        "{}{} {}",
128                        indentation,
129                        "#".repeat(level as usize),
130                        escape_atx_closing_hashes(text_content)
131                    )
132                } else {
133                    let underline_char = if level == 1 || style == HeadingStyle::Setext1 {
134                        '='
135                    } else {
136                        '-'
137                    };
138                    let visible_length = text_content.chars().count();
139                    let underline_length = visible_length.max(1); // Ensure at least 1 underline char
140                    format!(
141                        "{}{}\n{}{}",
142                        indentation,
143                        text_content,
144                        indentation,
145                        underline_char.to_string().repeat(underline_length)
146                    )
147                }
148            }
149            HeadingStyle::Consistent => {
150                // For Consistent style, default to ATX as it's the most commonly used
151                format!(
152                    "{}{} {}",
153                    indentation,
154                    "#".repeat(level as usize),
155                    escape_atx_closing_hashes(text_content)
156                )
157            }
158            HeadingStyle::SetextWithAtx => {
159                if level <= 2 {
160                    // Use Setext for h1/h2
161                    let underline_char = if level == 1 { '=' } else { '-' };
162                    let visible_length = text_content.chars().count();
163                    let underline_length = visible_length.max(1);
164                    format!(
165                        "{}{}\n{}{}",
166                        indentation,
167                        text_content,
168                        indentation,
169                        underline_char.to_string().repeat(underline_length)
170                    )
171                } else {
172                    // Use ATX for h3-h6
173                    format!(
174                        "{}{} {}",
175                        indentation,
176                        "#".repeat(level as usize),
177                        escape_atx_closing_hashes(text_content)
178                    )
179                }
180            }
181            HeadingStyle::SetextWithAtxClosed => {
182                if level <= 2 {
183                    // Use Setext for h1/h2
184                    let underline_char = if level == 1 { '=' } else { '-' };
185                    let visible_length = text_content.chars().count();
186                    let underline_length = visible_length.max(1);
187                    format!(
188                        "{}{}\n{}{}",
189                        indentation,
190                        text_content,
191                        indentation,
192                        underline_char.to_string().repeat(underline_length)
193                    )
194                } else {
195                    // Use ATX closed for h3-h6
196                    format!(
197                        "{}{} {} {}",
198                        indentation,
199                        "#".repeat(level as usize),
200                        text_content,
201                        "#".repeat(level as usize)
202                    )
203                }
204            }
205        }
206    }
207
208    /// Convert a heading text to a valid ID for fragment links
209    pub fn heading_to_fragment(text: &str) -> String {
210        // Remove any HTML tags
211        let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
212
213        // Convert to lowercase and trim
214        let text_lower = text_no_html.trim().to_lowercase();
215
216        // Replace spaces and punctuation with hyphens
217        let text_with_hyphens = text_lower
218            .chars()
219            .map(|c| if c.is_alphanumeric() { c } else { '-' })
220            .collect::<String>();
221
222        // Replace multiple consecutive hyphens with a single hyphen
223        let text_clean = text_with_hyphens
224            .split('-')
225            .filter(|s| !s.is_empty())
226            .collect::<Vec<_>>()
227            .join("-");
228
229        // Remove leading and trailing hyphens
230        text_clean.trim_matches('-').to_string()
231    }
232}
233
234/// Setext-to-ATX conversion joins text lines. That must not alter literal
235/// values or discard a hard break that belongs to the rendered heading.
236pub(crate) fn setext_to_atx_is_safe(
237    ctx: &crate::lint_context::LintContext,
238    heading_idx: usize,
239    heading: &crate::lint_context::HeadingInfo,
240) -> bool {
241    if !matches!(
242        heading.style,
243        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
244    ) {
245        return true;
246    }
247    let first_idx = heading_idx + 1 - heading.text_lines;
248    let tags = ctx.html_tags();
249    let inside_literal = |pos| {
250        let idx = tags.partition_point(|tag| tag.byte_offset < pos);
251        ctx.is_inside_template_code(pos) || ctx.is_inside_mdx_code(pos) || (idx > 0 && tags[idx - 1].byte_end > pos)
252    };
253    let first = &ctx.lines[first_idx];
254    if inside_literal(first.byte_offset + first.indent) {
255        return false;
256    }
257    let joined_lines = &ctx.lines[first_idx..heading_idx];
258    let title_crosses_join = |start: usize, end: usize, title: Option<&str>| {
259        if !title.is_some_and(|title| title.contains(['\n', '\r'])) {
260            return false;
261        }
262        let idx = joined_lines.partition_point(|line| line.byte_offset + line.byte_len <= start);
263        joined_lines
264            .get(idx)
265            .is_some_and(|line| line.byte_offset + line.byte_len < end)
266    };
267    // Inline title newlines are attribute data. Reference titles live in an
268    // untouched definition, and ordinary label soft breaks remain joinable.
269    if !joined_lines.is_empty()
270        && (ctx.links().iter().any(|link| {
271            link.link_type == pulldown_cmark::LinkType::Inline
272                && title_crosses_join(link.byte_offset, link.byte_end, link.title.as_deref())
273        }) || ctx.images().iter().any(|image| {
274            image.link_type == pulldown_cmark::LinkType::Inline
275                && title_crosses_join(image.byte_offset, image.byte_end, image.title.as_deref())
276        }))
277    {
278        // Decoded newlines can also come from entities in an unchanged title.
279        // Reparse only these rare candidates with the same Markdown flavor.
280        let converted = HeadingUtils::convert_heading_style(&heading.raw_text, heading.level as u32, HeadingStyle::Atx);
281        let converted_ctx = crate::lint_context::LintContext::new(&converted, ctx.flavor, None);
282        if converted_ctx.valid_headings().count() != 1 {
283            return false;
284        }
285        let text_start = first.byte_offset;
286        let text_end = ctx.lines[heading_idx].byte_offset + ctx.lines[heading_idx].byte_len;
287        let expected_links = ctx
288            .links()
289            .iter()
290            .filter(|link| {
291                link.link_type == pulldown_cmark::LinkType::Inline
292                    && link.byte_offset >= text_start
293                    && link.byte_end <= text_end
294            })
295            .map(|link| link.title.as_deref().unwrap_or(""));
296        let actual_links = converted_ctx
297            .links()
298            .iter()
299            .filter(|link| link.link_type == pulldown_cmark::LinkType::Inline)
300            .map(|link| link.title.as_deref().unwrap_or(""));
301        let expected_images = ctx
302            .images()
303            .iter()
304            .filter(|image| {
305                image.link_type == pulldown_cmark::LinkType::Inline
306                    && image.byte_offset >= text_start
307                    && image.byte_end <= text_end
308            })
309            .map(|image| image.title.as_deref().unwrap_or(""));
310        let actual_images = converted_ctx
311            .images()
312            .iter()
313            .filter(|image| image.link_type == pulldown_cmark::LinkType::Inline)
314            .map(|image| image.title.as_deref().unwrap_or(""));
315        if !expected_links.eq(actual_links) || !expected_images.eq(actual_images) {
316            return false;
317        }
318    }
319    (first_idx..heading_idx).all(|idx| {
320        let line = &ctx.lines[idx];
321        let content = line.content(ctx.content);
322        let boundary = line.byte_offset + line.byte_len;
323        !inside_literal(boundary)
324            && !ctx.line_ends_with_hard_break(idx + 1)
325            && !content.ends_with("  ")
326            && !(ctx.is_in_code_span_byte(boundary)
327                && (content.ends_with([' ', '\t']) || ctx.lines[idx + 1].content(ctx.content).starts_with([' ', '\t'])))
328    })
329}
330
331/// ATX-to-setext conversion must leave the document's heading structure intact.
332///
333/// `candidate` is the exact replacement text (text line, newline, underline,
334/// both carrying the heading's indentation). The conversion is safe when:
335///
336/// - the heading has text (setext cannot express an empty heading),
337/// - the line above cannot be continued by the new text line (a setext heading
338///   cannot interrupt a paragraph the way an ATX heading can, so a paragraph,
339///   list item or table row above would absorb the text into itself),
340/// - the candidate, parsed alone with the active flavor, is exactly one
341///   heading and no other block construct (list, quote,
342///   thematic break, fence, HTML, reference definition, flavor containers).
343pub(crate) fn atx_to_setext_is_safe(
344    ctx: &crate::lint_context::LintContext,
345    heading_idx: usize,
346    heading: &crate::lint_context::HeadingInfo,
347    candidate: &str,
348) -> bool {
349    if heading.raw_text.trim_matches([' ', '\t']).is_empty() {
350        return false;
351    }
352    // A line opening a delimited block (display math, TOML front matter) only
353    // takes effect once a closing delimiter exists somewhere below, which the
354    // isolated candidate cannot see.
355    let text = heading.raw_text.trim_start_matches([' ', '\t']);
356    if text.starts_with("$$") || text.starts_with("+++") {
357        return false;
358    }
359    if heading_idx > 0 {
360        let above = &ctx.lines[heading_idx - 1];
361        let above_text = above.content(ctx.content).trim_start();
362        let closes_fence = above.in_code_block
363            && above.indent < 4
364            && (above_text.starts_with("```") || above_text.starts_with("~~~"))
365            && above_text.trim_start_matches(['`', '~']).trim().is_empty();
366        let above_is_inert = above.is_blank
367            || above.heading.is_some()
368            || (above.is_horizontal_rule && !above.is_setext_heading_text)
369            || closes_fence
370            || above.in_front_matter
371            || (heading_idx > 1
372                && ctx.lines[heading_idx - 2].heading.as_ref().is_some_and(|h| {
373                    matches!(
374                        h.style,
375                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
376                    )
377                }));
378        if !above_is_inert {
379            return false;
380        }
381    }
382
383    let candidate_ctx = crate::lint_context::LintContext::new(candidate, ctx.flavor, None);
384    if candidate_ctx.lines.len() != 2 || candidate_ctx.valid_headings().count() != 1 {
385        return false;
386    }
387    let Some(parsed) = candidate_ctx.lines[0].heading.as_ref() else {
388        return false;
389    };
390    let is_setext = matches!(
391        parsed.style,
392        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
393    );
394    if !is_setext || parsed.text_lines != 1 {
395        return false;
396    }
397    candidate_ctx.lines.iter().all(|l| {
398        !(l.in_code_block
399            || l.in_front_matter
400            || l.in_html_block
401            || l.in_html_comment
402            || l.in_math_block
403            || l.in_pandoc_div
404            || (l.is_div_marker && ctx.flavor.is_pandoc_compatible())
405            || l.is_container_marker
406            || l.in_admonition
407            || l.in_content_tab
408            || l.in_mkdocs_html_markdown
409            || l.in_pymdown_block
410            || l.in_kramdown_extension_block
411            || l.is_kramdown_block_ial
412            || l.in_mkdocstrings
413            || l.in_esm_block
414            || l.in_jsx_expression
415            || l.in_jsx_block
416            || l.in_mdx_comment
417            || l.in_obsidian_comment
418            || l.in_footnote_definition
419            || l.in_myst_directive
420            || l.is_myst_comment
421            || l.in_definition_list
422            || l.in_list_block
423            || l.in_table_block
424            || l.list_item.is_some()
425            || l.blockquote.is_some())
426    })
427}
428
429/// Checks if a line is a heading
430#[inline]
431pub fn is_heading(line: &str) -> bool {
432    // Fast path checks first
433    let trimmed = line.trim();
434    if trimmed.is_empty() {
435        return false;
436    }
437
438    if trimmed.starts_with('#') {
439        // Check for ATX heading
440        ATX_PATTERN.is_match(line)
441    } else {
442        // We can't tell for setext headings without looking at the next line
443        false
444    }
445}
446
447/// Checks if a line is a setext heading marker
448#[inline]
449pub fn is_setext_heading_marker(line: &str) -> bool {
450    SETEXT_HEADING_1.is_match(line) || SETEXT_HEADING_2.is_match(line)
451}
452
453/// Get the heading level for a line
454#[inline]
455pub fn get_heading_level(lines: &[&str], index: usize) -> u32 {
456    if index >= lines.len() {
457        return 0;
458    }
459
460    let line = lines[index];
461
462    // Check for ATX style heading
463    if let Some(captures) = ATX_PATTERN.captures(line) {
464        let hashes = captures.get(2).map_or("", |m| m.as_str());
465        return hashes.len() as u32;
466    }
467
468    // Check for setext style heading
469    if index < lines.len() - 1 {
470        let next_line = lines[index + 1];
471
472        if SETEXT_HEADING_1.is_match(next_line) {
473            return 1;
474        }
475
476        if SETEXT_HEADING_2.is_match(next_line) {
477            return 2;
478        }
479    }
480
481    0
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_heading_style_conversion() {
490        assert_eq!(
491            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Atx),
492            "# Heading 1"
493        );
494        assert_eq!(
495            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::AtxClosed),
496            "## Heading 2 ##"
497        );
498        assert_eq!(
499            HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Setext1),
500            "Heading 1\n========="
501        );
502        assert_eq!(
503            HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::Setext2),
504            "Heading 2\n---------"
505        );
506    }
507
508    #[test]
509    fn test_convert_heading_style_edge_cases() {
510        // Empty text: ATX headings produce just the hash marks (valid markdown)
511        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Atx), "#");
512        assert_eq!(HeadingUtils::convert_heading_style("   ", 1, HeadingStyle::Atx), "#");
513        assert_eq!(HeadingUtils::convert_heading_style("", 2, HeadingStyle::Atx), "##");
514        assert_eq!(
515            HeadingUtils::convert_heading_style("", 1, HeadingStyle::AtxClosed),
516            "# #"
517        );
518        // Setext cannot represent empty headings, returns empty
519        assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
520
521        // Level clamping
522        assert_eq!(
523            HeadingUtils::convert_heading_style("Text", 0, HeadingStyle::Atx),
524            "# Text"
525        );
526        assert_eq!(
527            HeadingUtils::convert_heading_style("Text", 10, HeadingStyle::Atx),
528            "###### Text"
529        );
530
531        // Setext with level > 2 falls back to ATX
532        assert_eq!(
533            HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
534            "### Text"
535        );
536
537        // Preserve indentation
538        assert_eq!(
539            HeadingUtils::convert_heading_style("  Text", 1, HeadingStyle::Atx),
540            "  # Text"
541        );
542
543        // Very short text for setext
544        assert_eq!(
545            HeadingUtils::convert_heading_style("Hi", 1, HeadingStyle::Setext1),
546            "Hi\n=="
547        );
548    }
549
550    #[test]
551    fn test_heading_to_fragment() {
552        assert_eq!(HeadingUtils::heading_to_fragment("Simple Heading"), "simple-heading");
553        assert_eq!(
554            HeadingUtils::heading_to_fragment("Heading with Numbers 123"),
555            "heading-with-numbers-123"
556        );
557        assert_eq!(
558            HeadingUtils::heading_to_fragment("Special!@#$%Characters"),
559            "special-characters"
560        );
561        assert_eq!(HeadingUtils::heading_to_fragment("  Trimmed  "), "trimmed");
562        assert_eq!(
563            HeadingUtils::heading_to_fragment("Multiple   Spaces"),
564            "multiple-spaces"
565        );
566        assert_eq!(
567            HeadingUtils::heading_to_fragment("Heading <em>with HTML</em>"),
568            "heading-with-html"
569        );
570        assert_eq!(
571            HeadingUtils::heading_to_fragment("---Leading-Dashes---"),
572            "leading-dashes"
573        );
574        assert_eq!(HeadingUtils::heading_to_fragment(""), "");
575    }
576
577    #[test]
578    fn test_module_level_functions() {
579        // Test is_heading
580        assert!(is_heading("# Heading"));
581        assert!(is_heading("  ## Indented"));
582        assert!(!is_heading("Not a heading"));
583        assert!(!is_heading(""));
584
585        // Test is_setext_heading_marker
586        assert!(is_setext_heading_marker("========"));
587        assert!(is_setext_heading_marker("--------"));
588        assert!(is_setext_heading_marker("  ======"));
589        assert!(!is_setext_heading_marker("# Heading"));
590        assert!(is_setext_heading_marker("---")); // Three dashes is valid
591
592        // Test get_heading_level
593        let lines = vec!["# H1", "## H2", "### H3"];
594        assert_eq!(get_heading_level(&lines, 0), 1);
595        assert_eq!(get_heading_level(&lines, 1), 2);
596        assert_eq!(get_heading_level(&lines, 2), 3);
597        assert_eq!(get_heading_level(&lines, 10), 0);
598    }
599
600    #[test]
601    fn test_heading_style_from_str() {
602        assert_eq!(HeadingStyle::from_str("atx"), Ok(HeadingStyle::Atx));
603        assert_eq!(HeadingStyle::from_str("ATX"), Ok(HeadingStyle::Atx));
604        assert_eq!(HeadingStyle::from_str("atx_closed"), Ok(HeadingStyle::AtxClosed));
605        assert_eq!(HeadingStyle::from_str("atx-closed"), Ok(HeadingStyle::AtxClosed));
606        assert_eq!(HeadingStyle::from_str("ATX-CLOSED"), Ok(HeadingStyle::AtxClosed));
607        assert_eq!(HeadingStyle::from_str("setext1"), Ok(HeadingStyle::Setext1));
608        assert_eq!(HeadingStyle::from_str("setext"), Ok(HeadingStyle::Setext1));
609        assert_eq!(HeadingStyle::from_str("setext2"), Ok(HeadingStyle::Setext2));
610        assert_eq!(HeadingStyle::from_str("consistent"), Ok(HeadingStyle::Consistent));
611        assert_eq!(
612            HeadingStyle::from_str("setext_with_atx"),
613            Ok(HeadingStyle::SetextWithAtx)
614        );
615        assert_eq!(
616            HeadingStyle::from_str("setext-with-atx"),
617            Ok(HeadingStyle::SetextWithAtx)
618        );
619        assert_eq!(
620            HeadingStyle::from_str("setext_with_atx_closed"),
621            Ok(HeadingStyle::SetextWithAtxClosed)
622        );
623        assert_eq!(
624            HeadingStyle::from_str("setext-with-atx-closed"),
625            Ok(HeadingStyle::SetextWithAtxClosed)
626        );
627        assert_eq!(HeadingStyle::from_str("invalid"), Err(()));
628    }
629
630    #[test]
631    fn test_heading_style_display() {
632        assert_eq!(HeadingStyle::Atx.to_string(), "atx");
633        assert_eq!(HeadingStyle::AtxClosed.to_string(), "atx-closed");
634        assert_eq!(HeadingStyle::Setext1.to_string(), "setext1");
635        assert_eq!(HeadingStyle::Setext2.to_string(), "setext2");
636        assert_eq!(HeadingStyle::Consistent.to_string(), "consistent");
637    }
638
639    #[test]
640    fn test_unicode_heading_fragments() {
641        assert_eq!(HeadingUtils::heading_to_fragment("你好世界"), "你好世界");
642        assert_eq!(HeadingUtils::heading_to_fragment("Café René"), "café-rené");
643    }
644    #[test]
645    fn plain_atx_serialization_preserves_literal_terminal_hashes() {
646        for style in [
647            HeadingStyle::Atx,
648            HeadingStyle::Consistent,
649            HeadingStyle::Setext1,
650            HeadingStyle::Setext2,
651            HeadingStyle::SetextWithAtx,
652        ] {
653            for (title, expected) in [
654                ("Title ###", "Title \\###"),
655                ("Title\t#", "Title\t\\#"),
656                ("#######", "\\#######"),
657                ("Title###", "Title###"),
658                ("Title \\###", "Title \\###"),
659                ("Title ### {#id}", "Title ### {#id}"),
660            ] {
661                assert_eq!(
662                    HeadingUtils::convert_heading_style(title, 3, style),
663                    format!("### {expected}"),
664                    "{style:?}: {title}"
665                );
666            }
667        }
668        assert_eq!(
669            HeadingUtils::convert_heading_style("Title ###", 1, HeadingStyle::AtxClosed),
670            "# Title ### #"
671        );
672        assert_eq!(
673            HeadingUtils::convert_heading_style("Title ###", 1, HeadingStyle::Setext1),
674            "Title ###\n========="
675        );
676    }
677}