1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
6use crate::rules::front_matter_utils::FrontMatterUtils;
7use crate::utils::range_utils::calculate_line_range;
8use crate::utils::regex_cache::HTML_HEADING_PATTERN;
9use regex::Regex;
10
11#[derive(Clone)]
16pub struct MD041FirstLineHeading {
17 pub level: usize,
18 pub front_matter_title: bool,
19 pub front_matter_title_pattern: Option<Regex>,
20}
21
22impl Default for MD041FirstLineHeading {
23 fn default() -> Self {
24 Self {
25 level: 1,
26 front_matter_title: true,
27 front_matter_title_pattern: None,
28 }
29 }
30}
31
32impl MD041FirstLineHeading {
33 pub fn new(level: usize, front_matter_title: bool) -> Self {
34 Self {
35 level,
36 front_matter_title,
37 front_matter_title_pattern: None,
38 }
39 }
40
41 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>) -> Self {
42 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
43 Ok(regex) => Some(regex),
44 Err(e) => {
45 log::warn!("Invalid front_matter_title_pattern regex: {e}");
46 None
47 }
48 });
49
50 Self {
51 level,
52 front_matter_title,
53 front_matter_title_pattern,
54 }
55 }
56
57 fn has_front_matter_title(&self, content: &str) -> bool {
58 if !self.front_matter_title {
59 return false;
60 }
61
62 if let Some(ref pattern) = self.front_matter_title_pattern {
64 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
65 for line in front_matter_lines {
66 if pattern.is_match(line) {
67 return true;
68 }
69 }
70 return false;
71 }
72
73 FrontMatterUtils::has_front_matter_field(content, "title:")
75 }
76
77 fn is_non_content_line(line: &str) -> bool {
79 let trimmed = line.trim();
80
81 if trimmed.starts_with('[') && trimmed.contains("]: ") {
83 return true;
84 }
85
86 if trimmed.starts_with('*') && trimmed.contains("]: ") {
88 return true;
89 }
90
91 if Self::is_badge_image_line(trimmed) {
94 return true;
95 }
96
97 false
98 }
99
100 fn is_badge_image_line(line: &str) -> bool {
106 if line.is_empty() {
107 return false;
108 }
109
110 if !line.starts_with('!') && !line.starts_with('[') {
112 return false;
113 }
114
115 let mut remaining = line;
117 while !remaining.is_empty() {
118 remaining = remaining.trim_start();
119 if remaining.is_empty() {
120 break;
121 }
122
123 if remaining.starts_with("[![") {
125 if let Some(end) = Self::find_linked_image_end(remaining) {
126 remaining = &remaining[end..];
127 continue;
128 }
129 return false;
130 }
131
132 if remaining.starts_with("![") {
134 if let Some(end) = Self::find_image_end(remaining) {
135 remaining = &remaining[end..];
136 continue;
137 }
138 return false;
139 }
140
141 return false;
143 }
144
145 true
146 }
147
148 fn find_image_end(s: &str) -> Option<usize> {
150 if !s.starts_with("![") {
151 return None;
152 }
153 let alt_end = s[2..].find("](")?;
155 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
158 Some(paren_start + paren_end + 1)
159 }
160
161 fn find_linked_image_end(s: &str) -> Option<usize> {
163 if !s.starts_with("[![") {
164 return None;
165 }
166 let inner_end = Self::find_image_end(&s[1..])?;
168 let after_inner = 1 + inner_end;
169 if !s[after_inner..].starts_with("](") {
171 return None;
172 }
173 let link_start = after_inner + 2;
174 let link_end = s[link_start..].find(')')?;
175 Some(link_start + link_end + 1)
176 }
177
178 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
180 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
182 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
183 && let Some(h_level) = captures.get(1)
184 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
185 {
186 return true;
187 }
188
189 let html_tags = ctx.html_tags();
191 let target_tag = format!("h{level}");
192
193 let opening_index = html_tags.iter().position(|tag| {
195 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
197 && !tag.is_closing
198 });
199
200 let Some(open_idx) = opening_index else {
201 return false;
202 };
203
204 let mut depth = 1usize;
207 for tag in html_tags.iter().skip(open_idx + 1) {
208 if tag.line <= first_line_idx + 1 {
210 continue;
211 }
212
213 if tag.tag_name == target_tag {
214 if tag.is_closing {
215 depth -= 1;
216 if depth == 0 {
217 return true;
218 }
219 } else if !tag.is_self_closing {
220 depth += 1;
221 }
222 }
223 }
224
225 false
226 }
227}
228
229impl Rule for MD041FirstLineHeading {
230 fn name(&self) -> &'static str {
231 "MD041"
232 }
233
234 fn description(&self) -> &'static str {
235 "First line in file should be a top level heading"
236 }
237
238 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
239 let mut warnings = Vec::new();
240
241 if self.should_skip(ctx) {
243 return Ok(warnings);
244 }
245
246 let mut first_content_line_num = None;
248 let mut skip_lines = 0;
249
250 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
252 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
254 if line_info.content(ctx.content).trim() == "---" {
255 skip_lines = idx + 1;
256 break;
257 }
258 }
259 }
260
261 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
262 let line_content = line_info.content(ctx.content).trim();
263 if line_info.in_esm_block {
265 continue;
266 }
267 if line_info.in_html_comment {
269 continue;
270 }
271 if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
272 first_content_line_num = Some(line_num);
273 break;
274 }
275 }
276
277 if first_content_line_num.is_none() {
278 return Ok(warnings);
280 }
281
282 let first_line_idx = first_content_line_num.unwrap();
283
284 let first_line_info = &ctx.lines[first_line_idx];
286 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
287 heading.level as usize == self.level
288 } else {
289 Self::is_html_heading(ctx, first_line_idx, self.level)
291 };
292
293 if !is_correct_heading {
294 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
297 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
298
299 warnings.push(LintWarning {
300 rule_name: Some(self.name().to_string()),
301 line: start_line,
302 column: start_col,
303 end_line,
304 end_column: end_col,
305 message: format!("First line in file should be a level {} heading", self.level),
306 severity: Severity::Warning,
307 fix: None, });
309 }
310 Ok(warnings)
311 }
312
313 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
314 Ok(ctx.content.to_string())
317 }
318
319 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
321 let only_directives = !ctx.content.is_empty()
326 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
327 let t = l.trim();
328 (t.starts_with("{{#") && t.ends_with("}}"))
330 || (t.starts_with("<!--") && t.ends_with("-->"))
332 });
333
334 ctx.content.is_empty()
335 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
336 || only_directives
337 }
338
339 fn as_any(&self) -> &dyn std::any::Any {
340 self
341 }
342
343 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
344 where
345 Self: Sized,
346 {
347 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
349
350 let use_front_matter = !md041_config.front_matter_title.is_empty();
351
352 Box::new(MD041FirstLineHeading::with_pattern(
353 md041_config.level.as_usize(),
354 use_front_matter,
355 md041_config.front_matter_title_pattern,
356 ))
357 }
358
359 fn default_config_section(&self) -> Option<(String, toml::Value)> {
360 Some((
361 "MD041".to_string(),
362 toml::toml! {
363 level = 1
364 front-matter-title = "title"
365 front-matter-title-pattern = ""
366 }
367 .into(),
368 ))
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::lint_context::LintContext;
376
377 #[test]
378 fn test_first_line_is_heading_correct_level() {
379 let rule = MD041FirstLineHeading::default();
380
381 let content = "# My Document\n\nSome content here.";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384 let result = rule.check(&ctx).unwrap();
385 assert!(
386 result.is_empty(),
387 "Expected no warnings when first line is a level 1 heading"
388 );
389 }
390
391 #[test]
392 fn test_first_line_is_heading_wrong_level() {
393 let rule = MD041FirstLineHeading::default();
394
395 let content = "## My Document\n\nSome content here.";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert_eq!(result.len(), 1);
400 assert_eq!(result[0].line, 1);
401 assert!(result[0].message.contains("level 1 heading"));
402 }
403
404 #[test]
405 fn test_first_line_not_heading() {
406 let rule = MD041FirstLineHeading::default();
407
408 let content = "This is not a heading\n\n# This is a heading";
410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411 let result = rule.check(&ctx).unwrap();
412 assert_eq!(result.len(), 1);
413 assert_eq!(result[0].line, 1);
414 assert!(result[0].message.contains("level 1 heading"));
415 }
416
417 #[test]
418 fn test_empty_lines_before_heading() {
419 let rule = MD041FirstLineHeading::default();
420
421 let content = "\n\n# My Document\n\nSome content.";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let result = rule.check(&ctx).unwrap();
425 assert!(
426 result.is_empty(),
427 "Expected no warnings when empty lines precede a valid heading"
428 );
429
430 let content = "\n\nNot a heading\n\nSome content.";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert_eq!(result.len(), 1);
435 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
437 }
438
439 #[test]
440 fn test_front_matter_with_title() {
441 let rule = MD041FirstLineHeading::new(1, true);
442
443 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert!(
448 result.is_empty(),
449 "Expected no warnings when front matter has title field"
450 );
451 }
452
453 #[test]
454 fn test_front_matter_without_title() {
455 let rule = MD041FirstLineHeading::new(1, true);
456
457 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert_eq!(result.len(), 1);
462 assert_eq!(result[0].line, 6); }
464
465 #[test]
466 fn test_front_matter_disabled() {
467 let rule = MD041FirstLineHeading::new(1, false);
468
469 let content = "---\ntitle: My Document\n---\n\nSome content here.";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert_eq!(result.len(), 1);
474 assert_eq!(result[0].line, 5); }
476
477 #[test]
478 fn test_html_comments_before_heading() {
479 let rule = MD041FirstLineHeading::default();
480
481 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485 assert!(
486 result.is_empty(),
487 "HTML comments should be skipped when checking for first heading"
488 );
489 }
490
491 #[test]
492 fn test_multiline_html_comment_before_heading() {
493 let rule = MD041FirstLineHeading::default();
494
495 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let result = rule.check(&ctx).unwrap();
499 assert!(
500 result.is_empty(),
501 "Multi-line HTML comments should be skipped when checking for first heading"
502 );
503 }
504
505 #[test]
506 fn test_html_comment_with_blank_lines_before_heading() {
507 let rule = MD041FirstLineHeading::default();
508
509 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(
514 result.is_empty(),
515 "HTML comments with blank lines should be skipped when checking for first heading"
516 );
517 }
518
519 #[test]
520 fn test_html_comment_before_html_heading() {
521 let rule = MD041FirstLineHeading::default();
522
523 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert!(
528 result.is_empty(),
529 "HTML comments should be skipped before HTML headings"
530 );
531 }
532
533 #[test]
534 fn test_document_with_only_html_comments() {
535 let rule = MD041FirstLineHeading::default();
536
537 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx).unwrap();
541 assert!(
542 result.is_empty(),
543 "Documents with only HTML comments should not trigger MD041"
544 );
545 }
546
547 #[test]
548 fn test_html_comment_followed_by_non_heading() {
549 let rule = MD041FirstLineHeading::default();
550
551 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555 assert_eq!(
556 result.len(),
557 1,
558 "HTML comment followed by non-heading should still trigger MD041"
559 );
560 assert_eq!(
561 result[0].line, 2,
562 "Warning should be on the first non-comment, non-heading line"
563 );
564 }
565
566 #[test]
567 fn test_multiple_html_comments_before_heading() {
568 let rule = MD041FirstLineHeading::default();
569
570 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert!(
575 result.is_empty(),
576 "Multiple HTML comments should all be skipped before heading"
577 );
578 }
579
580 #[test]
581 fn test_html_comment_with_wrong_level_heading() {
582 let rule = MD041FirstLineHeading::default();
583
584 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(
589 result.len(),
590 1,
591 "HTML comment followed by wrong-level heading should still trigger MD041"
592 );
593 assert!(
594 result[0].message.contains("level 1 heading"),
595 "Should require level 1 heading"
596 );
597 }
598
599 #[test]
600 fn test_html_comment_mixed_with_reference_definitions() {
601 let rule = MD041FirstLineHeading::default();
602
603 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(
608 result.is_empty(),
609 "HTML comments and reference definitions should both be skipped before heading"
610 );
611 }
612
613 #[test]
614 fn test_html_comment_after_front_matter() {
615 let rule = MD041FirstLineHeading::default();
616
617 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert!(
622 result.is_empty(),
623 "HTML comments after front matter should be skipped before heading"
624 );
625 }
626
627 #[test]
628 fn test_html_comment_not_at_start_should_not_affect_rule() {
629 let rule = MD041FirstLineHeading::default();
630
631 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635 assert!(
636 result.is_empty(),
637 "HTML comments in middle of document should not affect MD041 (only first content matters)"
638 );
639 }
640
641 #[test]
642 fn test_multiline_html_comment_followed_by_non_heading() {
643 let rule = MD041FirstLineHeading::default();
644
645 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert_eq!(
650 result.len(),
651 1,
652 "Multi-line HTML comment followed by non-heading should still trigger MD041"
653 );
654 assert_eq!(
655 result[0].line, 5,
656 "Warning should be on the first non-comment, non-heading line"
657 );
658 }
659
660 #[test]
661 fn test_different_heading_levels() {
662 let rule = MD041FirstLineHeading::new(2, false);
664
665 let content = "## Second Level Heading\n\nContent.";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
669
670 let content = "# First Level Heading\n\nContent.";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert_eq!(result.len(), 1);
675 assert!(result[0].message.contains("level 2 heading"));
676 }
677
678 #[test]
679 fn test_setext_headings() {
680 let rule = MD041FirstLineHeading::default();
681
682 let content = "My Document\n===========\n\nContent.";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
687
688 let content = "My Document\n-----------\n\nContent.";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
693 assert!(result[0].message.contains("level 1 heading"));
694 }
695
696 #[test]
697 fn test_empty_document() {
698 let rule = MD041FirstLineHeading::default();
699
700 let content = "";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert!(result.is_empty(), "Expected no warnings for empty document");
705 }
706
707 #[test]
708 fn test_whitespace_only_document() {
709 let rule = MD041FirstLineHeading::default();
710
711 let content = " \n\n \t\n";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
716 }
717
718 #[test]
719 fn test_front_matter_then_whitespace() {
720 let rule = MD041FirstLineHeading::default();
721
722 let content = "---\ntitle: Test\n---\n\n \n\n";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert!(
727 result.is_empty(),
728 "Expected no warnings when no content after front matter"
729 );
730 }
731
732 #[test]
733 fn test_multiple_front_matter_types() {
734 let rule = MD041FirstLineHeading::new(1, true);
735
736 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert_eq!(result.len(), 1);
741 assert!(result[0].message.contains("level 1 heading"));
742
743 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 1);
748 assert!(result[0].message.contains("level 1 heading"));
749
750 let content = "---\ntitle: My Document\n---\n\nContent.";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert!(
755 result.is_empty(),
756 "Expected no warnings for YAML front matter with title"
757 );
758
759 let content = "+++\ntitle: My Document\n+++\n\nContent.";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
764 }
765
766 #[test]
767 fn test_malformed_front_matter() {
768 let rule = MD041FirstLineHeading::new(1, true);
769
770 let content = "- --\ntitle: My Document\n- --\n\nContent.";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(
775 result.is_empty(),
776 "Expected no warnings for malformed front matter with title"
777 );
778 }
779
780 #[test]
781 fn test_front_matter_with_heading() {
782 let rule = MD041FirstLineHeading::default();
783
784 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let result = rule.check(&ctx).unwrap();
788 assert!(
789 result.is_empty(),
790 "Expected no warnings when first line after front matter is correct heading"
791 );
792 }
793
794 #[test]
795 fn test_no_fix_suggestion() {
796 let rule = MD041FirstLineHeading::default();
797
798 let content = "Not a heading\n\nContent.";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert_eq!(result.len(), 1);
803 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
804 }
805
806 #[test]
807 fn test_complex_document_structure() {
808 let rule = MD041FirstLineHeading::default();
809
810 let content =
812 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(
816 result.is_empty(),
817 "HTML comments should be skipped, so first heading after comment should be valid"
818 );
819 }
820
821 #[test]
822 fn test_heading_with_special_characters() {
823 let rule = MD041FirstLineHeading::default();
824
825 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(
830 result.is_empty(),
831 "Expected no warnings for heading with inline formatting"
832 );
833 }
834
835 #[test]
836 fn test_level_configuration() {
837 for level in 1..=6 {
839 let rule = MD041FirstLineHeading::new(level, false);
840
841 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
843 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert!(
846 result.is_empty(),
847 "Expected no warnings for correct level {level} heading"
848 );
849
850 let wrong_level = if level == 1 { 2 } else { 1 };
852 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
853 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert_eq!(result.len(), 1);
856 assert!(result[0].message.contains(&format!("level {level} heading")));
857 }
858 }
859
860 #[test]
861 fn test_issue_152_multiline_html_heading() {
862 let rule = MD041FirstLineHeading::default();
863
864 let content = "<h1>\nSome text\n</h1>";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert!(
869 result.is_empty(),
870 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
871 );
872 }
873
874 #[test]
875 fn test_multiline_html_heading_with_attributes() {
876 let rule = MD041FirstLineHeading::default();
877
878 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(
883 result.is_empty(),
884 "Multi-line HTML heading with attributes should be recognized"
885 );
886 }
887
888 #[test]
889 fn test_multiline_html_heading_wrong_level() {
890 let rule = MD041FirstLineHeading::default();
891
892 let content = "<h2>\nSome text\n</h2>";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896 assert_eq!(result.len(), 1);
897 assert!(result[0].message.contains("level 1 heading"));
898 }
899
900 #[test]
901 fn test_multiline_html_heading_with_content_after() {
902 let rule = MD041FirstLineHeading::default();
903
904 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908 assert!(
909 result.is_empty(),
910 "Multi-line HTML heading followed by content should be valid"
911 );
912 }
913
914 #[test]
915 fn test_multiline_html_heading_incomplete() {
916 let rule = MD041FirstLineHeading::default();
917
918 let content = "<h1>\nSome text\n\nMore content without closing tag";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert_eq!(result.len(), 1);
923 assert!(result[0].message.contains("level 1 heading"));
924 }
925
926 #[test]
927 fn test_singleline_html_heading_still_works() {
928 let rule = MD041FirstLineHeading::default();
929
930 let content = "<h1>My Document</h1>\n\nContent.";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(
935 result.is_empty(),
936 "Single-line HTML headings should still be recognized"
937 );
938 }
939
940 #[test]
941 fn test_multiline_html_heading_with_nested_tags() {
942 let rule = MD041FirstLineHeading::default();
943
944 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let result = rule.check(&ctx).unwrap();
948 assert!(
949 result.is_empty(),
950 "Multi-line HTML heading with nested tags should be recognized"
951 );
952 }
953
954 #[test]
955 fn test_multiline_html_heading_various_levels() {
956 for level in 1..=6 {
958 let rule = MD041FirstLineHeading::new(level, false);
959
960 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
962 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964 assert!(
965 result.is_empty(),
966 "Multi-line HTML heading at level {level} should be recognized"
967 );
968
969 let wrong_level = if level == 1 { 2 } else { 1 };
971 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
972 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974 assert_eq!(result.len(), 1);
975 assert!(result[0].message.contains(&format!("level {level} heading")));
976 }
977 }
978
979 #[test]
980 fn test_issue_152_nested_heading_spans_many_lines() {
981 let rule = MD041FirstLineHeading::default();
982
983 let content = "<h1>\n <div>\n <img\n href=\"https://example.com/image.png\"\n alt=\"Example Image\"\n />\n <a\n href=\"https://example.com\"\n >Example Project</a>\n <span>Documentation</span>\n </div>\n</h1>";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
987 }
988
989 #[test]
990 fn test_issue_152_picture_tag_heading() {
991 let rule = MD041FirstLineHeading::default();
992
993 let content = "<h1>\n <picture>\n <source\n srcset=\"https://example.com/light.png\"\n media=\"(prefers-color-scheme: light)\"\n />\n <source\n srcset=\"https://example.com/dark.png\"\n media=\"(prefers-color-scheme: dark)\"\n />\n <img src=\"https://example.com/default.png\" />\n </picture>\n</h1>";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
997 result.is_empty(),
998 "Picture tag inside multi-line HTML heading should be recognized"
999 );
1000 }
1001
1002 #[test]
1003 fn test_badge_images_before_heading() {
1004 let rule = MD041FirstLineHeading::default();
1005
1006 let content = "\n\n# My Project";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(result.is_empty(), "Badge image should be skipped");
1011
1012 let content = " \n\n# My Project";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(result.is_empty(), "Multiple badges should be skipped");
1017
1018 let content = "[](https://example.com)\n\n# My Project";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(result.is_empty(), "Linked badge should be skipped");
1023 }
1024
1025 #[test]
1026 fn test_multiple_badge_lines_before_heading() {
1027 let rule = MD041FirstLineHeading::default();
1028
1029 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1034 }
1035
1036 #[test]
1037 fn test_badges_without_heading_still_warns() {
1038 let rule = MD041FirstLineHeading::default();
1039
1040 let content = "\n\nThis is not a heading.";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1045 }
1046
1047 #[test]
1048 fn test_mixed_content_not_badge_line() {
1049 let rule = MD041FirstLineHeading::default();
1050
1051 let content = " Some text here\n\n# Heading";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1056 }
1057
1058 #[test]
1059 fn test_is_badge_image_line_unit() {
1060 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1062 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1063 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1064 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1065
1066 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1068 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1069 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1070 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1071 }
1072}