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