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().is_empty() {
82 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 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 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); 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 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 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 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 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 format!(
195 "{}{} {} {}",
196 indentation,
197 "#".repeat(level as usize),
198 text_content,
199 "#".repeat(level as usize)
200 )
201 }
202 }
203 }
204 }
205
206 pub fn heading_to_fragment(text: &str) -> String {
208 let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
210
211 let text_lower = text_no_html.trim().to_lowercase();
213
214 let text_with_hyphens = text_lower
216 .chars()
217 .map(|c| if c.is_alphanumeric() { c } else { '-' })
218 .collect::<String>();
219
220 let text_clean = text_with_hyphens
222 .split('-')
223 .filter(|s| !s.is_empty())
224 .collect::<Vec<_>>()
225 .join("-");
226
227 text_clean.trim_matches('-').to_string()
229 }
230}
231
232pub(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 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 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#[inline]
331pub fn is_heading(line: &str) -> bool {
332 let trimmed = line.trim();
334 if trimmed.is_empty() {
335 return false;
336 }
337
338 if trimmed.starts_with('#') {
339 ATX_PATTERN.is_match(line)
341 } else {
342 false
344 }
345}
346
347#[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#[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 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 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 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 assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
420
421 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 assert_eq!(
433 HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
434 "### Text"
435 );
436
437 assert_eq!(
439 HeadingUtils::convert_heading_style(" Text", 1, HeadingStyle::Atx),
440 " # Text"
441 );
442
443 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 assert!(is_heading("# Heading"));
481 assert!(is_heading(" ## Indented"));
482 assert!(!is_heading("Not a heading"));
483 assert!(!is_heading(""));
484
485 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("---")); 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}