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 is_mdx_comment_only_line(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
168 let line = &ctx.lines[idx];
169 if !line.in_mdx_comment || line.is_setext_heading_text || ctx.heading_on_line(idx + 1).is_some() {
170 return false;
171 }
172 let prefix_len = line.blockquote.as_ref().map_or(0, |quote| quote.prefix.len());
173 let start = line.byte_offset + prefix_len;
174 let end = line.byte_offset + line.byte_len;
175 let ranges = ctx.mdx_comment_ranges();
176 let first = ranges.partition_point(|&(_, range_end)| range_end <= start);
177 let mut cursor = start;
178 for &(range_start, range_end) in &ranges[first..] {
179 if range_start >= end {
180 break;
181 }
182 let gap_end = range_start.max(cursor).min(end);
183 if !ctx.content[cursor..gap_end].trim().is_empty() {
184 return false;
185 }
186 cursor = range_end.max(cursor).min(end);
187 }
188 ctx.content[cursor..end].trim().is_empty()
189 }
190
191 fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
197 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
198
199 let filtered = ctx
200 .filtered_lines()
201 .skip_front_matter()
202 .skip_esm_blocks()
203 .skip_html_comments();
204
205 for filtered_line in filtered {
206 let idx = filtered_line.line_num - 1;
207 let line_info = &ctx.lines[idx];
208
209 if line_info.is_blank || line_info.is_kramdown_block_ial || Self::is_mdx_comment_only_line(ctx, idx) {
210 continue;
211 }
212
213 let line_content = filtered_line.content;
214 if ctx.flavor == crate::config::MarkdownFlavor::GhAw
215 && !line_info.in_code_block
216 && crate::utils::gh_aw::is_control_line(line_content)
217 {
218 continue;
219 }
220 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
221 continue;
222 }
223 if Self::is_non_content_line(line_content) {
224 continue;
225 }
226 return Some(idx);
227 }
228 None
229 }
230
231 fn heading_covering<'a>(
239 ctx: &'a crate::lint_context::LintContext,
240 idx: usize,
241 ) -> Option<(usize, &'a crate::lint_context::HeadingInfo)> {
242 let line_info = ctx.lines.get(idx)?;
243 if let Some(heading) = line_info.heading.as_deref() {
244 return Some((idx, heading));
245 }
246 if !line_info.is_setext_heading_text {
247 return None;
248 }
249 ctx.lines[idx..]
250 .iter()
251 .enumerate()
252 .take_while(|(_, li)| li.is_setext_heading_text)
253 .find_map(|(offset, li)| li.heading.as_deref().map(|heading| (idx + offset, heading)))
254 }
255
256 fn line_starts_in_literal_code(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
257 ctx.lines.get(idx).is_some_and(|line| {
258 let marker_offset = line.byte_offset + line.indent;
259 ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
260 })
261 }
262
263 fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
272 for (idx, line_info) in ctx.lines.iter().enumerate() {
273 if line_info.is_blank
274 || line_info.in_front_matter
275 || line_info.in_code_block
276 || line_info.in_html_comment
277 || Self::is_mdx_comment_only_line(ctx, idx)
278 || line_info.in_math_block
279 || Self::line_starts_in_literal_code(ctx, idx)
280 {
281 continue;
282 }
283
284 let in_container = line_info.in_list_block
285 || line_info.blockquote.is_some()
286 || line_info.in_admonition
287 || line_info.in_content_tab
288 || line_info.in_pandoc_div
289 || line_info.in_pymdown_block
290 || line_info.in_kramdown_extension_block;
291 if in_container {
292 continue;
293 }
294
295 if let Some(heading) = line_info.heading.as_deref() {
296 let marker_idx =
297 idx + usize::from(matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
298 if Self::line_starts_in_literal_code(ctx, marker_idx) {
299 continue;
300 }
301 return Some(idx + 1 - heading.text_lines);
304 }
305
306 let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
309 if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
310 return Some(idx);
311 }
312 }
313 None
314 }
315
316 fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
323 if self.allow_preamble {
324 Self::first_top_level_heading_idx(ctx)
325 } else {
326 Self::first_content_line_idx(ctx)
327 }
328 }
329
330 fn is_badge_image_line(line: &str) -> bool {
336 if line.is_empty() {
337 return false;
338 }
339
340 if !line.starts_with('!') && !line.starts_with('[') {
342 return false;
343 }
344
345 let mut remaining = line;
347 while !remaining.is_empty() {
348 remaining = remaining.trim_start();
349 if remaining.is_empty() {
350 break;
351 }
352
353 if remaining.starts_with("[![") {
355 if let Some(end) = Self::find_linked_image_end(remaining) {
356 remaining = &remaining[end..];
357 continue;
358 }
359 return false;
360 }
361
362 if remaining.starts_with("![") {
364 if let Some(end) = Self::find_image_end(remaining) {
365 remaining = &remaining[end..];
366 continue;
367 }
368 return false;
369 }
370
371 return false;
373 }
374
375 true
376 }
377
378 fn find_image_end(s: &str) -> Option<usize> {
380 if !s.starts_with("![") {
381 return None;
382 }
383 let alt_end = s[2..].find("](")?;
385 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
388 Some(paren_start + paren_end + 1)
389 }
390
391 fn find_linked_image_end(s: &str) -> Option<usize> {
393 if !s.starts_with("[![") {
394 return None;
395 }
396 let inner_end = Self::find_image_end(&s[1..])?;
398 let after_inner = 1 + inner_end;
399 if !s[after_inner..].starts_with("](") {
401 return None;
402 }
403 let link_start = after_inner + 2;
404 let link_end = s[link_start..].find(')')?;
405 Some(link_start + link_end + 1)
406 }
407
408 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
410 let trimmed = line.trim_start();
411
412 if trimmed.starts_with('#') {
414 let hashes = "#".repeat(target_level);
415 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
417 let after_hashes = &trimmed[content_start..];
418 let content = after_hashes.trim_start();
419
420 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
422 format!("{leading_ws}{hashes} {content}")
423 } else {
424 let hashes = "#".repeat(target_level);
427 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
428 format!("{leading_ws}{hashes} {trimmed}")
429 }
430 }
431
432 fn heading_as_atx(
439 &self,
440 ctx: &crate::lint_context::LintContext,
441 heading_idx: usize,
442 first_idx: usize,
443 current_level: usize,
444 ) -> String {
445 let first_line = ctx.lines[first_idx].content(ctx.content);
446 match ctx.lines[heading_idx].heading.as_deref() {
447 Some(heading) if heading.text_lines > 1 => {
448 let leading_ws: String = first_line.chars().take_while(|c| c.is_whitespace()).collect();
449 format!("{leading_ws}{} {}", "#".repeat(self.level), heading.raw_text)
450 }
451 _ => self.fix_heading_level(first_line, current_level, self.level),
452 }
453 }
454
455 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
467 if text.is_empty() {
468 return false;
469 }
470
471 if !next_is_blank_or_eof {
472 return false;
473 }
474
475 if text.chars().count() > 80 {
476 return false;
477 }
478
479 let last_char = text.chars().next_back().unwrap_or(' ');
480 !matches!(last_char, '.' | '?' | '!' | ':' | ';')
481 }
482
483 fn is_promotable_line(line_info: &crate::lint_context::LineInfo) -> bool {
493 line_info.is_paragraph_context() && line_info.list_item.is_none() && line_info.blockquote.is_none()
494 }
495
496 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
500 let path = ctx.source_file()?;
501 let stem = path.file_stem().and_then(|s| s.to_str())?;
502
503 let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
506 path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
507 } else {
508 stem
509 };
510
511 let title: String = effective_stem
512 .split(['-', '_'])
513 .filter(|w| !w.is_empty())
514 .map(|word| {
515 let mut chars = word.chars();
516 match chars.next() {
517 None => String::new(),
518 Some(first) => {
519 let upper: String = first.to_uppercase().collect();
520 upper + chars.as_str()
521 }
522 }
523 })
524 .collect::<Vec<_>>()
525 .join(" ");
526
527 if title.is_empty() { None } else { Some(title) }
528 }
529
530 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
532 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
534 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
535 && let Some(h_level) = captures.get(1)
536 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
537 {
538 return true;
539 }
540
541 let html_tags = ctx.html_tags();
543 let target_tag = format!("h{level}");
544
545 let opening_index = html_tags.iter().position(|tag| {
547 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
549 && !tag.is_closing
550 });
551
552 let Some(open_idx) = opening_index else {
553 return false;
554 };
555
556 let mut depth = 1usize;
559 for tag in html_tags.iter().skip(open_idx + 1) {
560 if tag.line <= first_line_idx + 1 {
562 continue;
563 }
564
565 if tag.tag_name == target_tag {
566 if tag.is_closing {
567 depth -= 1;
568 if depth == 0 {
569 return true;
570 }
571 } else if !tag.is_self_closing {
572 depth += 1;
573 }
574 }
575 }
576
577 false
578 }
579
580 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
582 if ctx.lines.is_empty() {
583 return None;
584 }
585
586 if self.allow_preamble {
590 let first_idx = Self::first_top_level_heading_idx(ctx)?;
591 let (heading_idx, heading) = Self::heading_covering(ctx, first_idx)?;
592 if heading.level as usize == self.level {
593 return None;
594 }
595 return Some(FixPlan::RelevelInPlace {
596 heading_idx,
597 first_idx,
598 is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
599 current_level: heading.level as usize,
600 });
601 }
602
603 let mut front_matter_end_idx = 0;
605 for line_info in &ctx.lines {
606 if line_info.in_front_matter {
607 front_matter_end_idx += 1;
608 } else {
609 break;
610 }
611 }
612
613 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
614 let is_gh_aw = ctx.flavor == crate::config::MarkdownFlavor::GhAw;
615
616 let mut found_heading: Option<(usize, usize, bool, usize)> = None;
618 let mut first_title_candidate: Option<(usize, String)> = None;
620 let mut found_non_title_content = false;
622 let mut saw_non_directive_content = false;
624 let mut saw_gh_aw_directive = false;
625
626 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
627 let line_content = line_info.content(ctx.content);
628 let trimmed = line_content.trim();
629
630 if is_gh_aw && !line_info.in_code_block && crate::utils::gh_aw::is_control_line(line_content) {
631 saw_gh_aw_directive = true;
632 continue;
633 }
634
635 let is_preamble = trimmed.is_empty()
637 || line_info.in_html_comment
638 || Self::is_mdx_comment_only_line(ctx, idx)
639 || line_info.in_html_block
640 || Self::is_non_content_line(line_content)
641 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
642 || line_info.in_kramdown_extension_block
643 || line_info.is_kramdown_block_ial;
644
645 if is_preamble {
646 continue;
647 }
648
649 let is_directive_block = line_info.in_admonition
652 || line_info.in_content_tab
653 || line_info.in_pandoc_div
654 || line_info.is_div_marker
655 || line_info.in_pymdown_block;
656
657 if !is_directive_block {
658 saw_non_directive_content = true;
659 }
660
661 if line_info.is_setext_heading_text && line_info.heading.is_none() {
665 continue;
666 }
667
668 if let Some(heading) = &line_info.heading {
670 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
671 found_heading = Some((idx, idx + 1 - heading.text_lines, is_setext, heading.level as usize));
672 break 'scan;
673 }
674
675 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
677 let next_is_blank_or_eof = ctx
678 .lines
679 .get(idx + 1)
680 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
681
682 if Self::is_promotable_line(line_info) && Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
683 first_title_candidate = Some((idx, trimmed.to_string()));
684 } else {
685 found_non_title_content = true;
686 }
687 }
688 }
689
690 if let Some((h_idx, first_idx, is_setext, current_level)) = found_heading {
691 if found_non_title_content || first_title_candidate.is_some() {
695 return None;
696 }
697
698 let needs_level_fix = current_level != self.level;
699
700 if saw_gh_aw_directive {
704 return needs_level_fix.then_some(FixPlan::RelevelInPlace {
705 heading_idx: h_idx,
706 first_idx,
707 is_setext,
708 current_level,
709 });
710 }
711 let needs_move = first_idx > front_matter_end_idx;
712
713 if needs_level_fix || needs_move {
714 return Some(FixPlan::MoveOrRelevel {
715 front_matter_end_idx,
716 heading_idx: h_idx,
717 first_idx,
718 is_setext,
719 current_level,
720 needs_level_fix,
721 });
722 }
723 return None; }
725
726 if let Some((title_idx, title_text)) = first_title_candidate {
729 if saw_gh_aw_directive {
730 return None;
731 }
732 return Some(FixPlan::PromotePlainText {
733 front_matter_end_idx,
734 title_line_idx: title_idx,
735 title_text,
736 });
737 }
738
739 if !saw_gh_aw_directive
742 && !saw_non_directive_content
743 && let Some(derived_title) = Self::derive_title(ctx)
744 {
745 return Some(FixPlan::InsertDerived {
746 front_matter_end_idx,
747 derived_title,
748 });
749 }
750
751 None
752 }
753
754 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
756 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
757 }
758}
759
760impl Rule for MD041FirstLineHeading {
761 fn name(&self) -> &'static str {
762 "MD041"
763 }
764
765 fn description(&self) -> &'static str {
766 "First line in file should be a top level heading"
767 }
768
769 fn fix_capability(&self) -> FixCapability {
774 if self.fix_enabled {
775 FixCapability::ConditionallyFixable
776 } else {
777 FixCapability::Unfixable
778 }
779 }
780
781 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
782 let mut warnings = Vec::new();
783
784 if self.should_skip(ctx) {
786 return Ok(warnings);
787 }
788
789 let Some(first_line_idx) = self.checked_line_idx(ctx) else {
790 return Ok(warnings);
791 };
792
793 let first_line_info = &ctx.lines[first_line_idx];
795 let covering_heading = Self::heading_covering(ctx, first_line_idx);
798 let is_correct_heading = if let Some((_, heading)) = covering_heading {
799 heading.level as usize == self.level
800 } else {
801 Self::is_html_heading(ctx, first_line_idx, self.level)
803 };
804
805 if !is_correct_heading {
806 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
809 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
810 let (end_line, end_col) = match covering_heading.filter(|(last_idx, _)| *last_idx > first_line_idx) {
813 Some((last_idx, _)) => {
814 let last_content = ctx.lines[last_idx].content(ctx.content);
815 (last_idx + 1, last_content.trim_end().chars().count() + 1)
816 }
817 None => (end_line, end_col),
818 };
819
820 let fix = if self.can_fix(ctx) {
826 self.analyze_for_fix(ctx).and_then(|plan| {
827 let range_start = first_line_info.byte_offset;
828 let range_end = range_start + first_line_info.byte_len;
829 match &plan {
830 FixPlan::MoveOrRelevel {
834 heading_idx,
835 first_idx,
836 current_level,
837 needs_level_fix,
838 is_setext,
839 ..
840 } if *first_idx == first_line_idx && !*is_setext => {
841 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
843 let replacement = if *needs_level_fix {
844 self.fix_heading_level(heading_line, *current_level, self.level)
845 } else {
846 heading_line.to_string()
847 };
848 Some(Fix::new(range_start..range_end, replacement))
849 }
850 FixPlan::RelevelInPlace {
851 heading_idx,
852 first_idx,
853 current_level,
854 is_setext,
855 } if *first_idx == first_line_idx && !*is_setext => {
856 let replacement = self.fix_heading_level(
857 ctx.lines[*heading_idx].content(ctx.content),
858 *current_level,
859 self.level,
860 );
861 Some(Fix::new(range_start..range_end, replacement))
862 }
863 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
864 let replacement = format!(
865 "{} {}",
866 "#".repeat(self.level),
867 ctx.lines[*title_line_idx].content(ctx.content).trim()
868 );
869 Some(Fix::new(range_start..range_end, replacement))
870 }
871 _ => {
872 self.fix(ctx)
876 .ok()
877 .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
878 }
879 }
880 })
881 } else {
882 None
883 };
884
885 warnings.push(LintWarning {
886 rule_name: Some(self.name().to_string()),
887 line: start_line,
888 column: start_col,
889 end_line,
890 end_column: end_col,
891 message: if self.allow_preamble {
892 format!("First heading in file should be a level {} heading", self.level)
893 } else {
894 format!("First line in file should be a level {} heading", self.level)
895 },
896 severity: Severity::Warning,
897 fix,
898 });
899 }
900 Ok(warnings)
901 }
902
903 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
904 if !self.fix_enabled {
905 return Ok(ctx.content.to_string());
906 }
907
908 if self.should_skip(ctx) {
909 return Ok(ctx.content.to_string());
910 }
911
912 let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
915 if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
916 return Ok(ctx.content.to_string());
917 }
918
919 let Some(plan) = self.analyze_for_fix(ctx) else {
920 return Ok(ctx.content.to_string());
921 };
922
923 let lines = ctx.raw_lines();
924
925 let mut result = String::new();
926 let preserve_trailing_newline = ctx.content.ends_with('\n');
927
928 match plan {
929 FixPlan::MoveOrRelevel {
930 front_matter_end_idx,
931 heading_idx,
932 first_idx,
933 is_setext,
934 current_level,
935 needs_level_fix,
936 } => {
937 let fixed_heading = if needs_level_fix || is_setext {
938 self.heading_as_atx(ctx, heading_idx, first_idx, current_level)
939 } else {
940 ctx.lines[heading_idx].content(ctx.content).to_string()
941 };
942
943 for line in lines.iter().take(front_matter_end_idx) {
944 result.push_str(line);
945 result.push('\n');
946 }
947 result.push_str(&fixed_heading);
948 result.push('\n');
949 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
950 if (first_idx..=heading_idx).contains(&idx) {
954 continue;
955 }
956 if is_setext && idx == heading_idx + 1 {
957 continue;
958 }
959 result.push_str(line);
960 result.push('\n');
961 }
962 }
963
964 FixPlan::PromotePlainText {
965 front_matter_end_idx,
966 title_line_idx,
967 title_text,
968 } => {
969 let hashes = "#".repeat(self.level);
970 let new_heading = format!("{hashes} {title_text}");
971
972 for line in lines.iter().take(front_matter_end_idx) {
973 result.push_str(line);
974 result.push('\n');
975 }
976 result.push_str(&new_heading);
977 result.push('\n');
978 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
979 if idx == title_line_idx {
980 continue;
981 }
982 result.push_str(line);
983 result.push('\n');
984 }
985 }
986
987 FixPlan::RelevelInPlace {
988 heading_idx,
989 first_idx,
990 is_setext,
991 current_level,
992 } => {
993 for (idx, line) in lines.iter().enumerate() {
994 if idx == first_idx {
995 result.push_str(&self.heading_as_atx(ctx, heading_idx, first_idx, current_level));
996 result.push('\n');
997 continue;
998 }
999 if idx > first_idx && idx <= heading_idx {
1002 continue;
1003 }
1004 if is_setext && idx == heading_idx + 1 {
1005 continue;
1006 }
1007 result.push_str(line);
1008 result.push('\n');
1009 }
1010 }
1011
1012 FixPlan::InsertDerived {
1013 front_matter_end_idx,
1014 derived_title,
1015 } => {
1016 let hashes = "#".repeat(self.level);
1017 let new_heading = format!("{hashes} {derived_title}");
1018
1019 for line in lines.iter().take(front_matter_end_idx) {
1020 result.push_str(line);
1021 result.push('\n');
1022 }
1023 result.push_str(&new_heading);
1024 result.push('\n');
1025 result.push('\n');
1026 for line in lines.iter().skip(front_matter_end_idx) {
1027 result.push_str(line);
1028 result.push('\n');
1029 }
1030 }
1031 }
1032
1033 if !preserve_trailing_newline && result.ends_with('\n') {
1034 result.pop();
1035 }
1036
1037 Ok(result)
1038 }
1039
1040 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1042 let only_directives = !ctx.content.is_empty()
1047 && ctx.lines.iter().filter(|line| !line.is_blank).all(|line| {
1048 let t = line.content(ctx.content).trim();
1049 (if ctx.flavor == crate::config::MarkdownFlavor::GhAw {
1051 !line.in_code_block && crate::utils::gh_aw::is_control_line(t)
1052 } else {
1053 t.starts_with("{{#") && t.ends_with("}}")
1054 })
1055 || (t.starts_with("<!--") && t.ends_with("-->"))
1057 });
1058
1059 ctx.content.is_empty()
1060 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
1061 || only_directives
1062 }
1063
1064 fn as_any(&self) -> &dyn std::any::Any {
1065 self
1066 }
1067
1068 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1069 where
1070 Self: Sized,
1071 {
1072 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
1074
1075 let use_front_matter = !md041_config.front_matter_title.is_empty();
1076
1077 Box::new(
1078 MD041FirstLineHeading::with_pattern_from(
1079 md041_config.level.as_usize(),
1080 use_front_matter,
1081 md041_config.front_matter_title_pattern,
1082 md041_config.fix,
1083 config.withheld_rule_values.contains("MD041"),
1084 )
1085 .with_allow_preamble(md041_config.allow_preamble),
1086 )
1087 }
1088
1089 crate::impl_rule_config_sections!(MD041Config);
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use crate::lint_context::LintContext;
1096
1097 #[test]
1098 fn test_visible_prose_beside_mdx_comments_is_not_preamble() {
1099 let rule = MD041FirstLineHeading {
1100 fix_enabled: true,
1101 ..MD041FirstLineHeading::default()
1102 };
1103 for prose in [
1104 "Visible prose {/* c */}",
1105 "{/* c */} Visible prose",
1106 "{/* c */} Visible prose {/* d */}",
1107 ] {
1108 let source = format!("{prose}\n\n# actual heading\n");
1109 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1110 let warnings = rule.check(&ctx).unwrap();
1111 assert_eq!(warnings.len(), 1, "visible content precedes the heading: {source}");
1112 assert_eq!(warnings[0].line, 1);
1113 assert_eq!(
1114 rule.fix(&ctx).unwrap(),
1115 source,
1116 "narrative content must not be reordered"
1117 );
1118 let allowed = rule.clone().with_allow_preamble(true);
1119 assert!(allowed.check(&ctx).unwrap().is_empty());
1120 assert_eq!(allowed.fix(&ctx).unwrap(), source);
1121 }
1122 }
1123
1124 #[test]
1125 fn test_comment_only_mdx_lines_still_count_as_preamble() {
1126 let rule = MD041FirstLineHeading::default();
1127 for preamble in [
1128 "{/* c */}",
1129 " {/* c */} ",
1130 "{/* c */} {/* d */}",
1131 "> {/* c */}",
1132 "{/*\n# literal heading\n*/}",
1133 ] {
1134 let source = format!("{preamble}\n\n# actual heading\n");
1135 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1136 assert!(
1137 rule.check(&ctx).unwrap().is_empty(),
1138 "no visible prose in preamble: {source}"
1139 );
1140 }
1141 }
1142
1143 #[test]
1144 fn test_mdx_inline_comments_do_not_hide_a_correct_first_heading() {
1145 let rule = MD041FirstLineHeading {
1146 fix_enabled: true,
1147 ..MD041FirstLineHeading::default()
1148 };
1149 for source in [
1150 "# hello {/* c */}\n\nVisible prose.\n",
1151 "hello {/* c */}\n===\n\nVisible prose.\n",
1152 ] {
1153 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1154 assert!(rule.check(&ctx).unwrap().is_empty(), "valid first heading: {source}");
1155 assert_eq!(rule.fix(&ctx).unwrap(), source, "no title may be added: {source}");
1156 }
1157 let source = "{/* preamble */}\n\n# hello {/* c */}\n\nVisible prose.\n";
1158 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1159 assert!(rule.check(&ctx).unwrap().is_empty());
1160 let rule = rule.with_allow_preamble(true);
1161 assert_eq!(rule.fix(&ctx).unwrap(), source);
1162 }
1163
1164 #[test]
1165 fn test_mdx_inline_comment_heading_is_judged_when_preamble_is_allowed() {
1166 let rule = MD041FirstLineHeading::default().with_allow_preamble(true);
1167 for source in [
1168 "## second level {/* c */}\n\n# later first level\n",
1169 "second level {/* c */}\n---\n\n# later first level\n",
1170 ] {
1171 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1172 let warnings = rule.check(&ctx).unwrap();
1173 assert_eq!(warnings.len(), 1, "the first heading still counts: {source}");
1174 assert_eq!(warnings[0].line, 1);
1175 }
1176 }
1177
1178 #[test]
1179 fn test_mdx_flow_comment_headings_remain_preamble_for_md041() {
1180 let rule = MD041FirstLineHeading::default();
1181 let source = "{/*\n# hidden heading\n*/}\n\n# visible title\n";
1182 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1183 assert!(rule.check(&ctx).unwrap().is_empty());
1184 }
1185
1186 #[test]
1187 fn test_first_line_is_heading_correct_level() {
1188 let rule = MD041FirstLineHeading::default();
1189
1190 let content = "# My Document\n\nSome content here.";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert!(
1195 result.is_empty(),
1196 "Expected no warnings when first line is a level 1 heading"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_first_line_is_heading_wrong_level() {
1202 let rule = MD041FirstLineHeading::default();
1203
1204 let content = "## My Document\n\nSome content here.";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let result = rule.check(&ctx).unwrap();
1208 assert_eq!(result.len(), 1);
1209 assert_eq!(result[0].line, 1);
1210 assert!(result[0].message.contains("level 1 heading"));
1211 }
1212
1213 #[test]
1214 fn test_first_line_not_heading() {
1215 let rule = MD041FirstLineHeading::default();
1216
1217 let content = "This is not a heading\n\n# This is a heading";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert_eq!(result.len(), 1);
1222 assert_eq!(result[0].line, 1);
1223 assert!(result[0].message.contains("level 1 heading"));
1224 }
1225
1226 #[test]
1227 fn test_empty_lines_before_heading() {
1228 let rule = MD041FirstLineHeading::default();
1229
1230 let content = "\n\n# My Document\n\nSome content.";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let result = rule.check(&ctx).unwrap();
1234 assert!(
1235 result.is_empty(),
1236 "Expected no warnings when empty lines precede a valid heading"
1237 );
1238
1239 let content = "\n\nNot a heading\n\nSome content.";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert_eq!(result.len(), 1);
1244 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
1246 }
1247
1248 #[test]
1249 fn test_front_matter_with_title() {
1250 let rule = MD041FirstLineHeading::new(1, true);
1251
1252 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
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 "Expected no warnings when front matter has title field"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_front_matter_without_title() {
1264 let rule = MD041FirstLineHeading::new(1, true);
1265
1266 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert_eq!(result.len(), 1);
1271 assert_eq!(result[0].line, 6); }
1273
1274 #[test]
1275 fn test_front_matter_disabled() {
1276 let rule = MD041FirstLineHeading::new(1, false);
1277
1278 let content = "---\ntitle: My Document\n---\n\nSome content here.";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282 assert_eq!(result.len(), 1);
1283 assert_eq!(result[0].line, 5); }
1285
1286 #[test]
1287 fn test_html_comments_before_heading() {
1288 let rule = MD041FirstLineHeading::default();
1289
1290 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294 assert!(
1295 result.is_empty(),
1296 "HTML comments should be skipped when checking for first heading"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_multiline_html_comment_before_heading() {
1302 let rule = MD041FirstLineHeading::default();
1303
1304 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308 assert!(
1309 result.is_empty(),
1310 "Multi-line HTML comments should be skipped when checking for first heading"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_html_comment_with_blank_lines_before_heading() {
1316 let rule = MD041FirstLineHeading::default();
1317
1318 let content = "<!-- This is a comment -->\n\n# My Document\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 "HTML comments with blank lines should be skipped when checking for first heading"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_html_comment_before_html_heading() {
1330 let rule = MD041FirstLineHeading::default();
1331
1332 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\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 "HTML comments should be skipped before HTML headings"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_document_with_only_html_comments() {
1344 let rule = MD041FirstLineHeading::default();
1345
1346 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert!(
1351 result.is_empty(),
1352 "Documents with only HTML comments should not trigger MD041"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_html_comment_followed_by_non_heading() {
1358 let rule = MD041FirstLineHeading::default();
1359
1360 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363 let result = rule.check(&ctx).unwrap();
1364 assert_eq!(
1365 result.len(),
1366 1,
1367 "HTML comment followed by non-heading should still trigger MD041"
1368 );
1369 assert_eq!(
1370 result[0].line, 2,
1371 "Warning should be on the first non-comment, non-heading line"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_multiple_html_comments_before_heading() {
1377 let rule = MD041FirstLineHeading::default();
1378
1379 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383 assert!(
1384 result.is_empty(),
1385 "Multiple HTML comments should all be skipped before heading"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_html_comment_with_wrong_level_heading() {
1391 let rule = MD041FirstLineHeading::default();
1392
1393 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert_eq!(
1398 result.len(),
1399 1,
1400 "HTML comment followed by wrong-level heading should still trigger MD041"
1401 );
1402 assert!(
1403 result[0].message.contains("level 1 heading"),
1404 "Should require level 1 heading"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_html_comment_mixed_with_reference_definitions() {
1410 let rule = MD041FirstLineHeading::default();
1411
1412 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert!(
1417 result.is_empty(),
1418 "HTML comments and reference definitions should both be skipped before heading"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_html_comment_after_front_matter() {
1424 let rule = MD041FirstLineHeading::default();
1425
1426 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let result = rule.check(&ctx).unwrap();
1430 assert!(
1431 result.is_empty(),
1432 "HTML comments after front matter should be skipped before heading"
1433 );
1434 }
1435
1436 #[test]
1437 fn test_html_comment_not_at_start_should_not_affect_rule() {
1438 let rule = MD041FirstLineHeading::default();
1439
1440 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443 let result = rule.check(&ctx).unwrap();
1444 assert!(
1445 result.is_empty(),
1446 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_multiline_html_comment_followed_by_non_heading() {
1452 let rule = MD041FirstLineHeading::default();
1453
1454 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let result = rule.check(&ctx).unwrap();
1458 assert_eq!(
1459 result.len(),
1460 1,
1461 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1462 );
1463 assert_eq!(
1464 result[0].line, 5,
1465 "Warning should be on the first non-comment, non-heading line"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_different_heading_levels() {
1471 let rule = MD041FirstLineHeading::new(2, false);
1473
1474 let content = "## Second Level Heading\n\nContent.";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1478
1479 let content = "# First Level Heading\n\nContent.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let result = rule.check(&ctx).unwrap();
1483 assert_eq!(result.len(), 1);
1484 assert!(result[0].message.contains("level 2 heading"));
1485 }
1486
1487 #[test]
1488 fn test_setext_headings() {
1489 let rule = MD041FirstLineHeading::default();
1490
1491 let content = "My Document\n===========\n\nContent.";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let result = rule.check(&ctx).unwrap();
1495 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1496
1497 let content = "My Document\n-----------\n\nContent.";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501 assert_eq!(result.len(), 1);
1502 assert!(result[0].message.contains("level 1 heading"));
1503 }
1504
1505 #[test]
1506 fn test_empty_document() {
1507 let rule = MD041FirstLineHeading::default();
1508
1509 let content = "";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513 assert!(result.is_empty(), "Expected no warnings for empty document");
1514 }
1515
1516 #[test]
1517 fn test_whitespace_only_document() {
1518 let rule = MD041FirstLineHeading::default();
1519
1520 let content = " \n\n \t\n";
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523 let result = rule.check(&ctx).unwrap();
1524 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1525 }
1526
1527 #[test]
1528 fn test_front_matter_then_whitespace() {
1529 let rule = MD041FirstLineHeading::default();
1530
1531 let content = "---\ntitle: Test\n---\n\n \n\n";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let result = rule.check(&ctx).unwrap();
1535 assert!(
1536 result.is_empty(),
1537 "Expected no warnings when no content after front matter"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_multiple_front_matter_types() {
1543 let rule = MD041FirstLineHeading::new(1, true);
1544
1545 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548 let result = rule.check(&ctx).unwrap();
1549 assert!(
1550 result.is_empty(),
1551 "Expected no warnings for TOML front matter with title"
1552 );
1553
1554 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558 assert!(
1559 result.is_empty(),
1560 "Expected no warnings for JSON front matter with title"
1561 );
1562
1563 let content = "---\ntitle: My Document\n---\n\nContent.";
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1566 let result = rule.check(&ctx).unwrap();
1567 assert!(
1568 result.is_empty(),
1569 "Expected no warnings for YAML front matter with title"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_toml_front_matter_with_heading() {
1575 let rule = MD041FirstLineHeading::default();
1576
1577 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581 assert!(
1582 result.is_empty(),
1583 "Expected no warnings when heading follows TOML front matter"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_toml_front_matter_without_title_no_heading() {
1589 let rule = MD041FirstLineHeading::new(1, true);
1590
1591 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let result = rule.check(&ctx).unwrap();
1595 assert_eq!(result.len(), 1);
1596 assert_eq!(result[0].line, 6);
1597 }
1598
1599 #[test]
1600 fn test_toml_front_matter_level_2_heading() {
1601 let rule = MD041FirstLineHeading::new(2, true);
1603
1604 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1606 let result = rule.check(&ctx).unwrap();
1607 assert!(
1608 result.is_empty(),
1609 "Issue #427: TOML front matter with title and correct heading level should not warn"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1615 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1617
1618 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let result = rule.check(&ctx).unwrap();
1621 assert!(
1622 result.is_empty(),
1623 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_json_front_matter_with_heading() {
1629 let rule = MD041FirstLineHeading::default();
1630
1631 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(
1636 result.is_empty(),
1637 "Expected no warnings when heading follows JSON front matter"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_malformed_front_matter() {
1643 let rule = MD041FirstLineHeading::new(1, true);
1644
1645 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 let result = rule.check(&ctx).unwrap();
1649 assert!(
1650 result.is_empty(),
1651 "Expected no warnings for malformed front matter with title"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_front_matter_with_heading() {
1657 let rule = MD041FirstLineHeading::default();
1658
1659 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662 let result = rule.check(&ctx).unwrap();
1663 assert!(
1664 result.is_empty(),
1665 "Expected no warnings when first line after front matter is correct heading"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_no_fix_suggestion() {
1671 let rule = MD041FirstLineHeading::default();
1672
1673 let content = "Not a heading\n\nContent.";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let result = rule.check(&ctx).unwrap();
1677 assert_eq!(result.len(), 1);
1678 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1679 }
1680
1681 #[test]
1682 fn test_complex_document_structure() {
1683 let rule = MD041FirstLineHeading::default();
1684
1685 let content =
1687 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 let result = rule.check(&ctx).unwrap();
1690 assert!(
1691 result.is_empty(),
1692 "HTML comments should be skipped, so first heading after comment should be valid"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_heading_with_special_characters() {
1698 let rule = MD041FirstLineHeading::default();
1699
1700 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704 assert!(
1705 result.is_empty(),
1706 "Expected no warnings for heading with inline formatting"
1707 );
1708 }
1709
1710 #[test]
1711 fn test_level_configuration() {
1712 for level in 1..=6 {
1714 let rule = MD041FirstLineHeading::new(level, false);
1715
1716 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1718 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1719 let result = rule.check(&ctx).unwrap();
1720 assert!(
1721 result.is_empty(),
1722 "Expected no warnings for correct level {level} heading"
1723 );
1724
1725 let wrong_level = if level == 1 { 2 } else { 1 };
1727 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1728 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1729 let result = rule.check(&ctx).unwrap();
1730 assert_eq!(result.len(), 1);
1731 assert!(result[0].message.contains(&format!("level {level} heading")));
1732 }
1733 }
1734
1735 #[test]
1736 fn test_issue_152_multiline_html_heading() {
1737 let rule = MD041FirstLineHeading::default();
1738
1739 let content = "<h1>\nSome text\n</h1>";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743 assert!(
1744 result.is_empty(),
1745 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_multiline_html_heading_with_attributes() {
1751 let rule = MD041FirstLineHeading::default();
1752
1753 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1756 let result = rule.check(&ctx).unwrap();
1757 assert!(
1758 result.is_empty(),
1759 "Multi-line HTML heading with attributes should be recognized"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_multiline_html_heading_wrong_level() {
1765 let rule = MD041FirstLineHeading::default();
1766
1767 let content = "<h2>\nSome text\n</h2>";
1769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770 let result = rule.check(&ctx).unwrap();
1771 assert_eq!(result.len(), 1);
1772 assert!(result[0].message.contains("level 1 heading"));
1773 }
1774
1775 #[test]
1776 fn test_multiline_html_heading_with_content_after() {
1777 let rule = MD041FirstLineHeading::default();
1778
1779 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782 let result = rule.check(&ctx).unwrap();
1783 assert!(
1784 result.is_empty(),
1785 "Multi-line HTML heading followed by content should be valid"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_multiline_html_heading_incomplete() {
1791 let rule = MD041FirstLineHeading::default();
1792
1793 let content = "<h1>\nSome text\n\nMore content without closing tag";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let result = rule.check(&ctx).unwrap();
1797 assert_eq!(result.len(), 1);
1798 assert!(result[0].message.contains("level 1 heading"));
1799 }
1800
1801 #[test]
1802 fn test_singleline_html_heading_still_works() {
1803 let rule = MD041FirstLineHeading::default();
1804
1805 let content = "<h1>My Document</h1>\n\nContent.";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808 let result = rule.check(&ctx).unwrap();
1809 assert!(
1810 result.is_empty(),
1811 "Single-line HTML headings should still be recognized"
1812 );
1813 }
1814
1815 #[test]
1816 fn test_multiline_html_heading_with_nested_tags() {
1817 let rule = MD041FirstLineHeading::default();
1818
1819 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822 let result = rule.check(&ctx).unwrap();
1823 assert!(
1824 result.is_empty(),
1825 "Multi-line HTML heading with nested tags should be recognized"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_multiline_html_heading_various_levels() {
1831 for level in 1..=6 {
1833 let rule = MD041FirstLineHeading::new(level, false);
1834
1835 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1837 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1838 let result = rule.check(&ctx).unwrap();
1839 assert!(
1840 result.is_empty(),
1841 "Multi-line HTML heading at level {level} should be recognized"
1842 );
1843
1844 let wrong_level = if level == 1 { 2 } else { 1 };
1846 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1847 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1848 let result = rule.check(&ctx).unwrap();
1849 assert_eq!(result.len(), 1);
1850 assert!(result[0].message.contains(&format!("level {level} heading")));
1851 }
1852 }
1853
1854 #[test]
1855 fn test_issue_152_nested_heading_spans_many_lines() {
1856 let rule = MD041FirstLineHeading::default();
1857
1858 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>";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1862 }
1863
1864 #[test]
1865 fn test_issue_152_picture_tag_heading() {
1866 let rule = MD041FirstLineHeading::default();
1867
1868 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>";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let result = rule.check(&ctx).unwrap();
1871 assert!(
1872 result.is_empty(),
1873 "Picture tag inside multi-line HTML heading should be recognized"
1874 );
1875 }
1876
1877 #[test]
1878 fn test_badge_images_before_heading() {
1879 let rule = MD041FirstLineHeading::default();
1880
1881 let content = "\n\n# My Project";
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let result = rule.check(&ctx).unwrap();
1885 assert!(result.is_empty(), "Badge image should be skipped");
1886
1887 let content = " \n\n# My Project";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891 assert!(result.is_empty(), "Multiple badges should be skipped");
1892
1893 let content = "[](https://example.com)\n\n# My Project";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let result = rule.check(&ctx).unwrap();
1897 assert!(result.is_empty(), "Linked badge should be skipped");
1898 }
1899
1900 #[test]
1901 fn test_multiple_badge_lines_before_heading() {
1902 let rule = MD041FirstLineHeading::default();
1903
1904 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907 let result = rule.check(&ctx).unwrap();
1908 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1909 }
1910
1911 #[test]
1912 fn test_badges_without_heading_still_warns() {
1913 let rule = MD041FirstLineHeading::default();
1914
1915 let content = "\n\nThis is not a heading.";
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1920 }
1921
1922 #[test]
1923 fn test_mixed_content_not_badge_line() {
1924 let rule = MD041FirstLineHeading::default();
1925
1926 let content = " Some text here\n\n# Heading";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929 let result = rule.check(&ctx).unwrap();
1930 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1931 }
1932
1933 #[test]
1934 fn test_is_badge_image_line_unit() {
1935 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1937 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1938 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1939 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1940
1941 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1943 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1944 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1945 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1946 }
1947
1948 #[test]
1952 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1953 let rule = MD041FirstLineHeading::default();
1954
1955 let content = "[](){ #example }\n# Title";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1958 let result = rule.check(&ctx).unwrap();
1959 assert!(
1960 result.is_empty(),
1961 "MkDocs anchor line should be skipped in MkDocs flavor"
1962 );
1963 }
1964
1965 #[test]
1966 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1967 let rule = MD041FirstLineHeading::default();
1968
1969 let content = "[](){ #example }\n# Title";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973 assert_eq!(
1974 result.len(),
1975 1,
1976 "MkDocs anchor line should NOT be skipped in Standard flavor"
1977 );
1978 }
1979
1980 #[test]
1981 fn test_multiple_mkdocs_anchors_before_heading() {
1982 let rule = MD041FirstLineHeading::default();
1983
1984 let content = "[](){ #first }\n[](){ #second }\n# Title";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1987 let result = rule.check(&ctx).unwrap();
1988 assert!(
1989 result.is_empty(),
1990 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1991 );
1992 }
1993
1994 #[test]
1995 fn test_mkdocs_anchor_with_front_matter() {
1996 let rule = MD041FirstLineHeading::default();
1997
1998 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2001 let result = rule.check(&ctx).unwrap();
2002 assert!(
2003 result.is_empty(),
2004 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_mkdocs_anchor_kramdown_style() {
2010 let rule = MD041FirstLineHeading::default();
2011
2012 let content = "[](){: #anchor }\n# Title";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2015 let result = rule.check(&ctx).unwrap();
2016 assert!(
2017 result.is_empty(),
2018 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_mkdocs_anchor_without_heading_still_warns() {
2024 let rule = MD041FirstLineHeading::default();
2025
2026 let content = "[](){ #anchor }\nThis is not a heading.";
2028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2029 let result = rule.check(&ctx).unwrap();
2030 assert_eq!(
2031 result.len(),
2032 1,
2033 "MkDocs anchor followed by non-heading should still trigger MD041"
2034 );
2035 }
2036
2037 #[test]
2038 fn test_mkdocs_anchor_with_html_comment() {
2039 let rule = MD041FirstLineHeading::default();
2040
2041 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
2043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2044 let result = rule.check(&ctx).unwrap();
2045 assert!(
2046 result.is_empty(),
2047 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
2048 );
2049 }
2050
2051 #[test]
2054 fn test_fix_disabled_by_default() {
2055 use crate::rule::Rule;
2056 let rule = MD041FirstLineHeading::default();
2057
2058 let content = "## Wrong Level\n\nContent.";
2060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061 let fixed = rule.fix(&ctx).unwrap();
2062 assert_eq!(fixed, content, "Fix should not change content when disabled");
2063 }
2064
2065 #[test]
2066 fn test_fix_wrong_heading_level() {
2067 use crate::rule::Rule;
2068 let rule = MD041FirstLineHeading {
2069 level: 1,
2070 front_matter_title: false,
2071 front_matter_title_pattern: None,
2072 allow_preamble: false,
2073 fix_enabled: true,
2074 };
2075
2076 let content = "## Wrong Level\n\nContent.\n";
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079 let fixed = rule.fix(&ctx).unwrap();
2080 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
2081 }
2082
2083 #[test]
2084 fn test_fix_heading_after_preamble() {
2085 use crate::rule::Rule;
2086 let rule = MD041FirstLineHeading {
2087 level: 1,
2088 front_matter_title: false,
2089 front_matter_title_pattern: None,
2090 allow_preamble: false,
2091 fix_enabled: true,
2092 };
2093
2094 let content = "\n\n# Title\n\nContent.\n";
2096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2097 let fixed = rule.fix(&ctx).unwrap();
2098 assert!(
2099 fixed.starts_with("# Title\n"),
2100 "Heading should be moved to first line, got: {fixed}"
2101 );
2102 }
2103
2104 #[test]
2105 fn test_fix_heading_after_html_comment() {
2106 use crate::rule::Rule;
2107 let rule = MD041FirstLineHeading {
2108 level: 1,
2109 front_matter_title: false,
2110 front_matter_title_pattern: None,
2111 allow_preamble: false,
2112 fix_enabled: true,
2113 };
2114
2115 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
2117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118 let fixed = rule.fix(&ctx).unwrap();
2119 assert!(
2120 fixed.starts_with("# Title\n"),
2121 "Heading should be moved above comment, got: {fixed}"
2122 );
2123 }
2124
2125 #[test]
2126 fn test_fix_heading_level_and_move() {
2127 use crate::rule::Rule;
2128 let rule = MD041FirstLineHeading {
2129 level: 1,
2130 front_matter_title: false,
2131 front_matter_title_pattern: None,
2132 allow_preamble: false,
2133 fix_enabled: true,
2134 };
2135
2136 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
2138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139 let fixed = rule.fix(&ctx).unwrap();
2140 assert!(
2141 fixed.starts_with("# Wrong Level\n"),
2142 "Heading should be fixed and moved, got: {fixed}"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_fix_with_front_matter() {
2148 use crate::rule::Rule;
2149 let rule = MD041FirstLineHeading {
2150 level: 1,
2151 front_matter_title: false,
2152 front_matter_title_pattern: None,
2153 allow_preamble: false,
2154 fix_enabled: true,
2155 };
2156
2157 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160 let fixed = rule.fix(&ctx).unwrap();
2161 assert!(
2162 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
2163 "Heading should be right after front matter, got: {fixed}"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_fix_with_toml_front_matter() {
2169 use crate::rule::Rule;
2170 let rule = MD041FirstLineHeading {
2171 level: 1,
2172 front_matter_title: false,
2173 front_matter_title_pattern: None,
2174 allow_preamble: false,
2175 fix_enabled: true,
2176 };
2177
2178 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181 let fixed = rule.fix(&ctx).unwrap();
2182 assert!(
2183 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
2184 "Heading should be right after TOML front matter, got: {fixed}"
2185 );
2186 }
2187
2188 #[test]
2189 fn test_fix_cannot_fix_no_heading() {
2190 use crate::rule::Rule;
2191 let rule = MD041FirstLineHeading {
2192 level: 1,
2193 front_matter_title: false,
2194 front_matter_title_pattern: None,
2195 allow_preamble: false,
2196 fix_enabled: true,
2197 };
2198
2199 let content = "Just some text.\n\nMore text.\n";
2201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2202 let fixed = rule.fix(&ctx).unwrap();
2203 assert_eq!(fixed, content, "Should not change content when no heading exists");
2204 }
2205
2206 #[test]
2207 fn test_fix_cannot_fix_content_before_heading() {
2208 use crate::rule::Rule;
2209 let rule = MD041FirstLineHeading {
2210 level: 1,
2211 front_matter_title: false,
2212 front_matter_title_pattern: None,
2213 allow_preamble: false,
2214 fix_enabled: true,
2215 };
2216
2217 let content = "Some intro text.\n\n# Title\n\nContent.\n";
2219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220 let fixed = rule.fix(&ctx).unwrap();
2221 assert_eq!(
2222 fixed, content,
2223 "Should not change content when real content exists before heading"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_fix_already_correct() {
2229 use crate::rule::Rule;
2230 let rule = MD041FirstLineHeading {
2231 level: 1,
2232 front_matter_title: false,
2233 front_matter_title_pattern: None,
2234 allow_preamble: false,
2235 fix_enabled: true,
2236 };
2237
2238 let content = "# Title\n\nContent.\n";
2240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2241 let fixed = rule.fix(&ctx).unwrap();
2242 assert_eq!(fixed, content, "Should not change already correct content");
2243 }
2244
2245 #[test]
2246 fn test_fix_setext_heading_removes_underline() {
2247 use crate::rule::Rule;
2248 let rule = MD041FirstLineHeading {
2249 level: 1,
2250 front_matter_title: false,
2251 front_matter_title_pattern: None,
2252 allow_preamble: false,
2253 fix_enabled: true,
2254 };
2255
2256 let content = "Wrong Level\n-----------\n\nContent.\n";
2258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2259 let fixed = rule.fix(&ctx).unwrap();
2260 assert_eq!(
2261 fixed, "# Wrong Level\n\nContent.\n",
2262 "Setext heading should be converted to ATX and underline removed"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_fix_setext_h1_heading() {
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 = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280 let fixed = rule.fix(&ctx).unwrap();
2281 assert_eq!(
2282 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2283 "Setext h1 should be moved and converted to ATX"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_html_heading_not_claimed_fixable() {
2289 use crate::rule::Rule;
2290 let rule = MD041FirstLineHeading {
2291 level: 1,
2292 front_matter_title: false,
2293 front_matter_title_pattern: None,
2294 allow_preamble: false,
2295 fix_enabled: true,
2296 };
2297
2298 let content = "<h2>Title</h2>\n\nContent.\n";
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301 let warnings = rule.check(&ctx).unwrap();
2302 assert_eq!(warnings.len(), 1);
2303 assert!(
2304 warnings[0].fix.is_none(),
2305 "HTML heading should not be claimed as fixable"
2306 );
2307 }
2308
2309 #[test]
2310 fn test_no_heading_not_claimed_fixable() {
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 = "Just some text.\n\nMore text.\n";
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let warnings = rule.check(&ctx).unwrap();
2324 assert_eq!(warnings.len(), 1);
2325 assert!(
2326 warnings[0].fix.is_none(),
2327 "Document without heading should not be claimed as fixable"
2328 );
2329 }
2330
2331 #[test]
2332 fn test_content_before_heading_not_claimed_fixable() {
2333 use crate::rule::Rule;
2334 let rule = MD041FirstLineHeading {
2335 level: 1,
2336 front_matter_title: false,
2337 front_matter_title_pattern: None,
2338 allow_preamble: false,
2339 fix_enabled: true,
2340 };
2341
2342 let content = "Intro text.\n\n## Heading\n\nMore.\n";
2344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345 let warnings = rule.check(&ctx).unwrap();
2346 assert_eq!(warnings.len(), 1);
2347 assert!(
2348 warnings[0].fix.is_none(),
2349 "Document with content before heading should not be claimed as fixable"
2350 );
2351 }
2352
2353 #[test]
2356 fn test_fix_html_block_before_heading_is_now_fixable() {
2357 use crate::rule::Rule;
2358 let rule = MD041FirstLineHeading {
2359 level: 1,
2360 front_matter_title: false,
2361 front_matter_title_pattern: None,
2362 allow_preamble: false,
2363 fix_enabled: true,
2364 };
2365
2366 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369
2370 let warnings = rule.check(&ctx).unwrap();
2371 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2372 assert!(
2373 warnings[0].fix.is_some(),
2374 "Should be fixable: heading exists after HTML block preamble"
2375 );
2376
2377 let fixed = rule.fix(&ctx).unwrap();
2378 assert!(
2379 fixed.starts_with("# My Document\n"),
2380 "Heading should be moved to the top, got: {fixed}"
2381 );
2382 }
2383
2384 #[test]
2385 fn test_fix_html_block_wrong_level_before_heading() {
2386 use crate::rule::Rule;
2387 let rule = MD041FirstLineHeading {
2388 level: 1,
2389 front_matter_title: false,
2390 front_matter_title_pattern: None,
2391 allow_preamble: false,
2392 fix_enabled: true,
2393 };
2394
2395 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let fixed = rule.fix(&ctx).unwrap();
2398 assert!(
2399 fixed.starts_with("# Wrong Level\n"),
2400 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2401 );
2402 }
2403
2404 #[test]
2407 fn test_fix_promote_plain_text_title() {
2408 use crate::rule::Rule;
2409 let rule = MD041FirstLineHeading {
2410 level: 1,
2411 front_matter_title: false,
2412 front_matter_title_pattern: None,
2413 allow_preamble: false,
2414 fix_enabled: true,
2415 };
2416
2417 let content = "My Project\n\nSome content.\n";
2418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419
2420 let warnings = rule.check(&ctx).unwrap();
2421 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2422 assert!(
2423 warnings[0].fix.is_some(),
2424 "Should be fixable: first line is a title candidate"
2425 );
2426
2427 let fixed = rule.fix(&ctx).unwrap();
2428 assert_eq!(
2429 fixed, "# My Project\n\nSome content.\n",
2430 "Title line should be promoted to heading"
2431 );
2432 }
2433
2434 #[test]
2435 fn test_fix_promote_plain_text_title_with_front_matter() {
2436 use crate::rule::Rule;
2437 let rule = MD041FirstLineHeading {
2438 level: 1,
2439 front_matter_title: false,
2440 front_matter_title_pattern: None,
2441 allow_preamble: false,
2442 fix_enabled: true,
2443 };
2444
2445 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447 let fixed = rule.fix(&ctx).unwrap();
2448 assert!(
2449 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2450 "Title should be promoted and placed right after front matter, got: {fixed}"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_fix_no_promote_ends_with_period() {
2456 use crate::rule::Rule;
2457 let rule = MD041FirstLineHeading {
2458 level: 1,
2459 front_matter_title: false,
2460 front_matter_title_pattern: None,
2461 allow_preamble: false,
2462 fix_enabled: true,
2463 };
2464
2465 let content = "This is a sentence.\n\nContent.\n";
2467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2468 let fixed = rule.fix(&ctx).unwrap();
2469 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2470
2471 let warnings = rule.check(&ctx).unwrap();
2472 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2473 }
2474
2475 #[test]
2476 fn test_fix_no_promote_ends_with_colon() {
2477 use crate::rule::Rule;
2478 let rule = MD041FirstLineHeading {
2479 level: 1,
2480 front_matter_title: false,
2481 front_matter_title_pattern: None,
2482 allow_preamble: false,
2483 fix_enabled: true,
2484 };
2485
2486 let content = "Note:\n\nContent.\n";
2487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2488 let fixed = rule.fix(&ctx).unwrap();
2489 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2490 }
2491
2492 #[test]
2493 fn test_fix_no_promote_if_too_long() {
2494 use crate::rule::Rule;
2495 let rule = MD041FirstLineHeading {
2496 level: 1,
2497 front_matter_title: false,
2498 front_matter_title_pattern: None,
2499 allow_preamble: false,
2500 fix_enabled: true,
2501 };
2502
2503 let long_line = "A".repeat(81);
2505 let content = format!("{long_line}\n\nContent.\n");
2506 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2507 let fixed = rule.fix(&ctx).unwrap();
2508 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2509 }
2510
2511 #[test]
2512 fn test_fix_no_promote_ordered_list_item() {
2513 use crate::rule::Rule;
2514 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2515
2516 for content in [
2518 "1. Introduction\n\nBody.\n",
2519 "1) Introduction\n\nBody.\n",
2520 "123456789. Ninth step\n\nBody.\n",
2521 ] {
2522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2523 assert!(!rule.check(&ctx).unwrap().is_empty(), "missing H1 is still reported");
2524 let fixed = rule.fix(&ctx).unwrap();
2525 assert_eq!(fixed, content, "an ordered list item must not become a heading");
2526 }
2527
2528 let content = "1234567890. Release Notes\n\nBody.\n";
2530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531 assert_eq!(rule.fix(&ctx).unwrap(), "# 1234567890. Release Notes\n\nBody.\n");
2532 }
2533
2534 #[test]
2537 fn test_fix_no_promote_structural_lines() {
2538 use crate::rule::Rule;
2539 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2540
2541 for content in [
2542 ">Quote\n\nBody.\n",
2543 "> Quote\n\nBody.\n",
2544 "- Item\n\nBody.\n",
2545 "* Item\n\nBody.\n",
2546 "+ Item\n\nBody.\n",
2547 " - Indented item\n\nBody.\n",
2548 "***\n\nBody.\n",
2549 "---\n\nBody.\n",
2550 "```\n\nBody.\n",
2551 " code\n\nBody.\n",
2552 ] {
2553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2554 let warnings = rule.check(&ctx).unwrap();
2555 assert_eq!(warnings.len(), 1, "missing H1 is still reported for {content:?}");
2556 assert!(warnings[0].fix.is_none(), "no fix may be offered for {content:?}");
2557 assert_eq!(
2558 rule.fix(&ctx).unwrap(),
2559 content,
2560 "{content:?} must not become a heading"
2561 );
2562 }
2563
2564 let ctx = LintContext::new("Plain Title\n\nBody.\n", crate::config::MarkdownFlavor::Standard, None);
2566 assert_eq!(rule.fix(&ctx).unwrap(), "# Plain Title\n\nBody.\n");
2567 }
2568
2569 #[test]
2570 fn test_fix_promotes_multibyte_title_within_character_limit() {
2571 use crate::rule::Rule;
2572 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2573
2574 let title = "日本語のタイトル".repeat(7);
2576 let content = format!("{title}\n\nBody.\n");
2577 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2578 let fixed = rule.fix(&ctx).unwrap();
2579 assert_eq!(fixed, format!("# {title}\n\nBody.\n"));
2580 }
2581
2582 #[test]
2583 fn test_fix_no_promote_if_no_blank_after() {
2584 use crate::rule::Rule;
2585 let rule = MD041FirstLineHeading {
2586 level: 1,
2587 front_matter_title: false,
2588 front_matter_title_pattern: None,
2589 allow_preamble: false,
2590 fix_enabled: true,
2591 };
2592
2593 let content = "My Project\nImmediately continues.\n\nContent.\n";
2595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2596 let fixed = rule.fix(&ctx).unwrap();
2597 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2598 }
2599
2600 #[test]
2601 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2602 use crate::rule::Rule;
2603 let rule = MD041FirstLineHeading {
2604 level: 1,
2605 front_matter_title: false,
2606 front_matter_title_pattern: None,
2607 allow_preamble: false,
2608 fix_enabled: true,
2609 };
2610
2611 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2615 let fixed = rule.fix(&ctx).unwrap();
2616 assert_eq!(
2617 fixed, content,
2618 "Should not fix when title candidate exists before a heading"
2619 );
2620
2621 let warnings = rule.check(&ctx).unwrap();
2622 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2623 }
2624
2625 #[test]
2626 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2627 use crate::rule::Rule;
2628 let rule = MD041FirstLineHeading {
2629 level: 1,
2630 front_matter_title: false,
2631 front_matter_title_pattern: None,
2632 allow_preamble: false,
2633 fix_enabled: true,
2634 };
2635
2636 let content = "My Project";
2638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let fixed = rule.fix(&ctx).unwrap();
2640 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2641 }
2642
2643 #[test]
2646 fn test_fix_insert_derived_directive_only_document() {
2647 use crate::rule::Rule;
2648 use std::path::PathBuf;
2649 let rule = MD041FirstLineHeading {
2650 level: 1,
2651 front_matter_title: false,
2652 front_matter_title_pattern: None,
2653 allow_preamble: false,
2654 fix_enabled: true,
2655 };
2656
2657 let content = "!!! note\n This is a note.\n";
2660 let ctx = LintContext::new(
2661 content,
2662 crate::config::MarkdownFlavor::MkDocs,
2663 Some(PathBuf::from("setup-guide.md")),
2664 );
2665
2666 let can_fix = rule.can_fix(&ctx);
2667 assert!(can_fix, "Directive-only document with source file should be fixable");
2668
2669 let fixed = rule.fix(&ctx).unwrap();
2670 assert!(
2671 fixed.starts_with("# Setup Guide\n"),
2672 "Should insert derived heading, got: {fixed}"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_fix_no_insert_derived_without_source_file() {
2678 use crate::rule::Rule;
2679 let rule = MD041FirstLineHeading {
2680 level: 1,
2681 front_matter_title: false,
2682 front_matter_title_pattern: None,
2683 allow_preamble: false,
2684 fix_enabled: true,
2685 };
2686
2687 let content = "!!! note\n This is a note.\n";
2689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2690 let fixed = rule.fix(&ctx).unwrap();
2691 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2692 }
2693
2694 #[test]
2695 fn test_fix_no_insert_derived_when_has_real_content() {
2696 use crate::rule::Rule;
2697 use std::path::PathBuf;
2698 let rule = MD041FirstLineHeading {
2699 level: 1,
2700 front_matter_title: false,
2701 front_matter_title_pattern: None,
2702 allow_preamble: false,
2703 fix_enabled: true,
2704 };
2705
2706 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2708 let ctx = LintContext::new(
2709 content,
2710 crate::config::MarkdownFlavor::MkDocs,
2711 Some(PathBuf::from("guide.md")),
2712 );
2713 let fixed = rule.fix(&ctx).unwrap();
2714 assert_eq!(
2715 fixed, content,
2716 "Should not insert derived heading when real content is present"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_derive_title_converts_kebab_case() {
2722 use std::path::PathBuf;
2723 let ctx = LintContext::new(
2724 "",
2725 crate::config::MarkdownFlavor::Standard,
2726 Some(PathBuf::from("my-setup-guide.md")),
2727 );
2728 let title = MD041FirstLineHeading::derive_title(&ctx);
2729 assert_eq!(title, Some("My Setup Guide".to_string()));
2730 }
2731
2732 #[test]
2733 fn test_derive_title_converts_underscores() {
2734 use std::path::PathBuf;
2735 let ctx = LintContext::new(
2736 "",
2737 crate::config::MarkdownFlavor::Standard,
2738 Some(PathBuf::from("api_reference.md")),
2739 );
2740 let title = MD041FirstLineHeading::derive_title(&ctx);
2741 assert_eq!(title, Some("Api Reference".to_string()));
2742 }
2743
2744 #[test]
2745 fn test_derive_title_none_without_source_file() {
2746 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2747 let title = MD041FirstLineHeading::derive_title(&ctx);
2748 assert_eq!(title, None);
2749 }
2750
2751 #[test]
2752 fn test_derive_title_index_file_uses_parent_dir() {
2753 use std::path::PathBuf;
2754 let ctx = LintContext::new(
2755 "",
2756 crate::config::MarkdownFlavor::Standard,
2757 Some(PathBuf::from("docs/getting-started/index.md")),
2758 );
2759 let title = MD041FirstLineHeading::derive_title(&ctx);
2760 assert_eq!(title, Some("Getting Started".to_string()));
2761 }
2762
2763 #[test]
2764 fn test_derive_title_readme_file_uses_parent_dir() {
2765 use std::path::PathBuf;
2766 let ctx = LintContext::new(
2767 "",
2768 crate::config::MarkdownFlavor::Standard,
2769 Some(PathBuf::from("my-project/README.md")),
2770 );
2771 let title = MD041FirstLineHeading::derive_title(&ctx);
2772 assert_eq!(title, Some("My Project".to_string()));
2773 }
2774
2775 #[test]
2776 fn test_derive_title_index_without_parent_returns_none() {
2777 use std::path::PathBuf;
2778 let ctx = LintContext::new(
2780 "",
2781 crate::config::MarkdownFlavor::Standard,
2782 Some(PathBuf::from("index.md")),
2783 );
2784 let title = MD041FirstLineHeading::derive_title(&ctx);
2785 assert_eq!(title, None);
2786 }
2787
2788 #[test]
2789 fn test_derive_title_readme_without_parent_returns_none() {
2790 use std::path::PathBuf;
2791 let ctx = LintContext::new(
2792 "",
2793 crate::config::MarkdownFlavor::Standard,
2794 Some(PathBuf::from("README.md")),
2795 );
2796 let title = MD041FirstLineHeading::derive_title(&ctx);
2797 assert_eq!(title, None);
2798 }
2799
2800 #[test]
2801 fn test_derive_title_readme_case_insensitive() {
2802 use std::path::PathBuf;
2803 let ctx = LintContext::new(
2805 "",
2806 crate::config::MarkdownFlavor::Standard,
2807 Some(PathBuf::from("docs/api/readme.md")),
2808 );
2809 let title = MD041FirstLineHeading::derive_title(&ctx);
2810 assert_eq!(title, Some("Api".to_string()));
2811 }
2812
2813 #[test]
2814 fn test_is_title_candidate_basic() {
2815 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2816 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2817 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2818 }
2819
2820 #[test]
2821 fn test_is_title_candidate_rejects_sentence_punctuation() {
2822 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2823 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2824 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2825 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2826 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2827 }
2828
2829 #[test]
2830 fn test_is_title_candidate_rejects_when_no_blank_after() {
2831 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2832 }
2833
2834 #[test]
2835 fn test_is_title_candidate_rejects_long_lines() {
2836 let long = "A".repeat(81);
2837 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2838 let ok = "A".repeat(80);
2840 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2841 }
2842
2843 #[test]
2844 fn test_is_title_candidate_judges_text_shape_only() {
2845 assert!(MD041FirstLineHeading::is_title_candidate("2026 Roadmap", true));
2848 assert!(MD041FirstLineHeading::is_title_candidate("1.0 Release Notes", true));
2849 assert!(MD041FirstLineHeading::is_title_candidate("C++ Notes", true));
2850 }
2851
2852 #[test]
2853 fn test_is_title_candidate_length_counts_characters_not_bytes() {
2854 let cjk = "日本語のタイトル".repeat(7);
2856 assert_eq!(cjk.chars().count(), 56);
2857 assert!(MD041FirstLineHeading::is_title_candidate(&cjk, true));
2858 let long = "日".repeat(81);
2860 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2861 }
2862
2863 #[test]
2864 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2865 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2868 let content = "My Document Title\n\nMore content follows.";
2870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871 let warnings = rule.check(&ctx).unwrap();
2872 assert_eq!(warnings.len(), 1);
2873 let fix = warnings[0]
2874 .fix
2875 .as_ref()
2876 .expect("Fix should be present for promotable text");
2877 assert!(
2878 !fix.replacement.is_empty(),
2879 "Fix replacement must not be empty — applying it directly must produce valid output"
2880 );
2881 assert!(
2882 fix.replacement.starts_with("# "),
2883 "Fix replacement should be a level-1 heading, got: {:?}",
2884 fix.replacement
2885 );
2886 assert_eq!(fix.replacement, "# My Document Title");
2887 }
2888
2889 #[test]
2890 fn test_fix_replacement_not_empty_for_releveling() {
2891 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2894 let content = "## Wrong Level\n\nContent.";
2895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2896 let warnings = rule.check(&ctx).unwrap();
2897 assert_eq!(warnings.len(), 1);
2898 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2899 assert!(
2900 !fix.replacement.is_empty(),
2901 "Fix replacement must not be empty for releveling"
2902 );
2903 assert_eq!(fix.replacement, "# Wrong Level");
2904 }
2905
2906 #[test]
2907 fn test_fix_replacement_applied_produces_valid_output() {
2908 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2910 let content = "My Document\n\nMore content.";
2912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2913
2914 let warnings = rule.check(&ctx).unwrap();
2915 assert_eq!(warnings.len(), 1);
2916 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2917
2918 let mut patched = content.to_string();
2920 patched.replace_range(fix.range.clone(), &fix.replacement);
2921
2922 let fixed = rule.fix(&ctx).unwrap();
2924
2925 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2926 }
2927
2928 #[test]
2929 fn test_mdx_disable_on_line_1_no_heading() {
2930 let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2935
2936 let rule = MD041FirstLineHeading::default();
2938 let warnings = rule.check(&ctx).unwrap();
2939 assert_eq!(
2944 warnings.len(),
2945 1,
2946 "The rule must emit the warning before engine filtering"
2947 );
2948 assert_eq!(warnings[0].line, 2, "Warning must point past the MDX disable comment");
2949 let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule)];
2950 let filtered = crate::lint(content, &rules, false, crate::config::MarkdownFlavor::MDX, None, None).unwrap();
2951 assert!(
2952 filtered.is_empty(),
2953 "The engine must suppress the disabled rule: {filtered:?}"
2954 );
2955 }
2956
2957 #[test]
2958 fn test_mdx_disable_fix_returns_unchanged() {
2959 let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2962 let rule = MD041FirstLineHeading {
2963 fix_enabled: true,
2964 ..MD041FirstLineHeading::default()
2965 };
2966 let result = rule.fix(&ctx).unwrap();
2967 assert_eq!(
2968 result, content,
2969 "fix() should not modify content when MD041 is disabled via MDX comment"
2970 );
2971 }
2972
2973 #[test]
2974 fn test_mdx_comment_without_disable_heading_on_next_line() {
2975 let rule = MD041FirstLineHeading::default();
2976
2977 let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2980 let result = rule.check(&ctx).unwrap();
2981 assert!(
2982 result.is_empty(),
2983 "MDX comment is preamble; heading on next line should satisfy MD041"
2984 );
2985 }
2986
2987 #[test]
2988 fn test_mdx_comment_without_heading_triggers_warning() {
2989 let rule = MD041FirstLineHeading::default();
2990
2991 let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2994 let result = rule.check(&ctx).unwrap();
2995 assert_eq!(
2996 result.len(),
2997 1,
2998 "MDX comment followed by non-heading should trigger MD041"
2999 );
3000 assert_eq!(
3001 result[0].line, 2,
3002 "Warning should be on line 2 (the first content line after MDX comment)"
3003 );
3004 }
3005
3006 #[test]
3007 fn test_multiline_mdx_comment_followed_by_heading() {
3008 let rule = MD041FirstLineHeading::default();
3009
3010 let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
3012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
3013 let result = rule.check(&ctx).unwrap();
3014 assert!(
3015 result.is_empty(),
3016 "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
3017 );
3018 }
3019
3020 #[test]
3021 fn test_html_comment_still_works_as_preamble_regression() {
3022 let rule = MD041FirstLineHeading::default();
3023
3024 let content = "<!-- Some comment -->\n# My Document\n\nContent.";
3026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3027 let result = rule.check(&ctx).unwrap();
3028 assert!(
3029 result.is_empty(),
3030 "HTML comment should still be treated as preamble (regression test)"
3031 );
3032 }
3033
3034 #[test]
3035 fn test_mdg_requires_first_h1() {
3036 let rule = MD041FirstLineHeading::default();
3039 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
3040
3041 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3042 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3043
3044 assert!(!rule.check(&mdg_ctx).unwrap().is_empty());
3045 assert_eq!(
3046 rule.check(&mdg_ctx).unwrap().len(),
3047 rule.check(&standard_ctx).unwrap().len(),
3048 "MDG must not differ from Standard"
3049 );
3050 }
3051
3052 #[test]
3053 fn test_mdg_fix_relevels_without_relocating_content() {
3054 let rule = MD041FirstLineHeading {
3057 fix_enabled: true,
3058 ..MD041FirstLineHeading::default()
3059 };
3060 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
3061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3062
3063 let fixed = rule.fix(&ctx).unwrap();
3064 assert_eq!(fixed, "# Feature: Checkout\n\n##### Scenario: Purchase\n");
3065
3066 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3067 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3068 }
3069
3070 #[test]
3071 fn test_setext_heading_opened_by_badge_lines_is_judged_at_its_last_line() {
3072 let rule = MD041FirstLineHeading {
3076 fix_enabled: true,
3077 ..MD041FirstLineHeading::default()
3078 };
3079 let content = "\n\nTitle\n---\n";
3080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3081
3082 let result = rule.check(&ctx).unwrap();
3083 assert_eq!(result.len(), 1);
3084 assert_eq!(
3085 (
3086 result[0].line,
3087 result[0].column,
3088 result[0].end_line,
3089 result[0].end_column
3090 ),
3091 (3, 1, 3, 6)
3092 );
3093 assert!(result[0].message.contains("level 1 heading"));
3094
3095 assert_eq!(rule.fix(&ctx).unwrap(), "#   Title\n");
3098 }
3099 #[test]
3100 fn test_literal_headings_do_not_select_first_top_level_heading() {
3101 let rule = MD041FirstLineHeading {
3102 allow_preamble: true,
3103 fix_enabled: true,
3104 ..Default::default()
3105 };
3106 for literal in [
3107 "# Fake",
3108 "## Fake",
3109 "\nFake\n===\n",
3110 "\nFake\n---\n",
3111 "<h1>Fake</h1>",
3112 "<h2>Fake</h2>",
3113 ] {
3114 for (flavor, code) in [
3115 (
3116 crate::config::MarkdownFlavor::Standard,
3117 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
3118 ),
3119 (
3120 crate::config::MarkdownFlavor::Hugo,
3121 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
3122 ),
3123 ] {
3124 for actual in ["# Visible", "## Visible"] {
3125 for ending in ["\n", "\r\n"] {
3126 let source = format!("{code}\n\n{actual}\n").replace('\n', ending);
3127 let expected = format!("{code}\n\n# Visible\n").replace('\n', ending);
3128 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
3129 let warnings = rule.check(&ctx).unwrap();
3130 assert_eq!(warnings.len(), usize::from(actual != "# Visible"), "{source}");
3131 if let Some(warning) = warnings.first() {
3132 assert_eq!(warning.line, source.lines().count());
3133 assert!(warning.fix.is_some());
3134 }
3135 assert_eq!(
3136 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
3137 expected,
3138 "{source}"
3139 );
3140 let direct_expected = if actual == "# Visible" {
3142 source.clone()
3143 } else {
3144 expected.replace("\r\n", "\n")
3145 };
3146 assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
3147 }
3148 }
3149 }
3150 }
3151 }
3152
3153 #[test]
3154 fn test_literal_only_document_has_no_first_top_level_heading() {
3155 let rule = MD041FirstLineHeading {
3156 allow_preamble: true,
3157 fix_enabled: true,
3158 ..Default::default()
3159 };
3160 for literal in ["## Fake", "\nFake\n---\n", "<h2>Fake</h2>"] {
3161 let source = format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}\n");
3162 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
3163 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
3164 assert_eq!(rule.fix(&ctx).unwrap(), source);
3165 }
3166 let source = "{% set unused=\"Fake\n---\nlast\" %}{{ unused|length }}\n\n# Visible\n";
3167 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
3168 assert!(rule.check(&ctx).unwrap().is_empty());
3169 assert_eq!(rule.fix(&ctx).unwrap(), source);
3170 }
3171
3172 #[test]
3173 fn test_literal_first_heading_guard_preserves_other_modes() {
3174 let rule = MD041FirstLineHeading {
3175 fix_enabled: true,
3176 ..Default::default()
3177 };
3178 let source = "{% set unused=\"first\n# Fake\nlast\" %}{{ unused|length }}\n\n# Visible\n";
3179 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
3180 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3181 assert_eq!(rule.fix(&ctx).unwrap(), source);
3182 let preamble = rule.with_allow_preamble(true);
3183 for (flavor, source) in [
3184 (crate::config::MarkdownFlavor::Standard, "Intro.\n\n## {{ title }}\n"),
3185 (crate::config::MarkdownFlavor::MDX, "<div>\n\n## Visible\n\n</div>\n"),
3186 ] {
3187 let ctx = crate::lint_context::LintContext::new(source, flavor, None);
3188 assert_eq!(preamble.check(&ctx).unwrap().len(), 1);
3189 assert_eq!(preamble.fix(&ctx).unwrap(), source.replacen("## ", "# ", 1));
3190 }
3191 }
3192}