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 fix_enabled: bool,
24}
25
26impl Default for MD041FirstLineHeading {
27 fn default() -> Self {
28 Self {
29 level: 1,
30 front_matter_title: true,
31 front_matter_title_pattern: None,
32 fix_enabled: false,
33 }
34 }
35}
36
37enum FixPlan {
39 MoveOrRelevel {
41 front_matter_end_idx: usize,
42 heading_idx: usize,
43 is_setext: bool,
44 current_level: usize,
45 needs_level_fix: bool,
46 },
47 PromotePlainText {
49 front_matter_end_idx: usize,
50 title_line_idx: usize,
51 title_text: String,
52 },
53 InsertDerived {
56 front_matter_end_idx: usize,
57 derived_title: String,
58 },
59}
60
61impl MD041FirstLineHeading {
62 pub fn new(level: usize, front_matter_title: bool) -> Self {
63 Self {
64 level,
65 front_matter_title,
66 front_matter_title_pattern: None,
67 fix_enabled: false,
68 }
69 }
70
71 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
72 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
73 Ok(regex) => Some(regex),
74 Err(e) => {
75 log::warn!("Invalid front_matter_title_pattern regex: {e}");
76 None
77 }
78 });
79
80 Self {
81 level,
82 front_matter_title,
83 front_matter_title_pattern,
84 fix_enabled,
85 }
86 }
87
88 fn has_front_matter_title(&self, content: &str) -> bool {
89 if !self.front_matter_title {
90 return false;
91 }
92
93 if let Some(ref pattern) = self.front_matter_title_pattern {
95 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
96 for line in front_matter_lines {
97 if pattern.is_match(line) {
98 return true;
99 }
100 }
101 return false;
102 }
103
104 FrontMatterUtils::has_front_matter_field(content, "title:")
106 }
107
108 fn is_non_content_line(line: &str) -> bool {
110 let trimmed = line.trim();
111
112 if trimmed.starts_with('[') && trimmed.contains("]: ") {
114 return true;
115 }
116
117 if trimmed.starts_with('*') && trimmed.contains("]: ") {
119 return true;
120 }
121
122 if Self::is_badge_image_line(trimmed) {
125 return true;
126 }
127
128 false
129 }
130
131 fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
137 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
138
139 let filtered = ctx
140 .filtered_lines()
141 .skip_front_matter()
142 .skip_esm_blocks()
143 .skip_html_comments()
144 .skip_mdx_comments();
145
146 for filtered_line in filtered {
147 let idx = filtered_line.line_num - 1;
148 let line_info = &ctx.lines[idx];
149
150 if line_info.is_blank || line_info.is_kramdown_block_ial {
151 continue;
152 }
153
154 let line_content = filtered_line.content;
155 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
156 continue;
157 }
158 if Self::is_non_content_line(line_content) {
159 continue;
160 }
161 return Some(idx);
162 }
163 None
164 }
165
166 fn is_badge_image_line(line: &str) -> bool {
172 if line.is_empty() {
173 return false;
174 }
175
176 if !line.starts_with('!') && !line.starts_with('[') {
178 return false;
179 }
180
181 let mut remaining = line;
183 while !remaining.is_empty() {
184 remaining = remaining.trim_start();
185 if remaining.is_empty() {
186 break;
187 }
188
189 if remaining.starts_with("[![") {
191 if let Some(end) = Self::find_linked_image_end(remaining) {
192 remaining = &remaining[end..];
193 continue;
194 }
195 return false;
196 }
197
198 if remaining.starts_with("![") {
200 if let Some(end) = Self::find_image_end(remaining) {
201 remaining = &remaining[end..];
202 continue;
203 }
204 return false;
205 }
206
207 return false;
209 }
210
211 true
212 }
213
214 fn find_image_end(s: &str) -> Option<usize> {
216 if !s.starts_with("![") {
217 return None;
218 }
219 let alt_end = s[2..].find("](")?;
221 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
224 Some(paren_start + paren_end + 1)
225 }
226
227 fn find_linked_image_end(s: &str) -> Option<usize> {
229 if !s.starts_with("[![") {
230 return None;
231 }
232 let inner_end = Self::find_image_end(&s[1..])?;
234 let after_inner = 1 + inner_end;
235 if !s[after_inner..].starts_with("](") {
237 return None;
238 }
239 let link_start = after_inner + 2;
240 let link_end = s[link_start..].find(')')?;
241 Some(link_start + link_end + 1)
242 }
243
244 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
246 let trimmed = line.trim_start();
247
248 if trimmed.starts_with('#') {
250 let hashes = "#".repeat(target_level);
251 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
253 let after_hashes = &trimmed[content_start..];
254 let content = after_hashes.trim_start();
255
256 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
258 format!("{leading_ws}{hashes} {content}")
259 } else {
260 let hashes = "#".repeat(target_level);
263 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
264 format!("{leading_ws}{hashes} {trimmed}")
265 }
266 }
267
268 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
276 if text.is_empty() {
277 return false;
278 }
279
280 if !next_is_blank_or_eof {
281 return false;
282 }
283
284 if text.len() > 80 {
285 return false;
286 }
287
288 let last_char = text.chars().next_back().unwrap_or(' ');
289 if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
290 return false;
291 }
292
293 if text.starts_with('#')
295 || text.starts_with("- ")
296 || text.starts_with("* ")
297 || text.starts_with("+ ")
298 || text.starts_with("> ")
299 {
300 return false;
301 }
302
303 true
304 }
305
306 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
310 let path = ctx.source_file.as_ref()?;
311 let stem = path.file_stem().and_then(|s| s.to_str())?;
312
313 let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
316 path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
317 } else {
318 stem
319 };
320
321 let title: String = effective_stem
322 .split(['-', '_'])
323 .filter(|w| !w.is_empty())
324 .map(|word| {
325 let mut chars = word.chars();
326 match chars.next() {
327 None => String::new(),
328 Some(first) => {
329 let upper: String = first.to_uppercase().collect();
330 upper + chars.as_str()
331 }
332 }
333 })
334 .collect::<Vec<_>>()
335 .join(" ");
336
337 if title.is_empty() { None } else { Some(title) }
338 }
339
340 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
342 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
344 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
345 && let Some(h_level) = captures.get(1)
346 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
347 {
348 return true;
349 }
350
351 let html_tags = ctx.html_tags();
353 let target_tag = format!("h{level}");
354
355 let opening_index = html_tags.iter().position(|tag| {
357 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
359 && !tag.is_closing
360 });
361
362 let Some(open_idx) = opening_index else {
363 return false;
364 };
365
366 let mut depth = 1usize;
369 for tag in html_tags.iter().skip(open_idx + 1) {
370 if tag.line <= first_line_idx + 1 {
372 continue;
373 }
374
375 if tag.tag_name == target_tag {
376 if tag.is_closing {
377 depth -= 1;
378 if depth == 0 {
379 return true;
380 }
381 } else if !tag.is_self_closing {
382 depth += 1;
383 }
384 }
385 }
386
387 false
388 }
389
390 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
392 if ctx.lines.is_empty() {
393 return None;
394 }
395
396 let mut front_matter_end_idx = 0;
398 for line_info in &ctx.lines {
399 if line_info.in_front_matter {
400 front_matter_end_idx += 1;
401 } else {
402 break;
403 }
404 }
405
406 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
407
408 let mut found_heading: Option<(usize, bool, usize)> = None;
410 let mut first_title_candidate: Option<(usize, String)> = None;
412 let mut found_non_title_content = false;
414 let mut saw_non_directive_content = false;
416
417 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
418 let line_content = line_info.content(ctx.content);
419 let trimmed = line_content.trim();
420
421 let is_preamble = trimmed.is_empty()
423 || line_info.in_html_comment
424 || line_info.in_mdx_comment
425 || line_info.in_html_block
426 || Self::is_non_content_line(line_content)
427 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
428 || line_info.in_kramdown_extension_block
429 || line_info.is_kramdown_block_ial;
430
431 if is_preamble {
432 continue;
433 }
434
435 let is_directive_block = line_info.in_admonition
438 || line_info.in_content_tab
439 || line_info.in_pandoc_div
440 || line_info.is_div_marker
441 || line_info.in_pymdown_block;
442
443 if !is_directive_block {
444 saw_non_directive_content = true;
445 }
446
447 if let Some(heading) = &line_info.heading {
449 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
450 found_heading = Some((idx, is_setext, heading.level as usize));
451 break 'scan;
452 }
453
454 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
456 let next_is_blank_or_eof = ctx
457 .lines
458 .get(idx + 1)
459 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
460
461 if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
462 first_title_candidate = Some((idx, trimmed.to_string()));
463 } else {
464 found_non_title_content = true;
465 }
466 }
467 }
468
469 if let Some((h_idx, is_setext, current_level)) = found_heading {
470 if found_non_title_content || first_title_candidate.is_some() {
474 return None;
475 }
476
477 let needs_level_fix = current_level != self.level;
478 let needs_move = h_idx > front_matter_end_idx;
479
480 if needs_level_fix || needs_move {
481 return Some(FixPlan::MoveOrRelevel {
482 front_matter_end_idx,
483 heading_idx: h_idx,
484 is_setext,
485 current_level,
486 needs_level_fix,
487 });
488 }
489 return None; }
491
492 if let Some((title_idx, title_text)) = first_title_candidate {
495 return Some(FixPlan::PromotePlainText {
496 front_matter_end_idx,
497 title_line_idx: title_idx,
498 title_text,
499 });
500 }
501
502 if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
505 return Some(FixPlan::InsertDerived {
506 front_matter_end_idx,
507 derived_title,
508 });
509 }
510
511 None
512 }
513
514 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
516 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
517 }
518}
519
520impl Rule for MD041FirstLineHeading {
521 fn name(&self) -> &'static str {
522 "MD041"
523 }
524
525 fn description(&self) -> &'static str {
526 "First line in file should be a top level heading"
527 }
528
529 fn fix_capability(&self) -> FixCapability {
530 FixCapability::Unfixable
531 }
532
533 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
534 let mut warnings = Vec::new();
535
536 if self.should_skip(ctx) {
538 return Ok(warnings);
539 }
540
541 let Some(first_line_idx) = Self::first_content_line_idx(ctx) else {
542 return Ok(warnings);
543 };
544
545 let first_line_info = &ctx.lines[first_line_idx];
547 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
548 heading.level as usize == self.level
549 } else {
550 Self::is_html_heading(ctx, first_line_idx, self.level)
552 };
553
554 if !is_correct_heading {
555 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
558 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
559
560 let fix = if self.can_fix(ctx) {
566 self.analyze_for_fix(ctx).and_then(|plan| {
567 let range_start = first_line_info.byte_offset;
568 let range_end = range_start + first_line_info.byte_len;
569 match &plan {
570 FixPlan::MoveOrRelevel {
571 heading_idx,
572 current_level,
573 needs_level_fix,
574 is_setext,
575 ..
576 } if *heading_idx == first_line_idx => {
577 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
579 let replacement = if *needs_level_fix || *is_setext {
580 self.fix_heading_level(heading_line, *current_level, self.level)
581 } else {
582 heading_line.to_string()
583 };
584 Some(Fix::new(range_start..range_end, replacement))
585 }
586 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
587 let replacement = format!(
588 "{} {}",
589 "#".repeat(self.level),
590 ctx.lines[*title_line_idx].content(ctx.content).trim()
591 );
592 Some(Fix::new(range_start..range_end, replacement))
593 }
594 _ => {
595 self.fix(ctx)
599 .ok()
600 .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
601 }
602 }
603 })
604 } else {
605 None
606 };
607
608 warnings.push(LintWarning {
609 rule_name: Some(self.name().to_string()),
610 line: start_line,
611 column: start_col,
612 end_line,
613 end_column: end_col,
614 message: format!("First line in file should be a level {} heading", self.level),
615 severity: Severity::Warning,
616 fix,
617 });
618 }
619 Ok(warnings)
620 }
621
622 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
623 if !self.fix_enabled {
624 return Ok(ctx.content.to_string());
625 }
626
627 if self.should_skip(ctx) {
628 return Ok(ctx.content.to_string());
629 }
630
631 let first_content_line = Self::first_content_line_idx(ctx).map_or(1, |i| i + 1);
634 if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) {
635 return Ok(ctx.content.to_string());
636 }
637
638 let Some(plan) = self.analyze_for_fix(ctx) else {
639 return Ok(ctx.content.to_string());
640 };
641
642 let lines = ctx.raw_lines();
643
644 let mut result = String::new();
645 let preserve_trailing_newline = ctx.content.ends_with('\n');
646
647 match plan {
648 FixPlan::MoveOrRelevel {
649 front_matter_end_idx,
650 heading_idx,
651 is_setext,
652 current_level,
653 needs_level_fix,
654 } => {
655 let heading_line = ctx.lines[heading_idx].content(ctx.content);
656 let fixed_heading = if needs_level_fix || is_setext {
657 self.fix_heading_level(heading_line, current_level, self.level)
658 } else {
659 heading_line.to_string()
660 };
661
662 for line in lines.iter().take(front_matter_end_idx) {
663 result.push_str(line);
664 result.push('\n');
665 }
666 result.push_str(&fixed_heading);
667 result.push('\n');
668 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
669 if idx == heading_idx {
670 continue;
671 }
672 if is_setext && idx == heading_idx + 1 {
673 continue;
674 }
675 result.push_str(line);
676 result.push('\n');
677 }
678 }
679
680 FixPlan::PromotePlainText {
681 front_matter_end_idx,
682 title_line_idx,
683 title_text,
684 } => {
685 let hashes = "#".repeat(self.level);
686 let new_heading = format!("{hashes} {title_text}");
687
688 for line in lines.iter().take(front_matter_end_idx) {
689 result.push_str(line);
690 result.push('\n');
691 }
692 result.push_str(&new_heading);
693 result.push('\n');
694 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
695 if idx == title_line_idx {
696 continue;
697 }
698 result.push_str(line);
699 result.push('\n');
700 }
701 }
702
703 FixPlan::InsertDerived {
704 front_matter_end_idx,
705 derived_title,
706 } => {
707 let hashes = "#".repeat(self.level);
708 let new_heading = format!("{hashes} {derived_title}");
709
710 for line in lines.iter().take(front_matter_end_idx) {
711 result.push_str(line);
712 result.push('\n');
713 }
714 result.push_str(&new_heading);
715 result.push('\n');
716 result.push('\n');
717 for line in lines.iter().skip(front_matter_end_idx) {
718 result.push_str(line);
719 result.push('\n');
720 }
721 }
722 }
723
724 if !preserve_trailing_newline && result.ends_with('\n') {
725 result.pop();
726 }
727
728 Ok(result)
729 }
730
731 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
733 let only_directives = !ctx.content.is_empty()
738 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
739 let t = l.trim();
740 (t.starts_with("{{#") && t.ends_with("}}"))
742 || (t.starts_with("<!--") && t.ends_with("-->"))
744 });
745
746 ctx.content.is_empty()
747 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
748 || only_directives
749 }
750
751 fn as_any(&self) -> &dyn std::any::Any {
752 self
753 }
754
755 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
756 where
757 Self: Sized,
758 {
759 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
761
762 let use_front_matter = !md041_config.front_matter_title.is_empty();
763
764 Box::new(MD041FirstLineHeading::with_pattern(
765 md041_config.level.as_usize(),
766 use_front_matter,
767 md041_config.front_matter_title_pattern,
768 md041_config.fix,
769 ))
770 }
771
772 fn default_config_section(&self) -> Option<(String, toml::Value)> {
773 Some((
774 "MD041".to_string(),
775 toml::toml! {
776 level = 1
777 front-matter-title = "title"
778 front-matter-title-pattern = ""
779 fix = false
780 }
781 .into(),
782 ))
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::lint_context::LintContext;
790
791 #[test]
792 fn test_first_line_is_heading_correct_level() {
793 let rule = MD041FirstLineHeading::default();
794
795 let content = "# My Document\n\nSome content here.";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(
800 result.is_empty(),
801 "Expected no warnings when first line is a level 1 heading"
802 );
803 }
804
805 #[test]
806 fn test_first_line_is_heading_wrong_level() {
807 let rule = MD041FirstLineHeading::default();
808
809 let content = "## My Document\n\nSome content here.";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert_eq!(result.len(), 1);
814 assert_eq!(result[0].line, 1);
815 assert!(result[0].message.contains("level 1 heading"));
816 }
817
818 #[test]
819 fn test_first_line_not_heading() {
820 let rule = MD041FirstLineHeading::default();
821
822 let content = "This is not a heading\n\n# This is a heading";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert_eq!(result.len(), 1);
827 assert_eq!(result[0].line, 1);
828 assert!(result[0].message.contains("level 1 heading"));
829 }
830
831 #[test]
832 fn test_empty_lines_before_heading() {
833 let rule = MD041FirstLineHeading::default();
834
835 let content = "\n\n# My Document\n\nSome content.";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839 assert!(
840 result.is_empty(),
841 "Expected no warnings when empty lines precede a valid heading"
842 );
843
844 let content = "\n\nNot a heading\n\nSome content.";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert_eq!(result.len(), 1);
849 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
851 }
852
853 #[test]
854 fn test_front_matter_with_title() {
855 let rule = MD041FirstLineHeading::new(1, true);
856
857 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861 assert!(
862 result.is_empty(),
863 "Expected no warnings when front matter has title field"
864 );
865 }
866
867 #[test]
868 fn test_front_matter_without_title() {
869 let rule = MD041FirstLineHeading::new(1, true);
870
871 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert_eq!(result.len(), 1);
876 assert_eq!(result[0].line, 6); }
878
879 #[test]
880 fn test_front_matter_disabled() {
881 let rule = MD041FirstLineHeading::new(1, false);
882
883 let content = "---\ntitle: My Document\n---\n\nSome content here.";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887 assert_eq!(result.len(), 1);
888 assert_eq!(result[0].line, 5); }
890
891 #[test]
892 fn test_html_comments_before_heading() {
893 let rule = MD041FirstLineHeading::default();
894
895 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert!(
900 result.is_empty(),
901 "HTML comments should be skipped when checking for first heading"
902 );
903 }
904
905 #[test]
906 fn test_multiline_html_comment_before_heading() {
907 let rule = MD041FirstLineHeading::default();
908
909 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913 assert!(
914 result.is_empty(),
915 "Multi-line HTML comments should be skipped when checking for first heading"
916 );
917 }
918
919 #[test]
920 fn test_html_comment_with_blank_lines_before_heading() {
921 let rule = MD041FirstLineHeading::default();
922
923 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert!(
928 result.is_empty(),
929 "HTML comments with blank lines should be skipped when checking for first heading"
930 );
931 }
932
933 #[test]
934 fn test_html_comment_before_html_heading() {
935 let rule = MD041FirstLineHeading::default();
936
937 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert!(
942 result.is_empty(),
943 "HTML comments should be skipped before HTML headings"
944 );
945 }
946
947 #[test]
948 fn test_document_with_only_html_comments() {
949 let rule = MD041FirstLineHeading::default();
950
951 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
956 result.is_empty(),
957 "Documents with only HTML comments should not trigger MD041"
958 );
959 }
960
961 #[test]
962 fn test_html_comment_followed_by_non_heading() {
963 let rule = MD041FirstLineHeading::default();
964
965 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 let result = rule.check(&ctx).unwrap();
969 assert_eq!(
970 result.len(),
971 1,
972 "HTML comment followed by non-heading should still trigger MD041"
973 );
974 assert_eq!(
975 result[0].line, 2,
976 "Warning should be on the first non-comment, non-heading line"
977 );
978 }
979
980 #[test]
981 fn test_multiple_html_comments_before_heading() {
982 let rule = MD041FirstLineHeading::default();
983
984 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(
989 result.is_empty(),
990 "Multiple HTML comments should all be skipped before heading"
991 );
992 }
993
994 #[test]
995 fn test_html_comment_with_wrong_level_heading() {
996 let rule = MD041FirstLineHeading::default();
997
998 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002 assert_eq!(
1003 result.len(),
1004 1,
1005 "HTML comment followed by wrong-level heading should still trigger MD041"
1006 );
1007 assert!(
1008 result[0].message.contains("level 1 heading"),
1009 "Should require level 1 heading"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_html_comment_mixed_with_reference_definitions() {
1015 let rule = MD041FirstLineHeading::default();
1016
1017 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert!(
1022 result.is_empty(),
1023 "HTML comments and reference definitions should both be skipped before heading"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_html_comment_after_front_matter() {
1029 let rule = MD041FirstLineHeading::default();
1030
1031 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert!(
1036 result.is_empty(),
1037 "HTML comments after front matter should be skipped before heading"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_html_comment_not_at_start_should_not_affect_rule() {
1043 let rule = MD041FirstLineHeading::default();
1044
1045 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(
1050 result.is_empty(),
1051 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_multiline_html_comment_followed_by_non_heading() {
1057 let rule = MD041FirstLineHeading::default();
1058
1059 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063 assert_eq!(
1064 result.len(),
1065 1,
1066 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1067 );
1068 assert_eq!(
1069 result[0].line, 5,
1070 "Warning should be on the first non-comment, non-heading line"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_different_heading_levels() {
1076 let rule = MD041FirstLineHeading::new(2, false);
1078
1079 let content = "## Second Level Heading\n\nContent.";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1083
1084 let content = "# First Level Heading\n\nContent.";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert_eq!(result.len(), 1);
1089 assert!(result[0].message.contains("level 2 heading"));
1090 }
1091
1092 #[test]
1093 fn test_setext_headings() {
1094 let rule = MD041FirstLineHeading::default();
1095
1096 let content = "My Document\n===========\n\nContent.";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1101
1102 let content = "My Document\n-----------\n\nContent.";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert_eq!(result.len(), 1);
1107 assert!(result[0].message.contains("level 1 heading"));
1108 }
1109
1110 #[test]
1111 fn test_empty_document() {
1112 let rule = MD041FirstLineHeading::default();
1113
1114 let content = "";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(result.is_empty(), "Expected no warnings for empty document");
1119 }
1120
1121 #[test]
1122 fn test_whitespace_only_document() {
1123 let rule = MD041FirstLineHeading::default();
1124
1125 let content = " \n\n \t\n";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1130 }
1131
1132 #[test]
1133 fn test_front_matter_then_whitespace() {
1134 let rule = MD041FirstLineHeading::default();
1135
1136 let content = "---\ntitle: Test\n---\n\n \n\n";
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let result = rule.check(&ctx).unwrap();
1140 assert!(
1141 result.is_empty(),
1142 "Expected no warnings when no content after front matter"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_multiple_front_matter_types() {
1148 let rule = MD041FirstLineHeading::new(1, true);
1149
1150 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154 assert!(
1155 result.is_empty(),
1156 "Expected no warnings for TOML front matter with title"
1157 );
1158
1159 let content = "{\n\"title\": \"My Document\"\n}\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 "Expected no warnings for JSON front matter with title"
1166 );
1167
1168 let content = "---\ntitle: My Document\n---\n\nContent.";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let result = rule.check(&ctx).unwrap();
1172 assert!(
1173 result.is_empty(),
1174 "Expected no warnings for YAML front matter with title"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_toml_front_matter_with_heading() {
1180 let rule = MD041FirstLineHeading::default();
1181
1182 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185 let result = rule.check(&ctx).unwrap();
1186 assert!(
1187 result.is_empty(),
1188 "Expected no warnings when heading follows TOML front matter"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_toml_front_matter_without_title_no_heading() {
1194 let rule = MD041FirstLineHeading::new(1, true);
1195
1196 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert_eq!(result.len(), 1);
1201 assert_eq!(result[0].line, 6);
1202 }
1203
1204 #[test]
1205 fn test_toml_front_matter_level_2_heading() {
1206 let rule = MD041FirstLineHeading::new(2, true);
1208
1209 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212 assert!(
1213 result.is_empty(),
1214 "Issue #427: TOML front matter with title and correct heading level should not warn"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1220 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1222
1223 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert!(
1227 result.is_empty(),
1228 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_json_front_matter_with_heading() {
1234 let rule = MD041FirstLineHeading::default();
1235
1236 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert!(
1241 result.is_empty(),
1242 "Expected no warnings when heading follows JSON front matter"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_malformed_front_matter() {
1248 let rule = MD041FirstLineHeading::new(1, true);
1249
1250 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254 assert!(
1255 result.is_empty(),
1256 "Expected no warnings for malformed front matter with title"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_front_matter_with_heading() {
1262 let rule = MD041FirstLineHeading::default();
1263
1264 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268 assert!(
1269 result.is_empty(),
1270 "Expected no warnings when first line after front matter is correct heading"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_no_fix_suggestion() {
1276 let rule = MD041FirstLineHeading::default();
1277
1278 let content = "Not a heading\n\nContent.";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282 assert_eq!(result.len(), 1);
1283 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1284 }
1285
1286 #[test]
1287 fn test_complex_document_structure() {
1288 let rule = MD041FirstLineHeading::default();
1289
1290 let content =
1292 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert!(
1296 result.is_empty(),
1297 "HTML comments should be skipped, so first heading after comment should be valid"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_heading_with_special_characters() {
1303 let rule = MD041FirstLineHeading::default();
1304
1305 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert!(
1310 result.is_empty(),
1311 "Expected no warnings for heading with inline formatting"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_level_configuration() {
1317 for level in 1..=6 {
1319 let rule = MD041FirstLineHeading::new(level, false);
1320
1321 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1323 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1324 let result = rule.check(&ctx).unwrap();
1325 assert!(
1326 result.is_empty(),
1327 "Expected no warnings for correct level {level} heading"
1328 );
1329
1330 let wrong_level = if level == 1 { 2 } else { 1 };
1332 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1333 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1334 let result = rule.check(&ctx).unwrap();
1335 assert_eq!(result.len(), 1);
1336 assert!(result[0].message.contains(&format!("level {level} heading")));
1337 }
1338 }
1339
1340 #[test]
1341 fn test_issue_152_multiline_html_heading() {
1342 let rule = MD041FirstLineHeading::default();
1343
1344 let content = "<h1>\nSome text\n</h1>";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert!(
1349 result.is_empty(),
1350 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_multiline_html_heading_with_attributes() {
1356 let rule = MD041FirstLineHeading::default();
1357
1358 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(
1363 result.is_empty(),
1364 "Multi-line HTML heading with attributes should be recognized"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_multiline_html_heading_wrong_level() {
1370 let rule = MD041FirstLineHeading::default();
1371
1372 let content = "<h2>\nSome text\n</h2>";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let result = rule.check(&ctx).unwrap();
1376 assert_eq!(result.len(), 1);
1377 assert!(result[0].message.contains("level 1 heading"));
1378 }
1379
1380 #[test]
1381 fn test_multiline_html_heading_with_content_after() {
1382 let rule = MD041FirstLineHeading::default();
1383
1384 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert!(
1389 result.is_empty(),
1390 "Multi-line HTML heading followed by content should be valid"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_multiline_html_heading_incomplete() {
1396 let rule = MD041FirstLineHeading::default();
1397
1398 let content = "<h1>\nSome text\n\nMore content without closing tag";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let result = rule.check(&ctx).unwrap();
1402 assert_eq!(result.len(), 1);
1403 assert!(result[0].message.contains("level 1 heading"));
1404 }
1405
1406 #[test]
1407 fn test_singleline_html_heading_still_works() {
1408 let rule = MD041FirstLineHeading::default();
1409
1410 let content = "<h1>My Document</h1>\n\nContent.";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 let result = rule.check(&ctx).unwrap();
1414 assert!(
1415 result.is_empty(),
1416 "Single-line HTML headings should still be recognized"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_multiline_html_heading_with_nested_tags() {
1422 let rule = MD041FirstLineHeading::default();
1423
1424 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427 let result = rule.check(&ctx).unwrap();
1428 assert!(
1429 result.is_empty(),
1430 "Multi-line HTML heading with nested tags should be recognized"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_multiline_html_heading_various_levels() {
1436 for level in 1..=6 {
1438 let rule = MD041FirstLineHeading::new(level, false);
1439
1440 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1442 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1443 let result = rule.check(&ctx).unwrap();
1444 assert!(
1445 result.is_empty(),
1446 "Multi-line HTML heading at level {level} should be recognized"
1447 );
1448
1449 let wrong_level = if level == 1 { 2 } else { 1 };
1451 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1452 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1453 let result = rule.check(&ctx).unwrap();
1454 assert_eq!(result.len(), 1);
1455 assert!(result[0].message.contains(&format!("level {level} heading")));
1456 }
1457 }
1458
1459 #[test]
1460 fn test_issue_152_nested_heading_spans_many_lines() {
1461 let rule = MD041FirstLineHeading::default();
1462
1463 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>";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465 let result = rule.check(&ctx).unwrap();
1466 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1467 }
1468
1469 #[test]
1470 fn test_issue_152_picture_tag_heading() {
1471 let rule = MD041FirstLineHeading::default();
1472
1473 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>";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let result = rule.check(&ctx).unwrap();
1476 assert!(
1477 result.is_empty(),
1478 "Picture tag inside multi-line HTML heading should be recognized"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_badge_images_before_heading() {
1484 let rule = MD041FirstLineHeading::default();
1485
1486 let content = "\n\n# My Project";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let result = rule.check(&ctx).unwrap();
1490 assert!(result.is_empty(), "Badge image should be skipped");
1491
1492 let content = " \n\n# My Project";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let result = rule.check(&ctx).unwrap();
1496 assert!(result.is_empty(), "Multiple badges should be skipped");
1497
1498 let content = "[](https://example.com)\n\n# My Project";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let result = rule.check(&ctx).unwrap();
1502 assert!(result.is_empty(), "Linked badge should be skipped");
1503 }
1504
1505 #[test]
1506 fn test_multiple_badge_lines_before_heading() {
1507 let rule = MD041FirstLineHeading::default();
1508
1509 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1514 }
1515
1516 #[test]
1517 fn test_badges_without_heading_still_warns() {
1518 let rule = MD041FirstLineHeading::default();
1519
1520 let content = "\n\nThis is not a heading.";
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523 let result = rule.check(&ctx).unwrap();
1524 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1525 }
1526
1527 #[test]
1528 fn test_mixed_content_not_badge_line() {
1529 let rule = MD041FirstLineHeading::default();
1530
1531 let content = " Some text here\n\n# Heading";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let result = rule.check(&ctx).unwrap();
1535 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1536 }
1537
1538 #[test]
1539 fn test_is_badge_image_line_unit() {
1540 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1542 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1543 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1544 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1545
1546 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1548 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1549 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1550 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1551 }
1552
1553 #[test]
1557 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1558 let rule = MD041FirstLineHeading::default();
1559
1560 let content = "[](){ #example }\n# Title";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1563 let result = rule.check(&ctx).unwrap();
1564 assert!(
1565 result.is_empty(),
1566 "MkDocs anchor line should be skipped in MkDocs flavor"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1572 let rule = MD041FirstLineHeading::default();
1573
1574 let content = "[](){ #example }\n# Title";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578 assert_eq!(
1579 result.len(),
1580 1,
1581 "MkDocs anchor line should NOT be skipped in Standard flavor"
1582 );
1583 }
1584
1585 #[test]
1586 fn test_multiple_mkdocs_anchors_before_heading() {
1587 let rule = MD041FirstLineHeading::default();
1588
1589 let content = "[](){ #first }\n[](){ #second }\n# Title";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert!(
1594 result.is_empty(),
1595 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_mkdocs_anchor_with_front_matter() {
1601 let rule = MD041FirstLineHeading::default();
1602
1603 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1606 let result = rule.check(&ctx).unwrap();
1607 assert!(
1608 result.is_empty(),
1609 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_mkdocs_anchor_kramdown_style() {
1615 let rule = MD041FirstLineHeading::default();
1616
1617 let content = "[](){: #anchor }\n# Title";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1620 let result = rule.check(&ctx).unwrap();
1621 assert!(
1622 result.is_empty(),
1623 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_mkdocs_anchor_without_heading_still_warns() {
1629 let rule = MD041FirstLineHeading::default();
1630
1631 let content = "[](){ #anchor }\nThis is not a heading.";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert_eq!(
1636 result.len(),
1637 1,
1638 "MkDocs anchor followed by non-heading should still trigger MD041"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_mkdocs_anchor_with_html_comment() {
1644 let rule = MD041FirstLineHeading::default();
1645
1646 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1649 let result = rule.check(&ctx).unwrap();
1650 assert!(
1651 result.is_empty(),
1652 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1653 );
1654 }
1655
1656 #[test]
1659 fn test_fix_disabled_by_default() {
1660 use crate::rule::Rule;
1661 let rule = MD041FirstLineHeading::default();
1662
1663 let content = "## Wrong Level\n\nContent.";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let fixed = rule.fix(&ctx).unwrap();
1667 assert_eq!(fixed, content, "Fix should not change content when disabled");
1668 }
1669
1670 #[test]
1671 fn test_fix_wrong_heading_level() {
1672 use crate::rule::Rule;
1673 let rule = MD041FirstLineHeading {
1674 level: 1,
1675 front_matter_title: false,
1676 front_matter_title_pattern: None,
1677 fix_enabled: true,
1678 };
1679
1680 let content = "## Wrong Level\n\nContent.\n";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let fixed = rule.fix(&ctx).unwrap();
1684 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1685 }
1686
1687 #[test]
1688 fn test_fix_heading_after_preamble() {
1689 use crate::rule::Rule;
1690 let rule = MD041FirstLineHeading {
1691 level: 1,
1692 front_matter_title: false,
1693 front_matter_title_pattern: None,
1694 fix_enabled: true,
1695 };
1696
1697 let content = "\n\n# Title\n\nContent.\n";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let fixed = rule.fix(&ctx).unwrap();
1701 assert!(
1702 fixed.starts_with("# Title\n"),
1703 "Heading should be moved to first line, got: {fixed}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_fix_heading_after_html_comment() {
1709 use crate::rule::Rule;
1710 let rule = MD041FirstLineHeading {
1711 level: 1,
1712 front_matter_title: false,
1713 front_matter_title_pattern: None,
1714 fix_enabled: true,
1715 };
1716
1717 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let fixed = rule.fix(&ctx).unwrap();
1721 assert!(
1722 fixed.starts_with("# Title\n"),
1723 "Heading should be moved above comment, got: {fixed}"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_fix_heading_level_and_move() {
1729 use crate::rule::Rule;
1730 let rule = MD041FirstLineHeading {
1731 level: 1,
1732 front_matter_title: false,
1733 front_matter_title_pattern: None,
1734 fix_enabled: true,
1735 };
1736
1737 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1740 let fixed = rule.fix(&ctx).unwrap();
1741 assert!(
1742 fixed.starts_with("# Wrong Level\n"),
1743 "Heading should be fixed and moved, got: {fixed}"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_fix_with_front_matter() {
1749 use crate::rule::Rule;
1750 let rule = MD041FirstLineHeading {
1751 level: 1,
1752 front_matter_title: false,
1753 front_matter_title_pattern: None,
1754 fix_enabled: true,
1755 };
1756
1757 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let fixed = rule.fix(&ctx).unwrap();
1761 assert!(
1762 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1763 "Heading should be right after front matter, got: {fixed}"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_fix_with_toml_front_matter() {
1769 use crate::rule::Rule;
1770 let rule = MD041FirstLineHeading {
1771 level: 1,
1772 front_matter_title: false,
1773 front_matter_title_pattern: None,
1774 fix_enabled: true,
1775 };
1776
1777 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let fixed = rule.fix(&ctx).unwrap();
1781 assert!(
1782 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1783 "Heading should be right after TOML front matter, got: {fixed}"
1784 );
1785 }
1786
1787 #[test]
1788 fn test_fix_cannot_fix_no_heading() {
1789 use crate::rule::Rule;
1790 let rule = MD041FirstLineHeading {
1791 level: 1,
1792 front_matter_title: false,
1793 front_matter_title_pattern: None,
1794 fix_enabled: true,
1795 };
1796
1797 let content = "Just some text.\n\nMore text.\n";
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let fixed = rule.fix(&ctx).unwrap();
1801 assert_eq!(fixed, content, "Should not change content when no heading exists");
1802 }
1803
1804 #[test]
1805 fn test_fix_cannot_fix_content_before_heading() {
1806 use crate::rule::Rule;
1807 let rule = MD041FirstLineHeading {
1808 level: 1,
1809 front_matter_title: false,
1810 front_matter_title_pattern: None,
1811 fix_enabled: true,
1812 };
1813
1814 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let fixed = rule.fix(&ctx).unwrap();
1818 assert_eq!(
1819 fixed, content,
1820 "Should not change content when real content exists before heading"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_fix_already_correct() {
1826 use crate::rule::Rule;
1827 let rule = MD041FirstLineHeading {
1828 level: 1,
1829 front_matter_title: false,
1830 front_matter_title_pattern: None,
1831 fix_enabled: true,
1832 };
1833
1834 let content = "# Title\n\nContent.\n";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let fixed = rule.fix(&ctx).unwrap();
1838 assert_eq!(fixed, content, "Should not change already correct content");
1839 }
1840
1841 #[test]
1842 fn test_fix_setext_heading_removes_underline() {
1843 use crate::rule::Rule;
1844 let rule = MD041FirstLineHeading {
1845 level: 1,
1846 front_matter_title: false,
1847 front_matter_title_pattern: None,
1848 fix_enabled: true,
1849 };
1850
1851 let content = "Wrong Level\n-----------\n\nContent.\n";
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let fixed = rule.fix(&ctx).unwrap();
1855 assert_eq!(
1856 fixed, "# Wrong Level\n\nContent.\n",
1857 "Setext heading should be converted to ATX and underline removed"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_fix_setext_h1_heading() {
1863 use crate::rule::Rule;
1864 let rule = MD041FirstLineHeading {
1865 level: 1,
1866 front_matter_title: false,
1867 front_matter_title_pattern: None,
1868 fix_enabled: true,
1869 };
1870
1871 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let fixed = rule.fix(&ctx).unwrap();
1875 assert_eq!(
1876 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1877 "Setext h1 should be moved and converted to ATX"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_html_heading_not_claimed_fixable() {
1883 use crate::rule::Rule;
1884 let rule = MD041FirstLineHeading {
1885 level: 1,
1886 front_matter_title: false,
1887 front_matter_title_pattern: None,
1888 fix_enabled: true,
1889 };
1890
1891 let content = "<h2>Title</h2>\n\nContent.\n";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let warnings = rule.check(&ctx).unwrap();
1895 assert_eq!(warnings.len(), 1);
1896 assert!(
1897 warnings[0].fix.is_none(),
1898 "HTML heading should not be claimed as fixable"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_no_heading_not_claimed_fixable() {
1904 use crate::rule::Rule;
1905 let rule = MD041FirstLineHeading {
1906 level: 1,
1907 front_matter_title: false,
1908 front_matter_title_pattern: None,
1909 fix_enabled: true,
1910 };
1911
1912 let content = "Just some text.\n\nMore text.\n";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let warnings = rule.check(&ctx).unwrap();
1916 assert_eq!(warnings.len(), 1);
1917 assert!(
1918 warnings[0].fix.is_none(),
1919 "Document without heading should not be claimed as fixable"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_content_before_heading_not_claimed_fixable() {
1925 use crate::rule::Rule;
1926 let rule = MD041FirstLineHeading {
1927 level: 1,
1928 front_matter_title: false,
1929 front_matter_title_pattern: None,
1930 fix_enabled: true,
1931 };
1932
1933 let content = "Intro text.\n\n## Heading\n\nMore.\n";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let warnings = rule.check(&ctx).unwrap();
1937 assert_eq!(warnings.len(), 1);
1938 assert!(
1939 warnings[0].fix.is_none(),
1940 "Document with content before heading should not be claimed as fixable"
1941 );
1942 }
1943
1944 #[test]
1947 fn test_fix_html_block_before_heading_is_now_fixable() {
1948 use crate::rule::Rule;
1949 let rule = MD041FirstLineHeading {
1950 level: 1,
1951 front_matter_title: false,
1952 front_matter_title_pattern: None,
1953 fix_enabled: true,
1954 };
1955
1956 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959
1960 let warnings = rule.check(&ctx).unwrap();
1961 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1962 assert!(
1963 warnings[0].fix.is_some(),
1964 "Should be fixable: heading exists after HTML block preamble"
1965 );
1966
1967 let fixed = rule.fix(&ctx).unwrap();
1968 assert!(
1969 fixed.starts_with("# My Document\n"),
1970 "Heading should be moved to the top, got: {fixed}"
1971 );
1972 }
1973
1974 #[test]
1975 fn test_fix_html_block_wrong_level_before_heading() {
1976 use crate::rule::Rule;
1977 let rule = MD041FirstLineHeading {
1978 level: 1,
1979 front_matter_title: false,
1980 front_matter_title_pattern: None,
1981 fix_enabled: true,
1982 };
1983
1984 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
1985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986 let fixed = rule.fix(&ctx).unwrap();
1987 assert!(
1988 fixed.starts_with("# Wrong Level\n"),
1989 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
1990 );
1991 }
1992
1993 #[test]
1996 fn test_fix_promote_plain_text_title() {
1997 use crate::rule::Rule;
1998 let rule = MD041FirstLineHeading {
1999 level: 1,
2000 front_matter_title: false,
2001 front_matter_title_pattern: None,
2002 fix_enabled: true,
2003 };
2004
2005 let content = "My Project\n\nSome content.\n";
2006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2007
2008 let warnings = rule.check(&ctx).unwrap();
2009 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2010 assert!(
2011 warnings[0].fix.is_some(),
2012 "Should be fixable: first line is a title candidate"
2013 );
2014
2015 let fixed = rule.fix(&ctx).unwrap();
2016 assert_eq!(
2017 fixed, "# My Project\n\nSome content.\n",
2018 "Title line should be promoted to heading"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_fix_promote_plain_text_title_with_front_matter() {
2024 use crate::rule::Rule;
2025 let rule = MD041FirstLineHeading {
2026 level: 1,
2027 front_matter_title: false,
2028 front_matter_title_pattern: None,
2029 fix_enabled: true,
2030 };
2031
2032 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert!(
2036 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2037 "Title should be promoted and placed right after front matter, got: {fixed}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_fix_no_promote_ends_with_period() {
2043 use crate::rule::Rule;
2044 let rule = MD041FirstLineHeading {
2045 level: 1,
2046 front_matter_title: false,
2047 front_matter_title_pattern: None,
2048 fix_enabled: true,
2049 };
2050
2051 let content = "This is a sentence.\n\nContent.\n";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let fixed = rule.fix(&ctx).unwrap();
2055 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2056
2057 let warnings = rule.check(&ctx).unwrap();
2058 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2059 }
2060
2061 #[test]
2062 fn test_fix_no_promote_ends_with_colon() {
2063 use crate::rule::Rule;
2064 let rule = MD041FirstLineHeading {
2065 level: 1,
2066 front_matter_title: false,
2067 front_matter_title_pattern: None,
2068 fix_enabled: true,
2069 };
2070
2071 let content = "Note:\n\nContent.\n";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let fixed = rule.fix(&ctx).unwrap();
2074 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2075 }
2076
2077 #[test]
2078 fn test_fix_no_promote_if_too_long() {
2079 use crate::rule::Rule;
2080 let rule = MD041FirstLineHeading {
2081 level: 1,
2082 front_matter_title: false,
2083 front_matter_title_pattern: None,
2084 fix_enabled: true,
2085 };
2086
2087 let long_line = "A".repeat(81);
2089 let content = format!("{long_line}\n\nContent.\n");
2090 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2091 let fixed = rule.fix(&ctx).unwrap();
2092 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2093 }
2094
2095 #[test]
2096 fn test_fix_no_promote_if_no_blank_after() {
2097 use crate::rule::Rule;
2098 let rule = MD041FirstLineHeading {
2099 level: 1,
2100 front_matter_title: false,
2101 front_matter_title_pattern: None,
2102 fix_enabled: true,
2103 };
2104
2105 let content = "My Project\nImmediately continues.\n\nContent.\n";
2107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2108 let fixed = rule.fix(&ctx).unwrap();
2109 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2110 }
2111
2112 #[test]
2113 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2114 use crate::rule::Rule;
2115 let rule = MD041FirstLineHeading {
2116 level: 1,
2117 front_matter_title: false,
2118 front_matter_title_pattern: None,
2119 fix_enabled: true,
2120 };
2121
2122 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2126 let fixed = rule.fix(&ctx).unwrap();
2127 assert_eq!(
2128 fixed, content,
2129 "Should not fix when title candidate exists before a heading"
2130 );
2131
2132 let warnings = rule.check(&ctx).unwrap();
2133 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2134 }
2135
2136 #[test]
2137 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2138 use crate::rule::Rule;
2139 let rule = MD041FirstLineHeading {
2140 level: 1,
2141 front_matter_title: false,
2142 front_matter_title_pattern: None,
2143 fix_enabled: true,
2144 };
2145
2146 let content = "My Project";
2148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2149 let fixed = rule.fix(&ctx).unwrap();
2150 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2151 }
2152
2153 #[test]
2156 fn test_fix_insert_derived_directive_only_document() {
2157 use crate::rule::Rule;
2158 use std::path::PathBuf;
2159 let rule = MD041FirstLineHeading {
2160 level: 1,
2161 front_matter_title: false,
2162 front_matter_title_pattern: None,
2163 fix_enabled: true,
2164 };
2165
2166 let content = "!!! note\n This is a note.\n";
2169 let ctx = LintContext::new(
2170 content,
2171 crate::config::MarkdownFlavor::MkDocs,
2172 Some(PathBuf::from("setup-guide.md")),
2173 );
2174
2175 let can_fix = rule.can_fix(&ctx);
2176 assert!(can_fix, "Directive-only document with source file should be fixable");
2177
2178 let fixed = rule.fix(&ctx).unwrap();
2179 assert!(
2180 fixed.starts_with("# Setup Guide\n"),
2181 "Should insert derived heading, got: {fixed}"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_fix_no_insert_derived_without_source_file() {
2187 use crate::rule::Rule;
2188 let rule = MD041FirstLineHeading {
2189 level: 1,
2190 front_matter_title: false,
2191 front_matter_title_pattern: None,
2192 fix_enabled: true,
2193 };
2194
2195 let content = "!!! note\n This is a note.\n";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2198 let fixed = rule.fix(&ctx).unwrap();
2199 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2200 }
2201
2202 #[test]
2203 fn test_fix_no_insert_derived_when_has_real_content() {
2204 use crate::rule::Rule;
2205 use std::path::PathBuf;
2206 let rule = MD041FirstLineHeading {
2207 level: 1,
2208 front_matter_title: false,
2209 front_matter_title_pattern: None,
2210 fix_enabled: true,
2211 };
2212
2213 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2215 let ctx = LintContext::new(
2216 content,
2217 crate::config::MarkdownFlavor::MkDocs,
2218 Some(PathBuf::from("guide.md")),
2219 );
2220 let fixed = rule.fix(&ctx).unwrap();
2221 assert_eq!(
2222 fixed, content,
2223 "Should not insert derived heading when real content is present"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_derive_title_converts_kebab_case() {
2229 use std::path::PathBuf;
2230 let ctx = LintContext::new(
2231 "",
2232 crate::config::MarkdownFlavor::Standard,
2233 Some(PathBuf::from("my-setup-guide.md")),
2234 );
2235 let title = MD041FirstLineHeading::derive_title(&ctx);
2236 assert_eq!(title, Some("My Setup Guide".to_string()));
2237 }
2238
2239 #[test]
2240 fn test_derive_title_converts_underscores() {
2241 use std::path::PathBuf;
2242 let ctx = LintContext::new(
2243 "",
2244 crate::config::MarkdownFlavor::Standard,
2245 Some(PathBuf::from("api_reference.md")),
2246 );
2247 let title = MD041FirstLineHeading::derive_title(&ctx);
2248 assert_eq!(title, Some("Api Reference".to_string()));
2249 }
2250
2251 #[test]
2252 fn test_derive_title_none_without_source_file() {
2253 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2254 let title = MD041FirstLineHeading::derive_title(&ctx);
2255 assert_eq!(title, None);
2256 }
2257
2258 #[test]
2259 fn test_derive_title_index_file_uses_parent_dir() {
2260 use std::path::PathBuf;
2261 let ctx = LintContext::new(
2262 "",
2263 crate::config::MarkdownFlavor::Standard,
2264 Some(PathBuf::from("docs/getting-started/index.md")),
2265 );
2266 let title = MD041FirstLineHeading::derive_title(&ctx);
2267 assert_eq!(title, Some("Getting Started".to_string()));
2268 }
2269
2270 #[test]
2271 fn test_derive_title_readme_file_uses_parent_dir() {
2272 use std::path::PathBuf;
2273 let ctx = LintContext::new(
2274 "",
2275 crate::config::MarkdownFlavor::Standard,
2276 Some(PathBuf::from("my-project/README.md")),
2277 );
2278 let title = MD041FirstLineHeading::derive_title(&ctx);
2279 assert_eq!(title, Some("My Project".to_string()));
2280 }
2281
2282 #[test]
2283 fn test_derive_title_index_without_parent_returns_none() {
2284 use std::path::PathBuf;
2285 let ctx = LintContext::new(
2287 "",
2288 crate::config::MarkdownFlavor::Standard,
2289 Some(PathBuf::from("index.md")),
2290 );
2291 let title = MD041FirstLineHeading::derive_title(&ctx);
2292 assert_eq!(title, None);
2293 }
2294
2295 #[test]
2296 fn test_derive_title_readme_without_parent_returns_none() {
2297 use std::path::PathBuf;
2298 let ctx = LintContext::new(
2299 "",
2300 crate::config::MarkdownFlavor::Standard,
2301 Some(PathBuf::from("README.md")),
2302 );
2303 let title = MD041FirstLineHeading::derive_title(&ctx);
2304 assert_eq!(title, None);
2305 }
2306
2307 #[test]
2308 fn test_derive_title_readme_case_insensitive() {
2309 use std::path::PathBuf;
2310 let ctx = LintContext::new(
2312 "",
2313 crate::config::MarkdownFlavor::Standard,
2314 Some(PathBuf::from("docs/api/readme.md")),
2315 );
2316 let title = MD041FirstLineHeading::derive_title(&ctx);
2317 assert_eq!(title, Some("Api".to_string()));
2318 }
2319
2320 #[test]
2321 fn test_is_title_candidate_basic() {
2322 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2323 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2324 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2325 }
2326
2327 #[test]
2328 fn test_is_title_candidate_rejects_sentence_punctuation() {
2329 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2330 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2331 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2332 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2333 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2334 }
2335
2336 #[test]
2337 fn test_is_title_candidate_rejects_when_no_blank_after() {
2338 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2339 }
2340
2341 #[test]
2342 fn test_is_title_candidate_rejects_long_lines() {
2343 let long = "A".repeat(81);
2344 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2345 let ok = "A".repeat(80);
2347 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2348 }
2349
2350 #[test]
2351 fn test_is_title_candidate_rejects_structural_markdown() {
2352 assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2353 assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2354 assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2355 assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2356 }
2357
2358 #[test]
2359 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2360 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2363 let content = "My Document Title\n\nMore content follows.";
2365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366 let warnings = rule.check(&ctx).unwrap();
2367 assert_eq!(warnings.len(), 1);
2368 let fix = warnings[0]
2369 .fix
2370 .as_ref()
2371 .expect("Fix should be present for promotable text");
2372 assert!(
2373 !fix.replacement.is_empty(),
2374 "Fix replacement must not be empty — applying it directly must produce valid output"
2375 );
2376 assert!(
2377 fix.replacement.starts_with("# "),
2378 "Fix replacement should be a level-1 heading, got: {:?}",
2379 fix.replacement
2380 );
2381 assert_eq!(fix.replacement, "# My Document Title");
2382 }
2383
2384 #[test]
2385 fn test_fix_replacement_not_empty_for_releveling() {
2386 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2389 let content = "## Wrong Level\n\nContent.";
2390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2391 let warnings = rule.check(&ctx).unwrap();
2392 assert_eq!(warnings.len(), 1);
2393 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2394 assert!(
2395 !fix.replacement.is_empty(),
2396 "Fix replacement must not be empty for releveling"
2397 );
2398 assert_eq!(fix.replacement, "# Wrong Level");
2399 }
2400
2401 #[test]
2402 fn test_fix_replacement_applied_produces_valid_output() {
2403 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2405 let content = "My Document\n\nMore content.";
2407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2408
2409 let warnings = rule.check(&ctx).unwrap();
2410 assert_eq!(warnings.len(), 1);
2411 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2412
2413 let mut patched = content.to_string();
2415 patched.replace_range(fix.range.clone(), &fix.replacement);
2416
2417 let fixed = rule.fix(&ctx).unwrap();
2419
2420 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2421 }
2422
2423 #[test]
2424 fn test_mdx_disable_on_line_1_no_heading() {
2425 let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2430
2431 let rule = MD041FirstLineHeading::default();
2433 let warnings = rule.check(&ctx).unwrap();
2434 if !warnings.is_empty() {
2439 assert_eq!(
2440 warnings[0].line, 2,
2441 "Warning must be on line 2 (first content line after MDX comment), not line 1"
2442 );
2443 }
2444 }
2445
2446 #[test]
2447 fn test_mdx_disable_fix_returns_unchanged() {
2448 let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2451 let rule = MD041FirstLineHeading {
2452 fix_enabled: true,
2453 ..MD041FirstLineHeading::default()
2454 };
2455 let result = rule.fix(&ctx).unwrap();
2456 assert_eq!(
2457 result, content,
2458 "fix() should not modify content when MD041 is disabled via MDX comment"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_mdx_comment_without_disable_heading_on_next_line() {
2464 let rule = MD041FirstLineHeading::default();
2465
2466 let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2469 let result = rule.check(&ctx).unwrap();
2470 assert!(
2471 result.is_empty(),
2472 "MDX comment is preamble; heading on next line should satisfy MD041"
2473 );
2474 }
2475
2476 #[test]
2477 fn test_mdx_comment_without_heading_triggers_warning() {
2478 let rule = MD041FirstLineHeading::default();
2479
2480 let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2483 let result = rule.check(&ctx).unwrap();
2484 assert_eq!(
2485 result.len(),
2486 1,
2487 "MDX comment followed by non-heading should trigger MD041"
2488 );
2489 assert_eq!(
2490 result[0].line, 2,
2491 "Warning should be on line 2 (the first content line after MDX comment)"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_multiline_mdx_comment_followed_by_heading() {
2497 let rule = MD041FirstLineHeading::default();
2498
2499 let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2502 let result = rule.check(&ctx).unwrap();
2503 assert!(
2504 result.is_empty(),
2505 "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_html_comment_still_works_as_preamble_regression() {
2511 let rule = MD041FirstLineHeading::default();
2512
2513 let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2516 let result = rule.check(&ctx).unwrap();
2517 assert!(
2518 result.is_empty(),
2519 "HTML comment should still be treated as preamble (regression test)"
2520 );
2521 }
2522}