1mod md041_config;
2
3pub(super) use md041_config::MD041Config;
4
5use crate::filtered_lines::FilteredLinesExt;
6use crate::lint_context::HeadingStyle;
7use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rules::front_matter_utils::FrontMatterUtils;
9use crate::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
10use crate::utils::range_utils::calculate_line_range;
11use crate::utils::regex_cache::HTML_HEADING_PATTERN;
12use regex::Regex;
13
14#[derive(Clone)]
19pub struct MD041FirstLineHeading {
20 pub level: usize,
21 pub front_matter_title: bool,
22 pub front_matter_title_pattern: Option<Regex>,
23 pub allow_preamble: bool,
24 pub fix_enabled: bool,
25}
26
27impl Default for MD041FirstLineHeading {
28 fn default() -> Self {
29 Self {
30 level: 1,
31 front_matter_title: true,
32 front_matter_title_pattern: None,
33 allow_preamble: false,
34 fix_enabled: false,
35 }
36 }
37}
38
39enum FixPlan {
41 MoveOrRelevel {
43 front_matter_end_idx: usize,
44 heading_idx: usize,
45 is_setext: bool,
46 current_level: usize,
47 needs_level_fix: bool,
48 },
49 PromotePlainText {
51 front_matter_end_idx: usize,
52 title_line_idx: usize,
53 title_text: String,
54 },
55 InsertDerived {
58 front_matter_end_idx: usize,
59 derived_title: String,
60 },
61 RelevelInPlace {
65 heading_idx: usize,
66 is_setext: bool,
67 current_level: usize,
68 },
69}
70
71impl MD041FirstLineHeading {
72 pub fn new(level: usize, front_matter_title: bool) -> Self {
73 Self {
74 level,
75 front_matter_title,
76 front_matter_title_pattern: None,
77 allow_preamble: false,
78 fix_enabled: false,
79 }
80 }
81
82 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
83 Self::with_pattern_from(level, front_matter_title, pattern, fix_enabled, false)
84 }
85
86 fn with_pattern_from(
89 level: usize,
90 front_matter_title: bool,
91 pattern: Option<String>,
92 fix_enabled: bool,
93 values_withheld: bool,
94 ) -> Self {
95 let front_matter_title_pattern = pattern.and_then(|p| {
96 crate::rule_config_serde::compile_config_regex(&p, "MD041", "front-matter-title-pattern", values_withheld)
97 });
98
99 Self {
100 level,
101 front_matter_title,
102 front_matter_title_pattern,
103 allow_preamble: false,
104 fix_enabled,
105 }
106 }
107
108 pub fn with_allow_preamble(mut self, allow_preamble: bool) -> Self {
110 self.allow_preamble = allow_preamble;
111 self
112 }
113
114 fn has_front_matter_title(&self, content: &str) -> bool {
115 if !self.front_matter_title {
116 return false;
117 }
118
119 if let Some(ref pattern) = self.front_matter_title_pattern {
121 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
122 for line in front_matter_lines {
123 if pattern.is_match(line) {
124 return true;
125 }
126 }
127 return false;
128 }
129
130 FrontMatterUtils::has_front_matter_field(content, "title:")
132 }
133
134 fn is_non_content_line(line: &str) -> bool {
136 let trimmed = line.trim();
137
138 if trimmed.starts_with('[') && trimmed.contains("]: ") {
140 return true;
141 }
142
143 if trimmed.starts_with('*') && trimmed.contains("]: ") {
145 return true;
146 }
147
148 if Self::is_badge_image_line(trimmed) {
151 return true;
152 }
153
154 false
155 }
156
157 fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
163 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
164
165 let filtered = ctx
166 .filtered_lines()
167 .skip_front_matter()
168 .skip_esm_blocks()
169 .skip_html_comments()
170 .skip_mdx_comments();
171
172 for filtered_line in filtered {
173 let idx = filtered_line.line_num - 1;
174 let line_info = &ctx.lines[idx];
175
176 if line_info.is_blank || line_info.is_kramdown_block_ial {
177 continue;
178 }
179
180 let line_content = filtered_line.content;
181 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
182 continue;
183 }
184 if Self::is_non_content_line(line_content) {
185 continue;
186 }
187 return Some(idx);
188 }
189 None
190 }
191
192 fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
201 for (idx, line_info) in ctx.lines.iter().enumerate() {
202 if line_info.is_blank
203 || line_info.in_front_matter
204 || line_info.in_code_block
205 || line_info.in_html_comment
206 || line_info.in_mdx_comment
207 || line_info.in_math_block
208 {
209 continue;
210 }
211
212 let in_container = line_info.in_list_block
213 || line_info.blockquote.is_some()
214 || line_info.in_admonition
215 || line_info.in_content_tab
216 || line_info.in_pandoc_div
217 || line_info.in_pymdown_block
218 || line_info.in_kramdown_extension_block;
219 if in_container {
220 continue;
221 }
222
223 if line_info.heading.is_some() {
224 return Some(idx);
225 }
226
227 let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
230 if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
231 return Some(idx);
232 }
233 }
234 None
235 }
236
237 fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
244 if self.allow_preamble {
245 Self::first_top_level_heading_idx(ctx)
246 } else {
247 Self::first_content_line_idx(ctx)
248 }
249 }
250
251 fn is_badge_image_line(line: &str) -> bool {
257 if line.is_empty() {
258 return false;
259 }
260
261 if !line.starts_with('!') && !line.starts_with('[') {
263 return false;
264 }
265
266 let mut remaining = line;
268 while !remaining.is_empty() {
269 remaining = remaining.trim_start();
270 if remaining.is_empty() {
271 break;
272 }
273
274 if remaining.starts_with("[![") {
276 if let Some(end) = Self::find_linked_image_end(remaining) {
277 remaining = &remaining[end..];
278 continue;
279 }
280 return false;
281 }
282
283 if remaining.starts_with("![") {
285 if let Some(end) = Self::find_image_end(remaining) {
286 remaining = &remaining[end..];
287 continue;
288 }
289 return false;
290 }
291
292 return false;
294 }
295
296 true
297 }
298
299 fn find_image_end(s: &str) -> Option<usize> {
301 if !s.starts_with("![") {
302 return None;
303 }
304 let alt_end = s[2..].find("](")?;
306 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
309 Some(paren_start + paren_end + 1)
310 }
311
312 fn find_linked_image_end(s: &str) -> Option<usize> {
314 if !s.starts_with("[![") {
315 return None;
316 }
317 let inner_end = Self::find_image_end(&s[1..])?;
319 let after_inner = 1 + inner_end;
320 if !s[after_inner..].starts_with("](") {
322 return None;
323 }
324 let link_start = after_inner + 2;
325 let link_end = s[link_start..].find(')')?;
326 Some(link_start + link_end + 1)
327 }
328
329 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
331 let trimmed = line.trim_start();
332
333 if trimmed.starts_with('#') {
335 let hashes = "#".repeat(target_level);
336 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
338 let after_hashes = &trimmed[content_start..];
339 let content = after_hashes.trim_start();
340
341 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
343 format!("{leading_ws}{hashes} {content}")
344 } else {
345 let hashes = "#".repeat(target_level);
348 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
349 format!("{leading_ws}{hashes} {trimmed}")
350 }
351 }
352
353 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
361 if text.is_empty() {
362 return false;
363 }
364
365 if !next_is_blank_or_eof {
366 return false;
367 }
368
369 if text.len() > 80 {
370 return false;
371 }
372
373 let last_char = text.chars().next_back().unwrap_or(' ');
374 if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
375 return false;
376 }
377
378 if text.starts_with('#')
380 || text.starts_with("- ")
381 || text.starts_with("* ")
382 || text.starts_with("+ ")
383 || text.starts_with("> ")
384 {
385 return false;
386 }
387
388 true
389 }
390
391 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
395 let path = ctx.source_file.as_ref()?;
396 let stem = path.file_stem().and_then(|s| s.to_str())?;
397
398 let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
401 path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
402 } else {
403 stem
404 };
405
406 let title: String = effective_stem
407 .split(['-', '_'])
408 .filter(|w| !w.is_empty())
409 .map(|word| {
410 let mut chars = word.chars();
411 match chars.next() {
412 None => String::new(),
413 Some(first) => {
414 let upper: String = first.to_uppercase().collect();
415 upper + chars.as_str()
416 }
417 }
418 })
419 .collect::<Vec<_>>()
420 .join(" ");
421
422 if title.is_empty() { None } else { Some(title) }
423 }
424
425 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
427 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
429 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
430 && let Some(h_level) = captures.get(1)
431 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
432 {
433 return true;
434 }
435
436 let html_tags = ctx.html_tags();
438 let target_tag = format!("h{level}");
439
440 let opening_index = html_tags.iter().position(|tag| {
442 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
444 && !tag.is_closing
445 });
446
447 let Some(open_idx) = opening_index else {
448 return false;
449 };
450
451 let mut depth = 1usize;
454 for tag in html_tags.iter().skip(open_idx + 1) {
455 if tag.line <= first_line_idx + 1 {
457 continue;
458 }
459
460 if tag.tag_name == target_tag {
461 if tag.is_closing {
462 depth -= 1;
463 if depth == 0 {
464 return true;
465 }
466 } else if !tag.is_self_closing {
467 depth += 1;
468 }
469 }
470 }
471
472 false
473 }
474
475 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
477 if ctx.lines.is_empty() {
478 return None;
479 }
480
481 if self.allow_preamble {
485 let heading_idx = Self::first_top_level_heading_idx(ctx)?;
486 let heading = ctx.lines[heading_idx].heading.as_ref()?;
487 if heading.level as usize == self.level {
488 return None;
489 }
490 return Some(FixPlan::RelevelInPlace {
491 heading_idx,
492 is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
493 current_level: heading.level as usize,
494 });
495 }
496
497 let mut front_matter_end_idx = 0;
499 for line_info in &ctx.lines {
500 if line_info.in_front_matter {
501 front_matter_end_idx += 1;
502 } else {
503 break;
504 }
505 }
506
507 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
508
509 let mut found_heading: Option<(usize, bool, usize)> = None;
511 let mut first_title_candidate: Option<(usize, String)> = None;
513 let mut found_non_title_content = false;
515 let mut saw_non_directive_content = false;
517
518 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
519 let line_content = line_info.content(ctx.content);
520 let trimmed = line_content.trim();
521
522 let is_preamble = trimmed.is_empty()
524 || line_info.in_html_comment
525 || line_info.in_mdx_comment
526 || line_info.in_html_block
527 || Self::is_non_content_line(line_content)
528 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
529 || line_info.in_kramdown_extension_block
530 || line_info.is_kramdown_block_ial;
531
532 if is_preamble {
533 continue;
534 }
535
536 let is_directive_block = line_info.in_admonition
539 || line_info.in_content_tab
540 || line_info.in_pandoc_div
541 || line_info.is_div_marker
542 || line_info.in_pymdown_block;
543
544 if !is_directive_block {
545 saw_non_directive_content = true;
546 }
547
548 if let Some(heading) = &line_info.heading {
550 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
551 found_heading = Some((idx, is_setext, heading.level as usize));
552 break 'scan;
553 }
554
555 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
557 let next_is_blank_or_eof = ctx
558 .lines
559 .get(idx + 1)
560 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
561
562 if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
563 first_title_candidate = Some((idx, trimmed.to_string()));
564 } else {
565 found_non_title_content = true;
566 }
567 }
568 }
569
570 if let Some((h_idx, is_setext, current_level)) = found_heading {
571 if found_non_title_content || first_title_candidate.is_some() {
575 return None;
576 }
577
578 let needs_level_fix = current_level != self.level;
579 let needs_move = h_idx > front_matter_end_idx;
580
581 if needs_level_fix || needs_move {
582 return Some(FixPlan::MoveOrRelevel {
583 front_matter_end_idx,
584 heading_idx: h_idx,
585 is_setext,
586 current_level,
587 needs_level_fix,
588 });
589 }
590 return None; }
592
593 if let Some((title_idx, title_text)) = first_title_candidate {
596 return Some(FixPlan::PromotePlainText {
597 front_matter_end_idx,
598 title_line_idx: title_idx,
599 title_text,
600 });
601 }
602
603 if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
606 return Some(FixPlan::InsertDerived {
607 front_matter_end_idx,
608 derived_title,
609 });
610 }
611
612 None
613 }
614
615 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
617 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
618 }
619}
620
621impl Rule for MD041FirstLineHeading {
622 fn name(&self) -> &'static str {
623 "MD041"
624 }
625
626 fn description(&self) -> &'static str {
627 "First line in file should be a top level heading"
628 }
629
630 fn fix_capability(&self) -> FixCapability {
635 if self.fix_enabled {
636 FixCapability::ConditionallyFixable
637 } else {
638 FixCapability::Unfixable
639 }
640 }
641
642 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
643 let mut warnings = Vec::new();
644
645 if self.should_skip(ctx) {
647 return Ok(warnings);
648 }
649
650 let Some(first_line_idx) = self.checked_line_idx(ctx) else {
651 return Ok(warnings);
652 };
653
654 let first_line_info = &ctx.lines[first_line_idx];
656 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
657 heading.level as usize == self.level
658 } else {
659 Self::is_html_heading(ctx, first_line_idx, self.level)
661 };
662
663 if !is_correct_heading {
664 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
667 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
668
669 let fix = if self.can_fix(ctx) {
675 self.analyze_for_fix(ctx).and_then(|plan| {
676 let range_start = first_line_info.byte_offset;
677 let range_end = range_start + first_line_info.byte_len;
678 match &plan {
679 FixPlan::MoveOrRelevel {
680 heading_idx,
681 current_level,
682 needs_level_fix,
683 is_setext,
684 ..
685 } if *heading_idx == first_line_idx => {
686 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
688 let replacement = if *needs_level_fix || *is_setext {
689 self.fix_heading_level(heading_line, *current_level, self.level)
690 } else {
691 heading_line.to_string()
692 };
693 Some(Fix::new(range_start..range_end, replacement))
694 }
695 FixPlan::RelevelInPlace {
696 heading_idx,
697 current_level,
698 is_setext,
699 } if *heading_idx == first_line_idx && !*is_setext => {
700 let replacement = self.fix_heading_level(
701 ctx.lines[*heading_idx].content(ctx.content),
702 *current_level,
703 self.level,
704 );
705 Some(Fix::new(range_start..range_end, replacement))
706 }
707 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
708 let replacement = format!(
709 "{} {}",
710 "#".repeat(self.level),
711 ctx.lines[*title_line_idx].content(ctx.content).trim()
712 );
713 Some(Fix::new(range_start..range_end, replacement))
714 }
715 _ => {
716 self.fix(ctx)
720 .ok()
721 .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
722 }
723 }
724 })
725 } else {
726 None
727 };
728
729 warnings.push(LintWarning {
730 rule_name: Some(self.name().to_string()),
731 line: start_line,
732 column: start_col,
733 end_line,
734 end_column: end_col,
735 message: if self.allow_preamble {
736 format!("First heading in file should be a level {} heading", self.level)
737 } else {
738 format!("First line in file should be a level {} heading", self.level)
739 },
740 severity: Severity::Warning,
741 fix,
742 });
743 }
744 Ok(warnings)
745 }
746
747 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
748 if !self.fix_enabled {
749 return Ok(ctx.content.to_string());
750 }
751
752 if self.should_skip(ctx) {
753 return Ok(ctx.content.to_string());
754 }
755
756 let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
759 if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
760 return Ok(ctx.content.to_string());
761 }
762
763 let Some(plan) = self.analyze_for_fix(ctx) else {
764 return Ok(ctx.content.to_string());
765 };
766
767 let lines = ctx.raw_lines();
768
769 let mut result = String::new();
770 let preserve_trailing_newline = ctx.content.ends_with('\n');
771
772 match plan {
773 FixPlan::MoveOrRelevel {
774 front_matter_end_idx,
775 heading_idx,
776 is_setext,
777 current_level,
778 needs_level_fix,
779 } => {
780 let heading_line = ctx.lines[heading_idx].content(ctx.content);
781 let fixed_heading = if needs_level_fix || is_setext {
782 self.fix_heading_level(heading_line, current_level, self.level)
783 } else {
784 heading_line.to_string()
785 };
786
787 for line in lines.iter().take(front_matter_end_idx) {
788 result.push_str(line);
789 result.push('\n');
790 }
791 result.push_str(&fixed_heading);
792 result.push('\n');
793 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
794 if idx == heading_idx {
795 continue;
796 }
797 if is_setext && idx == heading_idx + 1 {
798 continue;
799 }
800 result.push_str(line);
801 result.push('\n');
802 }
803 }
804
805 FixPlan::PromotePlainText {
806 front_matter_end_idx,
807 title_line_idx,
808 title_text,
809 } => {
810 let hashes = "#".repeat(self.level);
811 let new_heading = format!("{hashes} {title_text}");
812
813 for line in lines.iter().take(front_matter_end_idx) {
814 result.push_str(line);
815 result.push('\n');
816 }
817 result.push_str(&new_heading);
818 result.push('\n');
819 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
820 if idx == title_line_idx {
821 continue;
822 }
823 result.push_str(line);
824 result.push('\n');
825 }
826 }
827
828 FixPlan::RelevelInPlace {
829 heading_idx,
830 is_setext,
831 current_level,
832 } => {
833 for (idx, line) in lines.iter().enumerate() {
834 if idx == heading_idx {
835 result.push_str(&self.fix_heading_level(line, current_level, self.level));
836 result.push('\n');
837 continue;
838 }
839 if is_setext && idx == heading_idx + 1 {
841 continue;
842 }
843 result.push_str(line);
844 result.push('\n');
845 }
846 }
847
848 FixPlan::InsertDerived {
849 front_matter_end_idx,
850 derived_title,
851 } => {
852 let hashes = "#".repeat(self.level);
853 let new_heading = format!("{hashes} {derived_title}");
854
855 for line in lines.iter().take(front_matter_end_idx) {
856 result.push_str(line);
857 result.push('\n');
858 }
859 result.push_str(&new_heading);
860 result.push('\n');
861 result.push('\n');
862 for line in lines.iter().skip(front_matter_end_idx) {
863 result.push_str(line);
864 result.push('\n');
865 }
866 }
867 }
868
869 if !preserve_trailing_newline && result.ends_with('\n') {
870 result.pop();
871 }
872
873 Ok(result)
874 }
875
876 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
878 let only_directives = !ctx.content.is_empty()
883 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
884 let t = l.trim();
885 (t.starts_with("{{#") && t.ends_with("}}"))
887 || (t.starts_with("<!--") && t.ends_with("-->"))
889 });
890
891 ctx.content.is_empty()
892 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
893 || only_directives
894 }
895
896 fn as_any(&self) -> &dyn std::any::Any {
897 self
898 }
899
900 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
901 where
902 Self: Sized,
903 {
904 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
906
907 let use_front_matter = !md041_config.front_matter_title.is_empty();
908
909 Box::new(
910 MD041FirstLineHeading::with_pattern_from(
911 md041_config.level.as_usize(),
912 use_front_matter,
913 md041_config.front_matter_title_pattern,
914 md041_config.fix,
915 config.withheld_rule_values.contains("MD041"),
916 )
917 .with_allow_preamble(md041_config.allow_preamble),
918 )
919 }
920
921 fn default_config_section(&self) -> Option<(String, toml::Value)> {
922 Some((
923 "MD041".to_string(),
924 toml::toml! {
925 level = 1
926 front-matter-title = "title"
927 front-matter-title-pattern = ""
928 allow-preamble = false
929 fix = false
930 }
931 .into(),
932 ))
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use crate::lint_context::LintContext;
940
941 #[test]
942 fn test_first_line_is_heading_correct_level() {
943 let rule = MD041FirstLineHeading::default();
944
945 let content = "# My Document\n\nSome content here.";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert!(
950 result.is_empty(),
951 "Expected no warnings when first line is a level 1 heading"
952 );
953 }
954
955 #[test]
956 fn test_first_line_is_heading_wrong_level() {
957 let rule = MD041FirstLineHeading::default();
958
959 let content = "## My Document\n\nSome content here.";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let result = rule.check(&ctx).unwrap();
963 assert_eq!(result.len(), 1);
964 assert_eq!(result[0].line, 1);
965 assert!(result[0].message.contains("level 1 heading"));
966 }
967
968 #[test]
969 fn test_first_line_not_heading() {
970 let rule = MD041FirstLineHeading::default();
971
972 let content = "This is not a heading\n\n# This is a heading";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(result.len(), 1);
977 assert_eq!(result[0].line, 1);
978 assert!(result[0].message.contains("level 1 heading"));
979 }
980
981 #[test]
982 fn test_empty_lines_before_heading() {
983 let rule = MD041FirstLineHeading::default();
984
985 let content = "\n\n# My Document\n\nSome content.";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert!(
990 result.is_empty(),
991 "Expected no warnings when empty lines precede a valid heading"
992 );
993
994 let content = "\n\nNot a heading\n\nSome content.";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert_eq!(result.len(), 1);
999 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
1001 }
1002
1003 #[test]
1004 fn test_front_matter_with_title() {
1005 let rule = MD041FirstLineHeading::new(1, true);
1006
1007 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content 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 "Expected no warnings when front matter has title field"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_front_matter_without_title() {
1019 let rule = MD041FirstLineHeading::new(1, true);
1020
1021 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert_eq!(result.len(), 1);
1026 assert_eq!(result[0].line, 6); }
1028
1029 #[test]
1030 fn test_front_matter_disabled() {
1031 let rule = MD041FirstLineHeading::new(1, false);
1032
1033 let content = "---\ntitle: My Document\n---\n\nSome content here.";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037 assert_eq!(result.len(), 1);
1038 assert_eq!(result[0].line, 5); }
1040
1041 #[test]
1042 fn test_html_comments_before_heading() {
1043 let rule = MD041FirstLineHeading::default();
1044
1045 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(
1050 result.is_empty(),
1051 "HTML comments should be skipped when checking for first heading"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_multiline_html_comment_before_heading() {
1057 let rule = MD041FirstLineHeading::default();
1058
1059 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063 assert!(
1064 result.is_empty(),
1065 "Multi-line HTML comments should be skipped when checking for first heading"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_html_comment_with_blank_lines_before_heading() {
1071 let rule = MD041FirstLineHeading::default();
1072
1073 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert!(
1078 result.is_empty(),
1079 "HTML comments with blank lines should be skipped when checking for first heading"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_html_comment_before_html_heading() {
1085 let rule = MD041FirstLineHeading::default();
1086
1087 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091 assert!(
1092 result.is_empty(),
1093 "HTML comments should be skipped before HTML headings"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_document_with_only_html_comments() {
1099 let rule = MD041FirstLineHeading::default();
1100
1101 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105 assert!(
1106 result.is_empty(),
1107 "Documents with only HTML comments should not trigger MD041"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_html_comment_followed_by_non_heading() {
1113 let rule = MD041FirstLineHeading::default();
1114
1115 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert_eq!(
1120 result.len(),
1121 1,
1122 "HTML comment followed by non-heading should still trigger MD041"
1123 );
1124 assert_eq!(
1125 result[0].line, 2,
1126 "Warning should be on the first non-comment, non-heading line"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_multiple_html_comments_before_heading() {
1132 let rule = MD041FirstLineHeading::default();
1133
1134 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(
1139 result.is_empty(),
1140 "Multiple HTML comments should all be skipped before heading"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_html_comment_with_wrong_level_heading() {
1146 let rule = MD041FirstLineHeading::default();
1147
1148 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert_eq!(
1153 result.len(),
1154 1,
1155 "HTML comment followed by wrong-level heading should still trigger MD041"
1156 );
1157 assert!(
1158 result[0].message.contains("level 1 heading"),
1159 "Should require level 1 heading"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_html_comment_mixed_with_reference_definitions() {
1165 let rule = MD041FirstLineHeading::default();
1166
1167 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(
1172 result.is_empty(),
1173 "HTML comments and reference definitions should both be skipped before heading"
1174 );
1175 }
1176
1177 #[test]
1178 fn test_html_comment_after_front_matter() {
1179 let rule = MD041FirstLineHeading::default();
1180
1181 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185 assert!(
1186 result.is_empty(),
1187 "HTML comments after front matter should be skipped before heading"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_html_comment_not_at_start_should_not_affect_rule() {
1193 let rule = MD041FirstLineHeading::default();
1194
1195 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
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 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_multiline_html_comment_followed_by_non_heading() {
1207 let rule = MD041FirstLineHeading::default();
1208
1209 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let result = rule.check(&ctx).unwrap();
1213 assert_eq!(
1214 result.len(),
1215 1,
1216 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1217 );
1218 assert_eq!(
1219 result[0].line, 5,
1220 "Warning should be on the first non-comment, non-heading line"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_different_heading_levels() {
1226 let rule = MD041FirstLineHeading::new(2, false);
1228
1229 let content = "## Second Level Heading\n\nContent.";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let result = rule.check(&ctx).unwrap();
1232 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1233
1234 let content = "# First Level Heading\n\nContent.";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238 assert_eq!(result.len(), 1);
1239 assert!(result[0].message.contains("level 2 heading"));
1240 }
1241
1242 #[test]
1243 fn test_setext_headings() {
1244 let rule = MD041FirstLineHeading::default();
1245
1246 let content = "My Document\n===========\n\nContent.";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1251
1252 let content = "My Document\n-----------\n\nContent.";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert_eq!(result.len(), 1);
1257 assert!(result[0].message.contains("level 1 heading"));
1258 }
1259
1260 #[test]
1261 fn test_empty_document() {
1262 let rule = MD041FirstLineHeading::default();
1263
1264 let content = "";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268 assert!(result.is_empty(), "Expected no warnings for empty document");
1269 }
1270
1271 #[test]
1272 fn test_whitespace_only_document() {
1273 let rule = MD041FirstLineHeading::default();
1274
1275 let content = " \n\n \t\n";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1280 }
1281
1282 #[test]
1283 fn test_front_matter_then_whitespace() {
1284 let rule = MD041FirstLineHeading::default();
1285
1286 let content = "---\ntitle: Test\n---\n\n \n\n";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 let result = rule.check(&ctx).unwrap();
1290 assert!(
1291 result.is_empty(),
1292 "Expected no warnings when no content after front matter"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_multiple_front_matter_types() {
1298 let rule = MD041FirstLineHeading::new(1, true);
1299
1300 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304 assert!(
1305 result.is_empty(),
1306 "Expected no warnings for TOML front matter with title"
1307 );
1308
1309 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert!(
1314 result.is_empty(),
1315 "Expected no warnings for JSON front matter with title"
1316 );
1317
1318 let content = "---\ntitle: My Document\n---\n\nContent.";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert!(
1323 result.is_empty(),
1324 "Expected no warnings for YAML front matter with title"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_toml_front_matter_with_heading() {
1330 let rule = MD041FirstLineHeading::default();
1331
1332 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let result = rule.check(&ctx).unwrap();
1336 assert!(
1337 result.is_empty(),
1338 "Expected no warnings when heading follows TOML front matter"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_toml_front_matter_without_title_no_heading() {
1344 let rule = MD041FirstLineHeading::new(1, true);
1345
1346 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert_eq!(result.len(), 1);
1351 assert_eq!(result[0].line, 6);
1352 }
1353
1354 #[test]
1355 fn test_toml_front_matter_level_2_heading() {
1356 let rule = MD041FirstLineHeading::new(2, true);
1358
1359 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(
1363 result.is_empty(),
1364 "Issue #427: TOML front matter with title and correct heading level should not warn"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1370 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1372
1373 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let result = rule.check(&ctx).unwrap();
1376 assert!(
1377 result.is_empty(),
1378 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1379 );
1380 }
1381
1382 #[test]
1383 fn test_json_front_matter_with_heading() {
1384 let rule = MD041FirstLineHeading::default();
1385
1386 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389 let result = rule.check(&ctx).unwrap();
1390 assert!(
1391 result.is_empty(),
1392 "Expected no warnings when heading follows JSON front matter"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_malformed_front_matter() {
1398 let rule = MD041FirstLineHeading::new(1, true);
1399
1400 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403 let result = rule.check(&ctx).unwrap();
1404 assert!(
1405 result.is_empty(),
1406 "Expected no warnings for malformed front matter with title"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_front_matter_with_heading() {
1412 let rule = MD041FirstLineHeading::default();
1413
1414 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417 let result = rule.check(&ctx).unwrap();
1418 assert!(
1419 result.is_empty(),
1420 "Expected no warnings when first line after front matter is correct heading"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_no_fix_suggestion() {
1426 let rule = MD041FirstLineHeading::default();
1427
1428 let content = "Not a heading\n\nContent.";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 let result = rule.check(&ctx).unwrap();
1432 assert_eq!(result.len(), 1);
1433 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1434 }
1435
1436 #[test]
1437 fn test_complex_document_structure() {
1438 let rule = MD041FirstLineHeading::default();
1439
1440 let content =
1442 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 let result = rule.check(&ctx).unwrap();
1445 assert!(
1446 result.is_empty(),
1447 "HTML comments should be skipped, so first heading after comment should be valid"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_heading_with_special_characters() {
1453 let rule = MD041FirstLineHeading::default();
1454
1455 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let result = rule.check(&ctx).unwrap();
1459 assert!(
1460 result.is_empty(),
1461 "Expected no warnings for heading with inline formatting"
1462 );
1463 }
1464
1465 #[test]
1466 fn test_level_configuration() {
1467 for level in 1..=6 {
1469 let rule = MD041FirstLineHeading::new(level, false);
1470
1471 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1473 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert!(
1476 result.is_empty(),
1477 "Expected no warnings for correct level {level} heading"
1478 );
1479
1480 let wrong_level = if level == 1 { 2 } else { 1 };
1482 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1483 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485 assert_eq!(result.len(), 1);
1486 assert!(result[0].message.contains(&format!("level {level} heading")));
1487 }
1488 }
1489
1490 #[test]
1491 fn test_issue_152_multiline_html_heading() {
1492 let rule = MD041FirstLineHeading::default();
1493
1494 let content = "<h1>\nSome text\n</h1>";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let result = rule.check(&ctx).unwrap();
1498 assert!(
1499 result.is_empty(),
1500 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_multiline_html_heading_with_attributes() {
1506 let rule = MD041FirstLineHeading::default();
1507
1508 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512 assert!(
1513 result.is_empty(),
1514 "Multi-line HTML heading with attributes should be recognized"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_multiline_html_heading_wrong_level() {
1520 let rule = MD041FirstLineHeading::default();
1521
1522 let content = "<h2>\nSome text\n</h2>";
1524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1525 let result = rule.check(&ctx).unwrap();
1526 assert_eq!(result.len(), 1);
1527 assert!(result[0].message.contains("level 1 heading"));
1528 }
1529
1530 #[test]
1531 fn test_multiline_html_heading_with_content_after() {
1532 let rule = MD041FirstLineHeading::default();
1533
1534 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let result = rule.check(&ctx).unwrap();
1538 assert!(
1539 result.is_empty(),
1540 "Multi-line HTML heading followed by content should be valid"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_multiline_html_heading_incomplete() {
1546 let rule = MD041FirstLineHeading::default();
1547
1548 let content = "<h1>\nSome text\n\nMore content without closing tag";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551 let result = rule.check(&ctx).unwrap();
1552 assert_eq!(result.len(), 1);
1553 assert!(result[0].message.contains("level 1 heading"));
1554 }
1555
1556 #[test]
1557 fn test_singleline_html_heading_still_works() {
1558 let rule = MD041FirstLineHeading::default();
1559
1560 let content = "<h1>My Document</h1>\n\nContent.";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let result = rule.check(&ctx).unwrap();
1564 assert!(
1565 result.is_empty(),
1566 "Single-line HTML headings should still be recognized"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_multiline_html_heading_with_nested_tags() {
1572 let rule = MD041FirstLineHeading::default();
1573
1574 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578 assert!(
1579 result.is_empty(),
1580 "Multi-line HTML heading with nested tags should be recognized"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_multiline_html_heading_various_levels() {
1586 for level in 1..=6 {
1588 let rule = MD041FirstLineHeading::new(level, false);
1589
1590 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1592 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1593 let result = rule.check(&ctx).unwrap();
1594 assert!(
1595 result.is_empty(),
1596 "Multi-line HTML heading at level {level} should be recognized"
1597 );
1598
1599 let wrong_level = if level == 1 { 2 } else { 1 };
1601 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1602 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1603 let result = rule.check(&ctx).unwrap();
1604 assert_eq!(result.len(), 1);
1605 assert!(result[0].message.contains(&format!("level {level} heading")));
1606 }
1607 }
1608
1609 #[test]
1610 fn test_issue_152_nested_heading_spans_many_lines() {
1611 let rule = MD041FirstLineHeading::default();
1612
1613 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>";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1617 }
1618
1619 #[test]
1620 fn test_issue_152_picture_tag_heading() {
1621 let rule = MD041FirstLineHeading::default();
1622
1623 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>";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625 let result = rule.check(&ctx).unwrap();
1626 assert!(
1627 result.is_empty(),
1628 "Picture tag inside multi-line HTML heading should be recognized"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_badge_images_before_heading() {
1634 let rule = MD041FirstLineHeading::default();
1635
1636 let content = "\n\n# My Project";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert!(result.is_empty(), "Badge image should be skipped");
1641
1642 let content = " \n\n# My Project";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = rule.check(&ctx).unwrap();
1646 assert!(result.is_empty(), "Multiple badges should be skipped");
1647
1648 let content = "[](https://example.com)\n\n# My Project";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652 assert!(result.is_empty(), "Linked badge should be skipped");
1653 }
1654
1655 #[test]
1656 fn test_multiple_badge_lines_before_heading() {
1657 let rule = MD041FirstLineHeading::default();
1658
1659 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662 let result = rule.check(&ctx).unwrap();
1663 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1664 }
1665
1666 #[test]
1667 fn test_badges_without_heading_still_warns() {
1668 let rule = MD041FirstLineHeading::default();
1669
1670 let content = "\n\nThis is not a heading.";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let result = rule.check(&ctx).unwrap();
1674 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1675 }
1676
1677 #[test]
1678 fn test_mixed_content_not_badge_line() {
1679 let rule = MD041FirstLineHeading::default();
1680
1681 let content = " Some text here\n\n# Heading";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let result = rule.check(&ctx).unwrap();
1685 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1686 }
1687
1688 #[test]
1689 fn test_is_badge_image_line_unit() {
1690 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1692 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1693 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1694 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1695
1696 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1698 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1699 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1700 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1701 }
1702
1703 #[test]
1707 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1708 let rule = MD041FirstLineHeading::default();
1709
1710 let content = "[](){ #example }\n# Title";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1713 let result = rule.check(&ctx).unwrap();
1714 assert!(
1715 result.is_empty(),
1716 "MkDocs anchor line should be skipped in MkDocs flavor"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1722 let rule = MD041FirstLineHeading::default();
1723
1724 let content = "[](){ #example }\n# Title";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let result = rule.check(&ctx).unwrap();
1728 assert_eq!(
1729 result.len(),
1730 1,
1731 "MkDocs anchor line should NOT be skipped in Standard flavor"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_multiple_mkdocs_anchors_before_heading() {
1737 let rule = MD041FirstLineHeading::default();
1738
1739 let content = "[](){ #first }\n[](){ #second }\n# Title";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1742 let result = rule.check(&ctx).unwrap();
1743 assert!(
1744 result.is_empty(),
1745 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_mkdocs_anchor_with_front_matter() {
1751 let rule = MD041FirstLineHeading::default();
1752
1753 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1756 let result = rule.check(&ctx).unwrap();
1757 assert!(
1758 result.is_empty(),
1759 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_mkdocs_anchor_kramdown_style() {
1765 let rule = MD041FirstLineHeading::default();
1766
1767 let content = "[](){: #anchor }\n# Title";
1769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1770 let result = rule.check(&ctx).unwrap();
1771 assert!(
1772 result.is_empty(),
1773 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_mkdocs_anchor_without_heading_still_warns() {
1779 let rule = MD041FirstLineHeading::default();
1780
1781 let content = "[](){ #anchor }\nThis is not a heading.";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1784 let result = rule.check(&ctx).unwrap();
1785 assert_eq!(
1786 result.len(),
1787 1,
1788 "MkDocs anchor followed by non-heading should still trigger MD041"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_mkdocs_anchor_with_html_comment() {
1794 let rule = MD041FirstLineHeading::default();
1795
1796 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1799 let result = rule.check(&ctx).unwrap();
1800 assert!(
1801 result.is_empty(),
1802 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1803 );
1804 }
1805
1806 #[test]
1809 fn test_fix_disabled_by_default() {
1810 use crate::rule::Rule;
1811 let rule = MD041FirstLineHeading::default();
1812
1813 let content = "## Wrong Level\n\nContent.";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 let fixed = rule.fix(&ctx).unwrap();
1817 assert_eq!(fixed, content, "Fix should not change content when disabled");
1818 }
1819
1820 #[test]
1821 fn test_fix_wrong_heading_level() {
1822 use crate::rule::Rule;
1823 let rule = MD041FirstLineHeading {
1824 level: 1,
1825 front_matter_title: false,
1826 front_matter_title_pattern: None,
1827 allow_preamble: false,
1828 fix_enabled: true,
1829 };
1830
1831 let content = "## Wrong Level\n\nContent.\n";
1833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834 let fixed = rule.fix(&ctx).unwrap();
1835 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1836 }
1837
1838 #[test]
1839 fn test_fix_heading_after_preamble() {
1840 use crate::rule::Rule;
1841 let rule = MD041FirstLineHeading {
1842 level: 1,
1843 front_matter_title: false,
1844 front_matter_title_pattern: None,
1845 allow_preamble: false,
1846 fix_enabled: true,
1847 };
1848
1849 let content = "\n\n# Title\n\nContent.\n";
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let fixed = rule.fix(&ctx).unwrap();
1853 assert!(
1854 fixed.starts_with("# Title\n"),
1855 "Heading should be moved to first line, got: {fixed}"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_fix_heading_after_html_comment() {
1861 use crate::rule::Rule;
1862 let rule = MD041FirstLineHeading {
1863 level: 1,
1864 front_matter_title: false,
1865 front_matter_title_pattern: None,
1866 allow_preamble: false,
1867 fix_enabled: true,
1868 };
1869
1870 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873 let fixed = rule.fix(&ctx).unwrap();
1874 assert!(
1875 fixed.starts_with("# Title\n"),
1876 "Heading should be moved above comment, got: {fixed}"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_fix_heading_level_and_move() {
1882 use crate::rule::Rule;
1883 let rule = MD041FirstLineHeading {
1884 level: 1,
1885 front_matter_title: false,
1886 front_matter_title_pattern: None,
1887 allow_preamble: false,
1888 fix_enabled: true,
1889 };
1890
1891 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let fixed = rule.fix(&ctx).unwrap();
1895 assert!(
1896 fixed.starts_with("# Wrong Level\n"),
1897 "Heading should be fixed and moved, got: {fixed}"
1898 );
1899 }
1900
1901 #[test]
1902 fn test_fix_with_front_matter() {
1903 use crate::rule::Rule;
1904 let rule = MD041FirstLineHeading {
1905 level: 1,
1906 front_matter_title: false,
1907 front_matter_title_pattern: None,
1908 allow_preamble: false,
1909 fix_enabled: true,
1910 };
1911
1912 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let fixed = rule.fix(&ctx).unwrap();
1916 assert!(
1917 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1918 "Heading should be right after front matter, got: {fixed}"
1919 );
1920 }
1921
1922 #[test]
1923 fn test_fix_with_toml_front_matter() {
1924 use crate::rule::Rule;
1925 let rule = MD041FirstLineHeading {
1926 level: 1,
1927 front_matter_title: false,
1928 front_matter_title_pattern: None,
1929 allow_preamble: false,
1930 fix_enabled: true,
1931 };
1932
1933 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let fixed = rule.fix(&ctx).unwrap();
1937 assert!(
1938 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1939 "Heading should be right after TOML front matter, got: {fixed}"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_fix_cannot_fix_no_heading() {
1945 use crate::rule::Rule;
1946 let rule = MD041FirstLineHeading {
1947 level: 1,
1948 front_matter_title: false,
1949 front_matter_title_pattern: None,
1950 allow_preamble: false,
1951 fix_enabled: true,
1952 };
1953
1954 let content = "Just some text.\n\nMore text.\n";
1956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1957 let fixed = rule.fix(&ctx).unwrap();
1958 assert_eq!(fixed, content, "Should not change content when no heading exists");
1959 }
1960
1961 #[test]
1962 fn test_fix_cannot_fix_content_before_heading() {
1963 use crate::rule::Rule;
1964 let rule = MD041FirstLineHeading {
1965 level: 1,
1966 front_matter_title: false,
1967 front_matter_title_pattern: None,
1968 allow_preamble: false,
1969 fix_enabled: true,
1970 };
1971
1972 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1975 let fixed = rule.fix(&ctx).unwrap();
1976 assert_eq!(
1977 fixed, content,
1978 "Should not change content when real content exists before heading"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_fix_already_correct() {
1984 use crate::rule::Rule;
1985 let rule = MD041FirstLineHeading {
1986 level: 1,
1987 front_matter_title: false,
1988 front_matter_title_pattern: None,
1989 allow_preamble: false,
1990 fix_enabled: true,
1991 };
1992
1993 let content = "# Title\n\nContent.\n";
1995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1996 let fixed = rule.fix(&ctx).unwrap();
1997 assert_eq!(fixed, content, "Should not change already correct content");
1998 }
1999
2000 #[test]
2001 fn test_fix_setext_heading_removes_underline() {
2002 use crate::rule::Rule;
2003 let rule = MD041FirstLineHeading {
2004 level: 1,
2005 front_matter_title: false,
2006 front_matter_title_pattern: None,
2007 allow_preamble: false,
2008 fix_enabled: true,
2009 };
2010
2011 let content = "Wrong Level\n-----------\n\nContent.\n";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let fixed = rule.fix(&ctx).unwrap();
2015 assert_eq!(
2016 fixed, "# Wrong Level\n\nContent.\n",
2017 "Setext heading should be converted to ATX and underline removed"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_fix_setext_h1_heading() {
2023 use crate::rule::Rule;
2024 let rule = MD041FirstLineHeading {
2025 level: 1,
2026 front_matter_title: false,
2027 front_matter_title_pattern: None,
2028 allow_preamble: false,
2029 fix_enabled: true,
2030 };
2031
2032 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035 let fixed = rule.fix(&ctx).unwrap();
2036 assert_eq!(
2037 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2038 "Setext h1 should be moved and converted to ATX"
2039 );
2040 }
2041
2042 #[test]
2043 fn test_html_heading_not_claimed_fixable() {
2044 use crate::rule::Rule;
2045 let rule = MD041FirstLineHeading {
2046 level: 1,
2047 front_matter_title: false,
2048 front_matter_title_pattern: None,
2049 allow_preamble: false,
2050 fix_enabled: true,
2051 };
2052
2053 let content = "<h2>Title</h2>\n\nContent.\n";
2055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2056 let warnings = rule.check(&ctx).unwrap();
2057 assert_eq!(warnings.len(), 1);
2058 assert!(
2059 warnings[0].fix.is_none(),
2060 "HTML heading should not be claimed as fixable"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_no_heading_not_claimed_fixable() {
2066 use crate::rule::Rule;
2067 let rule = MD041FirstLineHeading {
2068 level: 1,
2069 front_matter_title: false,
2070 front_matter_title_pattern: None,
2071 allow_preamble: false,
2072 fix_enabled: true,
2073 };
2074
2075 let content = "Just some text.\n\nMore text.\n";
2077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2078 let warnings = rule.check(&ctx).unwrap();
2079 assert_eq!(warnings.len(), 1);
2080 assert!(
2081 warnings[0].fix.is_none(),
2082 "Document without heading should not be claimed as fixable"
2083 );
2084 }
2085
2086 #[test]
2087 fn test_content_before_heading_not_claimed_fixable() {
2088 use crate::rule::Rule;
2089 let rule = MD041FirstLineHeading {
2090 level: 1,
2091 front_matter_title: false,
2092 front_matter_title_pattern: None,
2093 allow_preamble: false,
2094 fix_enabled: true,
2095 };
2096
2097 let content = "Intro text.\n\n## Heading\n\nMore.\n";
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100 let warnings = rule.check(&ctx).unwrap();
2101 assert_eq!(warnings.len(), 1);
2102 assert!(
2103 warnings[0].fix.is_none(),
2104 "Document with content before heading should not be claimed as fixable"
2105 );
2106 }
2107
2108 #[test]
2111 fn test_fix_html_block_before_heading_is_now_fixable() {
2112 use crate::rule::Rule;
2113 let rule = MD041FirstLineHeading {
2114 level: 1,
2115 front_matter_title: false,
2116 front_matter_title_pattern: None,
2117 allow_preamble: false,
2118 fix_enabled: true,
2119 };
2120
2121 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124
2125 let warnings = rule.check(&ctx).unwrap();
2126 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2127 assert!(
2128 warnings[0].fix.is_some(),
2129 "Should be fixable: heading exists after HTML block preamble"
2130 );
2131
2132 let fixed = rule.fix(&ctx).unwrap();
2133 assert!(
2134 fixed.starts_with("# My Document\n"),
2135 "Heading should be moved to the top, got: {fixed}"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_fix_html_block_wrong_level_before_heading() {
2141 use crate::rule::Rule;
2142 let rule = MD041FirstLineHeading {
2143 level: 1,
2144 front_matter_title: false,
2145 front_matter_title_pattern: None,
2146 allow_preamble: false,
2147 fix_enabled: true,
2148 };
2149
2150 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152 let fixed = rule.fix(&ctx).unwrap();
2153 assert!(
2154 fixed.starts_with("# Wrong Level\n"),
2155 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2156 );
2157 }
2158
2159 #[test]
2162 fn test_fix_promote_plain_text_title() {
2163 use crate::rule::Rule;
2164 let rule = MD041FirstLineHeading {
2165 level: 1,
2166 front_matter_title: false,
2167 front_matter_title_pattern: None,
2168 allow_preamble: false,
2169 fix_enabled: true,
2170 };
2171
2172 let content = "My Project\n\nSome content.\n";
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174
2175 let warnings = rule.check(&ctx).unwrap();
2176 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2177 assert!(
2178 warnings[0].fix.is_some(),
2179 "Should be fixable: first line is a title candidate"
2180 );
2181
2182 let fixed = rule.fix(&ctx).unwrap();
2183 assert_eq!(
2184 fixed, "# My Project\n\nSome content.\n",
2185 "Title line should be promoted to heading"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_fix_promote_plain_text_title_with_front_matter() {
2191 use crate::rule::Rule;
2192 let rule = MD041FirstLineHeading {
2193 level: 1,
2194 front_matter_title: false,
2195 front_matter_title_pattern: None,
2196 allow_preamble: false,
2197 fix_enabled: true,
2198 };
2199
2200 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2202 let fixed = rule.fix(&ctx).unwrap();
2203 assert!(
2204 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2205 "Title should be promoted and placed right after front matter, got: {fixed}"
2206 );
2207 }
2208
2209 #[test]
2210 fn test_fix_no_promote_ends_with_period() {
2211 use crate::rule::Rule;
2212 let rule = MD041FirstLineHeading {
2213 level: 1,
2214 front_matter_title: false,
2215 front_matter_title_pattern: None,
2216 allow_preamble: false,
2217 fix_enabled: true,
2218 };
2219
2220 let content = "This is a sentence.\n\nContent.\n";
2222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2223 let fixed = rule.fix(&ctx).unwrap();
2224 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2225
2226 let warnings = rule.check(&ctx).unwrap();
2227 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2228 }
2229
2230 #[test]
2231 fn test_fix_no_promote_ends_with_colon() {
2232 use crate::rule::Rule;
2233 let rule = MD041FirstLineHeading {
2234 level: 1,
2235 front_matter_title: false,
2236 front_matter_title_pattern: None,
2237 allow_preamble: false,
2238 fix_enabled: true,
2239 };
2240
2241 let content = "Note:\n\nContent.\n";
2242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243 let fixed = rule.fix(&ctx).unwrap();
2244 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2245 }
2246
2247 #[test]
2248 fn test_fix_no_promote_if_too_long() {
2249 use crate::rule::Rule;
2250 let rule = MD041FirstLineHeading {
2251 level: 1,
2252 front_matter_title: false,
2253 front_matter_title_pattern: None,
2254 allow_preamble: false,
2255 fix_enabled: true,
2256 };
2257
2258 let long_line = "A".repeat(81);
2260 let content = format!("{long_line}\n\nContent.\n");
2261 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2262 let fixed = rule.fix(&ctx).unwrap();
2263 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2264 }
2265
2266 #[test]
2267 fn test_fix_no_promote_if_no_blank_after() {
2268 use crate::rule::Rule;
2269 let rule = MD041FirstLineHeading {
2270 level: 1,
2271 front_matter_title: false,
2272 front_matter_title_pattern: None,
2273 allow_preamble: false,
2274 fix_enabled: true,
2275 };
2276
2277 let content = "My Project\nImmediately continues.\n\nContent.\n";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280 let fixed = rule.fix(&ctx).unwrap();
2281 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2282 }
2283
2284 #[test]
2285 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2286 use crate::rule::Rule;
2287 let rule = MD041FirstLineHeading {
2288 level: 1,
2289 front_matter_title: false,
2290 front_matter_title_pattern: None,
2291 allow_preamble: false,
2292 fix_enabled: true,
2293 };
2294
2295 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2299 let fixed = rule.fix(&ctx).unwrap();
2300 assert_eq!(
2301 fixed, content,
2302 "Should not fix when title candidate exists before a heading"
2303 );
2304
2305 let warnings = rule.check(&ctx).unwrap();
2306 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2307 }
2308
2309 #[test]
2310 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2311 use crate::rule::Rule;
2312 let rule = MD041FirstLineHeading {
2313 level: 1,
2314 front_matter_title: false,
2315 front_matter_title_pattern: None,
2316 allow_preamble: false,
2317 fix_enabled: true,
2318 };
2319
2320 let content = "My Project";
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let fixed = rule.fix(&ctx).unwrap();
2324 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2325 }
2326
2327 #[test]
2330 fn test_fix_insert_derived_directive_only_document() {
2331 use crate::rule::Rule;
2332 use std::path::PathBuf;
2333 let rule = MD041FirstLineHeading {
2334 level: 1,
2335 front_matter_title: false,
2336 front_matter_title_pattern: None,
2337 allow_preamble: false,
2338 fix_enabled: true,
2339 };
2340
2341 let content = "!!! note\n This is a note.\n";
2344 let ctx = LintContext::new(
2345 content,
2346 crate::config::MarkdownFlavor::MkDocs,
2347 Some(PathBuf::from("setup-guide.md")),
2348 );
2349
2350 let can_fix = rule.can_fix(&ctx);
2351 assert!(can_fix, "Directive-only document with source file should be fixable");
2352
2353 let fixed = rule.fix(&ctx).unwrap();
2354 assert!(
2355 fixed.starts_with("# Setup Guide\n"),
2356 "Should insert derived heading, got: {fixed}"
2357 );
2358 }
2359
2360 #[test]
2361 fn test_fix_no_insert_derived_without_source_file() {
2362 use crate::rule::Rule;
2363 let rule = MD041FirstLineHeading {
2364 level: 1,
2365 front_matter_title: false,
2366 front_matter_title_pattern: None,
2367 allow_preamble: false,
2368 fix_enabled: true,
2369 };
2370
2371 let content = "!!! note\n This is a note.\n";
2373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2374 let fixed = rule.fix(&ctx).unwrap();
2375 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2376 }
2377
2378 #[test]
2379 fn test_fix_no_insert_derived_when_has_real_content() {
2380 use crate::rule::Rule;
2381 use std::path::PathBuf;
2382 let rule = MD041FirstLineHeading {
2383 level: 1,
2384 front_matter_title: false,
2385 front_matter_title_pattern: None,
2386 allow_preamble: false,
2387 fix_enabled: true,
2388 };
2389
2390 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2392 let ctx = LintContext::new(
2393 content,
2394 crate::config::MarkdownFlavor::MkDocs,
2395 Some(PathBuf::from("guide.md")),
2396 );
2397 let fixed = rule.fix(&ctx).unwrap();
2398 assert_eq!(
2399 fixed, content,
2400 "Should not insert derived heading when real content is present"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_derive_title_converts_kebab_case() {
2406 use std::path::PathBuf;
2407 let ctx = LintContext::new(
2408 "",
2409 crate::config::MarkdownFlavor::Standard,
2410 Some(PathBuf::from("my-setup-guide.md")),
2411 );
2412 let title = MD041FirstLineHeading::derive_title(&ctx);
2413 assert_eq!(title, Some("My Setup Guide".to_string()));
2414 }
2415
2416 #[test]
2417 fn test_derive_title_converts_underscores() {
2418 use std::path::PathBuf;
2419 let ctx = LintContext::new(
2420 "",
2421 crate::config::MarkdownFlavor::Standard,
2422 Some(PathBuf::from("api_reference.md")),
2423 );
2424 let title = MD041FirstLineHeading::derive_title(&ctx);
2425 assert_eq!(title, Some("Api Reference".to_string()));
2426 }
2427
2428 #[test]
2429 fn test_derive_title_none_without_source_file() {
2430 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2431 let title = MD041FirstLineHeading::derive_title(&ctx);
2432 assert_eq!(title, None);
2433 }
2434
2435 #[test]
2436 fn test_derive_title_index_file_uses_parent_dir() {
2437 use std::path::PathBuf;
2438 let ctx = LintContext::new(
2439 "",
2440 crate::config::MarkdownFlavor::Standard,
2441 Some(PathBuf::from("docs/getting-started/index.md")),
2442 );
2443 let title = MD041FirstLineHeading::derive_title(&ctx);
2444 assert_eq!(title, Some("Getting Started".to_string()));
2445 }
2446
2447 #[test]
2448 fn test_derive_title_readme_file_uses_parent_dir() {
2449 use std::path::PathBuf;
2450 let ctx = LintContext::new(
2451 "",
2452 crate::config::MarkdownFlavor::Standard,
2453 Some(PathBuf::from("my-project/README.md")),
2454 );
2455 let title = MD041FirstLineHeading::derive_title(&ctx);
2456 assert_eq!(title, Some("My Project".to_string()));
2457 }
2458
2459 #[test]
2460 fn test_derive_title_index_without_parent_returns_none() {
2461 use std::path::PathBuf;
2462 let ctx = LintContext::new(
2464 "",
2465 crate::config::MarkdownFlavor::Standard,
2466 Some(PathBuf::from("index.md")),
2467 );
2468 let title = MD041FirstLineHeading::derive_title(&ctx);
2469 assert_eq!(title, None);
2470 }
2471
2472 #[test]
2473 fn test_derive_title_readme_without_parent_returns_none() {
2474 use std::path::PathBuf;
2475 let ctx = LintContext::new(
2476 "",
2477 crate::config::MarkdownFlavor::Standard,
2478 Some(PathBuf::from("README.md")),
2479 );
2480 let title = MD041FirstLineHeading::derive_title(&ctx);
2481 assert_eq!(title, None);
2482 }
2483
2484 #[test]
2485 fn test_derive_title_readme_case_insensitive() {
2486 use std::path::PathBuf;
2487 let ctx = LintContext::new(
2489 "",
2490 crate::config::MarkdownFlavor::Standard,
2491 Some(PathBuf::from("docs/api/readme.md")),
2492 );
2493 let title = MD041FirstLineHeading::derive_title(&ctx);
2494 assert_eq!(title, Some("Api".to_string()));
2495 }
2496
2497 #[test]
2498 fn test_is_title_candidate_basic() {
2499 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2500 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2501 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2502 }
2503
2504 #[test]
2505 fn test_is_title_candidate_rejects_sentence_punctuation() {
2506 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2507 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2508 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2509 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2510 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2511 }
2512
2513 #[test]
2514 fn test_is_title_candidate_rejects_when_no_blank_after() {
2515 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2516 }
2517
2518 #[test]
2519 fn test_is_title_candidate_rejects_long_lines() {
2520 let long = "A".repeat(81);
2521 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2522 let ok = "A".repeat(80);
2524 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2525 }
2526
2527 #[test]
2528 fn test_is_title_candidate_rejects_structural_markdown() {
2529 assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2530 assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2531 assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2532 assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2533 }
2534
2535 #[test]
2536 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2537 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2540 let content = "My Document Title\n\nMore content follows.";
2542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2543 let warnings = rule.check(&ctx).unwrap();
2544 assert_eq!(warnings.len(), 1);
2545 let fix = warnings[0]
2546 .fix
2547 .as_ref()
2548 .expect("Fix should be present for promotable text");
2549 assert!(
2550 !fix.replacement.is_empty(),
2551 "Fix replacement must not be empty — applying it directly must produce valid output"
2552 );
2553 assert!(
2554 fix.replacement.starts_with("# "),
2555 "Fix replacement should be a level-1 heading, got: {:?}",
2556 fix.replacement
2557 );
2558 assert_eq!(fix.replacement, "# My Document Title");
2559 }
2560
2561 #[test]
2562 fn test_fix_replacement_not_empty_for_releveling() {
2563 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2566 let content = "## Wrong Level\n\nContent.";
2567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568 let warnings = rule.check(&ctx).unwrap();
2569 assert_eq!(warnings.len(), 1);
2570 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2571 assert!(
2572 !fix.replacement.is_empty(),
2573 "Fix replacement must not be empty for releveling"
2574 );
2575 assert_eq!(fix.replacement, "# Wrong Level");
2576 }
2577
2578 #[test]
2579 fn test_fix_replacement_applied_produces_valid_output() {
2580 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2582 let content = "My Document\n\nMore content.";
2584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2585
2586 let warnings = rule.check(&ctx).unwrap();
2587 assert_eq!(warnings.len(), 1);
2588 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2589
2590 let mut patched = content.to_string();
2592 patched.replace_range(fix.range.clone(), &fix.replacement);
2593
2594 let fixed = rule.fix(&ctx).unwrap();
2596
2597 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2598 }
2599
2600 #[test]
2601 fn test_mdx_disable_on_line_1_no_heading() {
2602 let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2607
2608 let rule = MD041FirstLineHeading::default();
2610 let warnings = rule.check(&ctx).unwrap();
2611 if !warnings.is_empty() {
2616 assert_eq!(
2617 warnings[0].line, 2,
2618 "Warning must be on line 2 (first content line after MDX comment), not line 1"
2619 );
2620 }
2621 }
2622
2623 #[test]
2624 fn test_mdx_disable_fix_returns_unchanged() {
2625 let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2628 let rule = MD041FirstLineHeading {
2629 fix_enabled: true,
2630 ..MD041FirstLineHeading::default()
2631 };
2632 let result = rule.fix(&ctx).unwrap();
2633 assert_eq!(
2634 result, content,
2635 "fix() should not modify content when MD041 is disabled via MDX comment"
2636 );
2637 }
2638
2639 #[test]
2640 fn test_mdx_comment_without_disable_heading_on_next_line() {
2641 let rule = MD041FirstLineHeading::default();
2642
2643 let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2646 let result = rule.check(&ctx).unwrap();
2647 assert!(
2648 result.is_empty(),
2649 "MDX comment is preamble; heading on next line should satisfy MD041"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_mdx_comment_without_heading_triggers_warning() {
2655 let rule = MD041FirstLineHeading::default();
2656
2657 let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2660 let result = rule.check(&ctx).unwrap();
2661 assert_eq!(
2662 result.len(),
2663 1,
2664 "MDX comment followed by non-heading should trigger MD041"
2665 );
2666 assert_eq!(
2667 result[0].line, 2,
2668 "Warning should be on line 2 (the first content line after MDX comment)"
2669 );
2670 }
2671
2672 #[test]
2673 fn test_multiline_mdx_comment_followed_by_heading() {
2674 let rule = MD041FirstLineHeading::default();
2675
2676 let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2679 let result = rule.check(&ctx).unwrap();
2680 assert!(
2681 result.is_empty(),
2682 "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2683 );
2684 }
2685
2686 #[test]
2687 fn test_html_comment_still_works_as_preamble_regression() {
2688 let rule = MD041FirstLineHeading::default();
2689
2690 let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2693 let result = rule.check(&ctx).unwrap();
2694 assert!(
2695 result.is_empty(),
2696 "HTML comment should still be treated as preamble (regression test)"
2697 );
2698 }
2699}