1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::lint_context::HeadingStyle;
6use crate::rule::{Fix, FixCapability, 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 fix_capability(&self) -> FixCapability {
493 FixCapability::Unfixable
494 }
495
496 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
497 let mut warnings = Vec::new();
498
499 if self.should_skip(ctx) {
501 return Ok(warnings);
502 }
503
504 let mut first_content_line_num = None;
506 let mut skip_lines = 0;
507
508 for line_info in &ctx.lines {
510 if line_info.in_front_matter {
511 skip_lines += 1;
512 } else {
513 break;
514 }
515 }
516
517 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
519
520 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
521 let line_content = line_info.content(ctx.content);
522 let trimmed = line_content.trim();
523 if line_info.in_esm_block {
525 continue;
526 }
527 if line_info.in_html_comment {
529 continue;
530 }
531 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
533 continue;
534 }
535 if line_info.in_kramdown_extension_block || line_info.is_kramdown_block_ial {
537 continue;
538 }
539 if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
540 first_content_line_num = Some(line_num);
541 break;
542 }
543 }
544
545 if first_content_line_num.is_none() {
546 return Ok(warnings);
548 }
549
550 let first_line_idx = first_content_line_num.unwrap();
551
552 let first_line_info = &ctx.lines[first_line_idx];
554 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
555 heading.level as usize == self.level
556 } else {
557 Self::is_html_heading(ctx, first_line_idx, self.level)
559 };
560
561 if !is_correct_heading {
562 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
565 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
566
567 let fix = if self.can_fix(ctx) {
573 self.analyze_for_fix(ctx).and_then(|plan| {
574 let range_start = first_line_info.byte_offset;
575 let range_end = range_start + first_line_info.byte_len;
576 match &plan {
577 FixPlan::MoveOrRelevel {
578 heading_idx,
579 current_level,
580 needs_level_fix,
581 is_setext,
582 ..
583 } if *heading_idx == first_line_idx => {
584 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
586 let replacement = if *needs_level_fix || *is_setext {
587 self.fix_heading_level(heading_line, *current_level, self.level)
588 } else {
589 heading_line.to_string()
590 };
591 Some(Fix {
592 range: range_start..range_end,
593 replacement,
594 })
595 }
596 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
597 let replacement = format!(
598 "{} {}",
599 "#".repeat(self.level),
600 ctx.lines[*title_line_idx].content(ctx.content).trim()
601 );
602 Some(Fix {
603 range: range_start..range_end,
604 replacement,
605 })
606 }
607 _ => {
608 self.fix(ctx).ok().map(|fixed_content| Fix {
612 range: 0..ctx.content.len(),
613 replacement: fixed_content,
614 })
615 }
616 }
617 })
618 } else {
619 None
620 };
621
622 warnings.push(LintWarning {
623 rule_name: Some(self.name().to_string()),
624 line: start_line,
625 column: start_col,
626 end_line,
627 end_column: end_col,
628 message: format!("First line in file should be a level {} heading", self.level),
629 severity: Severity::Warning,
630 fix,
631 });
632 }
633 Ok(warnings)
634 }
635
636 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
637 if !self.fix_enabled {
638 return Ok(ctx.content.to_string());
639 }
640
641 if self.should_skip(ctx) {
642 return Ok(ctx.content.to_string());
643 }
644
645 let first_content_line = ctx
647 .lines
648 .iter()
649 .enumerate()
650 .find(|(_, li)| !li.in_front_matter && !li.is_blank)
651 .map(|(i, _)| i + 1) .unwrap_or(1);
653 if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) {
654 return Ok(ctx.content.to_string());
655 }
656
657 let Some(plan) = self.analyze_for_fix(ctx) else {
658 return Ok(ctx.content.to_string());
659 };
660
661 let lines = ctx.raw_lines();
662
663 let mut result = String::new();
664 let preserve_trailing_newline = ctx.content.ends_with('\n');
665
666 match plan {
667 FixPlan::MoveOrRelevel {
668 front_matter_end_idx,
669 heading_idx,
670 is_setext,
671 current_level,
672 needs_level_fix,
673 } => {
674 let heading_line = ctx.lines[heading_idx].content(ctx.content);
675 let fixed_heading = if needs_level_fix || is_setext {
676 self.fix_heading_level(heading_line, current_level, self.level)
677 } else {
678 heading_line.to_string()
679 };
680
681 for line in lines.iter().take(front_matter_end_idx) {
682 result.push_str(line);
683 result.push('\n');
684 }
685 result.push_str(&fixed_heading);
686 result.push('\n');
687 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
688 if idx == heading_idx {
689 continue;
690 }
691 if is_setext && idx == heading_idx + 1 {
692 continue;
693 }
694 result.push_str(line);
695 result.push('\n');
696 }
697 }
698
699 FixPlan::PromotePlainText {
700 front_matter_end_idx,
701 title_line_idx,
702 title_text,
703 } => {
704 let hashes = "#".repeat(self.level);
705 let new_heading = format!("{hashes} {title_text}");
706
707 for line in lines.iter().take(front_matter_end_idx) {
708 result.push_str(line);
709 result.push('\n');
710 }
711 result.push_str(&new_heading);
712 result.push('\n');
713 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
714 if idx == title_line_idx {
715 continue;
716 }
717 result.push_str(line);
718 result.push('\n');
719 }
720 }
721
722 FixPlan::InsertDerived {
723 front_matter_end_idx,
724 derived_title,
725 } => {
726 let hashes = "#".repeat(self.level);
727 let new_heading = format!("{hashes} {derived_title}");
728
729 for line in lines.iter().take(front_matter_end_idx) {
730 result.push_str(line);
731 result.push('\n');
732 }
733 result.push_str(&new_heading);
734 result.push('\n');
735 result.push('\n');
736 for line in lines.iter().skip(front_matter_end_idx) {
737 result.push_str(line);
738 result.push('\n');
739 }
740 }
741 }
742
743 if !preserve_trailing_newline && result.ends_with('\n') {
744 result.pop();
745 }
746
747 Ok(result)
748 }
749
750 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
752 let only_directives = !ctx.content.is_empty()
757 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
758 let t = l.trim();
759 (t.starts_with("{{#") && t.ends_with("}}"))
761 || (t.starts_with("<!--") && t.ends_with("-->"))
763 });
764
765 ctx.content.is_empty()
766 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
767 || only_directives
768 }
769
770 fn as_any(&self) -> &dyn std::any::Any {
771 self
772 }
773
774 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
775 where
776 Self: Sized,
777 {
778 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
780
781 let use_front_matter = !md041_config.front_matter_title.is_empty();
782
783 Box::new(MD041FirstLineHeading::with_pattern(
784 md041_config.level.as_usize(),
785 use_front_matter,
786 md041_config.front_matter_title_pattern,
787 md041_config.fix,
788 ))
789 }
790
791 fn default_config_section(&self) -> Option<(String, toml::Value)> {
792 Some((
793 "MD041".to_string(),
794 toml::toml! {
795 level = 1
796 front-matter-title = "title"
797 front-matter-title-pattern = ""
798 fix = false
799 }
800 .into(),
801 ))
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::lint_context::LintContext;
809
810 #[test]
811 fn test_first_line_is_heading_correct_level() {
812 let rule = MD041FirstLineHeading::default();
813
814 let content = "# My Document\n\nSome content here.";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert!(
819 result.is_empty(),
820 "Expected no warnings when first line is a level 1 heading"
821 );
822 }
823
824 #[test]
825 fn test_first_line_is_heading_wrong_level() {
826 let rule = MD041FirstLineHeading::default();
827
828 let content = "## My Document\n\nSome content here.";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 1);
833 assert_eq!(result[0].line, 1);
834 assert!(result[0].message.contains("level 1 heading"));
835 }
836
837 #[test]
838 fn test_first_line_not_heading() {
839 let rule = MD041FirstLineHeading::default();
840
841 let content = "This is not a heading\n\n# This is a heading";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert_eq!(result.len(), 1);
846 assert_eq!(result[0].line, 1);
847 assert!(result[0].message.contains("level 1 heading"));
848 }
849
850 #[test]
851 fn test_empty_lines_before_heading() {
852 let rule = MD041FirstLineHeading::default();
853
854 let content = "\n\n# My Document\n\nSome content.";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(
859 result.is_empty(),
860 "Expected no warnings when empty lines precede a valid heading"
861 );
862
863 let content = "\n\nNot a heading\n\nSome content.";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert_eq!(result.len(), 1);
868 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
870 }
871
872 #[test]
873 fn test_front_matter_with_title() {
874 let rule = MD041FirstLineHeading::new(1, true);
875
876 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert!(
881 result.is_empty(),
882 "Expected no warnings when front matter has title field"
883 );
884 }
885
886 #[test]
887 fn test_front_matter_without_title() {
888 let rule = MD041FirstLineHeading::new(1, true);
889
890 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert_eq!(result.len(), 1);
895 assert_eq!(result[0].line, 6); }
897
898 #[test]
899 fn test_front_matter_disabled() {
900 let rule = MD041FirstLineHeading::new(1, false);
901
902 let content = "---\ntitle: My Document\n---\n\nSome content here.";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert_eq!(result.len(), 1);
907 assert_eq!(result[0].line, 5); }
909
910 #[test]
911 fn test_html_comments_before_heading() {
912 let rule = MD041FirstLineHeading::default();
913
914 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "HTML comments should be skipped when checking for first heading"
921 );
922 }
923
924 #[test]
925 fn test_multiline_html_comment_before_heading() {
926 let rule = MD041FirstLineHeading::default();
927
928 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let result = rule.check(&ctx).unwrap();
932 assert!(
933 result.is_empty(),
934 "Multi-line HTML comments should be skipped when checking for first heading"
935 );
936 }
937
938 #[test]
939 fn test_html_comment_with_blank_lines_before_heading() {
940 let rule = MD041FirstLineHeading::default();
941
942 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946 assert!(
947 result.is_empty(),
948 "HTML comments with blank lines should be skipped when checking for first heading"
949 );
950 }
951
952 #[test]
953 fn test_html_comment_before_html_heading() {
954 let rule = MD041FirstLineHeading::default();
955
956 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960 assert!(
961 result.is_empty(),
962 "HTML comments should be skipped before HTML headings"
963 );
964 }
965
966 #[test]
967 fn test_document_with_only_html_comments() {
968 let rule = MD041FirstLineHeading::default();
969
970 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974 assert!(
975 result.is_empty(),
976 "Documents with only HTML comments should not trigger MD041"
977 );
978 }
979
980 #[test]
981 fn test_html_comment_followed_by_non_heading() {
982 let rule = MD041FirstLineHeading::default();
983
984 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert_eq!(
989 result.len(),
990 1,
991 "HTML comment followed by non-heading should still trigger MD041"
992 );
993 assert_eq!(
994 result[0].line, 2,
995 "Warning should be on the first non-comment, non-heading line"
996 );
997 }
998
999 #[test]
1000 fn test_multiple_html_comments_before_heading() {
1001 let rule = MD041FirstLineHeading::default();
1002
1003 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 let result = rule.check(&ctx).unwrap();
1007 assert!(
1008 result.is_empty(),
1009 "Multiple HTML comments should all be skipped before heading"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_html_comment_with_wrong_level_heading() {
1015 let rule = MD041FirstLineHeading::default();
1016
1017 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert_eq!(
1022 result.len(),
1023 1,
1024 "HTML comment followed by wrong-level heading should still trigger MD041"
1025 );
1026 assert!(
1027 result[0].message.contains("level 1 heading"),
1028 "Should require level 1 heading"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_html_comment_mixed_with_reference_definitions() {
1034 let rule = MD041FirstLineHeading::default();
1035
1036 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040 assert!(
1041 result.is_empty(),
1042 "HTML comments and reference definitions should both be skipped before heading"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_html_comment_after_front_matter() {
1048 let rule = MD041FirstLineHeading::default();
1049
1050 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 result.is_empty(),
1056 "HTML comments after front matter should be skipped before heading"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_html_comment_not_at_start_should_not_affect_rule() {
1062 let rule = MD041FirstLineHeading::default();
1063
1064 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068 assert!(
1069 result.is_empty(),
1070 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_multiline_html_comment_followed_by_non_heading() {
1076 let rule = MD041FirstLineHeading::default();
1077
1078 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert_eq!(
1083 result.len(),
1084 1,
1085 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1086 );
1087 assert_eq!(
1088 result[0].line, 5,
1089 "Warning should be on the first non-comment, non-heading line"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_different_heading_levels() {
1095 let rule = MD041FirstLineHeading::new(2, false);
1097
1098 let content = "## Second Level Heading\n\nContent.";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1102
1103 let content = "# First Level Heading\n\nContent.";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107 assert_eq!(result.len(), 1);
1108 assert!(result[0].message.contains("level 2 heading"));
1109 }
1110
1111 #[test]
1112 fn test_setext_headings() {
1113 let rule = MD041FirstLineHeading::default();
1114
1115 let content = "My Document\n===========\n\nContent.";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1120
1121 let content = "My Document\n-----------\n\nContent.";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let result = rule.check(&ctx).unwrap();
1125 assert_eq!(result.len(), 1);
1126 assert!(result[0].message.contains("level 1 heading"));
1127 }
1128
1129 #[test]
1130 fn test_empty_document() {
1131 let rule = MD041FirstLineHeading::default();
1132
1133 let content = "";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert!(result.is_empty(), "Expected no warnings for empty document");
1138 }
1139
1140 #[test]
1141 fn test_whitespace_only_document() {
1142 let rule = MD041FirstLineHeading::default();
1143
1144 let content = " \n\n \t\n";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1149 }
1150
1151 #[test]
1152 fn test_front_matter_then_whitespace() {
1153 let rule = MD041FirstLineHeading::default();
1154
1155 let content = "---\ntitle: Test\n---\n\n \n\n";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 result.is_empty(),
1161 "Expected no warnings when no content after front matter"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_multiple_front_matter_types() {
1167 let rule = MD041FirstLineHeading::new(1, true);
1168
1169 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert!(
1174 result.is_empty(),
1175 "Expected no warnings for TOML front matter with title"
1176 );
1177
1178 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182 assert!(
1183 result.is_empty(),
1184 "Expected no warnings for JSON front matter with title"
1185 );
1186
1187 let content = "---\ntitle: My Document\n---\n\nContent.";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191 assert!(
1192 result.is_empty(),
1193 "Expected no warnings for YAML front matter with title"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_toml_front_matter_with_heading() {
1199 let rule = MD041FirstLineHeading::default();
1200
1201 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205 assert!(
1206 result.is_empty(),
1207 "Expected no warnings when heading follows TOML front matter"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_toml_front_matter_without_title_no_heading() {
1213 let rule = MD041FirstLineHeading::new(1, true);
1214
1215 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert_eq!(result.len(), 1);
1220 assert_eq!(result[0].line, 6);
1221 }
1222
1223 #[test]
1224 fn test_toml_front_matter_level_2_heading() {
1225 let rule = MD041FirstLineHeading::new(2, true);
1227
1228 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert!(
1232 result.is_empty(),
1233 "Issue #427: TOML front matter with title and correct heading level should not warn"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1239 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1241
1242 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert!(
1246 result.is_empty(),
1247 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_json_front_matter_with_heading() {
1253 let rule = MD041FirstLineHeading::default();
1254
1255 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(
1260 result.is_empty(),
1261 "Expected no warnings when heading follows JSON front matter"
1262 );
1263 }
1264
1265 #[test]
1266 fn test_malformed_front_matter() {
1267 let rule = MD041FirstLineHeading::new(1, true);
1268
1269 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273 assert!(
1274 result.is_empty(),
1275 "Expected no warnings for malformed front matter with title"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_front_matter_with_heading() {
1281 let rule = MD041FirstLineHeading::default();
1282
1283 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(
1288 result.is_empty(),
1289 "Expected no warnings when first line after front matter is correct heading"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_no_fix_suggestion() {
1295 let rule = MD041FirstLineHeading::default();
1296
1297 let content = "Not a heading\n\nContent.";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert_eq!(result.len(), 1);
1302 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1303 }
1304
1305 #[test]
1306 fn test_complex_document_structure() {
1307 let rule = MD041FirstLineHeading::default();
1308
1309 let content =
1311 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314 assert!(
1315 result.is_empty(),
1316 "HTML comments should be skipped, so first heading after comment should be valid"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_heading_with_special_characters() {
1322 let rule = MD041FirstLineHeading::default();
1323
1324 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
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 heading with inline formatting"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_level_configuration() {
1336 for level in 1..=6 {
1338 let rule = MD041FirstLineHeading::new(level, false);
1339
1340 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1342 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1343 let result = rule.check(&ctx).unwrap();
1344 assert!(
1345 result.is_empty(),
1346 "Expected no warnings for correct level {level} heading"
1347 );
1348
1349 let wrong_level = if level == 1 { 2 } else { 1 };
1351 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1352 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1353 let result = rule.check(&ctx).unwrap();
1354 assert_eq!(result.len(), 1);
1355 assert!(result[0].message.contains(&format!("level {level} heading")));
1356 }
1357 }
1358
1359 #[test]
1360 fn test_issue_152_multiline_html_heading() {
1361 let rule = MD041FirstLineHeading::default();
1362
1363 let content = "<h1>\nSome text\n</h1>";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1368 result.is_empty(),
1369 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_multiline_html_heading_with_attributes() {
1375 let rule = MD041FirstLineHeading::default();
1376
1377 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let result = rule.check(&ctx).unwrap();
1381 assert!(
1382 result.is_empty(),
1383 "Multi-line HTML heading with attributes should be recognized"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_multiline_html_heading_wrong_level() {
1389 let rule = MD041FirstLineHeading::default();
1390
1391 let content = "<h2>\nSome text\n</h2>";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert_eq!(result.len(), 1);
1396 assert!(result[0].message.contains("level 1 heading"));
1397 }
1398
1399 #[test]
1400 fn test_multiline_html_heading_with_content_after() {
1401 let rule = MD041FirstLineHeading::default();
1402
1403 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let result = rule.check(&ctx).unwrap();
1407 assert!(
1408 result.is_empty(),
1409 "Multi-line HTML heading followed by content should be valid"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_multiline_html_heading_incomplete() {
1415 let rule = MD041FirstLineHeading::default();
1416
1417 let content = "<h1>\nSome text\n\nMore content without closing tag";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421 assert_eq!(result.len(), 1);
1422 assert!(result[0].message.contains("level 1 heading"));
1423 }
1424
1425 #[test]
1426 fn test_singleline_html_heading_still_works() {
1427 let rule = MD041FirstLineHeading::default();
1428
1429 let content = "<h1>My Document</h1>\n\nContent.";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let result = rule.check(&ctx).unwrap();
1433 assert!(
1434 result.is_empty(),
1435 "Single-line HTML headings should still be recognized"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_multiline_html_heading_with_nested_tags() {
1441 let rule = MD041FirstLineHeading::default();
1442
1443 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
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 with nested tags should be recognized"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_multiline_html_heading_various_levels() {
1455 for level in 1..=6 {
1457 let rule = MD041FirstLineHeading::new(level, false);
1458
1459 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1461 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert!(
1464 result.is_empty(),
1465 "Multi-line HTML heading at level {level} should be recognized"
1466 );
1467
1468 let wrong_level = if level == 1 { 2 } else { 1 };
1470 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1471 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1472 let result = rule.check(&ctx).unwrap();
1473 assert_eq!(result.len(), 1);
1474 assert!(result[0].message.contains(&format!("level {level} heading")));
1475 }
1476 }
1477
1478 #[test]
1479 fn test_issue_152_nested_heading_spans_many_lines() {
1480 let rule = MD041FirstLineHeading::default();
1481
1482 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>";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1486 }
1487
1488 #[test]
1489 fn test_issue_152_picture_tag_heading() {
1490 let rule = MD041FirstLineHeading::default();
1491
1492 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>";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let result = rule.check(&ctx).unwrap();
1495 assert!(
1496 result.is_empty(),
1497 "Picture tag inside multi-line HTML heading should be recognized"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_badge_images_before_heading() {
1503 let rule = MD041FirstLineHeading::default();
1504
1505 let content = "\n\n# My Project";
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 let result = rule.check(&ctx).unwrap();
1509 assert!(result.is_empty(), "Badge image should be skipped");
1510
1511 let content = " \n\n# My Project";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let result = rule.check(&ctx).unwrap();
1515 assert!(result.is_empty(), "Multiple badges should be skipped");
1516
1517 let content = "[](https://example.com)\n\n# My Project";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521 assert!(result.is_empty(), "Linked badge should be skipped");
1522 }
1523
1524 #[test]
1525 fn test_multiple_badge_lines_before_heading() {
1526 let rule = MD041FirstLineHeading::default();
1527
1528 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let result = rule.check(&ctx).unwrap();
1532 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1533 }
1534
1535 #[test]
1536 fn test_badges_without_heading_still_warns() {
1537 let rule = MD041FirstLineHeading::default();
1538
1539 let content = "\n\nThis is not a heading.";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let result = rule.check(&ctx).unwrap();
1543 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1544 }
1545
1546 #[test]
1547 fn test_mixed_content_not_badge_line() {
1548 let rule = MD041FirstLineHeading::default();
1549
1550 let content = " Some text here\n\n# Heading";
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553 let result = rule.check(&ctx).unwrap();
1554 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1555 }
1556
1557 #[test]
1558 fn test_is_badge_image_line_unit() {
1559 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1561 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1562 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1563 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1564
1565 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1567 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1568 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1569 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1570 }
1571
1572 #[test]
1576 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1577 let rule = MD041FirstLineHeading::default();
1578
1579 let content = "[](){ #example }\n# Title";
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1582 let result = rule.check(&ctx).unwrap();
1583 assert!(
1584 result.is_empty(),
1585 "MkDocs anchor line should be skipped in MkDocs flavor"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1591 let rule = MD041FirstLineHeading::default();
1592
1593 let content = "[](){ #example }\n# Title";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597 assert_eq!(
1598 result.len(),
1599 1,
1600 "MkDocs anchor line should NOT be skipped in Standard flavor"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_multiple_mkdocs_anchors_before_heading() {
1606 let rule = MD041FirstLineHeading::default();
1607
1608 let content = "[](){ #first }\n[](){ #second }\n# Title";
1610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1611 let result = rule.check(&ctx).unwrap();
1612 assert!(
1613 result.is_empty(),
1614 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1615 );
1616 }
1617
1618 #[test]
1619 fn test_mkdocs_anchor_with_front_matter() {
1620 let rule = MD041FirstLineHeading::default();
1621
1622 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1625 let result = rule.check(&ctx).unwrap();
1626 assert!(
1627 result.is_empty(),
1628 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_mkdocs_anchor_kramdown_style() {
1634 let rule = MD041FirstLineHeading::default();
1635
1636 let content = "[](){: #anchor }\n# Title";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert!(
1641 result.is_empty(),
1642 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1643 );
1644 }
1645
1646 #[test]
1647 fn test_mkdocs_anchor_without_heading_still_warns() {
1648 let rule = MD041FirstLineHeading::default();
1649
1650 let content = "[](){ #anchor }\nThis is not a heading.";
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1653 let result = rule.check(&ctx).unwrap();
1654 assert_eq!(
1655 result.len(),
1656 1,
1657 "MkDocs anchor followed by non-heading should still trigger MD041"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_mkdocs_anchor_with_html_comment() {
1663 let rule = MD041FirstLineHeading::default();
1664
1665 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1668 let result = rule.check(&ctx).unwrap();
1669 assert!(
1670 result.is_empty(),
1671 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1672 );
1673 }
1674
1675 #[test]
1678 fn test_fix_disabled_by_default() {
1679 use crate::rule::Rule;
1680 let rule = MD041FirstLineHeading::default();
1681
1682 let content = "## Wrong Level\n\nContent.";
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let fixed = rule.fix(&ctx).unwrap();
1686 assert_eq!(fixed, content, "Fix should not change content when disabled");
1687 }
1688
1689 #[test]
1690 fn test_fix_wrong_heading_level() {
1691 use crate::rule::Rule;
1692 let rule = MD041FirstLineHeading {
1693 level: 1,
1694 front_matter_title: false,
1695 front_matter_title_pattern: None,
1696 fix_enabled: true,
1697 };
1698
1699 let content = "## Wrong Level\n\nContent.\n";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let fixed = rule.fix(&ctx).unwrap();
1703 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1704 }
1705
1706 #[test]
1707 fn test_fix_heading_after_preamble() {
1708 use crate::rule::Rule;
1709 let rule = MD041FirstLineHeading {
1710 level: 1,
1711 front_matter_title: false,
1712 front_matter_title_pattern: None,
1713 fix_enabled: true,
1714 };
1715
1716 let content = "\n\n# Title\n\nContent.\n";
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 let fixed = rule.fix(&ctx).unwrap();
1720 assert!(
1721 fixed.starts_with("# Title\n"),
1722 "Heading should be moved to first line, got: {fixed}"
1723 );
1724 }
1725
1726 #[test]
1727 fn test_fix_heading_after_html_comment() {
1728 use crate::rule::Rule;
1729 let rule = MD041FirstLineHeading {
1730 level: 1,
1731 front_matter_title: false,
1732 front_matter_title_pattern: None,
1733 fix_enabled: true,
1734 };
1735
1736 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let fixed = rule.fix(&ctx).unwrap();
1740 assert!(
1741 fixed.starts_with("# Title\n"),
1742 "Heading should be moved above comment, got: {fixed}"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_fix_heading_level_and_move() {
1748 use crate::rule::Rule;
1749 let rule = MD041FirstLineHeading {
1750 level: 1,
1751 front_matter_title: false,
1752 front_matter_title_pattern: None,
1753 fix_enabled: true,
1754 };
1755
1756 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759 let fixed = rule.fix(&ctx).unwrap();
1760 assert!(
1761 fixed.starts_with("# Wrong Level\n"),
1762 "Heading should be fixed and moved, got: {fixed}"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_fix_with_front_matter() {
1768 use crate::rule::Rule;
1769 let rule = MD041FirstLineHeading {
1770 level: 1,
1771 front_matter_title: false,
1772 front_matter_title_pattern: None,
1773 fix_enabled: true,
1774 };
1775
1776 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779 let fixed = rule.fix(&ctx).unwrap();
1780 assert!(
1781 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1782 "Heading should be right after front matter, got: {fixed}"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_fix_with_toml_front_matter() {
1788 use crate::rule::Rule;
1789 let rule = MD041FirstLineHeading {
1790 level: 1,
1791 front_matter_title: false,
1792 front_matter_title_pattern: None,
1793 fix_enabled: true,
1794 };
1795
1796 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799 let fixed = rule.fix(&ctx).unwrap();
1800 assert!(
1801 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1802 "Heading should be right after TOML front matter, got: {fixed}"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_fix_cannot_fix_no_heading() {
1808 use crate::rule::Rule;
1809 let rule = MD041FirstLineHeading {
1810 level: 1,
1811 front_matter_title: false,
1812 front_matter_title_pattern: None,
1813 fix_enabled: true,
1814 };
1815
1816 let content = "Just some text.\n\nMore text.\n";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let fixed = rule.fix(&ctx).unwrap();
1820 assert_eq!(fixed, content, "Should not change content when no heading exists");
1821 }
1822
1823 #[test]
1824 fn test_fix_cannot_fix_content_before_heading() {
1825 use crate::rule::Rule;
1826 let rule = MD041FirstLineHeading {
1827 level: 1,
1828 front_matter_title: false,
1829 front_matter_title_pattern: None,
1830 fix_enabled: true,
1831 };
1832
1833 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1836 let fixed = rule.fix(&ctx).unwrap();
1837 assert_eq!(
1838 fixed, content,
1839 "Should not change content when real content exists before heading"
1840 );
1841 }
1842
1843 #[test]
1844 fn test_fix_already_correct() {
1845 use crate::rule::Rule;
1846 let rule = MD041FirstLineHeading {
1847 level: 1,
1848 front_matter_title: false,
1849 front_matter_title_pattern: None,
1850 fix_enabled: true,
1851 };
1852
1853 let content = "# Title\n\nContent.\n";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 let fixed = rule.fix(&ctx).unwrap();
1857 assert_eq!(fixed, content, "Should not change already correct content");
1858 }
1859
1860 #[test]
1861 fn test_fix_setext_heading_removes_underline() {
1862 use crate::rule::Rule;
1863 let rule = MD041FirstLineHeading {
1864 level: 1,
1865 front_matter_title: false,
1866 front_matter_title_pattern: None,
1867 fix_enabled: true,
1868 };
1869
1870 let content = "Wrong Level\n-----------\n\nContent.\n";
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873 let fixed = rule.fix(&ctx).unwrap();
1874 assert_eq!(
1875 fixed, "# Wrong Level\n\nContent.\n",
1876 "Setext heading should be converted to ATX and underline removed"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_fix_setext_h1_heading() {
1882 use crate::rule::Rule;
1883 let rule = MD041FirstLineHeading {
1884 level: 1,
1885 front_matter_title: false,
1886 front_matter_title_pattern: None,
1887 fix_enabled: true,
1888 };
1889
1890 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let fixed = rule.fix(&ctx).unwrap();
1894 assert_eq!(
1895 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1896 "Setext h1 should be moved and converted to ATX"
1897 );
1898 }
1899
1900 #[test]
1901 fn test_html_heading_not_claimed_fixable() {
1902 use crate::rule::Rule;
1903 let rule = MD041FirstLineHeading {
1904 level: 1,
1905 front_matter_title: false,
1906 front_matter_title_pattern: None,
1907 fix_enabled: true,
1908 };
1909
1910 let content = "<h2>Title</h2>\n\nContent.\n";
1912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913 let warnings = rule.check(&ctx).unwrap();
1914 assert_eq!(warnings.len(), 1);
1915 assert!(
1916 warnings[0].fix.is_none(),
1917 "HTML heading should not be claimed as fixable"
1918 );
1919 }
1920
1921 #[test]
1922 fn test_no_heading_not_claimed_fixable() {
1923 use crate::rule::Rule;
1924 let rule = MD041FirstLineHeading {
1925 level: 1,
1926 front_matter_title: false,
1927 front_matter_title_pattern: None,
1928 fix_enabled: true,
1929 };
1930
1931 let content = "Just some text.\n\nMore text.\n";
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let warnings = rule.check(&ctx).unwrap();
1935 assert_eq!(warnings.len(), 1);
1936 assert!(
1937 warnings[0].fix.is_none(),
1938 "Document without heading should not be claimed as fixable"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_content_before_heading_not_claimed_fixable() {
1944 use crate::rule::Rule;
1945 let rule = MD041FirstLineHeading {
1946 level: 1,
1947 front_matter_title: false,
1948 front_matter_title_pattern: None,
1949 fix_enabled: true,
1950 };
1951
1952 let content = "Intro text.\n\n## Heading\n\nMore.\n";
1954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955 let warnings = rule.check(&ctx).unwrap();
1956 assert_eq!(warnings.len(), 1);
1957 assert!(
1958 warnings[0].fix.is_none(),
1959 "Document with content before heading should not be claimed as fixable"
1960 );
1961 }
1962
1963 #[test]
1966 fn test_fix_html_block_before_heading_is_now_fixable() {
1967 use crate::rule::Rule;
1968 let rule = MD041FirstLineHeading {
1969 level: 1,
1970 front_matter_title: false,
1971 front_matter_title_pattern: None,
1972 fix_enabled: true,
1973 };
1974
1975 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978
1979 let warnings = rule.check(&ctx).unwrap();
1980 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1981 assert!(
1982 warnings[0].fix.is_some(),
1983 "Should be fixable: heading exists after HTML block preamble"
1984 );
1985
1986 let fixed = rule.fix(&ctx).unwrap();
1987 assert!(
1988 fixed.starts_with("# My Document\n"),
1989 "Heading should be moved to the top, got: {fixed}"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_fix_html_block_wrong_level_before_heading() {
1995 use crate::rule::Rule;
1996 let rule = MD041FirstLineHeading {
1997 level: 1,
1998 front_matter_title: false,
1999 front_matter_title_pattern: None,
2000 fix_enabled: true,
2001 };
2002
2003 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let fixed = rule.fix(&ctx).unwrap();
2006 assert!(
2007 fixed.starts_with("# Wrong Level\n"),
2008 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2009 );
2010 }
2011
2012 #[test]
2015 fn test_fix_promote_plain_text_title() {
2016 use crate::rule::Rule;
2017 let rule = MD041FirstLineHeading {
2018 level: 1,
2019 front_matter_title: false,
2020 front_matter_title_pattern: None,
2021 fix_enabled: true,
2022 };
2023
2024 let content = "My Project\n\nSome content.\n";
2025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2026
2027 let warnings = rule.check(&ctx).unwrap();
2028 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2029 assert!(
2030 warnings[0].fix.is_some(),
2031 "Should be fixable: first line is a title candidate"
2032 );
2033
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert_eq!(
2036 fixed, "# My Project\n\nSome content.\n",
2037 "Title line should be promoted to heading"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_fix_promote_plain_text_title_with_front_matter() {
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 = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2053 let fixed = rule.fix(&ctx).unwrap();
2054 assert!(
2055 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2056 "Title should be promoted and placed right after front matter, got: {fixed}"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_fix_no_promote_ends_with_period() {
2062 use crate::rule::Rule;
2063 let rule = MD041FirstLineHeading {
2064 level: 1,
2065 front_matter_title: false,
2066 front_matter_title_pattern: None,
2067 fix_enabled: true,
2068 };
2069
2070 let content = "This is a sentence.\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, "Sentence-ending line should not be promoted");
2075
2076 let warnings = rule.check(&ctx).unwrap();
2077 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2078 }
2079
2080 #[test]
2081 fn test_fix_no_promote_ends_with_colon() {
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 content = "Note:\n\nContent.\n";
2091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2092 let fixed = rule.fix(&ctx).unwrap();
2093 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2094 }
2095
2096 #[test]
2097 fn test_fix_no_promote_if_too_long() {
2098 use crate::rule::Rule;
2099 let rule = MD041FirstLineHeading {
2100 level: 1,
2101 front_matter_title: false,
2102 front_matter_title_pattern: None,
2103 fix_enabled: true,
2104 };
2105
2106 let long_line = "A".repeat(81);
2108 let content = format!("{long_line}\n\nContent.\n");
2109 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2110 let fixed = rule.fix(&ctx).unwrap();
2111 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2112 }
2113
2114 #[test]
2115 fn test_fix_no_promote_if_no_blank_after() {
2116 use crate::rule::Rule;
2117 let rule = MD041FirstLineHeading {
2118 level: 1,
2119 front_matter_title: false,
2120 front_matter_title_pattern: None,
2121 fix_enabled: true,
2122 };
2123
2124 let content = "My Project\nImmediately continues.\n\nContent.\n";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let fixed = rule.fix(&ctx).unwrap();
2128 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2129 }
2130
2131 #[test]
2132 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2133 use crate::rule::Rule;
2134 let rule = MD041FirstLineHeading {
2135 level: 1,
2136 front_matter_title: false,
2137 front_matter_title_pattern: None,
2138 fix_enabled: true,
2139 };
2140
2141 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145 let fixed = rule.fix(&ctx).unwrap();
2146 assert_eq!(
2147 fixed, content,
2148 "Should not fix when title candidate exists before a heading"
2149 );
2150
2151 let warnings = rule.check(&ctx).unwrap();
2152 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2153 }
2154
2155 #[test]
2156 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2157 use crate::rule::Rule;
2158 let rule = MD041FirstLineHeading {
2159 level: 1,
2160 front_matter_title: false,
2161 front_matter_title_pattern: None,
2162 fix_enabled: true,
2163 };
2164
2165 let content = "My Project";
2167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2168 let fixed = rule.fix(&ctx).unwrap();
2169 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2170 }
2171
2172 #[test]
2175 fn test_fix_insert_derived_directive_only_document() {
2176 use crate::rule::Rule;
2177 use std::path::PathBuf;
2178 let rule = MD041FirstLineHeading {
2179 level: 1,
2180 front_matter_title: false,
2181 front_matter_title_pattern: None,
2182 fix_enabled: true,
2183 };
2184
2185 let content = "!!! note\n This is a note.\n";
2188 let ctx = LintContext::new(
2189 content,
2190 crate::config::MarkdownFlavor::MkDocs,
2191 Some(PathBuf::from("setup-guide.md")),
2192 );
2193
2194 let can_fix = rule.can_fix(&ctx);
2195 assert!(can_fix, "Directive-only document with source file should be fixable");
2196
2197 let fixed = rule.fix(&ctx).unwrap();
2198 assert!(
2199 fixed.starts_with("# Setup Guide\n"),
2200 "Should insert derived heading, got: {fixed}"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_fix_no_insert_derived_without_source_file() {
2206 use crate::rule::Rule;
2207 let rule = MD041FirstLineHeading {
2208 level: 1,
2209 front_matter_title: false,
2210 front_matter_title_pattern: None,
2211 fix_enabled: true,
2212 };
2213
2214 let content = "!!! note\n This is a note.\n";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2217 let fixed = rule.fix(&ctx).unwrap();
2218 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2219 }
2220
2221 #[test]
2222 fn test_fix_no_insert_derived_when_has_real_content() {
2223 use crate::rule::Rule;
2224 use std::path::PathBuf;
2225 let rule = MD041FirstLineHeading {
2226 level: 1,
2227 front_matter_title: false,
2228 front_matter_title_pattern: None,
2229 fix_enabled: true,
2230 };
2231
2232 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2234 let ctx = LintContext::new(
2235 content,
2236 crate::config::MarkdownFlavor::MkDocs,
2237 Some(PathBuf::from("guide.md")),
2238 );
2239 let fixed = rule.fix(&ctx).unwrap();
2240 assert_eq!(
2241 fixed, content,
2242 "Should not insert derived heading when real content is present"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_derive_title_converts_kebab_case() {
2248 use std::path::PathBuf;
2249 let ctx = LintContext::new(
2250 "",
2251 crate::config::MarkdownFlavor::Standard,
2252 Some(PathBuf::from("my-setup-guide.md")),
2253 );
2254 let title = MD041FirstLineHeading::derive_title(&ctx);
2255 assert_eq!(title, Some("My Setup Guide".to_string()));
2256 }
2257
2258 #[test]
2259 fn test_derive_title_converts_underscores() {
2260 use std::path::PathBuf;
2261 let ctx = LintContext::new(
2262 "",
2263 crate::config::MarkdownFlavor::Standard,
2264 Some(PathBuf::from("api_reference.md")),
2265 );
2266 let title = MD041FirstLineHeading::derive_title(&ctx);
2267 assert_eq!(title, Some("Api Reference".to_string()));
2268 }
2269
2270 #[test]
2271 fn test_derive_title_none_without_source_file() {
2272 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2273 let title = MD041FirstLineHeading::derive_title(&ctx);
2274 assert_eq!(title, None);
2275 }
2276
2277 #[test]
2278 fn test_derive_title_index_file_uses_parent_dir() {
2279 use std::path::PathBuf;
2280 let ctx = LintContext::new(
2281 "",
2282 crate::config::MarkdownFlavor::Standard,
2283 Some(PathBuf::from("docs/getting-started/index.md")),
2284 );
2285 let title = MD041FirstLineHeading::derive_title(&ctx);
2286 assert_eq!(title, Some("Getting Started".to_string()));
2287 }
2288
2289 #[test]
2290 fn test_derive_title_readme_file_uses_parent_dir() {
2291 use std::path::PathBuf;
2292 let ctx = LintContext::new(
2293 "",
2294 crate::config::MarkdownFlavor::Standard,
2295 Some(PathBuf::from("my-project/README.md")),
2296 );
2297 let title = MD041FirstLineHeading::derive_title(&ctx);
2298 assert_eq!(title, Some("My Project".to_string()));
2299 }
2300
2301 #[test]
2302 fn test_derive_title_index_without_parent_returns_none() {
2303 use std::path::PathBuf;
2304 let ctx = LintContext::new(
2306 "",
2307 crate::config::MarkdownFlavor::Standard,
2308 Some(PathBuf::from("index.md")),
2309 );
2310 let title = MD041FirstLineHeading::derive_title(&ctx);
2311 assert_eq!(title, None);
2312 }
2313
2314 #[test]
2315 fn test_derive_title_readme_without_parent_returns_none() {
2316 use std::path::PathBuf;
2317 let ctx = LintContext::new(
2318 "",
2319 crate::config::MarkdownFlavor::Standard,
2320 Some(PathBuf::from("README.md")),
2321 );
2322 let title = MD041FirstLineHeading::derive_title(&ctx);
2323 assert_eq!(title, None);
2324 }
2325
2326 #[test]
2327 fn test_derive_title_readme_case_insensitive() {
2328 use std::path::PathBuf;
2329 let ctx = LintContext::new(
2331 "",
2332 crate::config::MarkdownFlavor::Standard,
2333 Some(PathBuf::from("docs/api/readme.md")),
2334 );
2335 let title = MD041FirstLineHeading::derive_title(&ctx);
2336 assert_eq!(title, Some("Api".to_string()));
2337 }
2338
2339 #[test]
2340 fn test_is_title_candidate_basic() {
2341 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2342 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2343 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2344 }
2345
2346 #[test]
2347 fn test_is_title_candidate_rejects_sentence_punctuation() {
2348 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2349 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2350 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2351 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2352 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2353 }
2354
2355 #[test]
2356 fn test_is_title_candidate_rejects_when_no_blank_after() {
2357 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2358 }
2359
2360 #[test]
2361 fn test_is_title_candidate_rejects_long_lines() {
2362 let long = "A".repeat(81);
2363 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2364 let ok = "A".repeat(80);
2366 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2367 }
2368
2369 #[test]
2370 fn test_is_title_candidate_rejects_structural_markdown() {
2371 assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2372 assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2373 assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2374 assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2375 }
2376
2377 #[test]
2378 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2379 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2382 let content = "My Document Title\n\nMore content follows.";
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let warnings = rule.check(&ctx).unwrap();
2386 assert_eq!(warnings.len(), 1);
2387 let fix = warnings[0]
2388 .fix
2389 .as_ref()
2390 .expect("Fix should be present for promotable text");
2391 assert!(
2392 !fix.replacement.is_empty(),
2393 "Fix replacement must not be empty — applying it directly must produce valid output"
2394 );
2395 assert!(
2396 fix.replacement.starts_with("# "),
2397 "Fix replacement should be a level-1 heading, got: {:?}",
2398 fix.replacement
2399 );
2400 assert_eq!(fix.replacement, "# My Document Title");
2401 }
2402
2403 #[test]
2404 fn test_fix_replacement_not_empty_for_releveling() {
2405 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2408 let content = "## Wrong Level\n\nContent.";
2409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410 let warnings = rule.check(&ctx).unwrap();
2411 assert_eq!(warnings.len(), 1);
2412 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2413 assert!(
2414 !fix.replacement.is_empty(),
2415 "Fix replacement must not be empty for releveling"
2416 );
2417 assert_eq!(fix.replacement, "# Wrong Level");
2418 }
2419
2420 #[test]
2421 fn test_fix_replacement_applied_produces_valid_output() {
2422 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2424 let content = "My Document\n\nMore content.";
2426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2427
2428 let warnings = rule.check(&ctx).unwrap();
2429 assert_eq!(warnings.len(), 1);
2430 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2431
2432 let mut patched = content.to_string();
2434 patched.replace_range(fix.range.clone(), &fix.replacement);
2435
2436 let fixed = rule.fix(&ctx).unwrap();
2438
2439 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2440 }
2441}