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,
46 first_idx: usize,
49 is_setext: bool,
50 current_level: usize,
51 needs_level_fix: bool,
52 },
53 PromotePlainText {
55 front_matter_end_idx: usize,
56 title_line_idx: usize,
57 title_text: String,
58 },
59 InsertDerived {
62 front_matter_end_idx: usize,
63 derived_title: String,
64 },
65 RelevelInPlace {
69 heading_idx: usize,
71 first_idx: usize,
74 is_setext: bool,
75 current_level: usize,
76 },
77}
78
79impl MD041FirstLineHeading {
80 pub fn new(level: usize, front_matter_title: bool) -> Self {
81 Self {
82 level,
83 front_matter_title,
84 front_matter_title_pattern: None,
85 allow_preamble: false,
86 fix_enabled: false,
87 }
88 }
89
90 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
91 Self::with_pattern_from(level, front_matter_title, pattern, fix_enabled, false)
92 }
93
94 fn with_pattern_from(
97 level: usize,
98 front_matter_title: bool,
99 pattern: Option<String>,
100 fix_enabled: bool,
101 values_withheld: bool,
102 ) -> Self {
103 let front_matter_title_pattern = pattern.and_then(|p| {
104 crate::rule_config_serde::compile_config_regex(&p, "MD041", "front-matter-title-pattern", values_withheld)
105 });
106
107 Self {
108 level,
109 front_matter_title,
110 front_matter_title_pattern,
111 allow_preamble: false,
112 fix_enabled,
113 }
114 }
115
116 pub fn with_allow_preamble(mut self, allow_preamble: bool) -> Self {
118 self.allow_preamble = allow_preamble;
119 self
120 }
121
122 fn has_front_matter_title(&self, content: &str) -> bool {
123 if !self.front_matter_title {
124 return false;
125 }
126
127 if let Some(ref pattern) = self.front_matter_title_pattern {
129 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
130 for line in front_matter_lines {
131 if pattern.is_match(line) {
132 return true;
133 }
134 }
135 return false;
136 }
137
138 FrontMatterUtils::has_front_matter_field(content, "title:")
140 }
141
142 fn is_non_content_line(line: &str) -> bool {
144 let trimmed = line.trim();
145
146 if trimmed.starts_with('[') && trimmed.contains("]: ") {
148 return true;
149 }
150
151 if trimmed.starts_with('*') && trimmed.contains("]: ") {
153 return true;
154 }
155
156 if Self::is_badge_image_line(trimmed) {
159 return true;
160 }
161
162 false
163 }
164
165 fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
171 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
172
173 let filtered = ctx
174 .filtered_lines()
175 .skip_front_matter()
176 .skip_esm_blocks()
177 .skip_html_comments()
178 .skip_mdx_comments();
179
180 for filtered_line in filtered {
181 let idx = filtered_line.line_num - 1;
182 let line_info = &ctx.lines[idx];
183
184 if line_info.is_blank || line_info.is_kramdown_block_ial {
185 continue;
186 }
187
188 let line_content = filtered_line.content;
189 if ctx.flavor == crate::config::MarkdownFlavor::GhAw
190 && !line_info.in_code_block
191 && crate::utils::gh_aw::is_control_line(line_content)
192 {
193 continue;
194 }
195 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
196 continue;
197 }
198 if Self::is_non_content_line(line_content) {
199 continue;
200 }
201 return Some(idx);
202 }
203 None
204 }
205
206 fn heading_covering<'a>(
214 ctx: &'a crate::lint_context::LintContext,
215 idx: usize,
216 ) -> Option<(usize, &'a crate::lint_context::HeadingInfo)> {
217 let line_info = ctx.lines.get(idx)?;
218 if let Some(heading) = line_info.heading.as_deref() {
219 return Some((idx, heading));
220 }
221 if !line_info.is_setext_heading_text {
222 return None;
223 }
224 ctx.lines[idx..]
225 .iter()
226 .enumerate()
227 .take_while(|(_, li)| li.is_setext_heading_text)
228 .find_map(|(offset, li)| li.heading.as_deref().map(|heading| (idx + offset, heading)))
229 }
230
231 fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
240 for (idx, line_info) in ctx.lines.iter().enumerate() {
241 if line_info.is_blank
242 || line_info.in_front_matter
243 || line_info.in_code_block
244 || line_info.in_html_comment
245 || line_info.in_mdx_comment
246 || line_info.in_math_block
247 {
248 continue;
249 }
250
251 let in_container = line_info.in_list_block
252 || line_info.blockquote.is_some()
253 || line_info.in_admonition
254 || line_info.in_content_tab
255 || line_info.in_pandoc_div
256 || line_info.in_pymdown_block
257 || line_info.in_kramdown_extension_block;
258 if in_container {
259 continue;
260 }
261
262 if let Some(heading) = line_info.heading.as_deref() {
263 return Some(idx + 1 - heading.text_lines);
266 }
267
268 let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
271 if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
272 return Some(idx);
273 }
274 }
275 None
276 }
277
278 fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
285 if self.allow_preamble {
286 Self::first_top_level_heading_idx(ctx)
287 } else {
288 Self::first_content_line_idx(ctx)
289 }
290 }
291
292 fn is_badge_image_line(line: &str) -> bool {
298 if line.is_empty() {
299 return false;
300 }
301
302 if !line.starts_with('!') && !line.starts_with('[') {
304 return false;
305 }
306
307 let mut remaining = line;
309 while !remaining.is_empty() {
310 remaining = remaining.trim_start();
311 if remaining.is_empty() {
312 break;
313 }
314
315 if remaining.starts_with("[![") {
317 if let Some(end) = Self::find_linked_image_end(remaining) {
318 remaining = &remaining[end..];
319 continue;
320 }
321 return false;
322 }
323
324 if remaining.starts_with("![") {
326 if let Some(end) = Self::find_image_end(remaining) {
327 remaining = &remaining[end..];
328 continue;
329 }
330 return false;
331 }
332
333 return false;
335 }
336
337 true
338 }
339
340 fn find_image_end(s: &str) -> Option<usize> {
342 if !s.starts_with("![") {
343 return None;
344 }
345 let alt_end = s[2..].find("](")?;
347 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
350 Some(paren_start + paren_end + 1)
351 }
352
353 fn find_linked_image_end(s: &str) -> Option<usize> {
355 if !s.starts_with("[![") {
356 return None;
357 }
358 let inner_end = Self::find_image_end(&s[1..])?;
360 let after_inner = 1 + inner_end;
361 if !s[after_inner..].starts_with("](") {
363 return None;
364 }
365 let link_start = after_inner + 2;
366 let link_end = s[link_start..].find(')')?;
367 Some(link_start + link_end + 1)
368 }
369
370 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
372 let trimmed = line.trim_start();
373
374 if trimmed.starts_with('#') {
376 let hashes = "#".repeat(target_level);
377 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
379 let after_hashes = &trimmed[content_start..];
380 let content = after_hashes.trim_start();
381
382 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
384 format!("{leading_ws}{hashes} {content}")
385 } else {
386 let hashes = "#".repeat(target_level);
389 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
390 format!("{leading_ws}{hashes} {trimmed}")
391 }
392 }
393
394 fn heading_as_atx(
401 &self,
402 ctx: &crate::lint_context::LintContext,
403 heading_idx: usize,
404 first_idx: usize,
405 current_level: usize,
406 ) -> String {
407 let first_line = ctx.lines[first_idx].content(ctx.content);
408 match ctx.lines[heading_idx].heading.as_deref() {
409 Some(heading) if heading.text_lines > 1 => {
410 let leading_ws: String = first_line.chars().take_while(|c| c.is_whitespace()).collect();
411 format!("{leading_ws}{} {}", "#".repeat(self.level), heading.raw_text)
412 }
413 _ => self.fix_heading_level(first_line, current_level, self.level),
414 }
415 }
416
417 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
429 if text.is_empty() {
430 return false;
431 }
432
433 if !next_is_blank_or_eof {
434 return false;
435 }
436
437 if text.chars().count() > 80 {
438 return false;
439 }
440
441 let last_char = text.chars().next_back().unwrap_or(' ');
442 !matches!(last_char, '.' | '?' | '!' | ':' | ';')
443 }
444
445 fn is_promotable_line(line_info: &crate::lint_context::LineInfo) -> bool {
455 line_info.is_paragraph_context() && line_info.list_item.is_none() && line_info.blockquote.is_none()
456 }
457
458 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
462 let path = ctx.source_file()?;
463 let stem = path.file_stem().and_then(|s| s.to_str())?;
464
465 let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
468 path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
469 } else {
470 stem
471 };
472
473 let title: String = effective_stem
474 .split(['-', '_'])
475 .filter(|w| !w.is_empty())
476 .map(|word| {
477 let mut chars = word.chars();
478 match chars.next() {
479 None => String::new(),
480 Some(first) => {
481 let upper: String = first.to_uppercase().collect();
482 upper + chars.as_str()
483 }
484 }
485 })
486 .collect::<Vec<_>>()
487 .join(" ");
488
489 if title.is_empty() { None } else { Some(title) }
490 }
491
492 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
494 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
496 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
497 && let Some(h_level) = captures.get(1)
498 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
499 {
500 return true;
501 }
502
503 let html_tags = ctx.html_tags();
505 let target_tag = format!("h{level}");
506
507 let opening_index = html_tags.iter().position(|tag| {
509 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
511 && !tag.is_closing
512 });
513
514 let Some(open_idx) = opening_index else {
515 return false;
516 };
517
518 let mut depth = 1usize;
521 for tag in html_tags.iter().skip(open_idx + 1) {
522 if tag.line <= first_line_idx + 1 {
524 continue;
525 }
526
527 if tag.tag_name == target_tag {
528 if tag.is_closing {
529 depth -= 1;
530 if depth == 0 {
531 return true;
532 }
533 } else if !tag.is_self_closing {
534 depth += 1;
535 }
536 }
537 }
538
539 false
540 }
541
542 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
544 if ctx.lines.is_empty() {
545 return None;
546 }
547
548 if self.allow_preamble {
552 let first_idx = Self::first_top_level_heading_idx(ctx)?;
553 let (heading_idx, heading) = Self::heading_covering(ctx, first_idx)?;
554 if heading.level as usize == self.level {
555 return None;
556 }
557 return Some(FixPlan::RelevelInPlace {
558 heading_idx,
559 first_idx,
560 is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
561 current_level: heading.level as usize,
562 });
563 }
564
565 let mut front_matter_end_idx = 0;
567 for line_info in &ctx.lines {
568 if line_info.in_front_matter {
569 front_matter_end_idx += 1;
570 } else {
571 break;
572 }
573 }
574
575 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
576 let is_gh_aw = ctx.flavor == crate::config::MarkdownFlavor::GhAw;
577
578 let mut found_heading: Option<(usize, usize, bool, usize)> = None;
580 let mut first_title_candidate: Option<(usize, String)> = None;
582 let mut found_non_title_content = false;
584 let mut saw_non_directive_content = false;
586 let mut saw_gh_aw_directive = false;
587
588 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
589 let line_content = line_info.content(ctx.content);
590 let trimmed = line_content.trim();
591
592 if is_gh_aw && !line_info.in_code_block && crate::utils::gh_aw::is_control_line(line_content) {
593 saw_gh_aw_directive = true;
594 continue;
595 }
596
597 let is_preamble = trimmed.is_empty()
599 || line_info.in_html_comment
600 || line_info.in_mdx_comment
601 || line_info.in_html_block
602 || Self::is_non_content_line(line_content)
603 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
604 || line_info.in_kramdown_extension_block
605 || line_info.is_kramdown_block_ial;
606
607 if is_preamble {
608 continue;
609 }
610
611 let is_directive_block = line_info.in_admonition
614 || line_info.in_content_tab
615 || line_info.in_pandoc_div
616 || line_info.is_div_marker
617 || line_info.in_pymdown_block;
618
619 if !is_directive_block {
620 saw_non_directive_content = true;
621 }
622
623 if line_info.is_setext_heading_text && line_info.heading.is_none() {
627 continue;
628 }
629
630 if let Some(heading) = &line_info.heading {
632 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
633 found_heading = Some((idx, idx + 1 - heading.text_lines, is_setext, heading.level as usize));
634 break 'scan;
635 }
636
637 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
639 let next_is_blank_or_eof = ctx
640 .lines
641 .get(idx + 1)
642 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
643
644 if Self::is_promotable_line(line_info) && Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
645 first_title_candidate = Some((idx, trimmed.to_string()));
646 } else {
647 found_non_title_content = true;
648 }
649 }
650 }
651
652 if let Some((h_idx, first_idx, is_setext, current_level)) = found_heading {
653 if found_non_title_content || first_title_candidate.is_some() {
657 return None;
658 }
659
660 let needs_level_fix = current_level != self.level;
661
662 if saw_gh_aw_directive {
666 return needs_level_fix.then_some(FixPlan::RelevelInPlace {
667 heading_idx: h_idx,
668 first_idx,
669 is_setext,
670 current_level,
671 });
672 }
673 let needs_move = first_idx > front_matter_end_idx;
674
675 if needs_level_fix || needs_move {
676 return Some(FixPlan::MoveOrRelevel {
677 front_matter_end_idx,
678 heading_idx: h_idx,
679 first_idx,
680 is_setext,
681 current_level,
682 needs_level_fix,
683 });
684 }
685 return None; }
687
688 if let Some((title_idx, title_text)) = first_title_candidate {
691 if saw_gh_aw_directive {
692 return None;
693 }
694 return Some(FixPlan::PromotePlainText {
695 front_matter_end_idx,
696 title_line_idx: title_idx,
697 title_text,
698 });
699 }
700
701 if !saw_gh_aw_directive
704 && !saw_non_directive_content
705 && let Some(derived_title) = Self::derive_title(ctx)
706 {
707 return Some(FixPlan::InsertDerived {
708 front_matter_end_idx,
709 derived_title,
710 });
711 }
712
713 None
714 }
715
716 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
718 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
719 }
720}
721
722impl Rule for MD041FirstLineHeading {
723 fn name(&self) -> &'static str {
724 "MD041"
725 }
726
727 fn description(&self) -> &'static str {
728 "First line in file should be a top level heading"
729 }
730
731 fn fix_capability(&self) -> FixCapability {
736 if self.fix_enabled {
737 FixCapability::ConditionallyFixable
738 } else {
739 FixCapability::Unfixable
740 }
741 }
742
743 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
744 let mut warnings = Vec::new();
745
746 if self.should_skip(ctx) {
748 return Ok(warnings);
749 }
750
751 let Some(first_line_idx) = self.checked_line_idx(ctx) else {
752 return Ok(warnings);
753 };
754
755 let first_line_info = &ctx.lines[first_line_idx];
757 let covering_heading = Self::heading_covering(ctx, first_line_idx);
760 let is_correct_heading = if let Some((_, heading)) = covering_heading {
761 heading.level as usize == self.level
762 } else {
763 Self::is_html_heading(ctx, first_line_idx, self.level)
765 };
766
767 if !is_correct_heading {
768 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
771 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
772 let (end_line, end_col) = match covering_heading.filter(|(last_idx, _)| *last_idx > first_line_idx) {
775 Some((last_idx, _)) => {
776 let last_content = ctx.lines[last_idx].content(ctx.content);
777 (last_idx + 1, last_content.trim_end().chars().count() + 1)
778 }
779 None => (end_line, end_col),
780 };
781
782 let fix = if self.can_fix(ctx) {
788 self.analyze_for_fix(ctx).and_then(|plan| {
789 let range_start = first_line_info.byte_offset;
790 let range_end = range_start + first_line_info.byte_len;
791 match &plan {
792 FixPlan::MoveOrRelevel {
796 heading_idx,
797 first_idx,
798 current_level,
799 needs_level_fix,
800 is_setext,
801 ..
802 } if *first_idx == first_line_idx && !*is_setext => {
803 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
805 let replacement = if *needs_level_fix {
806 self.fix_heading_level(heading_line, *current_level, self.level)
807 } else {
808 heading_line.to_string()
809 };
810 Some(Fix::new(range_start..range_end, replacement))
811 }
812 FixPlan::RelevelInPlace {
813 heading_idx,
814 first_idx,
815 current_level,
816 is_setext,
817 } if *first_idx == first_line_idx && !*is_setext => {
818 let replacement = self.fix_heading_level(
819 ctx.lines[*heading_idx].content(ctx.content),
820 *current_level,
821 self.level,
822 );
823 Some(Fix::new(range_start..range_end, replacement))
824 }
825 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
826 let replacement = format!(
827 "{} {}",
828 "#".repeat(self.level),
829 ctx.lines[*title_line_idx].content(ctx.content).trim()
830 );
831 Some(Fix::new(range_start..range_end, replacement))
832 }
833 _ => {
834 self.fix(ctx)
838 .ok()
839 .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
840 }
841 }
842 })
843 } else {
844 None
845 };
846
847 warnings.push(LintWarning {
848 rule_name: Some(self.name().to_string()),
849 line: start_line,
850 column: start_col,
851 end_line,
852 end_column: end_col,
853 message: if self.allow_preamble {
854 format!("First heading in file should be a level {} heading", self.level)
855 } else {
856 format!("First line in file should be a level {} heading", self.level)
857 },
858 severity: Severity::Warning,
859 fix,
860 });
861 }
862 Ok(warnings)
863 }
864
865 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
866 if !self.fix_enabled {
867 return Ok(ctx.content.to_string());
868 }
869
870 if self.should_skip(ctx) {
871 return Ok(ctx.content.to_string());
872 }
873
874 let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
877 if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
878 return Ok(ctx.content.to_string());
879 }
880
881 let Some(plan) = self.analyze_for_fix(ctx) else {
882 return Ok(ctx.content.to_string());
883 };
884
885 let lines = ctx.raw_lines();
886
887 let mut result = String::new();
888 let preserve_trailing_newline = ctx.content.ends_with('\n');
889
890 match plan {
891 FixPlan::MoveOrRelevel {
892 front_matter_end_idx,
893 heading_idx,
894 first_idx,
895 is_setext,
896 current_level,
897 needs_level_fix,
898 } => {
899 let fixed_heading = if needs_level_fix || is_setext {
900 self.heading_as_atx(ctx, heading_idx, first_idx, current_level)
901 } else {
902 ctx.lines[heading_idx].content(ctx.content).to_string()
903 };
904
905 for line in lines.iter().take(front_matter_end_idx) {
906 result.push_str(line);
907 result.push('\n');
908 }
909 result.push_str(&fixed_heading);
910 result.push('\n');
911 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
912 if (first_idx..=heading_idx).contains(&idx) {
916 continue;
917 }
918 if is_setext && idx == heading_idx + 1 {
919 continue;
920 }
921 result.push_str(line);
922 result.push('\n');
923 }
924 }
925
926 FixPlan::PromotePlainText {
927 front_matter_end_idx,
928 title_line_idx,
929 title_text,
930 } => {
931 let hashes = "#".repeat(self.level);
932 let new_heading = format!("{hashes} {title_text}");
933
934 for line in lines.iter().take(front_matter_end_idx) {
935 result.push_str(line);
936 result.push('\n');
937 }
938 result.push_str(&new_heading);
939 result.push('\n');
940 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
941 if idx == title_line_idx {
942 continue;
943 }
944 result.push_str(line);
945 result.push('\n');
946 }
947 }
948
949 FixPlan::RelevelInPlace {
950 heading_idx,
951 first_idx,
952 is_setext,
953 current_level,
954 } => {
955 for (idx, line) in lines.iter().enumerate() {
956 if idx == first_idx {
957 result.push_str(&self.heading_as_atx(ctx, heading_idx, first_idx, current_level));
958 result.push('\n');
959 continue;
960 }
961 if idx > first_idx && idx <= heading_idx {
964 continue;
965 }
966 if is_setext && idx == heading_idx + 1 {
967 continue;
968 }
969 result.push_str(line);
970 result.push('\n');
971 }
972 }
973
974 FixPlan::InsertDerived {
975 front_matter_end_idx,
976 derived_title,
977 } => {
978 let hashes = "#".repeat(self.level);
979 let new_heading = format!("{hashes} {derived_title}");
980
981 for line in lines.iter().take(front_matter_end_idx) {
982 result.push_str(line);
983 result.push('\n');
984 }
985 result.push_str(&new_heading);
986 result.push('\n');
987 result.push('\n');
988 for line in lines.iter().skip(front_matter_end_idx) {
989 result.push_str(line);
990 result.push('\n');
991 }
992 }
993 }
994
995 if !preserve_trailing_newline && result.ends_with('\n') {
996 result.pop();
997 }
998
999 Ok(result)
1000 }
1001
1002 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1004 let only_directives = !ctx.content.is_empty()
1009 && ctx.lines.iter().filter(|line| !line.is_blank).all(|line| {
1010 let t = line.content(ctx.content).trim();
1011 (if ctx.flavor == crate::config::MarkdownFlavor::GhAw {
1013 !line.in_code_block && crate::utils::gh_aw::is_control_line(t)
1014 } else {
1015 t.starts_with("{{#") && t.ends_with("}}")
1016 })
1017 || (t.starts_with("<!--") && t.ends_with("-->"))
1019 });
1020
1021 ctx.content.is_empty()
1022 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
1023 || only_directives
1024 }
1025
1026 fn as_any(&self) -> &dyn std::any::Any {
1027 self
1028 }
1029
1030 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1031 where
1032 Self: Sized,
1033 {
1034 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
1036
1037 let use_front_matter = !md041_config.front_matter_title.is_empty();
1038
1039 Box::new(
1040 MD041FirstLineHeading::with_pattern_from(
1041 md041_config.level.as_usize(),
1042 use_front_matter,
1043 md041_config.front_matter_title_pattern,
1044 md041_config.fix,
1045 config.withheld_rule_values.contains("MD041"),
1046 )
1047 .with_allow_preamble(md041_config.allow_preamble),
1048 )
1049 }
1050
1051 crate::impl_rule_config_sections!(MD041Config);
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use crate::lint_context::LintContext;
1058
1059 #[test]
1060 fn test_first_line_is_heading_correct_level() {
1061 let rule = MD041FirstLineHeading::default();
1062
1063 let content = "# My Document\n\nSome content here.";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert!(
1068 result.is_empty(),
1069 "Expected no warnings when first line is a level 1 heading"
1070 );
1071 }
1072
1073 #[test]
1074 fn test_first_line_is_heading_wrong_level() {
1075 let rule = MD041FirstLineHeading::default();
1076
1077 let content = "## My Document\n\nSome content here.";
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080 let result = rule.check(&ctx).unwrap();
1081 assert_eq!(result.len(), 1);
1082 assert_eq!(result[0].line, 1);
1083 assert!(result[0].message.contains("level 1 heading"));
1084 }
1085
1086 #[test]
1087 fn test_first_line_not_heading() {
1088 let rule = MD041FirstLineHeading::default();
1089
1090 let content = "This is not a heading\n\n# This is a heading";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094 assert_eq!(result.len(), 1);
1095 assert_eq!(result[0].line, 1);
1096 assert!(result[0].message.contains("level 1 heading"));
1097 }
1098
1099 #[test]
1100 fn test_empty_lines_before_heading() {
1101 let rule = MD041FirstLineHeading::default();
1102
1103 let content = "\n\n# My Document\n\nSome content.";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107 assert!(
1108 result.is_empty(),
1109 "Expected no warnings when empty lines precede a valid heading"
1110 );
1111
1112 let content = "\n\nNot a heading\n\nSome content.";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert_eq!(result.len(), 1);
1117 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
1119 }
1120
1121 #[test]
1122 fn test_front_matter_with_title() {
1123 let rule = MD041FirstLineHeading::new(1, true);
1124
1125 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129 assert!(
1130 result.is_empty(),
1131 "Expected no warnings when front matter has title field"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_front_matter_without_title() {
1137 let rule = MD041FirstLineHeading::new(1, true);
1138
1139 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert_eq!(result.len(), 1);
1144 assert_eq!(result[0].line, 6); }
1146
1147 #[test]
1148 fn test_front_matter_disabled() {
1149 let rule = MD041FirstLineHeading::new(1, false);
1150
1151 let content = "---\ntitle: My Document\n---\n\nSome content here.";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert_eq!(result.len(), 1);
1156 assert_eq!(result[0].line, 5); }
1158
1159 #[test]
1160 fn test_html_comments_before_heading() {
1161 let rule = MD041FirstLineHeading::default();
1162
1163 let content = "<!-- This is a comment -->\n# My Document\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 "HTML comments should be skipped when checking for first heading"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_multiline_html_comment_before_heading() {
1175 let rule = MD041FirstLineHeading::default();
1176
1177 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(
1182 result.is_empty(),
1183 "Multi-line HTML comments should be skipped when checking for first heading"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_html_comment_with_blank_lines_before_heading() {
1189 let rule = MD041FirstLineHeading::default();
1190
1191 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(
1196 result.is_empty(),
1197 "HTML comments with blank lines should be skipped when checking for first heading"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_html_comment_before_html_heading() {
1203 let rule = MD041FirstLineHeading::default();
1204
1205 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209 assert!(
1210 result.is_empty(),
1211 "HTML comments should be skipped before HTML headings"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_document_with_only_html_comments() {
1217 let rule = MD041FirstLineHeading::default();
1218
1219 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert!(
1224 result.is_empty(),
1225 "Documents with only HTML comments should not trigger MD041"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_html_comment_followed_by_non_heading() {
1231 let rule = MD041FirstLineHeading::default();
1232
1233 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert_eq!(
1238 result.len(),
1239 1,
1240 "HTML comment followed by non-heading should still trigger MD041"
1241 );
1242 assert_eq!(
1243 result[0].line, 2,
1244 "Warning should be on the first non-comment, non-heading line"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_multiple_html_comments_before_heading() {
1250 let rule = MD041FirstLineHeading::default();
1251
1252 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(
1257 result.is_empty(),
1258 "Multiple HTML comments should all be skipped before heading"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_html_comment_with_wrong_level_heading() {
1264 let rule = MD041FirstLineHeading::default();
1265
1266 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert_eq!(
1271 result.len(),
1272 1,
1273 "HTML comment followed by wrong-level heading should still trigger MD041"
1274 );
1275 assert!(
1276 result[0].message.contains("level 1 heading"),
1277 "Should require level 1 heading"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_html_comment_mixed_with_reference_definitions() {
1283 let rule = MD041FirstLineHeading::default();
1284
1285 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert!(
1290 result.is_empty(),
1291 "HTML comments and reference definitions should both be skipped before heading"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_html_comment_after_front_matter() {
1297 let rule = MD041FirstLineHeading::default();
1298
1299 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302 let result = rule.check(&ctx).unwrap();
1303 assert!(
1304 result.is_empty(),
1305 "HTML comments after front matter should be skipped before heading"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_html_comment_not_at_start_should_not_affect_rule() {
1311 let rule = MD041FirstLineHeading::default();
1312
1313 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let result = rule.check(&ctx).unwrap();
1317 assert!(
1318 result.is_empty(),
1319 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_multiline_html_comment_followed_by_non_heading() {
1325 let rule = MD041FirstLineHeading::default();
1326
1327 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert_eq!(
1332 result.len(),
1333 1,
1334 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1335 );
1336 assert_eq!(
1337 result[0].line, 5,
1338 "Warning should be on the first non-comment, non-heading line"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_different_heading_levels() {
1344 let rule = MD041FirstLineHeading::new(2, false);
1346
1347 let content = "## Second Level Heading\n\nContent.";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1351
1352 let content = "# First Level Heading\n\nContent.";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356 assert_eq!(result.len(), 1);
1357 assert!(result[0].message.contains("level 2 heading"));
1358 }
1359
1360 #[test]
1361 fn test_setext_headings() {
1362 let rule = MD041FirstLineHeading::default();
1363
1364 let content = "My Document\n===========\n\nContent.";
1366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367 let result = rule.check(&ctx).unwrap();
1368 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1369
1370 let content = "My Document\n-----------\n\nContent.";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 let result = rule.check(&ctx).unwrap();
1374 assert_eq!(result.len(), 1);
1375 assert!(result[0].message.contains("level 1 heading"));
1376 }
1377
1378 #[test]
1379 fn test_empty_document() {
1380 let rule = MD041FirstLineHeading::default();
1381
1382 let content = "";
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385 let result = rule.check(&ctx).unwrap();
1386 assert!(result.is_empty(), "Expected no warnings for empty document");
1387 }
1388
1389 #[test]
1390 fn test_whitespace_only_document() {
1391 let rule = MD041FirstLineHeading::default();
1392
1393 let content = " \n\n \t\n";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1398 }
1399
1400 #[test]
1401 fn test_front_matter_then_whitespace() {
1402 let rule = MD041FirstLineHeading::default();
1403
1404 let content = "---\ntitle: Test\n---\n\n \n\n";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx).unwrap();
1408 assert!(
1409 result.is_empty(),
1410 "Expected no warnings when no content after front matter"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_multiple_front_matter_types() {
1416 let rule = MD041FirstLineHeading::new(1, true);
1417
1418 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let result = rule.check(&ctx).unwrap();
1422 assert!(
1423 result.is_empty(),
1424 "Expected no warnings for TOML front matter with title"
1425 );
1426
1427 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431 assert!(
1432 result.is_empty(),
1433 "Expected no warnings for JSON front matter with title"
1434 );
1435
1436 let content = "---\ntitle: My Document\n---\n\nContent.";
1438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439 let result = rule.check(&ctx).unwrap();
1440 assert!(
1441 result.is_empty(),
1442 "Expected no warnings for YAML front matter with title"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_toml_front_matter_with_heading() {
1448 let rule = MD041FirstLineHeading::default();
1449
1450 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let result = rule.check(&ctx).unwrap();
1454 assert!(
1455 result.is_empty(),
1456 "Expected no warnings when heading follows TOML front matter"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_toml_front_matter_without_title_no_heading() {
1462 let rule = MD041FirstLineHeading::new(1, true);
1463
1464 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467 let result = rule.check(&ctx).unwrap();
1468 assert_eq!(result.len(), 1);
1469 assert_eq!(result[0].line, 6);
1470 }
1471
1472 #[test]
1473 fn test_toml_front_matter_level_2_heading() {
1474 let rule = MD041FirstLineHeading::new(2, true);
1476
1477 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let result = rule.check(&ctx).unwrap();
1480 assert!(
1481 result.is_empty(),
1482 "Issue #427: TOML front matter with title and correct heading level should not warn"
1483 );
1484 }
1485
1486 #[test]
1487 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1488 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1490
1491 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493 let result = rule.check(&ctx).unwrap();
1494 assert!(
1495 result.is_empty(),
1496 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_json_front_matter_with_heading() {
1502 let rule = MD041FirstLineHeading::default();
1503
1504 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1507 let result = rule.check(&ctx).unwrap();
1508 assert!(
1509 result.is_empty(),
1510 "Expected no warnings when heading follows JSON front matter"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_malformed_front_matter() {
1516 let rule = MD041FirstLineHeading::new(1, true);
1517
1518 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let result = rule.check(&ctx).unwrap();
1522 assert!(
1523 result.is_empty(),
1524 "Expected no warnings for malformed front matter with title"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_front_matter_with_heading() {
1530 let rule = MD041FirstLineHeading::default();
1531
1532 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536 assert!(
1537 result.is_empty(),
1538 "Expected no warnings when first line after front matter is correct heading"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_no_fix_suggestion() {
1544 let rule = MD041FirstLineHeading::default();
1545
1546 let content = "Not a heading\n\nContent.";
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let result = rule.check(&ctx).unwrap();
1550 assert_eq!(result.len(), 1);
1551 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1552 }
1553
1554 #[test]
1555 fn test_complex_document_structure() {
1556 let rule = MD041FirstLineHeading::default();
1557
1558 let content =
1560 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1562 let result = rule.check(&ctx).unwrap();
1563 assert!(
1564 result.is_empty(),
1565 "HTML comments should be skipped, so first heading after comment should be valid"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_heading_with_special_characters() {
1571 let rule = MD041FirstLineHeading::default();
1572
1573 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let result = rule.check(&ctx).unwrap();
1577 assert!(
1578 result.is_empty(),
1579 "Expected no warnings for heading with inline formatting"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_level_configuration() {
1585 for level in 1..=6 {
1587 let rule = MD041FirstLineHeading::new(level, false);
1588
1589 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1591 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert!(
1594 result.is_empty(),
1595 "Expected no warnings for correct level {level} heading"
1596 );
1597
1598 let wrong_level = if level == 1 { 2 } else { 1 };
1600 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1601 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1602 let result = rule.check(&ctx).unwrap();
1603 assert_eq!(result.len(), 1);
1604 assert!(result[0].message.contains(&format!("level {level} heading")));
1605 }
1606 }
1607
1608 #[test]
1609 fn test_issue_152_multiline_html_heading() {
1610 let rule = MD041FirstLineHeading::default();
1611
1612 let content = "<h1>\nSome text\n</h1>";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616 assert!(
1617 result.is_empty(),
1618 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_multiline_html_heading_with_attributes() {
1624 let rule = MD041FirstLineHeading::default();
1625
1626 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630 assert!(
1631 result.is_empty(),
1632 "Multi-line HTML heading with attributes should be recognized"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_multiline_html_heading_wrong_level() {
1638 let rule = MD041FirstLineHeading::default();
1639
1640 let content = "<h2>\nSome text\n</h2>";
1642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1643 let result = rule.check(&ctx).unwrap();
1644 assert_eq!(result.len(), 1);
1645 assert!(result[0].message.contains("level 1 heading"));
1646 }
1647
1648 #[test]
1649 fn test_multiline_html_heading_with_content_after() {
1650 let rule = MD041FirstLineHeading::default();
1651
1652 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656 assert!(
1657 result.is_empty(),
1658 "Multi-line HTML heading followed by content should be valid"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_multiline_html_heading_incomplete() {
1664 let rule = MD041FirstLineHeading::default();
1665
1666 let content = "<h1>\nSome text\n\nMore content without closing tag";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 let result = rule.check(&ctx).unwrap();
1670 assert_eq!(result.len(), 1);
1671 assert!(result[0].message.contains("level 1 heading"));
1672 }
1673
1674 #[test]
1675 fn test_singleline_html_heading_still_works() {
1676 let rule = MD041FirstLineHeading::default();
1677
1678 let content = "<h1>My Document</h1>\n\nContent.";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let result = rule.check(&ctx).unwrap();
1682 assert!(
1683 result.is_empty(),
1684 "Single-line HTML headings should still be recognized"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_multiline_html_heading_with_nested_tags() {
1690 let rule = MD041FirstLineHeading::default();
1691
1692 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let result = rule.check(&ctx).unwrap();
1696 assert!(
1697 result.is_empty(),
1698 "Multi-line HTML heading with nested tags should be recognized"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_multiline_html_heading_various_levels() {
1704 for level in 1..=6 {
1706 let rule = MD041FirstLineHeading::new(level, false);
1707
1708 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1710 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1711 let result = rule.check(&ctx).unwrap();
1712 assert!(
1713 result.is_empty(),
1714 "Multi-line HTML heading at level {level} should be recognized"
1715 );
1716
1717 let wrong_level = if level == 1 { 2 } else { 1 };
1719 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1720 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1721 let result = rule.check(&ctx).unwrap();
1722 assert_eq!(result.len(), 1);
1723 assert!(result[0].message.contains(&format!("level {level} heading")));
1724 }
1725 }
1726
1727 #[test]
1728 fn test_issue_152_nested_heading_spans_many_lines() {
1729 let rule = MD041FirstLineHeading::default();
1730
1731 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>";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let result = rule.check(&ctx).unwrap();
1734 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1735 }
1736
1737 #[test]
1738 fn test_issue_152_picture_tag_heading() {
1739 let rule = MD041FirstLineHeading::default();
1740
1741 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>";
1742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743 let result = rule.check(&ctx).unwrap();
1744 assert!(
1745 result.is_empty(),
1746 "Picture tag inside multi-line HTML heading should be recognized"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_badge_images_before_heading() {
1752 let rule = MD041FirstLineHeading::default();
1753
1754 let content = "\n\n# My Project";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let result = rule.check(&ctx).unwrap();
1758 assert!(result.is_empty(), "Badge image should be skipped");
1759
1760 let content = " \n\n# My Project";
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763 let result = rule.check(&ctx).unwrap();
1764 assert!(result.is_empty(), "Multiple badges should be skipped");
1765
1766 let content = "[](https://example.com)\n\n# My Project";
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let result = rule.check(&ctx).unwrap();
1770 assert!(result.is_empty(), "Linked badge should be skipped");
1771 }
1772
1773 #[test]
1774 fn test_multiple_badge_lines_before_heading() {
1775 let rule = MD041FirstLineHeading::default();
1776
1777 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1782 }
1783
1784 #[test]
1785 fn test_badges_without_heading_still_warns() {
1786 let rule = MD041FirstLineHeading::default();
1787
1788 let content = "\n\nThis is not a heading.";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 let result = rule.check(&ctx).unwrap();
1792 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1793 }
1794
1795 #[test]
1796 fn test_mixed_content_not_badge_line() {
1797 let rule = MD041FirstLineHeading::default();
1798
1799 let content = " Some text here\n\n# Heading";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let result = rule.check(&ctx).unwrap();
1803 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1804 }
1805
1806 #[test]
1807 fn test_is_badge_image_line_unit() {
1808 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1810 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1811 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1812 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1813
1814 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1816 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1817 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1818 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1819 }
1820
1821 #[test]
1825 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1826 let rule = MD041FirstLineHeading::default();
1827
1828 let content = "[](){ #example }\n# Title";
1830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1831 let result = rule.check(&ctx).unwrap();
1832 assert!(
1833 result.is_empty(),
1834 "MkDocs anchor line should be skipped in MkDocs flavor"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1840 let rule = MD041FirstLineHeading::default();
1841
1842 let content = "[](){ #example }\n# Title";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 let result = rule.check(&ctx).unwrap();
1846 assert_eq!(
1847 result.len(),
1848 1,
1849 "MkDocs anchor line should NOT be skipped in Standard flavor"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_multiple_mkdocs_anchors_before_heading() {
1855 let rule = MD041FirstLineHeading::default();
1856
1857 let content = "[](){ #first }\n[](){ #second }\n# Title";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1860 let result = rule.check(&ctx).unwrap();
1861 assert!(
1862 result.is_empty(),
1863 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_mkdocs_anchor_with_front_matter() {
1869 let rule = MD041FirstLineHeading::default();
1870
1871 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1874 let result = rule.check(&ctx).unwrap();
1875 assert!(
1876 result.is_empty(),
1877 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_mkdocs_anchor_kramdown_style() {
1883 let rule = MD041FirstLineHeading::default();
1884
1885 let content = "[](){: #anchor }\n# Title";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1888 let result = rule.check(&ctx).unwrap();
1889 assert!(
1890 result.is_empty(),
1891 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_mkdocs_anchor_without_heading_still_warns() {
1897 let rule = MD041FirstLineHeading::default();
1898
1899 let content = "[](){ #anchor }\nThis is not a heading.";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1902 let result = rule.check(&ctx).unwrap();
1903 assert_eq!(
1904 result.len(),
1905 1,
1906 "MkDocs anchor followed by non-heading should still trigger MD041"
1907 );
1908 }
1909
1910 #[test]
1911 fn test_mkdocs_anchor_with_html_comment() {
1912 let rule = MD041FirstLineHeading::default();
1913
1914 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1917 let result = rule.check(&ctx).unwrap();
1918 assert!(
1919 result.is_empty(),
1920 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1921 );
1922 }
1923
1924 #[test]
1927 fn test_fix_disabled_by_default() {
1928 use crate::rule::Rule;
1929 let rule = MD041FirstLineHeading::default();
1930
1931 let content = "## Wrong Level\n\nContent.";
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let fixed = rule.fix(&ctx).unwrap();
1935 assert_eq!(fixed, content, "Fix should not change content when disabled");
1936 }
1937
1938 #[test]
1939 fn test_fix_wrong_heading_level() {
1940 use crate::rule::Rule;
1941 let rule = MD041FirstLineHeading {
1942 level: 1,
1943 front_matter_title: false,
1944 front_matter_title_pattern: None,
1945 allow_preamble: false,
1946 fix_enabled: true,
1947 };
1948
1949 let content = "## Wrong Level\n\nContent.\n";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952 let fixed = rule.fix(&ctx).unwrap();
1953 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1954 }
1955
1956 #[test]
1957 fn test_fix_heading_after_preamble() {
1958 use crate::rule::Rule;
1959 let rule = MD041FirstLineHeading {
1960 level: 1,
1961 front_matter_title: false,
1962 front_matter_title_pattern: None,
1963 allow_preamble: false,
1964 fix_enabled: true,
1965 };
1966
1967 let content = "\n\n# Title\n\nContent.\n";
1969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1970 let fixed = rule.fix(&ctx).unwrap();
1971 assert!(
1972 fixed.starts_with("# Title\n"),
1973 "Heading should be moved to first line, got: {fixed}"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_fix_heading_after_html_comment() {
1979 use crate::rule::Rule;
1980 let rule = MD041FirstLineHeading {
1981 level: 1,
1982 front_matter_title: false,
1983 front_matter_title_pattern: None,
1984 allow_preamble: false,
1985 fix_enabled: true,
1986 };
1987
1988 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 let fixed = rule.fix(&ctx).unwrap();
1992 assert!(
1993 fixed.starts_with("# Title\n"),
1994 "Heading should be moved above comment, got: {fixed}"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_fix_heading_level_and_move() {
2000 use crate::rule::Rule;
2001 let rule = MD041FirstLineHeading {
2002 level: 1,
2003 front_matter_title: false,
2004 front_matter_title_pattern: None,
2005 allow_preamble: false,
2006 fix_enabled: true,
2007 };
2008
2009 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let fixed = rule.fix(&ctx).unwrap();
2013 assert!(
2014 fixed.starts_with("# Wrong Level\n"),
2015 "Heading should be fixed and moved, got: {fixed}"
2016 );
2017 }
2018
2019 #[test]
2020 fn test_fix_with_front_matter() {
2021 use crate::rule::Rule;
2022 let rule = MD041FirstLineHeading {
2023 level: 1,
2024 front_matter_title: false,
2025 front_matter_title_pattern: None,
2026 allow_preamble: false,
2027 fix_enabled: true,
2028 };
2029
2030 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2033 let fixed = rule.fix(&ctx).unwrap();
2034 assert!(
2035 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
2036 "Heading should be right after front matter, got: {fixed}"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_fix_with_toml_front_matter() {
2042 use crate::rule::Rule;
2043 let rule = MD041FirstLineHeading {
2044 level: 1,
2045 front_matter_title: false,
2046 front_matter_title_pattern: None,
2047 allow_preamble: false,
2048 fix_enabled: true,
2049 };
2050
2051 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let fixed = rule.fix(&ctx).unwrap();
2055 assert!(
2056 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
2057 "Heading should be right after TOML front matter, got: {fixed}"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_fix_cannot_fix_no_heading() {
2063 use crate::rule::Rule;
2064 let rule = MD041FirstLineHeading {
2065 level: 1,
2066 front_matter_title: false,
2067 front_matter_title_pattern: None,
2068 allow_preamble: false,
2069 fix_enabled: true,
2070 };
2071
2072 let content = "Just some text.\n\nMore text.\n";
2074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2075 let fixed = rule.fix(&ctx).unwrap();
2076 assert_eq!(fixed, content, "Should not change content when no heading exists");
2077 }
2078
2079 #[test]
2080 fn test_fix_cannot_fix_content_before_heading() {
2081 use crate::rule::Rule;
2082 let rule = MD041FirstLineHeading {
2083 level: 1,
2084 front_matter_title: false,
2085 front_matter_title_pattern: None,
2086 allow_preamble: false,
2087 fix_enabled: true,
2088 };
2089
2090 let content = "Some intro text.\n\n# Title\n\nContent.\n";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2093 let fixed = rule.fix(&ctx).unwrap();
2094 assert_eq!(
2095 fixed, content,
2096 "Should not change content when real content exists before heading"
2097 );
2098 }
2099
2100 #[test]
2101 fn test_fix_already_correct() {
2102 use crate::rule::Rule;
2103 let rule = MD041FirstLineHeading {
2104 level: 1,
2105 front_matter_title: false,
2106 front_matter_title_pattern: None,
2107 allow_preamble: false,
2108 fix_enabled: true,
2109 };
2110
2111 let content = "# Title\n\nContent.\n";
2113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2114 let fixed = rule.fix(&ctx).unwrap();
2115 assert_eq!(fixed, content, "Should not change already correct content");
2116 }
2117
2118 #[test]
2119 fn test_fix_setext_heading_removes_underline() {
2120 use crate::rule::Rule;
2121 let rule = MD041FirstLineHeading {
2122 level: 1,
2123 front_matter_title: false,
2124 front_matter_title_pattern: None,
2125 allow_preamble: false,
2126 fix_enabled: true,
2127 };
2128
2129 let content = "Wrong Level\n-----------\n\nContent.\n";
2131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2132 let fixed = rule.fix(&ctx).unwrap();
2133 assert_eq!(
2134 fixed, "# Wrong Level\n\nContent.\n",
2135 "Setext heading should be converted to ATX and underline removed"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_fix_setext_h1_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 = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2153 let fixed = rule.fix(&ctx).unwrap();
2154 assert_eq!(
2155 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2156 "Setext h1 should be moved and converted to ATX"
2157 );
2158 }
2159
2160 #[test]
2161 fn test_html_heading_not_claimed_fixable() {
2162 use crate::rule::Rule;
2163 let rule = MD041FirstLineHeading {
2164 level: 1,
2165 front_matter_title: false,
2166 front_matter_title_pattern: None,
2167 allow_preamble: false,
2168 fix_enabled: true,
2169 };
2170
2171 let content = "<h2>Title</h2>\n\nContent.\n";
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174 let warnings = rule.check(&ctx).unwrap();
2175 assert_eq!(warnings.len(), 1);
2176 assert!(
2177 warnings[0].fix.is_none(),
2178 "HTML heading should not be claimed as fixable"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_no_heading_not_claimed_fixable() {
2184 use crate::rule::Rule;
2185 let rule = MD041FirstLineHeading {
2186 level: 1,
2187 front_matter_title: false,
2188 front_matter_title_pattern: None,
2189 allow_preamble: false,
2190 fix_enabled: true,
2191 };
2192
2193 let content = "Just some text.\n\nMore text.\n";
2195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2196 let warnings = rule.check(&ctx).unwrap();
2197 assert_eq!(warnings.len(), 1);
2198 assert!(
2199 warnings[0].fix.is_none(),
2200 "Document without heading should not be claimed as fixable"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_content_before_heading_not_claimed_fixable() {
2206 use crate::rule::Rule;
2207 let rule = MD041FirstLineHeading {
2208 level: 1,
2209 front_matter_title: false,
2210 front_matter_title_pattern: None,
2211 allow_preamble: false,
2212 fix_enabled: true,
2213 };
2214
2215 let content = "Intro text.\n\n## Heading\n\nMore.\n";
2217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2218 let warnings = rule.check(&ctx).unwrap();
2219 assert_eq!(warnings.len(), 1);
2220 assert!(
2221 warnings[0].fix.is_none(),
2222 "Document with content before heading should not be claimed as fixable"
2223 );
2224 }
2225
2226 #[test]
2229 fn test_fix_html_block_before_heading_is_now_fixable() {
2230 use crate::rule::Rule;
2231 let rule = MD041FirstLineHeading {
2232 level: 1,
2233 front_matter_title: false,
2234 front_matter_title_pattern: None,
2235 allow_preamble: false,
2236 fix_enabled: true,
2237 };
2238
2239 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2242
2243 let warnings = rule.check(&ctx).unwrap();
2244 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2245 assert!(
2246 warnings[0].fix.is_some(),
2247 "Should be fixable: heading exists after HTML block preamble"
2248 );
2249
2250 let fixed = rule.fix(&ctx).unwrap();
2251 assert!(
2252 fixed.starts_with("# My Document\n"),
2253 "Heading should be moved to the top, got: {fixed}"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_fix_html_block_wrong_level_before_heading() {
2259 use crate::rule::Rule;
2260 let rule = MD041FirstLineHeading {
2261 level: 1,
2262 front_matter_title: false,
2263 front_matter_title_pattern: None,
2264 allow_preamble: false,
2265 fix_enabled: true,
2266 };
2267
2268 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2270 let fixed = rule.fix(&ctx).unwrap();
2271 assert!(
2272 fixed.starts_with("# Wrong Level\n"),
2273 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2274 );
2275 }
2276
2277 #[test]
2280 fn test_fix_promote_plain_text_title() {
2281 use crate::rule::Rule;
2282 let rule = MD041FirstLineHeading {
2283 level: 1,
2284 front_matter_title: false,
2285 front_matter_title_pattern: None,
2286 allow_preamble: false,
2287 fix_enabled: true,
2288 };
2289
2290 let content = "My Project\n\nSome content.\n";
2291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2292
2293 let warnings = rule.check(&ctx).unwrap();
2294 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2295 assert!(
2296 warnings[0].fix.is_some(),
2297 "Should be fixable: first line is a title candidate"
2298 );
2299
2300 let fixed = rule.fix(&ctx).unwrap();
2301 assert_eq!(
2302 fixed, "# My Project\n\nSome content.\n",
2303 "Title line should be promoted to heading"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_fix_promote_plain_text_title_with_front_matter() {
2309 use crate::rule::Rule;
2310 let rule = MD041FirstLineHeading {
2311 level: 1,
2312 front_matter_title: false,
2313 front_matter_title_pattern: None,
2314 allow_preamble: false,
2315 fix_enabled: true,
2316 };
2317
2318 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320 let fixed = rule.fix(&ctx).unwrap();
2321 assert!(
2322 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2323 "Title should be promoted and placed right after front matter, got: {fixed}"
2324 );
2325 }
2326
2327 #[test]
2328 fn test_fix_no_promote_ends_with_period() {
2329 use crate::rule::Rule;
2330 let rule = MD041FirstLineHeading {
2331 level: 1,
2332 front_matter_title: false,
2333 front_matter_title_pattern: None,
2334 allow_preamble: false,
2335 fix_enabled: true,
2336 };
2337
2338 let content = "This is a sentence.\n\nContent.\n";
2340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2341 let fixed = rule.fix(&ctx).unwrap();
2342 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2343
2344 let warnings = rule.check(&ctx).unwrap();
2345 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2346 }
2347
2348 #[test]
2349 fn test_fix_no_promote_ends_with_colon() {
2350 use crate::rule::Rule;
2351 let rule = MD041FirstLineHeading {
2352 level: 1,
2353 front_matter_title: false,
2354 front_matter_title_pattern: None,
2355 allow_preamble: false,
2356 fix_enabled: true,
2357 };
2358
2359 let content = "Note:\n\nContent.\n";
2360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2361 let fixed = rule.fix(&ctx).unwrap();
2362 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2363 }
2364
2365 #[test]
2366 fn test_fix_no_promote_if_too_long() {
2367 use crate::rule::Rule;
2368 let rule = MD041FirstLineHeading {
2369 level: 1,
2370 front_matter_title: false,
2371 front_matter_title_pattern: None,
2372 allow_preamble: false,
2373 fix_enabled: true,
2374 };
2375
2376 let long_line = "A".repeat(81);
2378 let content = format!("{long_line}\n\nContent.\n");
2379 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2380 let fixed = rule.fix(&ctx).unwrap();
2381 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2382 }
2383
2384 #[test]
2385 fn test_fix_no_promote_ordered_list_item() {
2386 use crate::rule::Rule;
2387 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2388
2389 for content in [
2391 "1. Introduction\n\nBody.\n",
2392 "1) Introduction\n\nBody.\n",
2393 "123456789. Ninth step\n\nBody.\n",
2394 ] {
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 assert!(!rule.check(&ctx).unwrap().is_empty(), "missing H1 is still reported");
2397 let fixed = rule.fix(&ctx).unwrap();
2398 assert_eq!(fixed, content, "an ordered list item must not become a heading");
2399 }
2400
2401 let content = "1234567890. Release Notes\n\nBody.\n";
2403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404 assert_eq!(rule.fix(&ctx).unwrap(), "# 1234567890. Release Notes\n\nBody.\n");
2405 }
2406
2407 #[test]
2410 fn test_fix_no_promote_structural_lines() {
2411 use crate::rule::Rule;
2412 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2413
2414 for content in [
2415 ">Quote\n\nBody.\n",
2416 "> Quote\n\nBody.\n",
2417 "- Item\n\nBody.\n",
2418 "* Item\n\nBody.\n",
2419 "+ Item\n\nBody.\n",
2420 " - Indented item\n\nBody.\n",
2421 "***\n\nBody.\n",
2422 "---\n\nBody.\n",
2423 "```\n\nBody.\n",
2424 " code\n\nBody.\n",
2425 ] {
2426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2427 let warnings = rule.check(&ctx).unwrap();
2428 assert_eq!(warnings.len(), 1, "missing H1 is still reported for {content:?}");
2429 assert!(warnings[0].fix.is_none(), "no fix may be offered for {content:?}");
2430 assert_eq!(
2431 rule.fix(&ctx).unwrap(),
2432 content,
2433 "{content:?} must not become a heading"
2434 );
2435 }
2436
2437 let ctx = LintContext::new("Plain Title\n\nBody.\n", crate::config::MarkdownFlavor::Standard, None);
2439 assert_eq!(rule.fix(&ctx).unwrap(), "# Plain Title\n\nBody.\n");
2440 }
2441
2442 #[test]
2443 fn test_fix_promotes_multibyte_title_within_character_limit() {
2444 use crate::rule::Rule;
2445 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2446
2447 let title = "日本語のタイトル".repeat(7);
2449 let content = format!("{title}\n\nBody.\n");
2450 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2451 let fixed = rule.fix(&ctx).unwrap();
2452 assert_eq!(fixed, format!("# {title}\n\nBody.\n"));
2453 }
2454
2455 #[test]
2456 fn test_fix_no_promote_if_no_blank_after() {
2457 use crate::rule::Rule;
2458 let rule = MD041FirstLineHeading {
2459 level: 1,
2460 front_matter_title: false,
2461 front_matter_title_pattern: None,
2462 allow_preamble: false,
2463 fix_enabled: true,
2464 };
2465
2466 let content = "My Project\nImmediately continues.\n\nContent.\n";
2468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2469 let fixed = rule.fix(&ctx).unwrap();
2470 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2471 }
2472
2473 #[test]
2474 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2475 use crate::rule::Rule;
2476 let rule = MD041FirstLineHeading {
2477 level: 1,
2478 front_matter_title: false,
2479 front_matter_title_pattern: None,
2480 allow_preamble: false,
2481 fix_enabled: true,
2482 };
2483
2484 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2488 let fixed = rule.fix(&ctx).unwrap();
2489 assert_eq!(
2490 fixed, content,
2491 "Should not fix when title candidate exists before a heading"
2492 );
2493
2494 let warnings = rule.check(&ctx).unwrap();
2495 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2496 }
2497
2498 #[test]
2499 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2500 use crate::rule::Rule;
2501 let rule = MD041FirstLineHeading {
2502 level: 1,
2503 front_matter_title: false,
2504 front_matter_title_pattern: None,
2505 allow_preamble: false,
2506 fix_enabled: true,
2507 };
2508
2509 let content = "My Project";
2511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2512 let fixed = rule.fix(&ctx).unwrap();
2513 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2514 }
2515
2516 #[test]
2519 fn test_fix_insert_derived_directive_only_document() {
2520 use crate::rule::Rule;
2521 use std::path::PathBuf;
2522 let rule = MD041FirstLineHeading {
2523 level: 1,
2524 front_matter_title: false,
2525 front_matter_title_pattern: None,
2526 allow_preamble: false,
2527 fix_enabled: true,
2528 };
2529
2530 let content = "!!! note\n This is a note.\n";
2533 let ctx = LintContext::new(
2534 content,
2535 crate::config::MarkdownFlavor::MkDocs,
2536 Some(PathBuf::from("setup-guide.md")),
2537 );
2538
2539 let can_fix = rule.can_fix(&ctx);
2540 assert!(can_fix, "Directive-only document with source file should be fixable");
2541
2542 let fixed = rule.fix(&ctx).unwrap();
2543 assert!(
2544 fixed.starts_with("# Setup Guide\n"),
2545 "Should insert derived heading, got: {fixed}"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_fix_no_insert_derived_without_source_file() {
2551 use crate::rule::Rule;
2552 let rule = MD041FirstLineHeading {
2553 level: 1,
2554 front_matter_title: false,
2555 front_matter_title_pattern: None,
2556 allow_preamble: false,
2557 fix_enabled: true,
2558 };
2559
2560 let content = "!!! note\n This is a note.\n";
2562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2563 let fixed = rule.fix(&ctx).unwrap();
2564 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2565 }
2566
2567 #[test]
2568 fn test_fix_no_insert_derived_when_has_real_content() {
2569 use crate::rule::Rule;
2570 use std::path::PathBuf;
2571 let rule = MD041FirstLineHeading {
2572 level: 1,
2573 front_matter_title: false,
2574 front_matter_title_pattern: None,
2575 allow_preamble: false,
2576 fix_enabled: true,
2577 };
2578
2579 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2581 let ctx = LintContext::new(
2582 content,
2583 crate::config::MarkdownFlavor::MkDocs,
2584 Some(PathBuf::from("guide.md")),
2585 );
2586 let fixed = rule.fix(&ctx).unwrap();
2587 assert_eq!(
2588 fixed, content,
2589 "Should not insert derived heading when real content is present"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_derive_title_converts_kebab_case() {
2595 use std::path::PathBuf;
2596 let ctx = LintContext::new(
2597 "",
2598 crate::config::MarkdownFlavor::Standard,
2599 Some(PathBuf::from("my-setup-guide.md")),
2600 );
2601 let title = MD041FirstLineHeading::derive_title(&ctx);
2602 assert_eq!(title, Some("My Setup Guide".to_string()));
2603 }
2604
2605 #[test]
2606 fn test_derive_title_converts_underscores() {
2607 use std::path::PathBuf;
2608 let ctx = LintContext::new(
2609 "",
2610 crate::config::MarkdownFlavor::Standard,
2611 Some(PathBuf::from("api_reference.md")),
2612 );
2613 let title = MD041FirstLineHeading::derive_title(&ctx);
2614 assert_eq!(title, Some("Api Reference".to_string()));
2615 }
2616
2617 #[test]
2618 fn test_derive_title_none_without_source_file() {
2619 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2620 let title = MD041FirstLineHeading::derive_title(&ctx);
2621 assert_eq!(title, None);
2622 }
2623
2624 #[test]
2625 fn test_derive_title_index_file_uses_parent_dir() {
2626 use std::path::PathBuf;
2627 let ctx = LintContext::new(
2628 "",
2629 crate::config::MarkdownFlavor::Standard,
2630 Some(PathBuf::from("docs/getting-started/index.md")),
2631 );
2632 let title = MD041FirstLineHeading::derive_title(&ctx);
2633 assert_eq!(title, Some("Getting Started".to_string()));
2634 }
2635
2636 #[test]
2637 fn test_derive_title_readme_file_uses_parent_dir() {
2638 use std::path::PathBuf;
2639 let ctx = LintContext::new(
2640 "",
2641 crate::config::MarkdownFlavor::Standard,
2642 Some(PathBuf::from("my-project/README.md")),
2643 );
2644 let title = MD041FirstLineHeading::derive_title(&ctx);
2645 assert_eq!(title, Some("My Project".to_string()));
2646 }
2647
2648 #[test]
2649 fn test_derive_title_index_without_parent_returns_none() {
2650 use std::path::PathBuf;
2651 let ctx = LintContext::new(
2653 "",
2654 crate::config::MarkdownFlavor::Standard,
2655 Some(PathBuf::from("index.md")),
2656 );
2657 let title = MD041FirstLineHeading::derive_title(&ctx);
2658 assert_eq!(title, None);
2659 }
2660
2661 #[test]
2662 fn test_derive_title_readme_without_parent_returns_none() {
2663 use std::path::PathBuf;
2664 let ctx = LintContext::new(
2665 "",
2666 crate::config::MarkdownFlavor::Standard,
2667 Some(PathBuf::from("README.md")),
2668 );
2669 let title = MD041FirstLineHeading::derive_title(&ctx);
2670 assert_eq!(title, None);
2671 }
2672
2673 #[test]
2674 fn test_derive_title_readme_case_insensitive() {
2675 use std::path::PathBuf;
2676 let ctx = LintContext::new(
2678 "",
2679 crate::config::MarkdownFlavor::Standard,
2680 Some(PathBuf::from("docs/api/readme.md")),
2681 );
2682 let title = MD041FirstLineHeading::derive_title(&ctx);
2683 assert_eq!(title, Some("Api".to_string()));
2684 }
2685
2686 #[test]
2687 fn test_is_title_candidate_basic() {
2688 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2689 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2690 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2691 }
2692
2693 #[test]
2694 fn test_is_title_candidate_rejects_sentence_punctuation() {
2695 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2696 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2697 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2698 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2699 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2700 }
2701
2702 #[test]
2703 fn test_is_title_candidate_rejects_when_no_blank_after() {
2704 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2705 }
2706
2707 #[test]
2708 fn test_is_title_candidate_rejects_long_lines() {
2709 let long = "A".repeat(81);
2710 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2711 let ok = "A".repeat(80);
2713 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2714 }
2715
2716 #[test]
2717 fn test_is_title_candidate_judges_text_shape_only() {
2718 assert!(MD041FirstLineHeading::is_title_candidate("2026 Roadmap", true));
2721 assert!(MD041FirstLineHeading::is_title_candidate("1.0 Release Notes", true));
2722 assert!(MD041FirstLineHeading::is_title_candidate("C++ Notes", true));
2723 }
2724
2725 #[test]
2726 fn test_is_title_candidate_length_counts_characters_not_bytes() {
2727 let cjk = "日本語のタイトル".repeat(7);
2729 assert_eq!(cjk.chars().count(), 56);
2730 assert!(MD041FirstLineHeading::is_title_candidate(&cjk, true));
2731 let long = "日".repeat(81);
2733 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2734 }
2735
2736 #[test]
2737 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2738 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2741 let content = "My Document Title\n\nMore content follows.";
2743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let warnings = rule.check(&ctx).unwrap();
2745 assert_eq!(warnings.len(), 1);
2746 let fix = warnings[0]
2747 .fix
2748 .as_ref()
2749 .expect("Fix should be present for promotable text");
2750 assert!(
2751 !fix.replacement.is_empty(),
2752 "Fix replacement must not be empty — applying it directly must produce valid output"
2753 );
2754 assert!(
2755 fix.replacement.starts_with("# "),
2756 "Fix replacement should be a level-1 heading, got: {:?}",
2757 fix.replacement
2758 );
2759 assert_eq!(fix.replacement, "# My Document Title");
2760 }
2761
2762 #[test]
2763 fn test_fix_replacement_not_empty_for_releveling() {
2764 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2767 let content = "## Wrong Level\n\nContent.";
2768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2769 let warnings = rule.check(&ctx).unwrap();
2770 assert_eq!(warnings.len(), 1);
2771 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2772 assert!(
2773 !fix.replacement.is_empty(),
2774 "Fix replacement must not be empty for releveling"
2775 );
2776 assert_eq!(fix.replacement, "# Wrong Level");
2777 }
2778
2779 #[test]
2780 fn test_fix_replacement_applied_produces_valid_output() {
2781 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2783 let content = "My Document\n\nMore content.";
2785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786
2787 let warnings = rule.check(&ctx).unwrap();
2788 assert_eq!(warnings.len(), 1);
2789 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2790
2791 let mut patched = content.to_string();
2793 patched.replace_range(fix.range.clone(), &fix.replacement);
2794
2795 let fixed = rule.fix(&ctx).unwrap();
2797
2798 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2799 }
2800
2801 #[test]
2802 fn test_mdx_disable_on_line_1_no_heading() {
2803 let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2808
2809 let rule = MD041FirstLineHeading::default();
2811 let warnings = rule.check(&ctx).unwrap();
2812 assert_eq!(
2817 warnings.len(),
2818 1,
2819 "The rule must emit the warning before engine filtering"
2820 );
2821 assert_eq!(warnings[0].line, 2, "Warning must point past the MDX disable comment");
2822 let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule)];
2823 let filtered = crate::lint(content, &rules, false, crate::config::MarkdownFlavor::MDX, None, None).unwrap();
2824 assert!(
2825 filtered.is_empty(),
2826 "The engine must suppress the disabled rule: {filtered:?}"
2827 );
2828 }
2829
2830 #[test]
2831 fn test_mdx_disable_fix_returns_unchanged() {
2832 let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2835 let rule = MD041FirstLineHeading {
2836 fix_enabled: true,
2837 ..MD041FirstLineHeading::default()
2838 };
2839 let result = rule.fix(&ctx).unwrap();
2840 assert_eq!(
2841 result, content,
2842 "fix() should not modify content when MD041 is disabled via MDX comment"
2843 );
2844 }
2845
2846 #[test]
2847 fn test_mdx_comment_without_disable_heading_on_next_line() {
2848 let rule = MD041FirstLineHeading::default();
2849
2850 let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2853 let result = rule.check(&ctx).unwrap();
2854 assert!(
2855 result.is_empty(),
2856 "MDX comment is preamble; heading on next line should satisfy MD041"
2857 );
2858 }
2859
2860 #[test]
2861 fn test_mdx_comment_without_heading_triggers_warning() {
2862 let rule = MD041FirstLineHeading::default();
2863
2864 let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2867 let result = rule.check(&ctx).unwrap();
2868 assert_eq!(
2869 result.len(),
2870 1,
2871 "MDX comment followed by non-heading should trigger MD041"
2872 );
2873 assert_eq!(
2874 result[0].line, 2,
2875 "Warning should be on line 2 (the first content line after MDX comment)"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_multiline_mdx_comment_followed_by_heading() {
2881 let rule = MD041FirstLineHeading::default();
2882
2883 let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2886 let result = rule.check(&ctx).unwrap();
2887 assert!(
2888 result.is_empty(),
2889 "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_html_comment_still_works_as_preamble_regression() {
2895 let rule = MD041FirstLineHeading::default();
2896
2897 let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2900 let result = rule.check(&ctx).unwrap();
2901 assert!(
2902 result.is_empty(),
2903 "HTML comment should still be treated as preamble (regression test)"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_mdg_requires_first_h1() {
2909 let rule = MD041FirstLineHeading::default();
2912 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
2913
2914 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2915 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2916
2917 assert!(!rule.check(&mdg_ctx).unwrap().is_empty());
2918 assert_eq!(
2919 rule.check(&mdg_ctx).unwrap().len(),
2920 rule.check(&standard_ctx).unwrap().len(),
2921 "MDG must not differ from Standard"
2922 );
2923 }
2924
2925 #[test]
2926 fn test_mdg_fix_relevels_without_relocating_content() {
2927 let rule = MD041FirstLineHeading {
2930 fix_enabled: true,
2931 ..MD041FirstLineHeading::default()
2932 };
2933 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
2934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2935
2936 let fixed = rule.fix(&ctx).unwrap();
2937 assert_eq!(fixed, "# Feature: Checkout\n\n##### Scenario: Purchase\n");
2938
2939 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2940 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2941 }
2942
2943 #[test]
2944 fn test_setext_heading_opened_by_badge_lines_is_judged_at_its_last_line() {
2945 let rule = MD041FirstLineHeading {
2949 fix_enabled: true,
2950 ..MD041FirstLineHeading::default()
2951 };
2952 let content = "\n\nTitle\n---\n";
2953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2954
2955 let result = rule.check(&ctx).unwrap();
2956 assert_eq!(result.len(), 1);
2957 assert_eq!(
2958 (
2959 result[0].line,
2960 result[0].column,
2961 result[0].end_line,
2962 result[0].end_column
2963 ),
2964 (3, 1, 3, 6)
2965 );
2966 assert!(result[0].message.contains("level 1 heading"));
2967
2968 assert_eq!(rule.fix(&ctx).unwrap(), "#   Title\n");
2971 }
2972}