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