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 if ctx.lines.is_empty() {
271 return None;
272 }
273
274 let mut front_matter_end_idx = 0;
276 for line_info in &ctx.lines {
277 if line_info.in_front_matter {
278 front_matter_end_idx += 1;
279 } else {
280 break;
281 }
282 }
283
284 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
285 let mut has_non_preamble_before_heading = false;
286
287 for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
288 let line_content = line_info.content(ctx.content);
289 let trimmed = line_content.trim();
290
291 let is_preamble = trimmed.is_empty()
293 || line_info.in_html_comment
294 || Self::is_non_content_line(line_content)
295 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
296 || line_info.in_kramdown_extension_block
297 || line_info.is_kramdown_block_ial;
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 for line_info in &ctx.lines {
366 if line_info.in_front_matter {
367 skip_lines += 1;
368 } else {
369 break;
370 }
371 }
372
373 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
375
376 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
377 let line_content = line_info.content(ctx.content);
378 let trimmed = line_content.trim();
379 if line_info.in_esm_block {
381 continue;
382 }
383 if line_info.in_html_comment {
385 continue;
386 }
387 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
389 continue;
390 }
391 if line_info.in_kramdown_extension_block || line_info.is_kramdown_block_ial {
393 continue;
394 }
395 if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
396 first_content_line_num = Some(line_num);
397 break;
398 }
399 }
400
401 if first_content_line_num.is_none() {
402 return Ok(warnings);
404 }
405
406 let first_line_idx = first_content_line_num.unwrap();
407
408 let first_line_info = &ctx.lines[first_line_idx];
410 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
411 heading.level as usize == self.level
412 } else {
413 Self::is_html_heading(ctx, first_line_idx, self.level)
415 };
416
417 if !is_correct_heading {
418 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
421 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
422
423 let fix = if self.can_fix(ctx) {
426 let range_start = first_line_info.byte_offset;
427 let range_end = range_start + first_line_info.byte_len;
428 Some(Fix {
429 range: range_start..range_end,
430 replacement: String::new(), })
432 } else {
433 None
434 };
435
436 warnings.push(LintWarning {
437 rule_name: Some(self.name().to_string()),
438 line: start_line,
439 column: start_col,
440 end_line,
441 end_column: end_col,
442 message: format!("First line in file should be a level {} heading", self.level),
443 severity: Severity::Warning,
444 fix,
445 });
446 }
447 Ok(warnings)
448 }
449
450 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
451 if !self.fix_enabled {
453 return Ok(ctx.content.to_string());
454 }
455
456 if self.should_skip(ctx) {
458 return Ok(ctx.content.to_string());
459 }
460
461 let Some(analysis) = self.analyze_for_fix(ctx) else {
463 return Ok(ctx.content.to_string());
464 };
465
466 let lines = ctx.raw_lines();
467 let heading_idx = analysis.heading_idx;
468 let front_matter_end_idx = analysis.front_matter_end_idx;
469 let is_setext = analysis.is_setext;
470
471 let heading_info = &ctx.lines[heading_idx];
472 let heading_line = heading_info.content(ctx.content);
473
474 let fixed_heading = if analysis.needs_level_fix || is_setext {
476 self.fix_heading_level(heading_line, analysis.current_level, self.level)
477 } else {
478 heading_line.to_string()
479 };
480
481 let mut result = String::new();
483
484 for line in lines.iter().take(front_matter_end_idx) {
486 result.push_str(line);
487 result.push('\n');
488 }
489
490 result.push_str(&fixed_heading);
492 result.push('\n');
493
494 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
496 if idx == heading_idx {
498 continue;
499 }
500 if is_setext && idx == heading_idx + 1 {
502 continue;
503 }
504 result.push_str(line);
505 result.push('\n');
506 }
507
508 if !ctx.content.ends_with('\n') && result.ends_with('\n') {
510 result.pop();
511 }
512
513 Ok(result)
514 }
515
516 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
518 let only_directives = !ctx.content.is_empty()
523 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
524 let t = l.trim();
525 (t.starts_with("{{#") && t.ends_with("}}"))
527 || (t.starts_with("<!--") && t.ends_with("-->"))
529 });
530
531 ctx.content.is_empty()
532 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
533 || only_directives
534 }
535
536 fn as_any(&self) -> &dyn std::any::Any {
537 self
538 }
539
540 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
541 where
542 Self: Sized,
543 {
544 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
546
547 let use_front_matter = !md041_config.front_matter_title.is_empty();
548
549 Box::new(MD041FirstLineHeading::with_pattern(
550 md041_config.level.as_usize(),
551 use_front_matter,
552 md041_config.front_matter_title_pattern,
553 md041_config.fix,
554 ))
555 }
556
557 fn default_config_section(&self) -> Option<(String, toml::Value)> {
558 Some((
559 "MD041".to_string(),
560 toml::toml! {
561 level = 1
562 front-matter-title = "title"
563 front-matter-title-pattern = ""
564 fix = false
565 }
566 .into(),
567 ))
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::lint_context::LintContext;
575
576 #[test]
577 fn test_first_line_is_heading_correct_level() {
578 let rule = MD041FirstLineHeading::default();
579
580 let content = "# My Document\n\nSome content here.";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert!(
585 result.is_empty(),
586 "Expected no warnings when first line is a level 1 heading"
587 );
588 }
589
590 #[test]
591 fn test_first_line_is_heading_wrong_level() {
592 let rule = MD041FirstLineHeading::default();
593
594 let content = "## My Document\n\nSome content here.";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598 assert_eq!(result.len(), 1);
599 assert_eq!(result[0].line, 1);
600 assert!(result[0].message.contains("level 1 heading"));
601 }
602
603 #[test]
604 fn test_first_line_not_heading() {
605 let rule = MD041FirstLineHeading::default();
606
607 let content = "This is not a heading\n\n# This is a heading";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611 assert_eq!(result.len(), 1);
612 assert_eq!(result[0].line, 1);
613 assert!(result[0].message.contains("level 1 heading"));
614 }
615
616 #[test]
617 fn test_empty_lines_before_heading() {
618 let rule = MD041FirstLineHeading::default();
619
620 let content = "\n\n# My Document\n\nSome content.";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert!(
625 result.is_empty(),
626 "Expected no warnings when empty lines precede a valid heading"
627 );
628
629 let content = "\n\nNot a heading\n\nSome content.";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert_eq!(result.len(), 1);
634 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
636 }
637
638 #[test]
639 fn test_front_matter_with_title() {
640 let rule = MD041FirstLineHeading::new(1, true);
641
642 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(
647 result.is_empty(),
648 "Expected no warnings when front matter has title field"
649 );
650 }
651
652 #[test]
653 fn test_front_matter_without_title() {
654 let rule = MD041FirstLineHeading::new(1, true);
655
656 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 1);
661 assert_eq!(result[0].line, 6); }
663
664 #[test]
665 fn test_front_matter_disabled() {
666 let rule = MD041FirstLineHeading::new(1, false);
667
668 let content = "---\ntitle: My Document\n---\n\nSome content here.";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert_eq!(result.len(), 1);
673 assert_eq!(result[0].line, 5); }
675
676 #[test]
677 fn test_html_comments_before_heading() {
678 let rule = MD041FirstLineHeading::default();
679
680 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(
685 result.is_empty(),
686 "HTML comments should be skipped when checking for first heading"
687 );
688 }
689
690 #[test]
691 fn test_multiline_html_comment_before_heading() {
692 let rule = MD041FirstLineHeading::default();
693
694 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert!(
699 result.is_empty(),
700 "Multi-line HTML comments should be skipped when checking for first heading"
701 );
702 }
703
704 #[test]
705 fn test_html_comment_with_blank_lines_before_heading() {
706 let rule = MD041FirstLineHeading::default();
707
708 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert!(
713 result.is_empty(),
714 "HTML comments with blank lines should be skipped when checking for first heading"
715 );
716 }
717
718 #[test]
719 fn test_html_comment_before_html_heading() {
720 let rule = MD041FirstLineHeading::default();
721
722 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
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 "HTML comments should be skipped before HTML headings"
729 );
730 }
731
732 #[test]
733 fn test_document_with_only_html_comments() {
734 let rule = MD041FirstLineHeading::default();
735
736 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(
741 result.is_empty(),
742 "Documents with only HTML comments should not trigger MD041"
743 );
744 }
745
746 #[test]
747 fn test_html_comment_followed_by_non_heading() {
748 let rule = MD041FirstLineHeading::default();
749
750 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(
755 result.len(),
756 1,
757 "HTML comment followed by non-heading should still trigger MD041"
758 );
759 assert_eq!(
760 result[0].line, 2,
761 "Warning should be on the first non-comment, non-heading line"
762 );
763 }
764
765 #[test]
766 fn test_multiple_html_comments_before_heading() {
767 let rule = MD041FirstLineHeading::default();
768
769 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert!(
774 result.is_empty(),
775 "Multiple HTML comments should all be skipped before heading"
776 );
777 }
778
779 #[test]
780 fn test_html_comment_with_wrong_level_heading() {
781 let rule = MD041FirstLineHeading::default();
782
783 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert_eq!(
788 result.len(),
789 1,
790 "HTML comment followed by wrong-level heading should still trigger MD041"
791 );
792 assert!(
793 result[0].message.contains("level 1 heading"),
794 "Should require level 1 heading"
795 );
796 }
797
798 #[test]
799 fn test_html_comment_mixed_with_reference_definitions() {
800 let rule = MD041FirstLineHeading::default();
801
802 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(
807 result.is_empty(),
808 "HTML comments and reference definitions should both be skipped before heading"
809 );
810 }
811
812 #[test]
813 fn test_html_comment_after_front_matter() {
814 let rule = MD041FirstLineHeading::default();
815
816 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert!(
821 result.is_empty(),
822 "HTML comments after front matter should be skipped before heading"
823 );
824 }
825
826 #[test]
827 fn test_html_comment_not_at_start_should_not_affect_rule() {
828 let rule = MD041FirstLineHeading::default();
829
830 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "HTML comments in middle of document should not affect MD041 (only first content matters)"
837 );
838 }
839
840 #[test]
841 fn test_multiline_html_comment_followed_by_non_heading() {
842 let rule = MD041FirstLineHeading::default();
843
844 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert_eq!(
849 result.len(),
850 1,
851 "Multi-line HTML comment followed by non-heading should still trigger MD041"
852 );
853 assert_eq!(
854 result[0].line, 5,
855 "Warning should be on the first non-comment, non-heading line"
856 );
857 }
858
859 #[test]
860 fn test_different_heading_levels() {
861 let rule = MD041FirstLineHeading::new(2, false);
863
864 let content = "## Second Level Heading\n\nContent.";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
868
869 let content = "# First Level Heading\n\nContent.";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert_eq!(result.len(), 1);
874 assert!(result[0].message.contains("level 2 heading"));
875 }
876
877 #[test]
878 fn test_setext_headings() {
879 let rule = MD041FirstLineHeading::default();
880
881 let content = "My Document\n===========\n\nContent.";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
886
887 let content = "My Document\n-----------\n\nContent.";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert_eq!(result.len(), 1);
892 assert!(result[0].message.contains("level 1 heading"));
893 }
894
895 #[test]
896 fn test_empty_document() {
897 let rule = MD041FirstLineHeading::default();
898
899 let content = "";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(result.is_empty(), "Expected no warnings for empty document");
904 }
905
906 #[test]
907 fn test_whitespace_only_document() {
908 let rule = MD041FirstLineHeading::default();
909
910 let content = " \n\n \t\n";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let result = rule.check(&ctx).unwrap();
914 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
915 }
916
917 #[test]
918 fn test_front_matter_then_whitespace() {
919 let rule = MD041FirstLineHeading::default();
920
921 let content = "---\ntitle: Test\n---\n\n \n\n";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert!(
926 result.is_empty(),
927 "Expected no warnings when no content after front matter"
928 );
929 }
930
931 #[test]
932 fn test_multiple_front_matter_types() {
933 let rule = MD041FirstLineHeading::new(1, true);
934
935 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert!(
940 result.is_empty(),
941 "Expected no warnings for TOML front matter with title"
942 );
943
944 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
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 "Expected no warnings for JSON front matter with title"
951 );
952
953 let content = "---\ntitle: My Document\n---\n\nContent.";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(
958 result.is_empty(),
959 "Expected no warnings for YAML front matter with title"
960 );
961 }
962
963 #[test]
964 fn test_toml_front_matter_with_heading() {
965 let rule = MD041FirstLineHeading::default();
966
967 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\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 when heading follows TOML front matter"
974 );
975 }
976
977 #[test]
978 fn test_toml_front_matter_without_title_no_heading() {
979 let rule = MD041FirstLineHeading::new(1, true);
980
981 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert_eq!(result.len(), 1);
986 assert_eq!(result[0].line, 6);
987 }
988
989 #[test]
990 fn test_toml_front_matter_level_2_heading() {
991 let rule = MD041FirstLineHeading::new(2, true);
993
994 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert!(
998 result.is_empty(),
999 "Issue #427: TOML front matter with title and correct heading level should not warn"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1005 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1007
1008 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(
1012 result.is_empty(),
1013 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_json_front_matter_with_heading() {
1019 let rule = MD041FirstLineHeading::default();
1020
1021 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(
1026 result.is_empty(),
1027 "Expected no warnings when heading follows JSON front matter"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_malformed_front_matter() {
1033 let rule = MD041FirstLineHeading::new(1, true);
1034
1035 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(
1040 result.is_empty(),
1041 "Expected no warnings for malformed front matter with title"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_front_matter_with_heading() {
1047 let rule = MD041FirstLineHeading::default();
1048
1049 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.check(&ctx).unwrap();
1053 assert!(
1054 result.is_empty(),
1055 "Expected no warnings when first line after front matter is correct heading"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_no_fix_suggestion() {
1061 let rule = MD041FirstLineHeading::default();
1062
1063 let content = "Not a heading\n\nContent.";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert_eq!(result.len(), 1);
1068 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1069 }
1070
1071 #[test]
1072 fn test_complex_document_structure() {
1073 let rule = MD041FirstLineHeading::default();
1074
1075 let content =
1077 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(
1081 result.is_empty(),
1082 "HTML comments should be skipped, so first heading after comment should be valid"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_heading_with_special_characters() {
1088 let rule = MD041FirstLineHeading::default();
1089
1090 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094 assert!(
1095 result.is_empty(),
1096 "Expected no warnings for heading with inline formatting"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_level_configuration() {
1102 for level in 1..=6 {
1104 let rule = MD041FirstLineHeading::new(level, false);
1105
1106 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1108 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(
1111 result.is_empty(),
1112 "Expected no warnings for correct level {level} heading"
1113 );
1114
1115 let wrong_level = if level == 1 { 2 } else { 1 };
1117 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1118 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120 assert_eq!(result.len(), 1);
1121 assert!(result[0].message.contains(&format!("level {level} heading")));
1122 }
1123 }
1124
1125 #[test]
1126 fn test_issue_152_multiline_html_heading() {
1127 let rule = MD041FirstLineHeading::default();
1128
1129 let content = "<h1>\nSome text\n</h1>";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert!(
1134 result.is_empty(),
1135 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_multiline_html_heading_with_attributes() {
1141 let rule = MD041FirstLineHeading::default();
1142
1143 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147 assert!(
1148 result.is_empty(),
1149 "Multi-line HTML heading with attributes should be recognized"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_multiline_html_heading_wrong_level() {
1155 let rule = MD041FirstLineHeading::default();
1156
1157 let content = "<h2>\nSome text\n</h2>";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert_eq!(result.len(), 1);
1162 assert!(result[0].message.contains("level 1 heading"));
1163 }
1164
1165 #[test]
1166 fn test_multiline_html_heading_with_content_after() {
1167 let rule = MD041FirstLineHeading::default();
1168
1169 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert!(
1174 result.is_empty(),
1175 "Multi-line HTML heading followed by content should be valid"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_multiline_html_heading_incomplete() {
1181 let rule = MD041FirstLineHeading::default();
1182
1183 let content = "<h1>\nSome text\n\nMore content without closing tag";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187 assert_eq!(result.len(), 1);
1188 assert!(result[0].message.contains("level 1 heading"));
1189 }
1190
1191 #[test]
1192 fn test_singleline_html_heading_still_works() {
1193 let rule = MD041FirstLineHeading::default();
1194
1195 let content = "<h1>My Document</h1>\n\nContent.";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199 assert!(
1200 result.is_empty(),
1201 "Single-line HTML headings should still be recognized"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_multiline_html_heading_with_nested_tags() {
1207 let rule = MD041FirstLineHeading::default();
1208
1209 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let result = rule.check(&ctx).unwrap();
1213 assert!(
1214 result.is_empty(),
1215 "Multi-line HTML heading with nested tags should be recognized"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_multiline_html_heading_various_levels() {
1221 for level in 1..=6 {
1223 let rule = MD041FirstLineHeading::new(level, false);
1224
1225 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1227 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229 assert!(
1230 result.is_empty(),
1231 "Multi-line HTML heading at level {level} should be recognized"
1232 );
1233
1234 let wrong_level = if level == 1 { 2 } else { 1 };
1236 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1237 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert_eq!(result.len(), 1);
1240 assert!(result[0].message.contains(&format!("level {level} heading")));
1241 }
1242 }
1243
1244 #[test]
1245 fn test_issue_152_nested_heading_spans_many_lines() {
1246 let rule = MD041FirstLineHeading::default();
1247
1248 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>";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1252 }
1253
1254 #[test]
1255 fn test_issue_152_picture_tag_heading() {
1256 let rule = MD041FirstLineHeading::default();
1257
1258 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>";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(
1262 result.is_empty(),
1263 "Picture tag inside multi-line HTML heading should be recognized"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_badge_images_before_heading() {
1269 let rule = MD041FirstLineHeading::default();
1270
1271 let content = "\n\n# My Project";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274 let result = rule.check(&ctx).unwrap();
1275 assert!(result.is_empty(), "Badge image should be skipped");
1276
1277 let content = " \n\n# My Project";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(result.is_empty(), "Multiple badges should be skipped");
1282
1283 let content = "[](https://example.com)\n\n# My Project";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(result.is_empty(), "Linked badge should be skipped");
1288 }
1289
1290 #[test]
1291 fn test_multiple_badge_lines_before_heading() {
1292 let rule = MD041FirstLineHeading::default();
1293
1294 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let result = rule.check(&ctx).unwrap();
1298 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1299 }
1300
1301 #[test]
1302 fn test_badges_without_heading_still_warns() {
1303 let rule = MD041FirstLineHeading::default();
1304
1305 let content = "\n\nThis is not a heading.";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1310 }
1311
1312 #[test]
1313 fn test_mixed_content_not_badge_line() {
1314 let rule = MD041FirstLineHeading::default();
1315
1316 let content = " Some text here\n\n# Heading";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx).unwrap();
1320 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1321 }
1322
1323 #[test]
1324 fn test_is_badge_image_line_unit() {
1325 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1327 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1328 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1329 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1330
1331 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1333 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1334 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1335 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1336 }
1337
1338 #[test]
1342 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1343 let rule = MD041FirstLineHeading::default();
1344
1345 let content = "[](){ #example }\n# Title";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(
1350 result.is_empty(),
1351 "MkDocs anchor line should be skipped in MkDocs flavor"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1357 let rule = MD041FirstLineHeading::default();
1358
1359 let content = "[](){ #example }\n# Title";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let result = rule.check(&ctx).unwrap();
1363 assert_eq!(
1364 result.len(),
1365 1,
1366 "MkDocs anchor line should NOT be skipped in Standard flavor"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_multiple_mkdocs_anchors_before_heading() {
1372 let rule = MD041FirstLineHeading::default();
1373
1374 let content = "[](){ #first }\n[](){ #second }\n# Title";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(
1379 result.is_empty(),
1380 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_mkdocs_anchor_with_front_matter() {
1386 let rule = MD041FirstLineHeading::default();
1387
1388 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1391 let result = rule.check(&ctx).unwrap();
1392 assert!(
1393 result.is_empty(),
1394 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_mkdocs_anchor_kramdown_style() {
1400 let rule = MD041FirstLineHeading::default();
1401
1402 let content = "[](){: #anchor }\n# Title";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1405 let result = rule.check(&ctx).unwrap();
1406 assert!(
1407 result.is_empty(),
1408 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_mkdocs_anchor_without_heading_still_warns() {
1414 let rule = MD041FirstLineHeading::default();
1415
1416 let content = "[](){ #anchor }\nThis is not a heading.";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1419 let result = rule.check(&ctx).unwrap();
1420 assert_eq!(
1421 result.len(),
1422 1,
1423 "MkDocs anchor followed by non-heading should still trigger MD041"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_mkdocs_anchor_with_html_comment() {
1429 let rule = MD041FirstLineHeading::default();
1430
1431 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1434 let result = rule.check(&ctx).unwrap();
1435 assert!(
1436 result.is_empty(),
1437 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1438 );
1439 }
1440
1441 #[test]
1444 fn test_fix_disabled_by_default() {
1445 use crate::rule::Rule;
1446 let rule = MD041FirstLineHeading::default();
1447
1448 let content = "## Wrong Level\n\nContent.";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let fixed = rule.fix(&ctx).unwrap();
1452 assert_eq!(fixed, content, "Fix should not change content when disabled");
1453 }
1454
1455 #[test]
1456 fn test_fix_wrong_heading_level() {
1457 use crate::rule::Rule;
1458 let rule = MD041FirstLineHeading {
1459 level: 1,
1460 front_matter_title: false,
1461 front_matter_title_pattern: None,
1462 fix_enabled: true,
1463 };
1464
1465 let content = "## Wrong Level\n\nContent.\n";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let fixed = rule.fix(&ctx).unwrap();
1469 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1470 }
1471
1472 #[test]
1473 fn test_fix_heading_after_preamble() {
1474 use crate::rule::Rule;
1475 let rule = MD041FirstLineHeading {
1476 level: 1,
1477 front_matter_title: false,
1478 front_matter_title_pattern: None,
1479 fix_enabled: true,
1480 };
1481
1482 let content = "\n\n# Title\n\nContent.\n";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let fixed = rule.fix(&ctx).unwrap();
1486 assert!(
1487 fixed.starts_with("# Title\n"),
1488 "Heading should be moved to first line, got: {fixed}"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_fix_heading_after_html_comment() {
1494 use crate::rule::Rule;
1495 let rule = MD041FirstLineHeading {
1496 level: 1,
1497 front_matter_title: false,
1498 front_matter_title_pattern: None,
1499 fix_enabled: true,
1500 };
1501
1502 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let fixed = rule.fix(&ctx).unwrap();
1506 assert!(
1507 fixed.starts_with("# Title\n"),
1508 "Heading should be moved above comment, got: {fixed}"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_fix_heading_level_and_move() {
1514 use crate::rule::Rule;
1515 let rule = MD041FirstLineHeading {
1516 level: 1,
1517 front_matter_title: false,
1518 front_matter_title_pattern: None,
1519 fix_enabled: true,
1520 };
1521
1522 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1525 let fixed = rule.fix(&ctx).unwrap();
1526 assert!(
1527 fixed.starts_with("# Wrong Level\n"),
1528 "Heading should be fixed and moved, got: {fixed}"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_fix_with_front_matter() {
1534 use crate::rule::Rule;
1535 let rule = MD041FirstLineHeading {
1536 level: 1,
1537 front_matter_title: false,
1538 front_matter_title_pattern: None,
1539 fix_enabled: true,
1540 };
1541
1542 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let fixed = rule.fix(&ctx).unwrap();
1546 assert!(
1547 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1548 "Heading should be right after front matter, got: {fixed}"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_fix_with_toml_front_matter() {
1554 use crate::rule::Rule;
1555 let rule = MD041FirstLineHeading {
1556 level: 1,
1557 front_matter_title: false,
1558 front_matter_title_pattern: None,
1559 fix_enabled: true,
1560 };
1561
1562 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let fixed = rule.fix(&ctx).unwrap();
1566 assert!(
1567 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1568 "Heading should be right after TOML front matter, got: {fixed}"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_fix_cannot_fix_no_heading() {
1574 use crate::rule::Rule;
1575 let rule = MD041FirstLineHeading {
1576 level: 1,
1577 front_matter_title: false,
1578 front_matter_title_pattern: None,
1579 fix_enabled: true,
1580 };
1581
1582 let content = "Just some text.\n\nMore text.\n";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let fixed = rule.fix(&ctx).unwrap();
1586 assert_eq!(fixed, content, "Should not change content when no heading exists");
1587 }
1588
1589 #[test]
1590 fn test_fix_cannot_fix_content_before_heading() {
1591 use crate::rule::Rule;
1592 let rule = MD041FirstLineHeading {
1593 level: 1,
1594 front_matter_title: false,
1595 front_matter_title_pattern: None,
1596 fix_enabled: true,
1597 };
1598
1599 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let fixed = rule.fix(&ctx).unwrap();
1603 assert_eq!(
1604 fixed, content,
1605 "Should not change content when real content exists before heading"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_fix_already_correct() {
1611 use crate::rule::Rule;
1612 let rule = MD041FirstLineHeading {
1613 level: 1,
1614 front_matter_title: false,
1615 front_matter_title_pattern: None,
1616 fix_enabled: true,
1617 };
1618
1619 let content = "# Title\n\nContent.\n";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let fixed = rule.fix(&ctx).unwrap();
1623 assert_eq!(fixed, content, "Should not change already correct content");
1624 }
1625
1626 #[test]
1627 fn test_fix_setext_heading_removes_underline() {
1628 use crate::rule::Rule;
1629 let rule = MD041FirstLineHeading {
1630 level: 1,
1631 front_matter_title: false,
1632 front_matter_title_pattern: None,
1633 fix_enabled: true,
1634 };
1635
1636 let content = "Wrong Level\n-----------\n\nContent.\n";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let fixed = rule.fix(&ctx).unwrap();
1640 assert_eq!(
1641 fixed, "# Wrong Level\n\nContent.\n",
1642 "Setext heading should be converted to ATX and underline removed"
1643 );
1644 }
1645
1646 #[test]
1647 fn test_fix_setext_h1_heading() {
1648 use crate::rule::Rule;
1649 let rule = MD041FirstLineHeading {
1650 level: 1,
1651 front_matter_title: false,
1652 front_matter_title_pattern: None,
1653 fix_enabled: true,
1654 };
1655
1656 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let fixed = rule.fix(&ctx).unwrap();
1660 assert_eq!(
1661 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1662 "Setext h1 should be moved and converted to ATX"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_html_heading_not_claimed_fixable() {
1668 use crate::rule::Rule;
1669 let rule = MD041FirstLineHeading {
1670 level: 1,
1671 front_matter_title: false,
1672 front_matter_title_pattern: None,
1673 fix_enabled: true,
1674 };
1675
1676 let content = "<h2>Title</h2>\n\nContent.\n";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let warnings = rule.check(&ctx).unwrap();
1680 assert_eq!(warnings.len(), 1);
1681 assert!(
1682 warnings[0].fix.is_none(),
1683 "HTML heading should not be claimed as fixable"
1684 );
1685 }
1686
1687 #[test]
1688 fn test_no_heading_not_claimed_fixable() {
1689 use crate::rule::Rule;
1690 let rule = MD041FirstLineHeading {
1691 level: 1,
1692 front_matter_title: false,
1693 front_matter_title_pattern: None,
1694 fix_enabled: true,
1695 };
1696
1697 let content = "Just some text.\n\nMore text.\n";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let warnings = rule.check(&ctx).unwrap();
1701 assert_eq!(warnings.len(), 1);
1702 assert!(
1703 warnings[0].fix.is_none(),
1704 "Document without heading should not be claimed as fixable"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_content_before_heading_not_claimed_fixable() {
1710 use crate::rule::Rule;
1711 let rule = MD041FirstLineHeading {
1712 level: 1,
1713 front_matter_title: false,
1714 front_matter_title_pattern: None,
1715 fix_enabled: true,
1716 };
1717
1718 let content = "Intro text.\n\n## Heading\n\nMore.\n";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let warnings = rule.check(&ctx).unwrap();
1722 assert_eq!(warnings.len(), 1);
1723 assert!(
1724 warnings[0].fix.is_none(),
1725 "Document with content before heading should not be claimed as fixable"
1726 );
1727 }
1728}