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