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#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)]
14pub enum HeadingStyle {
15 Atx, AtxClosed, Setext1, Setext2, Consistent, SetextWithAtx, SetextWithAtxClosed, }
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
58pub(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
72pub struct HeadingUtils;
74
75impl HeadingUtils {
76 pub fn convert_heading_style(text_content: &str, level: u32, style: HeadingStyle) -> String {
78 let level = level.clamp(1, 6);
80
81 if text_content.trim_matches([' ', '\t']).is_empty() {
83 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 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 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 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); 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 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 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 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 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 format!(
197 "{}{} {} {}",
198 indentation,
199 "#".repeat(level as usize),
200 text_content,
201 "#".repeat(level as usize)
202 )
203 }
204 }
205 }
206 }
207
208 pub fn heading_to_fragment(text: &str) -> String {
210 let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
212
213 let text_lower = text_no_html.trim().to_lowercase();
215
216 let text_with_hyphens = text_lower
218 .chars()
219 .map(|c| if c.is_alphanumeric() { c } else { '-' })
220 .collect::<String>();
221
222 let text_clean = text_with_hyphens
224 .split('-')
225 .filter(|s| !s.is_empty())
226 .collect::<Vec<_>>()
227 .join("-");
228
229 text_clean.trim_matches('-').to_string()
231 }
232}
233
234pub(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 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 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
331pub(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 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#[inline]
431pub fn is_heading(line: &str) -> bool {
432 let trimmed = line.trim();
434 if trimmed.is_empty() {
435 return false;
436 }
437
438 if trimmed.starts_with('#') {
439 ATX_PATTERN.is_match(line)
441 } else {
442 false
444 }
445}
446
447#[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#[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 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 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 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 assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
520
521 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 assert_eq!(
533 HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
534 "### Text"
535 );
536
537 assert_eq!(
539 HeadingUtils::convert_heading_style(" Text", 1, HeadingStyle::Atx),
540 " # Text"
541 );
542
543 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 assert!(is_heading("# Heading"));
581 assert!(is_heading(" ## Indented"));
582 assert!(!is_heading("Not a heading"));
583 assert!(!is_heading(""));
584
585 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("---")); 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}