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