1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::lint_context::HeadingStyle;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rules::front_matter_utils::FrontMatterUtils;
8use crate::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
9use crate::utils::range_utils::calculate_line_range;
10use crate::utils::regex_cache::HTML_HEADING_PATTERN;
11use regex::Regex;
12
13#[derive(Clone)]
18pub struct MD041FirstLineHeading {
19 pub level: usize,
20 pub front_matter_title: bool,
21 pub front_matter_title_pattern: Option<Regex>,
22 pub fix_enabled: bool,
23}
24
25impl Default for MD041FirstLineHeading {
26 fn default() -> Self {
27 Self {
28 level: 1,
29 front_matter_title: true,
30 front_matter_title_pattern: None,
31 fix_enabled: false,
32 }
33 }
34}
35
36struct FixAnalysis {
38 front_matter_end_idx: usize,
39 heading_idx: usize,
40 is_setext: bool,
41 current_level: usize,
42 needs_level_fix: bool,
43}
44
45impl MD041FirstLineHeading {
46 pub fn new(level: usize, front_matter_title: bool) -> Self {
47 Self {
48 level,
49 front_matter_title,
50 front_matter_title_pattern: None,
51 fix_enabled: false,
52 }
53 }
54
55 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
56 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
57 Ok(regex) => Some(regex),
58 Err(e) => {
59 log::warn!("Invalid front_matter_title_pattern regex: {e}");
60 None
61 }
62 });
63
64 Self {
65 level,
66 front_matter_title,
67 front_matter_title_pattern,
68 fix_enabled,
69 }
70 }
71
72 fn has_front_matter_title(&self, content: &str) -> bool {
73 if !self.front_matter_title {
74 return false;
75 }
76
77 if let Some(ref pattern) = self.front_matter_title_pattern {
79 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
80 for line in front_matter_lines {
81 if pattern.is_match(line) {
82 return true;
83 }
84 }
85 return false;
86 }
87
88 FrontMatterUtils::has_front_matter_field(content, "title:")
90 }
91
92 fn is_non_content_line(line: &str) -> bool {
94 let trimmed = line.trim();
95
96 if trimmed.starts_with('[') && trimmed.contains("]: ") {
98 return true;
99 }
100
101 if trimmed.starts_with('*') && trimmed.contains("]: ") {
103 return true;
104 }
105
106 if Self::is_badge_image_line(trimmed) {
109 return true;
110 }
111
112 false
113 }
114
115 fn is_badge_image_line(line: &str) -> bool {
121 if line.is_empty() {
122 return false;
123 }
124
125 if !line.starts_with('!') && !line.starts_with('[') {
127 return false;
128 }
129
130 let mut remaining = line;
132 while !remaining.is_empty() {
133 remaining = remaining.trim_start();
134 if remaining.is_empty() {
135 break;
136 }
137
138 if remaining.starts_with("[![") {
140 if let Some(end) = Self::find_linked_image_end(remaining) {
141 remaining = &remaining[end..];
142 continue;
143 }
144 return false;
145 }
146
147 if remaining.starts_with("![") {
149 if let Some(end) = Self::find_image_end(remaining) {
150 remaining = &remaining[end..];
151 continue;
152 }
153 return false;
154 }
155
156 return false;
158 }
159
160 true
161 }
162
163 fn find_image_end(s: &str) -> Option<usize> {
165 if !s.starts_with("![") {
166 return None;
167 }
168 let alt_end = s[2..].find("](")?;
170 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
173 Some(paren_start + paren_end + 1)
174 }
175
176 fn find_linked_image_end(s: &str) -> Option<usize> {
178 if !s.starts_with("[![") {
179 return None;
180 }
181 let inner_end = Self::find_image_end(&s[1..])?;
183 let after_inner = 1 + inner_end;
184 if !s[after_inner..].starts_with("](") {
186 return None;
187 }
188 let link_start = after_inner + 2;
189 let link_end = s[link_start..].find(')')?;
190 Some(link_start + link_end + 1)
191 }
192
193 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
195 let trimmed = line.trim_start();
196
197 if trimmed.starts_with('#') {
199 let hashes = "#".repeat(target_level);
200 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
202 let after_hashes = &trimmed[content_start..];
203 let content = after_hashes.trim_start();
204
205 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
207 format!("{leading_ws}{hashes} {content}")
208 } else {
209 let hashes = "#".repeat(target_level);
212 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
213 format!("{leading_ws}{hashes} {trimmed}")
214 }
215 }
216
217 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
219 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
221 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
222 && let Some(h_level) = captures.get(1)
223 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
224 {
225 return true;
226 }
227
228 let html_tags = ctx.html_tags();
230 let target_tag = format!("h{level}");
231
232 let opening_index = html_tags.iter().position(|tag| {
234 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
236 && !tag.is_closing
237 });
238
239 let Some(open_idx) = opening_index else {
240 return false;
241 };
242
243 let mut depth = 1usize;
246 for tag in html_tags.iter().skip(open_idx + 1) {
247 if tag.line <= first_line_idx + 1 {
249 continue;
250 }
251
252 if tag.tag_name == target_tag {
253 if tag.is_closing {
254 depth -= 1;
255 if depth == 0 {
256 return true;
257 }
258 } else if !tag.is_self_closing {
259 depth += 1;
260 }
261 }
262 }
263
264 false
265 }
266
267 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixAnalysis> {
270 let lines = ctx.raw_lines();
271 if lines.is_empty() {
272 return None;
273 }
274
275 let mut front_matter_end_idx = 0;
277 if lines.first().map(|l| l.trim()) == Some("---") {
278 for (idx, line) in lines.iter().enumerate().skip(1) {
279 if line.trim() == "---" {
280 front_matter_end_idx = idx + 1;
281 break;
282 }
283 }
284 }
285
286 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
287 let mut has_non_preamble_before_heading = false;
288
289 for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
290 let line_content = line_info.content(ctx.content);
291 let trimmed = line_content.trim();
292
293 let is_preamble = trimmed.is_empty()
295 || line_info.in_html_comment
296 || Self::is_non_content_line(line_content)
297 || (is_mkdocs && is_mkdocs_anchor_line(line_content));
298
299 if is_preamble {
300 continue;
301 }
302
303 if let Some(heading) = &line_info.heading {
305 if has_non_preamble_before_heading {
307 return None;
308 }
309
310 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
311 let current_level = heading.level as usize;
312 let needs_level_fix = current_level != self.level;
313 let needs_move = idx > front_matter_end_idx;
314
315 if needs_level_fix || needs_move {
317 return Some(FixAnalysis {
318 front_matter_end_idx,
319 heading_idx: idx,
320 is_setext,
321 current_level,
322 needs_level_fix,
323 });
324 } else {
325 return None; }
327 } else {
328 has_non_preamble_before_heading = true;
330 }
331 }
332
333 None
335 }
336
337 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
339 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
340 }
341}
342
343impl Rule for MD041FirstLineHeading {
344 fn name(&self) -> &'static str {
345 "MD041"
346 }
347
348 fn description(&self) -> &'static str {
349 "First line in file should be a top level heading"
350 }
351
352 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
353 let mut warnings = Vec::new();
354
355 if self.should_skip(ctx) {
357 return Ok(warnings);
358 }
359
360 let mut first_content_line_num = None;
362 let mut skip_lines = 0;
363
364 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
366 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
368 if line_info.content(ctx.content).trim() == "---" {
369 skip_lines = idx + 1;
370 break;
371 }
372 }
373 }
374
375 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
377
378 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
379 let line_content = line_info.content(ctx.content);
380 let trimmed = line_content.trim();
381 if line_info.in_esm_block {
383 continue;
384 }
385 if line_info.in_html_comment {
387 continue;
388 }
389 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
391 continue;
392 }
393 if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
394 first_content_line_num = Some(line_num);
395 break;
396 }
397 }
398
399 if first_content_line_num.is_none() {
400 return Ok(warnings);
402 }
403
404 let first_line_idx = first_content_line_num.unwrap();
405
406 let first_line_info = &ctx.lines[first_line_idx];
408 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
409 heading.level as usize == self.level
410 } else {
411 Self::is_html_heading(ctx, first_line_idx, self.level)
413 };
414
415 if !is_correct_heading {
416 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
419 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
420
421 let fix = if self.can_fix(ctx) {
424 let range_start = first_line_info.byte_offset;
425 let range_end = range_start + first_line_info.byte_len;
426 Some(Fix {
427 range: range_start..range_end,
428 replacement: String::new(), })
430 } else {
431 None
432 };
433
434 warnings.push(LintWarning {
435 rule_name: Some(self.name().to_string()),
436 line: start_line,
437 column: start_col,
438 end_line,
439 end_column: end_col,
440 message: format!("First line in file should be a level {} heading", self.level),
441 severity: Severity::Warning,
442 fix,
443 });
444 }
445 Ok(warnings)
446 }
447
448 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
449 if !self.fix_enabled {
451 return Ok(ctx.content.to_string());
452 }
453
454 if self.should_skip(ctx) {
456 return Ok(ctx.content.to_string());
457 }
458
459 let Some(analysis) = self.analyze_for_fix(ctx) else {
461 return Ok(ctx.content.to_string());
462 };
463
464 let lines = ctx.raw_lines();
465 let heading_idx = analysis.heading_idx;
466 let front_matter_end_idx = analysis.front_matter_end_idx;
467 let is_setext = analysis.is_setext;
468
469 let heading_info = &ctx.lines[heading_idx];
470 let heading_line = heading_info.content(ctx.content);
471
472 let fixed_heading = if analysis.needs_level_fix || is_setext {
474 self.fix_heading_level(heading_line, analysis.current_level, self.level)
475 } else {
476 heading_line.to_string()
477 };
478
479 let mut result = String::new();
481
482 for line in lines.iter().take(front_matter_end_idx) {
484 result.push_str(line);
485 result.push('\n');
486 }
487
488 result.push_str(&fixed_heading);
490 result.push('\n');
491
492 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
494 if idx == heading_idx {
496 continue;
497 }
498 if is_setext && idx == heading_idx + 1 {
500 continue;
501 }
502 result.push_str(line);
503 result.push('\n');
504 }
505
506 if !ctx.content.ends_with('\n') && result.ends_with('\n') {
508 result.pop();
509 }
510
511 Ok(result)
512 }
513
514 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
516 let only_directives = !ctx.content.is_empty()
521 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
522 let t = l.trim();
523 (t.starts_with("{{#") && t.ends_with("}}"))
525 || (t.starts_with("<!--") && t.ends_with("-->"))
527 });
528
529 ctx.content.is_empty()
530 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
531 || only_directives
532 }
533
534 fn as_any(&self) -> &dyn std::any::Any {
535 self
536 }
537
538 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
539 where
540 Self: Sized,
541 {
542 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
544
545 let use_front_matter = !md041_config.front_matter_title.is_empty();
546
547 Box::new(MD041FirstLineHeading::with_pattern(
548 md041_config.level.as_usize(),
549 use_front_matter,
550 md041_config.front_matter_title_pattern,
551 md041_config.fix,
552 ))
553 }
554
555 fn default_config_section(&self) -> Option<(String, toml::Value)> {
556 Some((
557 "MD041".to_string(),
558 toml::toml! {
559 level = 1
560 front-matter-title = "title"
561 front-matter-title-pattern = ""
562 fix = false
563 }
564 .into(),
565 ))
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::lint_context::LintContext;
573
574 #[test]
575 fn test_first_line_is_heading_correct_level() {
576 let rule = MD041FirstLineHeading::default();
577
578 let content = "# My Document\n\nSome content here.";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(
583 result.is_empty(),
584 "Expected no warnings when first line is a level 1 heading"
585 );
586 }
587
588 #[test]
589 fn test_first_line_is_heading_wrong_level() {
590 let rule = MD041FirstLineHeading::default();
591
592 let content = "## My Document\n\nSome content here.";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert_eq!(result.len(), 1);
597 assert_eq!(result[0].line, 1);
598 assert!(result[0].message.contains("level 1 heading"));
599 }
600
601 #[test]
602 fn test_first_line_not_heading() {
603 let rule = MD041FirstLineHeading::default();
604
605 let content = "This is not a heading\n\n# This is a heading";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 assert_eq!(result.len(), 1);
610 assert_eq!(result[0].line, 1);
611 assert!(result[0].message.contains("level 1 heading"));
612 }
613
614 #[test]
615 fn test_empty_lines_before_heading() {
616 let rule = MD041FirstLineHeading::default();
617
618 let content = "\n\n# My Document\n\nSome content.";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert!(
623 result.is_empty(),
624 "Expected no warnings when empty lines precede a valid heading"
625 );
626
627 let content = "\n\nNot a heading\n\nSome content.";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631 assert_eq!(result.len(), 1);
632 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
634 }
635
636 #[test]
637 fn test_front_matter_with_title() {
638 let rule = MD041FirstLineHeading::new(1, true);
639
640 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert!(
645 result.is_empty(),
646 "Expected no warnings when front matter has title field"
647 );
648 }
649
650 #[test]
651 fn test_front_matter_without_title() {
652 let rule = MD041FirstLineHeading::new(1, true);
653
654 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert_eq!(result.len(), 1);
659 assert_eq!(result[0].line, 6); }
661
662 #[test]
663 fn test_front_matter_disabled() {
664 let rule = MD041FirstLineHeading::new(1, false);
665
666 let content = "---\ntitle: My Document\n---\n\nSome content here.";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert_eq!(result.len(), 1);
671 assert_eq!(result[0].line, 5); }
673
674 #[test]
675 fn test_html_comments_before_heading() {
676 let rule = MD041FirstLineHeading::default();
677
678 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682 assert!(
683 result.is_empty(),
684 "HTML comments should be skipped when checking for first heading"
685 );
686 }
687
688 #[test]
689 fn test_multiline_html_comment_before_heading() {
690 let rule = MD041FirstLineHeading::default();
691
692 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(
697 result.is_empty(),
698 "Multi-line HTML comments should be skipped when checking for first heading"
699 );
700 }
701
702 #[test]
703 fn test_html_comment_with_blank_lines_before_heading() {
704 let rule = MD041FirstLineHeading::default();
705
706 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(
711 result.is_empty(),
712 "HTML comments with blank lines should be skipped when checking for first heading"
713 );
714 }
715
716 #[test]
717 fn test_html_comment_before_html_heading() {
718 let rule = MD041FirstLineHeading::default();
719
720 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert!(
725 result.is_empty(),
726 "HTML comments should be skipped before HTML headings"
727 );
728 }
729
730 #[test]
731 fn test_document_with_only_html_comments() {
732 let rule = MD041FirstLineHeading::default();
733
734 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert!(
739 result.is_empty(),
740 "Documents with only HTML comments should not trigger MD041"
741 );
742 }
743
744 #[test]
745 fn test_html_comment_followed_by_non_heading() {
746 let rule = MD041FirstLineHeading::default();
747
748 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752 assert_eq!(
753 result.len(),
754 1,
755 "HTML comment followed by non-heading should still trigger MD041"
756 );
757 assert_eq!(
758 result[0].line, 2,
759 "Warning should be on the first non-comment, non-heading line"
760 );
761 }
762
763 #[test]
764 fn test_multiple_html_comments_before_heading() {
765 let rule = MD041FirstLineHeading::default();
766
767 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "Multiple HTML comments should all be skipped before heading"
774 );
775 }
776
777 #[test]
778 fn test_html_comment_with_wrong_level_heading() {
779 let rule = MD041FirstLineHeading::default();
780
781 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785 assert_eq!(
786 result.len(),
787 1,
788 "HTML comment followed by wrong-level heading should still trigger MD041"
789 );
790 assert!(
791 result[0].message.contains("level 1 heading"),
792 "Should require level 1 heading"
793 );
794 }
795
796 #[test]
797 fn test_html_comment_mixed_with_reference_definitions() {
798 let rule = MD041FirstLineHeading::default();
799
800 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert!(
805 result.is_empty(),
806 "HTML comments and reference definitions should both be skipped before heading"
807 );
808 }
809
810 #[test]
811 fn test_html_comment_after_front_matter() {
812 let rule = MD041FirstLineHeading::default();
813
814 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert!(
819 result.is_empty(),
820 "HTML comments after front matter should be skipped before heading"
821 );
822 }
823
824 #[test]
825 fn test_html_comment_not_at_start_should_not_affect_rule() {
826 let rule = MD041FirstLineHeading::default();
827
828 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert!(
833 result.is_empty(),
834 "HTML comments in middle of document should not affect MD041 (only first content matters)"
835 );
836 }
837
838 #[test]
839 fn test_multiline_html_comment_followed_by_non_heading() {
840 let rule = MD041FirstLineHeading::default();
841
842 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846 assert_eq!(
847 result.len(),
848 1,
849 "Multi-line HTML comment followed by non-heading should still trigger MD041"
850 );
851 assert_eq!(
852 result[0].line, 5,
853 "Warning should be on the first non-comment, non-heading line"
854 );
855 }
856
857 #[test]
858 fn test_different_heading_levels() {
859 let rule = MD041FirstLineHeading::new(2, false);
861
862 let content = "## Second Level Heading\n\nContent.";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
866
867 let content = "# First Level Heading\n\nContent.";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert_eq!(result.len(), 1);
872 assert!(result[0].message.contains("level 2 heading"));
873 }
874
875 #[test]
876 fn test_setext_headings() {
877 let rule = MD041FirstLineHeading::default();
878
879 let content = "My Document\n===========\n\nContent.";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
884
885 let content = "My Document\n-----------\n\nContent.";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert_eq!(result.len(), 1);
890 assert!(result[0].message.contains("level 1 heading"));
891 }
892
893 #[test]
894 fn test_empty_document() {
895 let rule = MD041FirstLineHeading::default();
896
897 let content = "";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert!(result.is_empty(), "Expected no warnings for empty document");
902 }
903
904 #[test]
905 fn test_whitespace_only_document() {
906 let rule = MD041FirstLineHeading::default();
907
908 let content = " \n\n \t\n";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
913 }
914
915 #[test]
916 fn test_front_matter_then_whitespace() {
917 let rule = MD041FirstLineHeading::default();
918
919 let content = "---\ntitle: Test\n---\n\n \n\n";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let result = rule.check(&ctx).unwrap();
923 assert!(
924 result.is_empty(),
925 "Expected no warnings when no content after front matter"
926 );
927 }
928
929 #[test]
930 fn test_multiple_front_matter_types() {
931 let rule = MD041FirstLineHeading::new(1, true);
932
933 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert_eq!(result.len(), 1);
938 assert!(result[0].message.contains("level 1 heading"));
939
940 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert_eq!(result.len(), 1);
945 assert!(result[0].message.contains("level 1 heading"));
946
947 let content = "---\ntitle: My Document\n---\n\nContent.";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(
952 result.is_empty(),
953 "Expected no warnings for YAML front matter with title"
954 );
955
956 let content = "+++\ntitle: My Document\n+++\n\nContent.";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
961 }
962
963 #[test]
964 fn test_malformed_front_matter() {
965 let rule = MD041FirstLineHeading::new(1, true);
966
967 let content = "- --\ntitle: My Document\n- --\n\nContent.";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert!(
972 result.is_empty(),
973 "Expected no warnings for malformed front matter with title"
974 );
975 }
976
977 #[test]
978 fn test_front_matter_with_heading() {
979 let rule = MD041FirstLineHeading::default();
980
981 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(
986 result.is_empty(),
987 "Expected no warnings when first line after front matter is correct heading"
988 );
989 }
990
991 #[test]
992 fn test_no_fix_suggestion() {
993 let rule = MD041FirstLineHeading::default();
994
995 let content = "Not a heading\n\nContent.";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let result = rule.check(&ctx).unwrap();
999 assert_eq!(result.len(), 1);
1000 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1001 }
1002
1003 #[test]
1004 fn test_complex_document_structure() {
1005 let rule = MD041FirstLineHeading::default();
1006
1007 let content =
1009 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(
1013 result.is_empty(),
1014 "HTML comments should be skipped, so first heading after comment should be valid"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_heading_with_special_characters() {
1020 let rule = MD041FirstLineHeading::default();
1021
1022 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let result = rule.check(&ctx).unwrap();
1026 assert!(
1027 result.is_empty(),
1028 "Expected no warnings for heading with inline formatting"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_level_configuration() {
1034 for level in 1..=6 {
1036 let rule = MD041FirstLineHeading::new(level, false);
1037
1038 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1040 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042 assert!(
1043 result.is_empty(),
1044 "Expected no warnings for correct level {level} heading"
1045 );
1046
1047 let wrong_level = if level == 1 { 2 } else { 1 };
1049 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1050 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert_eq!(result.len(), 1);
1053 assert!(result[0].message.contains(&format!("level {level} heading")));
1054 }
1055 }
1056
1057 #[test]
1058 fn test_issue_152_multiline_html_heading() {
1059 let rule = MD041FirstLineHeading::default();
1060
1061 let content = "<h1>\nSome text\n</h1>";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1064 let result = rule.check(&ctx).unwrap();
1065 assert!(
1066 result.is_empty(),
1067 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_multiline_html_heading_with_attributes() {
1073 let rule = MD041FirstLineHeading::default();
1074
1075 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(
1080 result.is_empty(),
1081 "Multi-line HTML heading with attributes should be recognized"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_multiline_html_heading_wrong_level() {
1087 let rule = MD041FirstLineHeading::default();
1088
1089 let content = "<h2>\nSome text\n</h2>";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092 let result = rule.check(&ctx).unwrap();
1093 assert_eq!(result.len(), 1);
1094 assert!(result[0].message.contains("level 1 heading"));
1095 }
1096
1097 #[test]
1098 fn test_multiline_html_heading_with_content_after() {
1099 let rule = MD041FirstLineHeading::default();
1100
1101 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105 assert!(
1106 result.is_empty(),
1107 "Multi-line HTML heading followed by content should be valid"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_multiline_html_heading_incomplete() {
1113 let rule = MD041FirstLineHeading::default();
1114
1115 let content = "<h1>\nSome text\n\nMore content without closing tag";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert_eq!(result.len(), 1);
1120 assert!(result[0].message.contains("level 1 heading"));
1121 }
1122
1123 #[test]
1124 fn test_singleline_html_heading_still_works() {
1125 let rule = MD041FirstLineHeading::default();
1126
1127 let content = "<h1>My Document</h1>\n\nContent.";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131 assert!(
1132 result.is_empty(),
1133 "Single-line HTML headings should still be recognized"
1134 );
1135 }
1136
1137 #[test]
1138 fn test_multiline_html_heading_with_nested_tags() {
1139 let rule = MD041FirstLineHeading::default();
1140
1141 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 let result = rule.check(&ctx).unwrap();
1145 assert!(
1146 result.is_empty(),
1147 "Multi-line HTML heading with nested tags should be recognized"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_multiline_html_heading_various_levels() {
1153 for level in 1..=6 {
1155 let rule = MD041FirstLineHeading::new(level, false);
1156
1157 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1159 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert!(
1162 result.is_empty(),
1163 "Multi-line HTML heading at level {level} should be recognized"
1164 );
1165
1166 let wrong_level = if level == 1 { 2 } else { 1 };
1168 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1169 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert_eq!(result.len(), 1);
1172 assert!(result[0].message.contains(&format!("level {level} heading")));
1173 }
1174 }
1175
1176 #[test]
1177 fn test_issue_152_nested_heading_spans_many_lines() {
1178 let rule = MD041FirstLineHeading::default();
1179
1180 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>";
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1184 }
1185
1186 #[test]
1187 fn test_issue_152_picture_tag_heading() {
1188 let rule = MD041FirstLineHeading::default();
1189
1190 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>";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192 let result = rule.check(&ctx).unwrap();
1193 assert!(
1194 result.is_empty(),
1195 "Picture tag inside multi-line HTML heading should be recognized"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_badge_images_before_heading() {
1201 let rule = MD041FirstLineHeading::default();
1202
1203 let content = "\n\n# My Project";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(result.is_empty(), "Badge image should be skipped");
1208
1209 let content = " \n\n# My Project";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let result = rule.check(&ctx).unwrap();
1213 assert!(result.is_empty(), "Multiple badges should be skipped");
1214
1215 let content = "[](https://example.com)\n\n# My Project";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert!(result.is_empty(), "Linked badge should be skipped");
1220 }
1221
1222 #[test]
1223 fn test_multiple_badge_lines_before_heading() {
1224 let rule = MD041FirstLineHeading::default();
1225
1226 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1231 }
1232
1233 #[test]
1234 fn test_badges_without_heading_still_warns() {
1235 let rule = MD041FirstLineHeading::default();
1236
1237 let content = "\n\nThis is not a heading.";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1242 }
1243
1244 #[test]
1245 fn test_mixed_content_not_badge_line() {
1246 let rule = MD041FirstLineHeading::default();
1247
1248 let content = " Some text here\n\n# Heading";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251 let result = rule.check(&ctx).unwrap();
1252 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1253 }
1254
1255 #[test]
1256 fn test_is_badge_image_line_unit() {
1257 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1259 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1260 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1261 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1262
1263 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1265 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1266 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1267 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1268 }
1269
1270 #[test]
1274 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1275 let rule = MD041FirstLineHeading::default();
1276
1277 let content = "[](){ #example }\n# Title";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(
1282 result.is_empty(),
1283 "MkDocs anchor line should be skipped in MkDocs flavor"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1289 let rule = MD041FirstLineHeading::default();
1290
1291 let content = "[](){ #example }\n# Title";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert_eq!(
1296 result.len(),
1297 1,
1298 "MkDocs anchor line should NOT be skipped in Standard flavor"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_multiple_mkdocs_anchors_before_heading() {
1304 let rule = MD041FirstLineHeading::default();
1305
1306 let content = "[](){ #first }\n[](){ #second }\n# Title";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1309 let result = rule.check(&ctx).unwrap();
1310 assert!(
1311 result.is_empty(),
1312 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_mkdocs_anchor_with_front_matter() {
1318 let rule = MD041FirstLineHeading::default();
1319
1320 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1323 let result = rule.check(&ctx).unwrap();
1324 assert!(
1325 result.is_empty(),
1326 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_mkdocs_anchor_kramdown_style() {
1332 let rule = MD041FirstLineHeading::default();
1333
1334 let content = "[](){: #anchor }\n# Title";
1336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1337 let result = rule.check(&ctx).unwrap();
1338 assert!(
1339 result.is_empty(),
1340 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_mkdocs_anchor_without_heading_still_warns() {
1346 let rule = MD041FirstLineHeading::default();
1347
1348 let content = "[](){ #anchor }\nThis is not a heading.";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1351 let result = rule.check(&ctx).unwrap();
1352 assert_eq!(
1353 result.len(),
1354 1,
1355 "MkDocs anchor followed by non-heading should still trigger MD041"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_mkdocs_anchor_with_html_comment() {
1361 let rule = MD041FirstLineHeading::default();
1362
1363 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1368 result.is_empty(),
1369 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1370 );
1371 }
1372
1373 #[test]
1376 fn test_fix_disabled_by_default() {
1377 use crate::rule::Rule;
1378 let rule = MD041FirstLineHeading::default();
1379
1380 let content = "## Wrong Level\n\nContent.";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383 let fixed = rule.fix(&ctx).unwrap();
1384 assert_eq!(fixed, content, "Fix should not change content when disabled");
1385 }
1386
1387 #[test]
1388 fn test_fix_wrong_heading_level() {
1389 use crate::rule::Rule;
1390 let rule = MD041FirstLineHeading {
1391 level: 1,
1392 front_matter_title: false,
1393 front_matter_title_pattern: None,
1394 fix_enabled: true,
1395 };
1396
1397 let content = "## Wrong Level\n\nContent.\n";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let fixed = rule.fix(&ctx).unwrap();
1401 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1402 }
1403
1404 #[test]
1405 fn test_fix_heading_after_preamble() {
1406 use crate::rule::Rule;
1407 let rule = MD041FirstLineHeading {
1408 level: 1,
1409 front_matter_title: false,
1410 front_matter_title_pattern: None,
1411 fix_enabled: true,
1412 };
1413
1414 let content = "\n\n# Title\n\nContent.\n";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417 let fixed = rule.fix(&ctx).unwrap();
1418 assert!(
1419 fixed.starts_with("# Title\n"),
1420 "Heading should be moved to first line, got: {fixed}"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_fix_heading_after_html_comment() {
1426 use crate::rule::Rule;
1427 let rule = MD041FirstLineHeading {
1428 level: 1,
1429 front_matter_title: false,
1430 front_matter_title_pattern: None,
1431 fix_enabled: true,
1432 };
1433
1434 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437 let fixed = rule.fix(&ctx).unwrap();
1438 assert!(
1439 fixed.starts_with("# Title\n"),
1440 "Heading should be moved above comment, got: {fixed}"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_fix_heading_level_and_move() {
1446 use crate::rule::Rule;
1447 let rule = MD041FirstLineHeading {
1448 level: 1,
1449 front_matter_title: false,
1450 front_matter_title_pattern: None,
1451 fix_enabled: true,
1452 };
1453
1454 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let fixed = rule.fix(&ctx).unwrap();
1458 assert!(
1459 fixed.starts_with("# Wrong Level\n"),
1460 "Heading should be fixed and moved, got: {fixed}"
1461 );
1462 }
1463
1464 #[test]
1465 fn test_fix_with_front_matter() {
1466 use crate::rule::Rule;
1467 let rule = MD041FirstLineHeading {
1468 level: 1,
1469 front_matter_title: false,
1470 front_matter_title_pattern: None,
1471 fix_enabled: true,
1472 };
1473
1474 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477 let fixed = rule.fix(&ctx).unwrap();
1478 assert!(
1479 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1480 "Heading should be right after front matter, got: {fixed}"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_fix_cannot_fix_no_heading() {
1486 use crate::rule::Rule;
1487 let rule = MD041FirstLineHeading {
1488 level: 1,
1489 front_matter_title: false,
1490 front_matter_title_pattern: None,
1491 fix_enabled: true,
1492 };
1493
1494 let content = "Just some text.\n\nMore text.\n";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let fixed = rule.fix(&ctx).unwrap();
1498 assert_eq!(fixed, content, "Should not change content when no heading exists");
1499 }
1500
1501 #[test]
1502 fn test_fix_cannot_fix_content_before_heading() {
1503 use crate::rule::Rule;
1504 let rule = MD041FirstLineHeading {
1505 level: 1,
1506 front_matter_title: false,
1507 front_matter_title_pattern: None,
1508 fix_enabled: true,
1509 };
1510
1511 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let fixed = rule.fix(&ctx).unwrap();
1515 assert_eq!(
1516 fixed, content,
1517 "Should not change content when real content exists before heading"
1518 );
1519 }
1520
1521 #[test]
1522 fn test_fix_already_correct() {
1523 use crate::rule::Rule;
1524 let rule = MD041FirstLineHeading {
1525 level: 1,
1526 front_matter_title: false,
1527 front_matter_title_pattern: None,
1528 fix_enabled: true,
1529 };
1530
1531 let content = "# Title\n\nContent.\n";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let fixed = rule.fix(&ctx).unwrap();
1535 assert_eq!(fixed, content, "Should not change already correct content");
1536 }
1537
1538 #[test]
1539 fn test_fix_setext_heading_removes_underline() {
1540 use crate::rule::Rule;
1541 let rule = MD041FirstLineHeading {
1542 level: 1,
1543 front_matter_title: false,
1544 front_matter_title_pattern: None,
1545 fix_enabled: true,
1546 };
1547
1548 let content = "Wrong Level\n-----------\n\nContent.\n";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551 let fixed = rule.fix(&ctx).unwrap();
1552 assert_eq!(
1553 fixed, "# Wrong Level\n\nContent.\n",
1554 "Setext heading should be converted to ATX and underline removed"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_fix_setext_h1_heading() {
1560 use crate::rule::Rule;
1561 let rule = MD041FirstLineHeading {
1562 level: 1,
1563 front_matter_title: false,
1564 front_matter_title_pattern: None,
1565 fix_enabled: true,
1566 };
1567
1568 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571 let fixed = rule.fix(&ctx).unwrap();
1572 assert_eq!(
1573 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1574 "Setext h1 should be moved and converted to ATX"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_html_heading_not_claimed_fixable() {
1580 use crate::rule::Rule;
1581 let rule = MD041FirstLineHeading {
1582 level: 1,
1583 front_matter_title: false,
1584 front_matter_title_pattern: None,
1585 fix_enabled: true,
1586 };
1587
1588 let content = "<h2>Title</h2>\n\nContent.\n";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591 let warnings = rule.check(&ctx).unwrap();
1592 assert_eq!(warnings.len(), 1);
1593 assert!(
1594 warnings[0].fix.is_none(),
1595 "HTML heading should not be claimed as fixable"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_no_heading_not_claimed_fixable() {
1601 use crate::rule::Rule;
1602 let rule = MD041FirstLineHeading {
1603 level: 1,
1604 front_matter_title: false,
1605 front_matter_title_pattern: None,
1606 fix_enabled: true,
1607 };
1608
1609 let content = "Just some text.\n\nMore text.\n";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let warnings = rule.check(&ctx).unwrap();
1613 assert_eq!(warnings.len(), 1);
1614 assert!(
1615 warnings[0].fix.is_none(),
1616 "Document without heading should not be claimed as fixable"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_content_before_heading_not_claimed_fixable() {
1622 use crate::rule::Rule;
1623 let rule = MD041FirstLineHeading {
1624 level: 1,
1625 front_matter_title: false,
1626 front_matter_title_pattern: None,
1627 fix_enabled: true,
1628 };
1629
1630 let content = "Intro text.\n\n## Heading\n\nMore.\n";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let warnings = rule.check(&ctx).unwrap();
1634 assert_eq!(warnings.len(), 1);
1635 assert!(
1636 warnings[0].fix.is_none(),
1637 "Document with content before heading should not be claimed as fixable"
1638 );
1639 }
1640}