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 || line_info.in_kramdown_extension_block
299 || line_info.is_kramdown_block_ial;
300
301 if is_preamble {
302 continue;
303 }
304
305 if let Some(heading) = &line_info.heading {
307 if has_non_preamble_before_heading {
309 return None;
310 }
311
312 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
313 let current_level = heading.level as usize;
314 let needs_level_fix = current_level != self.level;
315 let needs_move = idx > front_matter_end_idx;
316
317 if needs_level_fix || needs_move {
319 return Some(FixAnalysis {
320 front_matter_end_idx,
321 heading_idx: idx,
322 is_setext,
323 current_level,
324 needs_level_fix,
325 });
326 } else {
327 return None; }
329 } else {
330 has_non_preamble_before_heading = true;
332 }
333 }
334
335 None
337 }
338
339 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
341 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
342 }
343}
344
345impl Rule for MD041FirstLineHeading {
346 fn name(&self) -> &'static str {
347 "MD041"
348 }
349
350 fn description(&self) -> &'static str {
351 "First line in file should be a top level heading"
352 }
353
354 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
355 let mut warnings = Vec::new();
356
357 if self.should_skip(ctx) {
359 return Ok(warnings);
360 }
361
362 let mut first_content_line_num = None;
364 let mut skip_lines = 0;
365
366 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
368 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
370 if line_info.content(ctx.content).trim() == "---" {
371 skip_lines = idx + 1;
372 break;
373 }
374 }
375 }
376
377 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
379
380 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
381 let line_content = line_info.content(ctx.content);
382 let trimmed = line_content.trim();
383 if line_info.in_esm_block {
385 continue;
386 }
387 if line_info.in_html_comment {
389 continue;
390 }
391 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
393 continue;
394 }
395 if line_info.in_kramdown_extension_block || line_info.is_kramdown_block_ial {
397 continue;
398 }
399 if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
400 first_content_line_num = Some(line_num);
401 break;
402 }
403 }
404
405 if first_content_line_num.is_none() {
406 return Ok(warnings);
408 }
409
410 let first_line_idx = first_content_line_num.unwrap();
411
412 let first_line_info = &ctx.lines[first_line_idx];
414 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
415 heading.level as usize == self.level
416 } else {
417 Self::is_html_heading(ctx, first_line_idx, self.level)
419 };
420
421 if !is_correct_heading {
422 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
425 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
426
427 let fix = if self.can_fix(ctx) {
430 let range_start = first_line_info.byte_offset;
431 let range_end = range_start + first_line_info.byte_len;
432 Some(Fix {
433 range: range_start..range_end,
434 replacement: String::new(), })
436 } else {
437 None
438 };
439
440 warnings.push(LintWarning {
441 rule_name: Some(self.name().to_string()),
442 line: start_line,
443 column: start_col,
444 end_line,
445 end_column: end_col,
446 message: format!("First line in file should be a level {} heading", self.level),
447 severity: Severity::Warning,
448 fix,
449 });
450 }
451 Ok(warnings)
452 }
453
454 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
455 if !self.fix_enabled {
457 return Ok(ctx.content.to_string());
458 }
459
460 if self.should_skip(ctx) {
462 return Ok(ctx.content.to_string());
463 }
464
465 let Some(analysis) = self.analyze_for_fix(ctx) else {
467 return Ok(ctx.content.to_string());
468 };
469
470 let lines = ctx.raw_lines();
471 let heading_idx = analysis.heading_idx;
472 let front_matter_end_idx = analysis.front_matter_end_idx;
473 let is_setext = analysis.is_setext;
474
475 let heading_info = &ctx.lines[heading_idx];
476 let heading_line = heading_info.content(ctx.content);
477
478 let fixed_heading = if analysis.needs_level_fix || is_setext {
480 self.fix_heading_level(heading_line, analysis.current_level, self.level)
481 } else {
482 heading_line.to_string()
483 };
484
485 let mut result = String::new();
487
488 for line in lines.iter().take(front_matter_end_idx) {
490 result.push_str(line);
491 result.push('\n');
492 }
493
494 result.push_str(&fixed_heading);
496 result.push('\n');
497
498 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
500 if idx == heading_idx {
502 continue;
503 }
504 if is_setext && idx == heading_idx + 1 {
506 continue;
507 }
508 result.push_str(line);
509 result.push('\n');
510 }
511
512 if !ctx.content.ends_with('\n') && result.ends_with('\n') {
514 result.pop();
515 }
516
517 Ok(result)
518 }
519
520 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
522 let only_directives = !ctx.content.is_empty()
527 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
528 let t = l.trim();
529 (t.starts_with("{{#") && t.ends_with("}}"))
531 || (t.starts_with("<!--") && t.ends_with("-->"))
533 });
534
535 ctx.content.is_empty()
536 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
537 || only_directives
538 }
539
540 fn as_any(&self) -> &dyn std::any::Any {
541 self
542 }
543
544 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
545 where
546 Self: Sized,
547 {
548 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
550
551 let use_front_matter = !md041_config.front_matter_title.is_empty();
552
553 Box::new(MD041FirstLineHeading::with_pattern(
554 md041_config.level.as_usize(),
555 use_front_matter,
556 md041_config.front_matter_title_pattern,
557 md041_config.fix,
558 ))
559 }
560
561 fn default_config_section(&self) -> Option<(String, toml::Value)> {
562 Some((
563 "MD041".to_string(),
564 toml::toml! {
565 level = 1
566 front-matter-title = "title"
567 front-matter-title-pattern = ""
568 fix = false
569 }
570 .into(),
571 ))
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::lint_context::LintContext;
579
580 #[test]
581 fn test_first_line_is_heading_correct_level() {
582 let rule = MD041FirstLineHeading::default();
583
584 let content = "# My Document\n\nSome content here.";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(
589 result.is_empty(),
590 "Expected no warnings when first line is a level 1 heading"
591 );
592 }
593
594 #[test]
595 fn test_first_line_is_heading_wrong_level() {
596 let rule = MD041FirstLineHeading::default();
597
598 let content = "## My Document\n\nSome content here.";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 1);
603 assert_eq!(result[0].line, 1);
604 assert!(result[0].message.contains("level 1 heading"));
605 }
606
607 #[test]
608 fn test_first_line_not_heading() {
609 let rule = MD041FirstLineHeading::default();
610
611 let content = "This is not a heading\n\n# This is a heading";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert_eq!(result.len(), 1);
616 assert_eq!(result[0].line, 1);
617 assert!(result[0].message.contains("level 1 heading"));
618 }
619
620 #[test]
621 fn test_empty_lines_before_heading() {
622 let rule = MD041FirstLineHeading::default();
623
624 let content = "\n\n# My Document\n\nSome content.";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert!(
629 result.is_empty(),
630 "Expected no warnings when empty lines precede a valid heading"
631 );
632
633 let content = "\n\nNot a heading\n\nSome content.";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 1);
638 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
640 }
641
642 #[test]
643 fn test_front_matter_with_title() {
644 let rule = MD041FirstLineHeading::new(1, true);
645
646 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650 assert!(
651 result.is_empty(),
652 "Expected no warnings when front matter has title field"
653 );
654 }
655
656 #[test]
657 fn test_front_matter_without_title() {
658 let rule = MD041FirstLineHeading::new(1, true);
659
660 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert_eq!(result.len(), 1);
665 assert_eq!(result[0].line, 6); }
667
668 #[test]
669 fn test_front_matter_disabled() {
670 let rule = MD041FirstLineHeading::new(1, false);
671
672 let content = "---\ntitle: My Document\n---\n\nSome content here.";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert_eq!(result.len(), 1);
677 assert_eq!(result[0].line, 5); }
679
680 #[test]
681 fn test_html_comments_before_heading() {
682 let rule = MD041FirstLineHeading::default();
683
684 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let result = rule.check(&ctx).unwrap();
688 assert!(
689 result.is_empty(),
690 "HTML comments should be skipped when checking for first heading"
691 );
692 }
693
694 #[test]
695 fn test_multiline_html_comment_before_heading() {
696 let rule = MD041FirstLineHeading::default();
697
698 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert!(
703 result.is_empty(),
704 "Multi-line HTML comments should be skipped when checking for first heading"
705 );
706 }
707
708 #[test]
709 fn test_html_comment_with_blank_lines_before_heading() {
710 let rule = MD041FirstLineHeading::default();
711
712 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716 assert!(
717 result.is_empty(),
718 "HTML comments with blank lines should be skipped when checking for first heading"
719 );
720 }
721
722 #[test]
723 fn test_html_comment_before_html_heading() {
724 let rule = MD041FirstLineHeading::default();
725
726 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 result.is_empty(),
732 "HTML comments should be skipped before HTML headings"
733 );
734 }
735
736 #[test]
737 fn test_document_with_only_html_comments() {
738 let rule = MD041FirstLineHeading::default();
739
740 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert!(
745 result.is_empty(),
746 "Documents with only HTML comments should not trigger MD041"
747 );
748 }
749
750 #[test]
751 fn test_html_comment_followed_by_non_heading() {
752 let rule = MD041FirstLineHeading::default();
753
754 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(
759 result.len(),
760 1,
761 "HTML comment followed by non-heading should still trigger MD041"
762 );
763 assert_eq!(
764 result[0].line, 2,
765 "Warning should be on the first non-comment, non-heading line"
766 );
767 }
768
769 #[test]
770 fn test_multiple_html_comments_before_heading() {
771 let rule = MD041FirstLineHeading::default();
772
773 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert!(
778 result.is_empty(),
779 "Multiple HTML comments should all be skipped before heading"
780 );
781 }
782
783 #[test]
784 fn test_html_comment_with_wrong_level_heading() {
785 let rule = MD041FirstLineHeading::default();
786
787 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(
792 result.len(),
793 1,
794 "HTML comment followed by wrong-level heading should still trigger MD041"
795 );
796 assert!(
797 result[0].message.contains("level 1 heading"),
798 "Should require level 1 heading"
799 );
800 }
801
802 #[test]
803 fn test_html_comment_mixed_with_reference_definitions() {
804 let rule = MD041FirstLineHeading::default();
805
806 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(
811 result.is_empty(),
812 "HTML comments and reference definitions should both be skipped before heading"
813 );
814 }
815
816 #[test]
817 fn test_html_comment_after_front_matter() {
818 let rule = MD041FirstLineHeading::default();
819
820 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert!(
825 result.is_empty(),
826 "HTML comments after front matter should be skipped before heading"
827 );
828 }
829
830 #[test]
831 fn test_html_comment_not_at_start_should_not_affect_rule() {
832 let rule = MD041FirstLineHeading::default();
833
834 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
839 result.is_empty(),
840 "HTML comments in middle of document should not affect MD041 (only first content matters)"
841 );
842 }
843
844 #[test]
845 fn test_multiline_html_comment_followed_by_non_heading() {
846 let rule = MD041FirstLineHeading::default();
847
848 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851 let result = rule.check(&ctx).unwrap();
852 assert_eq!(
853 result.len(),
854 1,
855 "Multi-line HTML comment followed by non-heading should still trigger MD041"
856 );
857 assert_eq!(
858 result[0].line, 5,
859 "Warning should be on the first non-comment, non-heading line"
860 );
861 }
862
863 #[test]
864 fn test_different_heading_levels() {
865 let rule = MD041FirstLineHeading::new(2, false);
867
868 let content = "## Second Level Heading\n\nContent.";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
872
873 let content = "# First Level Heading\n\nContent.";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert_eq!(result.len(), 1);
878 assert!(result[0].message.contains("level 2 heading"));
879 }
880
881 #[test]
882 fn test_setext_headings() {
883 let rule = MD041FirstLineHeading::default();
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!(result.is_empty(), "Expected no warnings for setext level 1 heading");
890
891 let content = "My Document\n-----------\n\nContent.";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895 assert_eq!(result.len(), 1);
896 assert!(result[0].message.contains("level 1 heading"));
897 }
898
899 #[test]
900 fn test_empty_document() {
901 let rule = MD041FirstLineHeading::default();
902
903 let content = "";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907 assert!(result.is_empty(), "Expected no warnings for empty document");
908 }
909
910 #[test]
911 fn test_whitespace_only_document() {
912 let rule = MD041FirstLineHeading::default();
913
914 let content = " \n\n \t\n";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
919 }
920
921 #[test]
922 fn test_front_matter_then_whitespace() {
923 let rule = MD041FirstLineHeading::default();
924
925 let content = "---\ntitle: Test\n---\n\n \n\n";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929 assert!(
930 result.is_empty(),
931 "Expected no warnings when no content after front matter"
932 );
933 }
934
935 #[test]
936 fn test_multiple_front_matter_types() {
937 let rule = MD041FirstLineHeading::new(1, true);
938
939 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.check(&ctx).unwrap();
943 assert_eq!(result.len(), 1);
944 assert!(result[0].message.contains("level 1 heading"));
945
946 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert_eq!(result.len(), 1);
951 assert!(result[0].message.contains("level 1 heading"));
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 let content = "+++\ntitle: My Document\n+++\n\nContent.";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
967 }
968
969 #[test]
970 fn test_malformed_front_matter() {
971 let rule = MD041FirstLineHeading::new(1, true);
972
973 let content = "- --\ntitle: My Document\n- --\n\nContent.";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(
978 result.is_empty(),
979 "Expected no warnings for malformed front matter with title"
980 );
981 }
982
983 #[test]
984 fn test_front_matter_with_heading() {
985 let rule = MD041FirstLineHeading::default();
986
987 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(
992 result.is_empty(),
993 "Expected no warnings when first line after front matter is correct heading"
994 );
995 }
996
997 #[test]
998 fn test_no_fix_suggestion() {
999 let rule = MD041FirstLineHeading::default();
1000
1001 let content = "Not a heading\n\nContent.";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert_eq!(result.len(), 1);
1006 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1007 }
1008
1009 #[test]
1010 fn test_complex_document_structure() {
1011 let rule = MD041FirstLineHeading::default();
1012
1013 let content =
1015 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(
1019 result.is_empty(),
1020 "HTML comments should be skipped, so first heading after comment should be valid"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_heading_with_special_characters() {
1026 let rule = MD041FirstLineHeading::default();
1027
1028 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let result = rule.check(&ctx).unwrap();
1032 assert!(
1033 result.is_empty(),
1034 "Expected no warnings for heading with inline formatting"
1035 );
1036 }
1037
1038 #[test]
1039 fn test_level_configuration() {
1040 for level in 1..=6 {
1042 let rule = MD041FirstLineHeading::new(level, false);
1043
1044 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1046 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1047 let result = rule.check(&ctx).unwrap();
1048 assert!(
1049 result.is_empty(),
1050 "Expected no warnings for correct level {level} heading"
1051 );
1052
1053 let wrong_level = if level == 1 { 2 } else { 1 };
1055 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1056 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert_eq!(result.len(), 1);
1059 assert!(result[0].message.contains(&format!("level {level} heading")));
1060 }
1061 }
1062
1063 #[test]
1064 fn test_issue_152_multiline_html_heading() {
1065 let rule = MD041FirstLineHeading::default();
1066
1067 let content = "<h1>\nSome text\n</h1>";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1072 result.is_empty(),
1073 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_multiline_html_heading_with_attributes() {
1079 let rule = MD041FirstLineHeading::default();
1080
1081 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert!(
1086 result.is_empty(),
1087 "Multi-line HTML heading with attributes should be recognized"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_multiline_html_heading_wrong_level() {
1093 let rule = MD041FirstLineHeading::default();
1094
1095 let content = "<h2>\nSome text\n</h2>";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099 assert_eq!(result.len(), 1);
1100 assert!(result[0].message.contains("level 1 heading"));
1101 }
1102
1103 #[test]
1104 fn test_multiline_html_heading_with_content_after() {
1105 let rule = MD041FirstLineHeading::default();
1106
1107 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(
1112 result.is_empty(),
1113 "Multi-line HTML heading followed by content should be valid"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_multiline_html_heading_incomplete() {
1119 let rule = MD041FirstLineHeading::default();
1120
1121 let content = "<h1>\nSome text\n\nMore content without closing tag";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let result = rule.check(&ctx).unwrap();
1125 assert_eq!(result.len(), 1);
1126 assert!(result[0].message.contains("level 1 heading"));
1127 }
1128
1129 #[test]
1130 fn test_singleline_html_heading_still_works() {
1131 let rule = MD041FirstLineHeading::default();
1132
1133 let content = "<h1>My Document</h1>\n\nContent.";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert!(
1138 result.is_empty(),
1139 "Single-line HTML headings should still be recognized"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_multiline_html_heading_with_nested_tags() {
1145 let rule = MD041FirstLineHeading::default();
1146
1147 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151 assert!(
1152 result.is_empty(),
1153 "Multi-line HTML heading with nested tags should be recognized"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_multiline_html_heading_various_levels() {
1159 for level in 1..=6 {
1161 let rule = MD041FirstLineHeading::new(level, false);
1162
1163 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1165 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1166 let result = rule.check(&ctx).unwrap();
1167 assert!(
1168 result.is_empty(),
1169 "Multi-line HTML heading at level {level} should be recognized"
1170 );
1171
1172 let wrong_level = if level == 1 { 2 } else { 1 };
1174 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1175 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177 assert_eq!(result.len(), 1);
1178 assert!(result[0].message.contains(&format!("level {level} heading")));
1179 }
1180 }
1181
1182 #[test]
1183 fn test_issue_152_nested_heading_spans_many_lines() {
1184 let rule = MD041FirstLineHeading::default();
1185
1186 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>";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1190 }
1191
1192 #[test]
1193 fn test_issue_152_picture_tag_heading() {
1194 let rule = MD041FirstLineHeading::default();
1195
1196 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>";
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 "Picture tag inside multi-line HTML heading should be recognized"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_badge_images_before_heading() {
1207 let rule = MD041FirstLineHeading::default();
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(), "Badge image should be skipped");
1214
1215 let content = " \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(), "Multiple badges should be skipped");
1220
1221 let content = "[](https://example.com)\n\n# My Project";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(result.is_empty(), "Linked badge should be skipped");
1226 }
1227
1228 #[test]
1229 fn test_multiple_badge_lines_before_heading() {
1230 let rule = MD041FirstLineHeading::default();
1231
1232 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let result = rule.check(&ctx).unwrap();
1236 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1237 }
1238
1239 #[test]
1240 fn test_badges_without_heading_still_warns() {
1241 let rule = MD041FirstLineHeading::default();
1242
1243 let content = "\n\nThis is not a heading.";
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1246 let result = rule.check(&ctx).unwrap();
1247 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1248 }
1249
1250 #[test]
1251 fn test_mixed_content_not_badge_line() {
1252 let rule = MD041FirstLineHeading::default();
1253
1254 let content = " Some text here\n\n# Heading";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1259 }
1260
1261 #[test]
1262 fn test_is_badge_image_line_unit() {
1263 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1265 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1266 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1267 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1268
1269 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1271 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1272 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1273 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1274 }
1275
1276 #[test]
1280 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1281 let rule = MD041FirstLineHeading::default();
1282
1283 let content = "[](){ #example }\n# Title";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(
1288 result.is_empty(),
1289 "MkDocs anchor line should be skipped in MkDocs flavor"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1295 let rule = MD041FirstLineHeading::default();
1296
1297 let content = "[](){ #example }\n# Title";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert_eq!(
1302 result.len(),
1303 1,
1304 "MkDocs anchor line should NOT be skipped in Standard flavor"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_multiple_mkdocs_anchors_before_heading() {
1310 let rule = MD041FirstLineHeading::default();
1311
1312 let content = "[](){ #first }\n[](){ #second }\n# Title";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1315 let result = rule.check(&ctx).unwrap();
1316 assert!(
1317 result.is_empty(),
1318 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_mkdocs_anchor_with_front_matter() {
1324 let rule = MD041FirstLineHeading::default();
1325
1326 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(
1331 result.is_empty(),
1332 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_mkdocs_anchor_kramdown_style() {
1338 let rule = MD041FirstLineHeading::default();
1339
1340 let content = "[](){: #anchor }\n# Title";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1343 let result = rule.check(&ctx).unwrap();
1344 assert!(
1345 result.is_empty(),
1346 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_mkdocs_anchor_without_heading_still_warns() {
1352 let rule = MD041FirstLineHeading::default();
1353
1354 let content = "[](){ #anchor }\nThis is not a heading.";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert_eq!(
1359 result.len(),
1360 1,
1361 "MkDocs anchor followed by non-heading should still trigger MD041"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_mkdocs_anchor_with_html_comment() {
1367 let rule = MD041FirstLineHeading::default();
1368
1369 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1372 let result = rule.check(&ctx).unwrap();
1373 assert!(
1374 result.is_empty(),
1375 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1376 );
1377 }
1378
1379 #[test]
1382 fn test_fix_disabled_by_default() {
1383 use crate::rule::Rule;
1384 let rule = MD041FirstLineHeading::default();
1385
1386 let content = "## Wrong Level\n\nContent.";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389 let fixed = rule.fix(&ctx).unwrap();
1390 assert_eq!(fixed, content, "Fix should not change content when disabled");
1391 }
1392
1393 #[test]
1394 fn test_fix_wrong_heading_level() {
1395 use crate::rule::Rule;
1396 let rule = MD041FirstLineHeading {
1397 level: 1,
1398 front_matter_title: false,
1399 front_matter_title_pattern: None,
1400 fix_enabled: true,
1401 };
1402
1403 let content = "## Wrong Level\n\nContent.\n";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let fixed = rule.fix(&ctx).unwrap();
1407 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1408 }
1409
1410 #[test]
1411 fn test_fix_heading_after_preamble() {
1412 use crate::rule::Rule;
1413 let rule = MD041FirstLineHeading {
1414 level: 1,
1415 front_matter_title: false,
1416 front_matter_title_pattern: None,
1417 fix_enabled: true,
1418 };
1419
1420 let content = "\n\n# Title\n\nContent.\n";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let fixed = rule.fix(&ctx).unwrap();
1424 assert!(
1425 fixed.starts_with("# Title\n"),
1426 "Heading should be moved to first line, got: {fixed}"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_fix_heading_after_html_comment() {
1432 use crate::rule::Rule;
1433 let rule = MD041FirstLineHeading {
1434 level: 1,
1435 front_matter_title: false,
1436 front_matter_title_pattern: None,
1437 fix_enabled: true,
1438 };
1439
1440 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443 let fixed = rule.fix(&ctx).unwrap();
1444 assert!(
1445 fixed.starts_with("# Title\n"),
1446 "Heading should be moved above comment, got: {fixed}"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_fix_heading_level_and_move() {
1452 use crate::rule::Rule;
1453 let rule = MD041FirstLineHeading {
1454 level: 1,
1455 front_matter_title: false,
1456 front_matter_title_pattern: None,
1457 fix_enabled: true,
1458 };
1459
1460 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let fixed = rule.fix(&ctx).unwrap();
1464 assert!(
1465 fixed.starts_with("# Wrong Level\n"),
1466 "Heading should be fixed and moved, got: {fixed}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_fix_with_front_matter() {
1472 use crate::rule::Rule;
1473 let rule = MD041FirstLineHeading {
1474 level: 1,
1475 front_matter_title: false,
1476 front_matter_title_pattern: None,
1477 fix_enabled: true,
1478 };
1479
1480 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1483 let fixed = rule.fix(&ctx).unwrap();
1484 assert!(
1485 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1486 "Heading should be right after front matter, got: {fixed}"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_fix_cannot_fix_no_heading() {
1492 use crate::rule::Rule;
1493 let rule = MD041FirstLineHeading {
1494 level: 1,
1495 front_matter_title: false,
1496 front_matter_title_pattern: None,
1497 fix_enabled: true,
1498 };
1499
1500 let content = "Just some text.\n\nMore text.\n";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let fixed = rule.fix(&ctx).unwrap();
1504 assert_eq!(fixed, content, "Should not change content when no heading exists");
1505 }
1506
1507 #[test]
1508 fn test_fix_cannot_fix_content_before_heading() {
1509 use crate::rule::Rule;
1510 let rule = MD041FirstLineHeading {
1511 level: 1,
1512 front_matter_title: false,
1513 front_matter_title_pattern: None,
1514 fix_enabled: true,
1515 };
1516
1517 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let fixed = rule.fix(&ctx).unwrap();
1521 assert_eq!(
1522 fixed, content,
1523 "Should not change content when real content exists before heading"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_fix_already_correct() {
1529 use crate::rule::Rule;
1530 let rule = MD041FirstLineHeading {
1531 level: 1,
1532 front_matter_title: false,
1533 front_matter_title_pattern: None,
1534 fix_enabled: true,
1535 };
1536
1537 let content = "# Title\n\nContent.\n";
1539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1540 let fixed = rule.fix(&ctx).unwrap();
1541 assert_eq!(fixed, content, "Should not change already correct content");
1542 }
1543
1544 #[test]
1545 fn test_fix_setext_heading_removes_underline() {
1546 use crate::rule::Rule;
1547 let rule = MD041FirstLineHeading {
1548 level: 1,
1549 front_matter_title: false,
1550 front_matter_title_pattern: None,
1551 fix_enabled: true,
1552 };
1553
1554 let content = "Wrong Level\n-----------\n\nContent.\n";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let fixed = rule.fix(&ctx).unwrap();
1558 assert_eq!(
1559 fixed, "# Wrong Level\n\nContent.\n",
1560 "Setext heading should be converted to ATX and underline removed"
1561 );
1562 }
1563
1564 #[test]
1565 fn test_fix_setext_h1_heading() {
1566 use crate::rule::Rule;
1567 let rule = MD041FirstLineHeading {
1568 level: 1,
1569 front_matter_title: false,
1570 front_matter_title_pattern: None,
1571 fix_enabled: true,
1572 };
1573
1574 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let fixed = rule.fix(&ctx).unwrap();
1578 assert_eq!(
1579 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1580 "Setext h1 should be moved and converted to ATX"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_html_heading_not_claimed_fixable() {
1586 use crate::rule::Rule;
1587 let rule = MD041FirstLineHeading {
1588 level: 1,
1589 front_matter_title: false,
1590 front_matter_title_pattern: None,
1591 fix_enabled: true,
1592 };
1593
1594 let content = "<h2>Title</h2>\n\nContent.\n";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let warnings = rule.check(&ctx).unwrap();
1598 assert_eq!(warnings.len(), 1);
1599 assert!(
1600 warnings[0].fix.is_none(),
1601 "HTML heading should not be claimed as fixable"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_no_heading_not_claimed_fixable() {
1607 use crate::rule::Rule;
1608 let rule = MD041FirstLineHeading {
1609 level: 1,
1610 front_matter_title: false,
1611 front_matter_title_pattern: None,
1612 fix_enabled: true,
1613 };
1614
1615 let content = "Just some text.\n\nMore text.\n";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let warnings = rule.check(&ctx).unwrap();
1619 assert_eq!(warnings.len(), 1);
1620 assert!(
1621 warnings[0].fix.is_none(),
1622 "Document without heading should not be claimed as fixable"
1623 );
1624 }
1625
1626 #[test]
1627 fn test_content_before_heading_not_claimed_fixable() {
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 = "Intro text.\n\n## Heading\n\nMore.\n";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let warnings = rule.check(&ctx).unwrap();
1640 assert_eq!(warnings.len(), 1);
1641 assert!(
1642 warnings[0].fix.is_none(),
1643 "Document with content before heading should not be claimed as fixable"
1644 );
1645 }
1646}